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 = 360;
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();
}
}
}