孤儿账本只在正向终态清除,legacy 迁移判据对齐 exact retry

一、孤儿过早清账。上一条把孤儿改成「读到任何结论就清账本」,但 pending /
conflict 不是结论。浏览器关掉不会中止服务端处理——api-server 的处理与持久化
预算合计可达 90 秒,重开项目时单次 GET 没看见 resource 只说明「还不知道」。
此刻清账,服务端稍后落库便再无对账凭据,用户永远等不到「结果已进素材库」的
提示。改为只在 applied / dialog-missing 两个正向终态清账;pending / conflict
与读失败一律保留给下次加载重读,残留由保留期与条数上限兜住。

二、legacy 迁移漏 failed。判据按状态白名单列举 generating /
pending-confirmation,漏掉 failed + perfectPixelOperation——那是旧严格保存
失败的合法持久化形状,retryPerfectPixelOperation 明确接受它,「重试同一完美
像素操作」按钮正是在这个形状下出现。漏迁后下一次保存剥成 marker,再加载即
failed + invalid,重试按钮消失,用户只剩删掉重做,而那正是新 identity。判据
改用 isUnresolvedCanvasGenerationDialogRecord 取反,与 hydrate 判定收口态同
一个函数,两处不会漂移。

通用要求:涉及「这条 operation 还需不需要账本」的判断一律问「它收口了没有」,
不要列举状态——状态白名单会随语义变化静默漏项,本条就是实例。

同步更正专题文档三处残留矛盾:窗口起算点仍写作「稳定请求快照写入时」;仍称
「不会让布局校验失败升级成硬阻断」(应限定为解除客户端侧拒发,端到端 409
依赖仍在);「删除后不再对账」(应限定为结果不再自动回填画布)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 09:30:13 +00:00
parent 11a2c5afe0
commit 1168c777ea
6 changed files with 133 additions and 19 deletions
@@ -3756,7 +3756,10 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0);
});
it('silently clears an orphan ledger entry whose result never landed', async () => {
it('keeps an orphan ledger entry whose single read still cannot tell the outcome', async () => {
// 中文注释:孤儿不等于「服务端已经停下」。浏览器关掉不会中止服务端处理,api-server 的
// 处理与持久化预算合计可达 90 秒,此刻单次 GET 没看见 resource 只说明「还不知道」。
// 清账本会让服务端稍后落库时再无对账凭据,用户也永远等不到「结果已进素材库」的提示。
const operationId = 'perfect-pixel-orphan-nothing-landed';
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
const submittedAt = Date.now() - 6 * 60 * 60 * 1_000;
@@ -3799,10 +3802,13 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
await waitFor(() => {
expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0);
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
});
// 中文注释:什么都没落库,用户不需要知道任何事;弹提示只会制造噪音。
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
// 中文注释:结论未定,账本必须留给下次加载重读;保留期与条数上限兜住残留。
const ledger = readPerfectPixelOperations('user-a', 'project-1');
expect(ledger.size).toBe(1);
expect(ledger.get(operationId)?.operationId).toBe(operationId);
// 中文注释:既然什么都没确认,就不该打扰用户。
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
expect(refreshAssetLibrary).not.toHaveBeenCalled();
});
@@ -2945,24 +2945,32 @@ export function useImageCanvasGenerationWorkflow({
return;
}
if (isOrphan) {
// 中文注释:孤儿读到任何结论就收口清账本,避免它在保留期内每次加载都被重读。
// 读失败(走下面的 catch)不清——那是「不知道」,不是「知道没有」,留给下次加载。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
// 中文注释:只有 applied / dialog-missing 这两个**正向终态**才算读到了事实,才能
// 清账本。`pending` / `conflict` 不等于「服务端已经停下」——浏览器关掉不会中止
// 服务端处理,api-server 的处理与持久化预算合计可达 90 秒,远端 procedure 也可能
// 还在跑。此刻单次 GET 没看见 resource 就清账,服务端稍后落库便再无对账凭据,
// 用户也永远等不到「结果已进素材库」的提示。留给下次加载重读,保留期与条数上限
// 会兜住残留。读失败(走下面的 catch)同理不清。
if (verdict.kind === 'dialog-missing') {
// 中文注释:服务端当时没有占位可回填,结果只进了素材库——这正是「布局尽力保存
// 失败」的典型结局,用户必须被告知。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
} else if (verdict.kind === 'applied') {
// 中文注释:服务端已经把结果回填进画布,而本次读到的正是当前项目的权威状态,
// 所以结果本就在用户眼前。静默刷新素材库即可,弹提示只是噪音。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
refreshPerfectPixelAssetLibrary();
}
// 中文注释:pending / conflict 意味着什么都没落库,用户无需知道任何事。
return;
}
if (verdict.kind === 'applied' || verdict.kind === 'dialog-missing') {
@@ -1026,6 +1026,87 @@ describe('useImageCanvasProjectPersistence', () => {
});
});
it('migrates a legacy inline ledger on a failed placeholder that exact retry still accepts', async () => {
// 中文注释:`failed + perfectPixelOperation` 是旧严格保存失败的合法持久化形状——请求
// 已备好但 POST 从未发出,`retryPerfectPixelOperation` 明确接受该状态,面板上的
// 「重试同一完美像素操作」也只在它带 operation 时才出现。按状态白名单列举会把这批漏掉:
// 下一次保存剥成 marker 后再加载就是 failed + invalid,重试按钮消失,用户只剩删掉重做
// ——而那正是新 identity,正是 exact retry 要避免的重复。
window.localStorage.clear();
const dialogId = 'generation-dialog-legacy-prepared-failure';
const submittedAt = Date.now();
const legacyOperation = {
version: 1 as const,
kind: 'perfect-pixel' as const,
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'generated-images/editor/legacy-prepared.png',
projectId: 'editor-project-default',
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId,
title: '源图 · 完美像素',
placeholder: { ...STRICT_PLACEHOLDER },
},
},
submittedAt,
reconcileUntil: submittedAt + 75_000,
};
const legacyDialogItem = {
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialogId}`,
resourceId: `generation-dialog:${dialogId}`,
dialog: {
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
// 中文注释:失败但未收口——没有 generatedLayerId,账本仍是它唯一的重试凭据。
status: 'failed',
composerOpen: false,
errorMessage: '完美像素请求尚未发出:提交前画布保存失败',
perfectPixelOperation: legacyOperation,
placeholder: { ...STRICT_PLACEHOLDER },
},
} as unknown as EditorProjectLayerSnapshot;
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
projectId: 'editor-project-default',
title: '空画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [legacyDialogItem],
revision: 4,
layoutStorageVersion: 0,
updatedAt: '2026-08-05T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [legacyDialogItem],
resources: [],
updatedAt: '2026-08-05T00:00:00.000Z',
});
render(<PerfectPixelProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('strict-project-id').textContent).toBe(
'editor-project-default',
);
});
await waitFor(() => {
expect(
readPerfectPixelOperations('user-test', 'editor-project-default').size,
).toBe(1);
});
expect(
readPerfectPixelOperations('user-test', 'editor-project-default').get(
dialogId,
),
).toEqual(legacyOperation);
});
it('migrates a legacy inline perfect-pixel ledger into local storage on load', async () => {
// 中文注释:布局内联账本是账本移出布局之前的 legacy 形状。第一次 hydrate 还能认它,但
// 下一次保存会把它剥成 perfectPixelOperationId 标记,此后再没有路径能补写本机账本——
@@ -26,6 +26,7 @@ import {
dropDeadInlineGenerationPlaceholders,
hydrateLayer,
isInlineEditorMediaSource,
isUnresolvedCanvasGenerationDialogRecord,
resolveLayerResourceAssetKind,
serializeCanvasLayout,
splitCanvasLayoutItems,
@@ -1349,15 +1350,21 @@ export function useImageCanvasProjectPersistence({
// 中文注释:legacy 内联账本一次性迁到本机。布局里的内联快照会在下一次保存时被剥成
// `perfectPixelOperationId` 标记,此后再没有任何路径能把它补写进本机账本——不迁移
// 的话,部署那一刻仍在途的操作会在第二次加载变成 `failed + invalid`,永久失去 exact
// retry 的 identity。只补写缺失的(本机那份可能刚被重新锚定过,比布局里的新),也只
// 补写未收口的(收口态本就不需要账本)。
// retry 的 identity。
//
// 判据用「未收口」而不是列举状态:`failed + perfectPixelOperation` 是旧严格保存失败
// 的合法持久化形状,`retryPerfectPixelOperation` 也明确接受 `failed`,按状态白名单
// 列举会把这批仍能 exact retry 的占位漏掉。收口态(带 generatedLayerId)本就不需要
// 账本,与 `hydrateCanvasGenerationDialog` 同用一个判据,两处不会漂移。
// 只补写本机缺失的:本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新。
for (const dialog of generationDialogs) {
const operation = dialog.perfectPixelOperation;
if (
operation &&
!localPerfectPixelOperations.has(dialog.id) &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation')
isUnresolvedCanvasGenerationDialogRecord(
dialog as unknown as Record<string, unknown>,
)
) {
savePerfectPixelOperation(
currentUserId,