From 2fa2f245c1b0579cdf4f68debc573915a008c5cc Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 5 Aug 2026 05:55:06 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E7=BE=8E=E5=83=8F=E7=B4=A0=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=B4=A6=E6=9C=AC=E7=A7=BB=E5=87=BA=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容。它此前写在 用户的画布布局里,为此派生出一条严格布局保存通道:发 POST 前必须拿到布局 保存的 revision ack。任何布局校验失败因此都会升级成完美像素的硬阻断。 改为存在本机 localStorage,按 owner + project 双键隔离;布局里只留 perfectPixelOperationId 标记,用来把这类占位与队列型占位区分开。本机写入是 同步的、不过网络、不受服务端校验影响,因此它能提供严格保存想提供的那个保证 ——请求可被追溯——却不引入阻断点。严格保存通道整体删除,只保留一个不改变 失败语义的 preferLatestGenerationDialogs。 由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历 内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清 账本。 本机账本是明确设计,缺失只降级、不得构成阻断:换设备、清缓存、隐私模式、 配额写满都会读不到账本,此时带标记的占位一律收口成可删除的失败占位,用户 删掉重来即可。跨设备不再自动收口是已知且接受的代价。 布局内联账本作为 legacy 形状继续被读取,滚动部署期间的在途操作不会被一次性 判死;写入侧不再产生新的内联账本。 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 11 + .../ImageCanvasEditorModel.test.ts | 107 +++- .../image-editor/ImageCanvasEditorModel.ts | 61 +- .../image-editor/ImageCanvasEditorTypes.ts | 4 + .../perfectPixelOperationStore.test.ts | 228 +++++++ .../perfectPixelOperationStore.ts | 203 ++++++ .../useImageCanvasGenerationSurface.tsx | 3 +- .../useImageCanvasGenerationWorkflow.test.tsx | 123 ++-- .../useImageCanvasGenerationWorkflow.ts | 148 +++-- .../useImageCanvasProjectPersistence.test.tsx | 433 ++----------- .../useImageCanvasProjectPersistence.ts | 576 +++--------------- 11 files changed, 910 insertions(+), 987 deletions(-) create mode 100644 src/components/image-editor/perfectPixelOperationStore.test.ts create mode 100644 src/components/image-editor/perfectPixelOperationStore.ts diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fe0674956..dbc2d7612 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6381,3 +6381,14 @@ - 影响范围:`src/components/image-editor/useCanvasGenerationDialogs.ts`(删除 `isUnsettledPerfectPixelOperationDialog`,新增 `requiresGenerationDeleteConfirmation`)、`ImageCanvasEditorView.tsx` 的 `requestRemoveCanvasGenerationDialog`、`useImageCanvasLayerCommands.ts` 的 `deleteSelectedLayer`、`useImageCanvasGenerationWorkflow.ts` 的恢复失效分支。不修改服务端、契约或数据。 - 验证方式:覆盖三种状态下按 id 删除与随源图层删除均真正移除、完美像素占位任何状态都不要求确认而普通 `generating` 占位仍要求、快捷键与混合选择删除会写入历史并触发副作用、在途删除后已知失败退回全局提示、删除后 applied verdict 走 asset-only 提示且不回填画布、标记失效时快照被丢弃。运行 `npx vitest run src/components/image-editor`、`npx vitest run src/components/platform-entry`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 完美像素请求账本移出项目布局,严格布局保存整体删除 + +- 背景:完美像素是唯一没有 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` 的**可删除**占位,用户删掉重来即可。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。 +- 兼容与生命周期:布局内联账本是 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`。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 58ff88ea5..86e61057e 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -34,10 +34,7 @@ import type { EditorAsset, PerfectPixelOperationSnapshot, } from './ImageCanvasEditorTypes'; -import { - PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS, - PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS, -} from './useImageCanvasGenerationWorkflow'; +import { PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS } from './useImageCanvasGenerationWorkflow'; function buildPerfectPixelOperation( dialogId: string, @@ -990,7 +987,7 @@ describe('ImageCanvasEditorModel', () => { }); }); - it('strictly round-trips a pending perfect-pixel operation snapshot', () => { + it('keeps the operation ledger out of the layout and restores it from the local ledger', () => { const dialogId = 'dialog-perfect-pixel-round-trip'; const operation = buildPerfectPixelOperation(dialogId); const hydrated = hydrateCanvasGenerationDialog({ @@ -1006,15 +1003,25 @@ describe('ImageCanvasEditorModel', () => { expect(hydrated).toMatchObject({ id: dialogId, status: 'pending-confirmation', + perfectPixelOperationId: dialogId, perfectPixelOperation: operation, }); expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + const serializedLayout = JSON.stringify(layout); + expect(serializedLayout).toContain('"perfectPixelOperationId"'); + expect(serializedLayout).not.toContain('"perfectPixelOperation"'); + expect(serializedLayout).not.toContain(operation.request.sourceImageSrc); + const { generationDialogs } = splitCanvasLayoutItems( - serializeCanvasLayout({ - layers: [], - canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], - }), + layout, + new Map(), + undefined, + new Map([[dialogId, operation]]), ); expect(generationDialogs).toHaveLength(1); @@ -1024,6 +1031,67 @@ describe('ImageCanvasEditorModel', () => { ); }); + 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); + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + composerOpen: false, + perfectPixelOperation: operation, + }); + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + + // 中文注释:换设备 / 清缓存 / 隐私模式——布局里标记还在,本机账本读不到。这是明确 + // 设计:占位收口成可删除的失败态,绝不停在无从收口的处理中态。 + const { generationDialogs } = splitCanvasLayoutItems(layout); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('ignores a local ledger entry whose id does not match the placeholder', () => { + const dialogId = 'dialog-perfect-pixel-ledger-mismatch'; + const operation = buildPerfectPixelOperation('dialog-perfect-pixel-other'); + const { generationDialogs } = splitCanvasLayoutItems( + [ + { + itemType: 'generation-dialog', + layerId: `generation-dialog:${dialogId}`, + resourceId: `generation-dialog:${dialogId}`, + dialog: { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + perfectPixelOperationId: dialogId, + }, + } as unknown as EditorProjectLayerSnapshot, + ], + new Map(), + undefined, + new Map([['dialog-perfect-pixel-other', operation]]), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + it('preserves legacy 240-second operation identity while clamping its deadline on round-trip', () => { vi.useFakeTimers(); const now = 1_700_000_010_000; @@ -1061,6 +1129,9 @@ describe('ImageCanvasEditorModel', () => { layers: [], canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], }), + new Map(), + undefined, + new Map([[dialogId, expectedOperation]]), ); expect(generationDialogs).toHaveLength(1); @@ -1172,6 +1243,9 @@ describe('ImageCanvasEditorModel', () => { layers: [], canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], }), + new Map(), + undefined, + new Map([[dialogId, expectedOperation]]), ); expect(generationDialogs).toHaveLength(1); @@ -1466,20 +1540,15 @@ describe('ImageCanvasEditorModel', () => { }); }); - it('keeps the legacy placeholder window above source preparation and strict journaling', () => { - // operation 首次 strict journal 成功后会退出 legacy requiresLiveSession 清理;窗口只需 - // 覆盖源准备与该次 journal,POST 和对账由 durable operation 自己保护。 - const durableOperationJournalUpperBoundMs = - PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS + - PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS; - - expect(durableOperationJournalUpperBoundMs).toBe(150_000); + it('keeps the legacy placeholder window above source preparation', () => { + // 中文注释:占位一旦挂上 perfectPixelOperationId 就退出 legacy requiresLiveSession 清理, + // 所以这个窗口只需覆盖标记写入之前的那一段——源图解析/直传。POST 与对账由账本自己保护。 expect(INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS).toBeGreaterThan( - durableOperationJournalUpperBoundMs, + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS, ); expect( INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS - - durableOperationJournalUpperBoundMs, + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS, ).toBeGreaterThanOrEqual(90_000); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index a838ef3e5..018cd382a 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -724,11 +724,22 @@ function serializeGenerationReferences( }); } +/** + * 中文注释:布局里只写 `perfectPixelOperationId` 标记,不写请求账本本身。 + * + * 账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容,改由 + * `perfectPixelOperationStore` 存在本机(见那里的长注释)。布局仍需要一个标记,否则 + * 换设备打开时无法把这类占位与队列型占位区分开,只能当成普通占位一直转下去。 + */ function serializeDialogReferences( dialog: CanvasGenerationDialogState, ): CanvasGenerationDialogState { + const { perfectPixelOperation, ...persistedDialog } = dialog; + const perfectPixelOperationId = + dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId; return { - ...dialog, + ...persistedDialog, + ...(perfectPixelOperationId ? { perfectPixelOperationId } : {}), specReference: serializeGenerationReference(dialog.specReference), generationReferences: serializeGenerationReferences( dialog.generationReferences, @@ -999,8 +1010,14 @@ export function dropDeadInlineGenerationPlaceholders( const operationBackedDialogKeys = new Set( mirroredLayers.flatMap((item) => { const dialog = canvasGenerationDialogRecord(item); + // 中文注释:两种形状都算 operation-backed——`perfectPixelOperationId` 是账本移出 + // 布局后的新标记,`perfectPixelOperation` 是内联账本的 legacy 形状。 return dialog && - Object.prototype.hasOwnProperty.call(dialog, 'perfectPixelOperation') + (Object.prototype.hasOwnProperty.call( + dialog, + 'perfectPixelOperationId', + ) || + Object.prototype.hasOwnProperty.call(dialog, 'perfectPixelOperation')) ? [canvasGenerationDialogMirrorKey(item)] : []; }), @@ -1045,6 +1062,10 @@ export function splitCanvasLayoutItems( items: EditorProjectLayerSnapshot[], resourcesById: Map = new Map(), currentUserId?: string | null, + localPerfectPixelOperations?: ReadonlyMap< + string, + PerfectPixelOperationSnapshot + >, ): { layerItems: EditorProjectLayerSnapshot[]; generationDialogs: CanvasGenerationDialogState[]; @@ -1070,6 +1091,7 @@ export function splitCanvasLayoutItems( item.dialog, resourcesById, currentUserId, + localPerfectPixelOperations, ); if (dialog) { generationDialogs.push(dialog); @@ -1086,6 +1108,10 @@ export function hydrateCanvasGenerationDialog( value: unknown, resourcesById: Map = new Map(), currentUserId?: string | null, + localPerfectPixelOperations?: ReadonlyMap< + string, + PerfectPixelOperationSnapshot + >, ): CanvasGenerationDialogState | null { if (!value || typeof value !== 'object') { return null; @@ -1096,20 +1122,36 @@ export function hydrateCanvasGenerationDialog( if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) { return null; } - const hasPerfectPixelOperation = Object.prototype.hasOwnProperty.call( - snapshot, - 'perfectPixelOperation', - ); - const perfectPixelOperation = hasPerfectPixelOperation + // 中文注释:布局内联快照是 legacy 形状——账本改存本机之前写下的占位仍在库里, + // 必须继续认,否则滚动部署会把所有在途操作一次性判死。新写入只有 id 标记。 + const hasLegacyInlinePerfectPixelOperation = + Object.prototype.hasOwnProperty.call(snapshot, 'perfectPixelOperation'); + const legacyInlinePerfectPixelOperation = hasLegacyInlinePerfectPixelOperation ? hydratePerfectPixelOperation(snapshot.perfectPixelOperation, id) - : undefined; + : null; + const declaredPerfectPixelOperationId = Object.prototype.hasOwnProperty.call( + snapshot, + 'perfectPixelOperationId', + ); + const isPerfectPixelPlaceholder = + hasLegacyInlinePerfectPixelOperation || declaredPerfectPixelOperationId; + const perfectPixelOperation = + legacyInlinePerfectPixelOperation ?? + // 中文注释:只认 id 与占位一致的账本。id 漂移一律当账本不可用失败关闭,绝不按 + // 当前画布状态猜一条请求出来重放。 + (declaredPerfectPixelOperationId && + stringOrNull(snapshot.perfectPixelOperationId) === id + ? (localPerfectPixelOperations?.get(id) ?? null) + : null); const hasPersistedInvalidPerfectPixelOperationMarker = Object.prototype.hasOwnProperty.call( snapshot, 'perfectPixelOperationInvalid', ); + // 中文注释:标记在、账本不在,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的路径。 + // 收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。 const hasInvalidPerfectPixelOperation = - (hasPerfectPixelOperation && !perfectPixelOperation) || + (isPerfectPixelPlaceholder && !perfectPixelOperation) || hasPersistedInvalidPerfectPixelOperationMarker || (snapshot.status === 'pending-confirmation' && !perfectPixelOperation); const trustedPerfectPixelOperation = hasInvalidPerfectPixelOperation @@ -1137,6 +1179,7 @@ export function hydrateCanvasGenerationDialog( // 不会被 dropDeadInlineGenerationPlaceholders 误清。 requiresLiveSession: snapshot.requiresLiveSession === true ? true : undefined, + ...(isPerfectPixelPlaceholder ? { perfectPixelOperationId: id } : {}), ...(trustedPerfectPixelOperation ? { perfectPixelOperation: trustedPerfectPixelOperation } : {}), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 6c1ffeb55..51294c71a 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -257,6 +257,10 @@ export type GenerateDialogState = { // 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后 // 必须原样恢复。 requiresLiveSession?: boolean; + // 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id + // 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除 + // 的失败态——这是明确设计,不得据此阻断用户删除或重做。 + perfectPixelOperationId?: string; perfectPixelOperation?: PerfectPixelOperationSnapshot; perfectPixelOperationInvalid?: boolean; generationStartedAt?: number; diff --git a/src/components/image-editor/perfectPixelOperationStore.test.ts b/src/components/image-editor/perfectPixelOperationStore.test.ts new file mode 100644 index 000000000..8860263df --- /dev/null +++ b/src/components/image-editor/perfectPixelOperationStore.test.ts @@ -0,0 +1,228 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes'; +import { + forgetPerfectPixelOperation, + PERFECT_PIXEL_OPERATION_RETENTION_LIMIT, + PERFECT_PIXEL_OPERATION_RETENTION_MS, + readPerfectPixelOperations, + savePerfectPixelOperation, +} from './perfectPixelOperationStore'; + +const OWNER_USER_ID = 'user-a'; +const PROJECT_ID = 'project-1'; +const NOW = 1_785_715_270_000; + +function buildOperation( + dialogId: string, + overrides: Partial = {}, +): PerfectPixelOperationSnapshot { + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: PROJECT_ID, + sourceResourceId: 'resource-source', + assetKind: 'character', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: NOW, + reconcileUntil: NOW + 75_000, + ...overrides, + }; +} + +describe('perfectPixelOperationStore', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('round-trips an operation for the same owner and project', () => { + const operation = buildOperation('dialog-1'); + savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation); + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect(ledger.get('dialog-1')).toEqual(operation); + }); + + it('keeps ledgers separated by project and drops another owner entirely', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + + expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0); + // 中文注释:同一台机器换账号后,上一个账号的请求(含源图直传地址)必须整条丢弃。 + expect(readPerfectPixelOperations('user-b', PROJECT_ID).size).toBe(0); + expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0); + }); + + it('refuses to store an operation whose request targets another project', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + 'project-2', + buildOperation('dialog-1'), + ); + + expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0); + }); + + it('fails closed on a tampered ledger entry instead of replaying it', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-2'), + ); + const key = `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`; + const stored = JSON.parse(window.localStorage.getItem(key)!) as { + ownerUserId: string; + operations: Record; + }; + stored.operations['dialog-1']!.taskId = 'pixel-art-snap-somewhere-else'; + window.localStorage.setItem(key, JSON.stringify(stored)); + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect(ledger.has('dialog-1')).toBe(false); + expect(ledger.has('dialog-2')).toBe(true); + }); + + it('drops entries past the retention window', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-old', { + submittedAt: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS - 1, + reconcileUntil: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS + 74_999, + }), + ); + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-fresh'), + ); + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect([...ledger.keys()]).toEqual(['dialog-fresh']); + }); + + it('caps the ledger size by keeping the newest submissions', () => { + for ( + let index = 0; + index <= PERFECT_PIXEL_OPERATION_RETENTION_LIMIT; + index += 1 + ) { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation(`dialog-${index}`, { + submittedAt: NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index), + reconcileUntil: + NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index) + 75_000, + }), + ); + } + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect(ledger.size).toBe(PERFECT_PIXEL_OPERATION_RETENTION_LIMIT); + expect(ledger.has('dialog-0')).toBe(false); + expect( + ledger.has(`dialog-${PERFECT_PIXEL_OPERATION_RETENTION_LIMIT}`), + ).toBe(true); + }); + + it('forgets a settled operation and clears the key once empty', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + + forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'); + + expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0); + expect( + window.localStorage.getItem( + `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`, + ), + ).toBeNull(); + }); + + it('degrades to an empty ledger without throwing when storage is unavailable', () => { + const setItem = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + const getItem = vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(() => { + throw new Error('SecurityError'); + }); + + try { + // 中文注释:隐私模式 / 配额写满时账本读写都会抛。这里必须静默降级——账本缺失只 + // 意味着刷新后不能自动收口,绝不能反过来阻断发起、重试或删除。 + expect(() => + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ), + ).not.toThrow(); + expect(() => + forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'), + ).not.toThrow(); + expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe( + 0, + ); + } finally { + setItem.mockRestore(); + getItem.mockRestore(); + } + }); + + it('returns an empty ledger without an owner or project', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + + expect(readPerfectPixelOperations(null, PROJECT_ID).size).toBe(0); + expect(readPerfectPixelOperations(OWNER_USER_ID, ' ').size).toBe(0); + }); +}); diff --git a/src/components/image-editor/perfectPixelOperationStore.ts b/src/components/image-editor/perfectPixelOperationStore.ts new file mode 100644 index 000000000..324841c85 --- /dev/null +++ b/src/components/image-editor/perfectPixelOperationStore.ts @@ -0,0 +1,203 @@ +import { hydratePerfectPixelOperation } from './ImageCanvasEditorModel'; +import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes'; + +/** + * 中文注释:完美像素操作账本的本机存储。 + * + * **这是明确设计,不是降级方案**:账本记录的是「本机这次会话发出过哪一次 POST」, + * 它是对账凭据,不是用户的画布内容,因此不进项目布局。由此得到两条硬性质: + * + * 1. **写入同步、不依赖网络、不依赖服务端校验。** 发 POST 前先落本机账本即可获得 + * 「请求可被追溯」的保证,不必再用严格布局保存去换同一个保证。布局校验(例如 + * 资源元数据读写不对称)从此不可能阻断完美像素的发起或重试。 + * 2. **账本缺失只降级、绝不阻断。** 换设备、换浏览器、清缓存、隐私模式、配额写满, + * 都会读不到账本。那种情况下占位收口为可删除的失败态,用户可以删掉重来; + * 任何路径都不得因为「读不到账本」而拒绝用户发起、重试或删除。 + * + * 代价是跨设备不再自动收口:在 A 机发起、到 B 机打开同一项目时,B 机看到的是失败占位 + * 而不是对账中的占位。完美像素是免费同步操作,重做成本极低,用这点换掉「用户数据里 + * 混着系统对账状态」的耦合是划算的。 + */ + +const PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX = + 'genarrative.imageCanvas.perfectPixelOperations'; + +/** + * 中文注释:账本保留期。对账窗口只有 75 秒,但 `pending-confirmation` 占位允许用户在很久 + * 之后手动重试同一次 operation,那条路径同样需要账本,所以保留期必须远长于对账窗口。 + */ +export const PERFECT_PIXEL_OPERATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; + +/** + * 中文注释:单个项目最多保留的账本条数,超出时丢弃最旧的。防止长期使用把 localStorage + * 配额吃满——配额写满会连带影响同域下其它本地缓存,而账本本身是可丢弃的。 + */ +export const PERFECT_PIXEL_OPERATION_RETENTION_LIMIT = 32; + +type PerfectPixelOperationLedger = Map; + +function getPerfectPixelOperationStorage() { + if (typeof window === 'undefined') { + return null; + } + try { + return window.localStorage; + } catch { + return null; + } +} + +function perfectPixelOperationStorageKey(projectId: string | null | undefined) { + const normalizedProjectId = projectId?.trim(); + if (!normalizedProjectId) { + return null; + } + return `${PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX}.${normalizedProjectId}`; +} + +/** + * 中文注释:读账本时同时校验归属。同一台机器可能先后登录不同账号,账本里带着上一个 + * 账号的请求(含源图直传地址),换人后必须整条丢弃而不是原样返回。 + */ +function readLedgerEntries( + storage: Storage, + key: string, + ownerUserId: string, +): PerfectPixelOperationLedger { + const ledger: PerfectPixelOperationLedger = new Map(); + const rawValue = storage.getItem(key); + if (!rawValue) { + return ledger; + } + const parsedValue: unknown = JSON.parse(rawValue); + if (!parsedValue || typeof parsedValue !== 'object') { + return ledger; + } + const record = parsedValue as Record; + const recordOwnerUserId = + typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : ''; + if (recordOwnerUserId !== ownerUserId) { + storage.removeItem(key); + return ledger; + } + const operations = record.operations; + if (!operations || typeof operations !== 'object') { + return ledger; + } + const now = Date.now(); + for (const [operationId, value] of Object.entries( + operations as Record, + )) { + // 中文注释:本机账本与布局快照走同一套 v1 白名单校验。存储可被用户或其它脚本改写, + // 任何字段漂移都必须失败关闭——绝不能据一份可疑账本重放 POST。 + const operation = hydratePerfectPixelOperation(value, operationId); + if (!operation) { + continue; + } + if (now - operation.submittedAt > PERFECT_PIXEL_OPERATION_RETENTION_MS) { + continue; + } + ledger.set(operationId, operation); + } + return ledger; +} + +function writeLedgerEntries( + storage: Storage, + key: string, + ownerUserId: string, + ledger: PerfectPixelOperationLedger, +) { + if (ledger.size === 0) { + storage.removeItem(key); + return; + } + const retained = [...ledger.values()] + .sort((left, right) => right.submittedAt - left.submittedAt) + .slice(0, PERFECT_PIXEL_OPERATION_RETENTION_LIMIT); + storage.setItem( + key, + JSON.stringify({ + ownerUserId, + operations: Object.fromEntries( + retained.map((operation) => [operation.operationId, operation]), + ), + }), + ); +} + +/** + * 中文注释:读取某项目在本机的全部有效账本。任何异常都返回空账本——读不到账本只意味着 + * 「刷新后不能自动收口」,调用方必须能在空账本下继续工作。 + */ +export function readPerfectPixelOperations( + currentUserId: string | null | undefined, + projectId: string | null | undefined, +): PerfectPixelOperationLedger { + const ownerUserId = currentUserId?.trim(); + const key = perfectPixelOperationStorageKey(projectId); + const storage = getPerfectPixelOperationStorage(); + if (!ownerUserId || !key || !storage) { + return new Map(); + } + try { + return readLedgerEntries(storage, key, ownerUserId); + } catch { + return new Map(); + } +} + +/** + * 中文注释:写入一条账本。必须在发 POST 之前调用——这是整条链路里唯一「请求已发出」的 + * 本地证据。写入失败(配额、隐私模式)同样不阻断:调用方照常发 POST,只是丢掉刷新后 + * 自动收口的能力。 + */ +export function savePerfectPixelOperation( + currentUserId: string | null | undefined, + projectId: string | null | undefined, + operation: PerfectPixelOperationSnapshot, +) { + const ownerUserId = currentUserId?.trim(); + const key = perfectPixelOperationStorageKey(projectId); + const storage = getPerfectPixelOperationStorage(); + if (!ownerUserId || !key || !storage) { + return; + } + if (operation.request.projectId !== projectId?.trim()) { + return; + } + try { + const ledger = readLedgerEntries(storage, key, ownerUserId); + ledger.set(operation.operationId, operation); + writeLedgerEntries(storage, key, ownerUserId, ledger); + } catch { + // 中文注释:账本是尽力而为的本机便利,写失败不得影响本次提交。 + } +} + +/** + * 中文注释:操作收口(结果已套用 / 只落素材库 / 快照判定失效)后清账本,避免过期条目 + * 在下次加载时再发一次无谓的对账 GET。 + */ +export function forgetPerfectPixelOperation( + currentUserId: string | null | undefined, + projectId: string | null | undefined, + operationId: string, +) { + const ownerUserId = currentUserId?.trim(); + const key = perfectPixelOperationStorageKey(projectId); + const storage = getPerfectPixelOperationStorage(); + const normalizedOperationId = operationId.trim(); + if (!ownerUserId || !key || !storage || !normalizedOperationId) { + return; + } + try { + const ledger = readLedgerEntries(storage, key, ownerUserId); + if (!ledger.delete(normalizedOperationId)) { + return; + } + writeLedgerEntries(storage, key, ownerUserId, ledger); + } catch { + // 中文注释:清理失败最多留下一条过期账本,保留期会兜底。 + } +} diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 364cb97ed..eadf6cb82 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -113,8 +113,7 @@ type ImageCanvasGenerationSurfaceOptions = { project: EditorProjectSnapshot, ) => boolean | void; flushProjectPersistence?: (options?: { - requireSuccess?: boolean; - requiredDialogId?: string; + preferLatestGenerationDialogs?: boolean; }) => Promise; refreshAssetLibrary?: () => Promise | void; onWalletBalanceMayHaveChanged?: () => void; diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index f66197146..b364d15a0 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -25,6 +25,10 @@ import type { SidebarPanel, } from './ImageCanvasEditorTypes'; import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; +import { + readPerfectPixelOperations, + savePerfectPixelOperation, +} from './perfectPixelOperationStore'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { inspectPerfectPixelProjectSnapshot, @@ -2615,9 +2619,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(events.slice(0, 3)).toEqual(['snapshot', 'flush', 'post']); expect(flushProjectPersistence).toHaveBeenCalledTimes(1); expect(flushProjectPersistence).toHaveBeenCalledWith({ - requireSuccess: true, - requiredDialogId: flushedOperation!.operationId, - deadlineAt: expect.any(Number), + preferLatestGenerationDialogs: true, }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); expect(flushedOperation).toEqual( @@ -2901,14 +2903,10 @@ describe('useImageCanvasGenerationWorkflow', () => { ).toHaveLength(1); expect(flushProjectPersistence).toHaveBeenCalledTimes(2); expect(flushProjectPersistence).toHaveBeenNthCalledWith(1, { - requireSuccess: true, - requiredDialogId: firstOperation.operationId, - deadlineAt: expect.any(Number), + preferLatestGenerationDialogs: true, }); expect(flushProjectPersistence).toHaveBeenNthCalledWith(2, { - requireSuccess: true, - requiredDialogId: firstOperation.operationId, - deadlineAt: expect.any(Number), + preferLatestGenerationDialogs: true, }); }); @@ -3146,7 +3144,7 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); - it('keeps a confirmed source operation for exact retry when strict layout persistence fails before POST', async () => { + it('still posts and keeps the operation for exact retry when layout persistence fails first', async () => { const applyProjectSnapshot = vi.fn(); const flushProjectPersistence = vi .fn() @@ -3185,25 +3183,38 @@ describe('useImageCanvasGenerationWorkflow', () => { fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + // 中文注释:阶段 3 的核心行为翻转。账本已经先落本机,布局保存失败只影响画布同步, + // POST 照发——旧的严格保存会在这里把整条链路拦死。 await waitFor(() => { - expect(screen.getByTestId('dialog').textContent).toContain('failed'); + expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); }); - const failedOperation = JSON.parse( + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toContain( + 'pending-confirmation', + ); + }); + expect(flushProjectPersistence).toHaveBeenCalledTimes(1); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + + const postedOperation = JSON.parse( screen.getByTestId('perfect-pixel-operation').textContent!, ) as { operationId: string; taskId: string; request: EditorPixelArtSnapInput; }; - expect(failedOperation.request.sourceImageSrc).toBe( + expect(postedOperation.request.sourceImageSrc).toBe( 'generated-images/editor/confirmed-source.png', ); - expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); - expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); - expect(loadEditorProjectMock).not.toHaveBeenCalled(); - expect(applyProjectSnapshot).not.toHaveBeenCalled(); - expect(screen.getByTestId('dialog-error').textContent).toBe( - '完美像素请求尚未发出:画布保存失败 请在原占位重试同一操作。', + expect(postedOperation.taskId).toBe( + `pixel-art-snap-${postedOperation.operationId}`, + ); + const postedRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as + | EditorPixelArtSnapInput + | undefined; + expect(JSON.stringify(postedRequest)).toBe( + JSON.stringify(postedOperation.request), ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); @@ -3211,25 +3222,6 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '该素材已有一条未收口的完美像素操作,请在原占位上继续核对或重试。', ); - - fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); - await waitFor(() => { - expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); - }); - const retriedRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as - | EditorPixelArtSnapInput - | undefined; - expect(JSON.stringify(retriedRequest)).toBe( - JSON.stringify(failedOperation.request), - ); - expect(retriedRequest?.canvasCompletion.dialogId).toBe( - failedOperation.operationId, - ); - expect(failedOperation.taskId).toBe( - `pixel-art-snap-${failedOperation.operationId}`, - ); - expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); - expect(flushProjectPersistence).toHaveBeenCalledTimes(2); }); it('reconciles a responded failure that the server marked as post-persistence', async () => { @@ -3423,6 +3415,61 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); }); + it('reconciles an orphan local ledger entry whose placeholder never reached the layout', async () => { + // 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进 + // 布局就断电」的窗口。刷新后必须仍然对账并告知用户结果去了素材库——这正是删掉严格 + // 布局保存之后仍然安全的原因。 + const operationId = 'perfect-pixel-orphan-ledger'; + const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); + 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: Date.now(), + reconcileUntil: Date.now() + 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).toHaveBeenCalled(); + expect(refreshAssetLibrary).toHaveBeenCalled(); + // 中文注释:收口后账本必须清掉,否则下次加载会再发一次无谓的对账 GET。 + expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0); + }); + 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 dbae9d7da..2e9563d9b 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -116,6 +116,11 @@ import { type UiAssetExtractionTool, updateUiAssetExtractionDraftMark, } from './ImageCanvasUiAssetExtractionModel'; +import { + forgetPerfectPixelOperation, + readPerfectPixelOperations, + savePerfectPixelOperation, +} from './perfectPixelOperationStore'; import { applyQueuedEditorGenerationProject, createEditorGenerationMediaUploadId, @@ -220,17 +225,15 @@ function createProjectLayerSnapshotFromLayer( }; } -// 中文注释:源图解析/直传与 strict operation journal 使用两段独立预算。 +// 中文注释:源图可能需要 ticket → PUT → confirm,90 秒预算和同一 AbortSignal 覆盖完整 +// 上传;confirm 成功后已经有稳定 objectKey,operation 请求随即形成。 // -// 源图可能需要 ticket → PUT → confirm,90 秒预算和同一 AbortSignal 覆盖完整上传;confirm -// 成功后已经有稳定 objectKey,operation 请求随即形成。此后 strict layout save 使用独立 -// 60 秒绝对 deadline,不能继续消耗源上传的余额,也不能在本地放弃等待后继续占住保存队列。 +// 这一段之前 POST 必须为零:失败时保留 `failed + perfectPixelOperation`,允许原样重试同一 +// request/source object,也不能执行结果 GET 或声称派生素材可能已落库。 // -// strict ACK 之前 POST 必须为零。此阶段失败保留 `failed + perfectPixelOperation`,只允许 -// 原样重试同一 request/source object;不能退回普通按钮创建新 identity,也不能执行结果 GET -// 或声称派生素材可能已落库。 +// 账本落到本机(perfectPixelOperationStore)之后,这一段与 POST 之间不再有任何需要等待 +// 服务端 ACK 的环节——原先的严格布局保存预算随之删除。 export const PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS = 90_000; -export const PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS = 60_000; const PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS = 10_000; const PERFECT_PIXEL_RECONCILIATION_DELAYS_MS = [0, 1_000, 2_000, 4_000, 5_000]; @@ -931,10 +934,7 @@ type GenerationWorkflowOptions = { project: EditorProjectSnapshot, ) => boolean | void; flushProjectPersistence?: (options?: { - requireSuccess?: boolean; - requiredDialogId?: string; - deadlineAt?: number; - signal?: AbortSignal; + preferLatestGenerationDialogs?: boolean; }) => Promise; refreshAssetLibrary?: () => Promise | void; onWalletBalanceMayHaveChanged?: () => void; @@ -2145,6 +2145,13 @@ export function useImageCanvasGenerationWorkflow({ if (verdict.kind !== 'applied' && verdict.kind !== 'dialog-missing') { return false; } + // 中文注释:项目事实已给出终态,账本再无用处。留着只会让下次加载多发一次无谓的 + // 对账 GET,并占住保留期名额。 + forgetPerfectPixelOperation( + currentUserId, + operation.request.projectId, + operation.operationId, + ); const confirmedAsset = resolveConfirmedPerfectPixelAsset( result, operation, @@ -2177,6 +2184,7 @@ export function useImageCanvasGenerationWorkflow({ }, [ applyProjectSnapshot, + currentUserId, hasCanvasGenerationDialogById, refreshPerfectPixelAssetLibrary, setActiveSidebarPanel, @@ -2355,13 +2363,22 @@ export function useImageCanvasGenerationWorkflow({ updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({ ...dialog, requiresLiveSession: undefined, + perfectPixelOperationId: perfectPixelDialogId, perfectPixelOperation, })); + // 中文注释:账本必须先于 POST 落到本机。这一步是同步的、不过网络、不受服务端 + // 校验影响,因此它能提供「请求可被追溯」的保证,却不会像旧的严格布局保存那样 + // 把布局校验失败升级成完美像素的硬阻断。 + savePerfectPixelOperation( + currentUserId, + normalizedProjectId, + perfectPixelOperation, + ); + // 中文注释:布局保存尽力而为——占位存进服务端后,服务端才能用 canvasCompletion + // 就地替换它。保存失败不拦 POST,只是把结果降级成「只进素材库」,由对账提示用户。 await flushProjectPersistence({ - requireSuccess: true, - requiredDialogId: perfectPixelDialogId, - deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS, - }); + preferLatestGenerationDialogs: true, + }).catch(() => undefined); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; } @@ -2469,7 +2486,7 @@ export function useImageCanvasGenerationWorkflow({ const errorMessage = reconciledMessage ? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请稍后继续核对,或显式重试同一操作。` : perfectPixelOperation && !perfectPixelPostAttempted - ? `完美像素请求尚未发出:${serverMessage ?? '提交前画布保存失败'} 请在原占位重试同一操作。` + ? `完美像素请求尚未发出:${serverMessage ?? '提交前准备失败'} 请在原占位重试,或删除占位后重来。` : (serverMessage ?? '完美像素处理失败'); if ( perfectPixelDialogId && @@ -2481,10 +2498,16 @@ export function useImageCanvasGenerationWorkflow({ errorMessage, })); } else { - // 中文注释:durable operation 的普通删除路径已被禁止,但占位仍可能在快照写入前 - // 失败、被其它权威项目更新移除,或来自旧会话状态。没有本地 dialog 可挂错误时必须 - // 退回全局提示,不能静默开放一条新 identity 的重提路径。 + // 中文注释:占位可能在快照写入前失败、被用户删除、被其它权威项目更新移除,或来自 + // 旧会话状态。没有本地 dialog 可挂错误时退回全局提示。 showGenerationWarning(errorMessage); + if (perfectPixelDialogId) { + forgetPerfectPixelOperation( + currentUserId, + normalizedProjectId, + perfectPixelDialogId, + ); + } } } finally { if (perfectPixelDialogId) { @@ -2589,13 +2612,17 @@ export function useImageCanvasGenerationWorkflow({ status: 'generating', errorMessage: undefined, requiresLiveSession: undefined, + perfectPixelOperationId: normalizedDialogId, perfectPixelOperation: retriedOperation, })); + savePerfectPixelOperation( + currentUserId, + normalizedProjectId, + retriedOperation, + ); await flushProjectPersistence({ - requireSuccess: true, - requiredDialogId: normalizedDialogId, - deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS, - }); + preferLatestGenerationDialogs: true, + }).catch(() => undefined); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; } @@ -2732,19 +2759,36 @@ export function useImageCanvasGenerationWorkflow({ perfectPixelRecoveryControllersRef.current.clear(); return; } - const recoverableDialogs = canvasGenerationDialogs.filter( - (dialog) => - Boolean(dialog.perfectPixelOperation) && - (dialog.status === 'generating' || - dialog.status === 'pending-confirmation'), + const recoverableOperations = canvasGenerationDialogs.flatMap((dialog) => + dialog.perfectPixelOperation && + (dialog.status === 'generating' || + dialog.status === 'pending-confirmation') + ? [{ dialogId: dialog.id, operation: dialog.perfectPixelOperation }] + : [], ); + // 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进 + // 布局就断电」的窗口。那些孤儿账本同样要对账——结果多半已经落进素材库,用户有权知道。 + // 这条路径不需要占位存在:下面所有 updateCanvasGenerationDialogById 对缺席 dialog 都是 + // 无操作,applied / dialog-missing 分支本就带着「占位不在」的提示文案。 + const recoverableDialogIds = 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 + ) { + continue; + } + recoverableOperations.push({ dialogId: operationId, operation }); + } const recoveryKeys = new Set( - recoverableDialogs.map((dialog) => - perfectPixelRecoveryKey( - currentUserId, - normalizedProjectId, - dialog.perfectPixelOperation!, - ), + recoverableOperations.map(({ operation }) => + perfectPixelRecoveryKey(currentUserId, normalizedProjectId, operation), ), ); for (const [ @@ -2757,8 +2801,7 @@ export function useImageCanvasGenerationWorkflow({ } } - for (const dialog of recoverableDialogs) { - const operation = dialog.perfectPixelOperation!; + for (const { dialogId, operation } of recoverableOperations) { const recoveryKey = perfectPixelRecoveryKey( currentUserId, normalizedProjectId, @@ -2766,7 +2809,7 @@ export function useImageCanvasGenerationWorkflow({ ); // 中文注释:本页首次 POST / 人工重试已有自己的 Promise 收口。effect 会在 operation // 写入后重跑,必须把该 key 记为本会话已观察并跳过,否则会并发启动第二条 GET 轮询。 - if (hasActiveInlineGenerationDialog(dialog.id)) { + if (hasActiveInlineGenerationDialog(dialogId)) { observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey); continue; } @@ -2781,7 +2824,12 @@ export function useImageCanvasGenerationWorkflow({ perfectPixelRecoveryControllersRef.current.set(recoveryKey, controller); void (async () => { if (operation.request.projectId !== normalizedProjectId) { - updateCanvasGenerationDialogById(dialog.id, (current) => { + forgetPerfectPixelOperation( + currentUserId, + normalizedProjectId, + operation.operationId, + ); + updateCanvasGenerationDialogById(dialogId, (current) => { if ( !isSameUnsettledPerfectPixelOperation( current, @@ -2817,15 +2865,23 @@ export function useImageCanvasGenerationWorkflow({ ) { return; } + if (verdict.kind === 'applied' || verdict.kind === 'dialog-missing') { + // 中文注释:项目事实已给出终态,账本收口清掉,避免下次加载重复对账。 + forgetPerfectPixelOperation( + currentUserId, + normalizedProjectId, + operation.operationId, + ); + } if (verdict.kind === 'applied') { - if (!hasCanvasGenerationDialogById(dialog.id)) { + if (!hasCanvasGenerationDialogById(dialogId)) { refreshPerfectPixelAssetLibrary(); showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE); return; } const applied = applyProjectSnapshotWithoutHistory(verdict.project); if (applied === false) { - updateCanvasGenerationDialogById(dialog.id, (current) => + updateCanvasGenerationDialogById(dialogId, (current) => isSameUnsettledPerfectPixelOperation( current, operation.operationId, @@ -2844,8 +2900,8 @@ export function useImageCanvasGenerationWorkflow({ return; } if (verdict.kind === 'dialog-missing') { - if (hasCanvasGenerationDialogById(dialog.id)) { - updateCanvasGenerationDialogById(dialog.id, (current) => + if (hasCanvasGenerationDialogById(dialogId)) { + updateCanvasGenerationDialogById(dialogId, (current) => isSameUnsettledPerfectPixelOperation( current, operation.operationId, @@ -2858,7 +2914,7 @@ export function useImageCanvasGenerationWorkflow({ showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE); return; } - updateCanvasGenerationDialogById(dialog.id, (current) => + updateCanvasGenerationDialogById(dialogId, (current) => isSameUnsettledPerfectPixelOperation(current, operation.operationId) ? { ...current, @@ -2881,9 +2937,9 @@ export function useImageCanvasGenerationWorkflow({ .catch((error: unknown) => { if ( !controller.signal.aborted && - hasCanvasGenerationDialogById(dialog.id) + hasCanvasGenerationDialogById(dialogId) ) { - updateCanvasGenerationDialogById(dialog.id, (current) => + updateCanvasGenerationDialogById(dialogId, (current) => isSameUnsettledPerfectPixelOperation( current, operation.operationId, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 495d66c29..a18d8626d 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -800,14 +800,12 @@ const STRICT_PLACEHOLDER = { originalHeight: 240, }; -function StrictProjectPersistenceHarness({ +function PerfectPixelProjectPersistenceHarness({ canAccessProtectedData = true, operationSourceImageSrc = 'resource-source', - strictSaveBudgetMs = 60_000, }: { canAccessProtectedData?: boolean; operationSourceImageSrc?: string; - strictSaveBudgetMs?: number; }) { const [layers, setLayers] = useState([]); const [viewport, setViewport] = useState({ @@ -949,11 +947,7 @@ function StrictProjectPersistenceHarness({ placeholder: { ...STRICT_PLACEHOLDER }, }); void persistence - .flushProjectPersistence({ - requireSuccess: true, - requiredDialogId: STRICT_DIALOG_ID, - deadlineAt: Date.now() + strictSaveBudgetMs, - }) + .flushProjectPersistence({ preferLatestGenerationDialogs: true }) .then( () => { setStrictStatus('resolved'); @@ -965,7 +959,7 @@ function StrictProjectPersistenceHarness({ ); }} > - open and strictly flush perfect pixel + open and flush perfect pixel ); @@ -1029,18 +1023,8 @@ describe('useImageCanvasProjectPersistence', () => { }); }); - it('serializes a just-opened perfect-pixel operation and waits only for its revision ack', async () => { - const coverSnapshot = createDeferred(); - const layoutAck = createDeferred<{ - projectId: string; - canvasId: string; - revision: number; - updatedAt: string; - }>(); - createProjectCoverSnapshotBlobMock.mockReturnValue(coverSnapshot.promise); - saveEditorProjectLayoutMock.mockReturnValueOnce(layoutAck.promise); - - render(); + it('persists the perfect-pixel marker without leaking the request ledger into the layout', async () => { + render(); await waitFor(() => { expect(screen.getByTestId('strict-project-id').textContent).toBe( 'editor-project-default', @@ -1049,158 +1033,58 @@ describe('useImageCanvasProjectPersistence', () => { act(() => { screen - .getByRole('button', { - name: 'open and strictly flush perfect pixel', - }) + .getByRole('button', { name: 'open and flush perfect pixel' }) .click(); }); await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + expect(saveEditorProjectLayoutMock).toHaveBeenCalled(); }); + const [, layoutInput] = saveEditorProjectLayoutMock.mock.calls[0] as [ + string, + { layers: unknown[] }, + ]; + const dialogItem = layoutInput.layers.find( + (item) => + (item as { layerId?: string }).layerId === + `generation-dialog:${STRICT_DIALOG_ID}`, + ); + // 中文注释:`preferLatestGenerationDialogs` 让刚创建的占位不会因为 ref 落后一帧而漏存。 + expect(dialogItem).toBeTruthy(); + expect( + (dialogItem as { dialog: Record }).dialog, + ).toMatchObject({ + id: STRICT_DIALOG_ID, + status: 'generating', + perfectPixelOperationId: STRICT_DIALOG_ID, + }); + // 中文注释:请求账本只在本机,布局里连源图地址都不该出现。 + expect(JSON.stringify(layoutInput.layers)).not.toContain( + '"perfectPixelOperation"', + ); + expect(JSON.stringify(layoutInput.layers)).not.toContain('resource-source'); expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( 1, 'editor-project-default', - expect.objectContaining({ - expectedRevision: 0, - layers: expect.arrayContaining([ - expect.objectContaining({ - itemType: 'generation-dialog', - layerId: `generation-dialog:${STRICT_DIALOG_ID}`, - dialog: expect.objectContaining({ - id: STRICT_DIALOG_ID, - status: 'generating', - perfectPixelOperation: { - version: 1, - kind: 'perfect-pixel', - operationId: STRICT_DIALOG_ID, - taskId: STRICT_TASK_ID, - submittedAt: 1_771_400_000_000, - reconcileUntil: 1_771_400_075_000, - request: { - sourceImageSrc: 'resource-source', - projectId: 'editor-project-default', - sourceResourceId: 'resource-source', - assetKind: 'icon', - assetLabel: '源图 · 完美像素', - canvasCompletion: { - dialogId: STRICT_DIALOG_ID, - title: '源图 · 完美像素', - placeholder: STRICT_PLACEHOLDER, - }, - }, - }, - }), - }), - ]), - }), - expect.objectContaining({ - signal: expect.any(AbortSignal), - deadlineAt: expect.any(Number), - }), - ); - expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'pending', - ); - expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( - '0', - ); - - await act(async () => { - layoutAck.resolve({ - projectId: 'editor-project-default', - canvasId: 'editor-project-default:canvas:default', - revision: 1, - updatedAt: '2026-08-03T00:00:01.000Z', - }); - await layoutAck.promise; - }); - - await waitFor(() => { - expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'resolved', - ); - }); - expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( - '1', + expect.anything(), ); }); - it('releases a timed-out strict save locally and ignores its late resolution', async () => { - const lateLayoutAck = createDeferred<{ - projectId: string; - canvasId: string; - revision: number; - updatedAt: string; - }>(); - saveEditorProjectLayoutMock.mockReturnValueOnce(lateLayoutAck.promise); - - render(); - await waitFor(() => { - expect(screen.getByTestId('strict-project-id').textContent).toBe( - 'editor-project-default', - ); - }); - - act(() => { - screen - .getByRole('button', { - name: 'open and strictly flush perfect pixel', - }) - .click(); - }); - await waitFor(() => { - expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'rejected', - ); - }); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( - '0', - ); - - act(() => { - screen - .getByRole('button', { - name: 'open and strictly flush perfect pixel', - }) - .click(); - }); - await waitFor(() => { - expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'resolved', - ); - }); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); - expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( - '1', - ); - - await act(async () => { - lateLayoutAck.resolve({ - projectId: 'editor-project-default', - canvasId: 'editor-project-default:canvas:default', - revision: 1, - updatedAt: '2026-08-04T00:00:00.000Z', - }); - await lateLayoutAck.promise; - }); - expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( - '1', - ); - }); - - it.each([400, 403])( - 'rejects strict layout persistence on HTTP %s without retrying or posting', - async (status) => { - saveEditorProjectLayoutMock.mockRejectedValueOnce( + it.each([ + ['400', 400], + ['403', 403], + ])( + 'never blocks the caller when layout persistence fails with HTTP %s', + async (_label, status) => { + saveEditorProjectLayoutMock.mockRejectedValue( new ApiClientError({ - message: '布局提交被拒绝', + message: '布局保存被拒绝', status, - code: `HTTP_${status}`, + code: 'layout_rejected', }), ); - render(); + + render(); await waitFor(() => { expect(screen.getByTestId('strict-project-id').textContent).toBe( 'editor-project-default', @@ -1209,255 +1093,40 @@ describe('useImageCanvasProjectPersistence', () => { act(() => { screen - .getByRole('button', { - name: 'open and strictly flush perfect pixel', - }) + .getByRole('button', { name: 'open and flush perfect pixel' }) .click(); }); + // 中文注释:阶段 3 的核心断言——布局保存失败只是画布没同步上去,绝不能把下游 + // 完美像素 POST 拦下来。账本已经先落本机,请求仍然可追溯。 await waitFor(() => { expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'rejected', + 'resolved', ); }); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); expect( screen.getByTestId('strict-downstream-post-count').textContent, - ).toBe('0'); + ).toBe('1'); }, ); - it('rejects strict layout persistence without authority and never posts', async () => { - render(); - - act(() => { - screen - .getByRole('button', { - name: 'open and strictly flush perfect pixel', - }) - .click(); - }); - - await waitFor(() => { - expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'rejected', - ); - }); - expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); - expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( - '0', - ); - }); - - it('rejects a malformed perfect-pixel operation before sending layout persistence', async () => { + it('never blocks the caller when the project has no authority', async () => { render( - , + , ); - await waitFor(() => { - expect(screen.getByTestId('strict-project-id').textContent).toBe( - 'editor-project-default', - ); - }); act(() => { screen - .getByRole('button', { - name: 'open and strictly flush perfect pixel', - }) + .getByRole('button', { name: 'open and flush perfect pixel' }) .click(); }); - await waitFor(() => { - expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'rejected', - ); - }); - expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); - expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( - '0', - ); - }); - - it('rejects strict layout persistence after transport retries are exhausted', async () => { - render(); - await waitFor(() => { - expect(screen.getByTestId('strict-project-id').textContent).toBe( - 'editor-project-default', - ); - }); - saveEditorProjectLayoutMock.mockClear(); - saveEditorProjectLayoutMock.mockRejectedValue( - new TypeError('connection dropped'), - ); - vi.useFakeTimers(); - - try { - act(() => { - screen - .getByRole('button', { - name: 'open and strictly flush perfect pixel', - }) - .click(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); - - await act(async () => { - await vi.advanceTimersByTimeAsync(1_000); - }); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); - await act(async () => { - await vi.advanceTimersByTimeAsync(2_000); - }); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(3); - await act(async () => { - await vi.advanceTimersByTimeAsync(4_000); - }); - - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(4); - expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'rejected', - ); - expect( - screen.getByTestId('strict-downstream-post-count').textContent, - ).toBe('0'); - } finally { - vi.useRealTimers(); - } - }); - - it('rejects a strict conflict when authoritative reload fails without scheduling background recovery', async () => { - const conflict = new ApiClientError({ - message: '图片画布版本冲突', - status: 409, - code: 'conflict', - }); - saveEditorProjectLayoutMock.mockRejectedValueOnce(conflict); - loadEditorProjectMock.mockRejectedValueOnce( - new TypeError('authoritative reload failed'), - ); - render(); - await waitFor(() => { - expect(screen.getByTestId('strict-project-id').textContent).toBe( - 'editor-project-default', - ); - }); - - act(() => { - screen - .getByRole('button', { - name: 'open and strictly flush perfect pixel', - }) - .click(); - }); - - await waitFor(() => { - expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'rejected', - ); - }); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); - expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( - '0', - ); - - vi.useFakeTimers(); - try { - await act(async () => { - await vi.advanceTimersByTimeAsync(30_000); - }); - expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('replays a strict conflict and waits for the second revision ack before posting', async () => { - const conflict = new ApiClientError({ - message: '图片画布版本冲突', - status: 409, - code: 'conflict', - }); - const replayAck = createDeferred<{ - projectId: string; - canvasId: string; - revision: number; - updatedAt: string; - }>(); - saveEditorProjectLayoutMock - .mockRejectedValueOnce(conflict) - .mockReturnValueOnce(replayAck.promise); - loadEditorProjectMock.mockResolvedValueOnce( - createEmptyEditorProjectSnapshot(1, '其它标签页已保存'), - ); - render(); - await waitFor(() => { - expect(screen.getByTestId('strict-project-id').textContent).toBe( - 'editor-project-default', - ); - }); - - act(() => { - screen - .getByRole('button', { - name: 'open and strictly flush perfect pixel', - }) - .click(); - }); - - await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); - }); - expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( - 2, - 'editor-project-default', - expect.objectContaining({ - expectedRevision: 1, - layers: expect.arrayContaining([ - expect.objectContaining({ - itemType: 'generation-dialog', - dialog: expect.objectContaining({ - id: STRICT_DIALOG_ID, - perfectPixelOperation: expect.objectContaining({ - kind: 'perfect-pixel', - operationId: STRICT_DIALOG_ID, - }), - }), - }), - ]), - }), - expect.objectContaining({ - signal: expect.any(AbortSignal), - deadlineAt: expect.any(Number), - }), - ); - expect(screen.getByTestId('strict-save-status').textContent).toBe( - 'pending', - ); - expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( - '0', - ); - - await act(async () => { - replayAck.resolve({ - projectId: 'editor-project-default', - canvasId: 'editor-project-default:canvas:default', - revision: 2, - updatedAt: '2026-08-03T00:00:02.000Z', - }); - await replayAck.promise; - }); - await waitFor(() => { expect(screen.getByTestId('strict-save-status').textContent).toBe( 'resolved', ); }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( '1', ); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 843159055..d080f5b07 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -25,7 +25,6 @@ import { DEFAULT_CANVAS_BACKGROUND_COLOR, dropDeadInlineGenerationPlaceholders, hydrateLayer, - hydratePerfectPixelOperation, isInlineEditorMediaSource, resolveLayerResourceAssetKind, serializeCanvasLayout, @@ -50,6 +49,7 @@ import { firstSelectedLayerId, normalizeCanvasSelectionIds, } from './ImageCanvasSelectionModel'; +import { readPerfectPixelOperations } from './perfectPixelOperationStore'; type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; @@ -73,19 +73,6 @@ type PendingCreatedProjectResourceLayer = { resourceId: string; }; -type StrictProjectLayoutSaveCompletion = { - requiredDialogId: string; - requiredProjectId: string; - deadlineAt: number; - signal: AbortSignal; - promise: Promise; - resolve: () => void; - reject: (error: unknown) => void; - abort: (reason: unknown) => void; - cleanup: () => void; - settled: boolean; -}; - type PendingProjectLayoutSave = { projectId: string; input: Omit< @@ -94,7 +81,6 @@ type PendingProjectLayoutSave = { >; attemptExpectedRevision?: number; transportRetries?: number; - strictCompletion?: StrictProjectLayoutSaveCompletion; }; type ActiveProjectLayoutSaveAttempt = { @@ -279,188 +265,6 @@ function isRetryableEditorProjectLayoutSaveError(error: unknown) { ); } -function createStrictProjectLayoutSaveCompletion( - requiredDialogId: string, - requiredProjectId: string, - options: { - deadlineAt: number; - signal?: AbortSignal; - }, -): StrictProjectLayoutSaveCompletion { - let resolvePromise!: () => void; - let rejectPromise!: (error: unknown) => void; - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - // strict completion 可能在进入队列前因 deadline、authority 或输入校验失败;外层 flush - // 会返回同一错误,但内部 promise 此时尚无 await,先登记 rejection handler 避免误报。 - void promise.catch(() => {}); - const controller = new AbortController(); - let timeoutId: ReturnType | undefined; - const completion: StrictProjectLayoutSaveCompletion = { - requiredDialogId, - requiredProjectId, - deadlineAt: options.deadlineAt, - signal: controller.signal, - promise, - resolve: resolvePromise, - reject: rejectPromise, - abort: (reason) => { - if (!controller.signal.aborted) { - controller.abort( - reason instanceof Error - ? reason - : new Error('图片画布严格布局保存已取消'), - ); - } - }, - cleanup: () => {}, - settled: false, - }; - const onSourceAbort = () => { - completion.abort( - options.signal?.reason ?? new Error('图片画布严格布局保存已取消'), - ); - }; - const onCompletionAbort = () => { - rejectStrictProjectLayoutSave( - { strictCompletion: completion }, - controller.signal.reason, - ); - }; - completion.cleanup = () => { - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - } - options.signal?.removeEventListener('abort', onSourceAbort); - controller.signal.removeEventListener('abort', onCompletionAbort); - }; - controller.signal.addEventListener('abort', onCompletionAbort, { - once: true, - }); - if (options.signal?.aborted) { - onSourceAbort(); - } else { - options.signal?.addEventListener('abort', onSourceAbort, { once: true }); - } - if (completion.settled) { - return completion; - } - const remainingMs = options.deadlineAt - Date.now(); - const timeoutError = new Error('完美像素提交前的画布保存超时。'); - timeoutError.name = 'TimeoutError'; - if (remainingMs <= 0) { - completion.abort(timeoutError); - } else { - timeoutId = setTimeout(() => { - completion.abort(timeoutError); - }, remainingMs); - } - return completion; -} - -function resolveStrictProjectLayoutSave( - save: Pick, -) { - const completion = save.strictCompletion; - if (!completion || completion.settled) { - return; - } - completion.settled = true; - completion.cleanup(); - completion.resolve(); -} - -function rejectStrictProjectLayoutSave( - save: Pick, - error: unknown, -) { - const completion = save.strictCompletion; - if (!completion || completion.settled) { - return; - } - completion.settled = true; - const normalizedError = - error instanceof Error ? error : new Error('图片画布严格布局保存失败'); - completion.cleanup(); - completion.abort(normalizedError); - completion.reject(normalizedError); -} - -function strictProjectLayoutSaveCanContinue( - completion: StrictProjectLayoutSaveCompletion | undefined, -) { - if (!completion) { - return true; - } - if (completion.settled || completion.signal.aborted) { - return false; - } - if (completion.deadlineAt <= Date.now()) { - const error = new Error('完美像素提交前的画布保存超时。'); - error.name = 'TimeoutError'; - completion.abort(error); - return false; - } - return true; -} - -function awaitProjectLayoutSaveAttempt( - work: Promise, - signal: AbortSignal, -): Promise { - return new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => { - signal.removeEventListener('abort', onAbort); - }; - const settle = (callback: () => void) => { - if (settled) { - return; - } - settled = true; - cleanup(); - callback(); - }; - const onAbort = () => { - settle(() => - reject(signal.reason ?? new Error('图片画布严格布局保存已取消')), - ); - }; - signal.addEventListener('abort', onAbort, { once: true }); - work.then( - (value) => settle(() => resolve(value)), - (error: unknown) => settle(() => reject(error)), - ); - if (signal.aborted) { - onAbort(); - } - }); -} - -function projectLayoutContainsRequiredDialogOperation( - layers: readonly EditorProjectLayerSnapshot[], - requiredDialogId: string, - requiredProjectId: string, -) { - return layers.some((item) => { - if (item.itemType !== 'generation-dialog') { - return false; - } - const dialog = (item as { dialog?: CanvasGenerationDialogState }).dialog; - const operation = hydratePerfectPixelOperation( - dialog?.perfectPixelOperation, - requiredDialogId, - ); - return ( - dialog?.id === requiredDialogId && - operation !== null && - operation.request.projectId === requiredProjectId - ); - }); -} - function isLocalProjectResourceId(resourceId: string) { return resourceId.startsWith('local-'); } @@ -769,14 +573,7 @@ export function useImageCanvasProjectPersistence({ coverSnapshotViewportSizeRef.current = canvasSize; const clearPendingProjectLayoutSave = useCallback(() => { - const pendingSave = pendingProjectLayoutSaveRef.current; pendingProjectLayoutSaveRef.current = null; - if (pendingSave?.strictCompletion) { - rejectStrictProjectLayoutSave( - pendingSave, - new Error('图片画布严格布局保存被权威状态更新中断'), - ); - } if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; @@ -792,10 +589,6 @@ export function useImageCanvasProjectPersistence({ if (!pendingSave) { return; } - if (!strictProjectLayoutSaveCanContinue(pendingSave.strictCompletion)) { - pendingProjectLayoutSaveRef.current = null; - return; - } const expectedRevision = pendingSave.attemptExpectedRevision ?? projectRevisionRef.current; if ( @@ -804,10 +597,6 @@ export function useImageCanvasProjectPersistence({ expectedRevision === null ) { pendingProjectLayoutSaveRef.current = null; - rejectStrictProjectLayoutSave( - pendingSave, - new Error('图片画布缺少可用的权威 revision,无法确认布局保存'), - ); return; } @@ -835,31 +624,15 @@ export function useImageCanvasProjectPersistence({ ...pendingSave.input, expectedRevision, }; - const saveRequest = attemptedSave.strictCompletion - ? saveEditorProjectLayout(pendingSave.projectId, saveInput, { - signal: attemptedSave.strictCompletion.signal, - deadlineAt: attemptedSave.strictCompletion.deadlineAt, - }) - : saveEditorProjectLayout(pendingSave.projectId, saveInput); - const boundedSaveRequest = attemptedSave.strictCompletion - ? awaitProjectLayoutSaveAttempt( - saveRequest, - attemptedSave.strictCompletion.signal, - ) - : saveRequest; - const savePromise = boundedSaveRequest + const savePromise = saveEditorProjectLayout( + pendingSave.projectId, + saveInput, + ) .then((result) => { const acknowledgedRevision = result && typeof result.revision === 'number' ? result.revision : null; - const strictInputIsValid = - !attemptedSave.strictCompletion || - projectLayoutContainsRequiredDialogOperation( - attemptedSave.input.layers, - attemptedSave.strictCompletion.requiredDialogId, - attemptedSave.strictCompletion.requiredProjectId, - ); if ( saveStillBelongsToCurrentAuthority() && acknowledgedRevision !== null && @@ -873,51 +646,15 @@ export function useImageCanvasProjectPersistence({ }), ); } - if (attemptedSave.strictCompletion) { - if (!saveStillBelongsToCurrentAuthority()) { - rejectStrictProjectLayoutSave( - attemptedSave, - new Error('图片画布保存期间已失去当前项目 authority'), - ); - } else if (!strictInputIsValid) { - rejectStrictProjectLayoutSave( - attemptedSave, - new Error( - `严格布局保存未包含完美像素操作 ${attemptedSave.strictCompletion.requiredDialogId}`, - ), - ); - } else if ( - acknowledgedRevision === null || - acknowledgedRevision <= expectedRevision - ) { - rejectStrictProjectLayoutSave( - attemptedSave, - new Error('图片画布布局保存未返回有效的 revision ack'), - ); - } else { - resolveStrictProjectLayoutSave(attemptedSave); - } - } runNextSave = Boolean(pendingProjectLayoutSaveRef.current); }) .catch(async (error: unknown) => { - if ( - !strictProjectLayoutSaveCanContinue(attemptedSave.strictCompletion) - ) { - runNextSave = Boolean(pendingProjectLayoutSaveRef.current); - return; - } if (!saveStillBelongsToCurrentAuthority()) { - rejectStrictProjectLayoutSave( - attemptedSave, - new Error('图片画布保存期间已失去当前项目 authority'), - ); runNextSave = Boolean(pendingProjectLayoutSaveRef.current); return; } if (isEditorAuthError(error)) { openEditorLoginModal(); - rejectStrictProjectLayoutSave(attemptedSave, error); return; } if (isEditorProjectRevisionConflict(error)) { @@ -963,32 +700,13 @@ export function useImageCanvasProjectPersistence({ ); }; try { - const latestProject = attemptedSave.strictCompletion - ? await loadEditorProject(pendingSave.projectId, { - signal: attemptedSave.strictCompletion.signal, - deadlineAt: attemptedSave.strictCompletion.deadlineAt, - }) - : await loadEditorProject(pendingSave.projectId); - const applied = applyLatestProject(latestProject); - if ( - attemptedSave.strictCompletion && - (!applied || - pendingProjectLayoutSaveRef.current?.strictCompletion !== - attemptedSave.strictCompletion) - ) { - rejectStrictProjectLayoutSave( - attemptedSave, - new Error('图片画布冲突恢复后未能重放指定的完美像素操作'), - ); - } + const latestProject = await loadEditorProject( + pendingSave.projectId, + ); + applyLatestProject(latestProject); } catch (reloadError: unknown) { if (isEditorAuthError(reloadError)) { openEditorLoginModal(); - rejectStrictProjectLayoutSave(attemptedSave, reloadError); - return; - } - if (attemptedSave.strictCompletion) { - rejectStrictProjectLayoutSave(attemptedSave, reloadError); return; } scheduleAuthoritativeReload(0); @@ -996,40 +714,12 @@ export function useImageCanvasProjectPersistence({ return; } if (!isRetryableEditorProjectLayoutSaveError(error)) { - rejectStrictProjectLayoutSave(attemptedSave, error); if (pendingProjectLayoutSaveRef.current) { runNextSave = true; } return; } if (pendingProjectLayoutSaveRef.current) { - if (attemptedSave.strictCompletion) { - const nextPendingSave = pendingProjectLayoutSaveRef.current; - if ( - nextPendingSave.strictCompletion && - nextPendingSave.strictCompletion !== - attemptedSave.strictCompletion - ) { - rejectStrictProjectLayoutSave( - attemptedSave, - new Error('图片画布严格布局保存被另一个严格保存取代'), - ); - } else if ( - projectLayoutContainsRequiredDialogOperation( - nextPendingSave.input.layers, - attemptedSave.strictCompletion.requiredDialogId, - attemptedSave.strictCompletion.requiredProjectId, - ) - ) { - nextPendingSave.strictCompletion = - attemptedSave.strictCompletion; - } else { - rejectStrictProjectLayoutSave( - attemptedSave, - new Error('后续图片画布布局不再包含待确认的完美像素操作'), - ); - } - } runNextSave = true; return; } @@ -1048,8 +738,6 @@ export function useImageCanvasProjectPersistence({ Math.min(1_000 * 2 ** transportRetries, 8_000), ); } - } else { - rejectStrictProjectLayoutSave(attemptedSave, error); } }) .finally(() => { @@ -1202,7 +890,6 @@ export function useImageCanvasProjectPersistence({ options: { delayMs?: number; persistCover?: boolean; - strictCompletion?: StrictProjectLayoutSaveCompletion; } = {}, ) => { const revision = projectRevisionRef.current; @@ -1211,31 +898,11 @@ export function useImageCanvasProjectPersistence({ authoritativeProjectIdRef.current !== nextProjectId || revision === null ) { - if (options.strictCompletion) { - rejectStrictProjectLayoutSave( - { strictCompletion: options.strictCompletion }, - new Error('图片画布缺少可用的项目 authority'), - ); - } return false; } - const previousPendingSave = pendingProjectLayoutSaveRef.current; - if (!strictProjectLayoutSaveCanContinue(options.strictCompletion)) { - return false; - } - if ( - previousPendingSave?.strictCompletion && - previousPendingSave.strictCompletion !== options.strictCompletion - ) { - rejectStrictProjectLayoutSave( - previousPendingSave, - new Error('图片画布严格布局保存被新的布局提交取代'), - ); - } pendingProjectLayoutSaveRef.current = { projectId: nextProjectId, input, - strictCompletion: options.strictCompletion, }; const sessionSnapshot = createEditorProjectSessionCacheSnapshot({ projectId: nextProjectId, @@ -1291,155 +958,82 @@ export function useImageCanvasProjectPersistence({ ], ); + /** + * 中文注释:布局保存只有「尽力而为」这一种语义。 + * + * 曾经存在一条 strict 变体:完美像素在发 POST 前必须拿到布局保存的 revision ack,因为 + * 请求账本当时写在布局里。账本移到本机之后(见 perfectPixelOperationStore)那个前置 + * 条件不再成立,strict 通道连同它引入的阻断一起删除——布局保存失败此后只是「这次画布 + * 状态没同步上去」,不再能拦住任何生成操作。 + * + * `preferLatestGenerationDialogs` 只影响取哪一份占位快照:置位时取本轮 render 之前已 + * 提交的最新占位,避免刚创建的占位因为 ref 落后一帧而漏存。它不改变失败语义。 + */ const flushProjectPersistence = useCallback( async ( options: { - requireSuccess?: boolean; - requiredDialogId?: string; - deadlineAt?: number; - signal?: AbortSignal; + preferLatestGenerationDialogs?: boolean; } = {}, ) => { - const requireSuccess = options.requireSuccess === true; - const requiredDialogId = options.requiredDialogId?.trim() ?? ''; - if (requireSuccess && !requiredDialogId) { - throw new Error('严格布局保存必须指定 generation dialog'); + while (activeProjectLayoutSavePromiseRef.current) { + await activeProjectLayoutSavePromiseRef.current; } + + const nextProjectId = projectIdRef.current; if ( - requireSuccess && - (typeof options.deadlineAt !== 'number' || - !Number.isFinite(options.deadlineAt)) + !nextProjectId || + !canAccessProtectedDataRef.current || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== nextProjectId || + projectRevisionRef.current === null ) { - throw new Error('严格布局保存必须指定绝对 deadline'); + return; } - const initialProjectId = projectIdRef.current; - if ( - requireSuccess && - (!initialProjectId || - !canAccessProtectedDataRef.current || - !hasAuthoritativeProjectSnapshotRef.current || - authoritativeProjectIdRef.current !== initialProjectId || - projectRevisionRef.current === null) + + const coverDisplayViewport = viewportToCanvasDisplayViewport( + refs.viewportRef.current, + ); + const generationDialogs = + options.preferLatestGenerationDialogs && + refs.getCanvasGenerationDialogsSnapshot + ? refs.getCanvasGenerationDialogsSnapshot() + : refs.canvasGenerationDialogsRef.current; + const layoutInput = { + viewport: coverDisplayViewport, + layers: serializeCanvasLayout({ + layers: refs.layersRef.current, + canvasGenerationDialogs: generationDialogs, + canvasBackgroundColor: refs.canvasBackgroundColorRef.current, + }), + }; + queueProjectLayoutSave(nextProjectId, layoutInput, { + persistCover: false, + }); + + const coverSave = persistProjectCoverSnapshot( + nextProjectId, + coverDisplayViewport, + refs.layersRef.current, + ); + while ( + activeProjectLayoutSavePromiseRef.current || + pendingProjectLayoutSaveRef.current ) { - throw new Error('图片画布缺少可用的项目 authority'); - } - const strictCompletion = - requireSuccess && initialProjectId - ? createStrictProjectLayoutSaveCompletion( - requiredDialogId, - initialProjectId, - { - deadlineAt: options.deadlineAt!, - signal: options.signal, - }, - ) - : undefined; - - try { - while (activeProjectLayoutSavePromiseRef.current) { - const activeSave = activeProjectLayoutSavePromiseRef.current; - if (strictCompletion) { - await awaitProjectLayoutSaveAttempt( - activeSave, - strictCompletion.signal, - ); - } else { - await activeSave; - } - } - - const nextProjectId = projectIdRef.current; - if ( - !nextProjectId || - !canAccessProtectedDataRef.current || - !hasAuthoritativeProjectSnapshotRef.current || - authoritativeProjectIdRef.current !== nextProjectId || - projectRevisionRef.current === null || - (strictCompletion && - strictCompletion.requiredProjectId !== nextProjectId) - ) { - if (requireSuccess) { - throw new Error('图片画布缺少可用的项目 authority'); - } - return; - } - - const coverDisplayViewport = viewportToCanvasDisplayViewport( - refs.viewportRef.current, - ); - const generationDialogs = - requireSuccess && refs.getCanvasGenerationDialogsSnapshot - ? refs.getCanvasGenerationDialogsSnapshot() - : refs.canvasGenerationDialogsRef.current; - const layoutInput = { - viewport: coverDisplayViewport, - layers: serializeCanvasLayout({ - layers: refs.layersRef.current, - canvasGenerationDialogs: generationDialogs, - canvasBackgroundColor: refs.canvasBackgroundColorRef.current, - }), - }; - if ( - requireSuccess && - !projectLayoutContainsRequiredDialogOperation( - layoutInput.layers, - requiredDialogId, - nextProjectId, - ) - ) { - throw new Error(`严格布局保存未找到完美像素操作 ${requiredDialogId}`); - } - if ( - strictCompletion && - !strictProjectLayoutSaveCanContinue(strictCompletion) - ) { - throw ( - strictCompletion.signal.reason ?? - new Error('图片画布严格布局保存已取消') - ); - } - const queued = queueProjectLayoutSave(nextProjectId, layoutInput, { - persistCover: false, - strictCompletion, - }); - if (requireSuccess) { - if (!queued || !strictCompletion) { - throw new Error('图片画布严格布局保存未能进入提交队列'); - } - await strictCompletion.promise; - return; - } - - const coverSave = persistProjectCoverSnapshot( - nextProjectId, - coverDisplayViewport, - refs.layersRef.current, - ); - while ( - activeProjectLayoutSavePromiseRef.current || - pendingProjectLayoutSaveRef.current - ) { - const activeLayoutSave = activeProjectLayoutSavePromiseRef.current; - if (activeLayoutSave) { - await activeLayoutSave; - continue; - } - if (saveTimerRef.current !== null) { - window.clearTimeout(saveTimerRef.current); - saveTimerRef.current = null; - } - runPendingProjectLayoutSave(); - if (!activeProjectLayoutSavePromiseRef.current) { - break; - } - } - await coverSave; - } catch (error) { - if (strictCompletion && !strictCompletion.settled) { - rejectStrictProjectLayoutSave({ strictCompletion }, error); - } - throw error; + const activeLayoutSave = activeProjectLayoutSavePromiseRef.current; + if (activeLayoutSave) { + await activeLayoutSave; + continue; + } + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + runPendingProjectLayoutSave(); + if (!activeProjectLayoutSavePromiseRef.current) { + break; + } } + await coverSave; }, [ persistProjectCoverSnapshot, @@ -1659,9 +1253,6 @@ export function useImageCanvasProjectPersistence({ ? pendingSave : activeLocalSave : null; - const replayStrictCompletion = - pendingLocalLayout?.strictCompletion ?? - activeLocalSave?.strictCompletion; const previousAuthoritativeItemIds = authoritativeLayoutItemIdsRef.current; const applyingToCurrentProject = @@ -1739,11 +1330,18 @@ export function useImageCanvasProjectPersistence({ }, ]), ); + // 中文注释:请求账本只在本机。读不到(换设备、清缓存、隐私模式)时占位会被 + // hydrate 收口成可删除的失败态——这是明确设计,不阻断任何后续操作。 + const localPerfectPixelOperations = readPerfectPixelOperations( + currentUserId, + project.projectId, + ); const { layerItems, generationDialogs, canvasBackgroundColor } = splitCanvasLayoutItems( appliedLayoutItems, resourcesById, currentUserId, + localPerfectPixelOperations, ); const hydratedLayers = layerItems .map((layer) => hydrateLayer(layer, resourcesById)) @@ -1797,14 +1395,10 @@ export function useImageCanvasProjectPersistence({ project.canvas?.revision, ); if (pendingLocalLayout) { - queueProjectLayoutSave( - project.projectId, - { - viewport: appliedViewport, - layers: appliedLayoutItems, - }, - { strictCompletion: replayStrictCompletion }, - ); + queueProjectLayoutSave(project.projectId, { + viewport: appliedViewport, + layers: appliedLayoutItems, + }); } } return projectIsAuthoritative;