ai-chatbot/lib/agents/detail-replacement-batch.ts
134 f75d99baf6 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 正则
- 前端在项目尚未保存时给出明确提示,而不是把空编号丢给接口
2026-09-27 19:14:56 +08:00

118 lines
3.7 KiB
TypeScript

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;
imageUrl: string;
candidateId?: string | null;
passthrough?: boolean;
rerunReferenceImages?: Array<{ url: string }>;
};
export type DetailReplacementBatchRequest = {
taskKind: "agent-detail-replacement-batch";
projectId: string;
chatId: string;
submissionKey?: string;
model: string;
prompt: string;
userPrompt: string;
imageSize: string;
replacementStrategy: "product_only" | "scene_replace";
productImageUrls: string[];
modelImageUrl: string | null;
references: DetailReplacementBatchReference[];
};
export type DetailReplacementBatchCard = {
cardId: string;
status: "completed" | "fallback" | "passthrough";
imageUrl: string;
thumbnailUrl: string;
text: string;
error?: string;
};
export type DetailReplacementBatchResult = {
taskKind: "agent-detail-replacement-batch";
cards: DetailReplacementBatchCard[];
currentCardId: string | null;
};
export function isDetailReplacementBatchRequest(
value: unknown,
): value is DetailReplacementBatchRequest {
return Boolean(
value &&
typeof value === "object" &&
!Array.isArray(value) &&
(value as { taskKind?: unknown }).taskKind === "agent-detail-replacement-batch",
);
}
export function completedDetailReplacementCardIds(
result: DetailReplacementBatchResult | null | undefined,
) {
return new Set(
(result?.cards || [])
.filter((card) => card.status === "completed" || card.status === "passthrough")
.map((card) => card.cardId),
);
}
/**
* Identity of one user submission. Two clicks that ask for exactly the same
* work produce the same key, so the second one is answered with the running
* batch instead of submitting (and billing) the same images twice. A different
* card selection - for example re-running a single finished card while a batch
* is still going - produces a different key and is allowed to start.
*/
export function detailReplacementSubmissionKey(input: {
projectId: string;
model: string;
prompt: string;
userPrompt: string;
imageSize: string;
replacementStrategy: string;
productImageUrls: string[];
modelImageUrl: string | null;
references: DetailReplacementBatchReference[];
}) {
const payload = JSON.stringify({
projectId: input.projectId,
model: input.model,
prompt: input.prompt,
userPrompt: input.userPrompt,
imageSize: input.imageSize,
strategy: input.replacementStrategy,
products: [...input.productImageUrls].sort(),
modelImage: input.modelImageUrl || "",
cards: [...input.references]
.map((item) => [
item.id,
item.imageUrl,
item.passthrough ? 1 : 0,
(item.rerunReferenceImages || []).map((entry) => entry.url).join(","),
])
.sort((left, right) => String(left[0]).localeCompare(String(right[0]))),
});
return createHash("sha256").update(payload).digest("hex");
}
export function detailReplacementCardMessageId(taskId: string, cardId: string) {
const hex = createHash("sha256").update(`${taskId}:${cardId}`).digest("hex").slice(0, 32);
return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32)].join("-");
}