修复账本寿命短于标记寿命导致的成功误判与孤儿失效
阶段 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`。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
)
|
||||
? 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<string, unknown> | 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<string, unknown>);
|
||||
// 中文注释:标记在、账本不在且尚未收口,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的
|
||||
// 路径。收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。
|
||||
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),
|
||||
|
||||
@@ -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(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
currentUserId="user-a"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
currentUserId="user-a"
|
||||
applyProjectSnapshotWithoutHistory={vi.fn(() => 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(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
currentUserId="user-a"
|
||||
applyProjectSnapshotWithoutHistory={vi.fn(() => 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(() => {}));
|
||||
|
||||
@@ -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<PerfectPixelProjectVerdict> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user