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 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; } } 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; try { 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, ownerKey: `external-image-api:${authResult.accessKey.id}`, 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)); } return NextResponse.json( { success: false, error: publicError, }, { status: 500 }, ); } }