Your Name 4aead3018f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add image size credit multipliers
2026-05-22 20:49:14 +08:00

590 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}
}