修复账本寿命短于标记寿命导致的成功误判与孤儿失效
阶段 3 引入的判据「布局里有 perfectPixelOperationId、本机没有账本 ⇒ 无效」 按构造就是错的:标记写进布局后寿命无限(服务端完成 completion 只做字段级 改写,从不摘标记),账本寿命却很短(收口即清、75 秒对账窗口、7 天保留期、 换设备即无)。账本消失是正常终态,不是异常。 同一个不对称造成两个缺陷: 一、每一次成功都被判成失败。settle 时先清账本、紧接着用真实 hydrate 重新 套用权威快照,成功结果被判成 failed + perfectPixelOperationInvalid;该状态 还会被下一次自动保存写回服务端,覆盖正确的 idle,且标记一旦落库就能单独 强制 failed,自我固化。历史上早已完成的占位在标记化改写后同样中招。 二、孤儿账本对账在唯一目标场景下失效。它的场景是「关掉浏览器、稍后重开」, 重开必然晚于 75 秒,而分支按 reconcileUntil 短路,等于永不生效,条目还会在 本机躺满保留期反复被跳过。 修复:凡是「缺账本 ⇒ 无效」的判据一律先排除收口态(复用既有的 isUnresolvedCanvasGenerationDialogRecord 取反)。收口态无条件忽略已落库的 无效标记与残留错误文案、强制归位 idle,让被写脏的行自愈;写边界同步收窄, 收口后不再输出标记,让标记寿命与账本对齐。 孤儿不按 reconcileUntil 短路,改走新增的 readPerfectPixelOrphanVerdict—— 一次确定性的读,不轮询。dialog-missing 提示结果只进素材库;applied 静默 刷新(本次读到的就是权威状态,结果本就在眼前);未落库完全静默。三者都清 账本,读失败不清。 测试缺口的根因:工作流里所有 applied 用例的 applyProjectSnapshot 都是空桩, 跨 hook 协作从未被跑过;共享 fixture 也没还原服务端真实保留的标记。补了一条 把 verdict.project 真正喂进 splitCanvasLayoutItems 的集成用例,回退修复后它 报 expected 'failed' to be 'idle'。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`。
|
||||
|
||||
Reference in New Issue
Block a user