fix(agent): repair the UUID check that made every detail replacement request 400

新增的批次接口把一个 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 正则
- 前端在项目尚未保存时给出明确提示,而不是把空编号丢给接口
This commit is contained in:
134 2026-09-27 19:14:56 +08:00
parent db3a038b00
commit f75d99baf6
4 changed files with 56 additions and 5 deletions

View File

@ -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(

View File

@ -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)

View File

@ -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;

View File

@ -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\}\$/);
}
});