diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index dbc2d7612..876a84c2d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6387,8 +6387,22 @@ - 背景:完美像素是唯一没有 durable job 的生成路径——免费、同步、不走 `enqueue_editor_generation_job`,服务端没有任何一行记录「这次请求发出过」。为了让刷新后还能 GET-only 对账,请求账本 `perfectPixelOperation` 被写进了**用户的画布布局**,并由此派生出一条严格布局保存通道:发 POST 前必须拿到布局保存的 revision ack,否则整条链路中止。该耦合直接造成两类缺陷:一是账本寄生在用户数据上,占位一度被禁止删除(已由同日「完美像素占位恢复为可删除」作废);二是任何布局校验失败都会升级成完美像素的硬阻断,「画布图层元数据以资源行为准」那条缺陷正是因为严格保存才从静默重试变成用户可见的死锁。 - 决策:账本改由 `src/components/image-editor/perfectPixelOperationStore.ts` 存在本机 localStorage,按 owner + project 双键隔离;布局里只留 `perfectPixelOperationId` 标记,用来把这类占位与队列型占位区分开。发 POST 前先同步写本机账本,再**尽力而为**地保存布局;布局保存失败不再拦 POST,只是把结果降级为「只进素材库」。严格布局保存通道(`strictCompletion` 全套机制、`flushProjectPersistence` 的 `requireSuccess` / `requiredDialogId` / `deadlineAt` 选项、`PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS`)整体删除,只保留一个不改变失败语义的 `preferLatestGenerationDialogs`,用于取到刚创建、尚未回流到 ref 的占位。 - 为什么这样仍然安全:本机写入是同步的、不过网络、不受服务端校验影响,因此它能提供严格布局保存想提供的那个保证——「请求可被追溯」——却不引入任何阻断点。由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清账本。 -- **本机账本是明确设计,缺失只降级、不得构成阻断**:换设备、换浏览器、清缓存、隐私模式、配额写满都会读不到账本。这些情况下带标记的占位一律 hydrate 成 `failed + perfectPixelOperationInvalid` 的**可删除**占位,用户删掉重来即可。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。 +- **本机账本是明确设计,缺失只降级、不得构成阻断**:换设备、换浏览器、清缓存、隐私模式、配额写满都会读不到账本。这些情况下带标记的**未收口**占位一律 hydrate 成 `failed + perfectPixelOperationInvalid` 的**可删除**占位,用户删掉重来即可(收口态占位不适用,见下一条决策)。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。 - 兼容与生命周期:布局内联账本是 legacy 形状,hydrate 侧继续认,滚动部署期间的在途操作不会被一次性判死;写入侧不再产生新的内联账本。本机账本按 7 天保留期与 32 条上限裁剪,终态(applied / dialog-missing / 快照与项目不匹配 / 无占位可挂错误)立即清除。读取沿用与布局快照相同的 v1 白名单校验,任何字段漂移失败关闭,绝不据一份可疑账本重放 POST。 - 影响范围:新增 `perfectPixelOperationStore.ts`;`ImageCanvasEditorTypes.ts` 新增 `perfectPixelOperationId`;`ImageCanvasEditorModel.ts` 的 `serializeDialogReferences` / `hydrateCanvasGenerationDialog` / `splitCanvasLayoutItems` / `dropDeadInlineGenerationPlaceholders`;`useImageCanvasProjectPersistence.ts` 删除严格保存机制并在 hydrate 时读账本;`useImageCanvasGenerationWorkflow.ts` 的提交、重试与恢复 effect;`useImageCanvasGenerationSurface.tsx` 的 props 类型。不修改服务端、SpacetimeDB schema 或对外契约——服务端从来不认识这个字段。 - 验证方式:账本单测覆盖往返、owner / project 隔离、跨账号整条丢弃、被篡改条目失败关闭、保留期与条数裁剪、终态清除、以及存储不可用时静默降级;模型层覆盖「布局只留标记且不含源图地址」「标记在而账本缺失时收口为可删除失败态」「账本 id 与占位不符时失败关闭」;工作流覆盖「布局保存失败仍照发 POST 并保留可重试的 operation」与「孤儿账本条目照常对账并在终态清账本」;持久化层覆盖「布局保存 400 / 403 与缺 authority 时 flush 均不抛、下游照常执行」。运行 `npx vitest run src/components/image-editor`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 账本寿命短于标记寿命:收口态不需要账本,孤儿账本不看对账窗口 + +- 背景:上一条把请求账本移到本机后引入了一条判据——「布局里有 `perfectPixelOperationId` 标记、本机没有账本 ⇒ 该占位无效」。这条判据按构造就是错的,因为两者寿命根本不对称:标记写进布局后**寿命无限**(服务端完成 completion 时只做字段级改写,置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清 `errorMessage`,从不摘掉标记,见 `editor_project_storage.rs` 的 `plan_editor_pixel_art_canvas_layout`),而账本**寿命很短**(收口即清、75 秒对账窗口、7 天保留期、换设备即无)。账本消失是正常终态,不是异常。同一个不对称还以第二种形态出现在恢复 effect 里:孤儿账本按 `reconcileUntil` 短路。 +- 缺陷一(每一次成功都被判成失败):`settleLivePerfectPixelVerdict` 在 applied 终态先清账本,紧接着 `applyProjectSnapshot` 用真实 hydrate 重新套用权威快照;此时布局里标记还在、账本已清,于是成功结果被判成 `failed + perfectPixelOperationInvalid`,用户点开刚生成的图层会看到「操作快照无效」。更糟的是这个状态会被下一次自动保存序列化回服务端,覆盖服务端正确的 `idle`;`perfectPixelOperationInvalid` 一旦落库,此后单凭它就能强制 `failed`,**自我固化**。历史上早已完成的完美像素占位(当时带内联账本)在标记化改写后同样中招。 +- 缺陷二(兜底分支在唯一目标场景下失效):孤儿账本对账是「删掉严格布局保存仍然安全」的全部依据,目标场景是「POST 已发、布局没落盘、浏览器关闭、稍后重开」——而重开几乎必然晚于 75 秒对账窗口,`operation.reconcileUntil <= now` 的短路让这条分支基本永不生效,条目还会在本机躺满整个保留期反复被跳过。 +- 决策一:凡是「缺账本 ⇒ 无效」的判据,一律先排除**收口态**。收口的定义复用既有的 `isUnresolvedCanvasGenerationDialogRecord` 取反:带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`。收口态占位不需要账本——`generatedLayerId` 本身就是服务端已回填的证据;它同时**无条件忽略**已落库的 `perfectPixelOperationInvalid` 标记与残留 `errorMessage`,并把状态强制归位到 `idle`,让被上一版写脏的行在下一次 hydrate 时自愈。写边界同步收窄:`serializeDialogReferences` 对收口态占位不再输出 `perfectPixelOperationId`,让标记的寿命与账本对齐,不再在布局里堆积。 +- 决策二:孤儿账本**不按 `reconcileUntil` 短路**。`reconcileUntil` 的语义是「结果可能还在飞,值得多读几次」,而孤儿是上一个会话留下的、发出它的标签页早已不在,需要的是一次能回答「到底落没落」的确定性读,不是轮询。为此新增 `readPerfectPixelOrphanVerdict`(单次 `loadEditorProject` + `inspectPerfectPixelProjectSnapshot`),不复用 `reconcilePerfectPixelProject` 的轮询循环——后者在窗口耗尽时确实会因 `hasAttemptedRead` 初值为 false 而强制读一次,但那是实现副作用而非契约,寄生在上面迟早被重构静默破坏。收口口径:`dialog-missing` → 刷新素材库 + asset-only 提示(这正是布局尽力保存失败的典型结局);`applied` → 静默刷新素材库(本次读到的就是当前项目的权威状态,结果本就在用户眼前,弹提示只是噪音);`pending` / `conflict` → 完全静默(什么都没落库,用户无需知道)。三者都清账本;**读失败不清**——那是「不知道」而非「知道没有」,留给下次加载。 +- 保留不变:未收口占位在账本缺失时仍然收口成可删除的失败态,上一条决策的「缺失只降级、不得构成阻断」原样有效。本条只是把「缺失」的适用范围限定在它本来就该管的那一半。 +- 同类判据的通用要求:本仓库中任何「持久化标记 + 短寿命本地状态」的组合,判据都必须先问「这个标记所指的事情是不是已经结束了」。只要标记比它依赖的状态活得久,`marker && !state ⇒ invalid` 就一定会把正常终态误判成异常。 +- 影响范围:`ImageCanvasEditorModel.ts` 新增 `isSettledPerfectPixelDialogRecord` 并改写 `serializeDialogReferences` / `hydrateCanvasGenerationDialog`;`useImageCanvasGenerationWorkflow.ts` 新增 `readPerfectPixelOrphanVerdict` 并改写恢复 effect 的孤儿分支。不修改服务端、SpacetimeDB schema 或对外契约。 +- 测试缺口的根因与补救:缺陷一能溜过整套测试,是因为工作流用例里所有 applied 场景的 `applyProjectSnapshot` 都是空桩,「收口 → 清账本 → 真实 hydrate 重新套用」这条**跨 hook 协作**从未被跑过;模型层用例又只覆盖了 `generating` + 账本缺失,没有 `idle + generatedLayerId` 这一真实终态形状。补救不是多加两条断言,而是新增一条把 `verdict.project` 真正喂进 `splitCanvasLayoutItems` 的集成用例,并把共享 fixture `createPerfectPixelProject` 补上服务端真实会保留的 `perfectPixelOperationId`——fixture 不还原真实形状,下游所有用例都在测一个不存在的世界。 +- 验证方式:模型层覆盖「收口态无账本仍有效且序列化不再输出标记」「被上一版写脏的行自愈成 idle 且清掉残留错误文案」「收口态的内联 legacy 账本被剥离且不留标记」;工作流层覆盖「applied 结果经真实 hydrate 回来仍是 idle」(该用例已实证:回退修复后报 `expected 'failed' to be 'idle'`)、「过期孤儿仍做且只做一次读并清账本」、「未落库的孤儿静默清账本、不提示、不刷新素材库」。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 86e61057e..77d1f9a79 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1031,6 +1031,92 @@ describe('ImageCanvasEditorModel', () => { ); }); + it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => { + // 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在 + // 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状, + // 绝不能被判成失败。 + const dialogId = 'dialog-perfect-pixel-settled'; + const settledSnapshot = { + id: dialogId, + mode: 'quick-edit' as const, + prompt: '完美像素', + status: 'idle' as const, + composerOpen: false, + generatedLayerId: `layer-${dialogId}`, + perfectPixelOperationId: dialogId, + }; + + const hydrated = hydrateCanvasGenerationDialog(settledSnapshot); + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'idle', + generatedLayerId: `layer-${dialogId}`, + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + expect(hydrated).not.toHaveProperty('perfectPixelOperationId'); + expect(hydrated?.errorMessage).toBeUndefined(); + + // 中文注释:标记的寿命必须与账本对齐——收口后不再写回布局,否则错误形状会一直堆积。 + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + expect(JSON.stringify(layout)).not.toContain('"perfectPixelOperationId"'); + }); + + it('heals a settled placeholder that a previous build wrote back as invalid', () => { + // 中文注释:上一版判据会把成功结果写成 failed + perfectPixelOperationInvalid 并落库。 + // 这类已经被写脏的行必须在下一次 hydrate 时自愈,否则错误状态会自我固化。 + const dialogId = 'dialog-perfect-pixel-poisoned'; + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'failed', + composerOpen: false, + generatedLayerId: `layer-${dialogId}`, + perfectPixelOperationId: dialogId, + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + + expect(hydrated).toMatchObject({ id: dialogId, status: 'idle' }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + expect(hydrated?.errorMessage).toBeUndefined(); + }); + + it('drops the inline legacy ledger of an already settled placeholder without marking it', () => { + const dialogId = 'dialog-perfect-pixel-settled-legacy'; + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [ + { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'idle', + composerOpen: false, + generatedLayerId: `layer-${dialogId}`, + perfectPixelOperation: buildPerfectPixelOperation(dialogId), + } as CanvasGenerationDialogState, + ], + }); + const serializedLayout = JSON.stringify(layout); + expect(serializedLayout).not.toContain('"perfectPixelOperation"'); + expect(serializedLayout).not.toContain('"perfectPixelOperationId"'); + + const { generationDialogs } = splitCanvasLayoutItems(layout); + + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'idle', + }); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + }); + it('settles a perfect-pixel placeholder as deletable failure when the local ledger is absent', () => { const dialogId = 'dialog-perfect-pixel-other-device'; const operation = buildPerfectPixelOperation(dialogId); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 018cd382a..81018a9a6 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -730,13 +730,19 @@ function serializeGenerationReferences( * 账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容,改由 * `perfectPixelOperationStore` 存在本机(见那里的长注释)。布局仍需要一个标记,否则 * 换设备打开时无法把这类占位与队列型占位区分开,只能当成普通占位一直转下去。 + * + * 标记的寿命必须与账本对齐:收口后账本会被清掉,标记也就不该再留在布局里。否则每一次成功 + * 的完美像素都会在布局里留下一个「有标记、没账本」的占位,被 hydrate 判成无效。 */ function serializeDialogReferences( dialog: CanvasGenerationDialogState, ): CanvasGenerationDialogState { const { perfectPixelOperation, ...persistedDialog } = dialog; - const perfectPixelOperationId = - dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId; + const perfectPixelOperationId = isSettledPerfectPixelDialogRecord( + dialog as unknown as Record, + ) + ? undefined + : (dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId); return { ...persistedDialog, ...(perfectPixelOperationId ? { perfectPixelOperationId } : {}), @@ -878,6 +884,21 @@ export function isUnresolvedCanvasGenerationDialogRecord( ); } +/** + * 中文注释:完美像素占位是否已经收口,即结果图层已被服务端回填进画布。 + * + * **本机账本只在占位未收口期间存在**:发 POST 前写入,拿到终态就清除。而布局里的 + * `perfectPixelOperationId` 标记寿命无限——服务端完成时只做字段级改写(置 `status: "idle"`、 + * 写入 `generatedLayerId`),从不摘掉这个标记。两者寿命不对称,所以任何「有标记、没账本 + * ⇒ 无效」的判据都必须先排除收口态,否则每一次**成功**的完美像素都会在下一次 hydrate 时 + * 被判成 `failed + perfectPixelOperationInvalid`,并把这个错误状态写回服务端。 + */ +function isSettledPerfectPixelDialogRecord( + dialog: Record | null, +): boolean { + return Boolean(dialog) && !isUnresolvedCanvasGenerationDialogRecord(dialog); +} + /** * 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。 * @@ -1148,12 +1169,20 @@ export function hydrateCanvasGenerationDialog( snapshot, 'perfectPixelOperationInvalid', ); - // 中文注释:标记在、账本不在,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的路径。 - // 收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。 + // 中文注释:收口态占位不需要账本——服务端已经把结果图层回填进画布,`generatedLayerId` + // 就是证据。账本在收口那一刻已被主动清除,标记却永远留在布局里(服务端不摘),所以下面 + // 这些判据必须先排除收口态,否则每一次成功都会被判成失败,并把错误状态写回服务端。 + // 已经被写脏的历史行也在这里一并纠正:收口态无条件忽略已落库的无效标记。 + const isSettledPerfectPixelPlaceholder = + isPerfectPixelPlaceholder && + isSettledPerfectPixelDialogRecord(snapshot as Record); + // 中文注释:标记在、账本不在且尚未收口,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的 + // 路径。收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。 const hasInvalidPerfectPixelOperation = - (isPerfectPixelPlaceholder && !perfectPixelOperation) || - hasPersistedInvalidPerfectPixelOperationMarker || - (snapshot.status === 'pending-confirmation' && !perfectPixelOperation); + !isSettledPerfectPixelPlaceholder && + ((isPerfectPixelPlaceholder && !perfectPixelOperation) || + hasPersistedInvalidPerfectPixelOperationMarker || + (snapshot.status === 'pending-confirmation' && !perfectPixelOperation)); const trustedPerfectPixelOperation = hasInvalidPerfectPixelOperation ? undefined : perfectPixelOperation; @@ -1172,14 +1201,20 @@ export function hydrateCanvasGenerationDialog( prompt, status: hasInvalidPerfectPixelOperation ? 'failed' - : isGenerationStatus(snapshot.status) - ? snapshot.status - : 'idle', + : // 中文注释:带 generatedLayerId 的完美像素占位按定义已被服务端回填,状态只能是 + // `idle`。这里强制归位,顺带修复被上一版判据写脏成 `failed` 的历史行。 + isSettledPerfectPixelPlaceholder + ? 'idle' + : isGenerationStatus(snapshot.status) + ? snapshot.status + : 'idle', // 中文注释:只认布尔 true。缺字段的历史占位一律视为未置位,按队列型处理原样恢复, // 不会被 dropDeadInlineGenerationPlaceholders 误清。 requiresLiveSession: snapshot.requiresLiveSession === true ? true : undefined, - ...(isPerfectPixelPlaceholder ? { perfectPixelOperationId: id } : {}), + ...(isPerfectPixelPlaceholder && !isSettledPerfectPixelPlaceholder + ? { perfectPixelOperationId: id } + : {}), ...(trustedPerfectPixelOperation ? { perfectPixelOperation: trustedPerfectPixelOperation } : {}), @@ -1308,7 +1343,12 @@ export function hydrateCanvasGenerationDialog( imageSize: stringOrUndefined(snapshot.imageSize), errorMessage: hasInvalidPerfectPixelOperation ? INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE - : stringOrUndefined(snapshot.errorMessage), + : // 中文注释:服务端回填成功时会清掉 errorMessage,所以收口态占位身上的错误文案一定 + // 是残留——上一版判据写进去的那句「快照无效」正是这样落库的。一并清掉,否则状态 + // 已经纠正回 idle,面板上却还挂着一句失败提示。 + isSettledPerfectPixelPlaceholder + ? undefined + : stringOrUndefined(snapshot.errorMessage), generationStartedAt: numberOrUndefined(snapshot.generationStartedAt), generationFinishedAt: numberOrUndefined(snapshot.generationFinishedAt), placeholder: hydrateGenerationPlaceholder(snapshot.placeholder), diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index b364d15a0..a38407054 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -15,7 +15,10 @@ import type { EditorPixelArtSnapInput, EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; -import { PERFECT_PIXEL_RECONCILIATION_WINDOW_MS } from './ImageCanvasEditorModel'; +import { + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + splitCanvasLayoutItems, +} from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -177,6 +180,10 @@ function createPerfectPixelProject( prompt: '完美像素', status: state === 'pending' ? 'generating' : 'idle', composerOpen: false, + // 中文注释:服务端完成 completion 时只做字段级改写(status/composerOpen/ + // generatedLayerId/errorMessage),从不摘掉 perfectPixelOperationId。fixture 必须 + // 保留这个标记,否则「收口态占位 + 账本已清」这条真实形状永远测不到。 + perfectPixelOperationId: operationId, ...(state === 'applied' ? { generatedLayerId } : {}), }, }; @@ -3415,6 +3422,64 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); }); + it('does not turn an applied perfect-pixel result into an invalid placeholder when rehydrated', async () => { + // 中文注释:这条盯的是两个 hook 的**协作**,不是单个函数。此前所有 applied 用例的 + // applyProjectSnapshot 都是空桩,于是「收口 → 清账本 → 用真实 hydrate 重新套用权威 + // 快照」这条链路从未被跑过,成功结果被判成 failed 的缺陷才得以溜过整套测试。这里把 + // 桩换成真实的 splitCanvasLayoutItems + 本机账本读取。 + let operationId = ''; + let rehydratedDialogs: CanvasGenerationDialogState[] = []; + const applyProjectSnapshot = vi.fn((project: EditorProjectSnapshot) => { + const { generationDialogs } = splitCanvasLayoutItems( + project.layers, + new Map(), + 'user-a', + readPerfectPixelOperations('user-a', 'project-1'), + ); + rehydratedDialogs = generationDialogs; + }); + snapImageToPerfectPixelsMock.mockImplementationOnce( + async (request: EditorPixelArtSnapInput) => { + operationId = request.canvasCompletion.dialogId; + throw new Error('网络中断'); + }, + ); + loadEditorProjectMock.mockImplementation(async () => + createPerfectPixelProject(operationId, 'applied'), + ); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + + await waitFor(() => { + expect(applyProjectSnapshot).toHaveBeenCalled(); + }); + const settledDialog = rehydratedDialogs.find( + (dialog) => dialog.id === operationId, + ); + expect(settledDialog).toBeTruthy(); + expect(settledDialog?.status).toBe('idle'); + expect(settledDialog?.generatedLayerId).toBe(`layer-${operationId}`); + expect(settledDialog).not.toHaveProperty('perfectPixelOperationInvalid'); + expect(settledDialog?.errorMessage).toBeUndefined(); + // 中文注释:收口后账本确实被清,本用例因此正是「有标记、没账本」的真实形状。 + expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0); + }); + it('reconciles an orphan local ledger entry whose placeholder never reached the layout', async () => { // 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进 // 布局就断电」的窗口。刷新后必须仍然对账并告知用户结果去了素材库——这正是删掉严格 @@ -3470,6 +3535,111 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0); }); + it('still reconciles an orphan ledger entry whose reconcile window already expired', async () => { + // 中文注释:孤儿的目标场景就是「关掉浏览器、稍后重开」,重开必然晚于 75 秒对账窗口。 + // 按 reconcileUntil 短路会让这条兜底分支在它唯一的用武之地上完全失效。 + const operationId = 'perfect-pixel-orphan-expired'; + const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); + const submittedAt = Date.now() - 6 * 60 * 60 * 1_000; + savePerfectPixelOperation('user-a', 'project-1', { + version: 1, + kind: 'perfect-pixel', + operationId, + taskId: `pixel-art-snap-${operationId}`, + request: { + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'project-1', + canvasCompletion: { + dialogId: operationId, + title: '源图 · 完美像素', + placeholder: { + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }); + loadEditorProjectMock.mockImplementation(async () => + createPerfectPixelProject(operationId, 'dialog-missing'), + ); + + render( + true)} + flushProjectPersistence={vi.fn().mockResolvedValue(undefined)} + refreshAssetLibrary={refreshAssetLibrary} + />, + ); + + await waitFor(() => { + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '完美像素结果已保存到素材库,画布占位已不存在。', + ); + }); + // 中文注释:孤儿只做一次确定性的读,不轮询。 + expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); + expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0); + }); + + it('silently clears an orphan ledger entry whose result never landed', async () => { + const operationId = 'perfect-pixel-orphan-nothing-landed'; + const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); + const submittedAt = Date.now() - 6 * 60 * 60 * 1_000; + savePerfectPixelOperation('user-a', 'project-1', { + version: 1, + kind: 'perfect-pixel', + operationId, + taskId: `pixel-art-snap-${operationId}`, + request: { + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'project-1', + canvasCompletion: { + dialogId: operationId, + title: '源图 · 完美像素', + placeholder: { + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }); + loadEditorProjectMock.mockImplementation(async () => + createEmptyPerfectPixelProject(), + ); + + render( + true)} + flushProjectPersistence={vi.fn().mockResolvedValue(undefined)} + refreshAssetLibrary={refreshAssetLibrary} + />, + ); + + await waitFor(() => { + expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0); + }); + // 中文注释:什么都没落库,用户不需要知道任何事;弹提示只会制造噪音。 + expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); + expect(refreshAssetLibrary).not.toHaveBeenCalled(); + }); + it('does not let a never-settling asset refresh block a terminal reconciliation verdict', async () => { const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn(() => new Promise(() => {})); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 2e9563d9b..a9b02c9e1 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -417,6 +417,26 @@ async function reconcilePerfectPixelProject( throw options.signal?.reason ?? new DOMException('操作已取消', 'AbortError'); } +/** + * 中文注释:孤儿账本条目的收口读取——**一次确定性的读,不轮询**。 + * + * `reconcileUntil` 那 75 秒窗口的意义是「结果可能还在飞,值得多读几次」。孤儿账本是上一个 + * 会话留下的,发出它的标签页早已不在,服务端要么写完了要么没写;此时需要的是一次能回答 + * 「到底落没落」的读,而不是轮询。窗口是否已过期与要不要读无关——恰恰相反,用户重新打开 + * 项目几乎必然晚于 75 秒,按窗口短路会让孤儿对账在它唯一的目标场景下完全失效。 + */ +async function readPerfectPixelOrphanVerdict( + projectId: string, + operation: PerfectPixelOperationSnapshot, + options: { signal?: AbortSignal } = {}, +): Promise { + const project = await loadEditorProject(projectId, { + signal: options.signal, + deadlineAt: Date.now() + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS, + }); + return inspectPerfectPixelProjectSnapshot(project, operation); +} + function resolveConfirmedPerfectPixelAsset( result: EditorPixelArtSnapResult | null, operation: PerfectPixelOperationSnapshot, @@ -2763,28 +2783,41 @@ export function useImageCanvasGenerationWorkflow({ dialog.perfectPixelOperation && (dialog.status === 'generating' || dialog.status === 'pending-confirmation') - ? [{ dialogId: dialog.id, operation: dialog.perfectPixelOperation }] + ? [ + { + dialogId: dialog.id, + operation: dialog.perfectPixelOperation, + isOrphan: false, + }, + ] : [], ); // 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进 // 布局就断电」的窗口。那些孤儿账本同样要对账——结果多半已经落进素材库,用户有权知道。 // 这条路径不需要占位存在:下面所有 updateCanvasGenerationDialogById 对缺席 dialog 都是 // 无操作,applied / dialog-missing 分支本就带着「占位不在」的提示文案。 - const recoverableDialogIds = new Set( + // + // 只要本地还存在同 id 的 dialog 就不走孤儿路径,无论它是什么状态:`generating` / + // `pending-confirmation` 由上面的 dialog 路径负责;`failed` 说明用户可以在原占位手动 + // 重试,账本仍归那个占位所有,此时抢先当孤儿读掉并清账本会连带废掉重试能力。 + const localDialogIds = new Set( canvasGenerationDialogs.map((dialog) => dialog.id), ); - const nowMs = Date.now(); for (const [operationId, operation] of readPerfectPixelOperations( currentUserId, normalizedProjectId, )) { - if ( - recoverableDialogIds.has(operationId) || - operation.reconcileUntil <= nowMs - ) { + if (localDialogIds.has(operationId)) { continue; } - recoverableOperations.push({ dialogId: operationId, operation }); + // 中文注释:这里**不能**按 `reconcileUntil` 短路。孤儿的目标场景就是「关掉浏览器、 + // 稍后重开」,重开必然晚于 75 秒对账窗口;按窗口跳过等于让这条兜底分支永不生效, + // 而条目还会在本机躺满整个保留期反复被跳过。 + recoverableOperations.push({ + dialogId: operationId, + operation, + isOrphan: true, + }); } const recoveryKeys = new Set( recoverableOperations.map(({ operation }) => @@ -2801,7 +2834,7 @@ export function useImageCanvasGenerationWorkflow({ } } - for (const { dialogId, operation } of recoverableOperations) { + for (const { dialogId, operation, isOrphan } of recoverableOperations) { const recoveryKey = perfectPixelRecoveryKey( currentUserId, normalizedProjectId, @@ -2852,19 +2885,44 @@ export function useImageCanvasGenerationWorkflow({ }); return; } - const verdict = await reconcilePerfectPixelProject( - normalizedProjectId, - operation, - { - signal: controller.signal, - }, - ); + const verdict = isOrphan + ? await readPerfectPixelOrphanVerdict( + normalizedProjectId, + operation, + { + signal: controller.signal, + }, + ) + : await reconcilePerfectPixelProject(normalizedProjectId, operation, { + signal: controller.signal, + }); if ( controller.signal.aborted || projectId?.trim() !== normalizedProjectId ) { return; } + if (isOrphan) { + // 中文注释:孤儿读到任何结论就收口清账本,避免它在保留期内每次加载都被重读。 + // 读失败(走下面的 catch)不清——那是「不知道」,不是「知道没有」,留给下次加载。 + forgetPerfectPixelOperation( + currentUserId, + normalizedProjectId, + operation.operationId, + ); + if (verdict.kind === 'dialog-missing') { + // 中文注释:服务端当时没有占位可回填,结果只进了素材库——这正是「布局尽力保存 + // 失败」的典型结局,用户必须被告知。 + refreshPerfectPixelAssetLibrary(); + showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE); + } else if (verdict.kind === 'applied') { + // 中文注释:服务端已经把结果回填进画布,而本次读到的正是当前项目的权威状态, + // 所以结果本就在用户眼前。静默刷新素材库即可,弹提示只是噪音。 + refreshPerfectPixelAssetLibrary(); + } + // 中文注释:pending / conflict 意味着什么都没落库,用户无需知道任何事。 + return; + } if (verdict.kind === 'applied' || verdict.kind === 'dialog-missing') { // 中文注释:项目事实已给出终态,账本收口清掉,避免下次加载重复对账。 forgetPerfectPixelOperation(