完美像素请求账本移出项目布局
账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容。它此前写在 用户的画布布局里,为此派生出一条严格布局保存通道:发 POST 前必须拿到布局 保存的 revision ack。任何布局校验失败因此都会升级成完美像素的硬阻断。 改为存在本机 localStorage,按 owner + project 双键隔离;布局里只留 perfectPixelOperationId 标记,用来把这类占位与队列型占位区分开。本机写入是 同步的、不过网络、不受服务端校验影响,因此它能提供严格保存想提供的那个保证 ——请求可被追溯——却不引入阻断点。严格保存通道整体删除,只保留一个不改变 失败语义的 preferLatestGenerationDialogs。 由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历 内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清 账本。 本机账本是明确设计,缺失只降级、不得构成阻断:换设备、清缓存、隐私模式、 配额写满都会读不到账本,此时带标记的占位一律收口成可删除的失败占位,用户 删掉重来即可。跨设备不再自动收口是已知且接受的代价。 布局内联账本作为 legacy 形状继续被读取,滚动部署期间的在途操作不会被一次性 判死;写入侧不再产生新的内联账本。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, CanvasLayerResourceMetadata> = 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<string, CanvasLayerResourceMetadata> = 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 }
|
||||
: {}),
|
||||
|
||||
@@ -257,6 +257,10 @@ export type GenerateDialogState = {
|
||||
// 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后
|
||||
// 必须原样恢复。
|
||||
requiresLiveSession?: boolean;
|
||||
// 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id
|
||||
// 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除
|
||||
// 的失败态——这是明确设计,不得据此阻断用户删除或重做。
|
||||
perfectPixelOperationId?: string;
|
||||
perfectPixelOperation?: PerfectPixelOperationSnapshot;
|
||||
perfectPixelOperationInvalid?: boolean;
|
||||
generationStartedAt?: number;
|
||||
|
||||
@@ -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> = {},
|
||||
): 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<string, PerfectPixelOperationSnapshot>;
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, PerfectPixelOperationSnapshot>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
)) {
|
||||
// 中文注释:本机账本与布局快照走同一套 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 {
|
||||
// 中文注释:清理失败最多留下一条过期账本,保留期会兜底。
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,7 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
project: EditorProjectSnapshot,
|
||||
) => boolean | void;
|
||||
flushProjectPersistence?: (options?: {
|
||||
requireSuccess?: boolean;
|
||||
requiredDialogId?: string;
|
||||
preferLatestGenerationDialogs?: boolean;
|
||||
}) => Promise<void>;
|
||||
refreshAssetLibrary?: () => Promise<unknown> | void;
|
||||
onWalletBalanceMayHaveChanged?: () => void;
|
||||
|
||||
@@ -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(
|
||||
<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).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(() => {}));
|
||||
|
||||
@@ -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<void>;
|
||||
refreshAssetLibrary?: () => Promise<unknown> | 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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user