From 4ccfdc68f6d9359d2d03b9701f9efc2fc96db0a5 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 5 Aug 2026 12:25:17 +0000 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=A1=AE=E8=AE=A4=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E6=94=B9=E7=9C=8B=20marker=EF=BC=8C=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=BA=90=E5=87=86=E5=A4=87=E9=98=B6=E6=AE=B5=E7=9A=84=E5=85=8D?= =?UTF-8?q?=E8=B4=B9=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 判据是 status === 'generating' && !perfectPixelOperation,而 perfectPixelOperation 要到源图解析/直传完成后才写入。未登记的本地图片要走 ticket → PUT → confirm, 预算上限 90 秒;这段窗口里占位是 generating、只有 requiresLiveSession、没有账本, 判据返回 true——用户删一个免费操作会被告知「已消耗的泥点不会返还」,与「完美像素 任何状态直接删、不弹确认」的决策直接矛盾。 perfectPixelOperationId marker 从占位创建那一刻就写上(operationId === dialogId 在创建时已知),判据改看 marker。marker 的语义也因此更准确:它表示「这个占位属于 一次完美像素操作」,而不是「账本已存在」。 未采纳「删掉弹窗入口」:删除入口全仓只有 requestRemoveCanvasGenerationDialog 一条, 右键、Delete、工具栏全部汇入,完美像素没有自己的删除路径可摘。「从本链路删入口」 等价于「让判据认得出本链路」,绕不开识别问题。 未采纳新增计费字段(今天唯一生产者只有完美像素,属过度设计),也未采纳判据加 requiresLiveSession(那只是换一个代理,而用短寿命字段的存在性判断长期属性正是本 缺陷的成因模式)。 连带影响已核:会话内到期清理不受影响(看的是 perfectPixelOperation 而非 marker); 只有加载时的快照清理会豁免源准备阶段的占位,使其显示为可删的失败卡而非被静默清掉, 这与「系统不替用户删」一致。 测试夹具原本带着 perfectPixelOperation,编码了与判据相同的错误假设,结构上覆盖不到 这个窗口。夹具补上 marker 还原真实形状,并新增「只有 marker、尚无账本」用例,回退 判据后报 expected true to be false。 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 12 +++++++++++ .../useCanvasGenerationDialogs.test.tsx | 20 +++++++++++++++++++ .../useCanvasGenerationDialogs.ts | 10 +++++++++- .../useImageCanvasGenerationWorkflow.ts | 17 +++++++++++++--- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 41f12ff7d..8498b7e82 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6551,3 +6551,15 @@ - 顺带记录的待办(不在本次范围):图集拆分的 catch 只做 `window.alert('拆分图集失败')`,不区分「确定失败」与「网关合成的未知结果」。服务端可能已经切完并落库 N 个 asset 却报失败,用户照提示重拆就会拿到双份——这属于谎报,落在主链路一侧,与「丢资源不阻断」不是一类问题。最小修法是复用现成的 `isGatewayUnknownOutcomeError` 改文案,十几行,不需要照搬 B 层。 - 影响范围:仅文档。不改代码、不改测试。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已写入三层划分、B 层边界实测与清除条件)。 + +## 2026-08-05 删除确认判据改看 marker,覆盖源准备阶段的免费占位 + +- 缺陷:`requiresGenerationDeleteConfirmation` 的判据是 `status === 'generating' && !dialog.perfectPixelOperation`,而 `perfectPixelOperation` 要到源图解析 / 直传完成后才写入。未登记的本地图片要走 `ticket → PUT → confirm`,预算上限 90 秒;这段窗口里占位是 `generating`、只有 `requiresLiveSession: true`、没有账本,于是判据返回 true——用户删一个**免费**操作会被告知「已消耗的泥点不会返还」。与同日「完美像素占位恢复为可删除」里「任何状态直接删、不弹确认」的决策直接矛盾。已登记资源走短路解析、窗口接近于零,暴露只在未登记本地图片上成立。 +- 决策:`perfectPixelOperationId` marker 从占位**创建那一刻**就写上(`operationId === dialogId` 在创建时已知),判据改看 marker:`status === 'generating' && !dialog.perfectPixelOperationId`。marker 的语义也因此更准确——它表示「这个占位属于一次完美像素操作」,而不是「账本已存在」。 +- 未采纳「删掉弹窗入口」:删除入口全仓只有 `requestRemoveCanvasGenerationDialog` 一条,右键、Delete 快捷键、工具栏全部汇入,完美像素没有自己的删除路径可以摘除。「从本链路删除入口」在实现上等价于「让判据认得出本链路」,绕不开识别问题。真正删掉弹窗只能对所有生成占位一起做,那是另一个产品决定(对计费生成而言该文案是真实信息),不作为修此缺陷的副产品。 +- 未采纳「新增 `generationCostMudPoints` 字段让判据直接问是否计费」:语义上最正,但今天唯一的生产者只有完美像素,图集拆分根本不创建占位,第二个消费者并不存在,属于为一个调用方过度设计。 +- 未采纳「判据加 `requiresLiveSession === true`」:该字段全仓确实只有完美像素一处置位,一行即可修,但它的含义是「只能由本会话收口」而非「免费」。换一个代理不解决问题——**用短寿命字段的存在性判断长期属性**正是本缺陷(以及 F5)的成因模式。 +- 已核过的连带影响:会话内到期清理不受影响,`inlineGenerationPlaceholderExpiryAt` 看的是 `perfectPixelOperation` 而非 marker,源准备阶段被放弃的占位照常到期消失。受影响的只有加载时的快照清理 `dropDeadInlineGenerationPlaceholders`——它的豁免判据接受 marker,因此「源准备中途关标签页」的占位不再被静默清掉,而是在下次加载显示为可删的失败卡。这与「TTL 豁免 = 系统不替用户删,用户主动删除始终允许」一致,判为改善而非退化。id 撞车时 `openCanvasGenerationDialog` 会另生成 id,marker 会暂时指向旧值;它此刻只被当作存在性标记使用(效果仍是不弹确认),写 request 时按真实 dialogId 纠正,不影响任何判等。 +- 测试缺口的根因:原用例的夹具 `durablePerfectPixelDialog` 带着 `perfectPixelOperation`,编码了与判据相同的错误假设,结构上不可能覆盖 operation 形成之前的窗口。夹具已补上 marker 以还原真实形状,并新增「只有 marker、尚无账本」的用例,已实证:回退判据后报 `expected true to be false`。 +- 影响范围:`useCanvasGenerationDialogs.ts` 的判据、`useImageCanvasGenerationWorkflow.ts` 的占位创建。不修改服务端、SpacetimeDB schema 或对外契约。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index 6b848b868..eeb2d117f 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -41,6 +41,9 @@ function durablePerfectPixelDialog( status, composerOpen: true, sourceLayerId: 'layer-source', + // 中文注释:marker 从占位创建那一刻就存在,账本形成后也一直在。夹具必须还原这个形状, + // 否则下游用例是在测一个真实链路里不存在的状态。 + perfectPixelOperationId: dialogId, perfectPixelOperation: { version: 1, kind: 'perfect-pixel', @@ -384,6 +387,23 @@ describe('useCanvasGenerationDialogs', () => { }, ); + it('never asks for delete confirmation before the perfect-pixel ledger exists', () => { + // 中文注释:源图解析 / 直传最长 90 秒,期间占位只有 marker、还没有账本。此前判据按账本 + // 判,用户在这段窗口里删一个免费操作会看到「已消耗的泥点不会返还」。 + expect( + requiresGenerationDeleteConfirmation({ + id: 'perfect-pixel-preparing', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + composerOpen: false, + sourceLayerId: 'layer-source', + requiresLiveSession: true, + perfectPixelOperationId: 'perfect-pixel-preparing', + }), + ).toBe(false); + }); + it('still asks for delete confirmation on an ordinary generating placeholder', () => { expect( requiresGenerationDeleteConfirmation({ diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index d0f19329d..bbcdd69cb 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -31,11 +31,19 @@ export type CanvasGenerationDialogDraft = Omit< * `generation_cost_mud_points = 0`,删除占位也不撤销任何在途请求——它没有取消接口,结果照常 * 落库并进素材库,服务端 completion 发现 dialog 已不在会返回 DialogMissing 并由客户端提示。 * 所以完美像素占位在任何状态都直接删,不额外解释。 + * + * 判据必须看 `perfectPixelOperationId` 而**不是** `perfectPixelOperation`:后者要到源图解析 / + * 直传完成后才写入,那一段预算最长 90 秒(未登记的本地图片要走 ticket → PUT → confirm), + * 期间占位是 `generating` 且没有账本,按账本判会让用户删一个免费操作时看到「已消耗的泥点 + * 不会返还」。marker 在占位创建那一刻就写上,覆盖完整生命周期。 + * + * 更一般地:这里问的是「**这次生成计不计费**」,账本的有无只是它在某一段时间内的代理。 + * 用短寿命字段的存在性去判断长期属性,正是本仓库反复出错的形状。 */ export function requiresGenerationDeleteConfirmation( dialog: CanvasGenerationDialogState, ) { - return dialog.status === 'generating' && !dialog.perfectPixelOperation; + return dialog.status === 'generating' && !dialog.perfectPixelOperationId; } function withGenerationTimestamps( diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index bd81193d1..e02bc778e 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -2283,6 +2283,7 @@ export function useImageCanvasGenerationWorkflow({ let perfectPixelPostAttempted = false; try { const assetLabel = `${sourceLayer.title} · 完美像素`; + const perfectPixelOperationId = createPerfectPixelOperationId(); const placement = openPlacedCanvasGenerationDialog({ ...createQuickEditGenerationDialogDraft({ sourceLayer, @@ -2294,11 +2295,21 @@ export function useImageCanvasGenerationWorkflow({ height: sourceLayer.height, }, }), - id: createPerfectPixelOperationId(), + id: perfectPixelOperationId, composerOpen: false, + // 中文注释:marker 必须在占位诞生这一刻就写上,不能等到 operation 形成。源图解析 / + // 直传最长 90 秒,期间占位是 `generating` 且尚无账本;删除确认判据若按账本判,用户 + // 在这段窗口里删一个**免费**操作会看到「已消耗的泥点不会返还」。 + // + // 与 id 同值:`operationId === dialogId` 是这条链路的既有不变式。万一 id 撞车被 + // `openCanvasGenerationDialog` 换掉,marker 会暂时指向旧值——此时它仍然只被用作 + // 「这是一次完美像素操作」的存在性标记(不弹确认),随后写 request 时会按真实 + // dialogId 纠正,不影响任何判等。 + perfectPixelOperationId, // 中文注释:源引用尚未解析、operation 快照也尚未形成时先沿用 legacy 会话标记; - // 这一阶段退出不会发 POST,孤儿可按 TTL 清理。稳定 request 写入后会立即移除该 - // 标记,durable operation 改由刷新后的 GET-only 恢复收口,绝不能再被 TTL 删除。 + // 这一阶段退出不会发 POST,会话内到期清理照常生效(`inlineGenerationPlaceholderExpiryAt` + // 看的是 `perfectPixelOperation` 而非 marker)。稳定 request 写入后会立即移除该标记, + // durable operation 改由刷新后的 GET-only 恢复收口,绝不能再被 TTL 删除。 requiresLiveSession: true, }); perfectPixelDialogId = placement.dialogId;