完美像素补齐两处静默路径与缓存剥离的失败上报

A3-1906。对账分支发现权威快照里占位已被 completion 消费、本地占位也被用户
删除时直接静默 return。不应用快照是对的(删除意图胜出),但结论与相邻分支
一致——结果已落库,用户删掉的是占位不是素材,必须告诉他素材库多了一份。

同一形状的孪生分支在成功路径:!result.project 时提示被写在
if (hasCanvasGenerationDialogById(...)) 里面,占位不在就整段静默。改为无条件
提示、条件移除。

A4。会话缓存剥掉的占位数单独计,不并入提示计数——完美像素成功后缓存可能停留
在完成之前的版本,并入会报「上次处理未完成」的假告警。只在权威加载失败、没有
第二个来源纠正画面时才提示。已核实 isProjectReady 不挡画布渲染,该场景下用户
确实看得到一张静默少了占位的画布。

新增用例在去掉 1906 提示后失败。A4 无专用测试,理由记在 decision-log。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 05:47:01 +00:00
parent c276810bff
commit cb5fdb5c94
4 changed files with 96 additions and 7 deletions
@@ -6018,3 +6018,13 @@
- 验证结果:api-server 676 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致);`vitest src/components/image-editor` 894 通过 / 72 文件;`cargo fmt --check`、typecheck、eslint、`check:encoding` 通过。
- 未修(已立项):A2 客户端 120s 早于服务端最坏合法时长(约 270s);B1 并发闸许可跨越无预算的持久化阶段;C1/C2/C3 `requiresLiveSession` 链路;D、E 组其余清理项。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-03 完美像素静默路径收口(审查第一批续)
- 背景:第一批只修了失败尾部那一处静默(`else` 兜底)。复审指出对账分支还有一处早返回同样不说话,追查后发现它有个孪生分支在成功路径上,两处形状一致——操作其实已经落库、用户删掉了占位、没人告诉他素材库多了一份。
- 决策:提示与「移除占位」解绑。成功路径 `!result.project` 分支原先把提示写在 `if (hasCanvasGenerationDialogById(...))` 里面,占位不在就整段静默;改为无条件提示、条件移除。对账分支 `!placeholderSurvived` 且本地占位也没了时,不应用快照仍然正确(删除意图胜出),但要补同一句提示——走到这里意味着权威快照里占位已被 completion 消费,本分支下一步正是据此把结果当成功套用,结论一致:结果已落库。
- A4 的取舍:会话缓存里剥掉的占位数**不能**直接并入提示计数。完美像素成功后 `applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 不落库,所以会话缓存可能停留在完成之前的版本;重新加载时缓存里那个陈旧占位被剥掉、计数加一,而权威快照其实是成功的,并入就会报一条「上次处理未完成」的假告警。改为单独计数,只在权威加载失败、没有第二个来源可以纠正这幅画面时才提示。
- A4 的可观测性核实:`isProjectReady` 只被启动意图消费和自动保存 effect 使用,不参与画布渲染门禁,所以权威加载失败时画布照常显示,用户看到的确实是一张静默少了占位的画布,提示有必要。鉴权失败与项目失访两条路径各自弹窗或跳转,不在这里重复打扰——为此在 `replaceAppHistoryPath` 后补了 `return`,该分支原本就没有后续语句,行为不变。
- 验证:新增用例覆盖「对账发现两侧占位都没了 → 仍提示素材库结论」,去掉提示后该用例失败。`vitest src/components/image-editor` 895 通过 / 72 文件,typecheck、eslint 通过。
- 未覆盖:A4 没有专用测试。`readEditorProjectSessionCache` 是持久化 hook 内的局部函数而非可 mock 的模块,要测得在 jsdom 里按缓存键格式播种存储再让权威加载失败,成本高于这三行改动本身;改动本身是「捕获计数 + 失败分支上报」,无分支逻辑变化,暂按未覆盖记录。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -2748,6 +2748,63 @@ describe('useImageCanvasGenerationWorkflow', () => {
).toBe('空闲');
});
it('reports the asset-library outcome when reconciliation finds both placeholders gone', async () => {
// 中文注释:对账发现权威快照里占位已被 completion 消费——同一分支下一步正是据此把结果
// 当成功套用,所以结论一致:结果已落库。本地占位也没了(用户删的)时不应用快照是对的,
// 但不能连话都不说:用户删掉的是占位而不是素材,不提示他就既看不到画布结果、也不知道
// 素材库多了一份,进而重复执行。
let rejectPerfectPixel: ((error: unknown) => void) | undefined;
snapImageToPerfectPixelsMock.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectPerfectPixel = reject;
}),
);
// 中文注释:对账 GET 返回的项目里不含该占位,模拟 completion 已消费。
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-03T00:00:00.000Z',
});
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={vi.fn()}
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe(
'quick-edit:generating:closed:-:placeholder',
);
});
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
await waitFor(() => {
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
});
// 中文注释:裸 Error 表达 transport 异常(未知结果),这样才会进入对账分支。
rejectPerfectPixel?.(new Error('网络中断'));
await waitFor(() => {
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'完美像素结果已保存到素材库,画布占位已不存在。',
);
});
});
it('does not apply a completed project after the perfect-pixel placeholder was deleted', async () => {
let resolvePerfectPixel:
| ((value: {
@@ -1840,12 +1840,13 @@ export function useImageCanvasGenerationWorkflow({
});
upsertGeneratedAsset?.(result.asset);
if (!result.project) {
// 中文注释:提示不能挂在「占位还在」这个条件里。占位已被删除时结果同样已经落库,
// 而且此时用户更需要知道——他删掉的是占位,不是已经生成的素材。原先提示与移除
// 绑在同一个 if 里,占位不在就整段静默,用户不知道素材库多了一份。
if (hasCanvasGenerationDialogById(perfectPixelDialogId)) {
updateCanvasGenerationDialogById(perfectPixelDialogId, () => null);
showGenerationWarning(
'完美像素结果已保存到素材库,画布占位已不存在。',
);
}
showGenerationWarning('完美像素结果已保存到素材库,画布占位已不存在。');
return;
}
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
@@ -1904,6 +1905,13 @@ export function useImageCanvasGenerationWorkflow({
// 契约要求此时不应用完成快照、不写历史,删除意图胜出。成功路径同一处有
// 这道检查,对账路径不能漏。
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
// 中文注释:不应用快照是对的(删除意图胜出),但不能连话都不说。走到这里
// 意味着权威快照里占位已被 completion 消费——本分支下一步正是据此把结果
// 当成功套用,所以结论一致:结果已落库。用户删掉的是占位而不是素材,必须
// 告诉他素材库多了一份,否则他既看不到画布结果也不知道素材已生成。
showGenerationWarning(
'完美像素结果已保存到素材库,画布占位已不存在。',
);
return;
}
applyProjectSnapshot(reconciled, {
@@ -1197,13 +1197,17 @@ export function useImageCanvasProjectPersistence({
projectIdFromQuery,
currentUserId,
);
// 中文注释:缓存里剥掉的数量单独记,不能直接并入提示计数。会话缓存可能停留在完美像素
// 完成之前的版本(`applyProjectSnapshot` 置了 skipNext,成功后的第一次 effect 不落库),
// 此时缓存里的占位是陈旧的而权威快照其实已经成功——并入就会报一条「上次处理未完成」的
// 假告警。只有权威加载没能纠正这幅画面时,这个计数才代表真的有孤儿占位被静默清掉。
let cachedDeadPlaceholderCount = 0;
if (cachedProject) {
// 中文注释:会话缓存写于占位落库之后,同样可能带着已死会话的 generating 占位,
// 必须和权威快照走同一道剥离,否则首屏会先闪一个永远转圈的占位。
applyProjectSnapshot(
dropDeadInlineGenerationPlaceholders(cachedProject.project).project,
{ authoritative: false },
);
const cached = dropDeadInlineGenerationPlaceholders(cachedProject.project);
cachedDeadPlaceholderCount = cached.droppedCount;
applyProjectSnapshot(cached.project, { authoritative: false });
}
const loadProject = projectIdFromQuery
? loadEditorProject(projectIdFromQuery)
@@ -1262,6 +1266,16 @@ export function useImageCanvasProjectPersistence({
return;
}
replaceAppHistoryPath('/project');
return;
}
// 中文注释:权威快照没能到达,缓存里剥掉的占位就没有第二个来源可以纠正。此时画布
// 仍在渲染(isProjectReady 只管启动意图与自动保存,不挡画面),用户看到的是一张
// 静默少了占位的画布——必须提示,否则他既不知道占位被清掉,也不知道素材库可能已有
// 派生图。鉴权失败与项目失访这两条路径已各自跳转或弹窗,不在这里重复打扰。
if (cachedDeadPlaceholderCount > 0) {
setDeadInlinePlaceholderDropCount(
(currentCount) => currentCount + cachedDeadPlaceholderCount,
);
}
});