新增的批次接口把一个 UUID 正则写成了 8-4-4-12(少一组 -{4}),标准 UUID 是
8-4-4-4-12。结果是任何真实项目编号和请求编号都过不了校验:查询接口恒返 400
「项目编号无效」,提交接口恒返 400「任务编号或项目编号无效」,详情替换完全无法使用。
- 校验抽成 isDetailReplacementUuid 并修正为规范的 8-4-4-4-12,路由不再自带正则
- 回归测试覆盖真实线上项目编号、浏览器生成的请求编号,以及导致故障的 8-4-4-12 形状
- 增加源码守卫,防止该路由再次出现自带的错误 UUID 正则
- 前端在项目尚未保存时给出明确提示,而不是把空编号丢给接口
296 lines
14 KiB
TypeScript
296 lines
14 KiB
TypeScript
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
import { NextResponse } from "next/server";
|
|
import { auth } from "@/app/(auth)/auth";
|
|
import { db } from "@/lib/db";
|
|
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
|
|
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
|
|
import {
|
|
createCanvasImageTask,
|
|
enqueueCanvasImageTask,
|
|
ensureCanvasImageTaskActive,
|
|
getCanvasImageTaskForUser,
|
|
} from "@/lib/canvas/image-task-queue";
|
|
import {
|
|
checkUserImageQuota,
|
|
getChatById,
|
|
getEnabledAiModelsWithProvider,
|
|
getPointsSystemConfig,
|
|
getUserById,
|
|
saveChat,
|
|
} from "@/lib/db/queries";
|
|
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
|
import { resolveDirectReplacementModelRef } from "@/app/(chat)/api/chat/_lib/detail-replacement";
|
|
import {
|
|
buildAgentSlotPrompt,
|
|
normalizeAgentSupplementPrompt,
|
|
} from "@/app/(chat)/api/chat/_lib/agent-scene-workflow";
|
|
import {
|
|
detailReplacementSubmissionKey,
|
|
isDetailReplacementBatchRequest,
|
|
isDetailReplacementUuid,
|
|
type DetailReplacementBatchReference,
|
|
type DetailReplacementBatchRequest,
|
|
type DetailReplacementBatchResult,
|
|
} from "@/lib/agents/detail-replacement-batch";
|
|
|
|
export const maxDuration = 1200;
|
|
|
|
function scopedChatId(projectId: string) {
|
|
const normalized = projectId.toLowerCase().replace(/-/g, "");
|
|
const scoped = `${normalized.slice(0, 20)}a93e17a60001`;
|
|
return [scoped.slice(0, 8), scoped.slice(8, 12), scoped.slice(12, 16), scoped.slice(16, 20), scoped.slice(20, 32)].join("-");
|
|
}
|
|
|
|
function taskResponse(task: Awaited<ReturnType<typeof getCanvasImageTaskForUser>>) {
|
|
const result = task?.result as DetailReplacementBatchResult | null;
|
|
const request = task?.request as DetailReplacementBatchRequest | null;
|
|
return {
|
|
id: task?.id,
|
|
status: task?.status,
|
|
attempts: task?.attempts,
|
|
error: task?.error || null,
|
|
cards: Array.isArray(result?.cards) ? result.cards : [],
|
|
currentCardId: result?.currentCardId || null,
|
|
requestedCardIds: request?.references?.map((item) => item.id) || [],
|
|
createdAt: task?.createdAt || null,
|
|
queueStatus: task?.queueStatus || null,
|
|
};
|
|
}
|
|
|
|
async function findActiveBatchId(params: {
|
|
userId: string;
|
|
projectId: string;
|
|
submissionKey: string;
|
|
}) {
|
|
const [row] = await db.select({ id: canvasImageTask.id })
|
|
.from(canvasImageTask)
|
|
.where(and(
|
|
eq(canvasImageTask.userId, params.userId),
|
|
inArray(canvasImageTask.status, ["pending", "running"]),
|
|
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
|
sql`${canvasImageTask.request}->>'projectId' = ${params.projectId}`,
|
|
sql`${canvasImageTask.request}->>'submissionKey' = ${params.submissionKey}`,
|
|
))
|
|
.orderBy(desc(canvasImageTask.createdAt)).limit(1);
|
|
return row?.id || null;
|
|
}
|
|
|
|
function isUniqueViolation(error: unknown) {
|
|
for (let cursor: unknown = error; cursor && typeof cursor === "object"; ) {
|
|
if ((cursor as { code?: unknown }).code === "23505") return true;
|
|
cursor = (cursor as { cause?: unknown }).cause;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function GET(request: Request) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
const params = new URL(request.url).searchParams;
|
|
const projectId = String(params.get("projectId") || "").trim();
|
|
const taskId = String(params.get("taskId") || "").trim();
|
|
if (!isDetailReplacementUuid(projectId)) {
|
|
return NextResponse.json({ error: "项目编号无效" }, { status: 400 });
|
|
}
|
|
const [project] = await db.select({ id: canvasProject.id }).from(canvasProject).where(and(
|
|
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
|
)).limit(1);
|
|
if (!project) return NextResponse.json({ error: "项目不存在" }, { status: 404 });
|
|
|
|
const [candidate] = await db.select({ id: canvasImageTask.id, request: canvasImageTask.request })
|
|
.from(canvasImageTask)
|
|
.where(and(
|
|
eq(canvasImageTask.userId, session.user.id),
|
|
...(isDetailReplacementUuid(taskId) ? [eq(canvasImageTask.id, taskId)] : []),
|
|
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
|
sql`${canvasImageTask.request}->>'projectId' = ${projectId}`,
|
|
))
|
|
// A batch that is still working matters more than a newer finished one, so the
|
|
// workspace keeps showing live progress after a rerun of a single card.
|
|
.orderBy(
|
|
sql`case when ${canvasImageTask.status} in ('pending', 'running') then 0 else 1 end`,
|
|
desc(canvasImageTask.createdAt),
|
|
).limit(1);
|
|
if (!candidate || !isDetailReplacementBatchRequest(candidate.request)) {
|
|
return NextResponse.json({ task: null });
|
|
}
|
|
let task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
|
if (task && (task.status === "failed" ||
|
|
(task.status === "running" && Date.now() - task.updatedAt.getTime() > 90_000))) {
|
|
await ensureCanvasImageTaskActive({ taskId: task.id, userId: session.user.id });
|
|
task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
|
}
|
|
return NextResponse.json({ task: taskResponse(task) });
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
const body = await request.json();
|
|
const projectId = String(body.projectId || "").trim();
|
|
const taskId = String(body.requestId || "").trim();
|
|
if (!isDetailReplacementUuid(projectId) || !isDetailReplacementUuid(taskId)) {
|
|
return NextResponse.json({ error: "任务编号或项目编号无效" }, { status: 400 });
|
|
}
|
|
const [project] = await db.select().from(canvasProject).where(and(
|
|
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
|
)).limit(1);
|
|
if (!project || !isAgentProjectRow(project)) {
|
|
return NextResponse.json({ error: "风格化项目不存在" }, { status: 404 });
|
|
}
|
|
|
|
const state = extractAgentProjectStateFromCanvasData(project.canvasData);
|
|
const knownReferences = new Map(
|
|
(state.workflowState.agentModeState?.detailReplacement?.references || [])
|
|
.map((item) => [String(item.id), String(item.imageUrl)]),
|
|
);
|
|
const requested = Array.isArray(body.references) ? body.references : [];
|
|
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
|
|
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
|
return {
|
|
id: String(item.id || "").slice(0, 128),
|
|
label: String(item.label || "详情参考图").slice(0, 255),
|
|
imageUrl: String(item.imageUrl || "").trim(),
|
|
candidateId: String(item.candidateId || "").slice(0, 128) || null,
|
|
passthrough: item.passthrough === true,
|
|
rerunReferenceImages: Array.isArray(item.rerunReferenceImages)
|
|
? item.rerunReferenceImages.slice(0, 6).map((entry: { url?: string }) => ({ url: String(entry?.url || "") }))
|
|
: [],
|
|
};
|
|
});
|
|
if (
|
|
references.length === 0 || references.length > 40 ||
|
|
new Set(references.map((item) => item.id)).size !== references.length ||
|
|
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
|
|
) {
|
|
return NextResponse.json({ error: "详情参考图尚未保存或已发生变化,请刷新后重试" }, { status: 400 });
|
|
}
|
|
const productImageUrls = Array.isArray(body.productImageUrls)
|
|
? Array.from(new Set(body.productImageUrls.map((url: unknown) => String(url || "").trim())
|
|
.filter((url: string) => /^https?:\/\//i.test(url)))).slice(0, 16) as string[]
|
|
: [];
|
|
if (productImageUrls.length === 0 && !references.every((item) => item.passthrough)) {
|
|
return NextResponse.json({ error: "请先在输入图区选择至少一张产品图" }, { status: 400 });
|
|
}
|
|
const strategy = body.replacementStrategy === "product_only" ? "product_only" : "scene_replace";
|
|
const prompt = String(body.prompt || "").trim().slice(0, 24_000);
|
|
if (strategy !== "product_only" && !prompt) {
|
|
return NextResponse.json({ error: "请先配置风格化提示词" }, { status: 400 });
|
|
}
|
|
const models = await getEnabledAiModelsWithProvider();
|
|
const model = await resolveDirectReplacementModelRef({
|
|
selectedChatModel: String(body.model || "auto"),
|
|
finalModelId: String(body.model || "auto"),
|
|
models,
|
|
});
|
|
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
|
|
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
|
|
}
|
|
const quota = await checkUserImageQuota(session.user.id);
|
|
if (!quota.allowed) {
|
|
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });
|
|
}
|
|
const requestedImageCount = references.filter((item) => !item.passthrough).length;
|
|
if (quota.limit != null && quota.used + requestedImageCount > quota.limit) {
|
|
return NextResponse.json({ error: `本月剩余生图配额不足 ${requestedImageCount} 张` }, { status: 429 });
|
|
}
|
|
const pointsConfig = await getPointsSystemConfig();
|
|
if (pointsConfig.enabled && pointsConfig.blockOnInsufficient !== false) {
|
|
const selectedModel = models.find((entry) => entry.model.id === model)?.model;
|
|
const unitCost = getModelGenerationCreditCost(
|
|
selectedModel as Parameters<typeof getModelGenerationCreditCost>[0], "image", {
|
|
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
|
});
|
|
const user = await getUserById({ id: session.user.id });
|
|
const expectedCost = unitCost * requestedImageCount;
|
|
if (!user?.isHighVip && expectedCost > Number(user?.pointsBalance || 0)) {
|
|
return NextResponse.json({ error: `积分不足,本批次预计消耗 ${expectedCost} 积分` }, { status: 402 });
|
|
}
|
|
}
|
|
const chatId = scopedChatId(projectId);
|
|
const existingChat = await getChatById({ id: chatId });
|
|
if (existingChat && existingChat.userId !== session.user.id) {
|
|
return NextResponse.json({ error: "无权访问项目对话" }, { status: 403 });
|
|
}
|
|
if (!existingChat) await saveChat({
|
|
id: chatId, userId: session.user.id, title: project.title || "详情替换", visibility: "private",
|
|
});
|
|
const taskRequest: DetailReplacementBatchRequest = {
|
|
taskKind: "agent-detail-replacement-batch",
|
|
projectId,
|
|
chatId,
|
|
model,
|
|
prompt: strategy === "product_only"
|
|
? "仅替换参考详情图中的产品主体"
|
|
: buildAgentSlotPrompt({
|
|
workflowPrompt: prompt,
|
|
slotPrompt: "",
|
|
userSupplementPrompt: normalizeAgentSupplementPrompt(
|
|
String(body.userPrompt || ""), "详情参考图替换",
|
|
),
|
|
includeSlotTitle: false,
|
|
modeType: "detail_replacement",
|
|
}),
|
|
userPrompt: String(body.userPrompt || "").slice(0, 12_000),
|
|
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
|
replacementStrategy: strategy,
|
|
productImageUrls,
|
|
modelImageUrl: /^https?:\/\//i.test(String(body.modelImageUrl || ""))
|
|
? String(body.modelImageUrl) : null,
|
|
references,
|
|
};
|
|
const submissionKey = detailReplacementSubmissionKey(taskRequest);
|
|
taskRequest.submissionKey = submissionKey;
|
|
|
|
const duplicateId = await findActiveBatchId({
|
|
userId: session.user.id, projectId, submissionKey,
|
|
});
|
|
if (duplicateId) {
|
|
await ensureCanvasImageTaskActive({ taskId: duplicateId, userId: session.user.id });
|
|
const duplicate = await getCanvasImageTaskForUser({ taskId: duplicateId, userId: session.user.id });
|
|
return NextResponse.json({ task: taskResponse(duplicate) });
|
|
}
|
|
|
|
let task: Awaited<ReturnType<typeof createCanvasImageTask>>;
|
|
try {
|
|
task = await createCanvasImageTask({
|
|
taskId, userId: session.user.id, request: taskRequest as unknown as Record<string, unknown>,
|
|
});
|
|
} catch (error) {
|
|
if (!isUniqueViolation(error)) throw error;
|
|
// Two identical submissions raced past the lookup above; the database
|
|
// unique index kept one of them, so answer with that batch.
|
|
const racedId = await findActiveBatchId({
|
|
userId: session.user.id, projectId, submissionKey,
|
|
});
|
|
if (!racedId) throw error;
|
|
const raced = await getCanvasImageTaskForUser({ taskId: racedId, userId: session.user.id });
|
|
return NextResponse.json({ task: taskResponse(raced) });
|
|
}
|
|
const existingTask = await getCanvasImageTaskForUser({ taskId: task.id, userId: session.user.id });
|
|
const storedRequest = existingTask?.request as DetailReplacementBatchRequest | null;
|
|
if (
|
|
!isDetailReplacementBatchRequest(storedRequest) ||
|
|
storedRequest.projectId !== projectId ||
|
|
(storedRequest.submissionKey && storedRequest.submissionKey !== submissionKey)
|
|
) {
|
|
return NextResponse.json({ error: "任务编号已被其他请求使用" }, { status: 409 });
|
|
}
|
|
await db.insert(message).values({
|
|
id: taskId,
|
|
chatId,
|
|
role: "user",
|
|
parts: [{ type: "text", text: taskRequest.userPrompt || `批量替换 ${references.length} 张详情图` }],
|
|
attachments: [],
|
|
createdAt: new Date(),
|
|
}).onConflictDoNothing({ target: message.id });
|
|
await enqueueCanvasImageTask(task.id);
|
|
const current = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
|
|
return NextResponse.json({ task: taskResponse(current) });
|
|
} catch (error) {
|
|
console.error("[Detail Replacement Batch] Failed to start", error);
|
|
return NextResponse.json({ error: error instanceof Error ? error.message : "提交详情替换任务失败" }, { status: 500 });
|
|
}
|
|
}
|