diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5b6b35fe6..ffbf08631 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6093,7 +6093,7 @@ - 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items` 置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。 - 逃过测试的原因要单独记:那条「未知但实际成功」的用例夹具写的是 `layers: []`,是服务端永远不会产生的形状。**测试不是漏了,是主动为错误判据背书**——用一个假前提把反向逻辑测成了正确的。修复顺序因此定为「先改夹具、看它变红、再改判据」,让这件事显式暴露一次而不是被新判据顺手掩盖。 -- 决策一:判据改看 `status` / `generatedLayerId`,并复用既有语义。`projectHasUnresolvedGenerationDialog` 早就是本仓库对「这个生成收口了没有」的定义,只是原先埋在队列轮询里;提取为 `findCanvasGenerationDialogRecord` + `isUnresolvedCanvasGenerationDialogRecord` 两处共用,不自创新判据——自创正是本次出错的起点。取原始 record 而不 hydrate:hydrate 会给缺失 status 补 `idle`,把「服务端没写」和「服务端写了 idle」混成一种。 +- 决策一:判据改看 `status` / `generatedLayerId`,并复用既有语义。`projectHasUnresolvedGenerationDialog` 早就是本仓库对「这个生成收口了没有」的定义,只是原先埋在队列轮询里;原始 record 查询现由收集全部同 ID 记录的 `findCanvasGenerationDialogRecords` 与 `isUnresolvedCanvasGenerationDialogRecord` 两处共用,不自创新判据——自创正是本次出错的起点。取原始 record 而不 hydrate:hydrate 会给缺失 status 补 `idle`,把「服务端没写」和「服务端写了 idle」混成一种。 - 三态处置:dialog 不存在 → 占位在处理期间被删(本会话或另一标签页),completion 返回 `Ok(None)`,资源与素材已落库但快照不含结果图层,清本地占位并提示素材库,**不套用快照**(此前会套用一份不含结果的快照并写受撤销保护的历史,用户既看不到结果也撤不回);dialog 在且未收口 → 画布确实没收到,只给文案不同步;dialog 在且已收口 → 真成功,套用快照并写 `perfect-pixel` 历史。前两态都保留「用户已删本地占位则删除意图胜出」的检查。 - 缺陷二(网关合成响应被当确定失败):分类前提是「拿到 `ApiClientError` ⇒ 服务端明确表态过 ⇒ 结果已知」。该前提对 Pingora 自造的错误体不成立——它只有 `code` / `message`、没有 `details`,因此既不是 transport 异常也拿不到 `resultPersistenceStarted`,直接跳过对账;而 `ConnectTimedout / ReadTimedout / WriteTimedout → 504`、`ErrorSource::Upstream → 502` 都可能发生在 api-server 已完成 OSS PUT 之后。 - 决策二:新增 `isGatewayUnknownOutcomeError`,放在 `services/apiClient.ts` 而不是 image-editor——网关在所有接口前面,任何有副作用的 inline 写接口都有同一问题。只收 `GATEWAY_UPSTREAM_ERROR` / `GATEWAY_UPSTREAM_TIMEOUT` / `GATEWAY_PROXY_ERROR` 三类。`GATEWAY_RATE_LIMITED` / `GATEWAY_CONCURRENCY_LIMITED` / `PAYLOAD_TOO_LARGE` 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」,变成与本条镜像的反向谎报。 @@ -6221,3 +6221,10 @@ - 时序边界:`claim` 仍紧挨占位创建且早于任何 await;`release` 仍位于 `finally`。version 只负责 React 通知,不替代同步 Set,也不清理 observed recovery key;否则可能在 live Promise 尚未退出时启动第二条 GET。重复 claim / release 为幂等 no-op,不额外触发 effect。 - 验证:hook 定向测试使用生产 ownership hook、固定 dialogs 数组和固定 callbacks,并包在 StrictMode 中;单次 render 后先 claim 取消到期 timer,推进到超窗仍不清理,再仅 release 唤醒 effect并清理一次。重复 claim / release 分别保持 version `1 / 2`,删除与通知均只发生一次。hook 定向测试 10 条、generation workflow 定向测试 81 条通过;`npm run typecheck`、全仓 `npm run lint:eslint`、`npm run check:encoding` 与 `git diff --check` 通过。未追加其它测试或全量测试套件。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 项目快照对账不再按重复 dialog ID 首项短路 + +- 缺陷:项目快照查询 helper 只返回第一个同 ID generation dialog。legacy / External API 画布若含重复 ID,首条已收口、后条仍 unresolved 时,通用 queued completion 会漏掉用于收口的第二次 GET;完美像素还可能把不满足后端唯一性契约的快照误判为成功。 +- 决策:快照 helper 返回全部同 ID 原始记录。通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不按其中任意一条猜测结果。无需改变 hydrate、删除、后端或 OpenAPI。 +- 验证:两条 `duplicate` 定向用例通过;`npm run typecheck`、改动文件级 ESLint 与 `npm run check:encoding` 通过,未运行目录或全仓测试套件。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index c60ce384e..02474385e 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -55,6 +55,7 @@ - `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 - unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`,普通删除和随源图层清理不得移除该 operation;用户只能继续 GET 对账或显式按原 identity 重放。人工重试只刷新观察窗口,POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。 - 完美像素并发闸回归测试不得通过进程级队列 Atomic 的 before/after 判断“本用例未入队”。过期 deadline 用例只断言 `504`;queue guard 的 Drop 归还由独立用例覆盖,不引入 `--test-threads=1`、全局串行锁或其它串行化兜底。 +- 项目快照对账生成占位时必须检查全部同 ID 原始记录,不得用首项短路:通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不得按首条记录猜测成功。 ### 角色动作帧抠图像素边界 diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index b147abf67..d693e20d0 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -791,19 +791,20 @@ function isCanvasSettingsLayoutItem( } /** - * 中文注释:从快照里取出指定 id 的生成占位原始记录,取不到返回 null。 + * 中文注释:从快照里取出指定 id 的全部生成占位原始记录,取不到返回空数组。 * * 用原始 record 而不是 hydrate:调用方要判的是服务端写了什么,hydrate 会给缺失字段补默认值 * (例如 status 缺失时补 `idle`),把「服务端没写」和「服务端写了 idle」混成一种。 */ -export function findCanvasGenerationDialogRecord( +export function findCanvasGenerationDialogRecords( project: EditorProjectSnapshot, dialogId: string | null | undefined, -): Record | null { +): Record[] { const normalizedDialogId = dialogId?.trim(); if (!normalizedDialogId) { - return null; + return []; } + const matchingDialogs: Record[] = []; for (const item of project.layers) { if (item.itemType !== 'generation-dialog') { continue; @@ -814,10 +815,10 @@ export function findCanvasGenerationDialogRecord( ? ((item as { dialog?: unknown }).dialog as Record) : null; if (dialog?.id === normalizedDialogId) { - return dialog; + matchingDialogs.push(dialog); } } - return null; + return matchingDialogs; } /** diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 2f11c8537..8f8759f7a 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -2009,7 +2009,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); - it('reloads a queued project snapshot again when the completion dialog is still unresolved', async () => { + it('reloads a queued project snapshot when a later duplicate completion dialog is unresolved', async () => { vi.useFakeTimers(); try { const applyProjectSnapshot = vi.fn(); @@ -2020,6 +2020,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { title: '队列项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ + { + layerId: 'generation-dialog-background-removal-completed', + resourceId: 'generation-dialog-background-removal-completed', + itemType: 'generation-dialog', + dialog: { + id: 'dialog-background-removal', + status: 'idle', + generatedLayerId: 'layer-background-removal-result', + }, + }, { layerId: 'generation-dialog-background-removal', resourceId: 'generation-dialog-background-removal', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 55481e706..e1b3bd122 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -30,7 +30,7 @@ import { loadEditorProject, } from '../../services/image-editor/editorProjectClient'; import { - findCanvasGenerationDialogRecord, + findCanvasGenerationDialogRecords, isUnresolvedCanvasGenerationDialogRecord, } from './ImageCanvasEditorModel'; import type { @@ -456,8 +456,8 @@ function projectHasUnresolvedGenerationDialog( project: EditorProjectSnapshot, dialogId: string | null | undefined, ) { - return isUnresolvedCanvasGenerationDialogRecord( - findCanvasGenerationDialogRecord(project, dialogId), + return findCanvasGenerationDialogRecords(project, dialogId).some( + isUnresolvedCanvasGenerationDialogRecord, ); } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index b76b2390f..2f4da4f58 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1254,6 +1254,34 @@ describe('useImageCanvasGenerationWorkflow', () => { ).toMatchObject({ kind: 'pending', project }); }); + it('rejects duplicate perfect-pixel dialog ids even when the first record appears applied', () => { + const operationId = 'perfect-pixel-duplicate-dialog'; + const project = createPerfectPixelProject(operationId, 'applied'); + project.layers.push({ + itemType: 'generation-dialog', + layerId: `generation-dialog:${operationId}:duplicate`, + resourceId: `generation-dialog:${operationId}:duplicate`, + dialog: { + id: operationId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + composerOpen: false, + }, + }); + + expect( + inspectPerfectPixelProjectSnapshot(project, { + operationId, + taskId: `pixel-art-snap-${operationId}`, + }), + ).toMatchObject({ + kind: 'conflict', + project, + message: '权威项目中存在重复的完美像素占位,无法自动确认结果。', + }); + }); + it('requires a matching task resource before a missing dialog can prove asset-only success', () => { const operationId = 'perfect-pixel-missing-dialog'; const matchingProject = createPerfectPixelProject( diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 2d947bd55..6f9ba8022 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -29,7 +29,7 @@ import { } from '../../services/image-editor/editorProjectClient'; import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel'; import { - findCanvasGenerationDialogRecord, + findCanvasGenerationDialogRecords, isUnresolvedCanvasGenerationDialogRecord, PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, } from './ImageCanvasEditorModel'; @@ -267,7 +267,7 @@ export function inspectPerfectPixelProjectSnapshot( const matchingResources = project.resources.filter( (resource) => resource.taskId?.trim() === operation.taskId, ); - const dialog = findCanvasGenerationDialogRecord( + const matchingDialogs = findCanvasGenerationDialogRecords( project, operation.operationId, ); @@ -279,6 +279,14 @@ export function inspectPerfectPixelProjectSnapshot( message: '权威项目中存在重复的完美像素任务资源,无法自动确认结果。', }; } + if (matchingDialogs.length > 1) { + return { + kind: 'conflict', + project, + message: '权威项目中存在重复的完美像素占位,无法自动确认结果。', + }; + } + const dialog = matchingDialogs[0] ?? null; const resource = matchingResources[0] ?? null; if (!resource) { if (!dialog || isUnresolvedCanvasGenerationDialogRecord(dialog)) {