From 6c53eda71c487526a8b421fa881ce2faa8c572b6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 7 Aug 2026 18:03:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=BB=93=E6=9E=9C=E5=8E=9F=E5=AD=90=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增九类编辑器生成 durable receipt 与稳定提交指纹 统一资源、素材、binding、画布和队列终态的 SpacetimeDB 原子事务 补齐未知结果有界重放、CAS 刷新、lease fencing 与原子失败退款 收紧 owner、project、object、跨记录语义和 replay 一致性校验 保持 Agent 与 External compact 结果契约及 512 KiB 上限 延迟 inline 计费完成边界并区分 procedure dispatch 前后取消 同步 schema、迁移、生成绑定、架构文档和故障回归测试 --- .../shared-memory/decision-log.md | 15 + docs/project-memory/shared-memory/pitfalls.md | 21 + ...端架构】外部生成Worker化方案-2026-06-03.md | 11 +- ...辑器生成结果原子提交与幂等重放方案-2026-08-06.md | 116 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 19 +- ...片画布结构化持久化与迁移回滚方案-2026-07-19.md | 19 +- server-rs/Cargo.lock | 1 + .../crates/api-server/src/asset_billing.rs | 160 +- .../src/character_animation_assets.rs | 1485 ++++++-- .../crates/api-server/src/editor_project.rs | 2977 +++++++++++++++-- .../api-server/src/external_editor_api.rs | 1 + .../src/external_generation_worker.rs | 114 +- .../generation.rs | 819 +++-- .../vector_engine_audio_generation/persist.rs | 116 +- .../src/agent/tools/edit_image.rs | 30 +- .../src/agent/tools/generate_image.rs | 30 +- server-rs/crates/shared-contracts/Cargo.toml | 1 + .../shared-contracts/src/editor_generation.rs | 202 ++ server-rs/crates/shared-contracts/src/lib.rs | 8 + .../spacetime-client/src/active/mapper.rs | 2 +- .../src/active/mapper/external_generation.rs | 2 +- .../spacetime-client/src/editor_project.rs | 258 ++ .../crates/spacetime-client/src/mapper.rs | 1 + .../src/mapper/external_generation.rs | 2 +- .../spacetime-client/src/module_bindings.rs | 52 +- .../editor_generation_operation_table.rs | 235 ++ .../editor_generation_operation_type.rs | 89 + ...or_generation_result_persist_input_type.rs | 26 + ...neration_result_persist_item_input_type.rs | 24 + ...r_generation_result_persist_result_type.rs | 25 + ...r_generation_result_persist_status_type.rs | 18 + ...r_generation_result_persisted_item_type.rs | 24 + ..._generation_result_and_return_procedure.rs | 62 + ...r_pixel_art_result_and_return_procedure.rs | 16 +- ...r_pixel_art_result_and_return_procedure.rs | 16 +- .../src/asset_metadata/bindings.rs | 49 +- .../src/editor_project_storage.rs | 1939 +++++++++++ .../src/external_generation.rs | 190 +- .../crates/spacetime-module/src/migration.rs | 1 + .../src/runtime/active/profile.rs | 3 +- .../spacetime-module/src/runtime/profile.rs | 3 +- 41 files changed, 8179 insertions(+), 1003 deletions(-) create mode 100644 docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md create mode 100644 server-rs/crates/shared-contracts/src/editor_generation.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_operation_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_operation_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_item_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persisted_item_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/persist_editor_generation_result_and_return_procedure.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 07a5343f9..30d301bd1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6727,3 +6727,18 @@ - 业务隔离:共享组件不等于共享规则。SFX 继续使用 Vidu `audio1.0`、2–10 秒、默认 5 秒、现有 Prompt 回退、1500 字限制、价格和提交链路;BGM 继续使用 canonical Prompt、200 字生成限制、30 个预设、AI 补全 / 简化、单层撤销、提交锁和 Suno。BGM 按 dialog ID 写回,SFX 继续走现有 `setGenerateDialog`,两条路径不得互换。 - 非目标:本次只规划视图归并,不实现 SFX V2 的 ElevenLabs、中译英、自动时长、30 秒、Loop、一键优化或预设,不修改任何后端、External v1、Schema、计费或需求原文,也不新建配置驱动的 composer 框架。 - 实施状态:已恢复共享音频 composer,独立完整 BGM composer 及其测试文件已删除,原覆盖完整迁入总 composer。Prompt / 预设 / controller / 总 composer `121/121`、surface 与 submission workflow `72/72` 通过,typecheck、变更文件 ESLint、Prettier、编码检查和差异检查通过;没有修改后端、契约或需求原文,也没有实现 SFX V2 独有功能。 + +## 2026-08-06 编辑器生成结果使用 durable receipt 与统一原子提交 + +- 背景:图片、改图、去背景、图集 / UI 多产物、角色动作、视频、音效和背景音乐在 OSS 结果可用后,仍分段 confirm object、创建 project resource / account asset、保存 canvas 和 complete job。任一中间失败都会留下部分业务事实;只把 `external_generation_job` 当 operation journal 又无法覆盖无 job 的 inline,也无法独立证明某批 resource/asset/canvas 已作为一笔提交完成。 +- 决策:新增私有 `editor_generation_operation` durable commit receipt,queue 与 inline 共用。`persist_editor_generation_result_and_return` 在一次 `try_with_tx` 内提交可选 asset object、全部 resource / asset / binding、可选 canvas V2 CAS、queue job 终态和 receipt。job 仍是队列、lease、计费和通知真相,receipt 只是提交凭证,不复制大快照或形成平行 read model。worker 成功走统一 procedure 后不再单独 complete job。 +- 身份与重放:queue 以 job ID 为 operation ID,inline 以稳定 request ID 为 operation ID;Provider task ID 只做审计。operation fingerprint 绑定规范请求,commit SHA-256 对完整提交输入的稳定 BSATN 编码做 domain-separated 哈希,另外绑定逐 slot 候选、布局与 job completion,不使用 Rust `Debug` 文本充当持久协议。receipt 存在且所有权威事实一致时才返回 `AlreadyApplied`;不重复事件、不刷新时间、不推进 canvas revision。receipt 缺失但稳定 resource/asset/binding 已存在必须失败关闭;事务前已单独确认的 object 只在全部字段精确相等时允许复用。 +- 并发、时间与 OSS 边界:canvas 冲突只刷新 project 重算布局,不重跑 Provider / OSS;`completed_at_micros` 必须为正数,候选原时间字段与它一起绑定 commit SHA-256,重放不重新取时;job 终态与事件使用 SpacetimeDB `ctx.timestamp`。OSS `PUT / HEAD` 仍在数据库事务外,事务失败可以留下无引用 object,不声称跨 OSS exactly-once。 +- queue 结果与 CAS 重试补充:普通画布 queue 只持久化 source/warning 元数据,Editor Agent 和 External API 分别只写入各自裁剪后的结果,最终 JSON 不得超过 512 KiB。消费者身份必须在 worker 从完整 claimed job 构造调用上下文时固化,不能从已裁剪的 summary 兼容快照反推。CAS 冲突最多刷新布局一次,只允许 revision/layers 和 layout `updated_at_micros` 随最新 project 变化,避免回拨并发用户更新时间;items、job payload 与 `completed_at_micros` 保持不变。每个 prepared commit 的传输未知结果最多原样重放两次,不重跑 Provider / OSS。 +- receipt 只保存 queue result 的 SHA-256,不复制最多 512 KiB 的 payload;重放时从已完成 job 回读权威 payload 并核对摘要。事务边界即使没有 asset_object candidate,也必须统一核对 resource/asset/binding 的 object ID/key/owner,并要求 canvas layout 与全部 project resource 属于同一 project。 +- 事务内还要先查同 `operation_id` 的 `external_generation_job`:存在则首次/重放都强制完整 completion guard,不存在才允许 inline。resource/asset 的尺寸、媒体引用、task、kind 与生成元数据按 item 交叉验证,音频 binding 使用 operation 限定 tuple 和显式 kind 映射。省略 candidate 的已登记 object 在 receipt 重放时仍回读 owner/key/task/kind/媒体身份。 +- queue 跨记录绑定继续失败关闭:job `source_entity_id` 必须就是结果唯一 project,所有 `source_resource_id` 必须已存在且属于同 owner / project。Provider 已成功但原子持久化确定失败时,当前 worker/lease 验证、当前计费 attempt 退款和 job 失败终态由同一 SpacetimeDB 事务结算;不在 api-server 先独立退款。 +- compact result 裁剪不得丢失消费 DTO 必填字段或正式素材定位信息:角色动作/视频保留 `ok`,音效/BGM 保留 `prompt`,External 角色动作与视频还保留稳定 `assetId`,不复制大型生成 payload。account asset 的 `source_resource_id` 与 project resource 一样验证候选/已登记来源的 owner,并在有项目上下文时验证 project。 +- External v1 的二次 allowlist 裁剪同样保留 `prompt / actualPrompt`,契约验收以 `serialize_atomic_editor_generation_job_result` 最终 JSON 为准,不只测上游 builder。图标/UI 正常与 source-only fallback 同时保留 `ok / prompt / actualPrompt`,fallback 的尺寸/model/价格也从本次生成上下文显式携带,不依赖可选 project resource。Editor Agent 图片生成/修改 DTO 允许 compact payload 不携带 `provider`。inline 八类 provider 生成的已成功 billing guard 延迟到 owner handler 完成 durable receipt 提交才 disarm;procedure 发出前的明确失败/取消退款,发出后回包前的传输不确定或取消保留扣款。 +- 影响范围:所有现役编辑器生成类型、`spacetime-module` / `spacetime-client` 结果提交契约、queue worker 终态写回、schema / migration / generated bindings 与对应故障注入测试。完美像素保留现有专用原子 procedure;手动图集拆分保留现有批量事务,其 canvas completion 并入批量事务另行收口。 +- 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 093f45414..75a7464b7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4348,3 +4348,24 @@ - 原因:冲突两侧代表不同组件架构,逐行保留看似有用的 JSX 会把一个架构中的局部条件拼进另一个架构。import 排序、格式检查和只覆盖单一 mode 的测试都不能证明这种组合成立。 - 处理:先确定权威组件边界,再按完整调用链解决冲突。图片画布音频入口当前决策是恢复一个共享 `ImageCanvasAudioGenerationComposerView`,由组件内 `isSoundEffect` 分流;BGM/SFX 的 validator、写回、锁和提交契约仍分别保持。不要只补一个常量后继续维持已经废弃的双 composer 边界。 - 验证:同时渲染 `audio-sound-effect` 与 `audio-background-music`,覆盖两个 mode 的正向控件和互斥负向断言、dialog / mode 切换、BGM 稳定 ID 与 controller 缺失的失败关闭,并运行 `ImageCanvasGenerationComposerView.test.tsx` 与 typecheck。 + +## 生成结果的稳定 ID 和 job 终态都不能代替 durable receipt(2026-08-06) + +- 现象:Provider / OSS 已成功,但项目资源、账号素材、binding、画布和 job 只完成一部分;不确定结果重放时,有时又复制一批素材或重复推进 canvas revision。inline 路径在进程重启后尤其无法判断前一次提交是否整笔完成。 +- 原因:把“请求已入队”、“某个稳定 ID 已存在”或“job 已 completed”误当成整批业务记录已原子提交的证据。request fingerprint 只证明用户请求,不绑定最终 slot、派生记录、画布候选和 compact result;仅比较资源 ID 也无法发现内容漂移。 +- 处理:用 `editor_generation_operation` 记录 durable receipt,分开 request fingerprint 与整笔 commit SHA-256。首次调用在同一 SpacetimeDB 事务中校验 lease 并写 object/resource/asset/binding/canvas/job/receipt;重放先查 receipt,再读回逐 slot 权威事实精确比较。receipt 缺失但 resource/asset/binding 已存在时失败关闭,不得补写 receipt;事务前已确认的 asset object 只能在 ID、bucket/key、owner、策略、媒体、来源和实体字段全部相等时复用。 +- 时间与并发:`completed_at_micros` 必须为正数,object/resource/asset/binding/canvas 候选原时间字段与它一起纳入 commit SHA-256,不能在每次重放时重新取时;job 终态和完成事件只用 SpacetimeDB `ctx.timestamp`。canvas CAS 冲突后只刷新 project 并重算布局,不重跑 Provider / OSS。OSS 尚未进入该事务,无引用 object 仍是需另行清理的边界,不要宣称跨 OSS exactly-once。 +- queue completion 不能把 inline 完整响应无条件同时复制到 `result` 和 `editor-agent-tool-call-result`。图集/UI 最多 64 个切片会重复携带 resource/asset/prompt/generationInputs,容易超过 job payload 512 KiB 上限并让整个原子提交回滚。必须先按普通 UI、Editor Agent、External API 的消费方契约裁剪,再把最终 JSON 交给统一 procedure。 +- 消费方身份不能在提交前重新读取 summary 兼容快照来判断:该快照按设计清空 dedupe key 并删除 generationInputs,Editor Agent / External API 会因此被误判成普通 UI。应在 worker 持有完整 claimed job 时把安全的 consumer kind 与 source identity 固化到调用上下文。 +- procedure future 超时或连接断开不能直接映射为业务失败,远端事务可能已经提交。必须有界重放同一 prepared commit;明确 CAS 后才刷新 layout,且刷新 layout 应使用新时间,不能把项目 `updated_at` 回拨。receipt 不复制 queue payload,只存摘要并从 job 权威行回读;跨记录 object/project 一致性必须在事务内验证,不能依赖当前 builder 通常会携带完整 candidate。 +- job 的 owner/kind/fingerprint/lease 都正确仍不够:`source_entity_id` 还必须绑定结果项目,来源资源必须另查存在性与 owner/project 归属;否则同 owner 的 job 可以误写别的项目,或伪造跨用户/跨项目血缘。 +- Provider 成功时计费 guard 已解除,后续原子持久化失败不会自动退款。但也不能在 api-server 先独立退款再尝试 fail job:过期 worker、fail 断线或原子提交已成功但回包丢失时,会变成「结果成功且已退款」。正确边界是在同一 SpacetimeDB 事务内先 fencing 当前 lease,再同步写退款账本和失败终态;不得期待 `max_attempts = 1` 的编辑器任务再走租约耗尽路径补退。 +- compact result 只能删除大 payload,不能删除消费方 DTO 必填字段或定位正式结果的稳定引用。角色动作/视频缺 `ok`、音效/BGM 缺 `prompt` 都会让 Editor Agent 把已完成 job 判成不可重试的回填失败;External 角色动作/视频如果创建了账号素材,completed 结果还必须保留 `assetId`。 +- `project_resource.source_resource_id` 校验不会自动覆盖 `editor_asset.source_resource_id`;asset-only 结果可以没有项目资源候选,必须另查来源是本事务候选或已登记资源且属于同 owner;若本次结果有 project,还必须同 project。 +- inline 模式不会走 queue `fail_job`,若计费 wrapper 在 Provider 成功时立即 disarm,后续的上传/原子持久化明确失败会扣费无结果。应在全部 inline owner handler 外统一延迟已成功 billing guard 到 durable commit;明确失败退款,但传输未知结果不退,否则远端已成功时又会变成「结果 + 退款」。 +- 消费契约不能只测上游 builder:External v1 在 durable job 入库前还有一层 allowlist compactor,必须对最终 JSON 断言 `ok / prompt / actualPrompt` 及稳定 resource/asset 引用。 +- 计费 guard 的取消补偿必须区分 procedure dispatch 边界:`Build / PoolAcquire / ConnectBuild / ConnectHandshake` 等未发出阶段可确定退款;dispatch 后回包前的 future 取消与断连必须视为结果未知并保留扣款,等 durable receipt 对账。只在 error 返回后再标记 unknown 会留下取消窗口;必须在真正调用 procedure 前同步设置 task-local 标记,并在 `Procedure` 结果或确定未发出的失败后清除。 +- compact DTO 的可选字段必须用最终 consumer payload 回归:Editor Agent 图片生成/修改的 `provider` 会被脱敏删除,必须是可选字段;图标/UI 正常与 source-only fallback 则必须保留 `ok / prompt / actualPrompt`。fallback 不得从可选 project resource 反推必填字段,否则无 `projectId` 任务会持久 `prompt/model=null`、尺寸为零且图标/UI 丢失 `priceMudPoints`。 +- receipt 存在不等于引用 object 仍然可信:省略 candidate 的已登记 object 在重放时也要回读 owner/key/task/kind/媒体身份。同时先查同 operation ID job,存在 job 却漏传 completion 必须整笔回滚,否则会得到 receipt 成功而 job 仍 running 的永久分裂。resource/asset/binding 也不得仅核对 object ID/key,必须按 operation 合法 tuple 交叉验证业务元数据。 +- 验证:故障注入覆盖 resource 后 asset/binding 失败、canvas CAS 冲突、过期 lease、同 operation 异 fingerprint / 异 commit、receipt 缺失的部分既有记录、精确既有 object 复用与 object 内容漂移;成功重放必须证明记录数、时间、binding/job 事件数和 canvas revision 全部不变。 +- 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index d81274588..426e7df7f 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -29,7 +29,8 @@ VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部 - `claim_external_generation_jobs_and_return`:worker 按 `worker_id`、`limit` 和 lease 时长抢占 `pending` 或 lease 过期的 `running` 任务,返回本次 claim 的 `lease_token`。 - `renew_external_generation_job_lease_and_return`:worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。 - `update_external_generation_job_phase_and_return`:worker 按 `job_id + worker_id + lease_token` 把当前执行阶段更新为 `generating` 或 `processing`,并同步现有摘要投影;不新增阶段任务或阶段表。procedure 用结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`,调用方不解析错误文案;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试 `1` 次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。 -- `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。 +- `complete_external_generation_job_and_return`:只保留给不携带编辑器正式 object/resource/asset/canvas 业务写回的兼容路径。现役编辑器生成成功时不得单独调用它。 +- `persist_editor_generation_result_and_return`:编辑器 queue / inline 共用的结果提交口。单一事务写入可选 asset object、全部 project resource / account asset / asset binding、可选 canvas V2 CAS、queue job 完成与 durable receipt;返回 `Applied / AlreadyApplied` 和权威快照。 - `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。 - `list_external_generation_job_summaries_and_return`:按当前账号从轻量摘要投影读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格、执行阶段和完成提示确认状态。 - `acknowledge_external_generation_job_summaries_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入摘要投影的 `notification_acknowledged_at` 并追加审计事件。 @@ -87,6 +88,8 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、 新增私有审计表 `external_generation_job_event`,记录 `enqueued/claimed/lease_renewed/completed/failed/acknowledged` 等事件。事件表只追加状态转换事实,不作为当前状态源;排障时先看 `external_generation_job` 当前状态,再按 `job_id` 追 `external_generation_job_event` 时间线。 +另新增私有 `editor_generation_operation` durable commit receipt。它不与 `external_generation_job` 争抢任务状态:job 仍负责队列、lease、计费和通知,receipt 只固化某个 owner/kind/operation 的 request fingerprint、整笔 commit SHA-256、可选 project 以及 queue 的 job/worker/lease/result 绑定。inline 虽没有 job,也必须写 receipt;否则 API 进程重启后无法安全区分“完整提交”与“稳定 ID 巧合/历史部分记录”。 + 索引: - `by_external_generation_job_status_available(status, available_at)` @@ -205,9 +208,11 @@ controller 配置: - `editor_video_generation`:画布视频生成和视频素材快速编辑。 - `editor_sound_effect_generation` / `editor_background_music_generation`:画布音效与背景音乐。 -画板结果的业务真相仍是 `editor_project_resource`、账号级 `editor_asset` 和 `editor_canvas.layers_json`。请求携带 `projectId + canvasCompletion` 时,worker 成功后读取当前项目 layout,用最新 generation dialog placeholder 或无 dialog 完成占位写入结果图层,并保存项目快照;前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload 或本地临时响应重建正式图层。生成器已被删除时,worker 只保留生成出的资源 / 素材记录,不把结果重新塞回画布。 +画板结果的业务真相仍是 `asset_object`、`editor_project_resource`、账号级 `editor_asset`、可选 `asset_entity_binding` 和对应的 legacy / structured canvas 表;`editor_generation_operation` 只是提交回执,不替代这些 read model。worker 在 Provider 与 OSS 完成后只做 prepare:使用 owner + operation kind + job ID + stable slot 派生 resource/asset ID,构造可选 object/binding、候选 layout 和 compact job result,然后一次调用 `persist_editor_generation_result_and_return`。该 procedure 必须在当前事务快照校验 owner、job kind、由 `request_payload_json` 重算的 fingerprint 与未过期 lease,最后与业务记录一起完成 job 和 receipt。任一验证、binding 或 canvas CAS 失败都回滚全部数据库事实;worker 不得随后再调用 `complete_external_generation_job_and_return`。前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload、receipt 或本地临时响应重建正式图层。 -角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。 +结果重放必须保持同一 operation fingerprint 和同一 prepared commit:receipt 存在时核对 commit SHA-256、project/job/worker/lease/result 绑定与逐 slot 权威记录,完全一致才返回 `AlreadyApplied`,不重复 job/binding 事件或 canvas revision。receipt 缺失但任一稳定 resource/asset/binding 已存在、同 operation 异指纹/异内容、已过期 lease 都失败关闭;事务前已确认 object 只在候选全字段精确一致时复用。canvas CAS 冲突时只刷新 project 重算 layout,不再次调用 Provider 或上传 OSS。`completed_at_micros` 必须为正数,候选原时间字段与它一起绑定到 commit SHA-256,重放不得重新取时;job 终态与事件使用 SpacetimeDB `ctx.timestamp`。OSS `PUT / HEAD` 仍位于事务外,可留下无引用 object,不声称跨 OSS exactly-once。 + +角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已可用且 OSS 上传已验证后,如果透明背景处理最终失败,最终 prepared commit 只保留原图并用它完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、原图候选构造、透明处理图候选构造或统一原子提交失败仍按任务错误传播。 透明背景处理正常成功时,角色形象、图标 spritesheet 和 UI 素材提取的画布都同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧;图标和 UI 实际拆分出的业务素材从 provider 原图右侧继续排列。 diff --git a/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md b/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md new file mode 100644 index 000000000..5cdb66c46 --- /dev/null +++ b/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md @@ -0,0 +1,116 @@ +# 编辑器生成结果原子提交与幂等重放方案 + +日期:`2026-08-06` + +## 目标 + +修复 Issue #134:现役图片、图片修改、背景移除、图标图集、UI 素材提取、角色动作、视频、音效和背景音乐生成,在 OSS 结果已经可用后,必须把正式 `asset_object`、`editor_project_resource`、`editor_asset`、可选画布完成和队列终态作为同一个可重放提交处理,禁止继续按多个独立 SpacetimeDB procedure 分段写入。 + +本方案只承诺数据库内原子性。OSS `PUT / HEAD` 仍位于 SpacetimeDB 事务外;事务失败可能留下尚未登记或尚未引用的对象,后续按 operation 前缀做异步清理,不把它描述成跨 OSS 的 exactly-once。 + +## 权威操作身份 + +- 默认 queue 模式:`external_generation_job.job_id` 是唯一 operation ID。External v1 的 `Idempotency-Key`、主站稳定 `x-request-id` 和 Editor Agent 确定性任务 ID 都先收敛为该 job ID。 +- inline 兼容模式:使用 `RequestContext.request_id` 作为 operation ID,并对规范请求计算 SHA-256 fingerprint;同 ID 异 fingerprint 必须返回幂等冲突。inline 也必须写 durable receipt,不能只靠进程内 prepared result 或稳定记录 ID 猜测是否已提交。 +- Provider `taskId` 只保留为生成审计字段,不参与正式记录唯一性。 +- 每个 operation 的产物以稳定 `slot` 区分,例如 `provider-source`、`primary`、`processed`、`slice-0000`、`animation-preview`、`animation-final`。记录 ID 按 `owner + operation kind + operation ID + slot + record kind` 做 domain-separated SHA-256 派生,产物顺序变化不能改变既有 slot 的 ID。 + +## 统一 procedure + +在 `spacetime-module` 增加 `persist_editor_generation_result_and_return`。procedure 只允许 editor generation runtime service identity 调用,并在一个 `try_with_tx` 内完成全部动作。 + +输入的编码级形状: + +```rust +EditorGenerationResultPersistItemInput { + slot: String, + asset_object: Option, + project_resource: Option, + asset: Option, + binding: Option, +} + +EditorGenerationResultPersistInput { + owner_user_id: String, + operation_kind: String, + operation_id: String, + operation_fingerprint: String, + items: Vec, + canvas_layout: Option, + job_completion: Option, + completed_at_micros: i64, +} +``` + +输出返回 `Applied / AlreadyApplied`、逐 slot 的 object/resource/asset/binding 快照、可选 project 快照和可选 job 快照。 + +### Durable receipt + +新增私有表 `editor_generation_operation`,它是 queue 和 inline 共用的 durable commit receipt,不是第二套业务状态或任务队列。主键 `operation_key` 由 owner 和 operation ID 做 domain-separated SHA-256 派生,因此同 owner 不得跨 operation kind 复用同一 operation ID;表内固化 `owner_user_id / operation_kind / operation_id / operation_fingerprint / commit_sha256 / project_id / job_id / job_worker_id / job_lease_token / job_result_payload_sha256 / completed_at`。 + +- `operation_fingerprint` 绑定用户请求;`commit_sha256` 对完整 `EditorGenerationResultPersistInput` 的稳定 BSATN 编码做 domain-separated SHA-256,另外绑定本次准备提交的 slot、object/resource/asset/binding、画布候选与 job completion。不得使用 Rust `Debug` 文本充当持久协议,两类指纹也不得混为一个。 +- queue 路径必须把 receipt 与原 `job_id + worker_id + lease_token + result_payload_json` 全量绑定;receipt 只保存 payload SHA-256,不复制正文。首次提交仍必须验证当前有效 lease,完成后重放以 receipt 为提交凭证,并回读已完成 job 核对业务身份、权威 compact result 及其 SHA-256。 +- receipt 只保存幂等校验所需的有界元数据与摘要,不复制 project/canvas 大快照,不代替 resource、asset、binding 和 job 的权威表。 + +### 首次提交顺序 + +1. 校验调用身份、operation 字段、fingerprint、item 数量上限和 slot 唯一性。统一提交最多接受 66 个 item,用于容纳最多 64 个图集切片以及 provider 原图和透明整图。 +2. queue 输入必须完整携带 `job_id + worker_id + lease_token + result_payload_json`;inline 输入必须全部省略,禁止半套 guard。 +3. queue 路径在同一事务快照内校验 job owner、kind、request fingerprint、running 状态和有效 lease;过期 worker 不得写业务结果。`source_entity_id` 必须精确等于本次唯一结果 `project_id`,不得用同 owner 的 job 向其他项目提交。 +4. 对每个 item 校验稳定 object/resource/asset ID、owner、project、folder、object key、source resource、task 审计字段和媒体字段的交叉一致性。project resource 和 account asset 的 `source_resource_id` 均必须单独验证:来源资源必须是本次同事务候选或已登记资源,属于同 owner,且在结果具有项目上下文时属于同 project;不接受 asset-only 分支绕过血缘校验。 +5. `asset_object` 存在于输入时在同一事务内做精确 upsert;省略时,resource/asset/binding 引用的 object 必须已登记且属于同 owner。事务前 OSS `HEAD` 成功不等于 object 已正式登记。 +6. 创建全部 project resource、account asset 和可选 `asset_entity_binding`。binding 必须指向同 slot 的 object 与对应 resource/asset 实体,且 owner、asset kind、entity kind/id 和稳定 binding ID 完全一致。不得接受普通 media reuse 返回另一个随机 resource ID;稳定 ID 已被占用且内容不一致时失败关闭。 +7. 有 `canvas_layout` 时调用既有 V2 layout 持久化函数,以 `expected_revision` 做 CAS,并继续执行 legacy / structured 大小、资源引用和媒体族门禁。 +8. queue 路径最后调用事务内 job complete,写入调用方预先按现有规则构造的 compact result payload;再写入 durable receipt。任一步失败时 object/resource/asset/binding/canvas/job/receipt 全部回滚。 + +`completed_at_micros` 必须为正数,首次提交把它固化为 receipt `completed_at`。object/resource/asset/binding/canvas 候选各自现有的时间字段连同 `completed_at_micros` 一起进入 commit SHA-256;同一 prepared commit 的未知结果重放必须复用原时间,不得重新取时。明确的 canvas CAS 表示该事务已回滚,刷新 project 后形成新的 layout candidate,使用刷新时的 `updated_at_micros`,避免把并发用户刚写入的项目时间回拨。job `completed_at/updated_at` 与 job event 时间仍由 SpacetimeDB 事务时间 `ctx.timestamp` 产生,不信任调用方时钟。成功重放返回原快照,不刷新 receipt、业务记录、事件或 canvas revision。 + +### 重放 + +- receipt 存在时,只允许相同 owner/kind/ID、operation fingerprint、commit SHA-256 与原 project/job 绑定的完整重放;queue 额外核对原 worker/lease/result payload。逐 slot 权威 object/resource/asset/binding 和可选 project/job 仍必须可读且与候选一致,不得只看 receipt 就伪造快照。 +- receipt 存在且全部事实一致时返回 `AlreadyApplied`,不得新增记录、重复 binding changed / job completed 事件、刷新时间或推进 canvas revision。 +- receipt 缺失但任一稳定 asset object/resource/asset/binding、画布结果或已完成 job 已存在,属于可疑的部分写入,必须失败关闭;不得临时补 receipt 后声称幂等。现役生成 prepare 阶段只做 OSS PUT/HEAD,不得在统一 procedure 前单独登记稳定 asset object。 +- procedure 调用结果未知时,调用方最多自动重放同一 prepared commit 两次,不重新调用 Provider 或重新上传 OSS;明确的业务错误和 CAS 冲突不进入传输重放。 +- `operation_id` 在 `external_generation_job` 中已存在时,首次提交和 receipt 重放都必须携带与它一致的完整 job completion guard;只有事务内确认不存在同 ID job 时才允许 inline。 +- 同一 item 的 resource/asset 尺寸、媒体引用、task、asset kind 与生成元数据必须一致;binding 必须匹配 operation 明确允许的 entity/slot/kind/profile tuple。音频使用 `sound-effect -> editor_sound_effect`、`background-music -> editor_background_music` 显式映射,不使用粗暴的全字段硬等。 +- item 省略 `asset_object` candidate 而复用已登记对象时,首次提交与 `AlreadyApplied` 重放都要回读 canonical object,重新验证存在性、owner、object key、task、kind 和音频媒体类型。 + +## api-server 接入 + +- 通用持久化改为 `prepare -> build canvas candidate -> atomic commit`。prepare 阶段只生成稳定 ID、上传/验证对象和构造候选 DTO,不创建 resource/asset。 +- api-server 继续复用现有画布 completion / replacement 逻辑计算候选 `layers_json` 和 `expected_revision`;统一 procedure 在最终事务内重新执行既有 layout 校验和 CAS。 +- CAS 冲突只刷新当前 project、重新计算 layout 并重试 prepared commit;相同 operation、slot、对象和记录候选保持不变,禁止重跑 Provider。 +- 重新计算 layout 时只允许 revision、layers 与 layout `updated_at_micros` 随最新 project 变化;业务 items、job result payload 和 `completed_at_micros` 保持不变。首次 CAS 事务已明确回滚,因此刷新后的 layout 是新的 prepared commit;该 commit 若结果未知,只能原样重放自身。调用方最多自动刷新一次,第二次冲突直接返回。 +- queue completion 不持久化 inline handler 的完整响应:普通画布任务只保留 source/warning 元数据,Editor Agent 只写入裁剪后的 `editor-agent-tool-call-result`,External API 只写入裁剪后的 `result`。图集/UI 切片不得在 queue payload 中重复携带完整 resource/asset/prompt/generationInputs,最终 JSON 必须在 512 KiB 持久化上限内。 +- compact result 必须先满足原消费 DTO 的必填字段:角色动作/视频保留 `ok`,图标/UI 正常与 source-only fallback 保留 `ok / prompt / actualPrompt`,音效/BGM 保留 `prompt`;Editor Agent 与 External v1 的二次 allowlist 裁剪都不得再删除 `prompt / actualPrompt`,最终持久 payload 必须能反序列化为对应 response contract。Editor Agent 图片生成/修改 DTO 的 `provider` 为可选审计字段,compact payload 可删除它而不影响终态回填。External API 的角色动作与视频结果还必须保留本次已创建账号素材的稳定 `assetId`;裁剪可移除大 payload,但不得让 completed 结果无法定位正式素材。 +- queue 消费者身份在 worker 从完整 claimed job 构造调用上下文时固化;原子提交不得再从 summary 兼容快照反推,因为该快照会清空 dedupe key 并裁剪 request payload。 +- queue 的 compact result 当前不保存 `project`,因此可在事务前由稳定候选 resource/asset 和生成响应元数据构造;HTTP 成功响应中的 project 使用 procedure 返回的权威快照。 +- worker 在统一 procedure 已完成 job 后不得再次调用 `complete_external_generation_job`。只有 `Applied / AlreadyApplied` 才能作为成功终态。 +- Provider 已成功且计费 attempt 已扣款后,若原子提交确定失败并要把 job 置为终态 `failed`,必须由同一 SpacetimeDB 事务先验证当前 worker/lease,再结算当前 attempt 退款并写失败终态。不得在 api-server 先独立退款,否则过期 worker 或已成功但回包丢失的提交可能同时得到正式结果与退款。 +- inline 模式没有 job 失败事务补退,计费成功边界必须延迟到 durable result commit 完成。Provider/上传成功后的明确持久化失败退还已扣泥点;`Build / PoolAcquire / ConnectBuild / ConnectHandshake` 等 procedure 未发出阶段的失败或取消仍通过 deferred guard 退款。procedure dispatch 后到明确回包前必须标记结果未知;连续传输不确定或此窗口内 HTTP future 被取消时保留扣款,避免远端已成功时变成「正式结果 + 退款」。`Procedure` 回包是确定结果,成功或明确失败后必须清除未知标记。 + +## 多产物与现有特例 + +- 图片的 provider source、透明/规整结果和 source-only fallback 必须在最终选择明确后一次提交;fallback 只提交实际保留的结果集合。fallback compact result 的尺寸、`prompt / actualPrompt`、model 和图标/UI `priceMudPoints` 必须来自本次已冻结生成上下文,不得从可选 project resource 反推;不带 `projectId` 时仍必须产生完整消费契约。 +- 图标图集和 UI 提取使用稳定 slot 提交 provider source、透明整图和成功切片;切片失败时按既有 warning 语义只提交可信整图集合。 +- 角色动作一次提交预览视频与最终序列素材;逐帧 `asset_object` 可作为 item upsert 或已登记对象被最终序列引用,正式 project resource / account asset 与 canvas 不得分段提交。 +- 视频、音效和背景音乐使用单个 primary item。 +- 完美像素保留现有专用 operation/fingerprint/procedure;手动图集拆分不调用 Provider,不属于本次九类生成 job 的原子提交范围,继续使用现有批量 procedure 与画布完成链路。 + +## Schema 与兼容性 + +- 新增私有 `editor_generation_operation` durable receipt 表;它与 `external_generation_job` 分工,前者证明一笔业务结果原子提交,后者仍是 queue 执行、lease、计费和通知真相。新表必须纳入 `migration.rs` 导入/导出、schema 检查和本文档表目录。 +- 新增 Spacetime procedure/type ABI 后必须重新生成 `spacetime-client` bindings,并同步 facade mapper。 +- HTTP 路由、请求/响应 DTO、header、状态码和 External v1 异步语义保持不变,因此不修改 OpenAPI;必须复跑 External v1 契约测试证明没有漂移。 +- inline 兼容模式没有 durable job,但必须具有同样的 durable receipt、稳定 ID、fingerprint 和单事务重放;这仍不授权浏览器自动重试已可能发出的生成 POST,调用方应先走结果对账。 + +## 验收 + +- 资源创建后资产或 binding 校验失败:事务结束后 object/resource/asset/binding/canvas/job/receipt 均无部分写入。 +- 资源/资产创建后 canvas revision 冲突:全部业务记录回滚;使用同 operation 和 prepared result 刷新布局后可成功。 +- 成功后相同 operation 重放:返回原 object/resource/asset/binding/project/job,receipt 只有一行,记录数、时间、完成事件数和 canvas revision 不变。 +- 同 operation 异 request fingerprint、异 commit SHA-256、异 project/job 绑定、除精确可复用 object 外的部分既有记录、缺失 receipt 和过期 lease:失败关闭且零新增写入。 +- queue job 的 `source_entity_id` 与结果项目不同、或 `source_resource_id` 不属于同 owner / project:失败关闭且零新增写入。 +- Provider 成功后原子持久化确定失败:有效 lease、当前计费 attempt 退款与 job `failed` 在同一事务内成功或回滚;已 completed 或过期 lease 失败关闭且不退款。External 角色动作/视频成功结果保留稳定素材引用。 +- legacy 与 structured canvas、dialog 已删除、无 project/asset folder、单产物、多产物、64 切片和角色动作序列均覆盖。 +- 图片、修改、背景移除、图集、UI 提取、角色动作、视频、音效、背景音乐的生产路径不得再出现 `create resource -> create asset -> save canvas -> complete job` 分段组合。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b20d93646..bf6a5419a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -343,12 +343,14 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` - 现役覆盖:worker claim 只允许 `source_module = editor-canvas`;下述逐玩法生成和写回描述均为退役前历史。历史 pending / running 行继续保留原状态,不得领取、失败收口或改写 payload。 - 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 legacy `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 -- 素材写回:worker 成功后仍经 `api-server` facade 写入 `editor_project_resource` / `editor_asset`;结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 分流按上一条执行,前端不直接发明正式完成态。 +- 2026-08-06 收口覆盖:上一条用途描述中“当前先 CAS、单事务仍是目标”的旧句已作废。现役编辑器生成不再组合调用 object confirm、resource create、asset create、canvas save 和 job complete。`api-server` 只准备稳定候选,再经 `spacetime-client` 调用 `persist_editor_generation_result_and_return`;procedure 在同一 `try_with_tx` 内写入可选 `asset_object`、全部 `editor_project_resource`、`editor_asset`、可选 `asset_entity_binding`、可选 canvas V2 CAS、queue job 终态和 `editor_generation_operation` receipt。结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 仍经既有 V2 布局验证分流,前端不直接发明正式完成态。queue 首次提交在同一快照验证 owner、job kind、request fingerprint 和有效 `job_id + worker_id + lease_token`;统一 procedure 已完成 job 后 worker 不得再单独 complete。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 - 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 - 2026-07-29 收口补充:上条 source-only 的“透明背景处理最终失败”同时包含 Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败和透明图完整解码失败;三条链路共用 helper,只写已保存 provider 原图画布层,图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不得写透明图、派生资源或切片。provider 原图本身解码失败时在首次持久化前失败,不允许 `512×512` 元数据兜底。图标自动拆分、手动拆分与 UI 提取先在受 2 路 CPU semaphore、30 秒 / 请求 deadline 保护的 blocking prepare 中完成解码、透明化、连通域和 bounds 排序;platform 对全部原始连通域设置 `4096` 硬上限、用空间网格查询邻近辅助候选,并在首片 PNG 编码前同时执行 `maxOutputSlices=64` 与全部 padding crop 总像素预算。prepare 返回共享 RGBA + bounds 计划,api-server 再以容量 `2` 的有界管线按需编码、共享 HTTP client 并发 OSS `PUT + HEAD`,OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 `32 MiB` 来源对象前取得独立内存 admission,同一 admission 覆盖下载、prepare 到最后一片上传结束并在数据库调用前释放,CPU permit 只覆盖实际 CPU 阶段。全部对象上传验证成功后,切片的 `asset_object + editor_project_resource + editor_asset + editor_asset_group_cohort` 由单个受 editor generation runtime service identity 保护的 SpacetimeDB procedure 在一次 `try_with_tx` 中原子写入;resource / asset ID 由 owner + task + 序号稳定派生,已有同 ID 素材仅在内容完全一致时幂等复用,来源资源必须存在且与派生资源同 owner / project;上传中途失败不得写部分资源、素材或 cohort,不确定结果重放不得复制整批素材。自动超限只保留整张可信透明图并返回稳定 `sliceWarning`,不写切片;手动超限在首次持久化前返回 `422`。 +- 2026-08-06 原子提交对上述 source-only / slice 条款的修正:“provider 原图已持久化”只能解释为 OSS 对象已上传并验证,不再表示 project resource / account asset 已先行入库。source-only、透明整图和成功切片必须先完成最终选择,再作为一份 prepared commit 连同 canvas/job/receipt 一次提交;未选中或失败分支不得留下正式 resource / asset 部分记录。 + ### `external_generation_job_summary` - Rust 结构体:`ExternalGenerationJobSummary` @@ -377,6 +379,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`AssetEntityBinding` - 源码:`server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs` +- 说明:已确认 `asset_object` 到具体业务实体槽位的正式绑定。编辑器生成结果的可选 binding 必须与同 slot 的 object、resource / asset 实体、owner 和 `asset_kind` 一致,并与 object/resource/asset/canvas/job/durable receipt 在 `persist_editor_generation_result_and_return` 的同一事务中写入。重放必须读回原 binding 做完整比较,不得触发第二次 binding changed 事件或替换既有槽位。 ### `asset_event` @@ -388,6 +391,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`AssetObject` - 源码:`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs` - 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话、后台管理员会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询、现役 `editor_showcase_asset` 精选素材派生授权和当前已启用 `editor_showcase_campaign_config` 的活动卡专用目录 exact-key 派生授权,procedure 只允许 runtime service identity 调用。旧创作模板作品授权 view 和逐玩法资产采集器已经退出 module;`spacetime-client` 不订阅全量 `asset_object`,也不把连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。普通对象只有权威位置查询返回不存在且显式使用 curated `legacyPublicPath` 时才能进入白名单兼容;历史活动卡上传缺少 metadata 时,可由“`global` 配置已启用 + `image_object_key` 精确匹配 + key 位于 `generated-character-drafts/editor/showcase-campaign/`”这一窄授权继续换签,禁用或替换活动卡后旧 key 立即失效。新上传活动卡仍必须完成 `asset_object` confirm,不能把该历史兼容当作跳过正式登记的常规路径。 +- 编辑器生成结果例外不允许先单独 confirm 再分段创建业务记录:OSS `PUT / HEAD` 仍在事务外,但 `AssetObjectUpsertInput` 必须交由统一结果 procedure 在同一事务内与 resource、asset、binding、canvas、job 和 receipt 原子写入。候选省略 object 时,procedure 必须在同一事务快照中验证已登记 object 的 owner、位置和媒体身份;`HEAD` 成功不能代替正式登记。 ### SpacetimeDB view:`public_work_asset_read_grant` @@ -650,7 +654,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorCanvasGenerationDialog` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布生成对话框表,保存 canvas / project / owner、生成模式、状态、可选 source / generated layer、占位几何和有界扩展字段;typed 列为快照真相,`dialog_json` 只保留未结构化参数。当前 worker completion 以读取时 revision 调用 CAS 保存,冲突时拒绝覆盖;`job_id + worker_id + lease_token` 栅栏下的资源 / layer / dialog / job 单事务完成仍是后续收口。 +- 说明:图片画布生成对话框表,保存 canvas / project / owner、生成模式、状态、可选 source / generated layer、占位几何和有界扩展字段;typed 列为快照真相,`dialog_json` 只保留未结构化参数。编辑器生成完成使用候选布局的 `expected_revision` 执行 CAS,冲突时 object/resource/asset/binding/canvas/job/receipt 整笔回滚;queue 路径同时受 `job_id + worker_id + lease_token` 栅栏保护。调用方只能刷新权威项目后重算布局候选,不得重跑 provider 或更换原 operation。 - 完美像素 completion:同步处理成功后按当前 revision 重新读取权威 dialog;目标 dialog 存在时只写入一个派生 layer 并关联结果,目标 dialog 的删除已先持久化时跳过 layer / dialog 写回,不得按请求快照重建占位。该分支不属于 external job completion,允许此前已成功创建的 resource / asset 保留。 - 索引:按 canvas 和 project 读取结构化行;当前未建立 external job 二级索引。 @@ -666,6 +670,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成媒体资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json`、序列媒体结果字段和历史 `public_showcase_enabled`。`asset_kind` 是跨布局共享的资源默认素材类型;单个结构化图层的差异只写 `editor_canvas_layer.asset_kind_override`,有效类型按 `override ?? resource default` 计算,不能通过新增资源行模拟标签修改。`image_sequence_frames_json` 与 `image_sequence_duration_ms` 保存角色动作正式结果,前者数组顺序是唯一帧序;角色动作预览视频是独立视频资源,不在最终序列资源行重复保存路径。“动作(原始视频)”只作为 `asset_kind = video` 的 provider 中间产物保存,其项目资源与账号素材都必须保持 `generation_inputs_json = NULL`;完整生成 / 重放输入只属于最终 `asset_kind = character-animation` 的序列资源和素材。`generation_inputs_json` 只保存用户可见生成 / 重放输入。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation` 的 `陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组、类型覆盖和资源引用以 `editor_canvas_layer` 为权威,媒体业务真相仍由资源表持有;生成器对象以 `editor_canvas_generation_dialog` 为权威。legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取;唯一缺资源例外是经过稳定站内路径校验的历史 `local-* + generated + image-sequence` 自包含图层,active 后只能续存同一不可变扩展。新写入不再把素材生成输入快照或正式序列帧结果作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-run,apply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。 +- 生成链路的新 resource ID 必须按 owner + operation kind + operation ID + stable slot 派生,只能在统一结果 procedure 中创建或完整比较;普通“同媒体复用”不得把它替换成另一随机 ID。 - 完美像素资源:处理成功时只允许一个最终 PNG 对象对应一个新 project resource;源图已有正式 project resource 时,`source_resource_id` 指向该资源;结果尺寸与最终 PNG 一致,不写逻辑低分辨率图、诊断图或前后对比图。成功时同时创建一个同源 `editor_asset`,请求省略素材文件夹时落入默认素材文件夹;completion 因权威 dialog 的删除已先持久化而跳过画布写回时,这两类已确认资源无需回滚。 - `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs`;`background_mode` 和 `cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。 - 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。 @@ -684,6 +689,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、媒体读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、真实操作 `task_id`、可选后台归组 `group_task_id`、拆分批次预期数量 `group_task_expected_asset_count`、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id`、`generation_cost_mud_points` 和序列媒体结果字段。`asset_kind` 是唯一权威媒体类别;`image_sequence_frames_json` 与 `image_sequence_duration_ms` 保存角色动作正式结果,数组长度派生帧数、数组顺序决定播放顺序、FPS 按需推导,`generation_inputs_json` 只保存用户可见生成 / 重放输入。`prompt` 固定表示规范化后的用户原始意图,供跨资源搜索和用户侧元数据使用;provider 实际返回的改写只写 `actual_prompt`,提交给 provider 的系统 / 工程化 prompt 不得写入 `prompt`。角色透明图、图标透明图和自动切片等派生产物继承源用户 prompt,并用 `source_resource_id`、`generation_inputs_json`、provider / asset kind 表达处理来源。归组字段只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成把原始绿幕预览视频作为独立 `asset_kind = video` 素材保存,再把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧数组与图片序列毫秒时长写入两个正式媒体结果字段;生成端确认每个最终帧对象后,必须把该帧的 `objectKey` 与 `assetObjectId` 同时写入正式帧 payload,不能只在内部处理中暂存或仅保留首帧引用;存在项目上下文时,最终动作的 `source_resource_id` 指向预览视频资源。不把每帧拆成独立素材,也不重复保存预览路径。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别、媒体结果和用户可见生成输入快照。 +- 生成链路的新 asset ID 与 project resource 使用同一 operation/slot 身份但独立 domain 派生;首次提交和完整重放都必须保持 folder、object、source resource、task、媒体字段和生成输入逐字段一致。 - 正式序列帧写入边界:写入端必须逐帧取得非空 `objectKey` 与 `assetObjectId`,并始终按 `objectKey` 重建 `imageSrc` 的持久站内路径。只有临时签名 URL 而没有这两项稳定引用的 External v1 请求返回 `400`,不能进入正式素材或项目资源。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 @@ -729,6 +735,15 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 只保留在 private 表内,记录首次初始化的真实 `ctx.sender()`,不进入 procedure 返回快照;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时只有 HTTP 角色通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;worker / controller 启动只调用受鉴权的 queue-stats procedure 做只读身份预检。后台保存请求携带 `AppConfig` 中的受保护 bootstrap secret,以便配置行意外缺失时原子恢复;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,当前生产 API / worker / controller 因此继承同一 runtime token;迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。 - 索引:主键 `config_id`。 +### `editor_generation_operation` + +- Rust 结构体:`EditorGenerationOperation` +- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` +- 说明:编辑器生成结果的私有 durable commit receipt,queue 与受控 inline 路径共用。主键 `operation_key` 由 owner 与 operation ID 稳定派生,同 owner 不得跨 kind 复用 operation ID;`operation_fingerprint` 绑定规范用户请求,`commit_sha256` 绑定全部 slot 候选、object/resource/asset/binding、可选 canvas 候选与 job completion。表内另固化 owner/kind/ID、可选 `project_id`、queue 的 `job_id / job_worker_id / job_lease_token / job_result_payload_sha256` 和首次提交时间;重放从已完成 job 回读权威 compact result 并核对摘要,不在 receipt 复制正文。它只证明一笔业务结果已原子提交,不是第二套 job 状态或 resource/asset/canvas read model,不保存大快照。 +- 重放:receipt 存在时必须核对全部绑定和权威记录,完全一致才返回 `AlreadyApplied`;同 operation 的请求或提交摘要漂移、project/job 绑定漂移、receipt 缺失但稳定 resource/asset/binding 等业务记录已存在均失败关闭。事务前单独确认的 asset object 只在全部字段与稳定候选完全一致时允许复用,不能据此补造 receipt。重放不更新 receipt 时间,不重复 job/binding 事件,不推进 canvas revision。 +- 时间:`completed_at_micros` 必须为正数并固化为 receipt 完成时间;object/resource/asset/binding/canvas 候选的原时间字段与它一起进入 commit SHA-256,重放必须复用原 prepared commit 而不得重新取时。queue job 完成时间与完成事件仍使用 SpacetimeDB `ctx.timestamp`,不信任调用方时钟。 +- 索引:主键 `operation_key`;`by_editor_generation_operation_owner(owner_user_id, operation_id)` 仅用于受控定位和诊断,不允许同 owner 跨 operation kind 复用同一 operation ID。 + ### `editor_generation_runtime_identity_rotation` - Rust 结构体:`EditorGenerationRuntimeIdentityRotation` diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index 6c4abd51a..b3d683ac7 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -59,15 +59,18 @@ layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualP ### 3.5 worker 原子完成 -worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存;发生并发变更时拒绝覆盖并让任务保留可诊断失败,不再静默覆盖用户布局。最终收口仍是受 `job_id + worker_id + lease_token` 栅栏保护的后端 procedure 在同一事务内: +worker 完成生成任务时,`api-server` 先把 Provider / OSS 结果准备为稳定 operation/slot 候选,再调用 `persist_editor_generation_result_and_return`。procedure 受 editor generation runtime service identity 保护,queue 路径还必须在同一快照校验 `job_id + worker_id + lease_token`、owner、job kind 和由 job 规范请求重算的 SHA-256 fingerprint;inline 三个 job guard 全空,不接受半套栅栏。同一 `try_with_tx` 内: -1. 校验 job、owner、project、canvas、dialog 和租约; -2. 幂等创建或确认 `editor_project_resource`; -3. 创建 / 替换结果 layer,并删除或更新占位 layer; -4. 把 dialog 更新为终态并关联 `generated_layer_id`; -5. 递增 canvas revision,最后才允许完成 external job。 +1. 校验 operation 身份、request fingerprint、slot 唯一性、稳定 ID 和全部 owner/project/folder/source/task/媒体交叉关系; +2. 精确 upsert 可选 `asset_object`,或验证省略的 object 已登记且归属同 owner; +3. 创建全部 `editor_project_resource`、`editor_asset` 和可选 `asset_entity_binding`; +4. 对候选布局重新执行 legacy / structured 验证,以 `expected_revision` CAS 写入 layer / dialog 完成态并且只递增一次 canvas revision; +5. queue 路径写入 compact result 并完成 external job; +6. 写入 `editor_generation_operation` durable receipt,固化 operation fingerprint、整笔 commit SHA-256、project/job/worker/lease/result 绑定和首次完成时间。 -重复 completion 必须返回同一资源、layer 和 dialog 终态,不得重复插入,也不能因 dialog 暂时缺失而返回 `changed=false` 后仍把任务标记完成。任一步失败时整笔业务写回回滚,任务保留可诊断的失败或可重试状态。 +任一步失败时 object/resource/asset/binding/canvas/job/receipt 全部回滚。CAS 冲突时调用方只刷新当前 project 并重算 layout 候选,原 operation、slot、对象和业务记录候选不变,不重跑 Provider 或 OSS。完整重放只在 receipt 存在,且 request fingerprint、commit SHA-256、project/job 绑定与全部权威记录一致时返回 `AlreadyApplied`;不重复事件、不刷新时间、不推进 revision。receipt 缺失但稳定业务记录已存在、同 operation 内容漂移或不完整重放都必须失败关闭。 + +`completed_at_micros` 必须为正数并固化到 receipt;object/resource/asset/binding/canvas 候选的原时间字段也纳入 commit SHA-256,重放复用原 prepared commit,不重新取时。job 完成时间和完成事件使用事务 `ctx.timestamp`,不信任 worker 时钟。OSS `PUT / HEAD` 仍在 SpacetimeDB 事务外,因此事务失败可以留下未登记或未引用 object,不将本契约表述为跨 OSS exactly-once。 ### 3.6 免费同步栅格派生完成 @@ -123,7 +126,7 @@ SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端 - release 存量抽样中的缺资源 `local-*` 角色动作序列可无损 round-trip,并被识别为已持久化终态而非资源登记 pending;同形状但空帧、相对路径、HTTP / 签名 URL、`data:` / `blob:` 引用必须拒绝。已有资源的 `sourceResourceId == resourceId` 历史自引用应按资源表真相安全剥离,其他来源 ID 或资源字段冲突仍必须拒绝。 - release 全量审计暴露的普通缺资源行必须先通过定向 repair dry-run;图片只能复用同工程唯一资源,音频只能从已登记 private asset_object 恢复。修复后同一 plan 全部命中 `already_repaired`,再重跑全量 backfill dry-run,要求所有 canvas 均通过。 - structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用、`asset_kind_override` 和 dialog 状态;标签展示和类型能力判断统一按 `override ?? resource default`。修改当前图层标签与清除覆盖都保持 `resource_id` 和资源行数量不变;复制共享同一资源并复制 override,随后各副本可独立修改 override。两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。 -- worker completion 当前以读取时 revision 做 CAS,冲突时拒绝覆盖;V2 保存和保存后快照在同一 procedure 结果内返回,避免“已提交但后续 GET 失败”的不确定结果。lease-fenced 资源 / layer / dialog / job 单事务 completion 仍是后续收口项。 +- worker completion 已使用 durable receipt 与统一原子提交;V2 布局 CAS、object/resource/asset/binding、job 终态和 receipt 在同一 procedure 结果内返回。故障注入必须证明资产校验失败与 canvas revision 冲突均为零部分写入,成功后重放不新增记录、事件或 revision。 - structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。 - 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个最终 PNG、至多一个 project resource 和一个账号素材。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。 - 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index d3424f54c..564c67539 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -5272,6 +5272,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "sha2", ] [[package]] diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index d74034a01..8cdf30516 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -1,4 +1,7 @@ -use std::future::Future; +use std::{ + cell::{Cell, RefCell}, + future::Future, +}; use axum::http::StatusCode; use serde_json::json; @@ -35,6 +38,78 @@ struct AssetOperationBillingPlan { tokio::task_local! { static CURRENT_EXTERNAL_GENERATION_BILLING_CONTEXT: ExternalGenerationBillingContext; + static DEFERRED_ASSET_OPERATION_REFUNDS: RefCell>; + static EDITOR_GENERATION_COMMIT_DISPATCHED: Cell; + static EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN: Cell; +} + +pub(crate) async fn with_editor_generation_durable_billing_boundary( + future: Fut, +) -> Result +where + Fut: Future>, +{ + EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN + .scope(Cell::new(false), async { + EDITOR_GENERATION_COMMIT_DISPATCHED + .scope(Cell::new(false), async { + DEFERRED_ASSET_OPERATION_REFUNDS + .scope(RefCell::new(Vec::new()), async { + let result = future.await; + let outcome_unknown = + EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.with(Cell::get); + let refunds = DEFERRED_ASSET_OPERATION_REFUNDS.with(RefCell::take); + match &result { + Ok(_) => { + for mut refund in refunds { + refund.disarm(); + } + } + Err(_) if outcome_unknown => { + // 远端提交可能已经成功;不能把正式结果与扣费同时保留后再退款。 + for mut refund in refunds { + refund.disarm(); + } + } + Err(_) => { + for mut refund in refunds { + refund.refund_now().await; + } + } + } + result + }) + .await + }) + .await + }) + .await +} + +pub(crate) fn begin_editor_generation_commit_attempt() { + let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(false)); +} + +pub(crate) fn mark_editor_generation_commit_dispatched() { + let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(true)); + let _ = EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.try_with(|unknown| unknown.set(true)); +} + +pub(crate) fn editor_generation_commit_attempt_was_dispatched() -> bool { + EDITOR_GENERATION_COMMIT_DISPATCHED + .try_with(Cell::get) + .unwrap_or(false) +} + +pub(crate) fn mark_editor_generation_commit_outcome_confirmed() { + let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(false)); + let _ = EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.try_with(|unknown| unknown.set(false)); +} + +fn editor_generation_commit_outcome_is_unknown() -> bool { + EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN + .try_with(Cell::get) + .unwrap_or(false) } #[cfg(test)] @@ -145,7 +220,20 @@ where ); match operation.await { Ok(value) => { - refund_on_drop.disarm(); + let mut deferred_refund = Some(refund_on_drop); + let deferred = DEFERRED_ASSET_OPERATION_REFUNDS + .try_with(|refunds| { + refunds + .borrow_mut() + .push(deferred_refund.take().expect("deferred refund guard")); + }) + .is_ok(); + if !deferred { + deferred_refund + .as_mut() + .expect("immediate refund guard") + .disarm(); + } Ok(value) } Err(error) => { @@ -225,6 +313,16 @@ impl Drop for AssetOperationRefundOnDrop { if !self.active { return; } + if editor_generation_commit_outcome_is_unknown() { + tracing::warn!( + owner_user_id = self.owner_user_id, + asset_kind = self.asset_kind, + asset_id = self.asset_id, + "编辑器生成提交结果未知,取消请求时保留扣费并等待 durable receipt 对账" + ); + self.active = false; + return; + } let state = self.state.clone(); let owner_user_id = self.owner_user_id.clone(); let asset_kind = self.asset_kind.clone(); @@ -826,4 +924,62 @@ mod tests { module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON ); } + + #[test] + fn every_provider_inline_route_defers_billing_until_durable_completion() { + for (source, expected_calls) in [ + (include_str!("editor_project.rs"), 4), + (include_str!("character_animation_assets.rs"), 2), + ( + include_str!("vector_engine_audio_generation/generation.rs"), + 2, + ), + ] { + assert_eq!( + source + .matches("with_editor_generation_durable_billing_boundary(") + .count(), + expected_calls, + ); + } + } + + #[test] + fn atomic_commit_dispatch_marks_unknown_before_the_cancellable_result_wait() { + let editor_source = include_str!("editor_project.rs"); + let editor_body = editor_source + .split_once("async fn persist_editor_generation_prepared_commit(") + .and_then(|(_, tail)| { + tail.split_once("async fn call_with_editor_generation_unknown_result_replay") + .map(|(body, _)| body) + }) + .expect("prepared commit helper"); + assert!(editor_body.contains("persist_editor_generation_result_with_dispatch")); + assert!(editor_body.contains("mark_editor_generation_commit_dispatched")); + + let client_source = include_str!("../../spacetime-client/src/editor_project.rs"); + let client_body = client_source + .split_once("pub async fn persist_editor_generation_result_with_dispatch") + .and_then(|(_, tail)| { + tail.split_once("pub async fn create_editor_project") + .map(|(body, _)| body) + }) + .expect("dispatch-aware client procedure"); + let mark = client_body.find("on_dispatch();").expect("dispatch marker"); + let procedure = client_body + .find(".persist_editor_generation_result_and_return_then(") + .expect("procedure dispatch"); + + assert!(mark < procedure); + + let billing_source = include_str!("asset_billing.rs"); + let drop_body = billing_source + .split_once("impl Drop for AssetOperationRefundOnDrop") + .and_then(|(_, tail)| { + tail.split_once("pub(crate) fn should_refund_asset_operation_error") + .map(|(body, _)| body) + }) + .expect("refund guard drop"); + assert!(drop_body.contains("editor_generation_commit_outcome_is_unknown()")); + } } diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 3b55f00d6..b969ca3fb 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -22,14 +22,15 @@ use module_ai::{ AiTaskStageKind, AiTaskStatus, generate_ai_task_id, }; use module_assets::{ - AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, - build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, + AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectUpsertInput, + build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, + generate_asset_object_id, }; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssRequestAttemptContext, OssSignedGetObjectUrlRequest, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::assets::{ CharacterAnimationDraftPayload, CharacterAnimationGenerateRequest, @@ -48,11 +49,20 @@ use shared_contracts::assets::{ use shared_contracts::assets::{ CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse, }; -use spacetime_client::{ExternalGenerationJobRecord, SpacetimeClientError}; +use shared_contracts::editor_generation::{ + editor_generation_request_fingerprint, editor_generation_stable_asset_id, + editor_generation_stable_asset_object_id, editor_generation_stable_resource_id, +}; +use spacetime_client::{ + EditorAssetCreateRecordInput, EditorProjectResourceCreateRecordInput, + ExternalGenerationJobRecord, SpacetimeClientError, +}; use crate::{ api_response::json_success_body, - asset_billing::execute_billable_asset_operation_with_cost, + asset_billing::{ + execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary, + }, auth::AuthenticatedAccessToken, custom_world_asset_prompts::{ build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt, @@ -69,12 +79,15 @@ use crate::{ editor_project::{ EDITOR_ASSET_ID_PREFIX, EDITOR_BGFILTER_CROSS_CHECK_ENABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_RESOURCE_ID_PREFIX, - EditorCanvasGeneratedLayerInput, EditorGenerationPhaseReporter, - PersistEditorGeneratedAssetRequest, build_editor_canvas_generated_layer_item, - build_editor_canvas_resource_placement_item, complete_editor_canvas_generation_with_items, - persist_editor_generated_media_asset, + EditorCanvasGeneratedLayerInput, EditorCanvasLayoutPlan, EditorGenerationCaller, + EditorGenerationOperationContext, PreparedEditorGenerationResultItem, + build_editor_canvas_generated_layer_item, editor_asset_payload_from_record, + editor_project_payload_from_record, editor_project_resource_payload_from_record, + persist_editor_generation_result_atomically, remove_editor_generated_screen_background_with_bgfilter, - resolve_editor_reference_object_key_for_owner, with_editor_media_duration_generation_input, + resolve_editor_reference_object_key_for_owner, sanitize_editor_client_generation_inputs, + serialize_editor_generation_inputs, serialize_editor_image_sequence_frames, + with_editor_media_duration_generation_input, }, editor_screen_background_decision::{ EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, @@ -118,10 +131,15 @@ const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation" const EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND: &str = "character-animation"; const EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND: &str = "video"; const EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT: &str = "provider_source"; +const EDITOR_CHARACTER_ANIMATION_PREVIEW_RESULT_SLOT: &str = "animation-preview"; +const EDITOR_CHARACTER_ANIMATION_FINAL_SLOT: &str = "animation-final"; +const EDITOR_CHARACTER_ANIMATION_FRAME_SLOT_PREFIX: &str = "animation-frame-"; const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video"; const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas"; const EDITOR_VIDEO_SLOT: &str = "video_preview"; const EDITOR_VIDEO_COVER_SLOT: &str = "video_cover"; +const EDITOR_VIDEO_PRIMARY_RESULT_SLOT: &str = "video-primary"; +const EDITOR_VIDEO_COVER_RESULT_SLOT: &str = "video-cover"; const EDITOR_VIDEO_MODEL_SEEDANCE_2: &str = "seedance2.0"; const EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast"; const EDITOR_VIDEO_MODEL_KLING_3: &str = "kling3.0"; @@ -170,12 +188,24 @@ struct PersistedEditorVideo { object_key: String, asset_object_id: String, thumbnail_src: Option, + prepared_asset_objects: Vec<(String, AssetObjectUpsertInput)>, } struct PersistedEditorCharacterAnimationPreview { legacy_public_path: String, object_key: String, asset_object_id: String, + prepared_asset_object: AssetObjectUpsertInput, +} + +struct PreparedEditorCharacterAnimationResult { + items: Vec, + final_resource_id: String, +} + +struct PreparedEditorVideoResult { + items: Vec, + resource_id: String, } pub async fn list_character_animation_templates( @@ -593,13 +623,19 @@ pub async fn generate_editor_character_animation( }, )); } - generate_editor_character_animation_for_owner( + let caller = editor_inline_generation_caller( + owner_user_id, + &request_context, + EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, + &payload, + ) + .map_err(|error| character_animation_error_response(&request_context, error))?; + with_editor_generation_durable_billing_boundary(generate_editor_character_animation_for_owner( state, request_context, - owner_user_id, + caller, Ok(Json(payload)), - None, - ) + )) .await } @@ -656,9 +692,8 @@ pub(crate) async fn enqueue_editor_character_animation_for_owner( pub(crate) async fn generate_editor_character_animation_for_owner( state: AppState, request_context: RequestContext, - owner_user_id: String, + caller: EditorGenerationCaller, payload: Result, JsonRejection>, - phase_reporter: Option, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { character_animation_error_response( @@ -669,6 +704,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( })), ) })?; + let owner_user_id = caller.owner_user_id.clone(); let project_id = payload.project_id.clone(); let canvas_completion = payload.canvas_completion.clone(); let generation_inputs = payload.generation_inputs.clone(); @@ -737,6 +773,8 @@ pub(crate) async fn generate_editor_character_animation_for_owner( let http_client = build_upstream_http_client(settings.ark.request_timeout_ms) .map_err(|error| character_animation_error_response(&request_context, error))?; let extraction_settings = resolve_backend_frame_extraction_settings(&state); + editor_generation_operation(&caller) + .map_err(|error| character_animation_error_response(&request_context, error))?; let task_id = generate_ai_task_id(current_utc_micros()); // 抠图供应商失败审计上下文:逐帧阿里云抠图失败即使兜底成功,也要进 OTLP + tracking_event。 let matting_audit = crate::external_api_audit::ExternalApiAuditContext { @@ -793,7 +831,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( &state, &http_client, &settings, - owner_user_id.as_str(), + &caller, normalized.source_layer_id.as_str(), task_id.as_str(), normalized.prompt.as_str(), @@ -801,55 +839,16 @@ pub(crate) async fn generate_editor_character_animation_for_owner( ) .await?; // 扣费契约(【编辑器】画板角色形象生成入口设计-2026-06-15):角色动作的交付物是 - // 画布可用的透明序列帧,预览视频本身不是可用结果;因此预览落库、phase 上报、抽帧、 - // 逐帧抠图、首帧落库全部留在计费闭包内,任一步失败都走 execute_billable 的失败退款。 + // 画布可用的透明序列帧,预览视频本身不是可用结果;因此预览上传与 HEAD 校验、 + // phase 上报、抽帧、逐帧抠图、首帧上传与 HEAD 校验全部留在计费闭包内,任一步失败 + // 都走 execute_billable 的失败退款。数据库事实稍后与 canvas/job 一次性原子提交。 // 有别于角色生图 / 编辑器视频(provider 产物即交付物,provider 成功即收口扣费)。 - let source_record = persist_editor_generated_media_asset( - &state, - PersistEditorGeneratedAssetRequest { - project_id: project_id.clone(), - owner_user_id: owner_user_id.clone(), - folder_id: asset_folder_id - .clone() - .or_else(|| Some("project".to_string())), - label: editor_character_animation_source_asset_label(asset_label.as_str()), - image_src: generated.preview_video_path.clone(), - object_key: generated.object_key.clone(), - asset_object_id: generated.asset_object_id.clone(), - width: normalized.frame_width, - height: normalized.frame_height, - prompt: normalized.prompt.clone(), - actual_prompt: Some(generated.submitted_prompt.clone()), - model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), - provider: "Ark".to_string(), - task_id: task_id.clone(), - source_resource_id: source_resource_id.clone(), - asset_kind: Some( - EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND.to_string(), - ), - // “动作(原始视频)”只是 provider 中间产物,不是可重放的角色动作结果: - // 它固定以 asset_kind=video 保存,且 project resource / account asset 两行的 - // generation_inputs_json 都必须为 NULL。通用持久化 helper 会把这里传入的同一个 - // generation_inputs 同时写入两行,因此 preview 必须显式传 None;完整生成配方只 - // 保存在后面的最终 character-animation 序列资源 / 素材上。 - generation_inputs: None, - thumbnail_src: None, - generation_cost_mud_points: u64::from(normalized.price_mud_points), - image_sequence_frames: None, - image_sequence_duration_ms: None, - }, - ) - .await?; - if let Some(reporter) = phase_reporter.as_ref() { + if let Some(reporter) = caller.phase_reporter.as_ref() { reporter.report_processing(&state).await?; } - let provider_source_resource_id = source_record - .0 - .as_ref() - .map(|resource| resource.resource_id.clone()); - let persisted_frames = extract_and_persist_editor_character_animation_frames( + let persisted_frames = extract_and_prepare_editor_character_animation_frames( &state, - owner_user_id.as_str(), + &caller, normalized.source_layer_id.as_str(), task_id.as_str(), generated.preview_video_path.as_str(), @@ -858,8 +857,8 @@ pub(crate) async fn generate_editor_character_animation_for_owner( &matting_audit, ) .await?; - let frames = persisted_frames.frames; - let first_frame = frames.first().ok_or_else(|| { + let frames = persisted_frames.frames.clone(); + frames.first().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-character-animation", "message": "角色动作抽帧完成但没有可保存的序列帧。", @@ -871,97 +870,139 @@ pub(crate) async fn generate_editor_character_animation_for_owner( "message": format!("角色动作序列帧无法持久化:{error}"), })) })?; - let (final_resource, final_asset) = persist_editor_generated_media_asset( - &state, - PersistEditorGeneratedAssetRequest { - project_id: project_id.clone(), - owner_user_id: owner_user_id.clone(), - folder_id: asset_folder_id - .clone() - .or_else(|| Some("project".to_string())), - label: asset_label.clone(), - image_src: first_frame.image_src.clone(), - object_key: Some(persisted_frames.first_frame_object_key), - asset_object_id: Some(persisted_frames.first_frame_asset_object_id), - width: first_frame.width, - height: first_frame.height, - prompt: normalized.prompt.clone(), - actual_prompt: Some(generated.submitted_prompt.clone()), - model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), - provider: "Ark".to_string(), - task_id: task_id.clone(), - source_resource_id: provider_source_resource_id.clone(), - asset_kind: Some( - EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(), - ), - generation_inputs: generation_inputs.clone(), - thumbnail_src: Some(first_frame.image_src.clone()), - generation_cost_mud_points: 0, - image_sequence_frames: Some(frame_payload_json), - image_sequence_duration_ms: Some( - u64::from(normalized.duration_seconds) * 1_000, - ), - }, - ) - .await?; - let final_asset = final_asset.ok_or_else(|| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-character-animation", - "message": "角色动作透明序列帧已生成,但最终素材记录缺失。", - })) - })?; - let final_asset = serde_json::to_value(final_asset).map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-character-animation", - "message": format!("角色动作最终素材响应无法序列化:{error}"), - })) - })?; - - Ok::<_, AppError>(( - generated, - frames, - normalized, - final_resource, - final_asset, - )) + Ok::<_, AppError>((generated, frames, normalized, persisted_frames, frame_payload_json)) }, ) .await; - let (generated, frames, normalized, final_resource, final_asset) = + let (generated, frames, normalized, persisted_frames, frame_payload_json) = result.map_err(|error| character_animation_error_response(&request_context, error))?; - let completed_project = if let Some(completion) = canvas_completion.as_ref() { - let final_resource = final_resource.as_ref().ok_or_else(|| { + let first_frame = frames.first().ok_or_else(|| { + character_animation_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作抽帧完成但没有可保存的序列帧。", + })), + ) + })?; + let completed_at_micros = current_utc_micros(); + let prepared = prepare_editor_character_animation_result( + &caller, + project_id.as_deref(), + asset_folder_id.as_deref().or(Some("project")), + asset_label.as_str(), + source_resource_id, + generation_inputs, + &generated, + &normalized, + &persisted_frames, + frame_payload_json, + completed_at_micros, + ) + .map_err(|error| character_animation_error_response(&request_context, error))?; + let layer_id = format!("layer-editor-character-animation-{task_id}"); + let canvas_item = canvas_completion.as_ref().map(|completion| { + build_editor_character_animation_canvas_placement_item( + completion, + prepared.final_resource_id.as_str(), + first_frame.width, + first_frame.height, + layer_id.as_str(), + ) + }); + let canvas_layout_plan = EditorCanvasLayoutPlan::generation( + owner_user_id.as_str(), + project_id.as_deref(), + canvas_completion.as_ref(), + canvas_item.into_iter().collect(), + Some(layer_id), + ); + let final_candidate = prepared + .items + .iter() + .find(|item| item.slot == EDITOR_CHARACTER_ANIMATION_FINAL_SLOT) + .ok_or_else(|| { character_animation_error_response( &request_context, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-character-animation", - "message": "角色动作画布完成缺少最终项目资源。", + "message": "角色动作原子提交缺少最终候选记录。", })), ) })?; - let layer_id = format!("layer-editor-character-animation-{task_id}"); - let item = build_editor_canvas_resource_placement_item( - completion, - final_resource, - layer_id.clone(), - ); - complete_editor_canvas_generation_with_items( - &state, - owner_user_id.as_str(), - project_id.as_deref(), - canvas_completion.as_ref(), - vec![item], - Some(layer_id), - ) - .await - .map_err(|error| character_animation_error_response(&request_context, error))? - } else { - None - }; - let completed_project = - completed_project.and_then(|project| serde_json::to_value(project).ok()); + let compact_resource = final_candidate + .project_resource + .as_ref() + .map(compact_editor_generation_candidate_resource); + let compact_asset = final_candidate + .asset + .as_ref() + .map(compact_editor_generation_candidate_asset); + let job_result_payload_json = editor_generation_job_result_payload_json( + &caller, + json!({ + "ok": true, + "taskId": task_id, + "model": EDITOR_CHARACTER_ANIMATION_MODEL, + "prompt": generated.submitted_prompt, + "previewVideoPath": generated.preview_video_path, + "frameCount": frames.len(), + "durationSeconds": normalized.duration_seconds, + "frameWidth": normalized.frame_width, + "frameHeight": normalized.frame_height, + "fps": normalized.fps, + "priceMudPoints": normalized.price_mud_points, + "frames": &frames, + "resource": compact_resource, + "asset": compact_asset, + }), + ); + let persisted = persist_editor_generation_result_atomically( + &state, + &caller, + prepared.items, + canvas_layout_plan, + job_result_payload_json, + completed_at_micros, + ) + .await + .map_err(|error| character_animation_error_response(&request_context, error))?; + let final_resource = persisted + .items + .iter() + .find(|item| item.slot == EDITOR_CHARACTER_ANIMATION_FINAL_SLOT) + .and_then(|item| item.project_resource.clone()) + .map(editor_project_resource_payload_from_record); + let final_asset = persisted + .items + .iter() + .find(|item| item.slot == EDITOR_CHARACTER_ANIMATION_FINAL_SLOT) + .and_then(|item| item.asset.clone()) + .map(editor_asset_payload_from_record) + .ok_or_else(|| { + character_animation_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作透明序列帧已生成,但最终素材记录缺失。", + })), + ) + })?; + let completed_project = persisted + .project + .map(editor_project_payload_from_record) + .and_then(|project| serde_json::to_value(project).ok()); let final_resource = final_resource.and_then(|resource| serde_json::to_value(resource).ok()); + let final_asset = serde_json::to_value(final_asset).map_err(|error| { + character_animation_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-character-animation", + "message": format!("角色动作最终素材响应无法序列化:{error}"), + })), + ) + })?; Ok(json_success_body( Some(&request_context), @@ -1018,7 +1059,20 @@ pub async fn generate_editor_video( }, )); } - generate_editor_video_for_owner(state, request_context, owner_user_id, Ok(Json(payload))).await + let caller = editor_inline_generation_caller( + owner_user_id, + &request_context, + EDITOR_VIDEO_GENERATION_JOB_KIND, + &payload, + ) + .map_err(|error| editor_video_error_response(&request_context, error))?; + with_editor_generation_durable_billing_boundary(generate_editor_video_for_owner( + state, + request_context, + caller, + Ok(Json(payload)), + )) + .await } pub(crate) async fn enqueue_editor_video_generation_for_owner( @@ -1059,7 +1113,7 @@ pub(crate) async fn enqueue_editor_video_generation_for_owner( pub(crate) async fn generate_editor_video_for_owner( state: AppState, request_context: RequestContext, - owner_user_id: String, + caller: EditorGenerationCaller, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -1071,6 +1125,7 @@ pub(crate) async fn generate_editor_video_for_owner( })), ) })?; + let owner_user_id = caller.owner_user_id.clone(); let project_id = payload.project_id.clone(); let canvas_completion = payload.canvas_completion.clone(); let generation_inputs = payload.generation_inputs.clone(); @@ -1099,6 +1154,8 @@ pub(crate) async fn generate_editor_video_for_owner( .map_err(|error| editor_video_error_response(&request_context, error))?; let http_client = build_upstream_http_client(settings.ark.request_timeout_ms) .map_err(|error| editor_video_error_response(&request_context, error))?; + editor_generation_operation(&caller) + .map_err(|error| editor_video_error_response(&request_context, error))?; let task_id = generate_ai_task_id(current_utc_micros()); let generated = execute_billable_asset_operation_with_cost( @@ -1111,7 +1168,7 @@ pub(crate) async fn generate_editor_video_for_owner( &state, &http_client, &settings, - owner_user_id.as_str(), + &caller, task_id.as_str(), &normalized, ), @@ -1119,50 +1176,33 @@ pub(crate) async fn generate_editor_video_for_owner( .await .map_err(|error| editor_video_error_response(&request_context, error))?; let image_src = generated.preview_video_path.clone(); - let (resource, asset) = persist_editor_generated_media_asset( - &state, - PersistEditorGeneratedAssetRequest { - project_id: project_id.clone(), - owner_user_id: owner_user_id.clone(), - folder_id: asset_folder_id, - label: asset_label - .and_then(|value| { - let trimmed = value.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) - }) - .unwrap_or_else(|| "生成视频".to_string()), - image_src: image_src.clone(), - object_key: generated.object_key.clone(), - asset_object_id: generated.asset_object_id.clone(), - width: normalized.width, - height: normalized.height, - prompt: normalized.prompt.clone(), - actual_prompt: Some(generated.submitted_prompt.clone()), - model: normalized.model.clone(), - provider: "VectorEngine".to_string(), - task_id: task_id.clone(), - source_resource_id: source_resource_id.clone(), - asset_kind: Some(asset_kind.clone()), - generation_inputs: generation_inputs.clone(), - thumbnail_src: generated.thumbnail_src.clone(), - generation_cost_mud_points: u64::from(normalized.price_mud_points), - image_sequence_frames: None, - image_sequence_duration_ms: None, - }, + let completed_at_micros = current_utc_micros(); + let label = asset_label + .and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) + .unwrap_or_else(|| "生成视频".to_string()); + let prepared = prepare_editor_video_result( + &caller, + project_id.as_deref(), + asset_folder_id.as_deref(), + label, + source_resource_id.clone(), + asset_kind.clone(), + generation_inputs.clone(), + &generated, + &normalized, + completed_at_micros, ) - .await .map_err(|error| editor_video_error_response(&request_context, error))?; - let completed_project = if let Some(completion) = canvas_completion.as_ref() { + let canvas_layout_plan = if let Some(completion) = canvas_completion.as_ref() { let layer_id = format!("layer-editor-video-{task_id}"); - let resource_id = resource - .as_ref() - .map(|resource| resource.resource_id.clone()) - .unwrap_or_else(|| format!("local-resource-editor-video-{task_id}")); let item = build_editor_canvas_generated_layer_item( completion, EditorCanvasGeneratedLayerInput { layer_id: layer_id.clone(), - resource_id, + resource_id: prepared.resource_id.clone(), title: completion.title.trim().to_string(), src: image_src.clone(), thumbnail_src: generated.thumbnail_src.clone(), @@ -1183,23 +1223,85 @@ pub(crate) async fn generate_editor_video_for_owner( generation_inputs, }, ); - complete_editor_canvas_generation_with_items( - &state, + EditorCanvasLayoutPlan::generation( owner_user_id.as_str(), project_id.as_deref(), canvas_completion.as_ref(), vec![item], Some(layer_id), ) - .await - .map_err(|error| editor_video_error_response(&request_context, error))? } else { - None + EditorCanvasLayoutPlan::None }; - let completed_project = - completed_project.and_then(|project| serde_json::to_value(project).ok()); - let resource = resource.and_then(|resource| serde_json::to_value(resource).ok()); - let asset = asset.and_then(|asset| serde_json::to_value(asset).ok()); + let primary_candidate = prepared + .items + .iter() + .find(|item| item.slot == EDITOR_VIDEO_PRIMARY_RESULT_SLOT) + .ok_or_else(|| { + editor_video_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-video", + "message": "视频原子提交缺少主候选记录。", + })), + ) + })?; + let compact_resource = primary_candidate + .project_resource + .as_ref() + .map(compact_editor_generation_candidate_resource); + let compact_asset = primary_candidate + .asset + .as_ref() + .map(compact_editor_generation_candidate_asset); + let job_result_payload_json = editor_generation_job_result_payload_json( + &caller, + json!({ + "ok": true, + "taskId": task_id, + "videoSrc": generated.preview_video_path, + "thumbnailSrc": generated.thumbnail_src, + "objectKey": generated.object_key, + "assetObjectId": generated.asset_object_id, + "width": normalized.width, + "height": normalized.height, + "sourceType": "generated", + "prompt": normalized.prompt, + "actualPrompt": generated.submitted_prompt, + "model": normalized.model, + "durationSeconds": normalized.duration_seconds, + "resolution": normalized.resolution, + "priceMudPoints": normalized.price_mud_points, + "resource": compact_resource, + "asset": compact_asset, + }), + ); + let persisted = persist_editor_generation_result_atomically( + &state, + &caller, + prepared.items, + canvas_layout_plan, + job_result_payload_json, + completed_at_micros, + ) + .await + .map_err(|error| editor_video_error_response(&request_context, error))?; + let primary = persisted + .items + .iter() + .find(|item| item.slot == EDITOR_VIDEO_PRIMARY_RESULT_SLOT); + let resource = primary + .and_then(|item| item.project_resource.clone()) + .map(editor_project_resource_payload_from_record) + .and_then(|resource| serde_json::to_value(resource).ok()); + let asset = primary + .and_then(|item| item.asset.clone()) + .map(editor_asset_payload_from_record) + .and_then(|asset| serde_json::to_value(asset).ok()); + let completed_project = persisted + .project + .map(editor_project_payload_from_record) + .and_then(|project| serde_json::to_value(project).ok()); Ok(json_success_body( Some(&request_context), @@ -1736,6 +1838,7 @@ async fn request_image_to_video_preview( object_key: None, asset_object_id: None, thumbnail_src: None, + prepared_asset_objects: Vec::new(), upstream_task_id, submitted_prompt, moderation_fallback_applied, @@ -1996,7 +2099,7 @@ async fn put_generated_preview_video( async fn put_generated_editor_character_animation_preview( state: &AppState, - owner_user_id: &str, + caller: &EditorGenerationCaller, source_layer_id: &str, task_id: &str, preview_payload: MediaPayload, @@ -2016,7 +2119,7 @@ async fn put_generated_editor_character_animation_preview( preview_payload.bytes, build_asset_metadata( EDITOR_CHARACTER_ANIMATION_ASSET_KIND, - owner_user_id, + caller.owner_user_id.as_str(), "editor_layer", source_layer_id, EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT, @@ -2024,11 +2127,10 @@ async fn put_generated_editor_character_animation_preview( ), ) .await?; - let confirmed = confirm_editor_character_animation_source_asset_object( + let prepared_asset_object = prepare_editor_character_animation_source_asset_object( state, - owner_user_id, + caller, source_layer_id, - task_id, put_result.object_key.clone(), content_type, ) @@ -2037,7 +2139,8 @@ async fn put_generated_editor_character_animation_preview( Ok(PersistedEditorCharacterAnimationPreview { legacy_public_path: put_result.legacy_public_path, object_key: put_result.object_key, - asset_object_id: confirmed.record.asset_object_id, + asset_object_id: prepared_asset_object.asset_object_id.clone(), + prepared_asset_object, }) } @@ -2045,7 +2148,7 @@ async fn request_editor_character_animation_preview( state: &AppState, http_client: &reqwest::Client, settings: &EditorCharacterAnimationSettings, - owner_user_id: &str, + caller: &EditorGenerationCaller, source_layer_id: &str, task_id: &str, prompt: &str, @@ -2065,7 +2168,7 @@ async fn request_editor_character_animation_preview( .await?; let persisted = put_generated_editor_character_animation_preview( state, - owner_user_id, + caller, source_layer_id, task_id, preview_payload, @@ -2077,6 +2180,10 @@ async fn request_editor_character_animation_preview( object_key: Some(persisted.object_key), asset_object_id: Some(persisted.asset_object_id), thumbnail_src: None, + prepared_asset_objects: vec![( + EDITOR_CHARACTER_ANIMATION_PREVIEW_RESULT_SLOT.to_string(), + persisted.prepared_asset_object, + )], upstream_task_id, submitted_prompt: prompt.to_string(), moderation_fallback_applied: false, @@ -2087,26 +2194,32 @@ async fn request_editor_video_preview( state: &AppState, http_client: &reqwest::Client, settings: &EditorVideoSettings, - owner_user_id: &str, + caller: &EditorGenerationCaller, task_id: &str, request: &NormalizedEditorVideoRequest, ) -> Result { - let upstream_task_id = - create_editor_text_to_video_task(state, http_client, settings, owner_user_id, request) - .await?; + let upstream_task_id = create_editor_text_to_video_task( + state, + http_client, + settings, + caller.owner_user_id.as_str(), + request, + ) + .await?; let video_url = wait_for_ark_content_generation_task(http_client, &settings.ark, upstream_task_id.as_str()) .await?; let preview_payload = download_generated_video(http_client, video_url.as_str(), "下载画板生成视频失败。").await?; let persisted = - put_generated_editor_video(state, owner_user_id, task_id, request, preview_payload).await?; + put_generated_editor_video(state, caller, task_id, request, preview_payload).await?; Ok(GeneratedAnimationPreview { preview_video_path: persisted.legacy_public_path, object_key: Some(persisted.object_key), asset_object_id: Some(persisted.asset_object_id), thumbnail_src: persisted.thumbnail_src, + prepared_asset_objects: persisted.prepared_asset_objects, upstream_task_id, submitted_prompt: request.prompt.clone(), moderation_fallback_applied: false, @@ -2159,7 +2272,7 @@ async fn create_editor_text_to_video_task( async fn put_generated_editor_video( state: &AppState, - owner_user_id: &str, + caller: &EditorGenerationCaller, task_id: &str, request: &NormalizedEditorVideoRequest, preview_payload: MediaPayload, @@ -2178,7 +2291,7 @@ async fn put_generated_editor_video( preview_payload.bytes.clone(), build_asset_metadata( EDITOR_VIDEO_ASSET_KIND, - owner_user_id, + caller.owner_user_id.as_str(), EDITOR_VIDEO_ENTITY_KIND, task_id, EDITOR_VIDEO_SLOT, @@ -2186,24 +2299,52 @@ async fn put_generated_editor_video( ), ) .await?; - let confirmed = confirm_editor_video_asset_object( + let prepared_asset_object = prepare_editor_video_asset_object( state, - owner_user_id, - task_id, + caller, + EDITOR_VIDEO_PRIMARY_RESULT_SLOT, put_result.object_key.clone(), content_type, ) .await?; + let mut prepared_asset_objects = vec![( + EDITOR_VIDEO_PRIMARY_RESULT_SLOT.to_string(), + prepared_asset_object.clone(), + )]; let thumbnail_src = match put_generated_editor_video_cover( state, - owner_user_id, + caller.owner_user_id.as_str(), task_id, request, &preview_payload, ) .await { - Ok(thumbnail_src) => Some(thumbnail_src), + Ok(cover) => match prepare_editor_video_asset_object( + state, + caller, + EDITOR_VIDEO_COVER_RESULT_SLOT, + cover.object_key.clone(), + "image/png".to_string(), + ) + .await + { + Ok(cover_object) => { + prepared_asset_objects + .push((EDITOR_VIDEO_COVER_RESULT_SLOT.to_string(), cover_object)); + Some(cover.legacy_public_path) + } + Err(error) => { + tracing::warn!( + provider = "editor-video", + operation = "prepare_cover_asset_object", + task_id = %task_id, + error = %error, + "画板视频主文件已持久化,但首帧封面资产对象准备失败" + ); + None + } + }, Err(error) => { tracing::warn!( provider = "editor-video", @@ -2218,8 +2359,9 @@ async fn put_generated_editor_video( Ok(PersistedEditorVideo { legacy_public_path: put_result.legacy_public_path, object_key: put_result.object_key, - asset_object_id: confirmed.record.asset_object_id, + asset_object_id: prepared_asset_object.asset_object_id, thumbnail_src, + prepared_asset_objects, }) } @@ -2229,7 +2371,7 @@ async fn put_generated_editor_video_cover( task_id: &str, request: &NormalizedEditorVideoRequest, preview_payload: &MediaPayload, -) -> Result { +) -> Result { let temp_dir = create_animation_temp_dir()?; let input_path = temp_dir.join(format!("preview.{}", preview_payload.extension)); let output_path = temp_dir.join("cover.png"); @@ -2275,7 +2417,7 @@ async fn put_generated_editor_video_cover( ), ) .await?; - Ok(put_result.legacy_public_path) + Ok(put_result) } async fn create_editor_ark_image_to_video_task( @@ -2346,9 +2488,9 @@ async fn create_editor_ark_image_to_video_task( }) } -async fn extract_and_persist_editor_character_animation_frames( +async fn extract_and_prepare_editor_character_animation_frames( state: &AppState, - owner_user_id: &str, + caller: &EditorGenerationCaller, source_layer_id: &str, task_id: &str, preview_video_path: &str, @@ -2380,7 +2522,7 @@ async fn extract_and_persist_editor_character_animation_frames( |(frame_index, frame)| async move { process_and_persist_editor_character_animation_frame( state, - owner_user_id, + caller, source_layer_id, task_id, frame_index, @@ -2428,18 +2570,22 @@ async fn extract_and_persist_editor_character_animation_frames( fn build_persisted_editor_character_animation_frame_set( frame_payloads: Vec, ) -> Result { - let (first_frame_object_key, first_frame_asset_object_id) = frame_payloads + let first_frame_object_key = frame_payloads .first() - .map(|frame| (frame.object_key.clone(), frame.asset_object_id.clone())) + .map(|frame| frame.object_key.clone()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-character-animation", "message": "角色动作抽帧完成但没有生成首帧对象。", })) })?; + let prepared_asset_objects = frame_payloads + .iter() + .map(|frame| (frame.slot.clone(), frame.prepared_asset_object.clone())) + .collect(); Ok(PersistedEditorCharacterAnimationFrameSet { first_frame_object_key, - first_frame_asset_object_id, + prepared_asset_objects, frames: frame_payloads .into_iter() .map(|frame| frame.payload) @@ -2449,7 +2595,7 @@ fn build_persisted_editor_character_animation_frame_set( async fn process_and_persist_editor_character_animation_frame( state: &AppState, - owner_user_id: &str, + caller: &EditorGenerationCaller, source_layer_id: &str, task_id: &str, frame_index: usize, @@ -2479,7 +2625,7 @@ async fn process_and_persist_editor_character_animation_frame( frame.bytes, build_asset_metadata( EDITOR_CHARACTER_ANIMATION_ASSET_KIND, - owner_user_id, + caller.owner_user_id.as_str(), "editor_layer", source_layer_id, EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT, @@ -2527,7 +2673,7 @@ async fn process_and_persist_editor_character_animation_frame( finalized.bytes, build_asset_metadata( EDITOR_CHARACTER_ANIMATION_ASSET_KIND, - owner_user_id, + caller.owner_user_id.as_str(), "editor_layer", source_layer_id, "animation_frame", @@ -2535,11 +2681,12 @@ async fn process_and_persist_editor_character_animation_frame( ), ) .await?; - let confirmed = confirm_editor_character_animation_frame_asset_object( + let slot = editor_character_animation_frame_slot(frame_index); + let prepared_asset_object = prepare_editor_character_animation_frame_asset_object( state, - owner_user_id, + caller, source_layer_id, - task_id, + slot.as_str(), put_result.object_key.clone(), content_type, frame_index + 1, @@ -2551,12 +2698,13 @@ async fn process_and_persist_editor_character_animation_frame( payload: build_editor_character_animation_frame_payload( put_result.legacy_public_path, put_result.object_key.clone(), - confirmed.record.asset_object_id.clone(), + prepared_asset_object.asset_object_id.clone(), frame_width, frame_height, ), object_key: put_result.object_key, - asset_object_id: confirmed.record.asset_object_id, + prepared_asset_object, + slot, }) } @@ -2917,58 +3065,40 @@ async fn confirm_character_animation_asset_object( Ok(module_assets::ConfirmAssetObjectResult { record }) } -async fn confirm_editor_video_asset_object( +async fn prepare_editor_video_asset_object( state: &AppState, - owner_user_id: &str, - task_id: &str, + caller: &EditorGenerationCaller, + slot: &str, object_key: String, content_type: String, -) -> Result { +) -> Result { let oss_client = require_oss_client(state)?; let head = oss_client .head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key }) .await .map_err(map_character_animation_oss_error)?; - let now_micros = current_utc_micros(); - let record = state - .spacetime_client() - .confirm_asset_object( - build_asset_object_upsert_input( - generate_asset_object_id(now_micros), - head.bucket, - head.object_key, - AssetObjectAccessPolicy::Private, - head.content_type.or(Some(content_type)), - head.content_length, - head.etag, - EDITOR_VIDEO_ASSET_KIND.to_string(), - Some(task_id.to_string()), - Some(owner_user_id.to_string()), - None, - Some(task_id.to_string()), - now_micros, - ) - .map_err(map_asset_object_prepare_error)?, - ) - .await - .map_err(map_character_animation_spacetime_error)?; - - Ok(module_assets::ConfirmAssetObjectResult { record }) + prepare_editor_generation_asset_object_from_head( + caller, + slot, + head, + content_type, + EDITOR_VIDEO_ASSET_KIND, + editor_generation_operation(caller)?.operation_id.as_str(), + ) } -async fn confirm_editor_character_animation_source_asset_object( +async fn prepare_editor_character_animation_source_asset_object( state: &AppState, - owner_user_id: &str, + caller: &EditorGenerationCaller, source_layer_id: &str, - task_id: &str, object_key: String, content_type: String, -) -> Result { - confirm_editor_character_animation_asset_object( +) -> Result { + prepare_editor_character_animation_asset_object( state, - owner_user_id, + caller, source_layer_id, - task_id, + EDITOR_CHARACTER_ANIMATION_PREVIEW_RESULT_SLOT, object_key, content_type, EDITOR_CHARACTER_ANIMATION_ASSET_KIND, @@ -2976,15 +3106,15 @@ async fn confirm_editor_character_animation_source_asset_object( .await } -async fn confirm_editor_character_animation_frame_asset_object( +async fn prepare_editor_character_animation_frame_asset_object( state: &AppState, - owner_user_id: &str, + caller: &EditorGenerationCaller, source_layer_id: &str, - task_id: &str, + slot: &str, object_key: String, content_type: String, frame_index: usize, -) -> Result { +) -> Result { let oss_client = require_oss_client(state)?; let head = oss_client .head_object_with_retry( @@ -2998,78 +3128,577 @@ async fn confirm_editor_character_animation_frame_asset_object( ) .await .map_err(map_character_animation_oss_error)?; - confirm_editor_character_animation_asset_object_from_head( - state, - owner_user_id, - source_layer_id, - task_id, + prepare_editor_generation_asset_object_from_head( + caller, + slot, head, content_type, EDITOR_CHARACTER_ANIMATION_ASSET_KIND, + source_layer_id, ) - .await } -async fn confirm_editor_character_animation_asset_object( +async fn prepare_editor_character_animation_asset_object( state: &AppState, - owner_user_id: &str, + caller: &EditorGenerationCaller, source_layer_id: &str, - task_id: &str, + slot: &str, object_key: String, content_type: String, asset_kind: &str, -) -> Result { +) -> Result { let oss_client = require_oss_client(state)?; let head = oss_client .head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key }) .await .map_err(map_character_animation_oss_error)?; - confirm_editor_character_animation_asset_object_from_head( - state, - owner_user_id, - source_layer_id, - task_id, + prepare_editor_generation_asset_object_from_head( + caller, + slot, head, content_type, asset_kind, + source_layer_id, ) - .await } -async fn confirm_editor_character_animation_asset_object_from_head( - state: &AppState, - owner_user_id: &str, - source_layer_id: &str, - task_id: &str, +fn prepare_editor_generation_asset_object_from_head( + caller: &EditorGenerationCaller, + slot: &str, head: platform_oss::OssHeadObjectResponse, content_type: String, asset_kind: &str, -) -> Result { + entity_id: &str, +) -> Result { + let operation = editor_generation_operation(caller)?; let now_micros = current_utc_micros(); - let record = state - .spacetime_client() - .confirm_asset_object( - build_asset_object_upsert_input( - generate_asset_object_id(now_micros), - head.bucket, - head.object_key, - AssetObjectAccessPolicy::Private, - head.content_type.or(Some(content_type)), - head.content_length, - head.etag, - asset_kind.to_string(), - Some(task_id.to_string()), - Some(owner_user_id.to_string()), - None, - Some(source_layer_id.to_string()), - now_micros, - ) - .map_err(map_asset_object_prepare_error)?, - ) - .await - .map_err(map_character_animation_spacetime_error)?; + build_asset_object_upsert_input( + editor_generation_stable_asset_object_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + slot, + ), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(content_type)), + head.content_length, + head.etag, + asset_kind.to_string(), + Some(operation.operation_id.clone()), + Some(caller.owner_user_id.clone()), + None, + Some(entity_id.to_string()), + now_micros, + ) + .map_err(map_asset_object_prepare_error) +} - Ok(module_assets::ConfirmAssetObjectResult { record }) +fn editor_generation_operation( + caller: &EditorGenerationCaller, +) -> Result<&EditorGenerationOperationContext, AppError> { + caller.operation.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "编辑器生成缺少稳定 operation 上下文。", + })) + }) +} + +fn editor_inline_generation_caller( + owner_user_id: String, + request_context: &RequestContext, + operation_kind: &str, + payload: &T, +) -> Result { + let request_payload_json = serde_json::to_string(payload).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": format!("序列化编辑器生成幂等请求失败:{error}"), + })) + })?; + Ok(EditorGenerationCaller { + audit_subject_user_id: Some(owner_user_id.clone()), + audit_project_id: None, + owner_user_id, + phase_reporter: None, + operation: Some(EditorGenerationOperationContext { + operation_kind: operation_kind.to_string(), + operation_id: request_context.request_id().trim().to_string(), + operation_fingerprint: editor_generation_request_fingerprint( + operation_kind, + request_payload_json.as_str(), + ), + worker_id: None, + lease_token: None, + queue_result_context: None, + }), + }) +} + +fn editor_character_animation_frame_slot(frame_index: usize) -> String { + if frame_index == 0 { + EDITOR_CHARACTER_ANIMATION_FINAL_SLOT.to_string() + } else { + format!( + "{EDITOR_CHARACTER_ANIMATION_FRAME_SLOT_PREFIX}{:04}", + frame_index + 1 + ) + } +} + +#[allow(clippy::too_many_arguments)] +fn prepare_editor_character_animation_result( + caller: &EditorGenerationCaller, + project_id: Option<&str>, + asset_folder_id: Option<&str>, + asset_label: &str, + source_resource_id: Option, + generation_inputs: Option, + generated: &GeneratedAnimationPreview, + normalized: &NormalizedEditorCharacterAnimationRequest, + frames: &PersistedEditorCharacterAnimationFrameSet, + frame_payload_json: Value, + completed_at_micros: i64, +) -> Result { + let operation = editor_generation_operation(caller)?; + let project_id = project_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let folder_id = asset_folder_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let preview_resource_id = editor_generation_stable_resource_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + EDITOR_CHARACTER_ANIMATION_PREVIEW_RESULT_SLOT, + ); + let final_resource_id = editor_generation_stable_resource_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + EDITOR_CHARACTER_ANIMATION_FINAL_SLOT, + ); + let preview_object = generated + .prepared_asset_objects + .iter() + .find(|(slot, _)| slot == EDITOR_CHARACTER_ANIMATION_PREVIEW_RESULT_SLOT) + .map(|(_, object)| object.clone()) + .ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作预览视频缺少待提交资产对象。", + })) + })?; + let preview_resource = + project_id + .as_ref() + .map(|project_id| EditorProjectResourceCreateRecordInput { + resource_id: preview_resource_id.clone(), + project_id: project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + asset_object_id: Some(preview_object.asset_object_id.clone()), + image_src: generated.preview_video_path.clone(), + object_key: generated.object_key.clone(), + width: normalized.frame_width, + height: normalized.frame_height, + source_type: "generated".to_string(), + prompt: Some(normalized.prompt.clone()), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: Some(EDITOR_CHARACTER_ANIMATION_MODEL.to_string()), + provider: Some("Ark".to_string()), + task_id: Some(operation.operation_id.clone()), + source_resource_id: source_resource_id.clone(), + asset_kind: Some( + EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND.to_string(), + ), + generation_inputs_json: None, + updated_at_micros: completed_at_micros, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, + }); + let preview_asset = folder_id + .as_ref() + .map(|folder_id| EditorAssetCreateRecordInput { + asset_id: editor_generation_stable_asset_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + EDITOR_CHARACTER_ANIMATION_PREVIEW_RESULT_SLOT, + ), + owner_user_id: caller.owner_user_id.clone(), + folder_id: folder_id.clone(), + label: editor_character_animation_source_asset_label(asset_label), + asset_object_id: Some(preview_object.asset_object_id.clone()), + image_src: generated.preview_video_path.clone(), + object_key: generated.object_key.clone(), + width: normalized.frame_width, + height: normalized.frame_height, + source_type: "generated".to_string(), + prompt: Some(normalized.prompt.clone()), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: Some(EDITOR_CHARACTER_ANIMATION_MODEL.to_string()), + provider: Some("Ark".to_string()), + task_id: Some(operation.operation_id.clone()), + asset_kind: Some(EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND.to_string()), + generation_inputs_json: None, + source_resource_id: preview_resource + .as_ref() + .map(|resource| resource.resource_id.clone()) + .or(source_resource_id), + now_micros: completed_at_micros, + thumbnail_src: None, + generation_cost_mud_points: u64::from(normalized.price_mud_points), + group_task_id: None, + group_task_expected_asset_count: None, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, + }); + let first_frame = frames.frames.first().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作抽帧完成但没有可保存的序列帧。", + })) + })?; + let final_object = frames + .prepared_asset_objects + .iter() + .find(|(slot, _)| slot == EDITOR_CHARACTER_ANIMATION_FINAL_SLOT) + .map(|(_, object)| object.clone()) + .ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作首帧缺少待提交资产对象。", + })) + })?; + let generation_inputs = sanitize_editor_client_generation_inputs(generation_inputs); + let generation_inputs_json = serialize_editor_generation_inputs( + Some(EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND), + generation_inputs, + )?; + let image_sequence_frames_json = + serialize_editor_image_sequence_frames(Some(frame_payload_json))?; + let duration_ms = u64::from(normalized.duration_seconds) * 1_000; + let final_resource = + project_id + .as_ref() + .map(|project_id| EditorProjectResourceCreateRecordInput { + resource_id: final_resource_id.clone(), + project_id: project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + asset_object_id: Some(final_object.asset_object_id.clone()), + image_src: first_frame.image_src.clone(), + object_key: Some(frames.first_frame_object_key.clone()), + width: first_frame.width, + height: first_frame.height, + source_type: "generated".to_string(), + prompt: Some(normalized.prompt.clone()), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: Some(EDITOR_CHARACTER_ANIMATION_MODEL.to_string()), + provider: Some("Ark".to_string()), + task_id: Some(operation.operation_id.clone()), + source_resource_id: Some(preview_resource_id.clone()), + asset_kind: Some(EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string()), + generation_inputs_json: generation_inputs_json.clone(), + updated_at_micros: completed_at_micros, + image_sequence_frames_json: image_sequence_frames_json.clone(), + image_sequence_duration_ms: Some(duration_ms), + }); + let final_asset = folder_id.map(|folder_id| EditorAssetCreateRecordInput { + asset_id: editor_generation_stable_asset_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + EDITOR_CHARACTER_ANIMATION_FINAL_SLOT, + ), + owner_user_id: caller.owner_user_id.clone(), + folder_id, + label: asset_label.to_string(), + asset_object_id: Some(final_object.asset_object_id.clone()), + image_src: first_frame.image_src.clone(), + object_key: Some(frames.first_frame_object_key.clone()), + width: first_frame.width, + height: first_frame.height, + source_type: "generated".to_string(), + prompt: Some(normalized.prompt.clone()), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: Some(EDITOR_CHARACTER_ANIMATION_MODEL.to_string()), + provider: Some("Ark".to_string()), + task_id: Some(operation.operation_id.clone()), + asset_kind: Some(EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string()), + generation_inputs_json, + source_resource_id: final_resource + .as_ref() + .map(|resource| resource.resource_id.clone()), + now_micros: completed_at_micros, + thumbnail_src: Some(first_frame.image_src.clone()), + generation_cost_mud_points: 0, + group_task_id: None, + group_task_expected_asset_count: None, + image_sequence_frames_json, + image_sequence_duration_ms: Some(duration_ms), + }); + + let mut items = Vec::with_capacity(frames.prepared_asset_objects.len() + 1); + items.push(PreparedEditorGenerationResultItem { + slot: EDITOR_CHARACTER_ANIMATION_PREVIEW_RESULT_SLOT.to_string(), + asset_object: Some(preview_object), + project_resource: preview_resource, + asset: preview_asset, + binding: None, + }); + items.push(PreparedEditorGenerationResultItem { + slot: EDITOR_CHARACTER_ANIMATION_FINAL_SLOT.to_string(), + asset_object: Some(final_object), + project_resource: final_resource, + asset: final_asset, + binding: None, + }); + items.extend( + frames + .prepared_asset_objects + .iter() + .filter(|(slot, _)| slot != EDITOR_CHARACTER_ANIMATION_FINAL_SLOT) + .map(|(slot, object)| PreparedEditorGenerationResultItem { + slot: slot.clone(), + asset_object: Some(object.clone()), + project_resource: None, + asset: None, + binding: None, + }), + ); + Ok(PreparedEditorCharacterAnimationResult { + items, + final_resource_id, + }) +} + +#[allow(clippy::too_many_arguments)] +fn prepare_editor_video_result( + caller: &EditorGenerationCaller, + project_id: Option<&str>, + asset_folder_id: Option<&str>, + label: String, + source_resource_id: Option, + asset_kind: String, + generation_inputs: Option, + generated: &GeneratedAnimationPreview, + normalized: &NormalizedEditorVideoRequest, + completed_at_micros: i64, +) -> Result { + let operation = editor_generation_operation(caller)?; + let resource_id = editor_generation_stable_resource_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + EDITOR_VIDEO_PRIMARY_RESULT_SLOT, + ); + let primary_object = generated + .prepared_asset_objects + .iter() + .find(|(slot, _)| slot == EDITOR_VIDEO_PRIMARY_RESULT_SLOT) + .map(|(_, object)| object.clone()) + .ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-video", + "message": "生成视频缺少待提交资产对象。", + })) + })?; + let generation_inputs = sanitize_editor_client_generation_inputs(generation_inputs); + let generation_inputs_json = + serialize_editor_generation_inputs(Some(asset_kind.as_str()), generation_inputs)?; + let project_resource = project_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|project_id| EditorProjectResourceCreateRecordInput { + resource_id: resource_id.clone(), + project_id: project_id.to_string(), + owner_user_id: caller.owner_user_id.clone(), + asset_object_id: Some(primary_object.asset_object_id.clone()), + image_src: generated.preview_video_path.clone(), + object_key: generated.object_key.clone(), + width: normalized.width, + height: normalized.height, + source_type: "generated".to_string(), + prompt: Some(normalized.prompt.clone()), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: Some(normalized.model.clone()), + provider: Some("VectorEngine".to_string()), + task_id: Some(operation.operation_id.clone()), + source_resource_id: source_resource_id.clone(), + asset_kind: Some(asset_kind.clone()), + generation_inputs_json: generation_inputs_json.clone(), + updated_at_micros: completed_at_micros, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, + }); + let asset = asset_folder_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|folder_id| EditorAssetCreateRecordInput { + asset_id: editor_generation_stable_asset_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + EDITOR_VIDEO_PRIMARY_RESULT_SLOT, + ), + owner_user_id: caller.owner_user_id.clone(), + folder_id: folder_id.to_string(), + label, + asset_object_id: Some(primary_object.asset_object_id.clone()), + image_src: generated.preview_video_path.clone(), + object_key: generated.object_key.clone(), + width: normalized.width, + height: normalized.height, + source_type: "generated".to_string(), + prompt: Some(normalized.prompt.clone()), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: Some(normalized.model.clone()), + provider: Some("VectorEngine".to_string()), + task_id: Some(operation.operation_id.clone()), + asset_kind: Some(asset_kind), + generation_inputs_json, + source_resource_id: project_resource + .as_ref() + .map(|resource| resource.resource_id.clone()) + .or(source_resource_id), + now_micros: completed_at_micros, + thumbnail_src: generated.thumbnail_src.clone(), + generation_cost_mud_points: u64::from(normalized.price_mud_points), + group_task_id: None, + group_task_expected_asset_count: None, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, + }); + let mut items = Vec::with_capacity(generated.prepared_asset_objects.len()); + items.push(PreparedEditorGenerationResultItem { + slot: EDITOR_VIDEO_PRIMARY_RESULT_SLOT.to_string(), + asset_object: Some(primary_object), + project_resource, + asset, + binding: None, + }); + items.extend( + generated + .prepared_asset_objects + .iter() + .filter(|(slot, _)| slot != EDITOR_VIDEO_PRIMARY_RESULT_SLOT) + .map(|(slot, object)| PreparedEditorGenerationResultItem { + slot: slot.clone(), + asset_object: Some(object.clone()), + project_resource: None, + asset: None, + binding: None, + }), + ); + Ok(PreparedEditorVideoResult { items, resource_id }) +} + +fn build_editor_character_animation_canvas_placement_item( + completion: &shared_contracts::assets::EditorCanvasGenerationCompletionPayload, + resource_id: &str, + width: u32, + height: u32, + layer_id: &str, +) -> Value { + let original_width = f64::from(width.max(1)); + let original_height = f64::from(height.max(1)); + let display_width = + if completion.placeholder.width.is_finite() && completion.placeholder.width > 0.0 { + completion.placeholder.width.round().max(1.0) + } else { + original_width + }; + let display_height = + if completion.placeholder.height.is_finite() && completion.placeholder.height > 0.0 { + completion.placeholder.height.round().max(1.0) + } else { + original_height + }; + json!({ + "layerId": layer_id, + "resourceId": resource_id, + "title": completion.title.trim(), + "x": completion.placeholder.x, + "y": completion.placeholder.y, + "width": display_width, + "height": display_height, + "originalWidth": original_width, + "originalHeight": original_height, + }) +} + +fn editor_generation_job_result_payload_json( + caller: &EditorGenerationCaller, + compact_result: Value, +) -> Option { + let operation = caller.operation.as_ref()?; + operation.worker_id.as_ref()?; + let warning = compact_result + .get("warning") + .filter(|value| !value.is_null()) + .cloned() + .or_else(|| { + compact_result + .get("sliceWarning") + .filter(|value| !value.is_null()) + .cloned() + }); + let mut payload = json!({ + "sourceModule": "editor-canvas", + "sourceEntityId": caller + .audit_project_id + .clone() + .unwrap_or_else(|| "editor-generation".to_string()), + }); + if let Some(object) = payload.as_object_mut() { + object.insert("result".to_string(), compact_result.clone()); + object.insert("editor-agent-tool-call-result".to_string(), compact_result); + } + if let Some(warning) = warning + && let Some(object) = payload.as_object_mut() + { + object.insert("warning".to_string(), warning); + } + Some(payload.to_string()) +} + +fn compact_editor_generation_candidate_resource( + resource: &EditorProjectResourceCreateRecordInput, +) -> Value { + json!({ + "resourceId": resource.resource_id, + "projectId": resource.project_id, + "objectKey": resource.object_key, + "assetObjectId": resource.asset_object_id, + "imageSrc": resource.image_src, + "width": resource.width, + "height": resource.height, + "sourceType": resource.source_type, + "assetKind": resource.asset_kind, + "taskId": resource.task_id, + "sourceResourceId": resource.source_resource_id, + }) +} + +fn compact_editor_generation_candidate_asset(asset: &EditorAssetCreateRecordInput) -> Value { + json!({ + "assetId": asset.asset_id, + "folderId": asset.folder_id, + "objectKey": asset.object_key, + "assetObjectId": asset.asset_object_id, + "imageSrc": asset.image_src, + "thumbnailSrc": asset.thumbnail_src, + "width": asset.width, + "height": asset.height, + "sourceType": asset.source_type, + "assetKind": asset.asset_kind, + "taskId": asset.task_id, + }) } async fn bind_character_animation_asset( @@ -5983,6 +6612,7 @@ struct GeneratedAnimationPreview { object_key: Option, asset_object_id: Option, thumbnail_src: Option, + prepared_asset_objects: Vec<(String, AssetObjectUpsertInput)>, upstream_task_id: String, submitted_prompt: String, moderation_fallback_applied: bool, @@ -6011,14 +6641,15 @@ struct FinalizedAnimationFrame { struct PersistedEditorCharacterAnimationFrameSet { frames: Vec, first_frame_object_key: String, - first_frame_asset_object_id: String, + prepared_asset_objects: Vec<(String, AssetObjectUpsertInput)>, } struct ProcessedEditorCharacterAnimationFrame { frame_index: usize, payload: EditorCharacterAnimationFramePayload, object_key: String, - asset_object_id: String, + prepared_asset_object: AssetObjectUpsertInput, + slot: String, } // 统一收口动作生成阶段返回的草稿载荷,避免图片序列和视频预览分支在 handler 层分叉太散。 @@ -6050,6 +6681,28 @@ mod tests { use super::*; use crate::AppConfig; + fn editor_character_animation_test_asset_object( + asset_object_id: &str, + object_key: &str, + ) -> AssetObjectUpsertInput { + build_asset_object_upsert_input( + asset_object_id.to_string(), + "test-bucket".to_string(), + object_key.to_string(), + AssetObjectAccessPolicy::Private, + Some("image/png".to_string()), + 16, + Some(format!("etag-{asset_object_id}")), + EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string(), + Some("operation-1".to_string()), + Some("owner-1".to_string()), + None, + Some("layer-1".to_string()), + 1, + ) + .expect("test asset object should be valid") + } + #[test] fn character_animation_server_error_hides_internal_matting_details() { let error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ @@ -6488,10 +7141,11 @@ mod tests { &[ "execute_billable_asset_operation_with_cost", "request_editor_character_animation_preview", - "persist_editor_generated_media_asset", "reporter.report_processing(&state).await?", - "extract_and_persist_editor_character_animation_frames", + "extract_and_prepare_editor_character_animation_frames", "Ok::<_, AppError>((", + "prepare_editor_character_animation_result", + "persist_editor_generation_result_atomically", "final_asset", ], ); @@ -6500,16 +7154,15 @@ mod tests { #[test] fn editor_character_animation_persists_recipe_only_on_final_sequence() { let source = include_str!("character_animation_assets.rs"); - assert_function_contains_in_order( + assert_function_contains( source, - "pub(crate) async fn generate_editor_character_animation_for_owner", - "pub async fn generate_editor_video", + "fn prepare_editor_character_animation_result", + "fn prepare_editor_video_result", &[ "EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND", - "generation_inputs: None", - "let provider_source_resource_id", + "generation_inputs_json: None", "EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND", - "generation_inputs: generation_inputs.clone()", + "generation_inputs_json", ], ); } @@ -6520,7 +7173,7 @@ mod tests { // 动画帧不携带任何自定 timeout:预算全部由共享 helper 按 N/est 公式派生。 assert_function_contains( source, - "async fn extract_and_persist_editor_character_animation_frames", + "async fn extract_and_prepare_editor_character_animation_frames", "async fn process_and_persist_editor_character_animation_frame", &["process_and_persist_editor_character_animation_frame"], ); @@ -6643,7 +7296,7 @@ mod tests { let source = include_str!("character_animation_assets.rs"); assert_function_contains( source, - "async fn extract_and_persist_editor_character_animation_frames", + "async fn extract_and_prepare_editor_character_animation_frames", "async fn publish_animation_set", &[ "apply_chroma_key: false", @@ -6716,36 +7369,35 @@ mod tests { "pub(crate) async fn generate_editor_character_animation_for_owner", "pub async fn generate_editor_video", &[ - "let persisted_frames = extract_and_persist_editor_character_animation_frames", + "let persisted_frames = extract_and_prepare_editor_character_animation_frames", "let frame_payload_json = serde_json::to_value(&frames)", - "let (final_resource, final_asset)", - "project_id: project_id.clone()", - "label: asset_label.clone()", - "EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND", - "generation_cost_mud_points: 0", - "image_sequence_frames: Some(frame_payload_json)", - "image_sequence_duration_ms: Some(", + "prepare_editor_character_animation_result", + "persist_editor_generation_result_atomically", + "EDITOR_CHARACTER_ANIMATION_FINAL_SLOT", "resource: final_resource", "asset: Some(final_asset)", ], ); assert_function_contains( source, - "async fn extract_and_persist_editor_character_animation_frames", + "async fn extract_and_prepare_editor_character_animation_frames", "async fn process_and_persist_editor_character_animation_frame", - &["first_frame_object_key", "first_frame_asset_object_id"], + &["first_frame_object_key", "prepared_asset_objects"], ); assert_function_contains( source, "async fn process_and_persist_editor_character_animation_frame", - "async fn confirm_editor_character_animation_frame_asset_object", - &["confirm_editor_character_animation_frame_asset_object"], + "async fn publish_animation_set", + &["prepare_editor_character_animation_frame_asset_object"], ); assert_function_contains( source, - "async fn confirm_editor_character_animation_frame_asset_object", + "async fn prepare_editor_character_animation_frame_asset_object", "async fn bind_character_animation_asset", - &["EDITOR_CHARACTER_ANIMATION_ASSET_KIND"], + &[ + "EDITOR_CHARACTER_ANIMATION_ASSET_KIND", + "prepare_editor_generation_asset_object_from_head", + ], ); } @@ -6762,7 +7414,11 @@ mod tests { 256, ), object_key: "generated-animations/editor/layer/task/frame01.png".to_string(), - asset_object_id: "asset-object-frame01".to_string(), + prepared_asset_object: editor_character_animation_test_asset_object( + "asset-object-frame01", + "generated-animations/editor/layer/task/frame01.png", + ), + slot: EDITOR_CHARACTER_ANIMATION_FINAL_SLOT.to_string(), }, ProcessedEditorCharacterAnimationFrame { frame_index: 1, @@ -6774,7 +7430,11 @@ mod tests { 256, ), object_key: "generated-animations/editor/layer/task/frame02.png".to_string(), - asset_object_id: "asset-object-frame02".to_string(), + prepared_asset_object: editor_character_animation_test_asset_object( + "asset-object-frame02", + "generated-animations/editor/layer/task/frame02.png", + ), + slot: editor_character_animation_frame_slot(1), }, ]) .expect("generated frame set should retain stable references"); @@ -6793,6 +7453,123 @@ mod tests { assert_eq!(frames[1]["assetObjectId"], json!("asset-object-frame02")); } + #[test] + fn editor_character_animation_frame_slots_keep_first_frame_on_final_slot() { + assert_eq!( + editor_character_animation_frame_slot(0), + EDITOR_CHARACTER_ANIMATION_FINAL_SLOT + ); + assert_eq!( + editor_character_animation_frame_slot(1), + "animation-frame-0002" + ); + assert_eq!( + editor_character_animation_frame_slot(47), + "animation-frame-0048" + ); + } + + #[test] + fn editor_video_job_result_keeps_agent_result_and_warning() { + let caller = EditorGenerationCaller { + owner_user_id: "owner-1".to_string(), + audit_subject_user_id: Some("owner-1".to_string()), + audit_project_id: Some("project-1".to_string()), + phase_reporter: None, + operation: Some(EditorGenerationOperationContext { + operation_kind: EDITOR_VIDEO_GENERATION_JOB_KIND.to_string(), + operation_id: "job-1".to_string(), + operation_fingerprint: "a".repeat(64), + worker_id: Some("worker-1".to_string()), + lease_token: Some("lease-1".to_string()), + queue_result_context: Some( + crate::editor_project::EditorGenerationQueueResultContext::new( + crate::editor_project::EditorGenerationQueueConsumer::EditorAgent, + "editor-canvas", + "project-1", + ), + ), + }), + }; + let compact_result = json!({ + "taskId": "task-1", + "warning": { + "code": "cover-unavailable", + "reason": "封面生成失败,已保留主视频。", + }, + }); + let payload: Value = serde_json::from_str( + editor_generation_job_result_payload_json(&caller, compact_result.clone()) + .expect("queue result payload") + .as_str(), + ) + .expect("queue result should be JSON"); + + assert_eq!(payload["sourceModule"], json!("editor-canvas")); + assert_eq!(payload["sourceEntityId"], json!("project-1")); + assert_eq!(payload["result"], compact_result); + assert_eq!(payload["editor-agent-tool-call-result"], compact_result); + assert_eq!(payload["warning"], compact_result["warning"]); + } + + #[test] + fn character_and_video_queue_results_keep_required_contract_and_asset_reference() { + let asset = EditorAssetCreateRecordInput { + asset_id: "asset-stable-1".to_string(), + owner_user_id: "owner-1".to_string(), + folder_id: "folder-1".to_string(), + label: "动作素材".to_string(), + asset_object_id: Some("asset-object-1".to_string()), + image_src: "/api/assets/object/frame.png".to_string(), + object_key: Some("generated/frame.png".to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: Some("不进入 compact result".to_string()), + actual_prompt: None, + model: Some("internal-model".to_string()), + provider: Some("internal-provider".to_string()), + task_id: Some("task-1".to_string()), + asset_kind: Some("character-animation".to_string()), + generation_inputs_json: None, + source_resource_id: Some("resource-1".to_string()), + now_micros: 1_000, + thumbnail_src: Some("/api/assets/object/thumb.png".to_string()), + generation_cost_mud_points: 2, + group_task_id: None, + group_task_expected_asset_count: None, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, + }; + + let compact = compact_editor_generation_candidate_asset(&asset); + assert_eq!(compact["assetId"], "asset-stable-1"); + assert_eq!(compact["folderId"], "folder-1"); + assert_eq!(compact["assetObjectId"], "asset-object-1"); + assert_eq!(compact["objectKey"], "generated/frame.png"); + assert!(compact.get("ownerUserId").is_none()); + assert!(compact.get("prompt").is_none()); + + let source = include_str!("character_animation_assets.rs"); + for (start, end) in [ + ( + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + ), + ( + "pub(crate) async fn generate_editor_video_for_owner", + "fn editor_video_error_response", + ), + ] { + let body = source + .split_once(start) + .and_then(|(_, tail)| tail.split_once(end).map(|(body, _)| body)) + .expect("generation function"); + assert!(body.contains("\"ok\": true")); + assert!(body.contains("\"asset\": compact_asset")); + } + } + #[test] fn editor_character_animation_canvas_completion_uses_the_final_resource_lineage() { let source = include_str!("character_animation_assets.rs"); @@ -6801,10 +7578,10 @@ mod tests { "pub(crate) async fn generate_editor_character_animation_for_owner", "pub async fn generate_editor_video", &[ - "let provider_source_resource_id = source_record", - "let (final_resource, final_asset)", - "source_resource_id: provider_source_resource_id.clone()", - "build_editor_canvas_resource_placement_item", + "prepare_editor_character_animation_result", + "prepared.final_resource_id.as_str()", + "EditorCanvasLayoutPlan::generation", + "persist_editor_generation_result_atomically", "resource: final_resource", "asset: Some(final_asset)", ], @@ -6826,7 +7603,7 @@ mod tests { } #[test] - fn editor_video_intermediate_outputs_are_registered_before_derivatives() { + fn editor_video_intermediate_outputs_are_prepared_before_atomic_commit() { let source = include_str!("character_animation_assets.rs"); assert_function_contains_in_order( source, @@ -6834,16 +7611,16 @@ mod tests { "pub async fn generate_editor_video", &[ "request_editor_character_animation_preview", - "persist_editor_generated_media_asset", - "extract_and_persist_editor_character_animation_frames", + "extract_and_prepare_editor_character_animation_frames", + "prepare_editor_character_animation_result", + "persist_editor_generation_result_atomically", ], ); assert_function_contains( source, - "pub(crate) async fn generate_editor_character_animation_for_owner", - "pub async fn generate_editor_video", + "fn prepare_editor_character_animation_result", + "fn prepare_editor_video_result", &[ - "or_else(|| Some(\"project\".to_string()))", "EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND", "generation_cost_mud_points: u64::from(normalized.price_mud_points)", ], @@ -6854,7 +7631,7 @@ mod tests { "async fn request_editor_character_animation_preview", &[ "put_character_animation_object", - "confirm_editor_character_animation_source_asset_object", + "prepare_editor_character_animation_source_asset_object", ], ); assert_function_contains_in_order( @@ -6863,9 +7640,9 @@ mod tests { "async fn put_generated_editor_video_cover", &[ "put_character_animation_object", - "confirm_editor_video_asset_object", + "prepare_editor_video_asset_object", "match put_generated_editor_video_cover", - "None", + "prepared_asset_objects", ], ); } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 772daebd3..86a7b8f8f 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -36,11 +36,18 @@ use shared_contracts::assets::{ EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest, EditorCanvasGenerationPlaceholderPayload, }; +use shared_contracts::editor_generation::editor_generation_request_fingerprint; +use shared_contracts::editor_generation::{ + editor_generation_stable_asset_id, editor_generation_stable_asset_object_id, + editor_generation_stable_resource_id, +}; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::editor_project::{ - EditorPixelArtCanvasCompletionRecordInput, EditorPixelArtCanvasPlaceholderRecordInput, - EditorPixelArtResultPersistRecordInput, EditorPixelArtResultPreflightRecordInput, - EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput, + EditorGenerationResultPersistItemRecordInput, EditorGenerationResultPersistRecord, + EditorGenerationResultPersistRecordInput, EditorPixelArtCanvasCompletionRecordInput, + EditorPixelArtCanvasPlaceholderRecordInput, EditorPixelArtResultPersistRecordInput, + EditorPixelArtResultPreflightRecordInput, EditorSpritesheetSliceBatchPersistRecordInput, + EditorSpritesheetSlicePersistItemRecordInput, }; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, @@ -55,13 +62,19 @@ use spacetime_client::{ EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, - ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput, - ExternalGenerationJobRecord, SpacetimeClientError, + ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobPhaseUpdateError, + ExternalGenerationJobPhaseUpdateRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, + SpacetimeClientStage, }; use crate::{ api_response::json_success_body, - asset_billing::execute_billable_asset_operation_with_cost, + asset_billing::{ + begin_editor_generation_commit_attempt, editor_generation_commit_attempt_was_dispatched, + execute_billable_asset_operation_with_cost, mark_editor_generation_commit_dispatched, + mark_editor_generation_commit_outcome_confirmed, + with_editor_generation_durable_billing_boundary, + }, auth::AuthenticatedAccessToken, editor_generation_queue::{ EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, @@ -101,6 +114,13 @@ use crate::{ }; const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36; +const MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES: usize = 512 * 1024; +const MAX_EDITOR_GENERATION_WARNING_CHARS: usize = 2_048; +const EDITOR_GENERATION_SLICE_WARNING_PREFIX: &str = "图集已生成,但自动拆分未完成:"; +const EDITOR_GENERATION_WARNING_REDACTED_MESSAGE: &str = + "自动拆分未完成(告警详情含内联媒体引用,已省略)"; +const ATOMIC_EDITOR_GENERATION_RAW_RESULT_FIELD: &str = "atomic-editor-generation-result"; +const MAX_EDITOR_GENERATION_UNKNOWN_RESULT_REPLAYS: usize = 2; pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "proj-"; pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-"; @@ -454,11 +474,77 @@ pub struct EditorUiDesignAssetExtractionRequest { pub(crate) canvas_completion: Option, } +#[derive(Clone, Debug)] +pub(crate) struct EditorGenerationOperationContext { + pub(crate) operation_kind: String, + pub(crate) operation_id: String, + pub(crate) operation_fingerprint: String, + pub(crate) worker_id: Option, + pub(crate) lease_token: Option, + pub(crate) queue_result_context: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EditorGenerationQueueConsumer { + Standard, + EditorAgent, + ExternalApi, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EditorGenerationQueueResultContext { + consumer: EditorGenerationQueueConsumer, + source_module: String, + source_entity_id: String, +} + +impl EditorGenerationQueueResultContext { + pub(crate) fn new( + consumer: EditorGenerationQueueConsumer, + source_module: impl Into, + source_entity_id: impl Into, + ) -> Self { + Self { + consumer, + source_module: source_module.into(), + source_entity_id: source_entity_id.into(), + } + } + + pub(crate) fn from_job(job: &ExternalGenerationJobRecord) -> Self { + let consumer = if job + .dedupe_key + .trim() + .starts_with("external-api-generation:") + { + EditorGenerationQueueConsumer::ExternalApi + } else if serde_json::from_str::(job.request_payload_json.as_str()) + .ok() + .is_some_and(|payload| { + payload + .pointer("/generationInputs/source") + .and_then(Value::as_str) + .is_some_and(|source| source.trim() == "editor-agent") + }) + { + EditorGenerationQueueConsumer::EditorAgent + } else { + EditorGenerationQueueConsumer::Standard + }; + Self::new( + consumer, + job.source_module.clone(), + job.source_entity_id.clone(), + ) + } +} + pub(crate) struct EditorGenerationCaller { pub(crate) owner_user_id: String, pub(crate) audit_subject_user_id: Option, pub(crate) audit_project_id: Option, pub(crate) phase_reporter: Option, + pub(crate) operation: Option, } impl EditorGenerationCaller { @@ -469,9 +555,36 @@ impl EditorGenerationCaller { owner_user_id, audit_project_id: None, phase_reporter: None, + operation: None, } } + fn with_inline_operation( + mut self, + request_context: &RequestContext, + operation_kind: &str, + payload: &T, + ) -> Result { + let request_payload_json = serde_json::to_string(payload).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": format!("序列化编辑器生成幂等请求失败:{error}"), + })) + })?; + self.operation = Some(EditorGenerationOperationContext { + operation_kind: operation_kind.to_string(), + operation_id: request_context.request_id().trim().to_string(), + operation_fingerprint: editor_generation_request_fingerprint( + operation_kind, + request_payload_json.as_str(), + ), + worker_id: None, + lease_token: None, + queue_result_context: None, + }); + Ok(self) + } + async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> { if let Some(reporter) = self.phase_reporter.as_ref() { reporter.report_processing(state).await?; @@ -653,6 +766,662 @@ pub(crate) struct EditorGeneratedAssetRecord { pub(crate) asset: Option, } +#[derive(Debug)] +pub(crate) struct PreparedEditorGeneratedAssetRecord { + pub(crate) item: PreparedEditorGenerationResultItem, + pub(crate) record: EditorGeneratedAssetRecord, +} + +pub(crate) type PreparedEditorGenerationResultItem = EditorGenerationResultPersistItemRecordInput; + +#[derive(Clone, Debug)] +pub(crate) enum EditorCanvasLayoutPlan { + None, + Generation { + owner_user_id: String, + project_id: Option, + completion: Option, + items: Vec, + primary_layer_id: Option, + }, + BackgroundRemoval { + owner_user_id: String, + project_id: Option, + target_layer_id: Option, + resource: Option, + replacement_title: Option, + recenter_replacement: bool, + }, +} + +impl EditorCanvasLayoutPlan { + pub(crate) fn generation( + owner_user_id: &str, + project_id: Option<&str>, + completion: Option<&EditorCanvasGenerationCompletionRequest>, + items: Vec, + primary_layer_id: Option, + ) -> Self { + Self::Generation { + owner_user_id: owner_user_id.to_string(), + project_id: project_id.map(str::to_string), + completion: completion.cloned(), + items, + primary_layer_id, + } + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn background_removal( + owner_user_id: &str, + project_id: Option<&str>, + target_layer_id: Option<&str>, + resource: Option<&EditorProjectResourcePayload>, + replacement_title: Option<&str>, + recenter_replacement: bool, + ) -> Self { + Self::BackgroundRemoval { + owner_user_id: owner_user_id.to_string(), + project_id: project_id.map(str::to_string), + target_layer_id: target_layer_id.map(str::to_string), + resource: resource.cloned(), + replacement_title: replacement_title.map(str::to_string), + recenter_replacement, + } + } + + async fn prepare( + &self, + state: &AppState, + ) -> Result, AppError> { + let layout = match self { + Self::None => None, + Self::Generation { + owner_user_id, + project_id, + completion, + items, + primary_layer_id, + } => { + prepare_editor_canvas_generation_layout( + state, + owner_user_id, + project_id.as_deref(), + completion.as_ref(), + items.clone(), + primary_layer_id.clone(), + ) + .await? + } + Self::BackgroundRemoval { + owner_user_id, + project_id, + target_layer_id, + resource, + replacement_title, + recenter_replacement, + } => { + prepare_editor_canvas_background_removal_layout( + state, + owner_user_id, + project_id.as_deref(), + target_layer_id.as_deref(), + resource.as_ref(), + replacement_title.as_deref(), + *recenter_replacement, + ) + .await? + } + }; + Ok(layout) + } +} + +pub(crate) async fn persist_editor_generation_result_atomically( + state: &AppState, + caller: &EditorGenerationCaller, + items: Vec, + canvas_layout_plan: EditorCanvasLayoutPlan, + job_result_payload_json: Option, + completed_at_micros: i64, +) -> Result { + let operation = caller.operation.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "编辑器生成缺少稳定 operation 上下文。", + })) + })?; + let job_completion = match ( + &operation.worker_id, + &operation.lease_token, + &operation.queue_result_context, + ) { + (Some(worker_id), Some(lease_token), Some(queue_result_context)) => { + let result_payload_json = job_result_payload_json + .map(|payload| { + compact_atomic_editor_generation_job_result(queue_result_context, payload) + }) + .transpose()?; + Some(ExternalGenerationJobCompleteRecordInput { + job_id: operation.operation_id.clone(), + worker_id: worker_id.clone(), + lease_token: lease_token.clone(), + result_payload_json, + completed_at_micros, + }) + } + (None, None, None) => None, + _ => { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "编辑器生成 queue lease guard 不完整。", + })), + ); + } + }; + let canvas_layout = canvas_layout_plan.prepare(state).await?; + let has_canvas_layout = canvas_layout.is_some(); + let prepared_commit = EditorGenerationResultPersistRecordInput { + owner_user_id: caller.owner_user_id.clone(), + operation_kind: operation.operation_kind.clone(), + operation_id: operation.operation_id.clone(), + operation_fingerprint: operation.operation_fingerprint.clone(), + items, + canvas_layout, + job_completion, + completed_at_micros, + }; + let first_attempt = persist_editor_generation_prepared_commit(state, &prepared_commit).await; + match first_attempt { + Ok(persisted) => Ok(persisted), + Err(error) if has_canvas_layout && is_editor_canvas_revision_conflict(&error) => { + let refreshed_layout = canvas_layout_plan.prepare(state).await?; + let refreshed_prepared_commit = EditorGenerationResultPersistRecordInput { + canvas_layout: refreshed_layout, + ..prepared_commit + }; + persist_editor_generation_prepared_commit(state, &refreshed_prepared_commit).await + } + Err(error) => Err(error), + } +} + +async fn persist_editor_generation_prepared_commit( + state: &AppState, + prepared_commit: &EditorGenerationResultPersistRecordInput, +) -> Result { + call_with_editor_generation_unknown_result_replay(|| { + state + .spacetime_client() + .persist_editor_generation_result_with_dispatch( + prepared_commit.clone(), + mark_editor_generation_commit_dispatched, + ) + }) + .await + .map_err(map_editor_project_error) +} + +async fn call_with_editor_generation_unknown_result_replay( + mut call: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for replay in 0..=MAX_EDITOR_GENERATION_UNKNOWN_RESULT_REPLAYS { + begin_editor_generation_commit_attempt(); + match call().await { + Ok(value) => { + mark_editor_generation_commit_outcome_confirmed(); + return Ok(value); + } + Err(error) + if replay < MAX_EDITOR_GENERATION_UNKNOWN_RESULT_REPLAYS + && is_editor_generation_retryable_transport_error(&error) => + { + if editor_generation_commit_attempt_was_dispatched() + && !is_editor_generation_unknown_result_error(&error, true) + { + mark_editor_generation_commit_outcome_confirmed(); + } + continue; + } + Err(error) => { + if editor_generation_commit_attempt_was_dispatched() + && !is_editor_generation_unknown_result_error(&error, true) + { + mark_editor_generation_commit_outcome_confirmed(); + } + return Err(error); + } + } + } + unreachable!("editor generation unknown-result replay loop must return") +} + +fn is_editor_generation_retryable_transport_error(error: &SpacetimeClientError) -> bool { + match error { + SpacetimeClientError::Build(_) | SpacetimeClientError::ConnectDropped => true, + SpacetimeClientError::Timeout(_) => true, + SpacetimeClientError::Runtime(message) => { + message.contains("503") + || message.contains("Service Unavailable") + || message.contains("Failed to connect") + || message.contains("WebSocket") + || message.contains("连接已断开") + || message.contains("连接在返回结果前已断开") + } + SpacetimeClientError::Procedure(_) => false, + } +} + +fn is_editor_generation_unknown_result_error( + error: &SpacetimeClientError, + dispatched: bool, +) -> bool { + dispatched + && !matches!( + error, + SpacetimeClientError::Procedure(_) + | SpacetimeClientError::Timeout( + SpacetimeClientStage::PoolAcquire + | SpacetimeClientStage::ConnectBuild + | SpacetimeClientStage::ConnectHandshake + ) + | SpacetimeClientError::Build(_) + ) +} + +fn is_editor_canvas_revision_conflict(error: &AppError) -> bool { + error.status_code() == StatusCode::CONFLICT + && error + .details() + .and_then(|details| details.get("provider")) + .and_then(Value::as_str) + == Some("editor-project") + && error + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and(|message| message.starts_with("图片画布版本冲突:")) +} + +fn editor_generation_job_result_payload_json( + caller: &EditorGenerationCaller, + compact_result: Value, +) -> Option { + let operation = caller.operation.as_ref()?; + operation.worker_id.as_ref()?; + Some(json!({ ATOMIC_EDITOR_GENERATION_RAW_RESULT_FIELD: compact_result }).to_string()) +} + +fn compact_atomic_editor_generation_job_result( + queue_result_context: &EditorGenerationQueueResultContext, + raw_payload_json: String, +) -> Result { + let raw_payload = + serde_json::from_str::(raw_payload_json.as_str()).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": format!("解析编辑器生成 queue 结果失败:{error}"), + })) + })?; + let response = raw_payload + .get(ATOMIC_EDITOR_GENERATION_RAW_RESULT_FIELD) + .or_else(|| raw_payload.get("result")) + .or_else(|| raw_payload.get("editor-agent-tool-call-result")) + .cloned() + .unwrap_or(raw_payload); + serialize_atomic_editor_generation_job_result(queue_result_context, &response) +} + +fn serialize_atomic_editor_generation_job_result( + queue_result_context: &EditorGenerationQueueResultContext, + response: &Value, +) -> Result { + let mut payload = json!({ + "sourceModule": queue_result_context.source_module.clone(), + "sourceEntityId": queue_result_context.source_entity_id.clone(), + }); + if queue_result_context.consumer == EditorGenerationQueueConsumer::EditorAgent + && let Some(object) = payload.as_object_mut() + { + object.insert( + "editor-agent-tool-call-result".to_string(), + compact_editor_generation_result(response.clone()), + ); + } + if queue_result_context.consumer == EditorGenerationQueueConsumer::ExternalApi + && let Some(object) = payload.as_object_mut() + { + object.insert( + "result".to_string(), + compact_external_api_generation_result(response.clone()), + ); + } + if let Some(warning) = extract_editor_generation_warning(response) + && let Some(object) = payload.as_object_mut() + { + object.insert("warning".to_string(), warning); + } + let payload_json = serde_json::to_string(&payload).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": format!("序列化编辑器生成 queue 结果失败:{error}"), + })) + })?; + if payload_json.len() > MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "编辑器生成 queue 紧凑结果仍超过持久化上限。", + "actualBytes": payload_json.len(), + "maxBytes": MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES, + })), + ); + } + Ok(payload_json) +} + +fn compact_editor_generation_result(mut result: Value) -> Value { + let Some(object) = result.as_object_mut() else { + return result; + }; + object.remove("provider"); + if object + .get("model") + .and_then(Value::as_str) + .is_some_and(is_editor_internal_processing_model) + { + object.remove("model"); + } + if let Some(generation_inputs) = object + .get_mut("generationInputs") + .and_then(Value::as_object_mut) + { + for field in ["screenColorHex", "mattingProvider", "mattingModel"] { + generation_inputs.remove(field); + } + } + object.remove("project"); + object.remove("asset"); + object.remove("spritesheetAsset"); + for field in ["resource", "spritesheetResource"] { + let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else { + continue; + }; + resource.retain(|key, _| { + matches!( + key.as_str(), + "resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId" + ) + }); + } + if let Some(icon_image_srcs) = object + .get_mut("iconImageSrcs") + .and_then(Value::as_array_mut) + { + for icon in icon_image_srcs { + let Some(icon) = icon.as_object_mut() else { + continue; + }; + if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) { + resource.retain(|key, _| { + matches!( + key.as_str(), + "resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId" + ) + }); + } + icon.retain(|key, _| { + matches!( + key.as_str(), + "name" | "imageSrc" | "width" | "height" | "resource" + ) + }); + } + } + result +} + +fn compact_external_api_generation_result(result: Value) -> Value { + let mut result = result.get("data").cloned().unwrap_or(result); + let Some(object) = result.as_object_mut() else { + return Value::Null; + }; + object.retain(|key, _| { + matches!( + key.as_str(), + "ok" | "imageSrc" + | "videoSrc" + | "audioSrc" + | "previewVideoPath" + | "thumbnailSrc" + | "objectKey" + | "assetObjectId" + | "width" + | "height" + | "sourceType" + | "prompt" + | "actualPrompt" + | "model" + | "taskId" + | "durationSeconds" + | "resolution" + | "priceMudPoints" + | "audioKind" + | "spritesheetImageSrc" + | "spritesheetWidth" + | "spritesheetHeight" + | "iconImageSrcs" + | "frames" + | "frameCount" + | "frameWidth" + | "frameHeight" + | "fps" + | "resource" + | "asset" + | "spritesheetResource" + | "spritesheetAsset" + | "warning" + | "sliceWarning" + ) + }); + for field in ["resource", "spritesheetResource"] { + if let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) { + compact_external_generation_resource(resource); + } + } + for field in ["asset", "spritesheetAsset"] { + if let Some(asset) = object.get_mut(field).and_then(Value::as_object_mut) { + compact_external_generation_asset(asset); + } + } + if let Some(icons) = object + .get_mut("iconImageSrcs") + .and_then(Value::as_array_mut) + { + for icon in icons { + let Some(icon) = icon.as_object_mut() else { + continue; + }; + icon.retain(|key, _| { + matches!( + key.as_str(), + "name" | "imageSrc" | "objectKey" | "width" | "height" | "resource" | "asset" + ) + }); + if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) { + compact_external_generation_resource(resource); + } + if let Some(asset) = icon.get_mut("asset").and_then(Value::as_object_mut) { + compact_external_generation_asset(asset); + } + remove_unstable_external_generation_media_fields(icon); + } + } + if let Some(frames) = object.get_mut("frames").and_then(Value::as_array_mut) { + for frame in frames { + let Some(frame) = frame.as_object_mut() else { + continue; + }; + frame.retain(|key, _| { + matches!( + key.as_str(), + "frameIndex" | "imageSrc" | "objectKey" | "assetObjectId" | "width" | "height" + ) + }); + remove_unstable_external_generation_media_fields(frame); + } + } + for field in ["warning", "sliceWarning"] { + if let Some(warning) = object.get_mut(field).and_then(Value::as_object_mut) + && let Some(reason) = warning.get_mut("reason") + && let Some(value) = reason.as_str() + { + *reason = Value::String(normalize_editor_generation_warning_reason(value)); + } + } + remove_unstable_external_generation_media_fields(object); + result +} + +fn compact_external_generation_resource(resource: &mut serde_json::Map) { + resource.retain(|key, _| { + matches!( + key.as_str(), + "resourceId" + | "projectId" + | "objectKey" + | "assetObjectId" + | "imageSrc" + | "width" + | "height" + | "sourceType" + | "assetKind" + | "taskId" + | "sourceResourceId" + ) + }); + remove_unstable_external_generation_media_fields(resource); +} + +fn compact_external_generation_asset(asset: &mut serde_json::Map) { + asset.retain(|key, _| { + matches!( + key.as_str(), + "assetId" + | "folderId" + | "objectKey" + | "assetObjectId" + | "imageSrc" + | "thumbnailSrc" + | "width" + | "height" + | "sourceType" + | "assetKind" + | "taskId" + ) + }); + remove_unstable_external_generation_media_fields(asset); +} + +fn remove_unstable_external_generation_media_fields(object: &mut serde_json::Map) { + object.retain(|key, value| { + if !matches!( + key.as_str(), + "imageSrc" + | "videoSrc" + | "audioSrc" + | "previewVideoPath" + | "thumbnailSrc" + | "spritesheetImageSrc" + ) { + return true; + } + value + .as_str() + .is_some_and(is_stable_external_generation_media_reference) + }); +} + +fn is_stable_external_generation_media_reference(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() + && value.starts_with('/') + && !value.starts_with("//") + && !value.contains('?') + && !value.contains('#') + && !value.to_ascii_lowercase().starts_with("data:") + && !value.to_ascii_lowercase().starts_with("blob:") + && !value.to_ascii_lowercase().starts_with("http://") + && !value.to_ascii_lowercase().starts_with("https://") +} + +fn extract_editor_generation_warning_fields( + warning: Option<&Value>, + is_slice_warning: bool, +) -> Option<(String, String)> { + let warning = warning?; + let code = warning.get("code")?.as_str()?.trim(); + let reason = warning.get("reason")?.as_str()?.trim(); + if code.is_empty() || reason.is_empty() { + return None; + } + let reason = if is_slice_warning { + format!("{EDITOR_GENERATION_SLICE_WARNING_PREFIX}{reason}") + } else { + reason.to_string() + }; + Some((code.to_string(), reason)) +} + +fn extract_editor_generation_warning(response: &Value) -> Option { + let data = response.get("data").unwrap_or(response); + let common = extract_editor_generation_warning_fields(data.get("warning"), false); + let slice = extract_editor_generation_warning_fields(data.get("sliceWarning"), true); + let (code, reason) = match (common, slice) { + (None, None) => return None, + (Some(warning), None) | (None, Some(warning)) => warning, + (Some((common_code, common_reason)), Some((slice_code, slice_reason))) => { + let code = if common_code == slice_code { + common_code + } else { + EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE.to_string() + }; + (code, format!("{common_reason} {slice_reason}")) + } + }; + Some(json!({ + "code": code, + "reason": normalize_editor_generation_warning_reason(reason.as_str()), + })) +} + +fn normalize_editor_generation_warning_reason(reason: &str) -> String { + let normalized = reason.to_ascii_lowercase(); + if normalized.contains("data:") + || normalized.contains("blob:") + || normalized.contains("http://") + || normalized.contains("https://") + || normalized.contains("x-amz-") + || normalized.contains("signature=") + { + return EDITOR_GENERATION_WARNING_REDACTED_MESSAGE.to_string(); + } + let mut chars = reason.chars(); + let mut bounded = chars + .by_ref() + .take(MAX_EDITOR_GENERATION_WARNING_CHARS) + .collect::(); + if chars.next().is_some() { + bounded.push('…'); + } + bounded +} + struct EditorCanvasGenerationCompletionResult { layers: Value, changed: bool, @@ -746,7 +1515,7 @@ pub(crate) struct EditorCanvasGeneratedLayerInput { pub(crate) generation_inputs: Option, } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorGenerationWarningResponse { code: &'static str, @@ -979,7 +1748,7 @@ pub struct EditorCanvasPayload { updated_at: String, } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResourcePayload { pub(crate) resource_id: String, @@ -1721,7 +2490,18 @@ pub async fn generate_editor_image( }, )); } - generate_editor_image_for_owner(&state, &request_context, caller, payload).await + let caller = caller.with_inline_operation( + &request_context, + EDITOR_IMAGE_GENERATION_JOB_KIND, + &payload, + )?; + with_editor_generation_durable_billing_boundary(generate_editor_image_for_owner( + &state, + &request_context, + caller, + payload, + )) + .await } pub(crate) async fn enqueue_editor_image_generation_for_owner( @@ -2124,26 +2904,29 @@ pub(crate) async fn generate_editor_image_for_owner( let mut output_provider = "VectorEngine".to_string(); let mut output_generation_inputs = generation_inputs.clone(); let source_record = if is_character_generation { - let source_persisted = persist_editor_provider_source_image( + let source_persisted = prepare_editor_generated_image( state, - caller.owner_user_id.as_str(), + &caller, generated.task_id.as_str(), - image, + &image, role_setting.as_str(), generated.actual_prompt.as_deref(), storage_profile.asset_kind, "character-images", - "character-image", + "character-image-provider-source", + EDITOR_PROVIDER_SOURCE_SLOT, + "vector-engine", ) .await?; let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); let source_object_key = source_persisted.object_key.clone(); - let source_asset_object_id = source_persisted.asset_object_id.clone(); - let source_record = persist_editor_provider_source_resource( - state, - source_persisted, - PersistEditorProviderSourceResourceInput { + let source_asset_object_id = source_persisted.asset_object_id().to_string(); + let source_record = prepare_editor_generated_asset( + &caller, + EDITOR_PROVIDER_SOURCE_SLOT, + Some(source_persisted.asset_object), + PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload @@ -2164,9 +2947,18 @@ pub(crate) async fn generate_editor_image_for_owner( .or_else(|| Some("character".to_string())), generation_inputs: generation_inputs.clone(), generation_cost_mud_points: u64::from(configured_price_mud_points), + thumbnail_src: None, + group_task_id: None, + group_task_expected_asset_count: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, + image_src: source_image_src.clone(), + object_key: Some(source_object_key.clone()), + asset_object_id: Some(source_asset_object_id.clone()), + provider: "VectorEngine".to_string(), }, - ) - .await?; + current_utc_micros(), + )?; caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), @@ -2197,16 +2989,29 @@ pub(crate) async fn generate_editor_image_for_owner( error = %failure_message, "角色原图已保存,但透明背景处理失败,使用原图完成画布" ); - let (completed_project, fallback_warning) = complete_editor_source_only_fallback( - state, - caller.owner_user_id.as_str(), - payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - &source_record, - generation_warning, - dimension_warning.as_ref(), - ) - .await?; + let (source_record, completed_project, fallback_warning) = + persist_editor_source_only_fallback_atomically( + state, + &caller, + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + source_record, + generated.task_id.as_str(), + source_image_src.as_str(), + source_object_key.as_str(), + source_asset_object_id.as_str(), + EditorSourceOnlyFallbackResultContext { + width: delivery_width, + height: delivery_height, + prompt: role_setting.as_str(), + actual_prompt: generated.actual_prompt.as_deref(), + model: generation_options.model, + price_mud_points: 0, + }, + generation_warning, + dimension_warning.as_ref(), + ) + .await?; return Ok(json_success_body( Some(&request_context), EditorImageGenerationResponse { @@ -2266,16 +3071,29 @@ pub(crate) async fn generate_editor_image_for_owner( error = ?error, "角色透明图尺寸恢复失败,改用已保存的同尺寸原图完成画布" ); - let (completed_project, fallback_warning) = complete_editor_source_only_fallback( - state, - caller.owner_user_id.as_str(), - payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - &source_record, - generation_warning, - dimension_warning.as_ref(), - ) - .await?; + let (source_record, completed_project, fallback_warning) = + persist_editor_source_only_fallback_atomically( + state, + &caller, + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + source_record, + generated.task_id.as_str(), + source_image_src.as_str(), + source_object_key.as_str(), + source_asset_object_id.as_str(), + EditorSourceOnlyFallbackResultContext { + width: delivery_width, + height: delivery_height, + prompt: role_setting.as_str(), + actual_prompt: generated.actual_prompt.as_deref(), + model: generation_options.model, + price_mud_points: 0, + }, + generation_warning, + dimension_warning.as_ref(), + ) + .await?; return Ok(json_success_body( Some(&request_context), EditorImageGenerationResponse { @@ -2315,7 +3133,7 @@ pub(crate) async fn generate_editor_image_for_owner( output_actual_prompt = None; output_provider = removal_provider.to_string(); output_generation_inputs = apply_editor_matting_metadata_to_generation_inputs( - build_editor_derived_asset_generation_inputs("角色抠图", &source_record), + build_editor_derived_asset_generation_inputs("角色抠图", &source_record.record), removal_provider, removal_model.as_str(), ); @@ -2347,9 +3165,9 @@ pub(crate) async fn generate_editor_image_for_owner( let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); - let persisted = persist_editor_generated_image( + let prepared_image = prepare_editor_generated_image( state, - caller.owner_user_id.as_str(), + &caller, generated.task_id.as_str(), &image, output_prompt.as_str(), @@ -2361,21 +3179,24 @@ pub(crate) async fn generate_editor_image_for_owner( output_provider.as_str(), ) .await?; - let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); + let image_src = editor_media_src_from_object_key(prepared_image.object_key.as_str()); let provider_source_resource_id = source_record .as_ref() - .and_then(|record| record.resource.as_ref()) + .and_then(|prepared| prepared.record.resource.as_ref()) .map(|resource| resource.resource_id.clone()); - let generated_asset = persist_editor_generated_asset( - state, + let completed_at_micros = current_utc_micros(); + let prepared_asset = prepare_editor_generated_asset( + &caller, + storage_profile.slot, + Some(prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, label: asset_label, image_src: image_src.clone(), - object_key: Some(persisted.object_key.clone()), - asset_object_id: Some(persisted.asset_object_id.clone()), + object_key: Some(prepared_image.object_key.clone()), + asset_object_id: Some(prepared_image.asset_object_id().to_string()), width, height, prompt: output_prompt, @@ -2397,36 +3218,86 @@ pub(crate) async fn generate_editor_image_for_owner( image_sequence_frames: None, image_sequence_duration_ms: None, }, - ) - .await?; + completed_at_micros, + )?; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_multi_asset_canvas_layer_items( completion, - generated_asset.resource.as_ref(), + prepared_asset.record.resource.as_ref(), source_record .as_ref() - .and_then(|record| record.resource.as_ref()), + .and_then(|prepared| prepared.record.resource.as_ref()), ) } else { (Vec::new(), None) }; - let completed_project = complete_editor_canvas_generation_with_items( - state, + let canvas_layout_plan = EditorCanvasLayoutPlan::generation( caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), canvas_items, primary_layer_id, + ); + let final_warning = merge_editor_generation_warnings(generation_warning, dimension_warning); + let job_result_payload_json = editor_generation_job_result_payload_json( + &caller, + json!({ + "imageSrc": image_src.clone(), + "objectKey": prepared_image.object_key.clone(), + "assetObjectId": prepared_image.asset_object_id(), + "width": width, + "height": height, + "sourceType": "generated", + "prompt": role_setting.clone(), + "actualPrompt": generated.actual_prompt.clone(), + "model": generation_options.model, + "taskId": generated.task_id.clone(), + "resource": &prepared_asset.record.resource, + "asset": &prepared_asset.record.asset, + "warning": final_warning.clone(), + }), + ); + let mut items = source_record + .into_iter() + .map(|prepared| prepared.item) + .collect::>(); + items.push(prepared_asset.item); + let persisted_result = persist_editor_generation_result_atomically( + state, + &caller, + items, + canvas_layout_plan, + job_result_payload_json, + completed_at_micros, ) .await?; + let persisted_item = persisted_result + .items + .into_iter() + .find(|item| item.slot == storage_profile.slot) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-generation-operation", + "message": "图片生成原子提交缺少主结果。", + })) + })?; + let generated_asset = EditorGeneratedAssetRecord { + resource: persisted_item + .project_resource + .map(editor_project_resource_payload_from_record), + asset: persisted_item.asset.map(editor_asset_payload_from_record), + }; + let completed_project = persisted_result + .project + .map(editor_project_payload_from_record); Ok(json_success_body( Some(&request_context), EditorImageGenerationResponse { image_src, - object_key: Some(persisted.object_key), - asset_object_id: Some(persisted.asset_object_id), + object_key: Some(prepared_image.object_key.clone()), + asset_object_id: Some(prepared_image.asset_object_id().to_string()), width, height, source_type: "generated", @@ -2438,7 +3309,7 @@ pub(crate) async fn generate_editor_image_for_owner( asset: generated_asset.asset, project: completed_project, // 中文注释:成功路径同时收口尺寸归一降级与像素规整等生成告警。 - warning: merge_editor_generation_warnings(generation_warning, dimension_warning), + warning: final_warning, }, )) } @@ -4112,7 +4983,15 @@ pub async fn edit_editor_image( }, )); } - edit_editor_image_for_owner(&state, &request_context, caller, payload).await + let caller = + caller.with_inline_operation(&request_context, EDITOR_IMAGE_EDIT_JOB_KIND, &payload)?; + with_editor_generation_durable_billing_boundary(edit_editor_image_for_owner( + &state, + &request_context, + caller, + payload, + )) + .await } pub(crate) async fn enqueue_editor_image_edit_for_owner( @@ -4384,9 +5263,9 @@ pub(crate) async fn edit_editor_image_for_owner( .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); let owner_user_id = caller.owner_user_id.clone(); - let persisted = persist_editor_generated_image( + let prepared_image = prepare_editor_generated_image( state, - owner_user_id.as_str(), + &caller, generated.task_id.as_str(), &image, prompt.as_str(), @@ -4398,17 +5277,20 @@ pub(crate) async fn edit_editor_image_for_owner( "vector-engine", ) .await?; - let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); - let generated_asset = persist_editor_generated_asset( - state, + let image_src = editor_media_src_from_object_key(prepared_image.object_key.as_str()); + let completed_at_micros = current_utc_micros(); + let prepared_asset = prepare_editor_generated_asset( + &caller, + "edit", + Some(prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: payload.asset_folder_id, label: asset_label.clone(), image_src: image_src.clone(), - object_key: Some(persisted.object_key.clone()), - asset_object_id: Some(persisted.asset_object_id.clone()), + object_key: Some(prepared_image.object_key.clone()), + asset_object_id: Some(prepared_image.asset_object_id().to_string()), width, height, prompt: prompt.clone(), @@ -4426,36 +5308,90 @@ pub(crate) async fn edit_editor_image_for_owner( image_sequence_frames: None, image_sequence_duration_ms: None, }, - ) - .await?; - let completed_project = if payload.canvas_completion.is_some() { - complete_editor_canvas_generation( - state, + completed_at_micros, + )?; + let canvas_layout_plan = if let Some(completion) = payload.canvas_completion.as_ref() { + let canvas_item = prepared_asset.record.resource.as_ref().map(|resource| { + let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); + ( + build_editor_canvas_resource_placement_item(completion, resource, layer_id.clone()), + layer_id, + ) + }); + EditorCanvasLayoutPlan::generation( owner_user_id.as_str(), payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - generated_asset.resource.as_ref(), + Some(completion), + canvas_item + .as_ref() + .map(|(item, _)| item.clone()) + .into_iter() + .collect(), + canvas_item.map(|(_, layer_id)| layer_id), ) - .await? } else { - complete_editor_canvas_background_removal( - state, + EditorCanvasLayoutPlan::background_removal( owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), - generated_asset.resource.as_ref(), + prepared_asset.record.resource.as_ref(), Some(asset_label.as_str()), true, ) - .await? }; + let job_result_payload_json = editor_generation_job_result_payload_json( + &caller, + json!({ + "imageSrc": image_src.clone(), + "objectKey": prepared_image.object_key.clone(), + "assetObjectId": prepared_image.asset_object_id(), + "width": width, + "height": height, + "sourceType": "generated", + "prompt": prompt.clone(), + "actualPrompt": generated.actual_prompt.clone(), + "model": generation_options.model, + "taskId": generated.task_id.clone(), + "resource": &prepared_asset.record.resource, + "asset": &prepared_asset.record.asset, + "warning": &dimension_warning, + }), + ); + let persisted_result = persist_editor_generation_result_atomically( + state, + &caller, + vec![prepared_asset.item], + canvas_layout_plan, + job_result_payload_json, + completed_at_micros, + ) + .await?; + let persisted_item = persisted_result + .items + .into_iter() + .find(|item| item.slot == "edit") + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-generation-operation", + "message": "图片修改原子提交缺少 edit 结果。", + })) + })?; + let generated_asset = EditorGeneratedAssetRecord { + resource: persisted_item + .project_resource + .map(editor_project_resource_payload_from_record), + asset: persisted_item.asset.map(editor_asset_payload_from_record), + }; + let completed_project = persisted_result + .project + .map(editor_project_payload_from_record); Ok(json_success_body( Some(&request_context), EditorImageGenerationResponse { image_src, - object_key: Some(persisted.object_key), - asset_object_id: Some(persisted.asset_object_id), + object_key: Some(prepared_image.object_key.clone()), + asset_object_id: Some(prepared_image.asset_object_id().to_string()), width, height, source_type: "generated", @@ -4556,9 +5492,9 @@ pub(crate) async fn remove_editor_image_background_for_owner( .await?; let task_id = normalize_optional_string(payload.task_id.clone()) .unwrap_or_else(|| format!("background-removal-{}", current_utc_micros())); - let persisted = persist_editor_generated_image( + let prepared_image = prepare_editor_generated_image( state, - caller.owner_user_id.as_str(), + &caller, task_id.as_str(), &removed.image, "remove background", @@ -4570,7 +5506,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( "bgfilter-complex", ) .await?; - let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); + let image_src = editor_media_src_from_object_key(prepared_image.object_key.as_str()); let asset_label = resolve_editor_generated_asset_label(payload.asset_label.clone(), "去背景结果"); let generation_inputs = apply_editor_matting_metadata_to_generation_inputs( @@ -4578,16 +5514,19 @@ pub(crate) async fn remove_editor_image_background_for_owner( EDITOR_BGFILTER_MATTING_PROVIDER, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, ); - let generated_asset = persist_editor_generated_asset( - state, + let completed_at_micros = current_utc_micros(); + let prepared_asset = prepare_editor_generated_asset( + &caller, + "result", + Some(prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, label: asset_label.clone(), image_src: image_src.clone(), - object_key: Some(persisted.object_key.clone()), - asset_object_id: Some(persisted.asset_object_id.clone()), + object_key: Some(prepared_image.object_key.clone()), + asset_object_id: Some(prepared_image.asset_object_id().to_string()), width: removed.width, height: removed.height, prompt: "remove background".to_string(), @@ -4605,36 +5544,86 @@ pub(crate) async fn remove_editor_image_background_for_owner( image_sequence_frames: None, image_sequence_duration_ms: None, }, - ) - .await?; - let completed_project = if payload.canvas_completion.is_some() { - complete_editor_canvas_generation( - state, + completed_at_micros, + )?; + let canvas_layout_plan = if let Some(completion) = payload.canvas_completion.as_ref() { + let canvas_item = prepared_asset.record.resource.as_ref().map(|resource| { + let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); + ( + build_editor_canvas_resource_placement_item(completion, resource, layer_id.clone()), + layer_id, + ) + }); + EditorCanvasLayoutPlan::generation( caller.owner_user_id.as_str(), payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - generated_asset.resource.as_ref(), + Some(completion), + canvas_item + .as_ref() + .map(|(item, _)| item.clone()) + .into_iter() + .collect(), + canvas_item.map(|(_, layer_id)| layer_id), ) - .await? } else { - complete_editor_canvas_background_removal( - state, + EditorCanvasLayoutPlan::background_removal( caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), - generated_asset.resource.as_ref(), + prepared_asset.record.resource.as_ref(), Some(asset_label.as_str()), false, ) - .await? }; + let job_result_payload_json = editor_generation_job_result_payload_json( + &caller, + json!({ + "imageSrc": image_src.clone(), + "objectKey": prepared_image.object_key.clone(), + "assetObjectId": prepared_image.asset_object_id(), + "width": removed.width, + "height": removed.height, + "sourceType": "generated", + "taskId": task_id.clone(), + "resource": &prepared_asset.record.resource, + "asset": &prepared_asset.record.asset, + }), + ); + let persisted_result = persist_editor_generation_result_atomically( + state, + &caller, + vec![prepared_asset.item], + canvas_layout_plan, + job_result_payload_json, + completed_at_micros, + ) + .await?; + let persisted_item = persisted_result + .items + .into_iter() + .find(|item| item.slot == "result") + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-generation-operation", + "message": "去背景原子提交缺少 result 结果。", + })) + })?; + let generated_asset = EditorGeneratedAssetRecord { + resource: persisted_item + .project_resource + .map(editor_project_resource_payload_from_record), + asset: persisted_item.asset.map(editor_asset_payload_from_record), + }; + let completed_project = persisted_result + .project + .map(editor_project_payload_from_record); Ok(json_success_body( Some(&request_context), EditorBackgroundRemovalResponse { image_src, - object_key: persisted.object_key, - asset_object_id: persisted.asset_object_id, + object_key: prepared_image.object_key.clone(), + asset_object_id: prepared_image.asset_object_id().to_string(), width: removed.width, height: removed.height, source_type: "generated", @@ -6145,6 +7134,115 @@ async fn persist_editor_spritesheet_slices( .collect()) } +struct PreparedEditorSpritesheetSlices { + responses: Vec, + items: Vec, +} + +async fn prepare_editor_spritesheet_slices_for_generation( + state: &AppState, + caller: &EditorGenerationCaller, + prepared: PreparedEditorIconSpritesheetPlan, + input: PersistEditorSpritesheetSlicesInput, +) -> Result { + use futures_util::{StreamExt as _, TryStreamExt as _}; + + if prepared.plan.is_empty() { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集中未识别到可拆分的独立素材。", + })), + ); + } + let input = Arc::new(input); + let http_client = build_editor_spritesheet_http_client( + EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, + EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "aliyun-oss", + "message": format!("创建图集切片上传客户端失败:{error}"), + })) + })?; + let mut uploads = futures_util::stream::iter(0..prepared.plan.len()) + .map(|index| { + let prepared = prepared.clone(); + let http_client = http_client.clone(); + let input = Arc::clone(&input); + async move { + let slice = encode_editor_icon_spritesheet_slice(prepared, index).await?; + upload_editor_spritesheet_slice(state, &http_client, index, slice, &input).await + } + }) + .buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY) + .try_collect::>() + .await?; + uploads.sort_by_key(|upload| upload.index); + drop(prepared); + + let operation = caller.operation.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "准备图集切片时缺少稳定 operation 上下文。", + })) + })?; + let prepared_at_micros = current_utc_micros(); + let mut responses = Vec::with_capacity(uploads.len()); + let mut items = Vec::with_capacity(uploads.len()); + for mut upload in uploads { + let slot = format!("slice-{:04}", upload.index); + upload.asset_object.asset_object_id = editor_generation_stable_asset_object_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + slot.as_str(), + ); + let prepared_item = prepare_editor_generated_asset( + caller, + slot.as_str(), + Some(upload.asset_object), + PersistEditorGeneratedAssetInput { + project_id: input.project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + folder_id: input.asset_folder_id.clone(), + label: upload.name.clone(), + image_src: upload.image_src.clone(), + object_key: Some(upload.object_key), + asset_object_id: None, + width: upload.width, + height: upload.height, + prompt: input.prompt.clone(), + actual_prompt: input.actual_prompt.clone(), + model: input.model.clone(), + provider: input.provider.clone(), + task_id: input.task_id.clone(), + group_task_id: input.group_task_id.clone(), + group_task_expected_asset_count: None, + source_resource_id: input.source_resource_id.clone(), + asset_kind: Some("icon".to_string()), + generation_inputs: input.generation_inputs.clone(), + thumbnail_src: None, + generation_cost_mud_points: 0, + image_sequence_frames: None, + image_sequence_duration_ms: None, + }, + prepared_at_micros, + )?; + responses.push(EditorIconSpritesheetIconResponse { + name: upload.name, + image_src: upload.image_src, + width: upload.width, + height: upload.height, + resource: prepared_item.record.resource, + asset: prepared_item.record.asset, + }); + items.push(prepared_item.item); + } + Ok(PreparedEditorSpritesheetSlices { responses, items }) +} + fn stable_editor_spritesheet_slice_record_id( prefix: &str, owner_user_id: &str, @@ -6345,7 +7443,18 @@ pub async fn generate_editor_icon_spritesheet( }, )); } - generate_editor_icon_spritesheet_for_owner(&state, &request_context, caller, payload).await + let caller = caller.with_inline_operation( + &request_context, + EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, + &payload, + )?; + with_editor_generation_durable_billing_boundary(generate_editor_icon_spritesheet_for_owner( + &state, + &request_context, + caller, + payload, + )) + .await } pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( @@ -6604,24 +7713,28 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); - let source_persisted = persist_editor_provider_source_image( + let source_persisted = prepare_editor_generated_image( state, - caller.owner_user_id.as_str(), + &caller, generated.task_id.as_str(), - image, + &image, user_prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", - "spritesheet", + "spritesheet-provider-source", + EDITOR_PROVIDER_SOURCE_SLOT, + "vector-engine", ) .await?; let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); let source_object_key = source_persisted.object_key.clone(); - let source_record = persist_editor_provider_source_resource( - state, - source_persisted, - PersistEditorProviderSourceResourceInput { + let source_asset_object_id = source_persisted.asset_object_id().to_string(); + let source_record = prepare_editor_generated_asset( + &caller, + EDITOR_PROVIDER_SOURCE_SLOT, + Some(source_persisted.asset_object), + PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), @@ -6636,9 +7749,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), generation_cost_mud_points: u64::from(expected_price_mud_points), + image_src: source_image_src.clone(), + object_key: Some(source_object_key.clone()), + asset_object_id: Some(source_asset_object_id.clone()), + provider: "VectorEngine".to_string(), + group_task_id: None, + group_task_expected_asset_count: None, + thumbnail_src: None, + image_sequence_frames: None, + image_sequence_duration_ms: None, }, - ) - .await?; + current_utc_micros(), + )?; caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), @@ -6669,16 +7791,29 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( error = %failure_message, "图标图集原图已保存,但透明背景处理失败,使用原图完成画布" ); - let (completed_project, fallback_warning) = complete_editor_source_only_fallback( - state, - caller.owner_user_id.as_str(), - payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - &source_record, - generation_warning, - None, - ) - .await?; + let (source_record, completed_project, fallback_warning) = + persist_editor_source_only_fallback_atomically( + state, + &caller, + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + source_record, + generated.task_id.as_str(), + source_image_src.as_str(), + source_object_key.as_str(), + source_asset_object_id.as_str(), + EditorSourceOnlyFallbackResultContext { + width: source_width, + height: source_height, + prompt: prompt.as_str(), + actual_prompt: generated.actual_prompt.as_deref(), + model: generation_options.model, + price_mud_points: expected_price_mud_points, + }, + generation_warning, + None, + ) + .await?; return Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { @@ -6736,16 +7871,29 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( error = ?error, "图标透明图蒙版尺寸恢复失败,改用已保存的 provider 原图完成画布" ); - let (completed_project, fallback_warning) = complete_editor_source_only_fallback( - state, - caller.owner_user_id.as_str(), - payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - &source_record, - generation_warning, - None, - ) - .await?; + let (source_record, completed_project, fallback_warning) = + persist_editor_source_only_fallback_atomically( + state, + &caller, + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + source_record, + generated.task_id.as_str(), + source_image_src.as_str(), + source_object_key.as_str(), + source_asset_object_id.as_str(), + EditorSourceOnlyFallbackResultContext { + width: source_width, + height: source_height, + prompt: prompt.as_str(), + actual_prompt: generated.actual_prompt.as_deref(), + model: generation_options.model, + price_mud_points: expected_price_mud_points, + }, + generation_warning, + None, + ) + .await?; return Ok(json_success_body( Some(request_context), EditorIconSpritesheetGenerationResponse { @@ -6779,7 +7927,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ); } let matting_generation_inputs = apply_editor_matting_metadata_to_generation_inputs( - build_editor_derived_asset_generation_inputs("图标图集抠图", &source_record), + build_editor_derived_asset_generation_inputs("图标图集抠图", &source_record.record), removal_provider, removal_model.as_str(), ); @@ -6788,10 +7936,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "editor-icon-spritesheet", "图标 spritesheet 透明后处理图不是有效图片", )?; - let owner_user_id = caller.owner_user_id; - let spritesheet_persisted = persist_editor_generated_image( + let owner_user_id = caller.owner_user_id.clone(); + let spritesheet_prepared_image = prepare_editor_generated_image( state, - owner_user_id.as_str(), + &caller, generated.task_id.as_str(), &image, user_prompt.as_str(), @@ -6804,17 +7952,20 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ) .await?; let spritesheet_image_src = - editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str()); - let spritesheet_record = persist_editor_generated_asset( - state, + editor_media_src_from_object_key(spritesheet_prepared_image.object_key.as_str()); + let prepared_at_micros = current_utc_micros(); + let spritesheet_record = prepare_editor_generated_asset( + &caller, + "spritesheet", + Some(spritesheet_prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: spritesheet_label, image_src: spritesheet_image_src.clone(), - object_key: Some(spritesheet_persisted.object_key.clone()), - asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), + object_key: Some(spritesheet_prepared_image.object_key.clone()), + asset_object_id: Some(spritesheet_prepared_image.asset_object_id().to_string()), width: spritesheet_width, height: spritesheet_height, prompt: user_prompt.clone(), @@ -6825,6 +7976,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( group_task_id: None, group_task_expected_asset_count: None, source_resource_id: source_record + .record .resource .as_ref() .map(|resource| resource.resource_id.clone()), @@ -6835,28 +7987,30 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( image_sequence_frames: None, image_sequence_duration_ms: None, }, - ) - .await?; + prepared_at_micros, + )?; let slice_source = DownloadedImage { bytes: image.bytes, mime_type: image.mime_type, extension: image.extension, }; - let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( + let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), ) .await { Ok(icon_slices) => { - match persist_editor_spritesheet_slices( + match prepare_editor_spritesheet_slices_for_generation( state, + &caller, icon_slices, PersistEditorSpritesheetSlicesInput { owner_user_id: owner_user_id.clone(), project_id: payload.project_id.clone(), asset_folder_id: asset_folder_id.clone(), source_resource_id: spritesheet_record + .record .resource .as_ref() .map(|resource| resource.resource_id.clone()), @@ -6868,7 +8022,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( provider: "Genarrative".to_string(), generation_inputs: build_editor_derived_asset_generation_inputs( "图集拆分", - &spritesheet_record, + &spritesheet_record.record, ), path_kind: "icon-spritesheet-assets", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, @@ -6877,7 +8031,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ) .await { - Ok(icon_image_srcs) => (icon_image_srcs, None), + Ok(prepared_slices) => (prepared_slices.responses, prepared_slices.items, None), Err(error) => { let reason = error.body_text(); tracing::warn!( @@ -6888,6 +8042,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "图标图集已持久化,但自动拆分素材持久化失败" ); ( + Vec::new(), Vec::new(), Some(editor_icon_spritesheet_warning_after_persist_error(&error)), ) @@ -6904,29 +8059,102 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "图标图集已持久化,但自动拆分未完成" ); let warning = editor_icon_spritesheet_slice_warning_from_error(&error); - (Vec::new(), Some(warning)) + (Vec::new(), Vec::new(), Some(warning)) } }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, - spritesheet_record.resource.as_ref(), - source_record.resource.as_ref(), + spritesheet_record.record.resource.as_ref(), + source_record.record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { (Vec::new(), None) }; - let completed_project = complete_editor_canvas_generation_with_items( - state, + let canvas_layout_plan = EditorCanvasLayoutPlan::generation( owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), canvas_items, primary_layer_id, + ); + let job_result_payload_json = editor_generation_job_result_payload_json( + &caller, + json!({ + "ok": true, + "spritesheetImageSrc": spritesheet_image_src.clone(), + "spritesheetWidth": spritesheet_width, + "spritesheetHeight": spritesheet_height, + "iconImageSrcs": &icon_image_srcs, + "sliceWarning": &slice_warning, + "warning": &generation_warning, + "prompt": user_prompt.clone(), + "actualPrompt": generated.actual_prompt.clone(), + "model": generation_options.model, + "taskId": generated.task_id.clone(), + "priceMudPoints": expected_price_mud_points, + "spritesheetResource": &spritesheet_record.record.resource, + "spritesheetAsset": &spritesheet_record.record.asset, + "resourceId": spritesheet_record + .record + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()), + "assetObjectId": spritesheet_prepared_image.asset_object_id(), + "objectKey": spritesheet_prepared_image.object_key.clone(), + }), + ); + let completed_at_micros = current_utc_micros(); + let mut items = vec![source_record.item, spritesheet_record.item]; + items.extend(slice_items); + let persisted_result = persist_editor_generation_result_atomically( + state, + &caller, + items, + canvas_layout_plan, + job_result_payload_json, + completed_at_micros, ) .await?; + let completed_project = persisted_result + .project + .map(editor_project_payload_from_record); + let mut persisted_items = persisted_result.items; + let spritesheet_item_index = persisted_items + .iter() + .position(|item| item.slot == "spritesheet") + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-generation-operation", + "message": "图标图集原子提交缺少 spritesheet 结果。", + })) + })?; + let spritesheet_item = persisted_items.remove(spritesheet_item_index); + let spritesheet_record = EditorGeneratedAssetRecord { + resource: spritesheet_item + .project_resource + .map(editor_project_resource_payload_from_record), + asset: spritesheet_item.asset.map(editor_asset_payload_from_record), + }; + for (index, icon) in icon_image_srcs.iter_mut().enumerate() { + let slot = format!("slice-{index:04}"); + let item_index = persisted_items + .iter() + .position(|item| item.slot == slot) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-generation-operation", + "message": format!("图标图集原子提交缺少 {slot} 结果。"), + })) + })?; + let item = persisted_items.remove(item_index); + icon.resource = item + .project_resource + .map(editor_project_resource_payload_from_record); + icon.asset = item.asset.map(editor_asset_payload_from_record); + } Ok(json_success_body( Some(&request_context), @@ -7525,7 +8753,18 @@ pub async fn extract_editor_ui_design_assets( }, )); } - extract_editor_ui_design_assets_for_owner(&state, &request_context, caller, payload).await + let caller = caller.with_inline_operation( + &request_context, + EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, + &payload, + )?; + with_editor_generation_durable_billing_boundary(extract_editor_ui_design_assets_for_owner( + &state, + &request_context, + caller, + payload, + )) + .await } pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner( @@ -7775,41 +9014,56 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); - let source_persisted = persist_editor_provider_source_image( + let source_prepared_image = prepare_editor_generated_image( state, - caller.owner_user_id.as_str(), + &caller, generated.task_id.as_str(), - image, + &image, prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND, "ui-design-spritesheets", - "spritesheet", + "spritesheet-provider-source", + EDITOR_PROVIDER_SOURCE_SLOT, + "vector-engine", ) .await?; - let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); - let source_object_key = source_persisted.object_key.clone(); - let source_record = persist_editor_provider_source_resource( - state, - source_persisted, - PersistEditorProviderSourceResourceInput { + let source_image_src = + editor_media_src_from_object_key(source_prepared_image.object_key.as_str()); + let source_object_key = source_prepared_image.object_key.clone(); + let source_asset_object_id = source_prepared_image.asset_object_id().to_string(); + let prepared_at_micros = current_utc_micros(); + let source_record = prepare_editor_generated_asset( + &caller, + EDITOR_PROVIDER_SOURCE_SLOT, + Some(source_prepared_image.asset_object.clone()), + PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: editor_generated_asset_variant_label(source_label.as_str(), "原图"), + image_src: source_image_src.clone(), + object_key: Some(source_object_key.clone()), + asset_object_id: Some(source_asset_object_id.clone()), width: source_width, height: source_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), + provider: "VectorEngine".to_string(), task_id: generated.task_id.clone(), + group_task_id: None, + group_task_expected_asset_count: None, source_resource_id: None, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), + thumbnail_src: None, generation_cost_mud_points: u64::from(expected_price_mud_points), + image_sequence_frames: None, + image_sequence_duration_ms: None, }, - ) - .await?; + prepared_at_micros, + )?; caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), @@ -7840,16 +9094,29 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( error = %failure_message, "UI 素材图集原图已保存,但透明背景处理失败,使用原图完成画布" ); - let (completed_project, fallback_warning) = complete_editor_source_only_fallback( - state, - caller.owner_user_id.as_str(), - payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - &source_record, - None, - None, - ) - .await?; + let (source_record, completed_project, fallback_warning) = + persist_editor_source_only_fallback_atomically( + state, + &caller, + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + source_record, + generated.task_id.as_str(), + source_image_src.as_str(), + source_object_key.as_str(), + source_asset_object_id.as_str(), + EditorSourceOnlyFallbackResultContext { + width: source_width, + height: source_height, + prompt: prompt.as_str(), + actual_prompt: generated.actual_prompt.as_deref(), + model: generation_options.model, + price_mud_points: expected_price_mud_points, + }, + None, + None, + ) + .await?; return Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { @@ -7892,16 +9159,29 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( error = ?error, "UI 素材透明图蒙版尺寸恢复失败,改用已保存的 provider 原图完成画布" ); - let (completed_project, fallback_warning) = complete_editor_source_only_fallback( - state, - caller.owner_user_id.as_str(), - payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - &source_record, - None, - None, - ) - .await?; + let (source_record, completed_project, fallback_warning) = + persist_editor_source_only_fallback_atomically( + state, + &caller, + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + source_record, + generated.task_id.as_str(), + source_image_src.as_str(), + source_object_key.as_str(), + source_asset_object_id.as_str(), + EditorSourceOnlyFallbackResultContext { + width: source_width, + height: source_height, + prompt: prompt.as_str(), + actual_prompt: generated.actual_prompt.as_deref(), + model: generation_options.model, + price_mud_points: expected_price_mud_points, + }, + None, + None, + ) + .await?; return Ok(json_success_body( Some(request_context), EditorIconSpritesheetGenerationResponse { @@ -7923,7 +9203,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( )); } let matting_generation_inputs = apply_editor_matting_metadata_to_generation_inputs( - build_editor_derived_asset_generation_inputs("UI图集抠图", &source_record), + build_editor_derived_asset_generation_inputs("UI图集抠图", &source_record.record), removal_provider, removal_model.as_str(), ); @@ -7932,10 +9212,10 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "editor-ui-design-asset-extraction", "UI 素材 spritesheet 透明后处理图不是有效图片", )?; - let owner_user_id = caller.owner_user_id; - let spritesheet_persisted = persist_editor_generated_image( + let owner_user_id = caller.owner_user_id.clone(); + let spritesheet_prepared_image = prepare_editor_generated_image( state, - owner_user_id.as_str(), + &caller, generated.task_id.as_str(), &image, "去除纯色背景", @@ -7948,17 +9228,19 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( ) .await?; let spritesheet_image_src = - editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str()); - let spritesheet_record = persist_editor_generated_asset( - state, + editor_media_src_from_object_key(spritesheet_prepared_image.object_key.as_str()); + let spritesheet_record = prepare_editor_generated_asset( + &caller, + "spritesheet", + Some(spritesheet_prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: source_label, image_src: spritesheet_image_src.clone(), - object_key: Some(spritesheet_persisted.object_key.clone()), - asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), + object_key: Some(spritesheet_prepared_image.object_key.clone()), + asset_object_id: Some(spritesheet_prepared_image.asset_object_id().to_string()), width: spritesheet_width, height: spritesheet_height, prompt: "去除纯色背景".to_string(), @@ -7969,6 +9251,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( group_task_id: None, group_task_expected_asset_count: None, source_resource_id: source_record + .record .resource .as_ref() .map(|resource| resource.resource_id.clone()), @@ -7979,28 +9262,30 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( image_sequence_frames: None, image_sequence_duration_ms: None, }, - ) - .await?; + prepared_at_micros, + )?; let slice_source = DownloadedImage { bytes: image.bytes, mime_type: image.mime_type, extension: image.extension, }; - let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( + let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), ) .await { Ok(icon_slices) => { - match persist_editor_spritesheet_slices( + match prepare_editor_spritesheet_slices_for_generation( state, + &caller, icon_slices, PersistEditorSpritesheetSlicesInput { owner_user_id: owner_user_id.clone(), project_id: payload.project_id.clone(), asset_folder_id: asset_folder_id.clone(), source_resource_id: spritesheet_record + .record .resource .as_ref() .map(|resource| resource.resource_id.clone()), @@ -8012,7 +9297,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( provider: "Genarrative".to_string(), generation_inputs: build_editor_derived_asset_generation_inputs( "图集拆分", - &spritesheet_record, + &spritesheet_record.record, ), path_kind: "ui-design-assets", asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, @@ -8021,7 +9306,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( ) .await { - Ok(icon_image_srcs) => (icon_image_srcs, None), + Ok(prepared_slices) => (prepared_slices.responses, prepared_slices.items, None), Err(error) => { let reason = error.body_text(); tracing::warn!( @@ -8029,9 +9314,10 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( operation = "persist_slices", task_id = %generated.task_id, reason = %reason, - "UI 设计图透明图集已持久化,但独立素材持久化失败" + "UI 设计图透明图集已上传,但独立素材准备失败" ); ( + Vec::new(), Vec::new(), Some(editor_icon_spritesheet_warning_after_persist_error(&error)), ) @@ -8045,32 +9331,100 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( operation = "detect_slices", task_id = %generated.task_id, reason = %reason, - "UI 设计图透明图集已持久化,但未识别到可拆分素材" + "UI 设计图透明图集已上传,但未识别到可拆分素材" ); let warning = editor_icon_spritesheet_slice_warning_from_error(&error); - (Vec::new(), Some(warning)) + (Vec::new(), Vec::new(), Some(warning)) } }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, - spritesheet_record.resource.as_ref(), - source_record.resource.as_ref(), + spritesheet_record.record.resource.as_ref(), + source_record.record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { (Vec::new(), None) }; - let completed_project = complete_editor_canvas_generation_with_items( - state, + let canvas_layout_plan = EditorCanvasLayoutPlan::generation( owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), canvas_items, primary_layer_id, + ); + let job_result_payload_json = editor_generation_job_result_payload_json( + &caller, + json!({ + "ok": true, + "spritesheetImageSrc": spritesheet_image_src.clone(), + "spritesheetWidth": spritesheet_width, + "spritesheetHeight": spritesheet_height, + "iconImageSrcs": &icon_image_srcs, + "sliceWarning": &slice_warning, + "warning": null, + "prompt": prompt.clone(), + "actualPrompt": generated.actual_prompt.clone(), + "model": generation_options.model, + "taskId": generated.task_id.clone(), + "priceMudPoints": expected_price_mud_points, + "spritesheetResource": &spritesheet_record.record.resource, + "spritesheetAsset": &spritesheet_record.record.asset, + "assetObjectId": spritesheet_prepared_image.asset_object_id(), + "objectKey": spritesheet_prepared_image.object_key.clone(), + }), + ); + let completed_at_micros = current_utc_micros(); + let mut items = vec![source_record.item, spritesheet_record.item]; + items.extend(slice_items); + let persisted_result = persist_editor_generation_result_atomically( + state, + &caller, + items, + canvas_layout_plan, + job_result_payload_json, + completed_at_micros, ) .await?; + let completed_project = persisted_result + .project + .map(editor_project_payload_from_record); + let mut persisted_items = persisted_result.items; + let spritesheet_item_index = persisted_items + .iter() + .position(|item| item.slot == "spritesheet") + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-generation-operation", + "message": "UI 设计图素材原子提交缺少 spritesheet 结果。", + })) + })?; + let spritesheet_item = persisted_items.remove(spritesheet_item_index); + let spritesheet_record = EditorGeneratedAssetRecord { + resource: spritesheet_item + .project_resource + .map(editor_project_resource_payload_from_record), + asset: spritesheet_item.asset.map(editor_asset_payload_from_record), + }; + for (index, icon) in icon_image_srcs.iter_mut().enumerate() { + let slot = format!("slice-{index:04}"); + let item_index = persisted_items + .iter() + .position(|item| item.slot == slot) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-generation-operation", + "message": format!("UI 设计图素材原子提交缺少 {slot} 结果。"), + })) + })?; + let item = persisted_items.remove(item_index); + icon.resource = item + .project_resource + .map(editor_project_resource_payload_from_record); + icon.asset = item.asset.map(editor_asset_payload_from_record); + } Ok(json_success_body( Some(&request_context), @@ -9147,6 +10501,188 @@ pub(crate) async fn persist_editor_generated_asset( Ok(EditorGeneratedAssetRecord { resource, asset }) } +pub(crate) fn prepare_editor_generated_asset( + caller: &EditorGenerationCaller, + slot: &str, + asset_object: Option, + mut input: PersistEditorGeneratedAssetInput, + prepared_at_micros: i64, +) -> Result { + let operation = caller.operation.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "准备编辑器生成结果时缺少稳定 operation 上下文。", + })) + })?; + let slot = slot.trim(); + if slot.is_empty() { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "准备编辑器生成结果时缺少稳定 slot。", + })), + ); + } + if let Some(candidate) = asset_object.as_ref() { + input.asset_object_id = Some(candidate.asset_object_id.clone()); + input.object_key = Some(candidate.object_key.clone()); + } + let generation_inputs_json = serialize_editor_generation_inputs( + input.asset_kind.as_deref(), + input.generation_inputs.clone(), + )?; + let image_sequence_frames_json = + serialize_editor_image_sequence_frames(input.image_sequence_frames.take())?; + let object_key = normalize_editor_object_key(input.object_key); + let asset_object_id = normalize_optional_string(input.asset_object_id); + let model = normalize_optional_string(Some(input.model)); + let image_src = normalize_editor_persisted_media_src(input.image_src, object_key.as_deref())?; + let resource_input = normalize_optional_string(input.project_id).map(|project_id| { + EditorProjectResourceCreateRecordInput { + resource_id: editor_generation_stable_resource_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + slot, + ), + project_id, + owner_user_id: caller.owner_user_id.clone(), + asset_object_id: asset_object_id.clone(), + image_src: image_src.clone(), + object_key: object_key.clone(), + width: input.width, + height: input.height, + source_type: "generated".to_string(), + prompt: Some(input.prompt.clone()), + actual_prompt: input.actual_prompt.clone(), + model: model.clone(), + provider: Some(input.provider.clone()), + task_id: Some(input.task_id.clone()), + source_resource_id: normalize_optional_string(input.source_resource_id.clone()), + asset_kind: normalize_optional_string(input.asset_kind.clone()), + generation_inputs_json: generation_inputs_json.clone(), + updated_at_micros: prepared_at_micros, + image_sequence_frames_json: image_sequence_frames_json.clone(), + image_sequence_duration_ms: input.image_sequence_duration_ms, + } + }); + let resource = resource_input.clone().map(|record| { + editor_project_resource_payload_from_record(EditorProjectResourceRecord { + resource_id: record.resource_id, + project_id: record.project_id, + owner_user_id: record.owner_user_id, + asset_object_id: record.asset_object_id, + image_src: record.image_src, + object_key: record.object_key, + width: record.width, + height: record.height, + source_type: record.source_type, + prompt: record.prompt, + actual_prompt: record.actual_prompt, + model: record.model, + provider: record.provider, + task_id: record.task_id, + source_resource_id: record.source_resource_id, + asset_kind: record.asset_kind, + generation_inputs: record + .generation_inputs_json + .and_then(|value| serde_json::from_str(value.as_str()).ok()), + public_showcase_enabled: false, + created_at: String::new(), + updated_at: String::new(), + image_sequence_frames: record + .image_sequence_frames_json + .and_then(|value| serde_json::from_str(value.as_str()).ok()), + image_sequence_duration_ms: record.image_sequence_duration_ms, + }) + }); + let folder_id = + normalize_generated_asset_folder_id(input.folder_id, caller.owner_user_id.as_str()); + let source_resource_id = resource + .as_ref() + .map(|resource| resource.resource_id.clone()) + .or_else(|| normalize_optional_string(input.source_resource_id)); + let asset_input = folder_id.map(|folder_id| EditorAssetCreateRecordInput { + asset_id: editor_generation_stable_asset_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + slot, + ), + owner_user_id: caller.owner_user_id.clone(), + folder_id, + label: input.label, + asset_object_id, + image_src, + object_key, + width: input.width, + height: input.height, + source_type: "generated".to_string(), + prompt: Some(input.prompt), + actual_prompt: input.actual_prompt, + model, + provider: Some(input.provider), + task_id: Some(input.task_id), + asset_kind: normalize_optional_string(input.asset_kind), + generation_inputs_json, + source_resource_id, + generation_cost_mud_points: input.generation_cost_mud_points, + now_micros: prepared_at_micros, + thumbnail_src: normalize_optional_string(input.thumbnail_src), + group_task_id: normalize_optional_string(input.group_task_id), + group_task_expected_asset_count: input.group_task_expected_asset_count, + image_sequence_frames_json, + image_sequence_duration_ms: input.image_sequence_duration_ms, + }); + let asset = asset_input.clone().map(|record| { + editor_asset_payload_from_record(EditorAssetRecord { + asset_id: record.asset_id, + folder_id: record.folder_id, + label: record.label, + asset_object_id: record.asset_object_id, + image_src: record.image_src, + object_key: record.object_key, + width: record.width, + height: record.height, + source_type: record.source_type, + prompt: record.prompt, + actual_prompt: record.actual_prompt, + model: record.model, + provider: record.provider, + task_id: record.task_id, + asset_kind: record.asset_kind, + generation_inputs: record + .generation_inputs_json + .and_then(|value| serde_json::from_str(value.as_str()).ok()), + source_resource_id: record.source_resource_id, + public_showcase_enabled: None, + thumbnail_src: record.thumbnail_src, + generation_cost_mud_points: record.generation_cost_mud_points, + showcase_id: None, + showcase_review_status: None, + showcase_display_enabled: None, + showcase_like_count: None, + created_at: String::new(), + updated_at: String::new(), + image_sequence_frames: record + .image_sequence_frames_json + .and_then(|value| serde_json::from_str(value.as_str()).ok()), + image_sequence_duration_ms: record.image_sequence_duration_ms, + }) + }); + + Ok(PreparedEditorGeneratedAssetRecord { + item: PreparedEditorGenerationResultItem { + slot: slot.to_string(), + asset_object, + project_resource: resource_input, + asset: asset_input, + binding: None, + }, + record: EditorGeneratedAssetRecord { resource, asset }, + }) +} + pub(crate) async fn persist_editor_generated_media_asset( state: &AppState, mut input: PersistEditorGeneratedAssetRequest, @@ -9250,6 +10786,161 @@ pub(crate) async fn complete_editor_canvas_generation( Ok(Some(editor_project_payload_from_record(saved))) } +#[derive(Clone, Copy)] +struct EditorSourceOnlyFallbackResultContext<'a> { + width: u32, + height: u32, + prompt: &'a str, + actual_prompt: Option<&'a str>, + model: &'a str, + price_mud_points: u32, +} + +fn build_editor_source_only_fallback_compact_result( + operation_kind: &str, + source_record: &EditorGeneratedAssetRecord, + task_id: &str, + image_src: &str, + object_key: &str, + asset_object_id: &str, + result_context: EditorSourceOnlyFallbackResultContext<'_>, + warning: Option<&EditorGenerationWarningResponse>, +) -> Value { + if matches!( + operation_kind, + EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND | EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND + ) { + json!({ + "ok": true, + "spritesheetImageSrc": image_src, + "spritesheetWidth": result_context.width, + "spritesheetHeight": result_context.height, + "iconImageSrcs": [], + "sliceWarning": null, + "prompt": result_context.prompt, + "actualPrompt": result_context.actual_prompt, + "model": result_context.model, + "taskId": task_id, + "priceMudPoints": result_context.price_mud_points, + "spritesheetResource": &source_record.resource, + "spritesheetAsset": &source_record.asset, + "objectKey": object_key, + "assetObjectId": asset_object_id, + "warning": warning, + }) + } else { + json!({ + "taskId": task_id, + "imageSrc": image_src, + "objectKey": object_key, + "assetObjectId": asset_object_id, + "width": result_context.width, + "height": result_context.height, + "sourceType": "generated", + "prompt": result_context.prompt, + "actualPrompt": result_context.actual_prompt, + "model": result_context.model, + "resource": &source_record.resource, + "asset": &source_record.asset, + "warning": warning, + }) + } +} + +async fn persist_editor_source_only_fallback_atomically( + state: &AppState, + caller: &EditorGenerationCaller, + project_id: Option<&str>, + completion: Option<&EditorCanvasGenerationCompletionRequest>, + source_record: PreparedEditorGeneratedAssetRecord, + task_id: &str, + image_src: &str, + object_key: &str, + asset_object_id: &str, + result_context: EditorSourceOnlyFallbackResultContext<'_>, + current_warning: Option, + dimension_warning: Option<&EditorGenerationWarningResponse>, +) -> Result< + ( + EditorGeneratedAssetRecord, + Option, + Option, + ), + AppError, +> { + let completed_at_micros = current_utc_micros(); + let canvas_item = completion.and_then(|completion| { + source_record.record.resource.as_ref().map(|resource| { + let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); + ( + build_editor_canvas_resource_placement_item(completion, resource, layer_id.clone()), + layer_id, + ) + }) + }); + let canvas_layout_plan = EditorCanvasLayoutPlan::generation( + caller.owner_user_id.as_str(), + project_id, + completion, + canvas_item + .as_ref() + .map(|(item, _)| item.clone()) + .into_iter() + .collect(), + canvas_item.map(|(_, layer_id)| layer_id), + ); + let warning = merge_editor_generation_warnings( + current_warning, + Some(editor_postprocess_fallback_warning_with_dimension( + "生成任务成功,后处理失败。", + dimension_warning, + )), + ); + let operation_kind = caller + .operation + .as_ref() + .map(|operation| operation.operation_kind.as_str()) + .unwrap_or_default(); + let compact_result = build_editor_source_only_fallback_compact_result( + operation_kind, + &source_record.record, + task_id, + image_src, + object_key, + asset_object_id, + result_context, + warning.as_ref(), + ); + let job_result_payload_json = editor_generation_job_result_payload_json(caller, compact_result); + let persisted = persist_editor_generation_result_atomically( + state, + caller, + vec![source_record.item], + canvas_layout_plan, + job_result_payload_json, + completed_at_micros, + ) + .await?; + let item = persisted + .items + .into_iter() + .find(|item| item.slot == EDITOR_PROVIDER_SOURCE_SLOT) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-generation-operation", + "message": "角色原图降级提交缺少 provider-source 结果。", + })) + })?; + let source_record = EditorGeneratedAssetRecord { + resource: item + .project_resource + .map(editor_project_resource_payload_from_record), + asset: item.asset.map(editor_asset_payload_from_record), + }; + let project = persisted.project.map(editor_project_payload_from_record); + Ok((source_record, project, warning)) +} + async fn complete_editor_source_only_fallback( state: &AppState, owner_user_id: &str, @@ -9291,6 +10982,35 @@ pub(crate) async fn complete_editor_canvas_generation_with_items( items: Vec, primary_layer_id: Option, ) -> Result, AppError> { + let Some(layout) = prepare_editor_canvas_generation_layout( + state, + owner_user_id, + project_id, + completion, + items, + primary_layer_id, + ) + .await? + else { + return Ok(None); + }; + let saved = state + .spacetime_client() + .save_editor_project_layout_v2(layout) + .await + .map_err(map_editor_project_error)?; + + Ok(Some(editor_project_payload_from_record(saved))) +} + +pub(crate) async fn prepare_editor_canvas_generation_layout( + state: &AppState, + owner_user_id: &str, + project_id: Option<&str>, + completion: Option<&EditorCanvasGenerationCompletionRequest>, + items: Vec, + primary_layer_id: Option, +) -> Result, AppError> { let (Some(project_id), Some(completion)) = (project_id, completion) else { return Ok(None); }; @@ -9328,17 +11048,14 @@ pub(crate) async fn complete_editor_canvas_generation_with_items( if !completion_result.changed { return Ok(None); } - let saved = save_editor_project_layout_with_revision_and_get( - state, - project_id, - owner_user_id, + Ok(Some(EditorProjectLayoutSaveV2RecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), viewport, - completion_result.layers, + layers_json: serialize_editor_layers(completion_result.layers)?, expected_revision, - ) - .await?; - - Ok(Some(editor_project_payload_from_record(saved))) + updated_at_micros: current_utc_micros(), + })) } pub(crate) async fn complete_editor_canvas_background_removal( @@ -9350,6 +11067,38 @@ pub(crate) async fn complete_editor_canvas_background_removal( replacement_title: Option<&str>, recenter_replacement: bool, ) -> Result, AppError> { + let Some(layout) = prepare_editor_canvas_background_removal_layout( + state, + owner_user_id, + project_id, + target_layer_id, + resource, + replacement_title, + recenter_replacement, + ) + .await? + else { + return Ok(None); + }; + let saved = state + .spacetime_client() + .save_editor_project_layout_v2(layout) + .await + .map_err(map_editor_project_error)?; + + Ok(Some(editor_project_payload_from_record(saved))) +} + +#[allow(clippy::too_many_arguments)] +async fn prepare_editor_canvas_background_removal_layout( + state: &AppState, + owner_user_id: &str, + project_id: Option<&str>, + target_layer_id: Option<&str>, + resource: Option<&EditorProjectResourcePayload>, + replacement_title: Option<&str>, + recenter_replacement: bool, +) -> Result, AppError> { let (Some(project_id), Some(target_layer_id), Some(resource)) = (project_id, target_layer_id, resource) else { @@ -9383,17 +11132,14 @@ pub(crate) async fn complete_editor_canvas_background_removal( return Ok(None); }; - let saved = save_editor_project_layout_with_revision_and_get( - state, - project_id, - owner_user_id, + Ok(Some(EditorProjectLayoutSaveV2RecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), viewport, - layers, + layers_json: serialize_editor_layers(layers)?, expected_revision, - ) - .await?; - - Ok(Some(editor_project_payload_from_record(saved))) + updated_at_micros: current_utc_micros(), + })) } pub(crate) async fn save_editor_project_layout_with_revision_and_get( @@ -10543,6 +12289,17 @@ pub(crate) struct PersistedEditorGeneratedImage { pub(crate) asset_object_id: String, } +pub(crate) struct PreparedEditorGeneratedImage { + pub(crate) object_key: String, + pub(crate) asset_object: AssetObjectUpsertInput, +} + +impl PreparedEditorGeneratedImage { + pub(crate) fn asset_object_id(&self) -> &str { + self.asset_object.asset_object_id.as_str() + } +} + struct UploadedEditorGeneratedImageObject { object_key: String, asset_object: AssetObjectUpsertInput, @@ -10637,6 +12394,54 @@ pub(crate) async fn persist_editor_generated_image( .await } +#[allow(clippy::too_many_arguments)] +pub(crate) async fn prepare_editor_generated_image( + state: &AppState, + caller: &EditorGenerationCaller, + task_id: &str, + image: &DownloadedOpenAiImage, + prompt: &str, + _actual_prompt: Option<&str>, + asset_kind: &str, + path_kind: &str, + file_stem: &str, + slot: &str, + provider: &str, +) -> Result { + let operation = caller.operation.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "准备编辑器生成对象时缺少稳定 operation 上下文。", + })) + })?; + let uploaded = upload_editor_generated_image_object_data( + state, + caller.owner_user_id.as_str(), + task_id, + GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(image.mime_type.as_str()), + bytes: image.bytes.clone(), + }, + prompt, + asset_kind, + path_kind, + file_stem, + slot, + provider, + Some(editor_generation_stable_asset_object_id( + caller.owner_user_id.as_str(), + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + slot, + )), + ) + .await?; + Ok(PreparedEditorGeneratedImage { + object_key: uploaded.object_key, + asset_object: uploaded.asset_object, + }) +} + async fn persist_editor_generated_image_owned( state: &AppState, owner_user_id: &str, @@ -12253,6 +14058,234 @@ mod tests { )); assert_eq!(error.status_code(), StatusCode::CONFLICT); + assert!(is_editor_canvas_revision_conflict(&error)); + } + + #[test] + fn editor_canvas_revision_retry_rejects_other_conflict_kinds_and_providers() { + for error in [ + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "editor-project", + "message": "完美像素幂等冲突:dialog 指向的结果图层不存在", + })), + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "editor-project", + "message": "其它版本冲突", + })), + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "spacetimedb", + "message": "图片画布版本冲突:期望 revision 4,当前为 5", + })), + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": "图片画布版本冲突:期望 revision 4,当前为 5", + })), + ] { + assert!(!is_editor_canvas_revision_conflict(&error)); + } + } + + #[test] + fn atomic_editor_generation_canvas_retry_replays_one_prepared_commit_once() { + let source = include_str!("editor_project.rs"); + let body = source + .split_once("pub(crate) async fn persist_editor_generation_result_atomically") + .and_then(|(_, tail)| tail.split_once("fn is_editor_canvas_revision_conflict")) + .map(|(body, _)| body) + .expect("atomic editor generation persist helper should exist"); + + assert_eq!( + body.matches("persist_editor_generation_prepared_commit(state, &") + .count(), + 2, + "the helper must make one initial commit and at most one CAS retry", + ); + for expected in [ + "canvas_layout_plan.prepare(state).await?", + "let prepared_commit = EditorGenerationResultPersistRecordInput", + "persist_editor_generation_prepared_commit(state, &prepared_commit)", + "has_canvas_layout", + "is_editor_canvas_revision_conflict(&error)", + "let refreshed_layout = canvas_layout_plan.prepare(state).await?", + "canvas_layout: refreshed_layout", + "..prepared_commit", + ] { + assert!( + body.contains(expected), + "missing retry contract: {expected}" + ); + } + assert_eq!( + body.matches("get_external_generation_job(").count(), + 0, + "queue routing context must be captured before the summary projection loses it", + ); + assert!(!body.contains("first_layout_updated_at_micros")); + } + + #[tokio::test] + async fn atomic_editor_generation_unknown_result_replays_are_bounded() { + let mut attempts = 0usize; + let result = call_with_editor_generation_unknown_result_replay(|| { + attempts += 1; + let attempt = attempts; + async move { + if attempt == 1 { + Err(SpacetimeClientError::ConnectDropped) + } else { + Ok("applied") + } + } + }) + .await + .expect("the same prepared commit should be replayed after an unknown result"); + assert_eq!(result, "applied"); + assert_eq!(attempts, 2); + + let mut exhausted_attempts = 0usize; + let exhausted = call_with_editor_generation_unknown_result_replay::<(), _, _>(|| { + exhausted_attempts += 1; + async { Err(SpacetimeClientError::ConnectDropped) } + }) + .await; + assert!(matches!( + exhausted, + Err(SpacetimeClientError::ConnectDropped) + )); + assert_eq!( + exhausted_attempts, + MAX_EDITOR_GENERATION_UNKNOWN_RESULT_REPLAYS + 1 + ); + } + + #[tokio::test] + async fn atomic_editor_generation_domain_errors_are_not_replayed() { + let mut attempts = 0usize; + let result = call_with_editor_generation_unknown_result_replay::<(), _, _>(|| { + attempts += 1; + async { + Err(SpacetimeClientError::Procedure( + "图片画布版本冲突:期望 revision 4,当前为 5".to_string(), + )) + } + }) + .await; + assert!(matches!(result, Err(SpacetimeClientError::Procedure(_)))); + assert_eq!(attempts, 1); + } + + #[test] + fn atomic_editor_generation_unknown_outcome_requires_post_dispatch_ambiguity() { + assert!(!is_editor_generation_unknown_result_error( + &SpacetimeClientError::Build("invalid config".to_string()), + false, + )); + for stage in [ + SpacetimeClientStage::PoolAcquire, + SpacetimeClientStage::ConnectBuild, + SpacetimeClientStage::ConnectHandshake, + ] { + assert!(!is_editor_generation_unknown_result_error( + &SpacetimeClientError::Timeout(stage), + true, + )); + } + assert!(!is_editor_generation_unknown_result_error( + &SpacetimeClientError::Procedure("rejected".to_string()), + true, + )); + assert!(is_editor_generation_unknown_result_error( + &SpacetimeClientError::ConnectDropped, + true, + )); + assert!(is_editor_generation_unknown_result_error( + &SpacetimeClientError::Timeout(SpacetimeClientStage::ProcedureResult), + true, + )); + assert!(is_editor_generation_unknown_result_error( + &SpacetimeClientError::Runtime("malformed response".to_string()), + true, + )); + } + + #[test] + fn all_atomic_editor_generation_commit_sites_delegate_canvas_retry_to_shared_helper() { + let editor_source = include_str!("editor_project.rs"); + let character_source = include_str!("character_animation_assets.rs"); + let audio_source = include_str!("vector_engine_audio_generation/generation.rs"); + for (source, start, end) in [ + ( + editor_source, + "pub(crate) async fn generate_editor_image_for_owner", + "fn editor_image_generation_billing_asset_kind", + ), + ( + editor_source, + "pub(crate) async fn edit_editor_image_for_owner", + "pub async fn remove_editor_image_background", + ), + ( + editor_source, + "pub(crate) async fn remove_editor_image_background_for_owner", + "fn validate_editor_pixel_art_snap_canvas_completion", + ), + ( + editor_source, + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "fn resolve_editor_icon_spritesheet_source_layer_asset_kind", + ), + ( + editor_source, + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "async fn repair_editor_project_record_inline_media", + ), + ( + editor_source, + "async fn persist_editor_source_only_fallback_atomically", + "async fn complete_editor_source_only_fallback", + ), + ( + character_source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + ), + ( + character_source, + "pub(crate) async fn generate_editor_video_for_owner", + "pub async fn get_character_animation_job", + ), + ( + audio_source, + "async fn persist_editor_audio_generation", + "fn build_editor_audio_job_result_payload", + ), + ] { + let body = source + .split_once(start) + .and_then(|(_, tail)| tail.split_once(end)) + .map(|(body, _)| body) + .unwrap_or_else(|| panic!("missing atomic generation commit site: {start}")); + assert!( + body.contains("EditorCanvasLayoutPlan::"), + "{start} must pass a replayable canvas plan", + ); + assert!( + body.contains("persist_editor_generation_result_atomically("), + "{start} must use the shared atomic persist helper", + ); + assert!( + !body.contains("prepare_editor_canvas_generation_layout("), + "{start} must not prepare canvas outside the retrying helper", + ); + assert!( + !body.contains("prepare_editor_canvas_background_removal_layout("), + "{start} must not prepare background replacement outside the retrying helper", + ); + } + assert!( + !audio_source.contains("is_editor_audio_canvas_revision_conflict"), + "audio must not keep its former broad conflict classifier", + ); } #[test] @@ -17162,6 +19195,340 @@ mod tests { assert!(merged.reason.contains("后处理失败")); } + #[test] + fn atomic_editor_job_result_defers_consumer_compaction_until_queue_context_is_applied() { + let caller = EditorGenerationCaller { + owner_user_id: "user-1".to_string(), + audit_subject_user_id: Some("user-1".to_string()), + audit_project_id: Some("project-1".to_string()), + phase_reporter: None, + operation: Some(EditorGenerationOperationContext { + operation_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(), + operation_id: "job-1".to_string(), + operation_fingerprint: "a".repeat(64), + worker_id: Some("worker-1".to_string()), + lease_token: Some("lease-1".to_string()), + queue_result_context: Some(EditorGenerationQueueResultContext { + consumer: EditorGenerationQueueConsumer::Standard, + source_module: "editor-canvas".to_string(), + source_entity_id: "project-1".to_string(), + }), + }), + }; + let result = json!({ + "imageSrc": "/api/assets/read?objectKey=generated/image.png", + "objectKey": "generated/image.png", + "width": 512, + "height": 512, + "warning": { "code": "fallback", "reason": "测试降级" }, + }); + let payload: Value = serde_json::from_str( + editor_generation_job_result_payload_json(&caller, result.clone()) + .expect("queue caller should build a result payload") + .as_str(), + ) + .expect("atomic queue result should be valid JSON"); + + assert_eq!(payload[ATOMIC_EDITOR_GENERATION_RAW_RESULT_FIELD], result); + assert!(payload.get("result").is_none()); + assert!(payload.get("editor-agent-tool-call-result").is_none()); + } + + #[test] + fn atomic_job_result_keeps_only_the_target_consumer_contract() { + let result = json!({ + "ok": true, + "imageSrc": "/api/assets/object/generated.png", + "objectKey": "generated/image.png", + "width": 512, + "height": 512, + "prompt": "用户可见提示词", + "actualPrompt": "Provider 实际提示词", + "provider": "internal-provider", + "generationInputs": { + "fields": { "prompt": "用户可见提示词" }, + "mattingProvider": "internal-provider", + }, + "resource": { + "resourceId": "resource-1", + "objectKey": "generated/image.png", + "assetObjectId": "asset-object-1", + "prompt": "不应复制到 queue", + "generationInputs": { "large": "x".repeat(32 * 1024) }, + }, + "asset": { "assetId": "asset-1", "ownerUserId": "user-1" }, + }); + + let mut ordinary_job = atomic_editor_generation_job_fixture(); + let ordinary_context = EditorGenerationQueueResultContext::from_job(&ordinary_job); + let ordinary_payload: Value = serde_json::from_str( + serialize_atomic_editor_generation_job_result(&ordinary_context, &result) + .expect("ordinary queue payload") + .as_str(), + ) + .expect("ordinary queue payload should be JSON"); + assert!(ordinary_payload.get("result").is_none()); + assert!( + ordinary_payload + .get("editor-agent-tool-call-result") + .is_none() + ); + + ordinary_job.request_payload_json = json!({ + "generationInputs": { "source": "editor-agent" }, + }) + .to_string(); + let agent_context = EditorGenerationQueueResultContext::from_job(&ordinary_job); + let agent_payload: Value = serde_json::from_str( + serialize_atomic_editor_generation_job_result(&agent_context, &result) + .expect("agent queue payload") + .as_str(), + ) + .expect("agent queue payload should be JSON"); + assert!(agent_payload.get("result").is_none()); + assert_eq!( + agent_payload["editor-agent-tool-call-result"]["resource"], + json!({ + "resourceId": "resource-1", + "objectKey": "generated/image.png", + "assetObjectId": "asset-object-1", + }) + ); + assert!( + agent_payload["editor-agent-tool-call-result"] + .get("asset") + .is_none() + ); + assert!( + agent_payload["editor-agent-tool-call-result"] + .get("provider") + .is_none() + ); + + ordinary_job.request_payload_json = "{}".to_string(); + ordinary_job.dedupe_key = + "external-api-generation:editor_image_generation:fingerprint".to_string(); + let external_context = EditorGenerationQueueResultContext::from_job(&ordinary_job); + let external_payload: Value = serde_json::from_str( + serialize_atomic_editor_generation_job_result(&external_context, &result) + .expect("external queue payload") + .as_str(), + ) + .expect("external queue payload should be JSON"); + assert!( + external_payload + .get("editor-agent-tool-call-result") + .is_none() + ); + assert_eq!(external_payload["result"]["asset"]["assetId"], "asset-1"); + assert_eq!(external_payload["result"]["ok"], true); + assert_eq!(external_payload["result"]["prompt"], "用户可见提示词"); + assert_eq!( + external_payload["result"]["actualPrompt"], + "Provider 实际提示词" + ); + assert!( + external_payload["result"]["asset"] + .get("ownerUserId") + .is_none() + ); + } + + #[test] + fn atomic_icon_ui_results_keep_required_consumer_fields() { + let source = include_str!("editor_project.rs"); + for (start, end) in [ + ( + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn extract_editor_ui_design_assets", + ), + ( + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "pub(crate) fn editor_project_payload_from_record", + ), + ( + "fn build_editor_source_only_fallback_compact_result", + "async fn persist_editor_source_only_fallback_atomically", + ), + ] { + assert_function_contains( + source, + start, + end, + &["\"ok\": true", "\"prompt\":", "\"actualPrompt\":"], + ); + } + } + + #[test] + fn source_only_fallback_contract_does_not_depend_on_project_resource() { + let source_record = EditorGeneratedAssetRecord { + resource: None, + asset: None, + }; + let spritesheet_context = EditorSourceOnlyFallbackResultContext { + width: 1024, + height: 1024, + prompt: "生成图集", + actual_prompt: Some("Provider 图集提示词"), + model: "gpt-image-2", + price_mud_points: 12, + }; + for operation_kind in [ + EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, + EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, + ] { + let result = build_editor_source_only_fallback_compact_result( + operation_kind, + &source_record, + "task-sheet", + "/api/assets/object/sheet.png", + "generated/sheet.png", + "asset-object-sheet", + spritesheet_context, + None, + ); + + assert_eq!(result["ok"], true); + assert_eq!(result["spritesheetWidth"], 1024); + assert_eq!(result["spritesheetHeight"], 1024); + assert_eq!(result["prompt"], "生成图集"); + assert_eq!(result["actualPrompt"], "Provider 图集提示词"); + assert_eq!(result["model"], "gpt-image-2"); + assert_eq!(result["priceMudPoints"], 12); + assert!(result["spritesheetResource"].is_null()); + } + + let image_result = build_editor_source_only_fallback_compact_result( + EDITOR_IMAGE_GENERATION_JOB_KIND, + &source_record, + "task-image", + "/api/assets/object/image.png", + "generated/image.png", + "asset-object-image", + EditorSourceOnlyFallbackResultContext { + width: 768, + height: 1024, + prompt: "生成角色", + actual_prompt: None, + model: "gemini-3.1-flash-image-preview", + price_mud_points: 0, + }, + None, + ); + assert_eq!(image_result["width"], 768); + assert_eq!(image_result["height"], 1024); + assert_eq!(image_result["prompt"], "生成角色"); + assert_eq!(image_result["model"], "gemini-3.1-flash-image-preview"); + assert!(image_result["resource"].is_null()); + } + + #[test] + fn atomic_spritesheet_job_result_with_64_large_slices_stays_below_payload_limit() { + let large_metadata = "x".repeat(32 * 1024); + let icons = (0..64) + .map(|index| { + json!({ + "name": format!("icon-{index}"), + "imageSrc": format!("/api/assets/object/icon-{index}.png"), + "objectKey": format!("generated/icon-{index}.png"), + "width": 64, + "height": 64, + "resource": { + "resourceId": format!("resource-{index}"), + "objectKey": format!("generated/icon-{index}.png"), + "assetObjectId": format!("asset-object-{index}"), + "sourceResourceId": "spritesheet-resource", + "prompt": large_metadata.clone(), + "generationInputs": { + "fields": large_metadata.clone(), + "references": large_metadata.clone(), + }, + "ownerUserId": "user-1", + }, + "asset": { + "assetId": format!("asset-{index}"), + "objectKey": format!("generated/icon-{index}.png"), + "ownerUserId": "user-1", + "prompt": large_metadata.clone(), + "generationInputs": large_metadata.clone(), + }, + }) + }) + .collect::>(); + let result = json!({ + "spritesheetImageSrc": "/api/assets/object/sheet.png", + "spritesheetWidth": 1024, + "spritesheetHeight": 1024, + "iconImageSrcs": icons, + "spritesheetResource": { + "resourceId": "spritesheet-resource", + "objectKey": "generated/sheet.png", + "generationInputs": large_metadata.clone(), + }, + "spritesheetAsset": { + "assetId": "spritesheet-asset", + "generationInputs": large_metadata, + }, + }); + + let mut agent_job = atomic_editor_generation_job_fixture(); + agent_job.request_payload_json = json!({ + "generationInputs": { "source": "editor-agent" }, + }) + .to_string(); + let agent_context = EditorGenerationQueueResultContext::from_job(&agent_job); + let agent_payload = serialize_atomic_editor_generation_job_result(&agent_context, &result) + .expect("64-slice agent payload should remain compact"); + assert!(agent_payload.len() < MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES); + assert!(!agent_payload.contains("generationInputs")); + assert!(!agent_payload.contains("ownerUserId")); + + let mut external_job = atomic_editor_generation_job_fixture(); + external_job.dedupe_key = + "external-api-generation:editor_ui_design_asset_extraction:fingerprint".to_string(); + let external_context = EditorGenerationQueueResultContext::from_job(&external_job); + let external_payload = + serialize_atomic_editor_generation_job_result(&external_context, &result) + .expect("64-slice external payload should remain compact"); + assert!(external_payload.len() < MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES); + assert!(!external_payload.contains("generationInputs")); + assert!(!external_payload.contains("ownerUserId")); + } + + fn atomic_editor_generation_job_fixture() -> ExternalGenerationJobRecord { + ExternalGenerationJobRecord { + job_id: "job-1".to_string(), + dedupe_key: "editor-api-request-generation:editor_image_generation:fingerprint" + .to_string(), + job_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(), + owner_user_id: "user-1".to_string(), + source_module: "editor-canvas".to_string(), + source_entity_id: "project-1".to_string(), + request_label: "图片画布生成图片".to_string(), + request_payload_json: "{}".to_string(), + status: "running".to_string(), + attempt: 1, + max_attempts: 1, + last_error_message: None, + worker_id: Some("worker-1".to_string()), + lease_expires_at: Some("2026-08-07T00:00:00Z".to_string()), + available_at: "2026-08-07T00:00:00Z".to_string(), + result_payload_json: None, + created_at: "2026-08-07T00:00:00Z".to_string(), + started_at: Some("2026-08-07T00:00:00Z".to_string()), + completed_at: None, + updated_at: "2026-08-07T00:00:00Z".to_string(), + updated_at_micros: 1_786_060_800_000_000, + lease_token: Some("lease-1".to_string()), + price_mud_points: 2, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: Some("processing".to_string()), + } + } + #[test] fn editor_image_edit_request_omits_price_mud_points() { let request: EditorImageEditRequest = serde_json::from_value(json!({ @@ -17358,9 +19725,10 @@ mod tests { "pub(crate) async fn edit_editor_image_for_owner", "pub async fn remove_editor_image_background", &[ - "payload.canvas_completion.is_some()", - "complete_editor_canvas_background_removal", + "payload.canvas_completion.as_ref()", + "EditorCanvasLayoutPlan::background_removal", "payload.target_layer_id.as_deref()", + "persist_editor_generation_result_atomically", ], ); } @@ -17820,6 +20188,57 @@ mod tests { ); } + #[test] + fn editor_ui_design_asset_extraction_commits_prepared_bundle_atomically() { + let source = include_str!("editor_project.rs"); + let start = "pub(crate) async fn extract_editor_ui_design_assets_for_owner"; + let end = "async fn repair_editor_project_record_inline_media"; + assert_function_contains_in_order( + source, + start, + end, + &[ + "prepare_editor_generated_image(", + "EDITOR_PROVIDER_SOURCE_SLOT", + "prepare_editor_generated_asset(", + "remove_editor_generated_screen_background_with_bgfilter", + "prepare_editor_generated_image(", + "prepare_editor_generated_asset(", + "prepare_editor_spritesheet_slices_for_generation(", + "EditorCanvasLayoutPlan::generation(", + "persist_editor_generation_result_atomically(", + ], + ); + assert_function_occurrence_count( + source, + start, + end, + "persist_editor_generation_result_atomically(", + 1, + ); + assert_function_occurrence_count( + source, + start, + end, + "persist_editor_source_only_fallback_atomically(", + 2, + ); + assert_function_not_contains( + source, + start, + end, + &[ + "persist_editor_provider_source_image(", + "persist_editor_provider_source_resource(", + "persist_editor_generated_image(", + "persist_editor_generated_asset(", + "persist_editor_spritesheet_slices(", + "complete_editor_source_only_fallback(", + "complete_editor_canvas_generation_with_items(", + ], + ); + } + #[test] fn editor_manual_background_removal_reuses_single_account_snapshot_pair() { let source = include_str!("editor_project.rs"); @@ -17853,11 +20272,11 @@ mod tests { "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ - "persist_editor_provider_source_image", + "prepare_editor_generated_image", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", - "persist_editor_provider_source_resource", + "prepare_editor_generated_asset", "build_multi_asset_canvas_layer_items", "if is_character_generation", "let output_model = generation_options.model.to_string();", @@ -17877,8 +20296,8 @@ mod tests { "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ - "persist_editor_provider_source_image", - "persist_editor_provider_source_resource", + "prepare_editor_generated_image", + "prepare_editor_generated_asset", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", ], @@ -17888,13 +20307,14 @@ mod tests { "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ - "persist_editor_provider_source_image", + "prepare_editor_generated_image", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", - "persist_editor_provider_source_resource", + "prepare_editor_generated_asset", "slice_editor_icon_spritesheet_all", - "persist_editor_spritesheet_slices", + "prepare_editor_spritesheet_slices_for_generation", + "persist_editor_generation_result_atomically", "model: generation_options.model.to_string(),", "apply_editor_matting_metadata_to_generation_inputs", "build_editor_derived_asset_generation_inputs(\"图标图集抠图\"", @@ -17912,8 +20332,8 @@ mod tests { "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ - "persist_editor_provider_source_image", - "persist_editor_provider_source_resource", + "prepare_editor_generated_image", + "prepare_editor_generated_asset", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", @@ -17935,11 +20355,13 @@ mod tests { "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ - "persist_editor_provider_source_image", + "prepare_editor_generated_image", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", - "persist_editor_provider_source_resource", + "prepare_editor_generated_asset", "slice_editor_icon_spritesheet_all", + "prepare_editor_spritesheet_slices_for_generation", + "persist_editor_generation_result_atomically", "model: generation_options.model.to_string(),", "apply_editor_matting_metadata_to_generation_inputs", "build_editor_derived_asset_generation_inputs(\"UI图集抠图\"", @@ -17957,8 +20379,8 @@ mod tests { "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ - "persist_editor_provider_source_image", - "persist_editor_provider_source_resource", + "prepare_editor_generated_image", + "prepare_editor_generated_asset", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], @@ -18204,18 +20626,19 @@ mod tests { let source = include_str!("editor_project.rs"); assert_function_contains( source, - "async fn complete_editor_source_only_fallback", + "async fn persist_editor_source_only_fallback_atomically", "pub(crate) async fn complete_editor_canvas_generation_with_items", &[ - "complete_editor_canvas_generation", - "source_record.resource.as_ref()", + "EditorCanvasLayoutPlan::generation", + "source_record.record.resource.as_ref()", "merge_editor_generation_warnings", "editor_postprocess_fallback_warning_with_dimension", + "persist_editor_generation_result_atomically", ], ); assert_function_not_contains( source, - "async fn complete_editor_source_only_fallback", + "async fn persist_editor_source_only_fallback_atomically", "pub(crate) async fn complete_editor_canvas_generation_with_items", &[ "persist_editor_generated_image", @@ -18243,7 +20666,7 @@ mod tests { source, start, end, - "complete_editor_source_only_fallback(", + "persist_editor_source_only_fallback_atomically(", 2, ); assert_function_contains_in_order( @@ -18251,11 +20674,11 @@ mod tests { start, end, &[ - "persist_editor_provider_source_resource", + "prepare_editor_generated_asset", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "Err(error)", - "complete_editor_source_only_fallback", + "persist_editor_source_only_fallback_atomically", "return Ok(json_success_body", ], ); @@ -18284,7 +20707,7 @@ mod tests { }); let fallback = &body[fallback_start..fallback_end]; assert!(fallback.contains("return Ok(json_success_body")); - assert!(fallback.contains("complete_editor_source_only_fallback")); + assert!(fallback.contains("persist_editor_source_only_fallback_atomically")); for forbidden in [ "persist_editor_generated_image(", "persist_editor_generated_asset(", @@ -18300,9 +20723,10 @@ mod tests { assert!(fallback.contains("icon_image_srcs: Vec::new()")); assert!(fallback.contains("slice_warning: None")); let success_tail = &body[fallback_end..]; - assert!(success_tail.contains("persist_editor_generated_image(")); - assert!(success_tail.contains("persist_editor_generated_asset(")); - assert!(success_tail.contains("persist_editor_spritesheet_slices")); + assert!(success_tail.contains("prepare_editor_generated_image(")); + assert!(success_tail.contains("prepare_editor_generated_asset(")); + assert!(success_tail.contains("prepare_editor_spritesheet_slices_for_generation")); + assert!(success_tail.contains("persist_editor_generation_result_atomically(")); } } for (start, end) in [ @@ -18328,7 +20752,7 @@ mod tests { .expect("normal transparent persistence path should follow fallback"); let fallback = &body[fallback_start..fallback_end]; for snippet in [ - "complete_editor_source_only_fallback", + "persist_editor_source_only_fallback_atomically", "return Ok(json_success_body", "icon_image_srcs: Vec::new()", "slice_warning: None", @@ -18370,8 +20794,8 @@ mod tests { .find("restore_editor_generated_image_output_dimensions_or_original") .expect("character provider image should be restored to delivery dimensions"); let source_persist = body - .find("persist_editor_provider_source_image") - .expect("character provider source should be persisted"); + .find("prepare_editor_generated_image") + .expect("character provider source should be prepared"); let phase = body .find("caller.report_processing_phase(state).await?") .expect("phase report should remain fallible"); @@ -18397,7 +20821,7 @@ mod tests { ); for snippet in [ "Err(error)", - "complete_editor_source_only_fallback", + "persist_editor_source_only_fallback_atomically", "return Ok(json_success_body", "warning: fallback_warning", ] { @@ -18419,8 +20843,9 @@ mod tests { } let success_tail = &body[fallback_end..]; - assert!(success_tail.contains("persist_editor_generated_image(")); - assert!(success_tail.contains("persist_editor_generated_asset(")); + assert!(success_tail.contains("prepare_editor_generated_image(")); + assert!(success_tail.contains("prepare_editor_generated_asset(")); + assert!(success_tail.contains("persist_editor_generation_result_atomically(")); assert!(success_tail.contains(".await?;")); let alpha_restore = body @@ -18436,7 +20861,7 @@ mod tests { .expect("processed image should only continue after dimension validation"); let dimension_fallback_body = &body[dimension_fallback..processed_image]; for snippet in [ - "complete_editor_source_only_fallback", + "persist_editor_source_only_fallback_atomically", "return Ok(json_success_body", "generation_warning", "warning: fallback_warning", @@ -18494,10 +20919,10 @@ mod tests { start, end, &[ - "persist_editor_provider_source_image", + "prepare_editor_generated_image", "remove_editor_generated_screen_background_with_bgfilter", "apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", - "persist_editor_generated_image", + "prepare_editor_generated_image", ], ); } @@ -18685,7 +21110,8 @@ mod tests { &[ "let provider_image", "restore_editor_image_edit_output_dimensions_or_original", - "persist_editor_generated_image", + "prepare_editor_generated_image", + "persist_editor_generation_result_atomically", ], ); assert_function_contains_in_order( @@ -18695,35 +21121,36 @@ mod tests { &[ "let mut image", "restore_editor_generated_image_output_dimensions_or_original", - "persist_editor_generated_image", + "prepare_editor_generated_image", + "persist_editor_generation_result_atomically", ], ); assert_function_occurrence_count( source, "pub(crate) async fn edit_editor_image_for_owner", "pub async fn remove_editor_image_background", - "persist_editor_generated_image(", + "prepare_editor_generated_image(", 1, ); assert_function_occurrence_count( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", - "persist_editor_generated_image(", - 1, + "prepare_editor_generated_image(", + 2, ); assert_function_occurrence_count( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn split_editor_icon_spritesheet", - "persist_editor_generated_image(", - 1, + "prepare_editor_generated_image(", + 2, ); assert_function_occurrence_count( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn split_editor_icon_spritesheet", - "persist_editor_spritesheet_slices(", + "prepare_editor_spritesheet_slices_for_generation(", 1, ); assert_function_not_contains( @@ -18744,7 +21171,7 @@ mod tests { &[ "normalize_editor_image_generation_style", "snap_editor_pixel_art_or_original", - "persist_editor_generated_image", + "prepare_editor_generated_image", ], ); assert_function_contains_in_order( @@ -18755,8 +21182,9 @@ mod tests { "normalize_editor_image_generation_style", "remove_editor_generated_screen_background_with_bgfilter", "apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", - "persist_editor_generated_image", - "persist_editor_spritesheet_slices", + "prepare_editor_generated_image", + "prepare_editor_spritesheet_slices_for_generation", + "persist_editor_generation_result_atomically", ], ); assert_function_contains_in_order( @@ -18764,12 +21192,13 @@ mod tests { "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ - "persist_editor_provider_source_resource", + "prepare_editor_generated_asset", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND", - "persist_editor_generated_asset", + "prepare_editor_generated_asset", "slice_editor_icon_spritesheet_all", - "persist_editor_spritesheet_slices", + "prepare_editor_spritesheet_slices_for_generation", + "persist_editor_generation_result_atomically", ], ); assert_function_contains( diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index ea4761c12..0fa3734ed 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -972,6 +972,7 @@ fn editor_generation_caller( audit_subject_user_id: Some(principal.owner_user_id().to_string()), audit_project_id: normalize_optional_string(project_id), phase_reporter: None, + operation: None, } } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 04ef53ec6..eb055f383 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -41,9 +41,9 @@ use crate::{ }, editor_project::{ EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EditorBackgroundRemovalRequest, - EditorGenerationCaller, EditorGenerationPhaseReporter, - EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, - EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, + EditorGenerationCaller, EditorGenerationOperationContext, EditorGenerationPhaseReporter, + EditorGenerationQueueResultContext, EditorIconSpritesheetGenerationRequest, + EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner, generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, remove_editor_image_background_for_owner, @@ -777,15 +777,7 @@ async fn process_external_generation_job_once( ) .await { - Ok(response) => { - complete_editor_generation_job_with_response( - &state, - &worker_id, - &job, - &response.0, - ) - .await - } + Ok(_) => Ok(()), Err(error) => { let message = error.body_text(); fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -813,9 +805,7 @@ async fn process_external_generation_job_once( ) .await { - Ok(result) => { - complete_editor_generation_job(&state, &worker_id, &job, result.0).await - } + Ok(_) => Ok(()), Err(error) => { let message = error.body_text(); fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -844,9 +834,7 @@ async fn process_external_generation_job_once( ) .await { - Ok(result) => { - complete_editor_generation_job(&state, &worker_id, &job, result.0).await - } + Ok(_) => Ok(()), Err(error) => { let message = error.body_text(); fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -874,15 +862,7 @@ async fn process_external_generation_job_once( ) .await { - Ok(response) => { - complete_editor_generation_job_with_response( - &state, - &worker_id, - &job, - &response.0, - ) - .await - } + Ok(_) => Ok(()), Err(error) => { let message = error.body_text(); fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -910,15 +890,7 @@ async fn process_external_generation_job_once( ) .await { - Ok(response) => { - complete_editor_generation_job_with_response( - &state, - &worker_id, - &job, - &response.0, - ) - .await - } + Ok(_) => Ok(()), Err(error) => { let message = error.body_text(); fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -942,15 +914,12 @@ async fn process_external_generation_job_once( match generate_editor_character_animation_for_owner( state.clone(), request_context, - job.owner_user_id.clone(), + editor_generation_worker_caller(&worker_id, &job)?, Ok(Json(payload)), - Some(editor_generation_phase_reporter(&worker_id, &job)?), ) .await { - Ok(result) => { - complete_editor_generation_job(&state, &worker_id, &job, result.0).await - } + Ok(_) => Ok(()), Err(response) => { let message = response_error_message(response).await; fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -974,14 +943,12 @@ async fn process_external_generation_job_once( match generate_editor_video_for_owner( state.clone(), request_context, - job.owner_user_id.clone(), + editor_generation_worker_caller(&worker_id, &job)?, Ok(Json(payload)), ) .await { - Ok(result) => { - complete_editor_generation_job(&state, &worker_id, &job, result.0).await - } + Ok(_) => Ok(()), Err(response) => { let message = response_error_message(response).await; fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -1005,14 +972,12 @@ async fn process_external_generation_job_once( match generate_editor_sound_effect_for_owner( state.clone(), request_context, - job.owner_user_id.clone(), + editor_generation_worker_caller(&worker_id, &job)?, Ok(Json(payload)), ) .await { - Ok(result) => { - complete_editor_generation_job(&state, &worker_id, &job, result.0).await - } + Ok(_) => Ok(()), Err(response) => { let message = response_error_message(response).await; fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -1036,14 +1001,12 @@ async fn process_external_generation_job_once( match generate_editor_background_music_for_owner( state.clone(), request_context, - job.owner_user_id.clone(), + editor_generation_worker_caller(&worker_id, &job)?, Ok(Json(payload)), ) .await { - Ok(result) => { - complete_editor_generation_job(&state, &worker_id, &job, result.0).await - } + Ok(_) => Ok(()), Err(response) => { let message = response_error_message(response).await; fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -1150,6 +1113,18 @@ fn editor_generation_worker_caller( audit_subject_user_id: Some(job.owner_user_id.clone()), audit_project_id: Some(job.source_entity_id.clone()), phase_reporter: Some(editor_generation_phase_reporter(worker_id, job)?), + operation: Some(EditorGenerationOperationContext { + operation_kind: job.job_kind.clone(), + operation_id: job.job_id.clone(), + operation_fingerprint: + shared_contracts::editor_generation::editor_generation_request_fingerprint( + job.job_kind.as_str(), + job.request_payload_json.as_str(), + ), + worker_id: Some(worker_id.to_string()), + lease_token: Some(require_job_lease_token(job)?), + queue_result_context: Some(EditorGenerationQueueResultContext::from_job(job)), + }), }) } @@ -1597,6 +1572,7 @@ async fn fail_job( error_message, retry_after_micros: now_micros.saturating_add(60_000_000), failed_at_micros: now_micros, + // 退款由 SpacetimeDB 在验证 lease 后与失败终态同事务结算。 refund_ledger_id: None, }) .await @@ -2203,7 +2179,7 @@ mod tests { } #[test] - fn editor_image_job_completion_keeps_inline_response_warning() { + fn editor_image_job_completion_is_committed_by_atomic_persistence() { let source = include_str!("external_generation_worker.rs"); let start = source .find("EDITOR_IMAGE_GENERATION_JOB_KIND => {") @@ -2214,19 +2190,10 @@ mod tests { .expect("editor image worker branch end marker should exist"); let branch = &branch_tail[..end]; - for snippet in [ - "Ok(response)", - "complete_editor_generation_job_with_response", - "&response.0", - ] { - assert!( - branch.contains(snippet), - "editor image completion should preserve response warning via {snippet}" - ); - } + assert!(branch.contains("Ok(_) => Ok(())")); assert!( - !branch.contains("Ok(_) => complete_editor_generation_job"), - "editor image completion must not discard the inline fallback warning" + !branch.contains("complete_editor_generation_job"), + "editor image completion must be part of the atomic result persistence" ); } @@ -2523,6 +2490,21 @@ mod tests { assert!(message.contains("90 秒")); } + #[test] + fn terminal_worker_failure_delegates_refund_to_the_fenced_failure_transaction() { + let source = include_str!("external_generation_worker.rs"); + let body = source + .split_once("async fn fail_job(") + .and_then(|(_, tail)| { + tail.split_once("async fn renew_job_lease(") + .map(|(body, _)| body) + }) + .expect("fail_job helper"); + assert!(!body.contains("settle_current_external_generation_attempt_refund(")); + assert!(body.contains(".fail_external_generation_job(")); + assert!(body.contains("refund_ledger_id: None")); + } + fn external_generation_job_record_fixture( lease_token: Option<&str>, ) -> ExternalGenerationJobRecord { diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index 2ddb3fd1a..3416683d2 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -9,10 +9,17 @@ use platform_oss::LegacyAssetPrefix; use serde_json::Value; use serde_json::json; use shared_contracts::assets; +use shared_contracts::editor_generation::{ + editor_generation_request_fingerprint, editor_generation_stable_asset_binding_id, + editor_generation_stable_asset_object_id, +}; use spacetime_client::ExternalGenerationJobRecord; use crate::{ api_response::json_success_body, + asset_billing::{ + execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary, + }, auth::AuthenticatedAccessToken, editor_generation_config::EditorGenerationPricingConfig, editor_generation_queue::{ @@ -21,9 +28,13 @@ use crate::{ editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller, }, editor_project::{ - EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest, - build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, - persist_editor_generated_media_asset, with_editor_media_duration_generation_input, + EditorCanvasGeneratedLayerInput, EditorCanvasLayoutPlan, EditorGenerationCaller, + EditorGenerationOperationContext, PersistEditorGeneratedAssetInput, + build_editor_canvas_generated_layer_item, editor_asset_payload_from_record, + editor_project_payload_from_record, editor_project_resource_payload_from_record, + normalize_optional_string, persist_editor_generation_result_atomically, + prepare_editor_generated_asset, sanitize_editor_client_generation_inputs, + with_editor_media_duration_generation_input, }, http_error::AppError, request_context::RequestContext, @@ -32,7 +43,8 @@ use crate::{ use super::{ errors::{map_platform_audio_error, parse_json_payload, vector_engine_bad_gateway}, - publish::{wait_for_generated_audio_asset, wait_for_generated_audio_asset_with_task_kind}, + persist::{PreparedAudioAsset, prepare_generated_audio_asset}, + publish::{build_audio_billing_asset_id, resolve_creation_audio_points_cost}, settings::require_vector_engine_audio_settings, types::{AudioAssetBindingTarget, AudioAssetSlot}, }; @@ -208,8 +220,20 @@ pub async fn generate_editor_sound_effect( }, )); } - generate_editor_sound_effect_for_owner(state, request_context, owner_user_id, Ok(Json(payload))) - .await + let caller = build_inline_editor_audio_caller( + owner_user_id, + &request_context, + EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, + &payload, + ) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + with_editor_generation_durable_billing_boundary(generate_editor_sound_effect_for_owner( + state, + request_context, + caller, + Ok(Json(payload)), + )) + .await } pub(crate) async fn enqueue_editor_sound_effect_generation_for_owner( @@ -249,10 +273,11 @@ pub(crate) async fn enqueue_editor_sound_effect_generation_for_owner( pub(crate) async fn generate_editor_sound_effect_for_owner( state: AppState, request_context: RequestContext, - owner_user_id: String, + caller: EditorGenerationCaller, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; + let owner_user_id = caller.owner_user_id.clone(); let project_id = payload.project_id.clone(); let canvas_completion = payload.canvas_completion.clone(); let generation_inputs = payload.generation_inputs.clone(); @@ -292,126 +317,68 @@ pub(crate) async fn generate_editor_sound_effect_for_owner( .await .map_err(map_platform_audio_error) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let generated = wait_for_generated_audio_asset_with_task_kind( + let target = build_editor_audio_target( + require_editor_audio_operation(&caller) + .map_err(|error| error.into_response_with_context(Some(&request_context)))? + .operation_id + .as_str(), + "sound_effect", + "editor_sound_effect", + Some(u64::from(normalized.price_mud_points)), + ); + let generated = wait_for_prepared_editor_audio_asset( &state, + &http_client, + &caller, owner_user_id.as_str(), task.task_id.clone(), AudioAssetSlot::SoundEffect, task.kind, - build_editor_audio_target( - &task.task_id, - "sound_effect", - "editor_sound_effect", - Some(u64::from(normalized.price_mud_points)), - ), + target, ) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let audio_src = generated - .audio_src - .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址")) - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let (resource, asset) = persist_editor_generated_media_asset( + let audio_src = generated.audio_src.clone(); + let persisted = persist_editor_audio_generation( &state, - PersistEditorGeneratedAssetRequest { - project_id: project_id.clone(), - owner_user_id: owner_user_id.clone(), - folder_id: asset_folder_id, - label: asset_label - .and_then(|value| { - let trimmed = value.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) - }) - .unwrap_or_else(|| "生成音效".to_string()), - image_src: audio_src.clone(), - object_key: generated.object_key.clone(), - asset_object_id: generated.asset_object_id.clone(), - width: EDITOR_AUDIO_WIDTH, - height: EDITOR_AUDIO_HEIGHT, + &caller, + PreparedEditorAudioGenerationInput { + project_id, + asset_folder_id, + asset_label, + fallback_label: "生成音效", + asset_kind: "sound-effect", + layer_kind: "sound-effect", prompt: normalized.prompt.clone(), - actual_prompt: Some(normalized.prompt.clone()), model: normalized.model.clone(), - provider: generated.provider.clone(), - task_id: generated.task_id.clone(), - source_resource_id: None, - asset_kind: Some("sound-effect".to_string()), - generation_inputs: generation_inputs.clone(), - thumbnail_src: None, - generation_cost_mud_points: u64::from(normalized.price_mud_points), - image_sequence_frames: None, - image_sequence_duration_ms: None, + generation_inputs, + canvas_completion, + price_mud_points: normalized.price_mud_points, + generated, }, ) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let completed_project = if let Some(completion) = canvas_completion.as_ref() { - let layer_id = format!("layer-editor-sound-effect-{}", generated.task_id); - let resource_id = resource - .as_ref() - .map(|resource| resource.resource_id.clone()) - .unwrap_or_else(|| format!("local-resource-editor-sound-effect-{}", generated.task_id)); - let item = build_editor_canvas_generated_layer_item( - completion, - EditorCanvasGeneratedLayerInput { - layer_id: layer_id.clone(), - resource_id, - title: completion.title.trim().to_string(), - src: audio_src.clone(), - thumbnail_src: None, - original_width: EDITOR_AUDIO_WIDTH, - original_height: EDITOR_AUDIO_HEIGHT, - display_width: completion.placeholder.width, - display_height: completion.placeholder.height, - source_type: "generated".to_string(), - prompt: Some(normalized.prompt.clone()), - actual_prompt: Some(normalized.prompt.clone()), - model: Some(normalized.model.clone()), - provider: Some(generated.provider.clone()), - task_id: Some(generated.task_id.clone()), - object_key: generated.object_key.clone(), - asset_object_id: generated.asset_object_id.clone(), - source_resource_id: None, - asset_kind: Some("sound-effect".to_string()), - generation_inputs, - }, - ); - complete_editor_canvas_generation_with_items( - &state, - owner_user_id.as_str(), - project_id.as_deref(), - canvas_completion.as_ref(), - vec![item], - Some(layer_id), - ) - .await - .map_err(|error| error.into_response_with_context(Some(&request_context)))? - } else { - None - }; - let completed_project = - completed_project.and_then(|project| serde_json::to_value(project).ok()); - let resource = resource.and_then(|resource| serde_json::to_value(resource).ok()); - let asset = asset.and_then(|asset| serde_json::to_value(asset).ok()); Ok(json_success_body( Some(&request_context), assets::EditorAudioGenerateResponse { ok: true, audio_src, - object_key: generated.object_key, - asset_object_id: generated.asset_object_id, + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), width: EDITOR_AUDIO_WIDTH, height: EDITOR_AUDIO_HEIGHT, source_type: "generated".to_string(), prompt: normalized.prompt.clone(), actual_prompt: Some(normalized.prompt), model: normalized.model, - task_id: generated.task_id, + task_id: persisted.provider_task_id, price_mud_points: normalized.price_mud_points, audio_kind: "sound-effect".to_string(), - project: completed_project, - resource, - asset, + project: persisted.project, + resource: persisted.resource, + asset: persisted.asset, queue_state: None, }, )) @@ -442,12 +409,19 @@ pub async fn generate_editor_background_music( }, )); } - generate_editor_background_music_for_owner( + let caller = build_inline_editor_audio_caller( + owner_user_id, + &request_context, + EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, + &payload.payload, + ) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + with_editor_generation_durable_billing_boundary(generate_editor_background_music_for_owner( state, request_context, - owner_user_id, + caller, Ok(Json(payload.into_payload())), - ) + )) .await } @@ -563,10 +537,11 @@ fn build_editor_background_music_generate_response( pub(crate) async fn generate_editor_background_music_for_owner( state: AppState, request_context: RequestContext, - owner_user_id: String, + caller: EditorGenerationCaller, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; + let owner_user_id = caller.owner_user_id.clone(); let project_id = payload.project_id.clone(); let canvas_completion = payload.canvas_completion.clone(); let generation_inputs = payload.generation_inputs.clone(); @@ -601,122 +576,533 @@ pub(crate) async fn generate_editor_background_music_for_owner( .await .map_err(map_platform_audio_error) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let generated = wait_for_generated_audio_asset( + let target = build_editor_audio_target( + require_editor_audio_operation(&caller) + .map_err(|error| error.into_response_with_context(Some(&request_context)))? + .operation_id + .as_str(), + "background_music", + "editor_background_music", + Some(u64::from(normalized.price_mud_points)), + ); + let generated = wait_for_prepared_editor_audio_asset( &state, + &http_client, + &caller, owner_user_id.as_str(), task.task_id.clone(), AudioAssetSlot::BackgroundMusic, - build_editor_audio_target( - &task.task_id, - "background_music", - "editor_background_music", - Some(u64::from(normalized.price_mud_points)), - ), + AudioAssetSlot::BackgroundMusic.task_kind(), + target, ) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let audio_src = generated - .audio_src - .clone() - .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址")) - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let (resource, asset) = persist_editor_generated_media_asset( + let audio_src = generated.audio_src.clone(); + let persisted = persist_editor_audio_generation( &state, - PersistEditorGeneratedAssetRequest { - project_id: project_id.clone(), - owner_user_id: owner_user_id.clone(), - folder_id: asset_folder_id, - label: asset_label - .and_then(|value| { - let trimmed = value.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) - }) - .unwrap_or_else(|| "生成背景音乐".to_string()), - image_src: audio_src.clone(), - object_key: generated.object_key.clone(), - asset_object_id: generated.asset_object_id.clone(), - width: EDITOR_AUDIO_WIDTH, - height: EDITOR_AUDIO_HEIGHT, + &caller, + PreparedEditorAudioGenerationInput { + project_id, + asset_folder_id, + asset_label, + fallback_label: "生成背景音乐", + asset_kind: "background-music", + layer_kind: "background-music", prompt: normalized.gpt_description_prompt.clone(), - actual_prompt: Some(normalized.gpt_description_prompt.clone()), model: platform_audio::SUNO_DEFAULT_MODEL.to_string(), - provider: generated.provider.clone(), - task_id: generated.task_id.clone(), - source_resource_id: None, - asset_kind: Some("background-music".to_string()), - generation_inputs: generation_inputs.clone(), - thumbnail_src: None, - generation_cost_mud_points: u64::from(normalized.price_mud_points), - image_sequence_frames: None, - image_sequence_duration_ms: None, + generation_inputs, + canvas_completion, + price_mud_points: normalized.price_mud_points, + generated, }, ) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let completed_project = if let Some(completion) = canvas_completion.as_ref() { - let layer_id = format!("layer-editor-background-music-{}", generated.task_id); - let resource_id = resource - .as_ref() - .map(|resource| resource.resource_id.clone()); - let item = build_editor_canvas_generated_layer_item( + + Ok(json_success_body( + Some(&request_context), + build_editor_background_music_generate_response( + audio_src.clone(), + shared_contracts::creation_audio::GeneratedAudioAssetResponse { + kind: AudioAssetSlot::BackgroundMusic.creation_contract_kind(), + task_id: persisted.provider_task_id, + provider: persisted.provider, + status: "completed".to_string(), + asset_object_id: Some(persisted.asset_object_id), + object_key: Some(persisted.object_key), + asset_kind: Some("editor_background_music".to_string()), + audio_src: Some(audio_src), + }, + normalized, + persisted.project, + persisted.resource, + persisted.asset, + ), + )) +} + +#[derive(Debug)] +struct PreparedGeneratedEditorAudio { + provider_task_id: String, + provider: String, + audio_src: String, + object_key: String, + asset_object_id: String, + prepared: PreparedAudioAsset, +} + +#[derive(Debug)] +struct PreparedEditorAudioGenerationInput { + project_id: Option, + asset_folder_id: Option, + asset_label: Option, + fallback_label: &'static str, + asset_kind: &'static str, + layer_kind: &'static str, + prompt: String, + model: String, + generation_inputs: Option, + canvas_completion: Option, + price_mud_points: u32, + generated: PreparedGeneratedEditorAudio, +} + +#[derive(Debug)] +struct PersistedEditorAudioGeneration { + provider_task_id: String, + provider: String, + object_key: String, + asset_object_id: String, + project: Option, + resource: Option, + asset: Option, +} + +fn build_inline_editor_audio_caller( + owner_user_id: String, + request_context: &RequestContext, + operation_kind: &str, + payload: &T, +) -> Result { + let request_payload_json = serde_json::to_string(payload).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": format!("序列化编辑器音频生成幂等请求失败:{error}"), + })) + })?; + Ok(EditorGenerationCaller { + audit_subject_user_id: Some(owner_user_id.clone()), + owner_user_id, + audit_project_id: None, + phase_reporter: None, + operation: Some(EditorGenerationOperationContext { + operation_kind: operation_kind.to_string(), + operation_id: request_context.request_id().trim().to_string(), + operation_fingerprint: editor_generation_request_fingerprint( + operation_kind, + request_payload_json.as_str(), + ), + worker_id: None, + lease_token: None, + queue_result_context: None, + }), + }) +} + +fn require_editor_audio_operation( + caller: &EditorGenerationCaller, +) -> Result<&EditorGenerationOperationContext, AppError> { + caller.operation.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "编辑器音频生成缺少稳定 operation 上下文。", + })) + }) +} + +#[allow(clippy::too_many_arguments)] +async fn wait_for_prepared_editor_audio_asset( + state: &AppState, + http_client: &reqwest::Client, + caller: &EditorGenerationCaller, + owner_user_id: &str, + provider_task_id: String, + slot: AudioAssetSlot, + task_kind: platform_audio::AudioTaskKind, + target: AudioAssetBindingTarget, +) -> Result { + let provider_task_id = platform_audio::normalize_limited_text(&provider_task_id, "taskId", 160) + .map_err(map_platform_audio_error)?; + let operation = require_editor_audio_operation(caller)?; + let asset_object_id = editor_generation_stable_asset_object_id( + owner_user_id, + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + "primary-audio", + ); + let asset_binding_id = editor_generation_stable_asset_binding_id( + owner_user_id, + operation.operation_kind.as_str(), + operation.operation_id.as_str(), + "primary-audio", + ); + let settings = require_vector_engine_audio_settings(state)?; + let mut latest_status = String::new(); + for _ in 0..40 { + let (status, audio_urls) = platform_audio::resolve_audio_task_download_urls( + http_client, + &settings, + task_kind, + provider_task_id.as_str(), + ) + .await + .map_err(map_platform_audio_error)?; + if platform_audio::is_pending_task_status(&status) && audio_urls.is_empty() { + latest_status = status; + tokio::time::sleep(std::time::Duration::from_millis(3_000)).await; + continue; + } + if platform_audio::is_failed_task_status(&status) { + return Err(vector_engine_bad_gateway( + "音频生成任务失败,请调整提示词后重试", + )); + } + let audio_url = audio_urls + .into_iter() + .next() + .ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?; + let billing_asset_id = + build_audio_billing_asset_id(operation.operation_id.as_str(), slot, &target); + let points_cost = resolve_creation_audio_points_cost(slot, &target); + let prepared = execute_billable_asset_operation_with_cost( + state, + owner_user_id, + target.asset_kind.as_str(), + billing_asset_id.as_str(), + points_cost, + async { + let audio = platform_audio::download_generated_audio( + http_client, + audio_url.as_str(), + task_kind.provider(), + ) + .await + .map_err(map_platform_audio_error)?; + prepare_generated_audio_asset( + state, + http_client, + owner_user_id, + provider_task_id.as_str(), + operation.operation_id.as_str(), + task_kind, + target.clone(), + audio, + asset_object_id.clone(), + asset_binding_id.clone(), + ) + .await + }, + ) + .await?; + return Ok(PreparedGeneratedEditorAudio { + provider_task_id, + provider: task_kind.provider().to_string(), + audio_src: prepared.audio_src.clone(), + object_key: prepared.object_key.clone(), + asset_object_id, + prepared, + }); + } + + Err(vector_engine_bad_gateway(format!( + "音频生成超时:{}", + if latest_status.trim().is_empty() { + provider_task_id + } else { + latest_status + } + ))) +} + +async fn persist_editor_audio_generation( + state: &AppState, + caller: &EditorGenerationCaller, + input: PreparedEditorAudioGenerationInput, +) -> Result { + let operation = require_editor_audio_operation(caller)?; + let owner_user_id = caller.owner_user_id.as_str(); + let project_id = normalize_optional_string(input.project_id.clone()); + let generation_inputs = + sanitize_editor_client_generation_inputs(input.generation_inputs.clone()); + let completed_at_micros = super::clock::current_utc_micros(); + let label = normalize_editor_audio_asset_label(input.asset_label.clone(), input.fallback_label); + let mut prepared_asset = prepare_editor_generated_asset( + caller, + "primary-audio", + Some(input.generated.prepared.asset_object.clone()), + PersistEditorGeneratedAssetInput { + project_id: project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + folder_id: input.asset_folder_id.clone(), + label, + image_src: input.generated.audio_src.clone(), + object_key: Some(input.generated.object_key.clone()), + asset_object_id: Some(input.generated.asset_object_id.clone()), + width: EDITOR_AUDIO_WIDTH, + height: EDITOR_AUDIO_HEIGHT, + prompt: input.prompt.clone(), + actual_prompt: Some(input.prompt.clone()), + model: input.model.clone(), + provider: input.generated.provider.clone(), + task_id: input.generated.provider_task_id.clone(), + group_task_id: None, + group_task_expected_asset_count: None, + source_resource_id: None, + asset_kind: Some(input.asset_kind.to_string()), + generation_inputs: generation_inputs.clone(), + thumbnail_src: None, + generation_cost_mud_points: u64::from(input.price_mud_points), + image_sequence_frames: None, + image_sequence_duration_ms: None, + }, + completed_at_micros, + )?; + prepared_asset.item.binding = Some(input.generated.prepared.binding.clone()); + let resource_id = prepared_asset + .record + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()) + .unwrap_or_else(|| { + format!( + "local-resource-editor-{}-{}", + input.layer_kind, operation.operation_id + ) + }); + let layer_id = format!( + "layer-editor-{}-{}", + input.layer_kind, operation.operation_id + ); + let canvas_item = input.canvas_completion.as_ref().map(|completion| { + build_editor_canvas_generated_layer_item( completion, EditorCanvasGeneratedLayerInput { layer_id: layer_id.clone(), - resource_id: resource_id.unwrap_or_else(|| { - format!( - "local-resource-editor-background-music-{}", - generated.task_id - ) - }), + resource_id: resource_id.clone(), title: completion.title.trim().to_string(), - src: audio_src.clone(), + src: input.generated.audio_src.clone(), thumbnail_src: None, original_width: EDITOR_AUDIO_WIDTH, original_height: EDITOR_AUDIO_HEIGHT, display_width: completion.placeholder.width, display_height: completion.placeholder.height, source_type: "generated".to_string(), - prompt: Some(normalized.gpt_description_prompt.clone()), - actual_prompt: Some(normalized.gpt_description_prompt.clone()), - model: Some(platform_audio::SUNO_DEFAULT_MODEL.to_string()), - provider: Some(generated.provider.clone()), - task_id: Some(generated.task_id.clone()), - object_key: generated.object_key.clone(), - asset_object_id: generated.asset_object_id.clone(), + prompt: Some(input.prompt.clone()), + actual_prompt: Some(input.prompt.clone()), + model: Some(input.model.clone()), + provider: Some(input.generated.provider.clone()), + task_id: Some(input.generated.provider_task_id.clone()), + object_key: Some(input.generated.object_key.clone()), + asset_object_id: Some(input.generated.asset_object_id.clone()), source_resource_id: None, - asset_kind: Some("background-music".to_string()), - generation_inputs, + asset_kind: Some(input.asset_kind.to_string()), + generation_inputs: generation_inputs.clone(), }, - ); - complete_editor_canvas_generation_with_items( - &state, - owner_user_id.as_str(), - project_id.as_deref(), - canvas_completion.as_ref(), - vec![item], - Some(layer_id), ) - .await - .map_err(|error| error.into_response_with_context(Some(&request_context)))? + }); + let canvas_layout_plan = if let Some(canvas_item) = canvas_item { + EditorCanvasLayoutPlan::generation( + owner_user_id, + project_id.as_deref(), + input.canvas_completion.as_ref(), + vec![canvas_item], + Some(layer_id.clone()), + ) } else { - None + EditorCanvasLayoutPlan::None }; - let completed_project = - completed_project.and_then(|project| serde_json::to_value(project).ok()); - let resource = resource.and_then(|resource| serde_json::to_value(resource).ok()); - let asset = asset.and_then(|asset| serde_json::to_value(asset).ok()); + let prepared_resource_json = prepared_asset + .record + .resource + .as_ref() + .and_then(|resource| serde_json::to_value(resource).ok()); + let prepared_asset_json = prepared_asset + .record + .asset + .as_ref() + .and_then(|asset| serde_json::to_value(asset).ok()); + let result_payload_json = build_editor_audio_job_result_payload( + caller, + &input, + prepared_resource_json.as_ref(), + prepared_asset_json.as_ref(), + ); + let persisted = persist_editor_generation_result_atomically( + state, + caller, + vec![prepared_asset.item], + canvas_layout_plan, + result_payload_json, + completed_at_micros, + ) + .await?; + let persisted_item = persisted + .items + .into_iter() + .find(|item| item.slot == "primary-audio") + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-generation-operation", + "message": "编辑器音频原子提交缺少 primary-audio 结果。", + })) + })?; - Ok(json_success_body( - Some(&request_context), - build_editor_background_music_generate_response( - audio_src, - generated, - normalized, - completed_project, + Ok(PersistedEditorAudioGeneration { + provider_task_id: input.generated.provider_task_id, + provider: input.generated.provider, + object_key: input.generated.object_key, + asset_object_id: input.generated.asset_object_id, + project: persisted + .project + .map(editor_project_payload_from_record) + .and_then(|project| serde_json::to_value(project).ok()), + resource: persisted_item + .project_resource + .map(editor_project_resource_payload_from_record) + .and_then(|resource| serde_json::to_value(resource).ok()), + asset: persisted_item + .asset + .map(editor_asset_payload_from_record) + .and_then(|asset| serde_json::to_value(asset).ok()), + }) +} + +fn build_editor_audio_job_result_payload( + caller: &EditorGenerationCaller, + input: &PreparedEditorAudioGenerationInput, + resource: Option<&Value>, + asset: Option<&Value>, +) -> Option { + require_editor_audio_operation(caller) + .ok()? + .worker_id + .as_ref()?; + let compact_resource = resource.map(|resource| { + compact_editor_audio_result_object( resource, + &[ + "resourceId", + "projectId", + "objectKey", + "assetObjectId", + "imageSrc", + "width", + "height", + "sourceType", + "assetKind", + "taskId", + "sourceResourceId", + ], + ) + }); + let compact_asset = asset.map(|asset| { + compact_editor_audio_result_object( asset, - ), - )) + &[ + "assetId", + "folderId", + "objectKey", + "assetObjectId", + "imageSrc", + "thumbnailSrc", + "width", + "height", + "sourceType", + "assetKind", + "taskId", + ], + ) + }); + let stable_result = json!({ + "ok": true, + "audioSrc": input.generated.audio_src, + "objectKey": input.generated.object_key, + "assetObjectId": input.generated.asset_object_id, + "width": EDITOR_AUDIO_WIDTH, + "height": EDITOR_AUDIO_HEIGHT, + "sourceType": "generated", + "prompt": input.prompt, + "actualPrompt": input.prompt, + "model": input.model, + "taskId": input.generated.provider_task_id, + "priceMudPoints": input.price_mud_points, + "audioKind": input.layer_kind, + "resource": compact_resource, + "asset": compact_asset, + }); + let mut payload = json!({ + "sourceModule": "editor-canvas", + "sourceEntityId": caller.audit_project_id, + "result": stable_result.clone(), + }); + if input + .generation_inputs + .as_ref() + .and_then(|value| value.get("source")) + .and_then(Value::as_str) + .is_some_and(|source| source.trim() == "editor-agent") + && let Some(object) = payload.as_object_mut() + { + let mut agent_result = stable_result; + if let Some(agent_object) = agent_result.as_object_mut() { + agent_object.remove("asset"); + if let Some(resource) = agent_object + .get_mut("resource") + .and_then(Value::as_object_mut) + { + resource.retain(|key, _| { + matches!( + key.as_str(), + "resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId" + ) + }); + } + } + object.insert("editor-agent-tool-call-result".to_string(), agent_result); + } + Some(payload.to_string()) +} + +fn compact_editor_audio_result_object(value: &Value, allowed_fields: &[&str]) -> Value { + let Some(object) = value.as_object() else { + return Value::Null; + }; + Value::Object( + object + .iter() + .filter(|(key, value)| { + allowed_fields.contains(&key.as_str()) + && (!matches!(key.as_str(), "imageSrc" | "thumbnailSrc") + || value.as_str().is_some_and(|value| { + let value = value.trim(); + !value.is_empty() + && value.starts_with('/') + && !value.starts_with("//") + && !value.contains('?') + && !value.contains('#') + })) + }) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + ) +} + +fn normalize_editor_audio_asset_label(value: Option, fallback: &str) -> String { + normalize_optional_string(value) + .unwrap_or_else(|| fallback.to_string()) + .chars() + .take(shared_contracts::assets::EDITOR_ASSET_LABEL_MAX_CHARS) + .collect() } fn build_editor_audio_target( @@ -752,10 +1138,17 @@ mod tests { use super::{ CanonicalEditorBackgroundMusicSubmissionPayload, NormalizedEditorBackgroundMusicRequest, - build_editor_background_music_generate_response, normalize_editor_background_music_request, - normalize_editor_sound_effect_request, prepare_editor_background_music_queue_job, + build_editor_background_music_generate_response, build_inline_editor_audio_caller, + normalize_editor_background_music_request, normalize_editor_sound_effect_request, + prepare_editor_background_music_queue_job, + }; + use crate::{ + editor_generation_queue::{ + EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, + serialize_editor_generation_job_payload_for_test, + }, + request_context::RequestContext, }; - use crate::editor_generation_queue::serialize_editor_generation_job_payload_for_test; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -810,6 +1203,54 @@ mod tests { } } + #[test] + fn inline_audio_operation_uses_request_id_and_exact_payload_fingerprint() { + let request_context = RequestContext::new( + "request-audio-1".to_string(), + "POST /api/editor/audio".to_string(), + std::time::Duration::ZERO, + false, + ); + let payload = sound_effect_payload("脚步声", Some("audio1.0"), 3); + let caller = build_inline_editor_audio_caller( + "user-1".to_string(), + &request_context, + EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, + &payload, + ) + .expect("inline audio operation should be prepared"); + let operation = caller.operation.expect("operation context should exist"); + let request_payload_json = + serde_json::to_string(&payload).expect("payload should serialize"); + + assert_eq!(operation.operation_id, "request-audio-1"); + assert_eq!( + operation.operation_fingerprint, + shared_contracts::editor_generation::editor_generation_request_fingerprint( + EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, + request_payload_json.as_str(), + ) + ); + assert!(operation.worker_id.is_none()); + assert!(operation.lease_token.is_none()); + } + + #[test] + fn audio_queue_result_keeps_required_editor_agent_response_fields() { + let source = include_str!("generation.rs"); + let body = source + .split_once("fn build_editor_audio_job_result_payload(") + .and_then(|(_, tail)| { + tail.split_once("fn compact_editor_audio_result_object(") + .map(|(body, _)| body) + }) + .expect("audio queue result builder"); + + assert!(body.contains("\"ok\": true")); + assert!(body.contains("\"prompt\": input.prompt")); + assert!(body.contains("\"actualPrompt\": input.prompt")); + } + #[test] fn editor_background_music_normalization_uses_canonical_generation_prompt() { let representative = background_music_prompt_fixture("representative-complex"); diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs index 5900466fb..82ab136bd 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs @@ -1,7 +1,7 @@ use axum::http::StatusCode; use module_assets::{ - AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, - generate_asset_binding_id, generate_asset_object_id, + AssetEntityBindingInput, AssetObjectAccessPolicy, AssetObjectUpsertInput, + build_asset_entity_binding_input, build_asset_object_upsert_input, }; use platform_audio::{DownloadedAudio, GeneratedAudioPersistInput, GeneratedAudioPersistTarget}; use serde_json::json; @@ -10,7 +10,7 @@ use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppStat use super::{ clock::current_utc_micros, - errors::{map_asset_field_error, map_spacetime_error}, + errors::map_asset_field_error, types::{AudioAssetBindingTarget, AudioAssetSlot}, }; @@ -21,16 +21,45 @@ pub(super) struct PersistedAudioAsset { pub(super) audio_src: String, } +#[derive(Clone, Debug)] +pub(super) struct PreparedAudioAsset { + pub(super) asset_object: AssetObjectUpsertInput, + pub(super) binding: AssetEntityBindingInput, + pub(super) object_key: String, + pub(super) audio_src: String, +} + pub(super) async fn persist_generated_audio_asset( + _state: &AppState, + _http_client: &reqwest::Client, + _owner_user_id: &str, + _task_id: &str, + _slot: AudioAssetSlot, + _task_kind: platform_audio::AudioTaskKind, + _target: AudioAssetBindingTarget, + _audio: DownloadedAudio, +) -> Result { + Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-operation", + "message": "旧音频分步持久化路径已禁用;编辑器生成必须使用原子结果提交。", + })), + ) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_generated_audio_asset( state: &AppState, http_client: &reqwest::Client, owner_user_id: &str, - task_id: &str, - _slot: AudioAssetSlot, + provider_task_id: &str, + storage_operation_id: &str, task_kind: platform_audio::AudioTaskKind, target: AudioAssetBindingTarget, audio: DownloadedAudio, -) -> Result { + asset_object_id: String, + asset_binding_id: String, +) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", @@ -42,7 +71,7 @@ pub(super) async fn persist_generated_audio_asset( let put_request = platform_audio::prepare_generated_audio_put_request(GeneratedAudioPersistInput { owner_user_id: owner_user_id.to_string(), - task_id: task_id.to_string(), + task_id: storage_operation_id.to_string(), task_kind, target: GeneratedAudioPersistTarget { entity_kind: target.entity_kind.clone(), @@ -69,49 +98,38 @@ pub(super) async fn persist_generated_audio_asset( .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let now_micros = current_utc_micros(); - let asset_object = state - .spacetime_client() - .confirm_asset_object( - build_asset_object_upsert_input( - generate_asset_object_id(now_micros), - head.bucket, - head.object_key, - AssetObjectAccessPolicy::Private, - head.content_type.or(Some(audio_mime_type)), - head.content_length, - head.etag, - target.asset_kind.clone(), - Some(task_id.to_string()), - Some(owner_user_id.to_string()), - target.profile_id.clone(), - Some(target.entity_id.clone()), - now_micros, - ) - .map_err(map_asset_field_error)?, - ) - .await - .map_err(map_spacetime_error)?; - state - .spacetime_client() - .bind_asset_object_to_entity( - build_asset_entity_binding_input( - generate_asset_binding_id(now_micros), - asset_object.asset_object_id.clone(), - target.entity_kind, - target.entity_id, - target.slot, - target.asset_kind, - Some(owner_user_id.to_string()), - target.profile_id, - now_micros, - ) - .map_err(map_asset_field_error)?, - ) - .await - .map_err(map_spacetime_error)?; + let asset_object = build_asset_object_upsert_input( + asset_object_id.clone(), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(audio_mime_type)), + head.content_length, + head.etag, + target.asset_kind.clone(), + Some(provider_task_id.to_string()), + Some(owner_user_id.to_string()), + target.profile_id.clone(), + Some(target.entity_id.clone()), + now_micros, + ) + .map_err(map_asset_field_error)?; + let binding = build_asset_entity_binding_input( + asset_binding_id, + asset_object_id, + target.entity_kind, + target.entity_id, + target.slot, + target.asset_kind, + Some(owner_user_id.to_string()), + target.profile_id, + now_micros, + ) + .map_err(map_asset_field_error)?; - Ok(PersistedAudioAsset { - asset_object_id: asset_object.asset_object_id, + Ok(PreparedAudioAsset { + asset_object, + binding, object_key: put_result.object_key, audio_src: put_result.legacy_public_path, }) diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs index 52a5b22e7..5dec72306 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs @@ -144,7 +144,8 @@ pub struct EditorImageEditResult { pub prompt: String, pub actual_prompt: Option, pub model: String, - pub provider: String, + #[serde(default)] + pub provider: Option, pub task_id: String, pub resource: Option, pub asset: Option, @@ -193,3 +194,30 @@ impl EditImageTool { ) } } + +#[cfg(test)] +mod tests { + use super::EditorImageEditResult; + + #[test] + fn compact_edit_result_deserializes_without_provider() { + let result: EditorImageEditResult = serde_json::from_value(serde_json::json!({ + "imageSrc": "/api/assets/object/edited.png", + "objectKey": "generated/edited.png", + "assetObjectId": "asset-object-2", + "width": 512, + "height": 512, + "sourceType": "generated", + "prompt": "修改图片", + "actualPrompt": "修改后的图片", + "model": "gpt-image-2", + "taskId": "task-2", + "resource": null, + "asset": null, + "project": null + })) + .expect("compact Agent edit result should not require provider"); + + assert_eq!(result.provider, None); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs index 03f774b5c..f4f9950ef 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs @@ -160,7 +160,8 @@ pub struct EditorImageGenerationResult { pub prompt: String, pub actual_prompt: Option, pub model: String, - pub provider: String, + #[serde(default)] + pub provider: Option, pub task_id: String, pub resource: Option, pub asset: Option, @@ -205,3 +206,30 @@ impl GenerateImageTool { ) } } + +#[cfg(test)] +mod tests { + use super::EditorImageGenerationResult; + + #[test] + fn compact_generation_result_deserializes_without_provider() { + let result: EditorImageGenerationResult = serde_json::from_value(serde_json::json!({ + "imageSrc": "/api/assets/object/generated.png", + "objectKey": "generated/image.png", + "assetObjectId": "asset-object-1", + "width": 512, + "height": 512, + "sourceType": "generated", + "prompt": "生成一张图片", + "actualPrompt": "生成一张清晰图片", + "model": "gpt-image-2", + "taskId": "task-1", + "resource": null, + "asset": null, + "project": null + })) + .expect("compact Agent generation result should not require provider"); + + assert_eq!(result.provider, None); + } +} diff --git a/server-rs/crates/shared-contracts/Cargo.toml b/server-rs/crates/shared-contracts/Cargo.toml index af43dd48c..7d23e1bd9 100644 --- a/server-rs/crates/shared-contracts/Cargo.toml +++ b/server-rs/crates/shared-contracts/Cargo.toml @@ -12,3 +12,4 @@ oss-contracts = [] [dependencies] serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } diff --git a/server-rs/crates/shared-contracts/src/editor_generation.rs b/server-rs/crates/shared-contracts/src/editor_generation.rs new file mode 100644 index 000000000..5ffd1b23c --- /dev/null +++ b/server-rs/crates/shared-contracts/src/editor_generation.rs @@ -0,0 +1,202 @@ +use sha2::{Digest, Sha256}; + +pub const EDITOR_GENERATION_RESOURCE_ID_PREFIX: &str = "editor-resource-"; +pub const EDITOR_GENERATION_ASSET_ID_PREFIX: &str = "editor-asset-"; +pub const EDITOR_GENERATION_ASSET_OBJECT_ID_PREFIX: &str = "assetobj_"; +pub const EDITOR_GENERATION_ASSET_BINDING_ID_PREFIX: &str = "assetbind_"; + +pub fn editor_generation_request_fingerprint( + operation_kind: &str, + request_payload_json: &str, +) -> String { + sha256_hex( + format!( + "editor-generation-request-v1\0{}\0{}", + operation_kind.trim(), + request_payload_json + ) + .as_bytes(), + ) +} + +pub fn editor_generation_stable_asset_object_id( + owner_user_id: &str, + operation_kind: &str, + operation_id: &str, + slot: &str, +) -> String { + editor_generation_stable_record_id( + EDITOR_GENERATION_ASSET_OBJECT_ID_PREFIX, + owner_user_id, + operation_kind, + operation_id, + slot, + "asset-object", + ) +} + +pub fn editor_generation_stable_asset_binding_id( + owner_user_id: &str, + operation_kind: &str, + operation_id: &str, + slot: &str, +) -> String { + editor_generation_stable_record_id( + EDITOR_GENERATION_ASSET_BINDING_ID_PREFIX, + owner_user_id, + operation_kind, + operation_id, + slot, + "asset-binding", + ) +} + +pub fn editor_generation_stable_resource_id( + owner_user_id: &str, + operation_kind: &str, + operation_id: &str, + slot: &str, +) -> String { + editor_generation_stable_record_id( + EDITOR_GENERATION_RESOURCE_ID_PREFIX, + owner_user_id, + operation_kind, + operation_id, + slot, + "project-resource", + ) +} + +pub fn editor_generation_stable_asset_id( + owner_user_id: &str, + operation_kind: &str, + operation_id: &str, + slot: &str, +) -> String { + editor_generation_stable_record_id( + EDITOR_GENERATION_ASSET_ID_PREFIX, + owner_user_id, + operation_kind, + operation_id, + slot, + "account-asset", + ) +} + +fn editor_generation_stable_record_id( + prefix: &str, + owner_user_id: &str, + operation_kind: &str, + operation_id: &str, + slot: &str, + record_kind: &str, +) -> String { + let digest = sha256_hex( + format!( + "editor-generation-result-v1\0{}\0{}\0{}\0{}\0{}", + owner_user_id.trim(), + operation_kind.trim(), + operation_id.trim(), + slot.trim(), + record_kind, + ) + .as_bytes(), + ); + format!("{prefix}{}", &digest[..32]) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_editor_generation_ids_are_replay_safe_and_domain_separated() { + let first = editor_generation_stable_resource_id( + "owner-1", + "editor_image_generation", + "task-1", + "primary", + ); + assert_eq!( + first, + editor_generation_stable_resource_id( + "owner-1", + "editor_image_generation", + "task-1", + "primary", + ) + ); + assert_ne!( + first, + editor_generation_stable_resource_id( + "owner-1", + "editor_image_generation", + "task-1", + "provider-source", + ) + ); + assert_ne!( + first, + editor_generation_stable_asset_id( + "owner-1", + "editor_image_generation", + "task-1", + "primary", + ) + ); + assert_ne!( + first, + editor_generation_stable_asset_object_id( + "owner-1", + "editor_image_generation", + "task-1", + "primary", + ) + ); + assert_eq!( + editor_generation_stable_asset_object_id( + "owner-1", + "editor_image_generation", + "task-1", + "primary", + ) + .len(), + EDITOR_GENERATION_ASSET_OBJECT_ID_PREFIX.len() + 32 + ); + assert_ne!( + editor_generation_stable_asset_object_id( + "owner-1", + "editor_image_generation", + "task-1", + "primary", + ), + editor_generation_stable_asset_binding_id( + "owner-1", + "editor_image_generation", + "task-1", + "primary", + ) + ); + } + + #[test] + fn request_fingerprint_binds_kind_and_exact_payload() { + let first = editor_generation_request_fingerprint("image", r#"{"prompt":"a"}"#); + assert_eq!( + first, + editor_generation_request_fingerprint("image", r#"{"prompt":"a"}"#) + ); + assert_ne!( + first, + editor_generation_request_fingerprint("image", r#"{"prompt":"b"}"#) + ); + assert_ne!( + first, + editor_generation_request_fingerprint("video", r#"{"prompt":"a"}"#) + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 8bbc3b68c..8ad2b7140 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -18,6 +18,14 @@ pub mod creation_entry_config; #[cfg(any())] pub mod creative_agent; pub mod editor_agent; +pub mod editor_generation; +pub use editor_generation::{ + EDITOR_GENERATION_ASSET_BINDING_ID_PREFIX, EDITOR_GENERATION_ASSET_ID_PREFIX, + EDITOR_GENERATION_ASSET_OBJECT_ID_PREFIX, EDITOR_GENERATION_RESOURCE_ID_PREFIX, + editor_generation_request_fingerprint, editor_generation_stable_asset_binding_id, + editor_generation_stable_asset_id, editor_generation_stable_asset_object_id, + editor_generation_stable_resource_id, +}; pub mod external_generation; pub mod game_creation_app; pub mod hyper3d; diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 3f3478293..5b72ec11c 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -122,7 +122,7 @@ pub(crate) use self::external_generation::{ map_external_generation_job_phase_update_procedure_result, map_external_generation_job_procedure_result, map_external_generation_job_result_procedure_generated_artifacts, - map_external_generation_job_summary_list_result, + map_external_generation_job_snapshot, map_external_generation_job_summary_list_result, map_external_generation_job_summary_procedure_result, map_external_generation_queue_stats_result, }; diff --git a/server-rs/crates/spacetime-client/src/active/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/active/mapper/external_generation.rs index cf9643c32..d4ba85760 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/external_generation.rs @@ -265,7 +265,7 @@ pub(crate) fn map_external_generation_queue_stats_result( }) } -fn map_external_generation_job_snapshot( +pub(crate) fn map_external_generation_job_snapshot( snapshot: ExternalGenerationJobSnapshot, ) -> ExternalGenerationJobRecord { ExternalGenerationJobRecord { diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index 2cb17c09a..9b505f63b 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -135,6 +135,50 @@ pub struct EditorSpritesheetSliceBatchPersistRecord { pub items: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorGenerationResultPersistItemRecordInput { + pub slot: String, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub binding: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorGenerationResultPersistRecordInput { + pub owner_user_id: String, + pub operation_kind: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub items: Vec, + pub canvas_layout: Option, + pub job_completion: Option, + pub completed_at_micros: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditorGenerationResultPersistStatusRecord { + Applied, + AlreadyApplied, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorGenerationResultPersistedItemRecord { + pub slot: String, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub binding: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorGenerationResultPersistRecord { + pub status: EditorGenerationResultPersistStatusRecord, + pub items: Vec, + pub project: Option, + pub job: Option, +} + impl From for crate::module_bindings::EditorSpritesheetSlicePersistItemInput { @@ -162,6 +206,37 @@ impl From } } +impl From + for crate::module_bindings::EditorGenerationResultPersistItemInput +{ + fn from(input: EditorGenerationResultPersistItemRecordInput) -> Self { + Self { + slot: input.slot, + asset_object: input.asset_object.map(Into::into), + project_resource: input.project_resource.map(Into::into), + asset: input.asset.map(Into::into), + binding: input.binding.map(Into::into), + } + } +} + +impl From + for crate::module_bindings::EditorGenerationResultPersistInput +{ + fn from(input: EditorGenerationResultPersistRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + operation_kind: input.operation_kind, + operation_id: input.operation_id, + operation_fingerprint: input.operation_fingerprint, + items: input.items.into_iter().map(Into::into).collect(), + canvas_layout: input.canvas_layout.map(Into::into), + job_completion: input.job_completion.map(Into::into), + completed_at_micros: input.completed_at_micros, + } + } +} + impl SpacetimeClient { pub async fn preflight_editor_pixel_art_result( &self, @@ -238,6 +313,44 @@ impl SpacetimeClient { .await } + pub async fn persist_editor_generation_result( + &self, + input: EditorGenerationResultPersistRecordInput, + ) -> Result { + self.persist_editor_generation_result_with_dispatch(input, || {}) + .await + } + + pub async fn persist_editor_generation_result_with_dispatch( + &self, + input: EditorGenerationResultPersistRecordInput, + on_dispatch: F, + ) -> Result + where + F: FnOnce() + Send + 'static, + { + let procedure_input = input.into(); + + self.call_after_connect( + "persist_editor_generation_result_and_return", + move |connection, sender| { + on_dispatch(); + connection + .procedures() + .persist_editor_generation_result_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_generation_result_persist_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_editor_project( &self, input: EditorProjectCreateRecordInput, @@ -1216,6 +1329,84 @@ fn map_editor_spritesheet_slice_batch_persist_result( Ok(EditorSpritesheetSliceBatchPersistRecord { items }) } +fn map_editor_generation_result_persist_result( + result: crate::module_bindings::EditorGenerationResultPersistResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let status = match result + .status + .ok_or_else(|| SpacetimeClientError::validation_failed("编辑器生成结果原子提交缺少状态"))? + { + crate::module_bindings::EditorGenerationResultPersistStatus::Applied => { + EditorGenerationResultPersistStatusRecord::Applied + } + crate::module_bindings::EditorGenerationResultPersistStatus::AlreadyApplied => { + EditorGenerationResultPersistStatusRecord::AlreadyApplied + } + }; + let items = result + .items + .into_iter() + .map(|item| { + Ok(EditorGenerationResultPersistedItemRecord { + slot: item.slot, + asset_object: item + .asset_object + .map(map_editor_spritesheet_asset_object_snapshot), + project_resource: item + .project_resource + .map(map_editor_spritesheet_project_resource_snapshot) + .transpose()?, + asset: item + .asset + .map(map_editor_spritesheet_asset_snapshot) + .transpose()?, + binding: item.binding.map(map_editor_generation_binding_snapshot), + }) + }) + .collect::, SpacetimeClientError>>()?; + let project = result + .project + .map(|project| { + map_editor_project_optional_procedure_result( + crate::module_bindings::EditorProjectProcedureResult { + ok: true, + project: Some(project), + error_message: None, + }, + ) + }) + .transpose()? + .flatten(); + let job = result.job.map(map_external_generation_job_snapshot); + + Ok(EditorGenerationResultPersistRecord { + status, + items, + project, + job, + }) +} + +fn map_editor_generation_binding_snapshot( + snapshot: crate::module_bindings::AssetEntityBindingSnapshot, +) -> module_assets::AssetEntityBindingSnapshot { + module_assets::AssetEntityBindingSnapshot { + binding_id: snapshot.binding_id, + asset_object_id: snapshot.asset_object_id, + entity_kind: snapshot.entity_kind, + entity_id: snapshot.entity_id, + slot: snapshot.slot, + asset_kind: snapshot.asset_kind, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + fn map_editor_spritesheet_asset_object_snapshot( snapshot: crate::module_bindings::AssetObjectUpsertSnapshot, ) -> module_assets::AssetObjectUpsertSnapshot { @@ -1333,6 +1524,73 @@ fn parse_editor_spritesheet_generation_inputs( .transpose() } +#[cfg(test)] +mod editor_generation_result_persist_mapper_tests { + use super::*; + + fn empty_result(ok: bool) -> crate::module_bindings::EditorGenerationResultPersistResult { + crate::module_bindings::EditorGenerationResultPersistResult { + ok, + status: None, + items: Vec::new(), + project: None, + job: None, + error_message: None, + } + } + + #[test] + fn editor_generation_result_mapper_preserves_procedure_failure() { + let mut result = empty_result(false); + result.error_message = Some("事务提交失败".to_string()); + + let error = map_editor_generation_result_persist_result(result) + .expect_err("ok=false 必须保留 procedure 失败"); + + assert!(matches!( + error, + SpacetimeClientError::Procedure(message) if message == "事务提交失败" + )); + } + + #[test] + fn editor_generation_result_mapper_requires_status() { + let error = map_editor_generation_result_persist_result(empty_result(true)) + .expect_err("成功结果缺少状态时必须拒绝"); + + assert!(matches!( + error, + SpacetimeClientError::Runtime(message) + if message == "编辑器生成结果原子提交缺少状态" + )); + } + + #[test] + fn editor_generation_result_mapper_preserves_apply_status() { + let cases = [ + ( + crate::module_bindings::EditorGenerationResultPersistStatus::Applied, + EditorGenerationResultPersistStatusRecord::Applied, + ), + ( + crate::module_bindings::EditorGenerationResultPersistStatus::AlreadyApplied, + EditorGenerationResultPersistStatusRecord::AlreadyApplied, + ), + ]; + + for (binding_status, expected) in cases { + let mut result = empty_result(true); + result.status = Some(binding_status); + let mapped = + map_editor_generation_result_persist_result(result).expect("完整状态应映射成功"); + assert_eq!(mapped.status, expected); + assert!(mapped.items.is_empty()); + assert!(mapped.project.is_none()); + assert!(mapped.job.is_none()); + } + } +} + #[cfg(test)] mod pixel_art_persist_mapper_tests { use super::*; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index da3ac68b9..26efb0837 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -272,6 +272,7 @@ pub(crate) use self::external_generation::{ map_external_generation_job_claim_result, map_external_generation_job_list_result, map_external_generation_job_procedure_result, map_external_generation_job_result_procedure_generated_artifacts, + map_external_generation_job_snapshot, map_external_generation_job_summary_list_result, map_external_generation_job_summary_procedure_result, map_external_generation_queue_stats_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs index cf9643c32..d4ba85760 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -265,7 +265,7 @@ pub(crate) fn map_external_generation_queue_stats_result( }) } -fn map_external_generation_job_snapshot( +pub(crate) fn map_external_generation_job_snapshot( snapshot: ExternalGenerationJobSnapshot, ) -> ExternalGenerationJobRecord { ExternalGenerationJobRecord { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index a511b0ce5..0a2874a89 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -290,12 +290,19 @@ pub mod editor_canvas_type; pub mod editor_character_animation_normalization_input_type; pub mod editor_character_animation_normalization_procedure_result_type; pub mod editor_generation_model_pricing_type; +pub mod editor_generation_operation_table; +pub mod editor_generation_operation_type; pub mod editor_generation_pricing_config_procedure_result_type; pub mod editor_generation_pricing_config_snapshot_type; pub mod editor_generation_pricing_config_table; pub mod editor_generation_pricing_config_type; pub mod editor_generation_pricing_config_upsert_input_type; pub mod editor_generation_pricing_tier_type; +pub mod editor_generation_result_persist_input_type; +pub mod editor_generation_result_persist_item_input_type; +pub mod editor_generation_result_persist_result_type; +pub mod editor_generation_result_persist_status_type; +pub mod editor_generation_result_persisted_item_type; pub mod editor_generation_runtime_identity_rotate_input_type; pub mod editor_generation_runtime_identity_rotation_table; pub mod editor_generation_runtime_identity_rotation_type; @@ -485,6 +492,7 @@ pub mod npc_relation_state_type; pub mod npc_stance_profile_type; pub mod npc_state_table; pub mod npc_state_type; +pub mod persist_editor_generation_result_and_return_procedure; pub mod persist_editor_pixel_art_result_and_return_procedure; pub mod persist_editor_spritesheet_slice_batch_and_return_procedure; pub mod player_progression_grant_source_type; @@ -1125,12 +1133,19 @@ pub use editor_canvas_type::EditorCanvas; pub use editor_character_animation_normalization_input_type::EditorCharacterAnimationNormalizationInput; pub use editor_character_animation_normalization_procedure_result_type::EditorCharacterAnimationNormalizationProcedureResult; pub use editor_generation_model_pricing_type::EditorGenerationModelPricing; +pub use editor_generation_operation_table::*; +pub use editor_generation_operation_type::EditorGenerationOperation; pub use editor_generation_pricing_config_procedure_result_type::EditorGenerationPricingConfigProcedureResult; pub use editor_generation_pricing_config_snapshot_type::EditorGenerationPricingConfigSnapshot; pub use editor_generation_pricing_config_table::*; pub use editor_generation_pricing_config_type::EditorGenerationPricingConfig; pub use editor_generation_pricing_config_upsert_input_type::EditorGenerationPricingConfigUpsertInput; pub use editor_generation_pricing_tier_type::EditorGenerationPricingTier; +pub use editor_generation_result_persist_input_type::EditorGenerationResultPersistInput; +pub use editor_generation_result_persist_item_input_type::EditorGenerationResultPersistItemInput; +pub use editor_generation_result_persist_result_type::EditorGenerationResultPersistResult; +pub use editor_generation_result_persist_status_type::EditorGenerationResultPersistStatus; +pub use editor_generation_result_persisted_item_type::EditorGenerationResultPersistedItem; pub use editor_generation_runtime_identity_rotate_input_type::EditorGenerationRuntimeIdentityRotateInput; pub use editor_generation_runtime_identity_rotation_table::*; pub use editor_generation_runtime_identity_rotation_type::EditorGenerationRuntimeIdentityRotation; @@ -1320,6 +1335,7 @@ pub use npc_relation_state_type::NpcRelationState; pub use npc_stance_profile_type::NpcStanceProfile; pub use npc_state_table::*; pub use npc_state_type::NpcState; +pub use persist_editor_generation_result_and_return_procedure::persist_editor_generation_result_and_return; pub use persist_editor_pixel_art_result_and_return_procedure::persist_editor_pixel_art_result_and_return; pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_editor_spritesheet_slice_batch_and_return; pub use player_progression_grant_source_type::PlayerProgressionGrantSource; @@ -1831,6 +1847,7 @@ pub struct DbUpdate { editor_canvas_generation_dialog: __sdk::TableUpdate, editor_canvas_layer: __sdk::TableUpdate, editor_canvas_layout_migration: __sdk::TableUpdate, + editor_generation_operation: __sdk::TableUpdate, editor_generation_pricing_config: __sdk::TableUpdate, editor_generation_runtime_identity_rotation: __sdk::TableUpdate, @@ -2093,6 +2110,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { editor_canvas_layout_migration_table::parse_table_update(table_update)?, ) } + "editor_generation_operation" => db_update.editor_generation_operation.append( + editor_generation_operation_table::parse_table_update(table_update)?, + ), "editor_generation_pricing_config" => { db_update.editor_generation_pricing_config.append( editor_generation_pricing_config_table::parse_table_update(table_update)?, @@ -2669,6 +2689,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.editor_canvas_layout_migration, ) .with_updates_by_pk(|row| &row.canvas_id); + diff.editor_generation_operation = cache + .apply_diff_to_table::( + "editor_generation_operation", + &self.editor_generation_operation, + ) + .with_updates_by_pk(|row| &row.operation_key); diff.editor_generation_pricing_config = cache .apply_diff_to_table::( "editor_generation_pricing_config", @@ -3296,6 +3322,9 @@ impl __sdk::DbUpdate for DbUpdate { "editor_canvas_layout_migration" => db_update .editor_canvas_layout_migration .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "editor_generation_operation" => db_update + .editor_generation_operation + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "editor_generation_pricing_config" => db_update .editor_generation_pricing_config .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3720,6 +3749,9 @@ impl __sdk::DbUpdate for DbUpdate { "editor_canvas_layout_migration" => db_update .editor_canvas_layout_migration .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "editor_generation_operation" => db_update + .editor_generation_operation + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "editor_generation_pricing_config" => db_update .editor_generation_pricing_config .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4055,6 +4087,7 @@ pub struct AppliedDiff<'r> { editor_canvas_generation_dialog: __sdk::TableAppliedDiff<'r, EditorCanvasGenerationDialog>, editor_canvas_layer: __sdk::TableAppliedDiff<'r, EditorCanvasLayer>, editor_canvas_layout_migration: __sdk::TableAppliedDiff<'r, EditorCanvasLayoutMigration>, + editor_generation_operation: __sdk::TableAppliedDiff<'r, EditorGenerationOperation>, editor_generation_pricing_config: __sdk::TableAppliedDiff<'r, EditorGenerationPricingConfig>, editor_generation_runtime_identity_rotation: __sdk::TableAppliedDiff<'r, EditorGenerationRuntimeIdentityRotation>, @@ -4392,6 +4425,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.editor_canvas_layout_migration, event, ); + callbacks.invoke_table_row_callbacks::( + "editor_generation_operation", + &self.editor_generation_operation, + event, + ); callbacks.invoke_table_row_callbacks::( "editor_generation_pricing_config", &self.editor_generation_pricing_config, @@ -5085,19 +5123,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle { /// either a [`DbConnection`] or an [`EventContext`] and operate on either. pub trait RemoteDbContext: __sdk::DbContext< - DbView = RemoteTables, - Reducers = RemoteReducers, - SubscriptionBuilder = __sdk::SubscriptionBuilder, - > + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, +> { } impl< - Ctx: __sdk::DbContext< + Ctx: __sdk::DbContext< DbView = RemoteTables, Reducers = RemoteReducers, SubscriptionBuilder = __sdk::SubscriptionBuilder, >, -> RemoteDbContext for Ctx + > RemoteDbContext for Ctx { } @@ -5537,6 +5575,7 @@ impl __sdk::SpacetimeModule for RemoteModule { editor_canvas_generation_dialog_table::register_table(client_cache); editor_canvas_layer_table::register_table(client_cache); editor_canvas_layout_migration_table::register_table(client_cache); + editor_generation_operation_table::register_table(client_cache); editor_generation_pricing_config_table::register_table(client_cache); editor_generation_runtime_identity_rotation_table::register_table(client_cache); editor_project_table::register_table(client_cache); @@ -5676,6 +5715,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "editor_canvas_generation_dialog", "editor_canvas_layer", "editor_canvas_layout_migration", + "editor_generation_operation", "editor_generation_pricing_config", "editor_generation_runtime_identity_rotation", "editor_project", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_operation_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_operation_table.rs new file mode 100644 index 000000000..a14974491 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_operation_table.rs @@ -0,0 +1,235 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::editor_generation_operation_type::EditorGenerationOperation; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `editor_generation_operation`. +/// +/// Obtain a handle from the [`EditorGenerationOperationTableAccess::editor_generation_operation`] method on [`super::RemoteTables`], +/// like `ctx.db.editor_generation_operation()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_generation_operation().on_insert(...)`. +pub struct EditorGenerationOperationTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `editor_generation_operation`. +pub struct EditorGenerationOperationTableAccessor; + +impl __sdk::TableAccessor for EditorGenerationOperationTableAccessor { + type Row = EditorGenerationOperation; + type Handle<'db> = EditorGenerationOperationTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.editor_generation_operation() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `editor_generation_operation`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait EditorGenerationOperationTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`EditorGenerationOperationTableHandle`], which mediates access to the table `editor_generation_operation`. + fn editor_generation_operation(&self) -> EditorGenerationOperationTableHandle<'_>; +} + +impl EditorGenerationOperationTableAccess for super::RemoteTables { + fn editor_generation_operation(&self) -> EditorGenerationOperationTableHandle<'_> { + EditorGenerationOperationTableHandle { + imp: self + .imp + .get_table::("editor_generation_operation"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct EditorGenerationOperationInsertCallbackId(__sdk::CallbackId); +pub struct EditorGenerationOperationDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for EditorGenerationOperationTableHandle<'ctx> { + type Row = EditorGenerationOperation; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for EditorGenerationOperationTableHandle<'ctx> { + type Row = EditorGenerationOperation; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = EditorGenerationOperationInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorGenerationOperationInsertCallbackId { + EditorGenerationOperationInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorGenerationOperationInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = EditorGenerationOperationDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorGenerationOperationDeleteCallbackId { + EditorGenerationOperationDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorGenerationOperationDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for EditorGenerationOperationTableHandle<'ctx> { + type InsertCallbackId = EditorGenerationOperationInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorGenerationOperationInsertCallbackId { + EditorGenerationOperationInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorGenerationOperationInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for EditorGenerationOperationTableHandle<'ctx> { + type DeleteCallbackId = EditorGenerationOperationDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorGenerationOperationDeleteCallbackId { + EditorGenerationOperationDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorGenerationOperationDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct EditorGenerationOperationUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for EditorGenerationOperationTableHandle<'ctx> { + type UpdateCallbackId = EditorGenerationOperationUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorGenerationOperationUpdateCallbackId { + EditorGenerationOperationUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorGenerationOperationUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for EditorGenerationOperationTableHandle<'ctx> { + type UpdateCallbackId = EditorGenerationOperationUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorGenerationOperationUpdateCallbackId { + EditorGenerationOperationUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorGenerationOperationUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `operation_key` unique index on the table `editor_generation_operation`, +/// which allows point queries on the field of the same name +/// via the [`EditorGenerationOperationOperationKeyUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_generation_operation().operation_key().find(...)`. +pub struct EditorGenerationOperationOperationKeyUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> EditorGenerationOperationTableHandle<'ctx> { + /// Get a handle on the `operation_key` unique index on the table `editor_generation_operation`. + pub fn operation_key(&self) -> EditorGenerationOperationOperationKeyUnique<'ctx> { + EditorGenerationOperationOperationKeyUnique { + imp: self.imp.get_unique_constraint::("operation_key"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> EditorGenerationOperationOperationKeyUnique<'ctx> { + /// Find the subscribed row whose `operation_key` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("editor_generation_operation"); + _table.add_unique_constraint::("operation_key", |row| &row.operation_key); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `EditorGenerationOperation`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait editor_generation_operationQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `EditorGenerationOperation`. + fn editor_generation_operation( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl editor_generation_operationQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_generation_operation( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_generation_operation") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_operation_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_operation_type.rs new file mode 100644 index 000000000..d7b2cf52e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_operation_type.rs @@ -0,0 +1,89 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorGenerationOperation { + pub operation_key: String, + pub owner_user_id: String, + pub operation_kind: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub commit_sha_256: String, + pub project_id: Option, + pub job_id: Option, + pub job_worker_id: Option, + pub job_lease_token: Option, + pub job_result_payload_sha_256: Option, + pub completed_at: __sdk::Timestamp, +} + +impl __sdk::InModule for EditorGenerationOperation { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `EditorGenerationOperation`. +/// +/// Provides typed access to columns for query building. +pub struct EditorGenerationOperationCols { + pub operation_key: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub operation_kind: __sdk::__query_builder::Col, + pub operation_id: __sdk::__query_builder::Col, + pub operation_fingerprint: __sdk::__query_builder::Col, + pub commit_sha_256: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col>, + pub job_id: __sdk::__query_builder::Col>, + pub job_worker_id: __sdk::__query_builder::Col>, + pub job_lease_token: __sdk::__query_builder::Col>, + pub job_result_payload_sha_256: + __sdk::__query_builder::Col>, + pub completed_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for EditorGenerationOperation { + type Cols = EditorGenerationOperationCols; + fn cols(table_name: &'static str) -> Self::Cols { + EditorGenerationOperationCols { + operation_key: __sdk::__query_builder::Col::new(table_name, "operation_key"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + operation_kind: __sdk::__query_builder::Col::new(table_name, "operation_kind"), + operation_id: __sdk::__query_builder::Col::new(table_name, "operation_id"), + operation_fingerprint: __sdk::__query_builder::Col::new( + table_name, + "operation_fingerprint", + ), + commit_sha_256: __sdk::__query_builder::Col::new(table_name, "commit_sha_256"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + job_id: __sdk::__query_builder::Col::new(table_name, "job_id"), + job_worker_id: __sdk::__query_builder::Col::new(table_name, "job_worker_id"), + job_lease_token: __sdk::__query_builder::Col::new(table_name, "job_lease_token"), + job_result_payload_sha_256: __sdk::__query_builder::Col::new( + table_name, + "job_result_payload_sha_256", + ), + completed_at: __sdk::__query_builder::Col::new(table_name, "completed_at"), + } + } +} + +/// Indexed column accessor struct for the table `EditorGenerationOperation`. +/// +/// Provides typed access to indexed columns for query building. +pub struct EditorGenerationOperationIxCols { + pub operation_key: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for EditorGenerationOperation { + type IxCols = EditorGenerationOperationIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + EditorGenerationOperationIxCols { + operation_key: __sdk::__query_builder::IxCol::new(table_name, "operation_key"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for EditorGenerationOperation {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_input_type.rs new file mode 100644 index 000000000..eec92dd37 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_input_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_generation_result_persist_item_input_type::EditorGenerationResultPersistItemInput; +use super::editor_project_layout_save_v_2_input_type::EditorProjectLayoutSaveV2Input; +use super::external_generation_job_complete_input_type::ExternalGenerationJobCompleteInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorGenerationResultPersistInput { + pub owner_user_id: String, + pub operation_kind: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub items: Vec, + pub canvas_layout: Option, + pub job_completion: Option, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for EditorGenerationResultPersistInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_item_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_item_input_type.rs new file mode 100644 index 000000000..dcf3429cb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_item_input_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_entity_binding_input_type::AssetEntityBindingInput; +use super::asset_object_upsert_input_type::AssetObjectUpsertInput; +use super::editor_asset_create_input_type::EditorAssetCreateInput; +use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorGenerationResultPersistItemInput { + pub slot: String, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub binding: Option, +} + +impl __sdk::InModule for EditorGenerationResultPersistItemInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_result_type.rs new file mode 100644 index 000000000..3158cb6e4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_result_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_generation_result_persist_status_type::EditorGenerationResultPersistStatus; +use super::editor_generation_result_persisted_item_type::EditorGenerationResultPersistedItem; +use super::editor_project_snapshot_type::EditorProjectSnapshot; +use super::external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorGenerationResultPersistResult { + pub ok: bool, + pub status: Option, + pub items: Vec, + pub project: Option, + pub job: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorGenerationResultPersistResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_status_type.rs new file mode 100644 index 000000000..45cee8baf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persist_status_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum EditorGenerationResultPersistStatus { + Applied, + + AlreadyApplied, +} + +impl __sdk::InModule for EditorGenerationResultPersistStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persisted_item_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persisted_item_type.rs new file mode 100644 index 000000000..b9227ab69 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_result_persisted_item_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_entity_binding_snapshot_type::AssetEntityBindingSnapshot; +use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; +use super::editor_asset_snapshot_type::EditorAssetSnapshot; +use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorGenerationResultPersistedItem { + pub slot: String, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub binding: Option, +} + +impl __sdk::InModule for EditorGenerationResultPersistedItem { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_generation_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_generation_result_and_return_procedure.rs new file mode 100644 index 000000000..17c3468dc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_generation_result_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_generation_result_persist_input_type::EditorGenerationResultPersistInput; +use super::editor_generation_result_persist_result_type::EditorGenerationResultPersistResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PersistEditorGenerationResultAndReturnArgs { + pub input: EditorGenerationResultPersistInput, +} + +impl __sdk::InModule for PersistEditorGenerationResultAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `persist_editor_generation_result_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait persist_editor_generation_result_and_return { + fn persist_editor_generation_result_and_return( + &self, + input: EditorGenerationResultPersistInput, + ) { + self.persist_editor_generation_result_and_return_then(input, |_, _| {}); + } + + fn persist_editor_generation_result_and_return_then( + &self, + input: EditorGenerationResultPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl persist_editor_generation_result_and_return for super::RemoteProcedures { + fn persist_editor_generation_result_and_return_then( + &self, + input: EditorGenerationResultPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorGenerationResultPersistResult>( + "persist_editor_generation_result_and_return", + PersistEditorGenerationResultAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs index c25ee2e1c..cdccf6f35 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait persist_editor_pixel_art_result_and_return { input: EditorPixelArtResultPersistInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl persist_editor_pixel_art_result_and_return for super::RemoteProcedures { input: EditorPixelArtResultPersistInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorPixelArtResultPersistResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs index f38144b63..e2aa2624a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait preflight_editor_pixel_art_result_and_return { input: EditorPixelArtResultPreflightInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl preflight_editor_pixel_art_result_and_return for super::RemoteProcedures { input: EditorPixelArtResultPreflightInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, EditorPixelArtResultPreflightResult>( diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs b/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs index 98bd9b4d4..52736ab9b 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs @@ -48,7 +48,7 @@ pub fn bind_asset_object_to_entity_and_return( } } -fn upsert_asset_entity_binding( +pub(crate) fn upsert_asset_entity_binding( ctx: &ReducerContext, input: AssetEntityBindingInput, ) -> Result { @@ -125,6 +125,53 @@ fn upsert_asset_entity_binding( Ok(snapshot) } +pub(crate) fn find_asset_entity_binding_snapshot_by_id( + ctx: &ReducerContext, + binding_id: &str, +) -> Option { + ctx.db + .asset_entity_binding() + .binding_id() + .find(&binding_id.trim().to_string()) + .map(|row| AssetEntityBindingSnapshot { + binding_id: row.binding_id, + asset_object_id: row.asset_object_id, + entity_kind: row.entity_kind, + entity_id: row.entity_id, + slot: row.slot, + asset_kind: row.asset_kind, + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + +pub(crate) fn find_asset_entity_binding_snapshot_by_slot( + ctx: &ReducerContext, + entity_kind: &str, + entity_id: &str, + slot: &str, +) -> Option { + ctx.db + .asset_entity_binding() + .by_entity_slot() + .filter((entity_kind.trim(), entity_id.trim(), slot.trim())) + .next() + .map(|row| AssetEntityBindingSnapshot { + binding_id: row.binding_id, + asset_object_id: row.asset_object_id, + entity_kind: row.entity_kind, + entity_id: row.entity_id, + slot: row.slot, + asset_kind: row.asset_kind, + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + fn build_asset_entity_binding_row(snapshot: &AssetEntityBindingSnapshot) -> AssetEntityBinding { AssetEntityBinding { binding_id: snapshot.binding_id.clone(), diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 0058006b0..66d11e264 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -21,6 +21,19 @@ const EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH: u32 = 420; const EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT: u32 = 120; const EDITOR_CHARACTER_ANIMATION_NORMALIZATION_MAX_BATCH_SIZE: u32 = 25; const EDITOR_CHARACTER_ANIMATION_CANVAS_NORMALIZATION_MAX_BATCH_SIZE: u32 = 5; +// 透明图集最多产出 64 个切片,另有 provider 原图和透明整图两个正式 item。 +const EDITOR_GENERATION_RESULT_MAX_ITEMS: usize = 66; +const EDITOR_GENERATION_OPERATION_KINDS: [&str; 9] = [ + "editor_image_generation", + "editor_image_edit", + "editor_background_removal", + "editor_icon_spritesheet_generation", + "editor_ui_design_asset_extraction", + "editor_character_animation_generation", + "editor_video_generation", + "editor_sound_effect_generation", + "editor_background_music_generation", +]; const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "character-animation"; const EDITOR_CHARACTER_ANIMATION_OBJECT_KIND: &str = "editor_character_animation"; const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [ @@ -455,6 +468,32 @@ pub struct EditorGenerationRuntimeIdentityRotation { rotated_at: Timestamp, } +// 私有 durable receipt 是 inline 与 queue 两条链路共同的提交凭证。它不承接业务 +// 状态,只用于区分首次提交、完整重放和缺失 receipt 的部分写入。 +#[spacetimedb::table( + accessor = editor_generation_operation, + index( + accessor = by_editor_generation_operation_owner, + btree(columns = [owner_user_id, operation_id]) + ) +)] +#[derive(Clone)] +pub struct EditorGenerationOperation { + #[primary_key] + operation_key: String, + owner_user_id: String, + operation_kind: String, + operation_id: String, + operation_fingerprint: String, + commit_sha256: String, + project_id: Option, + job_id: Option, + job_worker_id: Option, + job_lease_token: Option, + job_result_payload_sha256: Option, + completed_at: Timestamp, +} + #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct EditorProjectViewportSnapshot { pub x: f64, @@ -1028,6 +1067,52 @@ pub struct EditorPixelArtResultPersistResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorGenerationResultPersistItemInput { + pub slot: String, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub binding: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorGenerationResultPersistInput { + pub owner_user_id: String, + pub operation_kind: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub items: Vec, + pub canvas_layout: Option, + pub job_completion: Option, + pub completed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorGenerationResultPersistedItem { + pub slot: String, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub binding: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] +pub enum EditorGenerationResultPersistStatus { + Applied, + AlreadyApplied, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorGenerationResultPersistResult { + pub ok: bool, + pub status: Option, + pub items: Vec, + pub project: Option, + pub job: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetUpdateInput { pub asset_id: String, @@ -1741,6 +1826,32 @@ pub fn persist_editor_pixel_art_result_and_return( } } +#[spacetimedb::procedure] +pub fn persist_editor_generation_result_and_return( + ctx: &mut ProcedureContext, + input: EditorGenerationResultPersistInput, +) -> EditorGenerationResultPersistResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| persist_editor_generation_result(tx, caller, input.clone())) { + Ok(persisted) => EditorGenerationResultPersistResult { + ok: true, + status: Some(persisted.status), + items: persisted.items, + project: persisted.project, + job: persisted.job, + error_message: None, + }, + Err(message) => EditorGenerationResultPersistResult { + ok: false, + status: None, + items: Vec::new(), + project: None, + job: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn update_editor_asset_and_return( ctx: &mut ProcedureContext, @@ -3005,6 +3116,1156 @@ fn persist_editor_pixel_art_result( }) } +struct EditorGenerationResultPersisted { + status: EditorGenerationResultPersistStatus, + items: Vec, + project: Option, + job: Option, +} + +fn persist_editor_generation_result( + ctx: &ReducerContext, + caller: Identity, + mut input: EditorGenerationResultPersistInput, +) -> Result { + require_editor_generation_runtime_service_identity(ctx, caller)?; + let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; + let operation_kind = normalize_required(&input.operation_kind, "operation_kind")?; + let operation_id = normalize_required(&input.operation_id, "operation_id")?; + let operation_fingerprint = + normalize_required(&input.operation_fingerprint, "operation_fingerprint")?; + validate_editor_generation_operation_fingerprint(operation_fingerprint.as_str())?; + normalize_editor_generation_default_asset_folders(&mut input, owner_user_id.as_str()); + validate_editor_generation_result_shape( + &input, + owner_user_id.as_str(), + operation_kind.as_str(), + operation_id.as_str(), + )?; + let result_project_id = editor_generation_result_project_id(&input)?; + validate_editor_generation_job_completion_presence( + ctx, + operation_id.as_str(), + input.job_completion.as_ref(), + )?; + + let operation_key = + editor_generation_operation_key(owner_user_id.as_str(), operation_id.as_str()); + let commit_sha256 = editor_generation_result_commit_sha256(&input)?; + if let Some(receipt) = ctx + .db + .editor_generation_operation() + .operation_key() + .find(&operation_key) + { + return replay_editor_generation_result(ctx, &input, &receipt, commit_sha256.as_str()); + } + + if let Some(job_completion) = &input.job_completion { + crate::external_generation::validate_editor_generation_job_lease_for_result_tx( + ctx, + job_completion, + owner_user_id.as_str(), + operation_kind.as_str(), + operation_fingerprint.as_str(), + result_project_id.as_deref(), + )?; + } + validate_editor_generation_result_new_records(ctx, &input, owner_user_id.as_str())?; + + let mut persisted_items = Vec::with_capacity(input.items.len()); + for item in &input.items { + let asset_object = item + .asset_object + .clone() + .map(|candidate| { + let expected_id = candidate.asset_object_id.trim().to_string(); + let snapshot = crate::asset_metadata::upsert_asset_object(ctx, candidate)?; + if snapshot.asset_object_id != expected_id { + return Err("生成结果 asset_object 未按指定 ID 提交".to_string()); + } + Ok(snapshot) + }) + .transpose()?; + let project_resource = item + .project_resource + .clone() + .map(|candidate| create_editor_generation_project_resource(ctx, candidate)) + .transpose()?; + let asset = item + .asset + .clone() + .map(|candidate| create_editor_asset(ctx, caller, candidate)) + .transpose()?; + let binding = item + .binding + .clone() + .map(|candidate| { + let expected_id = candidate.binding_id.trim().to_string(); + let snapshot = crate::asset_metadata::upsert_asset_entity_binding(ctx, candidate)?; + if snapshot.binding_id != expected_id { + return Err("生成结果 asset_entity_binding 未按指定 ID 提交".to_string()); + } + Ok(snapshot) + }) + .transpose()?; + persisted_items.push(EditorGenerationResultPersistedItem { + slot: item.slot.trim().to_string(), + asset_object, + project_resource, + asset, + binding, + }); + } + + let project = input + .canvas_layout + .clone() + .map(|canvas_layout| { + let project_id = canvas_layout.project_id.trim().to_string(); + persist_editor_project_layout_v2(ctx, canvas_layout)?; + build_project_snapshot(ctx, project_id.as_str()) + }) + .transpose()?; + let job = input + .job_completion + .clone() + .map(|completion| { + crate::external_generation::complete_external_generation_job_tx(ctx, completion) + }) + .transpose()?; + + let completed_at = Timestamp::from_micros_since_unix_epoch(input.completed_at_micros); + let job_result_payload_sha256 = job.as_ref().and_then(|job| { + editor_generation_result_payload_sha256(job.result_payload_json.as_deref()) + }); + let receipt = EditorGenerationOperation { + operation_key, + owner_user_id, + operation_kind, + operation_id, + operation_fingerprint, + commit_sha256, + project_id: result_project_id, + job_id: input + .job_completion + .as_ref() + .map(|completion| completion.job_id.trim().to_string()), + job_worker_id: input + .job_completion + .as_ref() + .map(|completion| completion.worker_id.trim().to_string()), + job_lease_token: input + .job_completion + .as_ref() + .map(|completion| completion.lease_token.trim().to_string()), + job_result_payload_sha256, + completed_at, + }; + ctx.db.editor_generation_operation().insert(receipt); + + Ok(EditorGenerationResultPersisted { + status: EditorGenerationResultPersistStatus::Applied, + items: persisted_items, + project, + job, + }) +} + +fn normalize_editor_generation_default_asset_folders( + input: &mut EditorGenerationResultPersistInput, + owner_user_id: &str, +) { + for item in &mut input.items { + if let Some(asset) = &mut item.asset + && asset.folder_id.trim() == EDITOR_ASSET_DEFAULT_FOLDER_ID + { + asset.folder_id = default_asset_folder_id(owner_user_id); + } + } +} + +fn validate_editor_generation_operation_fingerprint(fingerprint: &str) -> Result<(), String> { + if fingerprint.len() != 64 + || !fingerprint + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("operation_fingerprint 必须是 64 位小写 SHA-256 十六进制字符串".to_string()); + } + Ok(()) +} + +fn validate_editor_generation_job_completion_presence( + ctx: &ReducerContext, + operation_id: &str, + job_completion: Option<&ExternalGenerationJobCompleteInput>, +) -> Result<(), String> { + let job_exists = ctx + .db + .external_generation_job() + .job_id() + .find(&operation_id.to_string()) + .is_some(); + validate_editor_generation_job_completion_presence_for_state( + operation_id, + job_exists, + job_completion, + ) +} + +fn validate_editor_generation_job_completion_presence_for_state( + operation_id: &str, + job_exists: bool, + job_completion: Option<&ExternalGenerationJobCompleteInput>, +) -> Result<(), String> { + match (job_exists, job_completion) { + (true, None) => { + Err("operation_id 已绑定外部生成任务,必须提交完整 job completion guard".to_string()) + } + (false, Some(_)) => { + Err("job completion 引用的外部生成任务不存在,不能按 inline 提交".to_string()) + } + (true, Some(completion)) if completion.job_id.trim() != operation_id => { + Err("job completion 与 operation_id 对应的外部生成任务不一致".to_string()) + } + _ => Ok(()), + } +} + +fn validate_editor_generation_result_shape( + input: &EditorGenerationResultPersistInput, + owner_user_id: &str, + operation_kind: &str, + operation_id: &str, +) -> Result<(), String> { + if !EDITOR_GENERATION_OPERATION_KINDS.contains(&operation_kind) { + return Err("生成结果 operation_kind 不在编辑器生成白名单内".to_string()); + } + if operation_id.contains('\0') || operation_kind.contains('\0') { + return Err("生成结果 operation identity 不得包含 NUL".to_string()); + } + if input.completed_at_micros <= 0 { + return Err("生成结果 completed_at_micros 必须为正数".to_string()); + } + if !(1..=EDITOR_GENERATION_RESULT_MAX_ITEMS).contains(&input.items.len()) { + return Err("生成结果 item 数量必须在 1 到 66 之间".to_string()); + } + let result_project_id = editor_generation_result_project_id(input)?; + let mut slots = BTreeSet::new(); + let mut resource_ids = BTreeSet::new(); + let mut asset_ids = BTreeSet::new(); + let mut binding_ids = BTreeSet::new(); + for item in &input.items { + let slot = normalize_required(&item.slot, "item.slot")?; + if !slots.insert(slot.clone()) { + return Err("生成结果包含重复 slot".to_string()); + } + if item.asset_object.is_none() + && item.project_resource.is_none() + && item.asset.is_none() + && item.binding.is_none() + { + return Err("生成结果 item 至少包含一种正式记录".to_string()); + } + if let Some(object) = &item.asset_object { + let asset_object_id = + normalize_required(&object.asset_object_id, "asset_object.asset_object_id")?; + let expected_asset_object_id = + shared_contracts::editor_generation_stable_asset_object_id( + owner_user_id, + operation_kind, + operation_id, + slot.as_str(), + ); + if asset_object_id != expected_asset_object_id { + return Err("生成结果 asset object ID 不是 operation 稳定 ID".to_string()); + } + if normalize_optional_ref(object.owner_user_id.as_ref()) != Some(owner_user_id) { + return Err("生成结果 asset_object owner_user_id 不一致".to_string()); + } + } + if let Some(resource) = &item.project_resource { + let resource_id = + normalize_required(&resource.resource_id, "editor_project_resource.resource_id")?; + let expected_resource_id = shared_contracts::editor_generation_stable_resource_id( + owner_user_id, + operation_kind, + operation_id, + slot.as_str(), + ); + if resource_id != expected_resource_id { + return Err("生成结果 project resource ID 不是 operation 稳定 ID".to_string()); + } + if !resource_ids.insert(resource_id) { + return Err("生成结果包含重复 resource_id".to_string()); + } + if resource.owner_user_id.trim() != owner_user_id { + return Err("生成结果 project resource owner_user_id 不一致".to_string()); + } + let resource_project_id = + normalize_required(&resource.project_id, "editor_project_resource.project_id")?; + if result_project_id + .as_deref() + .is_some_and(|project_id| resource_project_id != project_id) + { + return Err("生成结果 project resource 与 canvas project_id 不一致".to_string()); + } + } + if let Some(asset) = &item.asset { + let asset_id = normalize_required(&asset.asset_id, "editor_asset.asset_id")?; + let expected_asset_id = shared_contracts::editor_generation_stable_asset_id( + owner_user_id, + operation_kind, + operation_id, + slot.as_str(), + ); + if asset_id != expected_asset_id { + return Err("生成结果 editor asset ID 不是 operation 稳定 ID".to_string()); + } + if !asset_ids.insert(asset_id) { + return Err("生成结果包含重复 asset_id".to_string()); + } + if asset.owner_user_id.trim() != owner_user_id { + return Err("生成结果 editor asset owner_user_id 不一致".to_string()); + } + } + if let Some(binding) = &item.binding { + let binding_id = normalize_required(&binding.binding_id, "binding.binding_id")?; + let expected_binding_id = shared_contracts::editor_generation_stable_asset_binding_id( + owner_user_id, + operation_kind, + operation_id, + slot.as_str(), + ); + if binding_id != expected_binding_id { + return Err("生成结果 asset binding ID 不是 operation 稳定 ID".to_string()); + } + if !binding_ids.insert(binding_id) { + return Err("生成结果包含重复 binding_id".to_string()); + } + if normalize_optional_ref(binding.owner_user_id.as_ref()) != Some(owner_user_id) { + return Err("生成结果 binding owner_user_id 不一致".to_string()); + } + } + validate_editor_generation_item_relationships( + item, + owner_user_id, + operation_kind, + operation_id, + )?; + } + if let Some(canvas_layout) = &input.canvas_layout { + if canvas_layout.owner_user_id.trim() != owner_user_id { + return Err("生成结果 canvas owner_user_id 不一致".to_string()); + } + } + if let Some(job_completion) = &input.job_completion { + if job_completion.job_id.trim() != operation_id { + return Err("queue 生成结果必须使用 job_id 作为 operation_id".to_string()); + } + normalize_required(&job_completion.worker_id, "job_completion.worker_id")?; + normalize_required(&job_completion.lease_token, "job_completion.lease_token")?; + } + Ok(()) +} + +fn editor_generation_result_project_id( + input: &EditorGenerationResultPersistInput, +) -> Result, String> { + let mut project_id = input + .canvas_layout + .as_ref() + .map(|layout| normalize_required(&layout.project_id, "canvas_layout.project_id")) + .transpose()?; + for resource in input + .items + .iter() + .filter_map(|item| item.project_resource.as_ref()) + { + let resource_project_id = + normalize_required(&resource.project_id, "editor_project_resource.project_id")?; + if project_id + .as_deref() + .is_some_and(|expected| expected != resource_project_id) + { + return Err("生成结果 project resource 不属于同一 project".to_string()); + } + project_id.get_or_insert(resource_project_id); + } + Ok(project_id) +} + +fn validate_editor_generation_item_relationships( + item: &EditorGenerationResultPersistItemInput, + owner_user_id: &str, + operation_kind: &str, + operation_id: &str, +) -> Result<(), String> { + let object_id = item + .asset_object + .as_ref() + .map(|object| object.asset_object_id.trim()); + let object_key = item + .asset_object + .as_ref() + .map(|object| object.object_key.trim().trim_start_matches('/')); + let resource_id = item + .project_resource + .as_ref() + .map(|resource| resource.resource_id.trim()); + let resource_object_id = item + .project_resource + .as_ref() + .and_then(|resource| normalize_optional_ref(resource.asset_object_id.as_ref())); + let asset_object_id = item + .asset + .as_ref() + .and_then(|asset| normalize_optional_ref(asset.asset_object_id.as_ref())); + let binding_object_id = item + .binding + .as_ref() + .map(|binding| normalize_required(&binding.asset_object_id, "binding.asset_object_id")) + .transpose()?; + let canonical_object_id = object_id + .or(resource_object_id) + .or(asset_object_id) + .or(binding_object_id.as_deref()); + for referenced_object_id in [ + object_id, + resource_object_id, + asset_object_id, + binding_object_id.as_deref(), + ] + .into_iter() + .flatten() + { + if Some(referenced_object_id) != canonical_object_id { + return Err("生成结果 item 内 asset_object ID 引用不一致".to_string()); + } + } + let resource_object_key = item.project_resource.as_ref().and_then(|resource| { + normalize_optional_ref(resource.object_key.as_ref()) + .map(|value| value.trim_start_matches('/')) + }); + let asset_object_key = item.asset.as_ref().and_then(|asset| { + normalize_optional_ref(asset.object_key.as_ref()).map(|value| value.trim_start_matches('/')) + }); + let canonical_object_key = object_key.or(resource_object_key).or(asset_object_key); + for referenced_object_key in [object_key, resource_object_key, asset_object_key] + .into_iter() + .flatten() + { + if Some(referenced_object_key) != canonical_object_key { + return Err("生成结果 item 内 asset_object object_key 引用不一致".to_string()); + } + } + if let Some(resource) = &item.project_resource { + if object_id.is_some() + && normalize_optional_ref(resource.asset_object_id.as_ref()) != object_id + { + return Err("生成结果 project resource 与 asset_object ID 不一致".to_string()); + } + if object_key.is_some() + && normalize_optional_ref(resource.object_key.as_ref()) != object_key + { + return Err("生成结果 project resource 与 asset_object object_key 不一致".to_string()); + } + } + if let Some(asset) = &item.asset { + if object_id.is_some() + && normalize_optional_ref(asset.asset_object_id.as_ref()) != object_id + { + return Err("生成结果 editor asset 与 asset_object ID 不一致".to_string()); + } + if object_key.is_some() && normalize_optional_ref(asset.object_key.as_ref()) != object_key { + return Err("生成结果 editor asset 与 asset_object object_key 不一致".to_string()); + } + if resource_id.is_some() + && normalize_optional_ref(asset.source_resource_id.as_ref()) != resource_id + { + return Err("生成结果 editor asset 与 project resource ID 不一致".to_string()); + } + } + if let Some(binding) = &item.binding { + if object_id.is_some() && binding.asset_object_id.trim() != object_id.unwrap_or_default() { + return Err("生成结果 binding 与 asset_object ID 不一致".to_string()); + } + if normalize_optional_ref(binding.owner_user_id.as_ref()) != Some(owner_user_id) { + return Err("生成结果 binding owner_user_id 不一致".to_string()); + } + let expected_audio_binding = match operation_kind { + "editor_sound_effect_generation" => { + Some(("sound-effect", "sound_effect", "editor_sound_effect")) + } + "editor_background_music_generation" => Some(( + "background-music", + "background_music", + "editor_background_music", + )), + _ => None, + }; + let Some((record_kind, slot, binding_kind)) = expected_audio_binding else { + return Err("非音频编辑器生成结果不得提交 asset binding".to_string()); + }; + if binding.entity_kind.trim() != "editor_audio" + || binding.entity_id.trim() != operation_id + || binding.slot.trim() != slot + || binding.asset_kind.trim() != binding_kind + || normalize_optional_ref(binding.profile_id.as_ref()).is_some() + { + return Err("生成结果音频 binding 语义与 operation 不一致".to_string()); + } + for candidate_kind in [ + item.project_resource + .as_ref() + .and_then(|resource| normalize_optional_ref(resource.asset_kind.as_ref())), + item.asset + .as_ref() + .and_then(|asset| normalize_optional_ref(asset.asset_kind.as_ref())), + ] + .into_iter() + .flatten() + { + if candidate_kind != record_kind { + return Err("生成结果音频记录 asset_kind 与 binding 不一致".to_string()); + } + } + if let Some(object) = &item.asset_object + && object.asset_kind.trim() != binding_kind + { + return Err("生成结果音频 asset_object kind 与 binding 不一致".to_string()); + } + } + if let (Some(resource), Some(asset)) = (&item.project_resource, &item.asset) { + let metadata_matches = resource.width == asset.width + && resource.height == asset.height + && resource.image_src.trim() == asset.image_src.trim() + && resource.source_type.trim() == asset.source_type.trim() + && normalized_optional_text_eq(resource.prompt.as_ref(), asset.prompt.as_ref()) + && normalized_optional_text_eq( + resource.actual_prompt.as_ref(), + asset.actual_prompt.as_ref(), + ) + && normalized_optional_text_eq(resource.model.as_ref(), asset.model.as_ref()) + && normalized_optional_text_eq(resource.provider.as_ref(), asset.provider.as_ref()) + && normalized_optional_text_eq(resource.task_id.as_ref(), asset.task_id.as_ref()) + && normalized_optional_text_eq(resource.asset_kind.as_ref(), asset.asset_kind.as_ref()) + && normalized_optional_text_eq( + resource.generation_inputs_json.as_ref(), + asset.generation_inputs_json.as_ref(), + ) + && resource.image_sequence_frames_json == asset.image_sequence_frames_json + && resource.image_sequence_duration_ms == asset.image_sequence_duration_ms; + if !metadata_matches { + return Err("生成结果 project resource 与 editor asset 业务元数据不一致".to_string()); + } + } + if let Some(object) = &item.asset_object { + let canonical_task_id = item + .project_resource + .as_ref() + .and_then(|resource| normalize_optional_ref(resource.task_id.as_ref())) + .or_else(|| { + item.asset + .as_ref() + .and_then(|asset| normalize_optional_ref(asset.task_id.as_ref())) + }); + if let Some(task_id) = canonical_task_id + && normalize_optional_ref(object.source_job_id.as_ref()) != Some(task_id) + { + return Err( + "生成结果 asset_object source_job_id 与业务记录 task_id 不一致".to_string(), + ); + } + } + Ok(()) +} + +fn normalized_optional_text_eq(left: Option<&String>, right: Option<&String>) -> bool { + normalize_optional_ref(left) == normalize_optional_ref(right) +} + +fn validate_editor_generation_result_new_records( + ctx: &ReducerContext, + input: &EditorGenerationResultPersistInput, + owner_user_id: &str, +) -> Result<(), String> { + for item in &input.items { + if let Some(object) = &item.asset_object { + validate_editor_generation_asset_object_candidate(ctx, object, owner_user_id)?; + } + if let Some(resource) = &item.project_resource { + let resource_id = resource.resource_id.trim().to_string(); + if ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id) + .is_some() + { + return Err("生成结果缺少 receipt,但稳定 project resource 已存在".to_string()); + } + let _ = prepare_editor_project_resource(ctx, resource.clone())?; + validate_editor_generation_registered_object_reference( + ctx, + resource.asset_object_id.as_deref(), + resource.object_key.as_deref(), + item.asset_object.as_ref(), + owner_user_id, + )?; + validate_editor_generation_source_resource_reference( + ctx, + input, + resource, + owner_user_id, + )?; + } + if let Some(asset) = &item.asset { + let asset_id = asset.asset_id.trim().to_string(); + if ctx.db.editor_asset().asset_id().find(&asset_id).is_some() { + return Err("生成结果缺少 receipt,但稳定 editor asset 已存在".to_string()); + } + validate_editor_generation_registered_object_reference( + ctx, + asset.asset_object_id.as_deref(), + asset.object_key.as_deref(), + item.asset_object.as_ref(), + owner_user_id, + )?; + validate_editor_generation_asset_source_resource_reference( + ctx, + input, + asset, + owner_user_id, + )?; + } + if let Some(binding) = &item.binding { + if crate::asset_metadata::find_asset_entity_binding_snapshot_by_id( + ctx, + binding.binding_id.trim(), + ) + .is_some() + { + return Err("生成结果缺少 receipt,但稳定 asset binding 已存在".to_string()); + } + if crate::asset_metadata::find_asset_entity_binding_snapshot_by_slot( + ctx, + &binding.entity_kind, + &binding.entity_id, + &binding.slot, + ) + .is_some() + { + return Err("生成结果 binding slot 已由其他 binding_id 占用".to_string()); + } + validate_editor_generation_registered_object_reference( + ctx, + Some(binding.asset_object_id.as_str()), + None, + item.asset_object.as_ref(), + owner_user_id, + )?; + } + } + Ok(()) +} + +fn validate_editor_generation_source_resource_reference( + ctx: &ReducerContext, + input: &EditorGenerationResultPersistInput, + resource: &EditorProjectResourceCreateInput, + owner_user_id: &str, +) -> Result<(), String> { + let Some(source_resource_id) = normalize_optional_ref(resource.source_resource_id.as_ref()) + else { + return Ok(()); + }; + if source_resource_id == resource.resource_id.trim() { + return Err("生成结果 project resource 不能引用自身作为来源".to_string()); + } + if let Some(source) = input + .items + .iter() + .filter_map(|item| item.project_resource.as_ref()) + .find(|candidate| candidate.resource_id.trim() == source_resource_id) + { + return validate_editor_generation_source_resource_identity( + resource, + source.owner_user_id.as_str(), + source.project_id.as_str(), + owner_user_id, + ); + } + let source = require_owned_project_resource(ctx, source_resource_id, owner_user_id)?; + validate_editor_generation_source_resource_identity( + resource, + source.owner_user_id.as_str(), + source.project_id.as_str(), + owner_user_id, + ) +} + +fn validate_editor_generation_asset_source_resource_reference( + ctx: &ReducerContext, + input: &EditorGenerationResultPersistInput, + asset: &EditorAssetCreateInput, + owner_user_id: &str, +) -> Result<(), String> { + let Some(source_resource_id) = normalize_optional_ref(asset.source_resource_id.as_ref()) else { + return Ok(()); + }; + let expected_project_id = editor_generation_result_project_id(input)?; + if let Some(source) = input + .items + .iter() + .filter_map(|item| item.project_resource.as_ref()) + .find(|candidate| candidate.resource_id.trim() == source_resource_id) + { + return validate_editor_generation_source_resource_owner_and_project( + source.owner_user_id.as_str(), + source.project_id.as_str(), + owner_user_id, + expected_project_id.as_deref(), + ); + } + let source = require_owned_project_resource(ctx, source_resource_id, owner_user_id)?; + validate_editor_generation_source_resource_owner_and_project( + source.owner_user_id.as_str(), + source.project_id.as_str(), + owner_user_id, + expected_project_id.as_deref(), + ) +} + +fn validate_editor_generation_source_resource_identity( + resource: &EditorProjectResourceCreateInput, + source_owner_user_id: &str, + source_project_id: &str, + owner_user_id: &str, +) -> Result<(), String> { + validate_editor_generation_source_resource_owner_and_project( + source_owner_user_id, + source_project_id, + owner_user_id, + Some(resource.project_id.as_str()), + ) +} + +fn validate_editor_generation_source_resource_owner_and_project( + source_owner_user_id: &str, + source_project_id: &str, + owner_user_id: &str, + expected_project_id: Option<&str>, +) -> Result<(), String> { + if source_owner_user_id.trim() != owner_user_id + || expected_project_id.is_some_and(|expected_project_id| { + source_project_id.trim() != expected_project_id.trim() + }) + { + return Err("生成结果来源资源不属于同一 owner 与项目".to_string()); + } + Ok(()) +} + +fn validate_editor_generation_asset_object_candidate( + ctx: &ReducerContext, + candidate: &AssetObjectUpsertInput, + owner_user_id: &str, +) -> Result<(), String> { + let candidate_id = + normalize_required(&candidate.asset_object_id, "asset_object.asset_object_id")?; + if normalize_optional_ref(candidate.owner_user_id.as_ref()) != Some(owner_user_id) { + return Err("生成结果 asset_object owner_user_id 不一致".to_string()); + } + let by_id = crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, candidate_id.as_str()); + let by_location = crate::asset_metadata::find_asset_object_by_location( + ctx, + &AssetObjectLocationInput { + bucket: candidate.bucket.clone(), + object_key: candidate.object_key.clone(), + }, + )?; + if let Some(existing) = by_id.as_ref().or(by_location.as_ref()) { + if existing.asset_object_id == candidate_id + && editor_generation_asset_object_matches(existing, candidate) + { + return Err("生成结果缺少 receipt,但稳定 asset_object 已存在".to_string()); + } + return Err("生成结果 asset_object 幂等键已被其他内容占用".to_string()); + } + Ok(()) +} + +fn validate_editor_generation_registered_object_reference( + ctx: &ReducerContext, + asset_object_id: Option<&str>, + object_key: Option<&str>, + candidate: Option<&AssetObjectUpsertInput>, + owner_user_id: &str, +) -> Result<(), String> { + let Some(asset_object_id) = asset_object_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + if candidate.is_some_and(|candidate| candidate.asset_object_id.trim() == asset_object_id) { + return Ok(()); + } + let existing = crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, asset_object_id) + .ok_or_else(|| "生成结果引用的 asset_object 尚未登记".to_string())?; + if normalize_optional_ref(existing.owner_user_id.as_ref()) != Some(owner_user_id) { + return Err("生成结果引用的 asset_object 不属于当前 owner".to_string()); + } + if let Some(object_key) = object_key.map(str::trim).filter(|value| !value.is_empty()) + && existing.object_key.trim() != object_key.trim_start_matches('/') + { + return Err("生成结果引用的 asset_object 与 object_key 不一致".to_string()); + } + Ok(()) +} + +fn create_editor_generation_project_resource( + ctx: &ReducerContext, + input: EditorProjectResourceCreateInput, +) -> Result { + let resource = prepare_editor_project_resource(ctx, input)?; + if ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource.resource_id) + .is_some() + { + return Err("生成结果稳定 project resource 已存在".to_string()); + } + let resource_id = resource.resource_id.clone(); + ctx.db.editor_project_resource().insert(resource); + ctx.db + .editor_project_resource() + .resource_id() + .find(&resource_id) + .map(resource_snapshot_from_row) + .ok_or_else(|| "生成结果 project resource 创建失败".to_string()) +} + +fn validate_editor_generation_replay_registered_object( + ctx: &ReducerContext, + item: &EditorGenerationResultPersistItemInput, + owner_user_id: &str, +) -> Result<(), String> { + if item.asset_object.is_some() { + return Ok(()); + } + let asset_object_id = item + .project_resource + .as_ref() + .and_then(|resource| normalize_optional_ref(resource.asset_object_id.as_ref())) + .or_else(|| { + item.asset + .as_ref() + .and_then(|asset| normalize_optional_ref(asset.asset_object_id.as_ref())) + }) + .or_else(|| { + item.binding + .as_ref() + .map(|binding| binding.asset_object_id.trim()) + }); + let Some(asset_object_id) = asset_object_id else { + return Ok(()); + }; + let object_key = item + .project_resource + .as_ref() + .and_then(|resource| normalize_optional_ref(resource.object_key.as_ref())) + .or_else(|| { + item.asset + .as_ref() + .and_then(|asset| normalize_optional_ref(asset.object_key.as_ref())) + }); + let existing = crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, asset_object_id) + .ok_or_else(|| "生成结果重放缺少引用的 asset_object".to_string())?; + if normalize_optional_ref(existing.owner_user_id.as_ref()) != Some(owner_user_id) { + return Err("生成结果重放引用的 asset_object owner 发生漂移".to_string()); + } + if let Some(object_key) = object_key + && existing.object_key.trim() != object_key.trim_start_matches('/') + { + return Err("生成结果重放引用的 asset_object object_key 发生漂移".to_string()); + } + let task_id = item + .project_resource + .as_ref() + .and_then(|resource| normalize_optional_ref(resource.task_id.as_ref())) + .or_else(|| { + item.asset + .as_ref() + .and_then(|asset| normalize_optional_ref(asset.task_id.as_ref())) + }); + if let Some(task_id) = task_id + && normalize_optional_ref(existing.source_job_id.as_ref()) != Some(task_id) + { + return Err("生成结果重放引用的 asset_object task 身份发生漂移".to_string()); + } + let record_kind = item + .project_resource + .as_ref() + .and_then(|resource| normalize_optional_ref(resource.asset_kind.as_ref())) + .or_else(|| { + item.asset + .as_ref() + .and_then(|asset| normalize_optional_ref(asset.asset_kind.as_ref())) + }); + let expected_object_kind = item + .binding + .as_ref() + .map(|binding| binding.asset_kind.trim()) + .or_else(|| { + record_kind + .map(audio_asset_object_kind) + .filter(|kind| !kind.is_empty()) + }) + .or(record_kind); + if expected_object_kind.is_some_and(|kind| existing.asset_kind.trim() != kind) { + return Err("生成结果重放引用的 asset_object kind 发生漂移".to_string()); + } + if record_kind.is_some_and(|kind| matches!(kind, "sound-effect" | "background-music")) + && !existing + .content_type + .as_deref() + .is_some_and(|content_type| content_type.trim().starts_with("audio/")) + { + return Err("生成结果重放引用的音频 asset_object 媒体类型发生漂移".to_string()); + } + Ok(()) +} + +fn replay_editor_generation_result( + ctx: &ReducerContext, + input: &EditorGenerationResultPersistInput, + receipt: &EditorGenerationOperation, + commit_sha256: &str, +) -> Result { + if receipt.owner_user_id.trim() != input.owner_user_id.trim() + || receipt.operation_kind.trim() != input.operation_kind.trim() + || receipt.operation_id.trim() != input.operation_id.trim() + || receipt.operation_fingerprint.trim() != input.operation_fingerprint.trim() + || receipt.commit_sha256 != commit_sha256 + { + return Err("生成结果 operation 已存在,但 fingerprint 或提交内容不一致".to_string()); + } + let mut items = Vec::with_capacity(input.items.len()); + for item in &input.items { + validate_editor_generation_replay_registered_object( + ctx, + item, + input.owner_user_id.as_str(), + )?; + let asset_object = item + .asset_object + .as_ref() + .map(|candidate| { + let existing = crate::asset_metadata::find_asset_object_snapshot_by_id( + ctx, + candidate.asset_object_id.as_str(), + ) + .ok_or_else(|| "生成结果重放缺少 asset_object".to_string())?; + if !editor_generation_asset_object_matches(&existing, candidate) { + return Err("生成结果重放 asset_object 内容漂移".to_string()); + } + Ok(existing) + }) + .transpose()?; + let project_resource = item + .project_resource + .as_ref() + .map(|candidate| { + let existing = ctx + .db + .editor_project_resource() + .resource_id() + .find(&candidate.resource_id.trim().to_string()) + .ok_or_else(|| "生成结果重放缺少 project resource".to_string())?; + if !editor_generation_project_resource_matches(&existing, candidate) { + return Err("生成结果重放 project resource 内容漂移".to_string()); + } + Ok(resource_snapshot_from_row(existing)) + }) + .transpose()?; + let asset = item + .asset + .as_ref() + .map(|candidate| { + let existing = ctx + .db + .editor_asset() + .asset_id() + .find(&candidate.asset_id.trim().to_string()) + .ok_or_else(|| "生成结果重放缺少 editor asset".to_string())?; + if !editor_generation_asset_matches(&existing, candidate) { + return Err("生成结果重放 editor asset 内容漂移".to_string()); + } + Ok(asset_snapshot_from_row(ctx, existing)) + }) + .transpose()?; + let binding = item + .binding + .as_ref() + .map(|candidate| { + let existing = crate::asset_metadata::find_asset_entity_binding_snapshot_by_id( + ctx, + candidate.binding_id.as_str(), + ) + .ok_or_else(|| "生成结果重放缺少 asset binding".to_string())?; + if !editor_generation_binding_matches(&existing, candidate) { + return Err("生成结果重放 asset binding 内容漂移".to_string()); + } + Ok(existing) + }) + .transpose()?; + items.push(EditorGenerationResultPersistedItem { + slot: item.slot.trim().to_string(), + asset_object, + project_resource, + asset, + binding, + }); + } + let expected_project_id = editor_generation_result_project_id(input)?; + if receipt.project_id != expected_project_id { + return Err("生成结果重放 canvas 提交内容不一致".to_string()); + } + let project = input + .canvas_layout + .as_ref() + .map(|layout| build_project_snapshot(ctx, layout.project_id.trim())) + .transpose()?; + let job = match &input.job_completion { + Some(completion) => { + if receipt.job_id.as_deref() != Some(completion.job_id.trim()) + || receipt.job_worker_id.as_deref() != Some(completion.worker_id.trim()) + || receipt.job_lease_token.as_deref() != Some(completion.lease_token.trim()) + { + return Err("生成结果重放 job completion 内容不一致".to_string()); + } + let job = + crate::external_generation::validate_completed_editor_generation_job_replay_tx( + ctx, + completion, + input.owner_user_id.as_str(), + input.operation_kind.as_str(), + input.operation_fingerprint.as_str(), + expected_project_id.as_deref(), + )?; + if receipt.job_result_payload_sha256 + != editor_generation_result_payload_sha256(job.result_payload_json.as_deref()) + { + return Err("生成结果重放 job 权威结果与 receipt 不一致".to_string()); + } + Some(job) + } + None => { + if receipt.job_id.is_some() + || receipt.job_worker_id.is_some() + || receipt.job_lease_token.is_some() + || receipt.job_result_payload_sha256.is_some() + { + return Err("生成结果重放遗漏 queue completion guard".to_string()); + } + None + } + }; + Ok(EditorGenerationResultPersisted { + status: EditorGenerationResultPersistStatus::AlreadyApplied, + items, + project, + job, + }) +} + +fn editor_generation_result_payload_sha256(payload: Option<&str>) -> Option { + payload.map(|payload| format!("{:x}", Sha256::digest(payload.as_bytes()))) +} + +fn editor_generation_operation_key(owner_user_id: &str, operation_id: &str) -> String { + format!( + "editor-generation-operation-{:x}", + Sha256::digest( + format!( + "editor-generation-operation-v1\0{}\0{}", + owner_user_id.trim(), + operation_id.trim() + ) + .as_bytes() + ) + ) +} + +fn editor_generation_result_commit_sha256( + input: &EditorGenerationResultPersistInput, +) -> Result { + let encoded = spacetimedb::sats::bsatn::to_vec(input) + .map_err(|error| format!("生成结果 prepared commit 编码失败:{error}"))?; + let mut hasher = Sha256::new(); + hasher.update(b"editor-generation-commit-v1\0"); + hasher.update(encoded); + Ok(format!("{:x}", hasher.finalize())) +} + +fn editor_generation_asset_object_matches( + existing: &AssetObjectUpsertSnapshot, + candidate: &AssetObjectUpsertInput, +) -> bool { + existing.asset_object_id.trim() == candidate.asset_object_id.trim() + && existing.bucket.trim() == candidate.bucket.trim() + && existing.object_key.trim() == candidate.object_key.trim().trim_start_matches('/') + && existing.access_policy == candidate.access_policy + && existing.content_type == normalize_optional(candidate.content_type.clone()) + && existing.content_length == candidate.content_length + && existing.content_hash == normalize_optional(candidate.content_hash.clone()) + && existing.version == candidate.version + && existing.source_job_id == normalize_optional(candidate.source_job_id.clone()) + && existing.owner_user_id == normalize_optional(candidate.owner_user_id.clone()) + && existing.profile_id == normalize_optional(candidate.profile_id.clone()) + && existing.entity_id == normalize_optional(candidate.entity_id.clone()) + && existing.asset_kind.trim() == candidate.asset_kind.trim() +} + +fn editor_generation_project_resource_matches( + existing: &EditorProjectResource, + candidate: &EditorProjectResourceCreateInput, +) -> bool { + editor_pixel_art_project_resource_matches(existing, candidate) + && existing.image_sequence_frames_json + == normalize_optional(candidate.image_sequence_frames_json.clone()) + && existing.image_sequence_duration_ms == candidate.image_sequence_duration_ms +} + +fn editor_generation_asset_matches( + existing: &EditorAsset, + candidate: &EditorAssetCreateInput, +) -> bool { + editor_pixel_art_asset_matches(existing, candidate) + && existing.image_sequence_frames_json + == normalize_optional(candidate.image_sequence_frames_json.clone()) + && existing.image_sequence_duration_ms == candidate.image_sequence_duration_ms +} + +fn editor_generation_binding_matches( + existing: &AssetEntityBindingSnapshot, + candidate: &AssetEntityBindingInput, +) -> bool { + existing.binding_id.trim() == candidate.binding_id.trim() + && existing.asset_object_id.trim() == candidate.asset_object_id.trim() + && existing.entity_kind.trim() == candidate.entity_kind.trim() + && existing.entity_id.trim() == candidate.entity_id.trim() + && existing.slot.trim() == candidate.slot.trim() + && existing.asset_kind.trim() == candidate.asset_kind.trim() + && existing.owner_user_id == normalize_optional(candidate.owner_user_id.clone()) + && existing.profile_id == normalize_optional(candidate.profile_id.clone()) +} + fn validate_editor_pixel_art_result_input( ctx: &ReducerContext, input: &EditorPixelArtResultPersistInput, @@ -12050,6 +13311,684 @@ mod tests { use super::*; use serde_json::json; + fn editor_generation_result_input() -> EditorGenerationResultPersistInput { + let owner_user_id = "user-1"; + let operation_kind = "editor_image_generation"; + let operation_id = "job-1"; + let slot = "primary"; + EditorGenerationResultPersistInput { + owner_user_id: owner_user_id.to_string(), + operation_kind: operation_kind.to_string(), + operation_id: operation_id.to_string(), + operation_fingerprint: "a".repeat(64), + items: vec![EditorGenerationResultPersistItemInput { + slot: slot.to_string(), + asset_object: None, + project_resource: Some(EditorProjectResourceCreateInput { + resource_id: shared_contracts::editor_generation_stable_resource_id( + owner_user_id, + operation_kind, + operation_id, + slot, + ), + project_id: "project-1".to_string(), + owner_user_id: owner_user_id.to_string(), + asset_object_id: None, + image_src: "/generated/image.png".to_string(), + object_key: None, + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: Some("测试".to_string()), + actual_prompt: None, + model: Some("test-model".to_string()), + provider: Some("test-provider".to_string()), + task_id: Some("provider-task-1".to_string()), + source_resource_id: None, + asset_kind: Some("character".to_string()), + generation_inputs_json: None, + updated_at_micros: 1_000, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, + }), + asset: None, + binding: None, + }], + canvas_layout: None, + job_completion: None, + completed_at_micros: 1_000, + } + } + + fn editor_generation_asset_input( + asset_object_id: Option<&str>, + object_key: Option<&str>, + source_resource_id: Option<&str>, + ) -> EditorAssetCreateInput { + EditorAssetCreateInput { + asset_id: "asset-1".to_string(), + owner_user_id: "user-1".to_string(), + folder_id: "project".to_string(), + label: "生成素材".to_string(), + asset_object_id: asset_object_id.map(str::to_string), + image_src: "/generated/image.png".to_string(), + object_key: object_key.map(str::to_string), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: Some("测试".to_string()), + actual_prompt: None, + model: Some("test-model".to_string()), + provider: Some("test-provider".to_string()), + task_id: Some("provider-task-1".to_string()), + asset_kind: Some("character".to_string()), + generation_inputs_json: None, + source_resource_id: source_resource_id.map(str::to_string), + now_micros: 1_000, + thumbnail_src: None, + generation_cost_mud_points: 0, + group_task_id: None, + group_task_expected_asset_count: None, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, + } + } + + #[test] + fn editor_generation_result_shape_requires_stable_ids_and_unique_slots() { + let input = editor_generation_result_input(); + assert!( + validate_editor_generation_result_shape( + &input, + "user-1", + "editor_image_generation", + "job-1", + ) + .is_ok() + ); + + let mut unstable = input.clone(); + unstable.items[0] + .project_resource + .as_mut() + .expect("resource") + .resource_id = "random-resource-id".to_string(); + assert!( + validate_editor_generation_result_shape( + &unstable, + "user-1", + "editor_image_generation", + "job-1", + ) + .expect_err("unstable resource ID must fail") + .contains("稳定 ID") + ); + + let mut duplicate_slot = input.clone(); + duplicate_slot.items.push(input.items[0].clone()); + assert!( + validate_editor_generation_result_shape( + &duplicate_slot, + "user-1", + "editor_image_generation", + "job-1", + ) + .expect_err("duplicate slots must fail") + .contains("重复 slot") + ); + } + + #[test] + fn editor_generation_result_rejects_cross_record_object_drift_without_candidate() { + let mut input = editor_generation_result_input(); + let item = &mut input.items[0]; + let resource = item.project_resource.as_mut().expect("resource"); + resource.asset_object_id = Some("object-a".to_string()); + resource.object_key = Some("generated/object-a.png".to_string()); + item.asset = Some(editor_generation_asset_input( + Some("object-b"), + Some("generated/object-a.png"), + Some(resource.resource_id.as_str()), + )); + let error = validate_editor_generation_item_relationships( + item, + "user-1", + "editor_image_generation", + "job-1", + ) + .expect_err("resource and asset must not reference different objects"); + assert!(error.contains("asset_object ID 引用不一致")); + + item.asset.as_mut().expect("asset").asset_object_id = Some("object-a".to_string()); + item.binding = Some(AssetEntityBindingInput { + binding_id: "binding-1".to_string(), + asset_object_id: "object-b".to_string(), + entity_kind: "editor_asset".to_string(), + entity_id: "asset-1".to_string(), + slot: "primary".to_string(), + asset_kind: "character".to_string(), + owner_user_id: Some("user-1".to_string()), + profile_id: None, + updated_at_micros: 1_000, + }); + let error = validate_editor_generation_item_relationships( + item, + "user-1", + "editor_image_generation", + "job-1", + ) + .expect_err("binding must reference the same object as resource and asset"); + assert!(error.contains("asset_object ID 引用不一致")); + + item.binding = None; + item.asset.as_mut().expect("asset").object_key = + Some("generated/object-other.png".to_string()); + let error = validate_editor_generation_item_relationships( + item, + "user-1", + "editor_image_generation", + "job-1", + ) + .expect_err("resource and asset must not reference different object keys"); + assert!(error.contains("object_key 引用不一致")); + } + + #[test] + fn editor_generation_job_presence_requires_matching_completion_guard() { + let completion = ExternalGenerationJobCompleteInput { + job_id: "job-1".to_string(), + worker_id: "worker-1".to_string(), + lease_token: "lease-1".to_string(), + result_payload_json: None, + completed_at_micros: 1_000, + }; + assert!( + validate_editor_generation_job_completion_presence_for_state( + "job-1", + true, + Some(&completion), + ) + .is_ok() + ); + assert!( + validate_editor_generation_job_completion_presence_for_state("job-1", true, None) + .expect_err("queued operation must require completion") + .contains("完整 job completion") + ); + assert!( + validate_editor_generation_job_completion_presence_for_state( + "job-1", + false, + Some(&completion), + ) + .expect_err("inline operation must not carry completion") + .contains("不存在") + ); + let mut wrong = completion; + wrong.job_id = "job-2".to_string(); + assert!( + validate_editor_generation_job_completion_presence_for_state( + "job-1", + true, + Some(&wrong), + ) + .expect_err("completion must match operation") + .contains("不一致") + ); + } + + #[test] + fn editor_generation_item_rejects_business_metadata_drift() { + let mut input = editor_generation_result_input(); + let item = &mut input.items[0]; + let resource_id = item + .project_resource + .as_ref() + .expect("resource") + .resource_id + .clone(); + item.asset = Some(editor_generation_asset_input( + None, + None, + Some(resource_id.as_str()), + )); + item.asset.as_mut().expect("asset").width += 1; + let error = validate_editor_generation_item_relationships( + item, + "user-1", + "editor_image_generation", + "job-1", + ) + .expect_err("resource and asset dimensions must agree"); + assert!(error.contains("业务元数据不一致")); + } + + #[test] + fn editor_generation_audio_binding_uses_explicit_kind_tuple() { + let mut item = editor_generation_result_input().items.remove(0); + let resource_id = item + .project_resource + .as_ref() + .expect("resource") + .resource_id + .clone(); + item.asset = Some(editor_generation_asset_input( + Some("object-a"), + Some("generated/object-a.mp3"), + Some(resource_id.as_str()), + )); + let resource = item.project_resource.as_mut().expect("resource"); + resource.asset_object_id = Some("object-a".to_string()); + resource.object_key = Some("generated/object-a.mp3".to_string()); + resource.asset_kind = Some("sound-effect".to_string()); + item.asset.as_mut().expect("asset").asset_kind = Some("sound-effect".to_string()); + item.binding = Some(AssetEntityBindingInput { + binding_id: "binding-1".to_string(), + asset_object_id: "object-a".to_string(), + entity_kind: "editor_audio".to_string(), + entity_id: "job-1".to_string(), + slot: "sound_effect".to_string(), + asset_kind: "editor_sound_effect".to_string(), + owner_user_id: Some("user-1".to_string()), + profile_id: None, + updated_at_micros: 1_000, + }); + assert!( + validate_editor_generation_item_relationships( + &item, + "user-1", + "editor_sound_effect_generation", + "job-1", + ) + .is_ok() + ); + item.binding.as_mut().expect("binding").slot = "background_music".to_string(); + assert!( + validate_editor_generation_item_relationships( + &item, + "user-1", + "editor_sound_effect_generation", + "job-1", + ) + .expect_err("audio slot drift must fail") + .contains("binding 语义") + ); + } + + #[test] + fn editor_generation_replay_revalidates_registered_object_without_candidate() { + let source = include_str!("editor_project_storage.rs"); + let replay = source + .split_once("fn replay_editor_generation_result(") + .and_then(|(_, tail)| { + tail.split_once("fn editor_generation_asset_object_matches(") + .map(|(body, _)| body) + }) + .expect("replay body"); + assert!(replay.contains("validate_editor_generation_replay_registered_object")); + let validator = source + .split_once("fn validate_editor_generation_replay_registered_object(") + .and_then(|(_, tail)| { + tail.split_once("fn replay_editor_generation_result(") + .map(|(body, _)| body) + }) + .expect("registered object replay validator"); + for required in [ + "find_asset_object_snapshot_by_id", + "existing.owner_user_id", + "existing.object_key", + "existing.source_job_id", + "existing.asset_kind", + ".content_type", + ] { + assert!( + validator.contains(required), + "missing replay guard: {required}" + ); + } + } + + #[test] + fn editor_generation_result_canvas_requires_matching_resource_project() { + let mut input = editor_generation_result_input(); + input.canvas_layout = Some(EditorProjectLayoutSaveV2Input { + project_id: "project-2".to_string(), + owner_user_id: "user-1".to_string(), + viewport: EditorProjectViewportSnapshot { + x: 0.0, + y: 0.0, + scale: 1.0, + }, + layers_json: "[]".to_string(), + expected_revision: 0, + updated_at_micros: 1_000, + }); + let error = validate_editor_generation_result_shape( + &input, + "user-1", + "editor_image_generation", + "job-1", + ) + .expect_err("canvas and resource must belong to the same project"); + assert!(error.contains("不属于同一 project")); + } + + #[test] + fn editor_generation_result_requires_one_project_without_canvas() { + let mut input = editor_generation_result_input(); + let mut second = input.items[0].clone(); + second.slot = "secondary".to_string(); + let second_resource = second + .project_resource + .as_mut() + .expect("secondary resource"); + second_resource.resource_id = shared_contracts::editor_generation_stable_resource_id( + "user-1", + "editor_image_generation", + "job-1", + "secondary", + ); + second_resource.project_id = "project-2".to_string(); + input.items.push(second); + + let error = validate_editor_generation_result_shape( + &input, + "user-1", + "editor_image_generation", + "job-1", + ) + .expect_err("resources without a canvas must still share one project"); + assert!(error.contains("不属于同一 project")); + } + + #[test] + fn editor_generation_source_resource_requires_same_owner_and_project() { + let input = editor_generation_result_input(); + let target = input.items[0] + .project_resource + .as_ref() + .expect("target resource"); + assert!( + validate_editor_generation_source_resource_identity( + target, + "user-1", + "project-1", + "user-1", + ) + .is_ok() + ); + assert!( + validate_editor_generation_source_resource_identity( + target, + "user-2", + "project-1", + "user-1", + ) + .is_err() + ); + assert!( + validate_editor_generation_source_resource_identity( + target, + "user-1", + "project-2", + "user-1", + ) + .is_err() + ); + assert!( + validate_editor_generation_source_resource_owner_and_project( + "user-1", + "project-2", + "user-1", + None, + ) + .is_ok(), + "asset-only 结果没有项目上下文时仍应允许同 owner 来源", + ); + assert!( + validate_editor_generation_source_resource_owner_and_project( + "user-2", + "project-1", + "user-1", + None, + ) + .is_err() + ); + let source = include_str!("editor_project_storage.rs"); + let new_record_validation = source + .split_once("fn validate_editor_generation_result_new_records(") + .and_then(|(_, tail)| { + tail.split_once("fn validate_editor_generation_source_resource_reference(") + .map(|(body, _)| body) + }) + .expect("new record validation"); + assert!( + new_record_validation + .contains("validate_editor_generation_asset_source_resource_reference") + ); + } + + #[test] + fn editor_generation_result_shape_accepts_sixty_four_slices_plus_two_source_items() { + let mut input = editor_generation_result_input(); + input.items.clear(); + for index in 0..EDITOR_GENERATION_RESULT_MAX_ITEMS { + let slot = if index == 0 { + "provider-source".to_string() + } else if index == 1 { + "spritesheet".to_string() + } else { + format!("slice-{:04}", index - 2) + }; + let mut item = editor_generation_result_input().items.remove(0); + item.slot = slot.clone(); + item.project_resource + .as_mut() + .expect("resource") + .resource_id = shared_contracts::editor_generation_stable_resource_id( + "user-1", + "editor_image_generation", + "job-1", + slot.as_str(), + ); + input.items.push(item); + } + assert!( + validate_editor_generation_result_shape( + &input, + "user-1", + "editor_image_generation", + "job-1", + ) + .is_ok() + ); + + let mut overflow = input; + let mut item = editor_generation_result_input().items.remove(0); + item.slot = "overflow".to_string(); + item.project_resource + .as_mut() + .expect("resource") + .resource_id = shared_contracts::editor_generation_stable_resource_id( + "user-1", + "editor_image_generation", + "job-1", + "overflow", + ); + overflow.items.push(item); + assert!( + validate_editor_generation_result_shape( + &overflow, + "user-1", + "editor_image_generation", + "job-1", + ) + .expect_err("more than the full spritesheet bundle must fail") + .contains("1 到 66") + ); + } + + #[test] + fn editor_generation_result_commit_hash_binds_every_prepared_commit_field() { + let input = editor_generation_result_input(); + let expected = editor_generation_result_commit_sha256(&input).expect("commit hash"); + assert_eq!( + expected, + editor_generation_result_commit_sha256(&input).expect("replayed commit hash") + ); + + let mut drifted = input; + drifted.items[0] + .project_resource + .as_mut() + .expect("resource") + .width = 1024; + assert_ne!( + expected, + editor_generation_result_commit_sha256(&drifted).expect("drifted commit hash") + ); + + let source = include_str!("editor_project_storage.rs"); + let hash_start = source + .find("fn editor_generation_result_commit_sha256(") + .expect("commit hash helper"); + let hash_tail = &source[hash_start..]; + let hash_end = hash_tail + .find("fn editor_generation_asset_object_matches(") + .expect("commit hash helper end"); + let hash_helper = &hash_tail[..hash_end]; + assert!(hash_helper.contains("spacetimedb::sats::bsatn::to_vec(input)")); + assert!(!hash_helper.contains("{input:?}")); + } + + #[test] + fn editor_generation_receipt_keeps_only_bounded_job_payload_hash() { + let payload = "x".repeat(2 * 1024 * 1024); + let digest = editor_generation_result_payload_sha256(Some(payload.as_str())) + .expect("payload digest"); + assert_eq!(digest.len(), 64); + assert!(!digest.contains(payload.as_str())); + assert_eq!(editor_generation_result_payload_sha256(None), None); + + let source = include_str!("editor_project_storage.rs"); + let receipt_start = source + .find("pub struct EditorGenerationOperation") + .expect("receipt table"); + let receipt_tail = &source[receipt_start..]; + let receipt_end = receipt_tail.find("}\n\n#[derive").expect("receipt end"); + let receipt = &receipt_tail[..receipt_end]; + assert!(receipt.contains("job_result_payload_sha256: Option")); + assert!(!receipt.contains("job_result_payload_json")); + + let replay_start = source + .find("fn replay_editor_generation_result(") + .expect("replay"); + let replay = &source[replay_start + ..source[replay_start..] + .find("fn editor_generation_operation_key(") + .map(|offset| replay_start + offset) + .expect("replay end")]; + assert!(replay.contains("job.result_payload_json.as_deref()")); + } + + #[test] + fn editor_generation_result_validates_binding_object_ownership_before_write() { + let source = include_str!("editor_project_storage.rs"); + let validation_start = source + .find("fn validate_editor_generation_result_new_records(") + .expect("new-record validation"); + let validation_tail = &source[validation_start..]; + let validation_end = validation_tail + .find("fn validate_editor_generation_asset_object_candidate(") + .expect("validation end"); + let validation = &validation_tail[..validation_end]; + assert!(validation.contains("Some(binding.asset_object_id.as_str())")); + assert!(validation.contains("validate_editor_generation_registered_object_reference(")); + } + + #[test] + fn editor_generation_result_fingerprint_must_be_sha256_hex() { + assert!(validate_editor_generation_operation_fingerprint(&"a".repeat(64)).is_ok()); + assert!(validate_editor_generation_operation_fingerprint("not-a-hash").is_err()); + assert!(validate_editor_generation_operation_fingerprint(&"z".repeat(64)).is_err()); + } + + #[test] + fn editor_generation_result_procedure_keeps_all_writes_in_one_transaction() { + let source = include_str!("editor_project_storage.rs"); + let procedure_start = source + .find("pub fn persist_editor_generation_result_and_return(") + .expect("atomic generation procedure should exist"); + let procedure_tail = &source[procedure_start..]; + let procedure_end = procedure_tail + .find("fn require_editor_generation_runtime_service_identity(") + .expect("procedure end marker should exist"); + assert!(procedure_tail[..procedure_end].contains( + "ctx.try_with_tx(|tx| persist_editor_generation_result(tx, caller, input.clone()))" + )); + + let transaction_start = source + .find("fn persist_editor_generation_result(") + .expect("transaction body should exist"); + let transaction_tail = &source[transaction_start..]; + let transaction_end = transaction_tail + .find("fn normalize_editor_generation_default_asset_folders(") + .expect("transaction body end marker should exist"); + let transaction = &transaction_tail[..transaction_end]; + let writes = [ + "upsert_asset_object(ctx, candidate)", + "create_editor_generation_project_resource(ctx, candidate)", + "create_editor_asset(ctx, caller, candidate)", + "upsert_asset_entity_binding(ctx, candidate)", + "persist_editor_project_layout_v2(ctx, canvas_layout)", + "complete_external_generation_job_tx(ctx, completion)", + "ctx.db.editor_generation_operation().insert(receipt)", + ]; + let mut cursor = 0; + for write in writes { + let offset = transaction[cursor..] + .find(write) + .unwrap_or_else(|| panic!("atomic transaction should contain {write}")); + cursor += offset + write.len(); + } + } + + #[test] + fn editor_generation_result_replay_is_read_only() { + let source = include_str!("editor_project_storage.rs"); + let replay_start = source + .find("fn replay_editor_generation_result(") + .expect("replay body should exist"); + let replay_tail = &source[replay_start..]; + let replay_end = replay_tail + .find("fn editor_generation_operation_key(") + .expect("replay body end marker should exist"); + let replay = &replay_tail[..replay_end]; + for forbidden in [ + "persist_editor_project_layout_v2(", + "complete_external_generation_job_tx(", + ".insert(", + ".update(", + ] { + assert!( + !replay.contains(forbidden), + "successful replay must remain read-only: {forbidden}" + ); + } + } + + #[test] + fn editor_generation_operation_key_fences_same_owner_request_id_across_kinds() { + assert_eq!( + editor_generation_operation_key("user-1", "request-1"), + editor_generation_operation_key(" user-1 ", " request-1 ") + ); + assert_ne!( + editor_generation_operation_key("user-1", "request-1"), + editor_generation_operation_key("user-2", "request-1") + ); + } + fn migration_frames(prefix: &str, with_frame_index: bool) -> JsonValue { let mut frames = vec![ json!({ diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 3052f7da3..a5a76d26e 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -887,6 +887,7 @@ fn finalize_external_generation_job_after_lease_exhaustion( &row.owner_user_id, row.price_mud_points, failed_at, + "final_attempt_lease_expired", )?; let row = mark_external_generation_job_lease_exhausted(row, failed_at, refund_ledger_id); persist_external_generation_job_row(ctx, row.clone()); @@ -918,7 +919,7 @@ fn mark_external_generation_job_lease_exhausted( row } -fn complete_external_generation_job_tx( +pub(crate) fn complete_external_generation_job_tx( ctx: &ReducerContext, input: ExternalGenerationJobCompleteInput, ) -> Result { @@ -1428,15 +1429,28 @@ fn fail_external_generation_job_tx( &input.lease_token, )?; let failed_at = ctx.timestamp; + let refund_ledger_id = crate::settle_external_generation_attempt_refund( + ctx, + &row.job_id, + row.attempt, + &row.owner_user_id, + row.price_mud_points, + failed_at, + "worker_attempt_failed", + )?; + let requested_refund_ledger_id = input + .refund_ledger_id + .and_then(|value| normalize_optional_text(value.as_str())); + if requested_refund_ledger_id.is_some() && requested_refund_ledger_id != refund_ledger_id { + return Err("external_generation_job.refund_ledger_id 与当前 attempt 不匹配".to_string()); + } let retry_delay_micros = duration_between_micros( input.retry_after_micros, input.failed_at_micros, "external_generation_job.retry_delay", )?; row.last_error_message = Some(error_message.clone()); - row.refund_ledger_id = input - .refund_ledger_id - .and_then(|value| normalize_optional_text(value.as_str())); + row.refund_ledger_id = refund_ledger_id; row.lease_expires_at = None; row.worker_id = None; row.lease_token = None; @@ -1514,36 +1528,100 @@ fn get_external_generation_queue_stats_tx( Ok(stats) } -#[cfg(any())] -pub(crate) fn validate_external_generation_job_lease_for_tx( +pub(crate) fn validate_editor_generation_job_lease_for_result_tx( ctx: &ReducerContext, - job_id: &str, - worker_id: &str, - lease_token: &str, - expected_job_kinds: &[&str], + input: &ExternalGenerationJobCompleteInput, expected_owner_user_id: &str, - expected_source_module: &str, - expected_source_entity_ids: &[String], -) -> Result<(), String> { - let row = get_worker_owned_external_generation_job(ctx, job_id, worker_id, lease_token)?; - if !expected_job_kinds.is_empty() - && !expected_job_kinds - .iter() - .any(|expected| row.job_kind.trim() == expected.trim()) - { - return Err("external_generation_job job_kind 与业务写回不匹配".to_string()); + expected_operation_kind: &str, + expected_operation_fingerprint: &str, + expected_project_id: Option<&str>, +) -> Result { + let row = get_worker_owned_external_generation_job( + ctx, + &input.job_id, + &input.worker_id, + &input.lease_token, + )?; + validate_editor_generation_job_business_identity( + &row, + expected_owner_user_id, + expected_operation_kind, + expected_operation_fingerprint, + expected_project_id, + )?; + Ok(map_external_generation_job_row(row)) +} + +pub(crate) fn validate_completed_editor_generation_job_replay_tx( + ctx: &ReducerContext, + input: &ExternalGenerationJobCompleteInput, + expected_owner_user_id: &str, + expected_operation_kind: &str, + expected_operation_fingerprint: &str, + expected_project_id: Option<&str>, +) -> Result { + validate_required("external_generation_job.job_id", &input.job_id)?; + let job_id = input.job_id.trim().to_string(); + let row = ctx + .db + .external_generation_job() + .job_id() + .find(&job_id) + .ok_or_else(|| "external_generation_job 不存在".to_string())?; + validate_editor_generation_job_business_identity( + &row, + expected_owner_user_id, + expected_operation_kind, + expected_operation_fingerprint, + expected_project_id, + )?; + if row.status != EXTERNAL_GENERATION_STATUS_COMPLETED { + return Err("external_generation_job 尚未完成,不能作为生成结果重放".to_string()); } + let expected_result_payload_json = if is_external_generation_editor_source(&row.source_module) { + validate_optional_external_generation_payload_json( + "external_generation_job.result_payload_json", + input.result_payload_json.as_deref(), + )? + } else { + input + .result_payload_json + .as_deref() + .and_then(normalize_optional_text) + }; + if row.result_payload_json != expected_result_payload_json { + return Err("external_generation_job 已完成结果与重放内容不一致".to_string()); + } + Ok(map_external_generation_job_row(row)) +} + +fn validate_editor_generation_job_business_identity( + row: &ExternalGenerationJob, + expected_owner_user_id: &str, + expected_operation_kind: &str, + expected_operation_fingerprint: &str, + expected_project_id: Option<&str>, +) -> Result<(), String> { if row.owner_user_id.trim() != expected_owner_user_id.trim() { return Err("external_generation_job owner_user_id 与业务写回不匹配".to_string()); } - if row.source_module.trim() != expected_source_module.trim() { - return Err("external_generation_job source_module 与业务写回不匹配".to_string()); + if row.job_kind.trim() != expected_operation_kind.trim() { + return Err("external_generation_job job_kind 与业务写回不匹配".to_string()); } - if !expected_source_entity_ids - .iter() - .any(|expected| row.source_entity_id.trim() == expected.trim()) + if !is_external_generation_editor_source(&row.source_module) { + return Err("external_generation_job source_module 不是编辑器生成链路".to_string()); + } + if let Some(expected_project_id) = expected_project_id + && row.source_entity_id.trim() != expected_project_id.trim() { - return Err("external_generation_job source_entity_id 与业务写回不匹配".to_string()); + return Err("external_generation_job source_entity_id 与生成结果项目不匹配".to_string()); + } + let actual_fingerprint = shared_contracts::editor_generation_request_fingerprint( + row.job_kind.as_str(), + row.request_payload_json.as_str(), + ); + if actual_fingerprint != expected_operation_fingerprint.trim() { + return Err("external_generation_job 请求 fingerprint 与业务写回不匹配".to_string()); } Ok(()) } @@ -2750,6 +2828,31 @@ mod tests { assert!(result.jobs.is_empty()); } + #[test] + fn worker_failure_refund_is_fenced_and_committed_with_the_job_transition() { + let source = include_str!("external_generation.rs"); + let body = source + .split_once("fn fail_external_generation_job_tx(") + .and_then(|(_, tail)| { + tail.split_once("fn get_external_generation_queue_stats_tx(") + .map(|(body, _)| body) + }) + .expect("worker failure transaction"); + let lease_guard = body + .find("get_worker_owned_external_generation_job(") + .expect("lease guard"); + let refund = body + .find("settle_external_generation_attempt_refund(") + .expect("wallet settlement"); + let persist = body + .find("persist_external_generation_job_row(") + .expect("job transition"); + + assert!(lease_guard < refund); + assert!(refund < persist); + assert!(body.contains("row.refund_ledger_id = refund_ledger_id")); + } + #[test] fn pending_job_is_claimable_only_after_available_time() { let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING); @@ -3252,6 +3355,41 @@ mod tests { assert_eq!(scanned_count, 2); } + #[test] + fn editor_generation_job_identity_binds_result_project() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); + row.job_kind = "editor_image_generation".to_string(); + row.source_module = "editor-canvas".to_string(); + row.source_entity_id = "project-1".to_string(); + row.request_payload_json = r#"{"prompt":"test"}"#.to_string(); + let fingerprint = shared_contracts::editor_generation_request_fingerprint( + row.job_kind.as_str(), + row.request_payload_json.as_str(), + ); + + assert!( + validate_editor_generation_job_business_identity( + &row, + "user-1", + "editor_image_generation", + fingerprint.as_str(), + Some("project-1"), + ) + .is_ok() + ); + assert!( + validate_editor_generation_job_business_identity( + &row, + "user-1", + "editor_image_generation", + fingerprint.as_str(), + Some("project-2"), + ) + .expect_err("job source entity must match the written project") + .contains("source_entity_id") + ); + } + #[test] fn payload_compaction_replaces_nested_inline_media_and_preserves_prompt() { let payload = r#"{"prompt":"保留这段展示提示","nested":[{"url":"data:image/png;base64,AAAA"},{"deep":{"source":" BLOB:https://example.test/id"}}]}"#; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 8e50e3116..0939bd94b 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -265,6 +265,7 @@ macro_rules! migration_tables { editor_showcase_campaign_config, editor_generation_pricing_config, editor_generation_runtime_identity_rotation, + editor_generation_operation, puzzle_agent_session, puzzle_background_compile_task, puzzle_agent_message, diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 56a43c81b..e53d8e6b9 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9241,6 +9241,7 @@ pub(crate) fn settle_external_generation_attempt_refund( user_id: &str, amount: u64, settled_at: Timestamp, + settlement_reason: &str, ) -> Result, String> { if amount == 0 { return Ok(None); @@ -9259,7 +9260,7 @@ pub(crate) fn settle_external_generation_attempt_refund( metadata_json: serde_json::json!({ "externalGenerationJobId": job_id.trim(), "claimAttempt": attempt, - "settlementReason": "final_attempt_lease_expired", + "settlementReason": settlement_reason, }) .to_string(), }, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 3e5df5323..762ae0a5c 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -8818,6 +8818,7 @@ pub(crate) fn settle_external_generation_attempt_refund( user_id: &str, amount: u64, settled_at: Timestamp, + settlement_reason: &str, ) -> Result, String> { if amount == 0 { return Ok(None); @@ -8836,7 +8837,7 @@ pub(crate) fn settle_external_generation_attempt_refund( metadata_json: serde_json::json!({ "externalGenerationJobId": job_id.trim(), "claimAttempt": attempt, - "settlementReason": "final_attempt_lease_expired", + "settlementReason": settlement_reason, }) .to_string(), },