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, ensureHostedImageMarkdown, ensureHostedReferenceImageUrls, extractGeneratedImagesFromMarkdown, generateImagesWithFailover, getRequesterIp, resolveExternalImageApiOwnerUserId, serializeExternalImageApiModel, uploadExternalImageApiFile, } from "@/lib/external-image-api/service"; export const runtime = "nodejs"; export const maxDuration = 120; 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; }; 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(), metadata: z.record(z.string(), z.any()).optional(), referenceImages: z.array(z.string().trim().min(1)).max(14).optional(), }); 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; } } catch { // ignore invalid metadata JSON } return undefined; } 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 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, 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, 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 candidateModels = await buildExternalImageApiModelCandidates({ referenceImageCount: payload.referenceImages?.length || 0, }); 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: payload.imageSize || null, status: "pending", requesterIp, requesterUserAgent, requestPayload: { ...payload, 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: payload.imageSize || null, referenceImageUrls: payload.referenceImages || [], }); const actualModel = generationResult.usedModel; const rawMarkdown = generationResult.markdown; const hostedMarkdown = await ensureHostedImageMarkdown( rawMarkdown, payload.imageSize || "2K", ); const images = extractGeneratedImagesFromMarkdown(hostedMarkdown); if (!images.length) { throw new Error("模型返回结果里未解析到图片"); } 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: payload.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, 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), images, generatedImageIds: persistResult.savedImageIds, generatedAssetIds: persistResult.savedAssetIds, attempts: generationResult.attempts, }, }); } catch (error) { console.error("[External Image API] Generate failed:", error); 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: error instanceof Error ? error.message : "图片生成失败", responsePayload: { error: error instanceof Error ? error.message : "图片生成失败", }, 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: error instanceof Error ? error.message : "图片生成失败", }, { status: 500 }, ); } finally { if (releaseExternalImageApiSlot) { releaseExternalImageApiSlot(); } } }