From 1168c777ea4c06da52195cd6bc5312ce954a37b0 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 5 Aug 2026 09:30:13 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AD=A4=E5=84=BF=E8=B4=A6=E6=9C=AC=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=E6=AD=A3=E5=90=91=E7=BB=88=E6=80=81=E6=B8=85=E9=99=A4?= =?UTF-8?q?=EF=BC=8Clegacy=20=E8=BF=81=E7=A7=BB=E5=88=A4=E6=8D=AE=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20exact=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、孤儿过早清账。上一条把孤儿改成「读到任何结论就清账本」,但 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 --- .../shared-memory/decision-log.md | 12 +++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 6 +- .../useImageCanvasGenerationWorkflow.test.tsx | 14 +++- .../useImageCanvasGenerationWorkflow.ts | 24 ++++-- .../useImageCanvasProjectPersistence.test.tsx | 81 +++++++++++++++++++ .../useImageCanvasProjectPersistence.ts | 15 +++- 6 files changed, 133 insertions(+), 19 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f8b06bcc9..4c456633b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6439,3 +6439,15 @@ - 影响范围:`useImageCanvasProjectPersistence.ts` 的 `applyProjectSnapshot`。不修改服务端、SpacetimeDB schema 或对外契约。 - 验证方式:新增「legacy 内联账本在加载后被迁入本机,且剥离内联快照后仍能凭本机账本往返回有效的 `pending-confirmation` 占位」用例,已实证:回退迁移后报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已同步 legacy 迁移要求与 Undo 已知限制)。 + +## 2026-08-05 孤儿账本只在正向终态清除;legacy 迁移判据对齐 exact retry + +- 缺陷一(孤儿过早清账):上一条把孤儿改成「读到任何结论就清账本」,但 `pending` / `conflict` 不是结论。浏览器关掉不会中止服务端处理——api-server 的处理与持久化预算合计可达 90 秒,远端 procedure 也可能还在跑;重开项目时单次 GET 没看见 resource 只说明「还不知道」。此刻清账,服务端稍后落库便再无对账凭据,用户永远等不到「结果已进素材库」的提示,同时又多开一个 identity 的口子。 +- 决策一:只有 `applied` / `dialog-missing` 这两个**正向终态**才清账本;`pending` / `conflict` 与读失败一律保留,留给下次加载重读。残留由 7 天保留期与 32 条上限兜住,代价是极少数永不落库的条目每个会话多一次 GET——比丢失凭据便宜得多。 +- 缺陷二(legacy 迁移漏 `failed`):迁移判据按状态白名单列举了 `generating` / `pending-confirmation`,漏掉 `failed + perfectPixelOperation`。那是旧严格保存失败的合法持久化形状(请求已备好、POST 从未发出),`retryPerfectPixelOperation` 明确接受该状态,面板上的「重试同一完美像素操作」也正是在这个形状下出现。漏迁的后果与缺陷本体一致:下一次保存剥成 marker 后再加载即 `failed + invalid`,重试按钮消失,用户只剩删掉重做——而那正是新 identity,正是 exact retry 存在的意义所在。 +- 决策二:迁移判据改用 `isUnresolvedCanvasGenerationDialogRecord` 取反,与 `hydrateCanvasGenerationDialog` 判定收口态用的是同一个函数,两处不会漂移。**通用要求**:涉及「这条 operation 还需不需要账本」的判断一律问「它收口了没有」,不要列举状态——状态白名单会随着新增状态或语义变化而静默漏项,本条就是实例。 +- exact retry 的价值必须记清楚,它不是「省一次操作」:原样重放同一 identity 时服务端幂等生效(稳定 task / object / resource / asset ID 由 `owner + project + dialogId` 派生,请求带 fingerprint),同内容重放返回 `AlreadyApplied` 而不是再造一份。删掉它意味着对账查不出结论时用户只能新建 identity,旧的若其实成功就会重复。曾评估过「直接删除该功能以减少用户困扰」,权衡后保留——困扰来自文案与状态不清晰,可以单独治理,而幂等保证一旦删掉无法用文案补回。 +- 同步更正的文档:专题文档三处残留矛盾——`submittedAt / reconcileUntil` 仍称「从稳定请求快照写入时建立」(应为 pre-POST flush 之后、POST 之前)、仍称「不会让布局校验失败升级成硬阻断」(应限定为解除了客户端侧拒发,端到端 409 依赖仍在)、以及「删除后不再对账」(应限定为结果不再自动回填画布,对账本身继续进行)。 +- 影响范围:`useImageCanvasGenerationWorkflow.ts` 的恢复 effect 孤儿分支、`useImageCanvasProjectPersistence.ts` 的 legacy 迁移判据。不修改服务端、SpacetimeDB schema 或对外契约。 +- 验证方式:孤儿用例翻转为「单次读仍无法判定时保留账本、不提示、不刷新素材库」;新增「legacy `failed + operation` 被迁入本机」用例。两条均已实证:回退修复后各报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index cab94fe38..0030603d8 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -50,11 +50,11 @@ - 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 - 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。 - 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入**本机账本**(占位本身只带 `perfectPixelOperationId` 标记)并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。 -- 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功后形成稳定 `perfectPixelOperation` 并**同步写入本机账本**(`perfectPixelOperationStore`,owner + project 双键的 localStorage),布局里只留 `perfectPixelOperationId` 标记。原先的 strict layout save 通道(60 秒绝对预算、revision ACK 前 POST 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯,却不会让布局校验失败升级成硬阻断。POST 前仍然 `await` 一次 best-effort 布局保存——服务端要求占位**此前已经持久化**,否则 `validate_editor_pixel_art_snap_placeholder_exists` 直接 409;但 best-effort 不再提供成功 ACK,因此客户端**无法证明**该前置已满足,只能提高满足它的概率(占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失)。该 flush 没有整体上限,所以 75 秒对账窗口必须在 flush 返回、authority 复核通过之后才锚定,且首次提交与人工重试同此口径;锚定只覆盖 `submittedAt / reconcileUntil`,按同一 `operationId` 覆盖账本,request 与 dialog / operation / task identity 逐字节不变。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`;重试请求必须与账本中的 POST JSON byte-for-byte 一致且不得重新上传。**已知缺口**:「普通按钮不得创建第二个 operation」这条只由 `existingOperation` 闸提供,而该闸只扫描内存中的 `canvasGenerationDialogs`。占位恢复可删除之后,用户删掉未收口占位再从源图发起,就会得到第二个 identity,而旧的服务端操作仍可能迟到落库,两个 `taskId` 无法幂等合并。防重要真正闭合,必须让本机账本也参与判定,尚未实现。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。 +- 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功后形成稳定 `perfectPixelOperation` 并**同步写入本机账本**(`perfectPixelOperationStore`,owner + project 双键的 localStorage),布局里只留 `perfectPixelOperationId` 标记。原先的 strict layout save 通道(60 秒绝对预算、revision ACK 前 POST 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯。被解除的是**客户端侧**「拿不到 revision ack 就拒发」这一层阻断;端到端依赖仍在——布局 PATCH 被校验拒绝、占位因此从未落库时,POST 仍会被服务端以 409 拒收。POST 前仍然 `await` 一次 best-effort 布局保存——服务端要求占位**此前已经持久化**,否则 `validate_editor_pixel_art_snap_placeholder_exists` 直接 409;但 best-effort 不再提供成功 ACK,因此客户端**无法证明**该前置已满足,只能提高满足它的概率(占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失)。该 flush 没有整体上限,所以 75 秒对账窗口必须在 flush 返回、authority 复核通过之后才锚定,且首次提交与人工重试同此口径;锚定只覆盖 `submittedAt / reconcileUntil`,按同一 `operationId` 覆盖账本,request 与 dialog / operation / task identity 逐字节不变。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`;重试请求必须与账本中的 POST JSON byte-for-byte 一致且不得重新上传。**已知缺口**:「普通按钮不得创建第二个 operation」这条只由 `existingOperation` 闸提供,而该闸只扫描内存中的 `canvasGenerationDialogs`。占位恢复可删除之后,用户删掉未收口占位再从源图发起,就会得到第二个 identity,而旧的服务端操作仍可能迟到落库,两个 `taskId` 无法幂等合并。防重要真正闭合,必须让本机账本也参与判定,尚未实现。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。 - 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。 - strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。后端先纯计算精确 object key 和候选 project resource,再调用只读 SpacetimeDB preflight 校验自定义素材目录归属、复用权威 completion planner,并执行 legacy / structured 的 2 MiB 总量与 512 KiB 单项门禁;默认目录尚未创建时允许通过,preflight 不写库。preflight 与 PUT / HEAD / 原子 persist 共用 60 秒绝对 deadline;preflight 失败或超时不得 PUT,也不得带 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交,并重新校验目录、布局、幂等身份与 revision。preflight 不加锁或 reservation,所以通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下 OSS 孤儿对象;这是本次最小修复明确保留的 TOCTOU 边界。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 -- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 -- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`(账本在本机,布局只留 `perfectPixelOperationId`)。**用户可以随时删除该占位**,任何状态都不例外、也不弹确认:删除不撤销任何在途请求,结果照常落库并进素材库,服务端发现 dialog 已不在会返回 `DialogMissing`;封锁用户删除自己画布上的元素不是可接受的代价。删除后不再对账这条 operation,是用户主动放弃的结果,不得判定为缺陷。未删除时用户可继续 GET 对账或显式按原 identity 重放。人工重试在 pre-POST flush **之后**才刷新观察窗口(同上一节的锚定口径),POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。 +- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 在 pre-POST flush 返回、authority 复核通过之后、POST 发出之前建立统一 75 秒绝对窗口(该 flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光),POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 +- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`(账本在本机,布局只留 `perfectPixelOperationId`)。**用户可以随时删除该占位**,任何状态都不例外、也不弹确认:删除不撤销任何在途请求,结果照常落库并进素材库,服务端发现 dialog 已不在会返回 `DialogMissing`;封锁用户删除自己画布上的元素不是可接受的代价。删除后**结果不再自动回填画布**(服务端发现 dialog 已不在会返回 `DialogMissing`),这是用户主动放弃的结果,不得判定为缺陷;但对账本身不会因此停止——当前标签页已经在飞的 Promise 会继续读到终态,本机账本也会以孤儿身份在下次加载被读一次,结果确已落库时仍会提示用户去素材库取。未删除时用户可继续 GET 对账或显式按原 identity 重放。人工重试在 pre-POST flush **之后**才刷新观察窗口(同上一节的锚定口径),POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。 - 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 与其它占位同样可被立即删除,写正常的 `delete-generation-result` 历史并清理 identity;删除确认只对**计费**生成成立(现成弹窗讲的是「已消耗的泥点不会返还」,而完美像素 `generation_cost_mud_points = 0`),判据收敛为具名的 `requiresGenerationDeleteConfirmation`。低层 `removeCanvasGenerationDialogById` 必须无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因。 - 完美像素并发闸回归测试不得通过进程级队列 Atomic 的 before/after 判断“本用例未入队”。过期 deadline 用例只断言 `504`;queue guard 的 Drop 归还由独立用例覆盖,不引入 `--test-threads=1`、全局串行锁或其它串行化兜底。 - 项目快照对账生成占位时必须检查全部同 ID 原始记录,不得用首项短路:通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不得按首条记录猜测成功。 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 732aeb7db..e24b6ab90 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -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(); }); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index c2958eda1..08b539202 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -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') { diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index b88fc4dc9..72efe2db1 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -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(); + 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 标记,此后再没有路径能补写本机账本—— diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index aac0e834e..e138e0b2a 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -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, + ) ) { savePerfectPixelOperation( currentUserId,