diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 254b8f475..031272abb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5972,3 +5972,13 @@ - 验证:新增三条用例分别覆盖「未知但实际成功→按快照收口并写历史」「未知且占位存活→只同步快照、标失败、文案要求先核对」「`ApiClientError` 已知失败→不发对账 GET、不动快照」。既有用例 `keeps a failed perfect-pixel placeholder` 原本用裸 `Error` 表达「服务端识别不到网格」,语义不准且会误入对账路径,改为 `ApiClientError`。测试 harness 新增 `dialog-error` 输出,否则对账文案不可观测。 - 补充(同日):对账只对真正发出过 POST 的失败生效。占位创建、源图解析和 `flushProjectPersistence` 都在 POST 之前,它们失败时请求根本没发出,此时给出「素材库可能已存在派生图」是反向谎报,与本条要修的谎报是镜像关系;用 `perfectPixelPostAttempted` 标记划界,同时省掉一次无意义的权威读取。占位存活分支也不再调用 `applyProjectSnapshot`:传给该 hook 的是 `ImageCanvasEditorView` 的 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致(占位都在),套用只会覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定,不是同步。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-01 完美像素持久化阶段由服务端显式告知客户端 + +- 背景:上一版对账用 `error instanceof ApiClientError` 判定「结果已知」,即「服务端响应过就等于没落库」。这个等式不成立——持久化非事务,`complete_editor_canvas_generation` 走 CAS 写入,冲突时 `spacetime-module` 抛「图片画布版本冲突」,经 `map_editor_project_error` 变成 `409`;`403 / 404 / 400` 同理。也就是说带响应的 4xx 同样可能发生在 OSS 对象、asset object、project resource 和账号素材全部落库之后。完美像素要跑满 30 秒,用户在这期间改动画布把 revision 推进并不罕见,因此该场景触发频率高于最初估计。 +- 否决的两个方案:把所有 POST 后失败都当未知(每次常见校验失败多两次读取,且给不可能产生素材的场景附上「请核对素材库」的不适用提示);按状态码分类(`409` 确实只来自写操作,但 `403 / 404` 和 `5xx` 在持久化前后都会出现,分不干净,等于把猜测写进代码)。 +- 决策:由服务端显式告知。`AppError` 新增 `with_detail_field`,在已有 details 上补字段而不是像 `with_details` 那样整体替换,保留下游写入的 provider / message——客户端要靠 message 定位、靠新字段决策。`snap_editor_image_to_pixel_art` 在第一次 OSS PUT 之后的四条失败路径(账号素材持久化失败、项目资源缺失、账号素材缺失、画布回填失败)置 `resultPersistenceStarted: true`。客户端只对「完全无响应」和「带该标记」的失败做对账,常见的纯校验 `400`、排队 `503`、预算 `504` 既不多打读取也不附加提示。 +- 配套修复:对账成功分支补上 `hasCanvasGenerationDialogById` 检查——权威快照里占位消失有两种原因,服务端消费掉或用户在请求期间删除,后者契约要求不应用完成快照、不写历史,成功路径同一处早有这道检查而对账路径漏了。对账同时刷新素材库(新增可选 `refreshAssetLibrary` 贯穿 `ImageCanvasEditorView` → surface → workflow),否则只 GET 项目却让用户核对素材库,他看到的仍是旧列表,不满足契约的「项目 / 素材快照」。占位存活分支刻意不调 `applyProjectSnapshot`:传入的是 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致,套用只会覆盖未保存编辑。 +- 验证:服务端 `assert_function_occurrence_count` 把标记钉为 4 处,并用顺序断言要求它只出现在 `persist_editor_generated_image_owned` 之后——漏标一处或误标在校验阶段都会失败。客户端新增用例覆盖「带标记的 409 触发对账并刷新素材库」「未带标记的 400 不对账、文案保持服务端原文」「用户删除占位则不应用快照不写历史」。 +- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口,需先给 dialog 增加「无 durable job 的 inline 链路」标记,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,未调整。 +- 关联文档:`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 d90376f0b..5c0fd7a84 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -169,7 +169,7 @@ - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 - Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,失败占位保留明确错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放,因此 catch 必须区分已知与未知结果:`ApiClientError` 表示服务端明确响应过,直接标失败;transport 异常、abort 和客户端超时属于未知结果,必须先 GET 权威项目快照对账——占位已被 completion 消费掉则按快照收口并写入正常的完美像素历史,占位仍存活则只同步快照、不写历史,并明确告知结果未知且素材库可能已有派生图,由用户先核对再决定是否重试。刷新后停在 `generating` 的占位目前仍无自动收口,需要用户手动处理。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,失败占位保留明确错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放,因此 catch 必须区分已知与未知结果:服务端在第一次 OSS PUT 之后的失败路径上置 `details.resultPersistenceStarted = true`;未带该标记的响应(纯校验 `400`、排队 `503`、预算 `504` 等)表示尚未落库,直接标失败;transport 异常、abort、客户端超时以及带该标记的响应属于结果可能已落库,必须先 GET 权威项目快照对账——占位已被 completion 消费掉则按快照收口并写入正常的完美像素历史,占位仍存活则只同步快照、不写历史,并明确告知结果未知且素材库可能已有派生图,由用户先核对再决定是否重试。刷新后停在 `generating` 的占位目前仍无自动收口,需要用户手动处理。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d93345324..27b79bf77 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -161,6 +161,13 @@ const EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY: usize = 4; /// 即无全局上限)时的连接风暴。它只防雪崩,不做流量整形。 const EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH: usize = 2048; const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); +/// 中文注释:完美像素持久化是非事务的(OSS + asset object → project resource → +/// editor asset → canvas completion)。后段失败时前段记录会保留,而 completion 走 +/// `map_editor_project_error` 会被映射成 403 / 404 / 409 / 400,客户端从状态码分不出 +/// 失败发生在持久化之前还是之后。这个 detail 字段就是那个信号:只在第一次 OSS PUT +/// 之后的失败路径上置位,客户端据此决定是否先 GET 权威快照对账,而不是直接标失败诱使 +/// 用户重试再造一份对象、资源和素材。 +pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted"; 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"; @@ -4945,7 +4952,7 @@ pub async fn snap_editor_image_to_pixel_art( error = %error, "editor_pixel_art_snap_asset_persistence_failed_after_object_put" ); - error + error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; let resource = generated_asset.resource.ok_or_else(|| { tracing::warn!( @@ -4958,6 +4965,7 @@ pub async fn snap_editor_image_to_pixel_art( StatusCode::INTERNAL_SERVER_ERROR, "完美像素结果未创建项目资源。", ) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; let asset = generated_asset.asset.ok_or_else(|| { tracing::warn!( @@ -4971,6 +4979,7 @@ pub async fn snap_editor_image_to_pixel_art( StatusCode::INTERNAL_SERVER_ERROR, "完美像素结果未创建账号素材。", ) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; // 中文注释:占位在处理期间被用户删除时,沿用现有 completion 规则,只保留已经 // 登记的项目资源和账号素材,不复活图层,也不把该并发行为误报为像素处理失败。 @@ -4991,7 +5000,7 @@ pub async fn snap_editor_image_to_pixel_art( error = %error, "editor_pixel_art_snap_canvas_completion_failed_after_asset_persistence" ); - error + error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; Ok(json_success_body( @@ -12460,6 +12469,29 @@ mod tests { "resolve_editor_reference_object_key_for_owner(state", ], ); + // 中文注释:第一次 OSS PUT 之后的每条失败路径都必须带 resultPersistenceStarted。 + // 服务端持久化非事务,completion 失败经 map_editor_project_error 会变成 4xx, + // 客户端从状态码分不出失败在持久化前还是后;漏标一处,那条路径上的失败就会被前端 + // 判成「结果已知」,用户重试再造一份对象、资源和素材。四处分别对应:账号素材持久化 + // 失败、项目资源缺失、账号素材缺失、画布回填失败。 + assert_function_occurrence_count( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + 4, + ); + assert_function_contains_in_order( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + // 中文注释:标记只能出现在第一次 PUT 之后。出现在 persist 之前说明有纯 + // 校验失败被误标成「可能已落库」,会让常见的 400 也触发多余的对账读取。 + "persist_editor_generated_image_owned(", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + ], + ); assert_function_not_contains( source, "pub async fn snap_editor_image_to_pixel_art(", diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index ac061d6d9..9ce320983 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -68,6 +68,24 @@ impl AppError { self } + /// 中文注释:在已有 details 上补一个字段,而不是像 `with_details` 那样整体替换。 + /// 用于给沿途传上来的错误追加旁路信息(例如「失败发生在持久化开始之后」),同时保留 + /// 下游原本写入的 provider / message 等字段——客户端要靠 message 定位,靠新字段决策。 + pub fn with_detail_field(mut self, key: &'static str, value: Value) -> Self { + match self.details.take() { + Some(Value::Object(mut object)) => { + object.insert(key.to_string(), value); + self.details = Some(Value::Object(object)); + } + // 中文注释:details 为空或不是对象时按对象重建。本仓库的 details 一律是对象, + // 走到后一个分支说明调用方写法有变,宁可保留标记也不静默丢弃。 + _ => { + self.details = Some(serde_json::json!({ key: value })); + } + } + self + } + pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self { self.headers.insert(name, value); self diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index bc92d0b65..723db96d2 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1490,6 +1490,7 @@ export function ImageCanvasEditorView({ upsertGeneratedAsset, applyProjectSnapshot: applyGeneratedProjectSnapshot, flushProjectPersistence, + refreshAssetLibrary, onWalletBalanceMayHaveChanged: refreshEditorWalletState, }); const handleEditorAgentConfirmSent = useCallback(() => { diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 0324aee26..7389b85bc 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -107,6 +107,7 @@ type ImageCanvasGenerationSurfaceOptions = { action?: CanvasHistoryAction, ) => void; flushProjectPersistence?: () => Promise; + refreshAssetLibrary?: () => Promise | void; onWalletBalanceMayHaveChanged?: () => void; }; @@ -184,6 +185,7 @@ export function useImageCanvasGenerationSurface({ upsertGeneratedAsset, applyProjectSnapshot, flushProjectPersistence, + refreshAssetLibrary, onWalletBalanceMayHaveChanged, }: ImageCanvasGenerationSurfaceOptions) { const toolbarOptionCloseTimerRef = useRef[0]['applyProjectSnapshot']; + refreshAssetLibrary?: Parameters< + typeof useImageCanvasGenerationWorkflow + >[0]['refreshAssetLibrary']; flushProjectPersistence?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['flushProjectPersistence']; @@ -189,6 +193,7 @@ function GenerationWorkflowHarness({ currentUserId, applyProjectSnapshot, flushProjectPersistence, + refreshAssetLibrary, upsertGeneratedAsset, }); @@ -2368,12 +2373,10 @@ describe('useImageCanvasGenerationWorkflow', () => { await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain('failed'); }); - expect(screen.getByTestId('dialog-error').textContent).toContain( - '结果未知', - ); - expect(screen.getByTestId('dialog-error').textContent).toContain( - '请先确认再决定是否重试', - ); + const unresolvedMessage = + screen.getByTestId('dialog-error').textContent ?? ''; + expect(unresolvedMessage).toContain('画布未收到完美像素结果。'); + expect(unresolvedMessage).toContain('素材库已刷新'); }); it('does not reconcile when the perfect-pixel request never left the client', async () => { @@ -2409,18 +2412,138 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('dialog-error').textContent).toBe('画布保存失败'); }); - it('keeps a rejected perfect-pixel request out of the reconciliation path', async () => { - // 中文注释:服务端明确响应过(ApiClientError)就是已知结果,不需要也不应该 - // 再发对账 GET,否则每个 400 都要多打一次权威读取。 + it('reconciles a responded failure that the server marked as post-persistence', async () => { + // 中文注释:服务端持久化非事务,completion 失败经 map_editor_project_error 变成 4xx, + // 状态码分不出阶段。服务端置 resultPersistenceStarted 后客户端才对账。 const applyProjectSnapshot = vi.fn(); + const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); + const reconciledProject = { + projectId: 'project-1', + title: '未命名画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + itemType: 'generation-dialog', + dialog: { + id: 'generation-dialog-1', + mode: 'quick-edit', + status: 'generating', + prompt: '完美像素', + }, + }, + ], + resources: [], + updatedAt: '2026-08-01T00:00:00.000Z', + }; + snapImageToPerfectPixelsMock.mockRejectedValueOnce( + new ApiClientError({ + message: '画布版本冲突。', + status: 409, + code: 'HTTP_409', + // 中文注释:服务端在第一次 OSS PUT 之后的失败路径上置位该字段。 + details: { resultPersistenceStarted: true }, + }), + ); + loadEditorProjectMock.mockResolvedValueOnce(reconciledProject); + + render( + {})} + refreshAssetLibrary={refreshAssetLibrary} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + + await waitFor(() => { + expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1'); + }); + // 中文注释:契约要求核对「项目 / 素材」两份快照,素材库是独立记录。 + expect(refreshAssetLibrary).toHaveBeenCalledTimes(1); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toContain('failed'); + }); + const message = screen.getByTestId('dialog-error').textContent ?? ''; + // 中文注释:服务端原文保留便于定位,对账结论与核对提示附加在后。 + expect(message).toContain('画布版本冲突。'); + expect(message).toContain('画布未收到完美像素结果。'); + expect(message).toContain('素材库已刷新'); + }); + + it('skips reconciliation for a responded failure the server did not mark', async () => { + // 中文注释:纯校验失败发生在任何 IO 之前,不可能留下对象或素材。服务端不置 + // resultPersistenceStarted,客户端就不该多打两次读取,也不该附上「请核对素材库」 + // 这种不适用的提示。 + const applyProjectSnapshot = vi.fn(); + const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); snapImageToPerfectPixelsMock.mockRejectedValueOnce( new ApiClientError({ message: 'assetKind 与来源素材权威类型不一致。', status: 400, code: 'HTTP_400', + details: { provider: 'pixel-art-snapper', field: 'assetKind' }, }), ); + render( + {})} + refreshAssetLibrary={refreshAssetLibrary} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toContain('failed'); + }); + expect(loadEditorProjectMock).not.toHaveBeenCalled(); + expect(refreshAssetLibrary).not.toHaveBeenCalled(); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + // 中文注释:文案保持服务端原文,不追加对账结论。 + expect(screen.getByTestId('dialog-error').textContent).toBe( + 'assetKind 与来源素材权威类型不一致。', + ); + }); + + it('drops a reconciled perfect-pixel result when the user deleted the placeholder', async () => { + // 中文注释:权威快照里占位不在了有两种原因——服务端 completion 消费掉,或者用户在 + // 请求期间主动删除。后者契约要求不应用完成快照、不写历史,删除意图胜出。 + const applyProjectSnapshot = vi.fn(); + const reconciledProject = { + projectId: 'project-1', + title: '未命名画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-08-01T00:00:00.000Z', + }; + const timeoutError = new Error('The operation timed out.'); + timeoutError.name = 'TimeoutError'; + snapImageToPerfectPixelsMock.mockImplementationOnce(async () => { + // 中文注释:请求在途时用户删掉占位。 + fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); + throw timeoutError; + }); + loadEditorProjectMock.mockResolvedValueOnce(reconciledProject); + render( { fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { - expect(screen.getByTestId('dialog').textContent).toContain('failed'); + expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1'); }); - expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect(applyProjectSnapshot).not.toHaveBeenCalled(); - expect(screen.getByTestId('dialog-error').textContent).toBe( - 'assetKind 与来源素材权威类型不一致。', - ); + await waitFor(() => { + expect( + screen.getByRole('status', { name: '完美像素状态' }).textContent, + ).toBe('空闲'); + }); + expect(screen.getByTestId('dialog').textContent).toBe('-'); }); it('uploads an inline perfect-pixel source before flushing and posting', async () => { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 84a68d128..e0bf30c42 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -633,6 +633,7 @@ type GenerationWorkflowOptions = { action?: CanvasHistoryAction, ) => void; flushProjectPersistence?: () => Promise; + refreshAssetLibrary?: () => Promise | void; onWalletBalanceMayHaveChanged?: () => void; }; @@ -668,6 +669,7 @@ export function useImageCanvasGenerationWorkflow({ upsertGeneratedAsset, applyProjectSnapshot, flushProjectPersistence, + refreshAssetLibrary, onWalletBalanceMayHaveChanged, }: GenerationWorkflowOptions) { const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false); @@ -1851,22 +1853,33 @@ export function useImageCanvasGenerationWorkflow({ setActiveTool('select'); setActiveSidebarPanel('layers'); } catch (error) { - // 中文注释:ApiClientError 只在拿到服务端 Response 时构造(apiClient 的 - // buildApiClientError),transport 异常、abort 和 120 秒超时会原样抛出。 - // 前者结果已知,后者未知——服务端可能已经完成 OSS PUT、asset object、 - // project resource、账号素材和画布回填,只是响应没回来。该路由是 unsafe - // POST 且禁用自动重放,所以未知时必须先 GET 权威快照对账再决定文案:直接 - // 标 failed 会谎报结果,用户重试就再造一份对象、资源和素材。 - // 中文注释:只有真正发出过 POST 才谈得上「结果未知」。占位创建、源图解析和 - // flush 都在 POST 之前,它们失败时请求根本没发出去,此时说「素材库可能已存在 - // 派生图」是反向谎报,和这次要修的那个谎报是镜像关系。 - const outcomeIsUnknown = - perfectPixelPostAttempted && !(error instanceof ApiClientError); + // 中文注释:只有真正发出过 POST 才谈得上「结果可能已落库」。占位创建、源图解析 + // 和 flush 都在 POST 之前,它们失败时请求根本没发出去,此时提示核对素材库是反向 + // 谎报。 + // + // 一旦发出过 POST,就不能只用「服务端有没有响应」判定结果是否已知。服务端持久化 + // 是非事务的(OSS + asset object → project resource → editor asset → canvas + // completion),而 completion 失败走 map_editor_project_error 会被映射成 + // 403 / 404 / 409 / 400,带响应的 4xx 同样可能发生在对象、资源和账号素材全部落库 + // 之后。状态码分不出持久化阶段,所以服务端在第一次 OSS PUT 之后的失败路径上置 + // `resultPersistenceStarted`,客户端只对这类失败和完全无响应的失败做对账——常见的 + // 纯校验 400 / 排队 503 / 预算 504 不会白白多两次读取,也不会被附上不适用的提示。 + const persistenceMayHaveStarted = + error instanceof ApiClientError && + (error.details as { resultPersistenceStarted?: unknown } | null) + ?.resultPersistenceStarted === true; + const outcomeMayBePersisted = + perfectPixelPostAttempted && + Boolean(perfectPixelDialogId) && + (!(error instanceof ApiClientError) || persistenceMayHaveStarted); let reconciledMessage: string | undefined; - if (outcomeIsUnknown && perfectPixelDialogId) { - const reconciled = await loadEditorProject(normalizedProjectId).catch( - () => null, - ); + if (outcomeMayBePersisted && perfectPixelDialogId) { + // 中文注释:契约要求核对「项目 / 素材」两份快照。素材库是独立记录,只 GET + // 项目却让用户去核对素材库,他看到的仍是旧列表。 + const [reconciled] = await Promise.all([ + loadEditorProject(normalizedProjectId).catch(() => null), + Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined), + ]); if (reconciled) { const placeholderSurvived = reconciled.layers.some( (item) => @@ -1875,8 +1888,13 @@ export function useImageCanvasGenerationWorkflow({ perfectPixelDialogId, ); if (!placeholderSurvived) { - // 中文注释:占位已被服务端 completion 消费掉,说明这次其实成功了。 - // 按权威快照收口并写入正常的完美像素历史,不再报错。 + // 中文注释:权威快照里占位不在了有两种原因——服务端 completion 消费掉, + // 或者用户在请求期间主动删除。必须再查本地:本地占位也没了就是用户删的, + // 契约要求此时不应用完成快照、不写历史,删除意图胜出。成功路径同一处有 + // 这道检查,对账路径不能漏。 + if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) { + return; + } applyProjectSnapshot(reconciled, { type: 'perfect-pixel', count: 1, @@ -1890,20 +1908,21 @@ export function useImageCanvasGenerationWorkflow({ // 它的 action 默认值是 `generate-image`,不传 action 会写一条类型错误且受 // 撤销保护的历史;而权威快照此刻和本地状态一致(占位都在),套用它只会白白 // 覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定而不是同步。 - // 持久化是非事务的,OSS 对象与账号素材仍可能已落库,所以文案必须让用户先 - // 去核对而不是直接重试。 - reconciledMessage = - '完美像素结果未知:已核对权威快照,画布未收到结果。素材库可能已存在派生图,请先确认再决定是否重试。'; + reconciledMessage = '画布未收到完美像素结果。'; } else { - reconciledMessage = - '完美像素结果未知,且权威快照读取失败。请刷新后确认素材库与画布,再决定是否重试。'; + reconciledMessage = '权威项目快照读取失败,无法确认完美像素结果。'; } } - const errorMessage = - reconciledMessage ?? - (error instanceof Error && error.message.trim() + const serverMessage = + error instanceof Error && error.message.trim() ? error.message - : '完美像素处理失败'); + : undefined; + // 中文注释:保留服务端原文(例如 assetKind 校验失败)便于定位,同时附上对账结论。 + // 持久化非事务,对象与账号素材可能已落库而只有画布回填没完成,所以必须让用户先核对 + // 素材库再决定,而不是直接重试。 + const errorMessage = reconciledMessage + ? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}素材库已刷新,如已生成派生图请勿重复执行。` + : (serverMessage ?? '完美像素处理失败'); if ( perfectPixelDialogId && hasCanvasGenerationDialogById(perfectPixelDialogId) @@ -1935,6 +1954,7 @@ export function useImageCanvasGenerationWorkflow({ flushProjectPersistence, hasCanvasGenerationDialogById, openPlacedCanvasGenerationDialog, + refreshAssetLibrary, projectId, setActiveSidebarPanel, setActiveTool,