import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing"; import { createCanvasImageTask, enqueueCanvasImageTask, getCanvasImageTaskForUser, } from "@/lib/canvas/image-task-queue"; import { getSystemSetting } from "@/lib/db/queries"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { STYLE_PRO_CONFIG_KEY, type StyleProCategory, type StyleProCustomField, type StyleProCustomInputMap, type StyleProGenerationMode, type StyleProOption, type StyleProSelectionMap, normalizeStyleProConfig, } from "@/lib/style-pro/config"; import { findStyleProImageModelByRef, getStyleProImageModelEntries, type StyleProModelEntry, } from "@/lib/style-pro/model-options"; import type { StyleProTaskRequest, StyleProTaskResult, } from "@/lib/style-pro/task"; export const maxDuration = 120; export const dynamic = "force-dynamic"; const generateRequestSchema = z.object({ canvasId: z.string().trim().min(1).max(160).optional(), requestId: z.string().uuid().optional(), sourceImageUrl: z.string().trim().min(1).max(4000), generationMode: z.enum(["style", "realistic"]).optional(), modelId: z.string().trim().min(1).max(160).optional(), selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)), customInputs: z .object({ global: z.record(z.string().max(2000)).optional(), categories: z.record(z.record(z.string().max(2000))).optional(), }) .optional(), }); const STYLE_PRO_REQUEST_LOCK_TTL_MS = 10 * 60 * 1000; const styleProActiveRequestKeys = new Map(); function pruneExpiredStyleProRequestLocks() { const now = Date.now(); for (const [key, expiresAt] of styleProActiveRequestKeys.entries()) { if (expiresAt <= now) { styleProActiveRequestKeys.delete(key); } } } function acquireStyleProRequestLock({ requestId, userId, }: { requestId?: string | null; userId: string; }) { if (!requestId) return ""; pruneExpiredStyleProRequestLocks(); const key = `${userId}:${requestId}`; if (styleProActiveRequestKeys.has(key)) return null; styleProActiveRequestKeys.set(key, Date.now() + STYLE_PRO_REQUEST_LOCK_TTL_MS); return key; } type SelectedOption = { category: StyleProCategory; option: StyleProOption; }; function isDisallowedImageUrl(value: string) { const trimmed = value.trim().toLowerCase(); return trimmed.startsWith("data:") || trimmed.startsWith("javascript:"); } function buildSelectionPayload(selectedOptions: SelectedOption[]) { return selectedOptions.map(({ category, option }) => ({ categoryId: category.id, categoryTitle: category.title, jsonKey: option.jsonKey || category.id, optionId: option.id, optionLabel: option.label, prompt: option.prompt, })); } function normalizeCustomInputValue(field: StyleProCustomField, value: unknown) { const raw = String(value ?? "").trim(); if (!raw) return ""; if (field.type === "color") { return /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(raw) ? raw.toUpperCase() : ""; } const maxLength = Math.max( 1, Math.min(2000, Number(field.maxLength || (field.type === "textarea" ? 800 : 160))) ); return raw.slice(0, maxLength); } function renderCustomInputPrompt(params: { field: StyleProCustomField; value: string; category?: StyleProCategory; }) { const template = params.field.promptTemplate?.trim() || "{{fieldLabel}}:{{value}}"; return template .replaceAll("{{value}}", params.value) .replaceAll("{{fieldLabel}}", params.field.label) .replaceAll("{{fieldId}}", params.field.id) .replaceAll("{{categoryTitle}}", params.category?.title || "全局自定义") .replaceAll("{{categoryId}}", params.category?.id || "global"); } function validateCustomInputs(params: { config: ReturnType; enabledCategories: StyleProCategory[]; rawCustomInputs?: StyleProCustomInputMap; }) { const normalizedInputs: StyleProCustomInputMap = { global: {}, categories: {}, }; const selectedCustomInputs: NonNullable = []; const rawGlobal = params.rawCustomInputs?.global || {}; const rawCategories = params.rawCustomInputs?.categories || {}; for (const field of params.config.customFields.filter((item) => item.enabled)) { const value = normalizeCustomInputValue(field, rawGlobal[field.id]); if (field.required && !value) { return { ok: false as const, error: `请填写:${field.label}`, }; } if (!value) continue; normalizedInputs.global![field.id] = value; selectedCustomInputs.push({ scope: "global", fieldId: field.id, fieldLabel: field.label, fieldType: field.type, value, prompt: renderCustomInputPrompt({ field, value }), }); } for (const category of params.enabledCategories) { const categoryInput = rawCategories[category.id] || {}; for (const field of (category.customFields || []).filter((item) => item.enabled)) { const value = normalizeCustomInputValue(field, categoryInput[field.id]); if (field.required && !value) { return { ok: false as const, error: `请填写:${category.title} / ${field.label}`, }; } if (!value) continue; normalizedInputs.categories![category.id] = normalizedInputs.categories![category.id] || {}; normalizedInputs.categories![category.id][field.id] = value; selectedCustomInputs.push({ scope: "category", categoryId: category.id, categoryTitle: category.title, fieldId: field.id, fieldLabel: field.label, fieldType: field.type, value, prompt: renderCustomInputPrompt({ field, value, category }), }); } } return { ok: true as const, normalizedInputs, selectedCustomInputs, }; } function validateSelections( categories: StyleProCategory[], rawSelections: Record ) { const categoryById = new Map( categories.map((category) => [category.id, category]) ); const selectedOptions: SelectedOption[] = []; const normalizedSelections: Record = {}; for (const [categoryId, optionIds] of Object.entries(rawSelections)) { const category = categoryById.get(categoryId); if (!category) { return { ok: false as const, error: `无效分类:${categoryId}`, }; } const enabledOptions = new Map( category.options .filter((option) => option.enabled) .map((option) => [option.id, option]) ); const uniqueOptionIds = Array.from(new Set(optionIds)); if (uniqueOptionIds.length > category.maxSelections) { return { ok: false as const, error: `${category.title} 最多选择 ${category.maxSelections} 项`, }; } for (const optionId of uniqueOptionIds) { const option = enabledOptions.get(optionId); if (!option) { return { ok: false as const, error: `无效选项:${category.title} / ${optionId}`, }; } selectedOptions.push({ category, option }); } normalizedSelections[category.id] = uniqueOptionIds; } const missingCategory = categories.find((category) => { if (!category.required && category.minSelections <= 0) return false; const selectedCount = normalizedSelections[category.id]?.length || 0; return ( selectedCount < Math.max(category.required ? 1 : 0, category.minSelections) ); }); if (missingCategory) { return { ok: false as const, error: `请完成必选项:${missingCategory.title}`, }; } return { ok: true as const, selectedOptions, normalizedSelections, }; } function buildStyleProTaskRequest(params: { body: z.infer; generationMode: StyleProGenerationMode; normalizedSelections: StyleProSelectionMap; customInputs: StyleProCustomInputMap; selectedOptions: SelectedOption[]; selectedCustomInputs: NonNullable; selectedModels: Array<{ model: { id: string; displayName: string; }; }>; config: ReturnType; billing: { unitCost: number; reason: string; modelId: string; messageId?: string | null; note: string; }; }): StyleProTaskRequest { return { taskKind: "style-pro", canvasId: params.body.canvasId || null, sourceImageUrl: params.body.sourceImageUrl, generationMode: params.generationMode, normalizedSelections: params.normalizedSelections, customInputs: params.customInputs, selectedOptions: buildSelectionPayload(params.selectedOptions), selectedCustomInputs: params.selectedCustomInputs, selectedModels: params.selectedModels.map((entry) => ({ id: entry.model.id, displayName: entry.model.displayName, })), optionLabels: params.selectedOptions.map( ({ category, option }) => `${category.title}:${option.label}` ), prompts: { defaultPrompt: params.config.defaultPrompt, realisticFirstPrompt: params.config.realisticFirstPrompt || undefined, realisticSecondPrompt: params.config.realisticSecondPrompt || undefined, negativePrompt: params.config.negativePrompt || undefined, aspectRatio: params.config.aspectRatio || "auto", imageSize: params.config.imageSize || "2K", }, billing: params.billing, requestedStyleProModelId: params.generationMode === "style" ? params.body.modelId || params.selectedModels[0]?.model.id || null : null, }; } export async function GET(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json( { success: false, error: "未登录" }, { status: 401 } ); } const { searchParams } = new URL(request.url); const taskId = String(searchParams.get("taskId") || "").trim(); if (!taskId) { return NextResponse.json( { success: false, error: "缺少 taskId 参数" }, { status: 400 } ); } const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id, }); if (!task || (task.request as { taskKind?: unknown })?.taskKind !== "style-pro") { return NextResponse.json( { success: false, error: "任务不存在" }, { status: 404 } ); } return NextResponse.json({ success: true, task: { id: task.id, status: task.status, attempts: task.attempts, error: task.error || null, result: (task.result || null) as StyleProTaskResult | null, createdAt: task.createdAt, startedAt: task.startedAt, completedAt: task.completedAt, queueStatus: task.queueStatus, }, }); } catch (error) { console.error("[Style Pro Generate][TaskStatus] Failed:", error); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "查询任务失败", }, { status: 500 } ); } } export async function POST(request: Request) { let requestLockKey = ""; try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json( { success: false, error: "未登录" }, { status: 401 } ); } const body = generateRequestSchema.parse(await request.json()); const acquiredRequestLockKey = acquireStyleProRequestLock({ requestId: body.requestId, userId: session.user.id, }); if (acquiredRequestLockKey === null) { return NextResponse.json( { success: false, error: "当前图片正在生成中,请稍候" }, { status: 409 } ); } requestLockKey = acquiredRequestLockKey; const generationMode: StyleProGenerationMode = body.generationMode === "realistic" ? "realistic" : "style"; if (isDisallowedImageUrl(body.sourceImageUrl)) { return NextResponse.json( { success: false, error: "请使用已上传的图片链接生成" }, { status: 400 } ); } const setting = await getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch( () => null ); const config = normalizeStyleProConfig(setting?.value); if (!config.enabled) { return NextResponse.json( { success: false, error: "风格化 PRO 暂未启用" }, { status: 403 } ); } const enabledCategories = config.categories .filter((category) => category.enabled) .map((category) => ({ ...category, options: category.options.filter((option) => option.enabled), })) .filter((category) => category.options.length > 0); const selectionResult = validateSelections( enabledCategories, body.selections ); if (!selectionResult.ok) { return NextResponse.json( { success: false, error: selectionResult.error }, { status: 400 } ); } const customInputResult = validateCustomInputs({ config, enabledCategories, rawCustomInputs: body.customInputs, }); if (!customInputResult.ok) { return NextResponse.json( { success: false, error: customInputResult.error }, { status: 400 } ); } const imageModelEntries = await getStyleProImageModelEntries({ includeHidden: generationMode === "realistic", }); const selectedModels: StyleProModelEntry[] = generationMode === "realistic" ? (() => { const firstModel = findStyleProImageModelByRef( imageModelEntries, config.realisticFirstModelId ); const secondModel = findStyleProImageModelByRef( imageModelEntries, config.realisticSecondModelId ); return firstModel && secondModel ? [firstModel, secondModel] : []; })() : (() => { const configuredDefaultModel = findStyleProImageModelByRef( imageModelEntries, config.modelId ); let selectedModel: StyleProModelEntry | null = configuredDefaultModel || imageModelEntries.find((entry) => entry.model.isDefault) || imageModelEntries[0] || null; if (body.modelId) { selectedModel = findStyleProImageModelByRef(imageModelEntries, body.modelId) || null; if (!selectedModel) { return []; } } return selectedModel ? [selectedModel] : []; })(); if (generationMode === "realistic" && selectedModels.length < 2) { return NextResponse.json( { success: false, error: "请先在后台配置实景化两段隐藏模型" }, { status: 400 } ); } if ( generationMode === "style" && body.modelId && selectedModels.length === 0 ) { return NextResponse.json( { success: false, error: "所选生图模型不可用" }, { status: 400 } ); } if (selectedModels.length === 0) { return NextResponse.json( { success: false, error: "暂无可用的风格化 PRO 生图模型" }, { status: 400 } ); } const unitCost = selectedModels.reduce( (sum, entry) => sum + getModelGenerationCreditCost(entry.model as any, "image", { imageSize: config.imageSize || "2K", }), 0 ); const billingModelId = generationMode === "realistic" ? selectedModels.map((entry) => entry.model.id).join("+") : selectedModels[0].model.id; const billing = { unitCost, reason: generationMode === "realistic" ? "style_pro_realistic_generation" : "style_pro_generation", modelId: billingModelId, messageId: body.requestId || null, note: generationMode === "realistic" ? "风格化 PRO 实景化生图消耗积分" : "风格化 PRO 生图消耗积分", }; const pointsCharge = await prepareFeaturePointsCharge({ userId: session.user.id, ...billing, }); if (!pointsCharge.ok) { return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status, }); } const taskRequest = buildStyleProTaskRequest({ body, generationMode, normalizedSelections: selectionResult.normalizedSelections, customInputs: customInputResult.normalizedInputs, selectedOptions: selectionResult.selectedOptions, selectedCustomInputs: customInputResult.selectedCustomInputs, selectedModels, config, billing, }); const task = await createCanvasImageTask({ userId: session.user.id, request: taskRequest as unknown as Record, }); await enqueueCanvasImageTask(task.id); const currentTask = await getCanvasImageTaskForUser({ taskId: task.id, userId: session.user.id, }); return NextResponse.json({ success: true, taskId: task.id, status: currentTask?.status || task.status, queueStatus: currentTask?.queueStatus, }); } catch (error) { console.error("[Style Pro Generate] Failed:", error); if (error instanceof z.ZodError) { return NextResponse.json( { success: false, error: "请求参数不完整" }, { status: 400 } ); } return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "风格化 PRO 生成失败", }, { status: 500 } ); } finally { if (requestLockKey) { styleProActiveRequestKeys.delete(requestLockKey); } } }