590 lines
18 KiB
TypeScript
590 lines
18 KiB
TypeScript
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<string, number>();
|
||
|
||
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<typeof normalizeStyleProConfig>;
|
||
enabledCategories: StyleProCategory[];
|
||
rawCustomInputs?: StyleProCustomInputMap;
|
||
}) {
|
||
const normalizedInputs: StyleProCustomInputMap = {
|
||
global: {},
|
||
categories: {},
|
||
};
|
||
const selectedCustomInputs: NonNullable<StyleProTaskRequest["selectedCustomInputs"]> = [];
|
||
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<string, string[]>
|
||
) {
|
||
const categoryById = new Map(
|
||
categories.map((category) => [category.id, category])
|
||
);
|
||
const selectedOptions: SelectedOption[] = [];
|
||
const normalizedSelections: Record<string, string[]> = {};
|
||
|
||
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<typeof generateRequestSchema>;
|
||
generationMode: StyleProGenerationMode;
|
||
normalizedSelections: StyleProSelectionMap;
|
||
customInputs: StyleProCustomInputMap;
|
||
selectedOptions: SelectedOption[];
|
||
selectedCustomInputs: NonNullable<StyleProTaskRequest["selectedCustomInputs"]>;
|
||
selectedModels: Array<{
|
||
model: {
|
||
id: string;
|
||
displayName: string;
|
||
};
|
||
}>;
|
||
config: ReturnType<typeof normalizeStyleProConfig>;
|
||
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<string, unknown>,
|
||
});
|
||
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);
|
||
}
|
||
}
|
||
}
|