diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 76f1e0791..254b8f475 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5970,4 +5970,5 @@ - 决策:未知结果先 `loadEditorProject` 取权威快照,再按占位是否存活分流。占位已被 completion 消费掉说明这次其实成功,按快照收口并写入正常的 `perfect-pixel` 历史,不报错。占位仍在说明画布没收到结果,同步快照消除本地与服务端偏差但**不写历史**,文案明确告知结果未知且素材库可能已有派生图、要求用户先核对再决定是否重试——持久化是非事务的,OSS 对象与账号素材可能已落库而画布回填未完成。对账 GET 本身失败时给出「权威快照读取失败」的独立文案,不退回谎报。 - 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口。占位在 POST 前已由 `flushProjectPersistence` 落库,`hydrateCanvasGenerationDialog` 原样恢复 `generating`,而该链路不进 `external_generation_job`,任务侧栏轮询看不到它,inline POST 的 Promise 随旧页面销毁。需要在 hydration 后加对账,且要先给 dialog 增加「属于无 durable job 的 inline 链路」标记,改动面大于本次,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,调小可让对账更早发生,未处理。 - 验证:新增三条用例分别覆盖「未知但实际成功→按快照收口并写历史」「未知且占位存活→只同步快照、标失败、文案要求先核对」「`ApiClientError` 已知失败→不发对账 GET、不动快照」。既有用例 `keeps a failed perfect-pixel placeholder` 原本用裸 `Error` 表达「服务端识别不到网格」,语义不准且会误入对账路径,改为 `ApiClientError`。测试 harness 新增 `dialog-error` 输出,否则对账文案不可观测。 +- 补充(同日):对账只对真正发出过 POST 的失败生效。占位创建、源图解析和 `flushProjectPersistence` 都在 POST 之前,它们失败时请求根本没发出,此时给出「素材库可能已存在派生图」是反向谎报,与本条要修的谎报是镜像关系;用 `perfectPixelPostAttempted` 标记划界,同时省掉一次无意义的权威读取。占位存活分支也不再调用 `applyProjectSnapshot`:传给该 hook 的是 `ImageCanvasEditorView` 的 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致(占位都在),套用只会覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定,不是同步。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 861d8b949..90b78833a 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -2361,8 +2361,10 @@ describe('useImageCanvasGenerationWorkflow', () => { await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1'); }); - // 中文注释:只同步快照,不带历史动作——这次没有成功。 - expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject); + // 中文注释:判定用途,不做同步。传进来的 applyProjectSnapshot 是 + // applyGeneratedProjectSnapshot,不传 action 会写一条类型错误且受撤销保护的 + // generate-image 历史;而权威快照此刻与本地一致,套用只会覆盖未保存编辑。 + expect(applyProjectSnapshot).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain('failed'); }); @@ -2374,6 +2376,39 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('does not reconcile when the perfect-pixel request never left the client', async () => { + // 中文注释:占位创建、源图解析和 flush 都在 POST 之前。它们失败时请求根本没发出, + // 说「素材库可能已存在派生图」是反向谎报,也不该白打一次权威读取。 + const applyProjectSnapshot = vi.fn(); + const flushProjectPersistence = vi + .fn() + .mockRejectedValueOnce(new Error('画布保存失败')); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toContain('failed'); + }); + expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); + expect(loadEditorProjectMock).not.toHaveBeenCalled(); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + expect(screen.getByTestId('dialog-error').textContent).toBe('画布保存失败'); + }); + it('keeps a rejected perfect-pixel request out of the reconciliation path', async () => { // 中文注释:服务端明确响应过(ApiClientError)就是已知结果,不需要也不应该 // 再发对账 GET,否则每个 400 都要多打一次权威读取。 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 8354ad067..84a68d128 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -1783,6 +1783,7 @@ export function useImageCanvasGenerationWorkflow({ setCharacterAnimationPanel(null); let perfectPixelDialogId: string | undefined; + let perfectPixelPostAttempted = false; try { const assetLabel = `${sourceLayer.title} · 完美像素`; const placement = openPlacedCanvasGenerationDialog({ @@ -1810,6 +1811,7 @@ export function useImageCanvasGenerationWorkflow({ ); await flushProjectPersistence(); const sourceResourceId = sourceLayer.resourceId.trim(); + perfectPixelPostAttempted = true; const result = await snapImageToPerfectPixels({ sourceImageSrc, projectId: normalizedProjectId, @@ -1855,7 +1857,11 @@ export function useImageCanvasGenerationWorkflow({ // project resource、账号素材和画布回填,只是响应没回来。该路由是 unsafe // POST 且禁用自动重放,所以未知时必须先 GET 权威快照对账再决定文案:直接 // 标 failed 会谎报结果,用户重试就再造一份对象、资源和素材。 - const outcomeIsUnknown = !(error instanceof ApiClientError); + // 中文注释:只有真正发出过 POST 才谈得上「结果未知」。占位创建、源图解析和 + // flush 都在 POST 之前,它们失败时请求根本没发出去,此时说「素材库可能已存在 + // 派生图」是反向谎报,和这次要修的那个谎报是镜像关系。 + const outcomeIsUnknown = + perfectPixelPostAttempted && !(error instanceof ApiClientError); let reconciledMessage: string | undefined; if (outcomeIsUnknown && perfectPixelDialogId) { const reconciled = await loadEditorProject(normalizedProjectId).catch( @@ -1879,10 +1885,13 @@ export function useImageCanvasGenerationWorkflow({ setActiveSidebarPanel('layers'); return; } - // 中文注释:占位仍在,画布没收到结果。先同步权威快照消除本地与服务端的 - // 偏差,但不写历史——这次没有成功。持久化是非事务的,OSS 对象与账号素材 - // 仍可能已经落库,所以文案必须让用户先去核对而不是直接重试。 - applyProjectSnapshot(reconciled); + // 中文注释:占位仍在,画布没收到结果。这里刻意不调 applyProjectSnapshot: + // 传给本 hook 的是 ImageCanvasEditorView 的 applyGeneratedProjectSnapshot, + // 它的 action 默认值是 `generate-image`,不传 action 会写一条类型错误且受 + // 撤销保护的历史;而权威快照此刻和本地状态一致(占位都在),套用它只会白白 + // 覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定而不是同步。 + // 持久化是非事务的,OSS 对象与账号素材仍可能已落库,所以文案必须让用户先 + // 去核对而不是直接重试。 reconciledMessage = '完美像素结果未知:已核对权威快照,画布未收到结果。素材库可能已存在派生图,请先确认再决定是否重试。'; } else {