对账窗口改在 pre-POST flush 之后锚定

查服务端确认了一件推翻先前定性的事实:validate_editor_pixel_art_snap_
placeholder_exists 在处理前检查占位是否已经持久化,既没有同 ID 的已持久化
dialog、也没有同 operation 的稳定 resource 时直接返回 409。所以 POST 前那次
await flush 不是可省的画布同步,而是服务端硬前置,不能取消。

据此更正上一条决策里的错误声明:不是「布局保存失败只降级成 asset-only」,
而是「占位从未持久化时服务端返回 409;best-effort flush 不再提供成功 ACK,
客户端因此无法证明该前置已满足,只能提高满足它的概率」。被解除的是客户端侧
「拿不到 revision ack 就拒发」的阻断,不是端到端依赖。

缺陷:首次提交与人工 exact retry 都在这次 flush 之前就算好 submittedAt /
reconcileUntil。该 flush 没有整体上限,慢保存足以在 POST 发出前烧光整个
75 秒窗口,请求带着已过期的 deadline 发出,对账退化成读一次就收尾。

修复:flush 返回、authority 复核通过之后才用
createPerfectPixelReconciliationOperation 重新锚定,按同一 operationId
覆盖账本与 dialog,登记新的 recovery key,随后立即 POST。只覆盖时间字段,
request 与 dialog / operation / task identity 逐字节不变,不产生第二条账本。

保留 flush 前的预写而不是整体后移:flush 期间另一标签页可能加载同一项目,
服务端已有带 marker 的占位而 localStorage 跨标签共享,本机若没有账本那条占位
会被 hydrate 成 failed + invalid。provisional 账本正好堵住这个窗口。

同步修正专题文档中已被近几个提交推翻的条款:strict layout save 预算与
revision ACK 前 POST 为零、未收口 operation 不可删除、durable operation
不得被普通删除路径清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 08:43:59 +00:00
parent 515682d571
commit 4515bbcff6
4 changed files with 245 additions and 23 deletions
@@ -6385,7 +6385,7 @@
## 2026-08-05 完美像素请求账本移出项目布局,严格布局保存整体删除
- 背景:完美像素是唯一没有 durable job 的生成路径——免费、同步、不走 `enqueue_editor_generation_job`,服务端没有任何一行记录「这次请求发出过」。为了让刷新后还能 GET-only 对账,请求账本 `perfectPixelOperation` 被写进了**用户的画布布局**,并由此派生出一条严格布局保存通道:发 POST 前必须拿到布局保存的 revision ack,否则整条链路中止。该耦合直接造成两类缺陷:一是账本寄生在用户数据上,占位一度被禁止删除(已由同日「完美像素占位恢复为可删除」作废);二是任何布局校验失败都会升级成完美像素的硬阻断,「画布图层元数据以资源行为准」那条缺陷正是因为严格保存才从静默重试变成用户可见的死锁。
- 决策:账本改由 `src/components/image-editor/perfectPixelOperationStore.ts` 存在本机 localStorage,按 owner + project 双键隔离;布局里只留 `perfectPixelOperationId` 标记,用来把这类占位与队列型占位区分开。发 POST 前先同步写本机账本,再**尽力而为**地保存布局;布局保存失败不再拦 POST,只是把结果降级为只进素材库」。严格布局保存通道(`strictCompletion` 全套机制、`flushProjectPersistence``requireSuccess` / `requiredDialogId` / `deadlineAt` 选项、`PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS`)整体删除,只保留一个不改变失败语义的 `preferLatestGenerationDialogs`,用于取到刚创建、尚未回流到 ref 的占位。
- 决策:账本改由 `src/components/image-editor/perfectPixelOperationStore.ts` 存在本机 localStorage,按 owner + project 双键隔离;布局里只留 `perfectPixelOperationId` 标记,用来把这类占位与队列型占位区分开。发 POST 前先同步写本机账本,再**尽力而为**地保存布局;布局保存失败不再让客户端拒发 POST。(**更正**:本条原文写作「布局保存失败不再拦 POST,只是把结果降级为只进素材库』」,不准确——服务端要求占位此前已持久化,占位从未落库时 POST 会被 409 拒收。被解除的是「客户端在拿到 revision ack 前拒发」这一**客户端侧**阻断,不是端到端依赖;见 2026-08-05「pre-POST flush 是服务端硬前置」。)严格布局保存通道(`strictCompletion` 全套机制、`flushProjectPersistence``requireSuccess` / `requiredDialogId` / `deadlineAt` 选项、`PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS`)整体删除,只保留一个不改变失败语义的 `preferLatestGenerationDialogs`,用于取到刚创建、尚未回流到 ref 的占位。
- 为什么这样仍然安全:本机写入是同步的、不过网络、不受服务端校验影响,因此它能提供严格布局保存想提供的那个保证——「请求可被追溯」——却不引入任何阻断点。由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清账本。
- **本机账本是明确设计,缺失只降级、不得构成阻断**:换设备、换浏览器、清缓存、隐私模式、配额写满都会读不到账本。这些情况下带标记的**未收口**占位一律 hydrate 成 `failed + perfectPixelOperationInvalid` 的**可删除**占位,用户删掉重来即可(收口态占位不适用,见下一条决策)。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。
- 兼容与生命周期:布局内联账本是 legacy 形状,hydrate 侧继续认,滚动部署期间的在途操作不会被一次性判死;写入侧不再产生新的内联账本。本机账本按 7 天保留期与 32 条上限裁剪,终态(applied / dialog-missing / 快照与项目不匹配 / 无占位可挂错误)立即清除。读取沿用与布局快照相同的 v1 白名单校验,任何字段漂移失败关闭,绝不据一份可疑账本重放 POST。
@@ -6406,3 +6406,16 @@
- 测试缺口的根因与补救:缺陷一能溜过整套测试,是因为工作流用例里所有 applied 场景的 `applyProjectSnapshot` 都是空桩,「收口 → 清账本 → 真实 hydrate 重新套用」这条**跨 hook 协作**从未被跑过;模型层用例又只覆盖了 `generating` + 账本缺失,没有 `idle + generatedLayerId` 这一真实终态形状。补救不是多加两条断言,而是新增一条把 `verdict.project` 真正喂进 `splitCanvasLayoutItems` 的集成用例,并把共享 fixture `createPerfectPixelProject` 补上服务端真实会保留的 `perfectPixelOperationId`——fixture 不还原真实形状,下游所有用例都在测一个不存在的世界。
- 验证方式:模型层覆盖「收口态无账本仍有效且序列化不再输出标记」「被上一版写脏的行自愈成 idle 且清掉残留错误文案」「收口态的内联 legacy 账本被剥离且不留标记」;工作流层覆盖「applied 结果经真实 hydrate 回来仍是 idle」(该用例已实证:回退修复后报 `expected 'failed' to be 'idle'`)、「过期孤儿仍做且只做一次读并清账本」、「未落库的孤儿静默清账本、不提示、不刷新素材库」。运行 `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`
## 2026-08-05 pre-POST flush 是服务端硬前置,对账窗口改在 flush 之后锚定
- 事实更正:`server-rs/crates/api-server/src/editor_project.rs``validate_editor_pixel_art_snap_placeholder_exists` 在处理前检查占位是否**已经持久化**到项目布局;既没有同 ID 的已持久化 dialog、也没有同 operation 的稳定 resource 时返回 **409**。因此 POST 前那次 `await flushProjectPersistence` 不是可省的画布同步,而是服务端硬前置,不能简单取消。上一条决策里「布局保存失败不再拦 POST,只是把结果降级为『只进素材库』」的说法就此更正:准确表述是——**占位从未持久化时服务端返回 409best-effort flush 不再提供成功 ACK,因此客户端无法证明该前置条件已经满足,只能提高满足它的概率**(占位可能已被此前的 450ms 自动保存落库,PATCH 也可能成功而 ACK 丢失)。被解除的是客户端侧「拿不到 revision ack 就拒发」的阻断,不是端到端依赖。
- 缺陷:首次提交与人工 exact retry 都在这次 flush **之前**就算好 `submittedAt / reconcileUntil`。该 flush 没有整体上限(单次 PATCH 60 秒 × 最多 4 次尝试,且 flush 的等待循环会清掉退避定时器立刻重跑),慢保存足以在 POST 发出前烧光整个 75 秒窗口,请求带着已过期的 reconciliation deadline 发出,对账退化成「强制读一次即以 pending 收尾」。
- 决策:窗口一律锚在 POST 发出的时刻。flush 返回且 authority 复核通过之后,调用 `createPerfectPixelReconciliationOperation` 重新设置 `submittedAt = 当前时间``reconcileUntil = 当前时间 + 75 秒`,按同一 `operationId` 覆盖本机账本与 dialog,并登记新的 recovery keykey 含 `reconcileUntil`),随后立即 POST。只覆盖时间字段:`request` 与 dialog / operation / task identity 逐字节不变,也不产生第二条账本。
- 为什么保留 flush 前的预写而不是整体后移:flush 期间另一标签页可能加载同一项目,此时服务端已有带 `perfectPixelOperationId` 的占位,而 localStorage 跨标签共享——本机若还没有账本,那条占位会被直接 hydrate 成 `failed + invalid`。预写的 provisional 账本正好堵住这个可长达数分钟的窗口,因此采用「预写 + flush 后重新锚定」,不采用「把首次账本写入整体挪到 flush 之后」。
- 人工 exact retry 同此口径:flush 之前继续沿用旧 operation(UI 可以先切到 `generating` 让用户看到重试已开始),flush 完成、authority 复核通过后才重新锚定、覆盖账本与 dialog,然后 POST。先刷新窗口再等 flush 等于把窗口烧在等待上。
- 明确不在本次范围:flush 本身的无上限等待,以及删除 `strictCompletion` 后每次 PATCH 重起 60 秒 deadline 的连带效果。既然等待是硬前置,给它加上限只会把「慢」换成「409 失败」,不构成改善;真要治需要服务端接受「占位随请求一起提交」,属于接口契约变更。
- 影响范围:`useImageCanvasGenerationWorkflow.ts``snapSelectedLayerToPerfectPixels``retryPerfectPixelOperation`。不修改服务端、SpacetimeDB schema 或对外契约。
- 同步更新的文档:本文件上一条的错误声明已就地更正;`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` 中「strict layout save 60 秒预算 / strict revision ACK 前 POST 为零」「未收口 operation 不可删除、不写 delete-generation-result 历史」「durable operation 不得被普通删除路径清理」等已被近几个提交推翻的条款一并修正。历史 commit message 只能靠重写 Git 历史才能改动,不为此改写历史,以本条追加说明为准。
- 验证方式:两条受控时钟用例分别覆盖首次提交与 exact retry——让 pre-POST flush 期间时钟前进 90 秒(超过整个 75 秒窗口),断言 POST 那一刻账本里是刚建立的完整 75 秒窗口、`submittedAt` 等于 POST 时刻、账本仍只有一条、`taskId``request` 逐字节未变;retry 用例另断言 flush 期间 dialog 上挂的仍是旧 `submittedAt`,证明窗口没有被提前刷新。两条用例均已实证:回退修复后报 `expected 1800000000000 to be 1800000090000`。运行 `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`
@@ -50,12 +50,12 @@
- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `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` 请求快照写入该占位并 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` 后,strict layout save 另有 60 秒,覆盖等待既有保存、PATCH、冲突 GET 和重试退避。strict waiter 到期必须协作取消当前请求并释放本地保存队列;远端已经收到的迟到 PATCH 无法撤销,但不得再触发后续完美像素 POST。strict revision ACK 前 POST 和结果对账 GET 均为零。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`,普通按钮不得创建第二个 operation;用户只能从原占位重试,重试请求必须与 journal 中的 POST JSON byte-for-byte 一致且不得重新上传。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 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯,却不会让布局校验失败升级成硬阻断。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`,普通按钮不得创建第二个 operation;用户只能从原占位重试,重试请求必须与 journal 中的 POST JSON byte-for-byte 一致且不得重新上传。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。
- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebPGIF、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 秒绝对 deadlinepreflight 失败或超时不得 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`,普通删除和随源图层清理不得移除该 operation;用户只能继续 GET 对账或显式按原 identity 重放。人工重试只刷新观察窗口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 tombstonecompletion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。
- 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 只激活原占位并提示继续对账/原样重试,不写 `delete-generation-result` 历史、不清选择也不移除 identity普通 generating dialog 继续进入既有删除确认,只有可立即删除的终态占位才真正写历史并清理
- 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 tombstonecompletion 与其它已持久化布局编辑冲突时继续按权威 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`,不得按首条记录猜测成功。
@@ -180,7 +180,7 @@
- 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。
- 完美像素前端第二批以 durable operation 为提交边界:生成占位必须持久化 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }`,其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}``request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput``submittedAt / reconcileUntil` 构成从快照写入起算、不得被 POST 回包续期的 75 秒整链绝对窗口。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前必须取得包含该 dialog 与 operation 快照的布局保存成功确认,保存冲突、鉴权失败、重试耗尽或无法确认时 POST 必须为零。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。
- 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。GET 的绝对 deadline 从进入 `requestJson` 起覆盖鉴权恢复、所有 attempt 和响应体读取;不能把只覆盖响应头的 `timeoutMs` 当成整次读取上界。单次 deadline 到期按一次读取失败处理,随后由轮询返回 `pending`,首次提交必须进入 `finally` 释放 dialog ownership 与图层锁,hydrate 恢复必须清理 recovery controller。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。
- 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 快照的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。新 durable operation 无论在哪个标签页、是否超过 legacy TTL 都不得被 `requiresLiveSession` 或普通删除路径清理;TTL 只兼容完全没有 operation journal 字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers``project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。
- 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 快照的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。 operation 的占位不受 `requiresLiveSession` TTL 清理(系统不替用户删),但**用户主动删除始终允许**——两者是不同的事。TTL 只兼容完全没有 operation 标记字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers``project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。
- legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。
@@ -2917,6 +2917,173 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
});
it('anchors the first submission reconciliation window at POST time when the pre-POST flush is slow', async () => {
// 中文注释:pre-POST flush 是服务端硬前置(占位未持久化会被 409 拒收),且没有整体
// 上限。窗口若锚在 flush 之前,慢保存会让 POST 带着已过期的 reconciliation deadline
// 发出。这里把时钟停住手工推进,断言 POST 那一刻窗口是刚建立的完整 75 秒。
let clock = 1_800_000_000_000;
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock);
try {
const flushGate = createDeferred<void>();
const flushProjectPersistence = vi.fn(() => flushGate.promise);
let requestAtPost: EditorPixelArtSnapInput | undefined;
let ledgerAtPost: ReturnType<typeof readPerfectPixelOperations> =
new Map();
let clockAtPost = 0;
snapImageToPerfectPixelsMock.mockImplementationOnce(
async (request: EditorPixelArtSnapInput) => {
requestAtPost = request;
clockAtPost = clock;
ledgerAtPost = readPerfectPixelOperations('user-a', 'project-1');
throw new ApiClientError({
message: '素材类型校验失败。',
status: 400,
code: 'HTTP_400',
});
},
);
render(
<GenerationWorkflowHarness
projectId="project-1"
currentUserId="user-a"
applyProjectSnapshot={vi.fn()}
flushProjectPersistence={flushProjectPersistence}
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(flushProjectPersistence).toHaveBeenCalledTimes(1);
});
// 中文注释:flush 期间必须已有 provisional 账本——否则另一标签页加载同一项目时,
// 服务端那条带 marker 的占位会被 hydrate 成 failed + invalid。
const provisional = readPerfectPixelOperations('user-a', 'project-1');
expect(provisional.size).toBe(1);
const [provisionalOperation] = [...provisional.values()];
clock += 90_000;
flushGate.resolve();
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
});
const submitted = ledgerAtPost.get(provisionalOperation!.operationId);
expect(ledgerAtPost.size).toBe(1);
expect(submitted?.submittedAt).toBe(clockAtPost);
expect(submitted!.reconcileUntil - submitted!.submittedAt).toBe(75_000);
// 中文注释:只覆盖时间字段——identity 与请求体逐字节不变,也不产生第二条账本。
expect(submitted?.taskId).toBe(provisionalOperation!.taskId);
expect(JSON.stringify(submitted!.request)).toBe(
JSON.stringify(provisionalOperation!.request),
);
expect(JSON.stringify(requestAtPost)).toBe(
JSON.stringify(provisionalOperation!.request),
);
} finally {
nowSpy.mockRestore();
}
});
it('anchors the exact retry reconciliation window at POST time when the pre-POST flush is slow', async () => {
let clock = 1_800_000_000_000;
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock);
try {
const retryFlushGate = createDeferred<void>();
const flushProjectPersistence = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementationOnce(() => retryFlushGate.promise);
let requestAtRetryPost: EditorPixelArtSnapInput | undefined;
let ledgerAtRetryPost: ReturnType<typeof readPerfectPixelOperations> =
new Map();
let clockAtRetryPost = 0;
snapImageToPerfectPixelsMock
.mockImplementationOnce(async (request: EditorPixelArtSnapInput) =>
createMismatchedPerfectPixelResult(request),
)
.mockImplementationOnce(async (request: EditorPixelArtSnapInput) => {
requestAtRetryPost = request;
clockAtRetryPost = clock;
ledgerAtRetryPost = readPerfectPixelOperations('user-a', 'project-1');
throw new ApiClientError({
message: '素材类型校验失败。',
status: 400,
code: 'HTTP_400',
});
});
loadEditorProjectMock.mockImplementation(async () => {
const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as
| EditorPixelArtSnapInput
| undefined;
return createConflictingPerfectPixelProject(
request!.canvasCompletion.dialogId,
);
});
render(
<GenerationWorkflowHarness
projectId="project-1"
currentUserId="user-a"
applyProjectSnapshot={vi.fn()}
flushProjectPersistence={flushProjectPersistence}
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).toContain(
'pending-confirmation',
);
});
const beforeRetry = JSON.parse(
screen.getByTestId('perfect-pixel-operation').textContent!,
) as { operationId: string; taskId: string; submittedAt: number };
fireEvent.click(screen.getByRole('button', { name: '重试完美像素' }));
await waitFor(() => {
expect(flushProjectPersistence).toHaveBeenCalledTimes(2);
});
// 中文注释:flush 期间不得提前刷新窗口——挂着的仍是旧 operation。
const duringFlush = JSON.parse(
screen.getByTestId('perfect-pixel-operation').textContent!,
) as { submittedAt: number };
expect(duringFlush.submittedAt).toBe(beforeRetry.submittedAt);
clock += 90_000;
retryFlushGate.resolve();
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2);
});
const retried = ledgerAtRetryPost.get(beforeRetry.operationId);
expect(ledgerAtRetryPost.size).toBe(1);
expect(retried?.submittedAt).toBe(clockAtRetryPost);
expect(retried!.reconcileUntil - retried!.submittedAt).toBe(75_000);
expect(retried?.taskId).toBe(beforeRetry.taskId);
const firstRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as
| EditorPixelArtSnapInput
| undefined;
expect(JSON.stringify(requestAtRetryPost)).toBe(
JSON.stringify(firstRequest),
);
} finally {
nowSpy.mockRestore();
}
});
it('reuses one inline upload and the exact persisted POST bytes when an unknown operation is retried', async () => {
const flushProjectPersistence = vi.fn().mockResolvedValue(undefined);
uploadEditorMediaAssetObjectFileMock.mockResolvedValueOnce({
@@ -2394,14 +2394,45 @@ export function useImageCanvasGenerationWorkflow({
normalizedProjectId,
perfectPixelOperation,
);
// 中文注释:布局保存尽力而为——占位存进服务端后,服务端才能用 canvasCompletion
// 就地替换它。保存失败不拦 POST,只是把结果降级成「只进素材库」,由对账提示用户。
// 中文注释:这次 flush 不是可省的画布同步,而是服务端的硬前置——POST 到达时若占位
// 从未持久化,`validate_editor_pixel_art_snap_placeholder_exists` 直接 409。改成
// best-effort 之后客户端不再拿到成功 ACK,因此**无法证明**该前置已满足,只能提高
// 满足它的概率:占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失。
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
// 中文注释:对账窗口必须锚在 POST 发出的时刻。上面这次 flush 没有整体上限(单次
// PATCH 60 秒 × 最多 4 次尝试),慢保存足以把 75 秒窗口在 POST 之前烧光,让请求
// 带着已过期的 deadline 发出。这里重新锚定——只覆盖时间字段,request、dialog /
// operation / task identity 与预写的那条完全一致,账本按同一 operationId 覆盖,
// 不会产生第二条。
//
// 预写仍然保留在 flush 之前:flush 期间另一标签页可能加载同一项目,此时服务端已有
// 带 marker 的占位,本机若没有账本就会被 hydrate 成 `failed + invalid`。预写的
// provisional 账本正好堵住这个窗口。
const submittedOperation = createPerfectPixelReconciliationOperation(
perfectPixelOperation,
);
perfectPixelOperation = submittedOperation;
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
submittedOperation,
),
);
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
submittedOperation,
);
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
perfectPixelOperation: submittedOperation,
}));
perfectPixelPostAttempted = true;
const result = await snapImageToPerfectPixels(
perfectPixelOperation.request,
@@ -2607,8 +2638,11 @@ export function useImageCanvasGenerationWorkflow({
if (perfectPixelLayerIdsRef.current.has(lockKey)) {
return;
}
const retriedOperation =
createPerfectPixelReconciliationOperation(operation);
// 中文注释:flush 之前继续沿用旧 operation——窗口只能锚在 POST 发出的时刻,而这次
// flush 是服务端硬前置且没有整体上限,先刷新窗口再等它等于把窗口烧在等待上。
// request 与 dialog / operation / task identity 前后完全一致,只有时间字段会在
// flush 之后被重新锚定。
let retriedOperation = operation;
let postAttempted = false;
perfectPixelLayerIdsRef.current.add(lockKey);
claimActiveInlineGenerationDialog(normalizedDialogId);
@@ -2620,6 +2654,24 @@ export function useImageCanvasGenerationWorkflow({
return nextLayerIds;
});
}
// 中文注释:UI 先切到处理中,让用户立刻看到重试已经开始;此时挂的仍是旧
// operation,账本里已有的那条与它一致,不需要重写。
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'generating',
errorMessage: undefined,
requiresLiveSession: undefined,
perfectPixelOperationId: normalizedDialogId,
perfectPixelOperation: operation,
}));
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
// 中文注释:flush 之后才重新锚定对账窗口,并按同一 operationId 覆盖账本与 dialog。
retriedOperation = createPerfectPixelReconciliationOperation(operation);
observedPerfectPixelRecoveryKeysRef.current.add(
perfectPixelRecoveryKey(
currentUserId,
@@ -2627,25 +2679,15 @@ export function useImageCanvasGenerationWorkflow({
retriedOperation,
),
);
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
status: 'generating',
errorMessage: undefined,
requiresLiveSession: undefined,
perfectPixelOperationId: normalizedDialogId,
perfectPixelOperation: retriedOperation,
}));
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
retriedOperation,
);
await flushProjectPersistence({
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
...current,
perfectPixelOperation: retriedOperation,
}));
postAttempted = true;
const result = await snapImageToPerfectPixels(retriedOperation.request);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {