498 lines
16 KiB
TypeScript
498 lines
16 KiB
TypeScript
import { eq } from "drizzle-orm";
|
|
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { db } from "@/lib/db";
|
|
import { externalImageApiRequestLog } from "@/lib/db/schema";
|
|
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
|
import {
|
|
authenticateExternalImageApiRequest,
|
|
buildExternalImageApiModelCandidates,
|
|
buildExternalImageApiKeyFingerprint,
|
|
ExternalImageApiGenerationError,
|
|
ensureHostedReferenceImageUrls,
|
|
generateImagesWithFailover,
|
|
getRequesterIp,
|
|
normalizeExternalImageApiImageSize,
|
|
resolveExternalImageApiOwnerUserId,
|
|
serializeExternalImageApiModel,
|
|
uploadExternalImageApiFile,
|
|
} from "@/lib/external-image-api/service";
|
|
|
|
export const runtime = "nodejs";
|
|
export const maxDuration = 1200;
|
|
|
|
const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__";
|
|
const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2;
|
|
const DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE = 8;
|
|
const DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS = 15_000;
|
|
|
|
class ExternalImageApiBusyError extends Error {
|
|
readonly retryAfterSeconds: number;
|
|
|
|
constructor(message: string, retryAfterSeconds = 15) {
|
|
super(message);
|
|
this.name = "ExternalImageApiBusyError";
|
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
}
|
|
}
|
|
|
|
type ExternalImageApiWaiter = {
|
|
resolve: (release: () => void) => void;
|
|
reject: (error: Error) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
};
|
|
|
|
type ExternalImageApiLimiterState = {
|
|
running: number;
|
|
queue: ExternalImageApiWaiter[];
|
|
};
|
|
|
|
function parsePositiveInteger(value: unknown, fallback: number) {
|
|
const parsed = Number.parseInt(String(value || ""), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function getExternalImageApiLimiterState(): ExternalImageApiLimiterState {
|
|
const globalState = globalThis as typeof globalThis & {
|
|
[EXTERNAL_IMAGE_API_LIMITER_KEY]?: ExternalImageApiLimiterState;
|
|
};
|
|
|
|
if (!globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]) {
|
|
globalState[EXTERNAL_IMAGE_API_LIMITER_KEY] = {
|
|
running: 0,
|
|
queue: [],
|
|
};
|
|
}
|
|
|
|
return globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]!;
|
|
}
|
|
|
|
function acquireExternalImageApiSlot(): Promise<() => void> {
|
|
const state = getExternalImageApiLimiterState();
|
|
const maxConcurrent = parsePositiveInteger(
|
|
process.env.EXTERNAL_IMAGE_API_MAX_CONCURRENT,
|
|
DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT,
|
|
);
|
|
const maxQueue = parsePositiveInteger(
|
|
process.env.EXTERNAL_IMAGE_API_MAX_QUEUE,
|
|
DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE,
|
|
);
|
|
const queueTimeoutMs = parsePositiveInteger(
|
|
process.env.EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
|
|
DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
|
|
);
|
|
|
|
const release = () => {
|
|
state.running = Math.max(0, state.running - 1);
|
|
const next = state.queue.shift();
|
|
if (!next) return;
|
|
clearTimeout(next.timer);
|
|
state.running += 1;
|
|
next.resolve(release);
|
|
};
|
|
|
|
if (state.running < maxConcurrent) {
|
|
state.running += 1;
|
|
return Promise.resolve(release);
|
|
}
|
|
|
|
if (state.queue.length >= maxQueue) {
|
|
return Promise.reject(
|
|
new ExternalImageApiBusyError("图片生成服务繁忙,请稍后重试"),
|
|
);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const waiter: ExternalImageApiWaiter = {
|
|
resolve,
|
|
reject,
|
|
timer: setTimeout(() => {
|
|
const index = state.queue.indexOf(waiter);
|
|
if (index >= 0) {
|
|
state.queue.splice(index, 1);
|
|
}
|
|
reject(new ExternalImageApiBusyError("图片生成排队超时,请稍后重试"));
|
|
}, queueTimeoutMs),
|
|
};
|
|
state.queue.push(waiter);
|
|
});
|
|
}
|
|
|
|
const requestSchema = z.object({
|
|
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
|
negativePrompt: z.string().trim().optional(),
|
|
imageCount: z.coerce.number().int().min(1).max(8).optional(),
|
|
aspectRatio: z.string().trim().max(32).optional(),
|
|
imageSize: z.string().trim().max(32).optional(),
|
|
model: z.string().trim().optional(),
|
|
modelId: z.string().trim().optional(),
|
|
modelRef: z.string().trim().optional(),
|
|
metadata: z.record(z.string(), z.any()).optional(),
|
|
referenceImages: z.array(z.string().trim().min(1)).max(14).optional(),
|
|
}).superRefine((payload, ctx) => {
|
|
if (payload.model || payload.modelId || payload.modelRef) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["model"],
|
|
message: "第三方生图 API 不允许传入模型参数,系统会按后台配置自动选模",
|
|
});
|
|
}
|
|
});
|
|
|
|
function normalizeString(value: FormDataEntryValue | string | null | undefined) {
|
|
return String(value || "").trim();
|
|
}
|
|
|
|
function parseOptionalInteger(value: FormDataEntryValue | null) {
|
|
const normalized = normalizeString(value);
|
|
if (!normalized) return undefined;
|
|
const parsed = Number(normalized);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
function parseOptionalJsonRecord(value: FormDataEntryValue | null) {
|
|
const normalized = normalizeString(value);
|
|
if (!normalized) return undefined;
|
|
try {
|
|
const parsed = JSON.parse(normalized);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
} catch {
|
|
// ignore invalid metadata JSON
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function hasDisallowedModelOverride(formValue: string) {
|
|
return formValue.trim().length > 0;
|
|
}
|
|
|
|
function normalizeReferenceImageInputs(value: unknown): string[] {
|
|
if (!value) return [];
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.flatMap((item) => normalizeReferenceImageInputs(item))
|
|
.filter(Boolean);
|
|
}
|
|
if (typeof value === "string") {
|
|
return value
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async function parseRequestPayload(request: Request) {
|
|
const contentType = request.headers.get("content-type") || "";
|
|
if (contentType.toLowerCase().includes("multipart/form-data")) {
|
|
const formData = await request.formData();
|
|
const forcedModel = normalizeString(formData.get("model"));
|
|
const forcedModelId = normalizeString(formData.get("modelId"));
|
|
const forcedModelRef = normalizeString(formData.get("modelRef"));
|
|
if (
|
|
hasDisallowedModelOverride(forcedModel) ||
|
|
hasDisallowedModelOverride(forcedModelId) ||
|
|
hasDisallowedModelOverride(forcedModelRef)
|
|
) {
|
|
throw new z.ZodError([
|
|
{
|
|
code: "custom",
|
|
path: ["model"],
|
|
message: "第三方生图 API 不允许传入模型参数,系统会按后台配置自动选模",
|
|
},
|
|
]);
|
|
}
|
|
|
|
const fileEntries = [
|
|
...formData.getAll("images"),
|
|
...formData.getAll("image"),
|
|
...formData.getAll("referenceImages"),
|
|
...formData.getAll("referenceImage"),
|
|
...formData.getAll("files"),
|
|
];
|
|
const files = fileEntries.filter((item): item is File => item instanceof File);
|
|
const uploadedReferenceImages = await Promise.all(
|
|
files.map((file) => uploadExternalImageApiFile(file)),
|
|
);
|
|
|
|
const inlineReferenceImages = [
|
|
...formData.getAll("imageUrls"),
|
|
...formData.getAll("imageUrl"),
|
|
...formData.getAll("referenceImageUrls"),
|
|
...formData.getAll("referenceImages"),
|
|
...formData.getAll("referenceImage"),
|
|
]
|
|
.filter((item): item is string => typeof item === "string")
|
|
.flatMap((item) => normalizeReferenceImageInputs(item));
|
|
|
|
const hostedInlineImages = await ensureHostedReferenceImageUrls(
|
|
inlineReferenceImages,
|
|
);
|
|
|
|
const parsed = requestSchema.parse({
|
|
prompt: normalizeString(formData.get("prompt")),
|
|
negativePrompt: normalizeString(formData.get("negativePrompt")) || undefined,
|
|
imageCount: parseOptionalInteger(formData.get("imageCount")),
|
|
aspectRatio: normalizeString(formData.get("aspectRatio")) || undefined,
|
|
imageSize: normalizeString(formData.get("imageSize")) || undefined,
|
|
model: forcedModel,
|
|
modelId: forcedModelId,
|
|
modelRef: forcedModelRef,
|
|
metadata: parseOptionalJsonRecord(formData.get("metadata")),
|
|
referenceImages: [...uploadedReferenceImages, ...hostedInlineImages],
|
|
});
|
|
if (!parsed.referenceImages?.length) {
|
|
throw new z.ZodError([
|
|
{
|
|
code: "custom",
|
|
path: ["referenceImages"],
|
|
message: "至少上传 1 张参考图",
|
|
},
|
|
]);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
const json = await request.json();
|
|
const parsed = requestSchema.parse({
|
|
...json,
|
|
model: normalizeString((json as any)?.model),
|
|
modelId: normalizeString((json as any)?.modelId),
|
|
modelRef: normalizeString((json as any)?.modelRef),
|
|
referenceImages: await ensureHostedReferenceImageUrls([
|
|
...normalizeReferenceImageInputs((json as any)?.referenceImages),
|
|
...normalizeReferenceImageInputs((json as any)?.imageUrls),
|
|
...normalizeReferenceImageInputs((json as any)?.images),
|
|
...normalizeReferenceImageInputs((json as any)?.image),
|
|
]),
|
|
});
|
|
if (!parsed.referenceImages?.length) {
|
|
throw new z.ZodError([
|
|
{
|
|
code: "custom",
|
|
path: ["referenceImages"],
|
|
message: "至少上传 1 张参考图",
|
|
},
|
|
]);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const authResult = await authenticateExternalImageApiRequest(request);
|
|
if (!authResult.accessKey) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: authResult.error,
|
|
},
|
|
{ status: authResult.status },
|
|
);
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
let logId: string | null = null;
|
|
let releaseExternalImageApiSlot: (() => void) | null = null;
|
|
|
|
try {
|
|
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
|
|
const payload = await parseRequestPayload(request);
|
|
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
|
|
const candidateModels = await buildExternalImageApiModelCandidates({
|
|
referenceImageCount: payload.referenceImages?.length || 0,
|
|
aspectRatio: payload.aspectRatio,
|
|
imageSize,
|
|
});
|
|
const selectedModel = candidateModels[0];
|
|
const ownerUserId = await resolveExternalImageApiOwnerUserId(
|
|
authResult.config.defaultOwnerEmail,
|
|
);
|
|
const requesterIp = getRequesterIp(request);
|
|
const requesterUserAgent = request.headers.get("user-agent") || null;
|
|
|
|
const [createdLog] = await db
|
|
.insert(externalImageApiRequestLog)
|
|
.values({
|
|
apiKeyId: authResult.accessKey.id,
|
|
apiKeyName: authResult.accessKey.name,
|
|
ownerUserId,
|
|
modelDbId: selectedModel.model.id,
|
|
modelRuntimeId: selectedModel.model.modelId,
|
|
modelDisplayName: selectedModel.model.displayName,
|
|
prompt: payload.prompt,
|
|
negativePrompt: payload.negativePrompt || null,
|
|
imageCount: payload.imageCount || 1,
|
|
aspectRatio: payload.aspectRatio || null,
|
|
imageSize,
|
|
status: "pending",
|
|
requesterIp,
|
|
requesterUserAgent,
|
|
requestPayload: {
|
|
...payload,
|
|
imageSize,
|
|
candidateModels: candidateModels.map(serializeExternalImageApiModel),
|
|
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
|
|
authResult.accessKey,
|
|
),
|
|
},
|
|
})
|
|
.returning({ id: externalImageApiRequestLog.id });
|
|
|
|
logId = createdLog?.id || null;
|
|
|
|
const generationResult = await generateImagesWithFailover({
|
|
candidates: candidateModels,
|
|
prompt: payload.prompt,
|
|
negativePrompt: payload.negativePrompt || null,
|
|
imageCount: payload.imageCount || 1,
|
|
aspectRatio: payload.aspectRatio || null,
|
|
imageSize,
|
|
referenceImageUrls: payload.referenceImages || [],
|
|
});
|
|
const actualModel = generationResult.usedModel;
|
|
const hostedMarkdown = generationResult.markdown;
|
|
const images = generationResult.images;
|
|
const originalImageUrls = images.map((image) => image.originalUrl || image.url);
|
|
const thumbnailImageUrls = images.map((image) => image.thumbnailUrl);
|
|
|
|
const persistResult = await saveGeneratedImages(
|
|
hostedMarkdown,
|
|
ownerUserId,
|
|
null,
|
|
null,
|
|
payload.prompt,
|
|
actualModel.model.id,
|
|
{
|
|
sourceType: "external_api_generated",
|
|
userPrompt: payload.prompt,
|
|
optimizedPrompt: payload.prompt,
|
|
requestedImageCount: payload.imageCount || images.length,
|
|
aspectRatio: payload.aspectRatio,
|
|
imageSize,
|
|
sourceImageUrls: payload.referenceImages || [],
|
|
relationType: "edited_from",
|
|
skipBilling: true,
|
|
},
|
|
);
|
|
|
|
if (logId) {
|
|
await db
|
|
.update(externalImageApiRequestLog)
|
|
.set({
|
|
modelDbId: actualModel.model.id,
|
|
modelRuntimeId: actualModel.model.modelId,
|
|
modelDisplayName: actualModel.model.displayName,
|
|
status: "succeeded",
|
|
responsePayload: {
|
|
model: serializeExternalImageApiModel(actualModel),
|
|
markdown: hostedMarkdown,
|
|
images,
|
|
imageUrls: originalImageUrls,
|
|
thumbnailUrls: thumbnailImageUrls,
|
|
attempts: generationResult.attempts,
|
|
},
|
|
generatedImageIds: persistResult.savedImageIds,
|
|
generatedAssetIds: persistResult.savedAssetIds,
|
|
generatedImageUrls: persistResult.savedOriginalUrls,
|
|
durationMs: Date.now() - startedAt,
|
|
completedAt: new Date(),
|
|
})
|
|
.where(eq(externalImageApiRequestLog.id, logId));
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: {
|
|
requestId: logId,
|
|
model: serializeExternalImageApiModel(actualModel),
|
|
imageUrl: originalImageUrls[0] || null,
|
|
image_url: originalImageUrls[0] || null,
|
|
imageUrls: originalImageUrls,
|
|
image_urls: originalImageUrls,
|
|
originalUrls: originalImageUrls,
|
|
original_urls: originalImageUrls,
|
|
thumbnailUrls: thumbnailImageUrls,
|
|
thumbnail_urls: thumbnailImageUrls,
|
|
images,
|
|
generatedImageIds: persistResult.savedImageIds,
|
|
generatedAssetIds: persistResult.savedAssetIds,
|
|
attempts: generationResult.attempts,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("[External Image API] Generate failed:", error);
|
|
const shouldHideModelInfo = error instanceof ExternalImageApiGenerationError;
|
|
const internalError = shouldHideModelInfo
|
|
? error.internalMessage
|
|
: error instanceof Error
|
|
? error.message
|
|
: "图片生成失败";
|
|
const publicError = shouldHideModelInfo
|
|
? error.userMessage
|
|
: error instanceof Error
|
|
? error.message
|
|
: "图片生成失败";
|
|
|
|
if (error instanceof z.ZodError) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: error.errors[0]?.message || "参数错误",
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
if (logId) {
|
|
await db
|
|
.update(externalImageApiRequestLog)
|
|
.set({
|
|
status: "failed",
|
|
errorMessage: internalError,
|
|
responsePayload: {
|
|
error: shouldHideModelInfo
|
|
? {
|
|
message: error.userMessage,
|
|
attempts: error.attempts,
|
|
internalError,
|
|
}
|
|
: internalError,
|
|
attempts: shouldHideModelInfo ? error.attempts : undefined,
|
|
},
|
|
durationMs: Date.now() - startedAt,
|
|
completedAt: new Date(),
|
|
})
|
|
.where(eq(externalImageApiRequestLog.id, logId));
|
|
}
|
|
|
|
if (error instanceof ExternalImageApiBusyError) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: error.message,
|
|
},
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
"Retry-After": String(error.retryAfterSeconds),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: publicError,
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
} finally {
|
|
if (releaseExternalImageApiSlot) {
|
|
releaseExternalImageApiSlot();
|
|
}
|
|
}
|
|
}
|