From f75d99baf6c8f1ef70a84d0c6cacaac36a1fae57 Mon Sep 17 00:00:00 2001 From: 134 Date: Sun, 27 Sep 2026 19:14:56 +0800 Subject: [PATCH] fix(agent): repair the UUID check that made every detail replacement request 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增的批次接口把一个 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 正则 - 前端在项目尚未保存时给出明确提示,而不是把空编号丢给接口 --- .../detail-replacement/batches/route.ts | 11 +++--- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 4 +++ lib/agents/detail-replacement-batch.ts | 12 +++++++ tests/routes/detail-replacement-batch.test.ts | 34 +++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/app/api/agents/detail-replacement/batches/route.ts b/app/api/agents/detail-replacement/batches/route.ts index 1e562c463..cce8997aa 100644 --- a/app/api/agents/detail-replacement/batches/route.ts +++ b/app/api/agents/detail-replacement/batches/route.ts @@ -27,6 +27,7 @@ import { import { detailReplacementSubmissionKey, isDetailReplacementBatchRequest, + isDetailReplacementUuid, type DetailReplacementBatchReference, type DetailReplacementBatchRequest, type DetailReplacementBatchResult, @@ -34,8 +35,6 @@ import { export const maxDuration = 1200; -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - function scopedChatId(projectId: string) { const normalized = projectId.toLowerCase().replace(/-/g, ""); const scoped = `${normalized.slice(0, 20)}a93e17a60001`; @@ -90,7 +89,9 @@ export async function GET(request: Request) { const params = new URL(request.url).searchParams; const projectId = String(params.get("projectId") || "").trim(); const taskId = String(params.get("taskId") || "").trim(); - if (!UUID_RE.test(projectId)) return NextResponse.json({ error: "项目编号无效" }, { status: 400 }); + 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); @@ -100,7 +101,7 @@ export async function GET(request: Request) { .from(canvasImageTask) .where(and( eq(canvasImageTask.userId, session.user.id), - ...(taskId && UUID_RE.test(taskId) ? [eq(canvasImageTask.id, taskId)] : []), + ...(isDetailReplacementUuid(taskId) ? [eq(canvasImageTask.id, taskId)] : []), sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`, sql`${canvasImageTask.request}->>'projectId' = ${projectId}`, )) @@ -129,7 +130,7 @@ export async function POST(request: Request) { const body = await request.json(); const projectId = String(body.projectId || "").trim(); const taskId = String(body.requestId || "").trim(); - if (!UUID_RE.test(projectId) || !UUID_RE.test(taskId)) { + if (!isDetailReplacementUuid(projectId) || !isDetailReplacementUuid(taskId)) { return NextResponse.json({ error: "任务编号或项目编号无效" }, { status: 400 }); } const [project] = await db.select().from(canvasProject).where(and( diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 60e0e7517..9ea0a2d7c 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -6349,6 +6349,10 @@ export function AgentGenerationWorkspaceDarkV2({ } if (replacementModeType === "detail_replacement") { if (detailBatchSubmittingRef.current) return; + if (!isValidChatUuid(projectId)) { + toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换"); + return; + } const productImageUrls = Array.from(new Set( (detailReplacementProductSourceImages.length > 0 ? detailReplacementProductSourceImages.map((item) => item.url) diff --git a/lib/agents/detail-replacement-batch.ts b/lib/agents/detail-replacement-batch.ts index 01daf6ad5..2f3ef95c3 100644 --- a/lib/agents/detail-replacement-batch.ts +++ b/lib/agents/detail-replacement-batch.ts @@ -1,5 +1,17 @@ import { createHash } from "node:crypto"; +const DETAIL_REPLACEMENT_UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Every id this feature exchanges - project, chat, task and request ids - is a + * canonical 8-4-4-4-12 UUID. Keep the check in one place so the API route and + * its tests can never drift apart again. + */ +export function isDetailReplacementUuid(value: unknown): value is string { + return typeof value === "string" && DETAIL_REPLACEMENT_UUID_PATTERN.test(value.trim()); +} + export type DetailReplacementBatchReference = { id: string; label: string; diff --git a/tests/routes/detail-replacement-batch.test.ts b/tests/routes/detail-replacement-batch.test.ts index 237a736b8..62817c078 100644 --- a/tests/routes/detail-replacement-batch.test.ts +++ b/tests/routes/detail-replacement-batch.test.ts @@ -4,8 +4,11 @@ import { detailReplacementCardMessageId, detailReplacementSubmissionKey, isDetailReplacementBatchRequest, + isDetailReplacementUuid, type DetailReplacementBatchResult, } from "@/lib/agents/detail-replacement-batch"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; test("recognizes only durable detail replacement batches", () => { expect(isDetailReplacementBatchRequest({ taskKind: "agent-detail-replacement-batch" })).toBe(true); @@ -69,3 +72,34 @@ test("re-running a single card while a batch runs is not treated as a duplicate" prompt: "换一个背景", })).not.toBe(full); }); + +test("accepts the canonical 8-4-4-4-12 UUIDs the API receives", () => { + // Real project ids taken from production requests that returned 400 in the field. + expect(isDetailReplacementUuid("b1fa322a-dc36-49a0-9d07-8f06ffdab680")).toBe(true); + expect(isDetailReplacementUuid("15f4bdc7-2487-4926-bf38-87df559aea6a")).toBe(true); + // Browser generated request ids look like this. + expect(isDetailReplacementUuid("3f2504e0-4f89-41d3-9a0c-0305e82c3301")).toBe(true); + // The scoped chat id the route derives for a batch project is a UUID too. + expect(isDetailReplacementUuid("b1fa322a-dc36-49a0-9d07-a93e17a60001")).toBe(true); +}); + +test("rejects malformed ids, including the 8-4-4-12 shape that broke polling", () => { + expect(isDetailReplacementUuid("")).toBe(false); + expect(isDetailReplacementUuid(null)).toBe(false); + expect(isDetailReplacementUuid(undefined)).toBe(false); + // Missing the fourth group: this is exactly the bug that made every request 400. + expect(isDetailReplacementUuid("b1fa322a-dc36-49a0-8f06ffdab680")).toBe(false); + expect(isDetailReplacementUuid("b1fa322adc3649a09d078f06ffdab680")).toBe(false); + expect(isDetailReplacementUuid("b1fa322a-dc36-49a0-9d07-8f06ffdab680-extra")).toBe(false); +}); + +test("the batch route keeps no stale UUID regex of its own", () => { + const sources = [ + "app/api/agents/detail-replacement/batches/route.ts", + "lib/agents/detail-replacement-batch-runner.ts", + ].map((file) => readFileSync(resolve(process.cwd(), file), "utf8")); + for (const source of sources) { + // A local 8-4-4-12 literal is the typo this feature shipped with. + expect(source).not.toMatch(/\^\[0-9a-f\]\{8\}-\[0-9a-f\]\{4\}-\[0-9a-f\]\{4\}-\[0-9a-f\]\{12\}\$/); + } +});