孤儿账本只在正向终态清除,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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user