From 7cbf33de96d8fc96516ebf087b930b31f37df025 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 12 Aug 2026 13:20:20 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dissue160?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared-memory/decision-log.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- .../crates/api-server/src/editor_project.rs | 175 +++++++++--------- .../useImageCanvasGenerationWorkflow.test.tsx | 40 ++++ .../useImageCanvasGenerationWorkflow.ts | 11 ++ 5 files changed, 141 insertions(+), 91 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4f1f34f8b..b4b8a5dff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7092,7 +7092,7 @@ - 决策:生成请求勾选 `style="pixelArt"` 与已有图片手动 `POST /api/editor/images/pixel-art-snaps` 共用同一输出语义:snapper 直接编码并持久化唯一的逻辑分辨率 PNG,不再 nearest 恢复到源图、RGBA 输入、业务交付或 generation dialog 占位尺寸。成功输出宽高固定为 `(columns.len() - 1) × (rows.len() - 1)`,允许与输入、交付和占位尺寸不同;响应、project resource、账号素材和结果 layer 一律记录最终 PNG 的实际宽高。 - 保留边界:普通图片和角色在规整前执行的 Lanczos 交付尺寸归一继续保留;角色 / 图标的平底网格分析源与透明 RGBA 采样源仍必须同尺寸,Alpha 覆盖、Alpha 加权 RGB、二值 Alpha、P30 步长估算、确定性采样、输入上限、deadline、strict 无网格拒绝和失败降级尺寸守卫全部不变。这些约束保护输入坐标系、资源安全或失败路径,不构成成功输出与输入同尺寸的承诺。 - 持久化边界:数量增量保持不变。普通图片只保存一张最终逻辑主图;角色保留一张 provider 原图与一张最终透明逻辑主图;图标保留一张 provider 原图、一张最终透明逻辑图集和原有成功切片;手动完美像素只保存一张最终逻辑 PNG。不得另外保存输入尺寸恢复版、像素化前后双份主图、预览、诊断或报告,不修改 asset kind、队列类型、数据库 schema、路由或请求 / 响应字段形状。 -- 跨版本重放:手动入口算法指纹升为 `perfect-pixel-v2`。同一稳定 operation 已有结果时,candidate object key 相同才继续既有 exact replay;key 不同或既有稳定资源缺 key 时,必须在 preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`,由客户端 GET 权威项目收口,不得冒充本次请求已经设置 `resultPersistenceStarted`。preflight 到最终提交之间仍无数据库 reservation,滚动发布必须排空旧算法实例,不能把该护栏解释为消除了并发 TOCTOU。 +- 跨版本重放:手动入口算法指纹升为 `perfect-pixel-v2`。完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,即在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`,由客户端 GET 权威项目收口;不再按 candidate object key 继续 exact replay。该分支不带 `resultPersistenceStarted`,因为本请求尚未开始持久化。preflight 到最终提交之间仍无数据库 reservation,滚动发布必须排空旧算法实例,不能把该护栏解释为消除了并发 TOCTOU。 - 历史边界:本条覆盖 2026-07-28 首发决策中“逻辑结果 nearest 恢复交付尺寸 / 逻辑图不持久化”和 2026-07-30 手动入口中“右侧新增同尺寸 PNG / 不保存逻辑低分辨率图”的旧口径;旧条目作为历史记录保留,不回写改造。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index db8847913..4c75569f3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -66,8 +66,8 @@ - 前端提交前先创建关闭 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 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯。被解除的是**客户端侧**「拿不到 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 一致且不得重新上传。**明确接受的行为,不是缺口**:占位恢复可删除之后,用户删掉未收口占位再从源图发起会得到第二个 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` 写回一个派生图层;resource、asset、响应与图层使用该 PNG 的实际宽高,不要求与源图或占位尺寸相等,也不得另存输入尺寸恢复版、诊断图或前后对比图。 -- 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` 对账,不能把未收到回包等同于未提交。 -- 手动入口的算法指纹随逻辑分辨率输出升级为 `perfect-pixel-v2`。若 owner-scoped 项目快照中同一稳定 resource 已存在,candidate object key 相同才继续 exact replay;key 不同或既有 resource 缺 key 时,后端必须在 preflight / OSS PUT 前返回 `operationResultAlreadyExists=true`,前端 initial 与 retry 两条 catch 都按稳定 task GET 项目对账。该标记表示旧权威结果已存在,不得与“本次 PUT 已开始”的 `resultPersistenceStarted` 混用;发布时仍须排空旧算法实例以规避 preflight 到提交之间的跨版本 TOCTOU。 +- 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;一旦 owner-scoped 项目快照已发现同 operation 的稳定 resource,本次 POST 不再执行 candidate-key exact replay,而是直接返回 `operationResultAlreadyExists=true` 并交由 GET 对账;输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 +- 手动入口的算法指纹随逻辑分辨率输出升级为 `perfect-pixel-v2`。在完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,后端必须在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`;不再按 candidate object key 继续 exact replay。前端 initial 与 retry 两条 catch 都按稳定 task GET 项目对账,由权威快照明确 `applied`、`dialog-missing` 或 `conflict`。该标记表示旧权威结果已存在,不得与“本次 PUT 已开始”的 `resultPersistenceStarted` 混用;发布时仍须排空旧算法实例以规避独立新操作在 preflight 到提交之间的跨版本 TOCTOU。 - `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` 必须无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 4cdd11524..9154d2f9e 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -6489,9 +6489,8 @@ fn validate_editor_pixel_art_snap_placeholder_exists( return Ok(()); } // 中文注释:首个事务若以 DialogMissing 成功、但 HTTP 响应丢失,同 operation 的稳定 - // project resource 已存在,而占位按定义仍然不存在。该形状必须允许继续走到原子 procedure - // 的 exact compare-and-return;否则幂等重放会被这个处理前门禁反向拦成 409。 - let expected_task_id = format!("pixel-art-snap-{dialog_id}"); + // project resource 已存在,而占位按定义仍然不存在。该形状必须允许到达紧随其后的 + // stable-result guard,统一返回 GET-only 权威对账;否则会被这个处理前门禁反向拦住。 let expected_resource_id = format!( "{EDITOR_RESOURCE_ID_PREFIX}{}", editor_pixel_art_stable_record_suffix( @@ -6505,7 +6504,6 @@ fn validate_editor_pixel_art_snap_placeholder_exists( resource.resource_id == expected_resource_id && resource.owner_user_id == owner_user_id && resource.project_id == project_id - && resource.task_id.as_deref() == Some(expected_task_id.as_str()) }) { return Ok(()); } @@ -6524,16 +6522,6 @@ struct EditorPixelArtSourceResolution { object_key: String, asset_kind: Option, generation_input_reference: Option, - existing_result_generation_inputs: Option>, - // 外层 Some 表示稳定结果资源已存在;内层 None 保留“记录存在但缺 object_key”的损坏形状。 - existing_result_object_key: Option>, -} - -fn resolve_editor_pixel_art_persisted_generation_inputs( - authoritative: Option, - existing_result: Option>, -) -> Option { - existing_result.unwrap_or(authoritative) } fn push_editor_pixel_art_source_asset_kind( @@ -6607,20 +6595,7 @@ async fn resolve_editor_pixel_art_source_for_owner( project: &EditorProjectPayload, source_resource: Option<&EditorProjectResourcePayload>, requested_asset_kind: Option<&str>, - expected_result_resource_id: &str, - expected_result_task_id: &str, ) -> Result { - let existing_stable_resource = project - .resources - .iter() - .find(|resource| resource.resource_id.trim() == expected_result_resource_id); - let existing_result = existing_stable_resource.filter(|resource| { - resource.task_id.as_deref().map(str::trim) == Some(expected_result_task_id) - }); - let existing_result_generation_inputs = - existing_result.map(|resource| resource.generation_inputs.clone()); - let existing_result_object_key = existing_stable_resource - .map(|resource| normalize_optional_string(resource.object_key.clone())); let resolved_without_lookup = match source_resource { Some(source_resource) => resolve_editor_pixel_art_source_without_lookup( owner_user_id, @@ -6819,24 +6794,22 @@ async fn resolve_editor_pixel_art_source_for_owner( object_key, asset_kind, generation_input_reference, - existing_result_generation_inputs, - existing_result_object_key, }) } -fn ensure_editor_pixel_art_existing_result_matches_candidate_object_key( - existing_result_object_key: Option>, - candidate_object_key: &str, +fn ensure_editor_pixel_art_stable_result_is_absent( + resources: &[EditorProjectResourcePayload], + expected_result_resource_id: &str, ) -> Result<(), AppError> { - let Some(existing_result_object_key) = existing_result_object_key else { - return Ok(()); - }; - if existing_result_object_key == Some(candidate_object_key) { + if !resources + .iter() + .any(|resource| resource.resource_id.trim() == expected_result_resource_id) + { return Ok(()); } Err(editor_pixel_art_snap_failure( StatusCode::CONFLICT, - "同一完美像素操作已有其它权威结果,请先读取项目状态对账。", + "同一完美像素操作已有权威结果,请先读取项目状态对账。", ) .with_detail_field(EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL, json!(true))) } @@ -7030,7 +7003,6 @@ pub async fn snap_editor_image_to_pixel_art( // 都在许可覆盖范围内,许可随 handler 返回自动释放。 let _snap_permit = acquire_editor_pixel_art_snap_permit(processing_deadline).await?; let owner_user_id = current_owner_user_id(&authenticated); - let expected_result_task_id = format!("pixel-art-snap-{dialog_id}"); let expected_result_resource_id = format!( "{EDITOR_RESOURCE_ID_PREFIX}{}", editor_pixel_art_stable_record_suffix( @@ -7066,6 +7038,14 @@ pub async fn snap_editor_image_to_pixel_art( project_id.as_str(), &payload.canvas_completion, )?; + // 中文注释:稳定 result resource 是 owner-scoped 项目快照中的权威完成事实。 + // 一旦它已存在,本请求不得再解析来源、读取 OSS 或重跑像素规整;旧响应丢失和 + // 跨版本 exact retry 都统一交由客户端 GET 项目快照判定 applied / DialogMissing / + // conflict。这里不附 resultPersistenceStarted:本请求尚未进入任何持久化副作用。 + ensure_editor_pixel_art_stable_result_is_absent( + project.resources.as_slice(), + expected_result_resource_id.as_str(), + )?; let source_resource = if let Some(source_resource_id) = source_resource_id.as_deref() { Some( project @@ -7090,8 +7070,6 @@ pub async fn snap_editor_image_to_pixel_art( &project, source_resource, payload.asset_kind.as_deref(), - expected_result_resource_id.as_str(), - expected_result_task_id.as_str(), ) .await }) @@ -7104,19 +7082,14 @@ pub async fn snap_editor_image_to_pixel_art( })??; let source_object_key = source.object_key; let asset_kind = source.asset_kind; - let existing_result_object_key = source.existing_result_object_key; let authoritative_generation_inputs = rebuild_editor_generation_inputs_with_authoritative_references( payload.generation_inputs.take(), source.generation_input_reference.into_iter().collect(), ); - // 旧结果已经落库时,重放必须携带原记录的 metadata 才能通过 SpacetimeDB 的精确 - // compare-and-return;这只复用已由服务端持久化的 owner-scoped 记录。新操作始终使用 - // 上面按已鉴权源重建的 references,不再接受客户端自报 provenance。 - payload.generation_inputs = resolve_editor_pixel_art_persisted_generation_inputs( - authoritative_generation_inputs, - source.existing_result_generation_inputs, - ); + // 新操作始终使用按已鉴权源重建的 references,不接受客户端自报 provenance。已有稳定 + // operation 已在上面的 owner-scoped 项目快照阶段返回 GET-only 对账,不会走到这里。 + payload.generation_inputs = authoritative_generation_inputs; let source_image = download_editor_persisted_image_object_within_deadline( &state, source_object_key.as_str(), @@ -7203,14 +7176,6 @@ pub async fn snap_editor_image_to_pixel_art( "genarrative", )?; let prepared_object_key = prepared_upload.storage_paths.object_key.clone(); - // 中文注释:算法版本变化会改变 fingerprint 与 object key,但 operation/dialog 和稳定记录 - // ID 保持不变。若旧版本结果已经落库,必须在任何 preflight/OSS PUT 之前失败关闭并对账。 - ensure_editor_pixel_art_existing_result_matches_candidate_object_key( - existing_result_object_key - .as_ref() - .map(|object_key| object_key.as_deref()), - prepared_object_key.as_str(), - )?; let image_src = editor_media_src_from_object_key(prepared_object_key.as_str()); let mut project_resource = EditorProjectResourceCreateRecordInput { resource_id: persistence_identity.resource_id.clone(), @@ -14223,26 +14188,30 @@ mod tests { } #[test] - fn perfect_pixel_replay_uses_existing_server_metadata_for_exact_compare() { - let authoritative = Some(json!({ + fn perfect_pixel_uses_authoritative_source_metadata_for_new_operations() { + let client_claimed = Some(json!({ "fields": [], - "references": [{"refType": "project-resource", "refId": "resource-owned"}] + "references": [{"refType": "asset", "refId": "asset-other-owner"}] })); - let historical = Some(json!({ - "fields": [], - "references": [{"refType": "asset", "refId": "legacy-client-value"}] - })); - assert_eq!( - resolve_editor_pixel_art_persisted_generation_inputs( - authoritative, - Some(historical.clone()), + rebuild_editor_generation_inputs_with_authoritative_references( + sanitize_editor_untrusted_generation_inputs(client_claimed), + vec![json!({ + "title": "原图", + "label": "当前项目资源", + "refType": "project-resource", + "refId": "resource-owned" + })], ), - historical - ); - assert_eq!( - resolve_editor_pixel_art_persisted_generation_inputs(None, Some(None)), - None + Some(json!({ + "fields": [], + "references": [{ + "title": "原图", + "label": "当前项目资源", + "refType": "project-resource", + "refId": "resource-owned" + }] + })) ); } @@ -16095,7 +16064,7 @@ mod tests { &completion, ) .is_ok(), - "DialogMissing 的同 operation 重放必须进入 procedure 做 exact compare" + "DialogMissing 的同 operation 重放必须进入稳定结果权威对账" ); } @@ -16130,13 +16099,19 @@ mod tests { "tokio::time::timeout_at(", ".get_editor_project", "validate_editor_pixel_art_snap_placeholder_exists", + // 中文注释:同一稳定 operation 已有资源时,必须在来源解析、OSS GET 和 CPU + // 规整前直接转入 GET-only 权威对账;旧资源损坏也由客户端 verdict 报告, + // 本请求不得尝试以新计算补写它。 + "ensure_editor_pixel_art_stable_result_is_absent(", "resolve_editor_pixel_art_source_for_owner", "完美像素来源归属校验超出处理预算。", "download_editor_persisted_image_object_within_deadline", "validate_editor_pixel_art_static_raster", "snap_editor_pixel_art_strict", "Some(processing_deadline)", - // 中文注释:prepare 只计算精确 object key;只读 preflight 与后续 + // 中文注释:prepare 只计算新操作的精确 object key;已有稳定 result 已在 + // 前面的 owner-scoped 项目快照分支返回,不能再依赖 candidate key 判定。 + // 只读 preflight 与后续 // PUT/HEAD/原子 persist 共用第二份 60 秒绝对 deadline。preflight 必须发生 // 在第一次外部写之前,避免已知的目录/布局拒绝留下 OSS 孤儿对象。 "prepare_editor_generated_image_object_data(", @@ -16150,6 +16125,15 @@ mod tests { ".persist_editor_pixel_art_result(", ], ); + assert_function_not_contains( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + "ensure_editor_pixel_art_existing_result_matches_candidate_object_key(", + "resolve_editor_pixel_art_persisted_generation_inputs(", + ], + ); assert_function_contains_in_order( source, "pub async fn snap_editor_image_to_pixel_art(", @@ -19596,25 +19580,40 @@ mod tests { } #[test] - fn explicit_pixel_art_snap_reconciles_existing_result_before_upload() { - let candidate = "pixel-art-snaps/v2.png"; + fn explicit_pixel_art_snap_reconciles_existing_stable_result_before_source_processing() { + let mut stable_result = editor_project_resource_for_canvas_test( + "editor-resource-stable-result", + "image", + 128, + 128, + ); + stable_result.resource_id = "editor-resource-stable-result".to_string(); + stable_result.task_id = Some("pixel-art-snap-corrupted-task".to_string()); + stable_result.object_key = None; + assert!( - ensure_editor_pixel_art_existing_result_matches_candidate_object_key(None, candidate,) + ensure_editor_pixel_art_stable_result_is_absent(&[], "editor-resource-stable-result") .is_ok() ); - assert!( - ensure_editor_pixel_art_existing_result_matches_candidate_object_key( - Some(Some(candidate)), - candidate, - ) - .is_ok() - ); - let error = ensure_editor_pixel_art_existing_result_matches_candidate_object_key( - Some(Some("pixel-art-snaps/v1.png")), - candidate, + let error = ensure_editor_pixel_art_stable_result_is_absent( + &[stable_result], + "editor-resource-stable-result", ) - .expect_err("different stable result must fail before upload"); + .expect_err("stable result must enter authority reconciliation before source processing"); assert_eq!(error.status_code(), StatusCode::CONFLICT); + assert_eq!( + error.details().and_then(|details| details + [EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL] + .as_bool()), + Some(true) + ); + assert_eq!( + error + .details() + .and_then(|details| details[EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL].as_bool()), + None, + "this request has not entered result persistence" + ); } #[test] diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index b78294d65..bc1b36f55 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1396,6 +1396,46 @@ describe('useImageCanvasGenerationWorkflow', () => { ).toMatchObject({ kind: 'pending', project: foreignProject }); }); + it('rejects an incomplete matching task resource instead of treating it as an applied result', () => { + const operationId = 'perfect-pixel-corrupted-resource'; + const project = createPerfectPixelProject(operationId, 'applied'); + project.resources[0] = { + ...project.resources[0]!, + objectKey: null, + }; + + expect( + inspectPerfectPixelProjectSnapshot(project, { + operationId, + taskId: `pixel-art-snap-${operationId}`, + }), + ).toMatchObject({ + kind: 'conflict', + project, + message: '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + }); + }); + + it('rejects a settled dialog whose generated layer points at another resource', () => { + const operationId = 'perfect-pixel-mismatched-layer'; + const project = createPerfectPixelProject(operationId, 'applied'); + project.layers[0] = { + ...project.layers[0]!, + resourceId: 'resource-other', + }; + + expect( + inspectPerfectPixelProjectSnapshot(project, { + operationId, + taskId: `pixel-art-snap-${operationId}`, + }), + ).toMatchObject({ + kind: 'conflict', + project, + message: '完美像素占位与任务资源的画布关联不一致,无法自动应用。', + }); + }); + it('opens a movable canvas generation placeholder and keeps toolbar state active', () => { render(); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 26b9efab1..8614b8aa1 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -369,6 +369,17 @@ export function inspectPerfectPixelProjectSnapshot( message: '完美像素占位已收口,但权威项目缺少对应任务资源。', }; } + if ( + resource.projectId !== project.projectId || + !resource.objectKey?.trim() || + !resource.imageSrc.trim() + ) { + return { + kind: 'conflict', + project, + message: '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + }; + } if (!dialog) { return { kind: 'dialog-missing', project, resource }; } -- 2.52.0 From b432c90ed5ed5f299eff6e73dd778af0ff99e17d Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 12 Aug 2026 22:10:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=8C=E7=BE=8E?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E6=8D=9F=E5=9D=8F=E7=BB=93=E6=9E=9C=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端返回已鉴权的稳定结果资源 ID 前端按稳定资源与任务双重校验权威结果 补充损坏任务与完整错误收口回归测试 同步图片画布技术方案与共享决策记录 --- .../shared-memory/decision-log.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../crates/api-server/src/editor_project.rs | 13 ++- .../useImageCanvasGenerationWorkflow.test.tsx | 84 +++++++++++++++++++ .../useImageCanvasGenerationWorkflow.ts | 35 +++++++- 5 files changed, 131 insertions(+), 5 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 207f369d3..25fdd38db 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7101,7 +7101,7 @@ - 决策:生成请求勾选 `style="pixelArt"` 与已有图片手动 `POST /api/editor/images/pixel-art-snaps` 共用同一输出语义:snapper 直接编码并持久化唯一的逻辑分辨率 PNG,不再 nearest 恢复到源图、RGBA 输入、业务交付或 generation dialog 占位尺寸。成功输出宽高固定为 `(columns.len() - 1) × (rows.len() - 1)`,允许与输入、交付和占位尺寸不同;响应、project resource、账号素材和结果 layer 一律记录最终 PNG 的实际宽高。 - 保留边界:普通图片和角色在规整前执行的 Lanczos 交付尺寸归一继续保留;角色 / 图标的平底网格分析源与透明 RGBA 采样源仍必须同尺寸,Alpha 覆盖、Alpha 加权 RGB、二值 Alpha、P30 步长估算、确定性采样、输入上限、deadline、strict 无网格拒绝和失败降级尺寸守卫全部不变。这些约束保护输入坐标系、资源安全或失败路径,不构成成功输出与输入同尺寸的承诺。 - 持久化边界:数量增量保持不变。普通图片只保存一张最终逻辑主图;角色保留一张 provider 原图与一张最终透明逻辑主图;图标保留一张 provider 原图、一张最终透明逻辑图集和原有成功切片;手动完美像素只保存一张最终逻辑 PNG。不得另外保存输入尺寸恢复版、像素化前后双份主图、预览、诊断或报告,不修改 asset kind、队列类型、数据库 schema、路由或请求 / 响应字段形状。 -- 跨版本重放:手动入口算法指纹升为 `perfect-pixel-v2`。完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,即在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`,由客户端 GET 权威项目收口;不再按 candidate object key 继续 exact replay。该分支不带 `resultPersistenceStarted`,因为本请求尚未开始持久化。preflight 到最终提交之间仍无数据库 reservation,滚动发布必须排空旧算法实例,不能把该护栏解释为消除了并发 TOCTOU。 +- 跨版本重放:手动入口算法指纹升为 `perfect-pixel-v2`。完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,即在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true` 及已鉴权的稳定 `resultResourceId`,由客户端 GET 权威项目收口;前端按稳定资源 ID 定位后继续复核 task、项目、对象与 dialog/layer 关联,损坏 task 必须明确失败关闭为冲突,不得漏检后持续等待。不再按 candidate object key 继续 exact replay。该分支不带 `resultPersistenceStarted`,因为本请求尚未开始持久化。preflight 到最终提交之间仍无数据库 reservation,滚动发布必须排空旧算法实例,不能把该护栏解释为消除了并发 TOCTOU。 - 历史边界:本条覆盖 2026-07-28 首发决策中“逻辑结果 nearest 恢复交付尺寸 / 逻辑图不持久化”和 2026-07-30 手动入口中“右侧新增同尺寸 PNG / 不保存逻辑低分辨率图”的旧口径;旧条目作为历史记录保留,不回写改造。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 4c75569f3..5c9479dc3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -67,7 +67,7 @@ - 源准备与 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 一致且不得重新上传。**明确接受的行为,不是缺口**:占位恢复可删除之后,用户删掉未收口占位再从源图发起会得到第二个 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` 写回一个派生图层;resource、asset、响应与图层使用该 PNG 的实际宽高,不要求与源图或占位尺寸相等,也不得另存输入尺寸恢复版、诊断图或前后对比图。 - 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;一旦 owner-scoped 项目快照已发现同 operation 的稳定 resource,本次 POST 不再执行 candidate-key exact replay,而是直接返回 `operationResultAlreadyExists=true` 并交由 GET 对账;输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 -- 手动入口的算法指纹随逻辑分辨率输出升级为 `perfect-pixel-v2`。在完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,后端必须在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`;不再按 candidate object key 继续 exact replay。前端 initial 与 retry 两条 catch 都按稳定 task GET 项目对账,由权威快照明确 `applied`、`dialog-missing` 或 `conflict`。该标记表示旧权威结果已存在,不得与“本次 PUT 已开始”的 `resultPersistenceStarted` 混用;发布时仍须排空旧算法实例以规避独立新操作在 preflight 到提交之间的跨版本 TOCTOU。 +- 手动入口的算法指纹随逻辑分辨率输出升级为 `perfect-pixel-v2`。在完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,后端必须在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`,并携带已鉴权的稳定 `resultResourceId`;不再按 candidate object key 继续 exact replay。前端 initial 与 retry 两条 catch 都按稳定资源与 task GET 项目对账,由权威快照明确 `applied`、`dialog-missing` 或 `conflict`;即使稳定记录的 `taskId` 损坏,也必须据 `resultResourceId` 找到该记录并失败关闭为冲突,不得持续等待。该标记表示旧权威结果已存在,不得与“本次 PUT 已开始”的 `resultPersistenceStarted` 混用;发布时仍须排空旧算法实例以规避独立新操作在 preflight 到提交之间的跨版本 TOCTOU。 - `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` 必须无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9154d2f9e..8d8733480 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -204,6 +204,7 @@ const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs( /// 未知结果边界。这个 detail 字段只在该边界之后置位,客户端据此先读权威快照对账。 pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted"; const EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL: &str = "operationResultAlreadyExists"; +const EDITOR_OPERATION_RESULT_RESOURCE_ID_DETAIL: &str = "resultResourceId"; const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap"; const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel"; const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative"; @@ -6811,7 +6812,11 @@ fn ensure_editor_pixel_art_stable_result_is_absent( StatusCode::CONFLICT, "同一完美像素操作已有权威结果,请先读取项目状态对账。", ) - .with_detail_field(EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL, json!(true))) + .with_detail_field(EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL, json!(true)) + .with_detail_field( + EDITOR_OPERATION_RESULT_RESOURCE_ID_DETAIL, + json!(expected_result_resource_id), + )) } fn resolve_editor_pixel_art_asset_folder_id(asset_folder_id: Option) -> Option { @@ -19607,6 +19612,12 @@ mod tests { .as_bool()), Some(true) ); + assert_eq!( + error + .details() + .and_then(|details| details[EDITOR_OPERATION_RESULT_RESOURCE_ID_DETAIL].as_str()), + Some("editor-resource-stable-result") + ); assert_eq!( error .details() diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index bc1b36f55..1b27d0c84 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1416,6 +1416,31 @@ describe('useImageCanvasGenerationWorkflow', () => { }); }); + it('uses the server-confirmed stable resource id to reject a corrupted task id', () => { + const operationId = 'perfect-pixel-corrupted-task'; + const project = createPerfectPixelProject(operationId, 'dialog-missing'); + project.resources[0] = { + ...project.resources[0]!, + taskId: 'pixel-art-snap-another-operation', + objectKey: null, + }; + + expect( + inspectPerfectPixelProjectSnapshot( + project, + { + operationId, + taskId: `pixel-art-snap-${operationId}`, + }, + project.resources[0]!.resourceId, + ), + ).toMatchObject({ + kind: 'conflict', + project, + message: '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + }); + }); + it('rejects a settled dialog whose generated layer points at another resource', () => { const operationId = 'perfect-pixel-mismatched-layer'; const project = createPerfectPixelProject(operationId, 'applied'); @@ -3841,6 +3866,65 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('dialog').textContent).not.toContain('failed'); }); + it('uses the server-confirmed stable resource id during existing-result reconciliation', async () => { + const applyProjectSnapshot = vi.fn(); + let reconciledProject: EditorProjectSnapshot | undefined; + snapImageToPerfectPixelsMock.mockImplementationOnce( + async (request: EditorPixelArtSnapInput) => { + const operationId = request.canvasCompletion.dialogId; + reconciledProject = createPerfectPixelProject( + operationId, + 'dialog-missing', + ); + reconciledProject.resources[0] = { + ...reconciledProject.resources[0]!, + taskId: 'pixel-art-snap-corrupted-task', + objectKey: null, + }; + throw new ApiClientError({ + message: '同一完美像素操作已有权威结果,请先读取项目状态对账。', + status: 409, + code: 'HTTP_409', + details: { + operationResultAlreadyExists: true, + resultResourceId: reconciledProject.resources[0]!.resourceId, + }, + }); + }, + ); + loadEditorProjectMock.mockImplementationOnce(async () => { + expect(reconciledProject).toBeDefined(); + return reconciledProject!; + }); + + render( + {})} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + + await waitFor(() => { + expect(screen.getByTestId('dialog-error').textContent).toContain( + '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + ); + }); + expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + expect(screen.getByTestId('dialog').textContent).toContain( + 'pending-confirmation', + ); + }); + it('skips reconciliation for a responded failure the server did not mark', async () => { // 中文注释:纯校验失败发生在任何 IO 之前,不可能留下对象或素材。服务端不置 // resultPersistenceStarted,客户端就不该多打两次读取,也不该附上「请核对素材库」 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 8614b8aa1..45ea3c89a 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -334,9 +334,14 @@ export type PerfectPixelProjectVerdict = export function inspectPerfectPixelProjectSnapshot( project: EditorProjectSnapshot, operation: Pick, + resultResourceId?: string | null, ): PerfectPixelProjectVerdict { + const normalizedResultResourceId = resultResourceId?.trim() ?? ''; const matchingResources = project.resources.filter( - (resource) => resource.taskId?.trim() === operation.taskId, + (resource) => + resource.taskId?.trim() === operation.taskId || + (normalizedResultResourceId !== '' && + resource.resourceId.trim() === normalizedResultResourceId), ); const matchingDialogs = findCanvasGenerationDialogRecords( project, @@ -370,6 +375,9 @@ export function inspectPerfectPixelProjectSnapshot( }; } if ( + (normalizedResultResourceId !== '' && + resource.resourceId.trim() !== normalizedResultResourceId) || + resource.taskId?.trim() !== operation.taskId || resource.projectId !== project.projectId || !resource.objectKey?.trim() || !resource.imageSrc.trim() @@ -447,7 +455,7 @@ function waitForPerfectPixelReconciliationDelay( async function reconcilePerfectPixelProject( projectId: string, operation: PerfectPixelOperationSnapshot, - options: { signal?: AbortSignal } = {}, + options: { signal?: AbortSignal; resultResourceId?: string | null } = {}, ): Promise { let attempt = 0; let hasAttemptedRead = false; @@ -489,6 +497,7 @@ async function reconcilePerfectPixelProject( const verdict = inspectPerfectPixelProjectSnapshot( latestProject, operation, + options.resultResourceId, ); if (verdict.kind !== 'pending') { return verdict; @@ -2796,6 +2805,11 @@ export function useImageCanvasGenerationWorkflow({ error instanceof ApiClientError && (error.details as { operationResultAlreadyExists?: unknown } | null) ?.operationResultAlreadyExists === true; + const existingResultResourceId = + operationResultAlreadyExists && error instanceof ApiClientError + ? (error.details as { resultResourceId?: unknown } | null) + ?.resultResourceId + : null; const outcomeMayBePersisted = perfectPixelPostAttempted && Boolean(perfectPixelDialogId) && @@ -2819,6 +2833,12 @@ export function useImageCanvasGenerationWorkflow({ const verdict = await reconcilePerfectPixelProject( normalizedProjectId, perfectPixelOperation, + { + resultResourceId: + typeof existingResultResourceId === 'string' + ? existingResultResourceId + : null, + }, ); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; @@ -3045,6 +3065,11 @@ export function useImageCanvasGenerationWorkflow({ error instanceof ApiClientError && (error.details as { operationResultAlreadyExists?: unknown } | null) ?.operationResultAlreadyExists === true; + const existingResultResourceId = + operationResultAlreadyExists && error instanceof ApiClientError + ? (error.details as { resultResourceId?: unknown } | null) + ?.resultResourceId + : null; const outcomeMayBePersisted = postAttempted && (!(error instanceof ApiClientError) || @@ -3055,6 +3080,12 @@ export function useImageCanvasGenerationWorkflow({ const verdict = await reconcilePerfectPixelProject( normalizedProjectId, retriedOperation, + { + resultResourceId: + typeof existingResultResourceId === 'string' + ? existingResultResourceId + : null, + }, ); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; -- 2.52.0 From 902a6b1b33324bf2424fbbd33333b0c4dc863c11 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 13 Aug 2026 03:49:13 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=8C=E7=BE=8E?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E6=97=A2=E6=9C=89=E7=BB=93=E6=9E=9C=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充人工重试遇到损坏稳定资源时的 GET-only 对账冲突回归。 同步后端权威契约的稳定结果短路语义。 --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- .../useImageCanvasGenerationWorkflow.test.tsx | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d3c9496aa..07a0d37aa 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -249,12 +249,12 @@ npm run check:server-rs-ddd - 图标规范结构化分析里位于 `` / `` XML 元素内的数据必须转义 `& < > " '`;玩法润色、美术风格润色、规范图生图和图标 spritesheet 等自然语言 prompt 必须保留已经过边界校验的原文。图标 spritesheet 的 `iconDescriptions` 在请求边界执行独立合同:原始数组满足 OpenAPI `1..100`,去空后至少保留 1 条;单条最多 `200` 个 Unicode 字符、拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builder,External v1 超限同步返回 `400`。 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。 -12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。 +12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,客户端仅以 GET-only 项目对账判定权威结果,不复用既存 metadata 作 exact compare-and-return。 ## 外部服务与资产 - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 -- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。完整同内容既有记录只读返回 `AlreadyApplied`,不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 +- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,并由客户端 GET-only 对账;本次请求不得附加 `resultPersistenceStarted`。`AlreadyApplied` 仅在 early guard 与最终 procedure 并发相遇时作为底层幂等兜底,复用既有 commit 且不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 - 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 1b27d0c84..2c1c07237 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -3330,6 +3330,81 @@ describe('useImageCanvasGenerationWorkflow', () => { }); }); + it('reconciles a retried existing-result marker against its corrupted stable resource as a conflict', async () => { + const applyProjectSnapshot = vi.fn(); + let corruptedProject: EditorProjectSnapshot | undefined; + snapImageToPerfectPixelsMock + .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => + createMismatchedPerfectPixelResult(request), + ) + .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => { + const operationId = request.canvasCompletion.dialogId; + corruptedProject = createPerfectPixelProject( + operationId, + 'dialog-missing', + ); + corruptedProject.resources[0] = { + ...corruptedProject.resources[0]!, + taskId: 'pixel-art-snap-corrupted-task', + objectKey: null, + }; + throw new ApiClientError({ + message: '同一完美像素操作已有权威结果,请先读取项目状态对账。', + status: 409, + code: 'HTTP_409', + details: { + operationResultAlreadyExists: true, + resultResourceId: corruptedProject.resources[0]!.resourceId, + }, + }); + }); + loadEditorProjectMock.mockImplementation(async () => { + const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as + | EditorPixelArtSnapInput + | undefined; + return snapImageToPerfectPixelsMock.mock.calls.length === 1 + ? createConflictingPerfectPixelProject( + request!.canvasCompletion.dialogId, + ) + : corruptedProject!; + }); + + render( + {})} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toContain( + 'pending-confirmation', + ); + }); + + fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); + await waitFor(() => { + expect(screen.getByTestId('dialog-error').textContent).toContain( + '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + ); + }); + + expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2); + expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + expect(screen.getByTestId('dialog').textContent).toContain( + 'pending-confirmation', + ); + }); + 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 -- 2.52.0