合并主分支

处理合并冲突
This commit is contained in:
2026-08-07 18:16:00 +08:00
committed by 王德宇
41 changed files with 8179 additions and 1003 deletions
@@ -6734,3 +6734,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 receiptqueue 与 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 IDinline 以稳定 request ID 为 operation IDProvider 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 提交才 disarmprocedure 发出前的明确失败/取消退款,发出后回包前的传输不确定或取消保留扣款。
- 影响范围:所有现役编辑器生成类型、`spacetime-module` / `spacetime-client` 结果提交契约、queue worker 终态写回、schema / migration / generated bindings 与对应故障注入测试。完美像素保留现有专用原子 procedure;手动图集拆分保留现有批量事务,其 canvas completion 并入批量事务另行收口。
- 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134
@@ -4356,3 +4356,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 receipt2026-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 并删除 generationInputsEditor 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;明确失败退款,但传输未知结果不退,否则远端已成功时又会变成「结果 + 退款」。
- 消费契约不能只测上游 builderExternal 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
@@ -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 commitreceipt 存在时核对 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 原图右侧继续排列。
@@ -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<AssetObjectUpsertInput>,
project_resource: Option<EditorProjectResourceCreateInput>,
asset: Option<EditorAssetCreateInput>,
binding: Option<AssetEntityBindingInput>,
}
EditorGenerationResultPersistInput {
owner_user_id: String,
operation_kind: String,
operation_id: String,
operation_fingerprint: String,
items: Vec<EditorGenerationResultPersistItemInput>,
canvas_layout: Option<EditorProjectLayoutSaveV2Input>,
job_completion: Option<ExternalGenerationJobCompleteInput>,
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/jobreceipt 只有一行,记录数、时间、完成事件数和 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` 分段组合。
File diff suppressed because one or more lines are too long
@@ -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 fingerprintinline 三个 job guard 全空,不接受半套栅栏。同一 `try_with_tx` 内:
1. 校验 job、ownerproject、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` 必须为正数并固化到 receiptobject/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 快照仍可读取。
+1
View File
@@ -5272,6 +5272,7 @@ version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -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<Vec<AssetOperationRefundOnDrop>>;
static EDITOR_GENERATION_COMMIT_DISPATCHED: Cell<bool>;
static EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN: Cell<bool>;
}
pub(crate) async fn with_editor_generation_durable_billing_boundary<T, E, Fut>(
future: Fut,
) -> Result<T, E>
where
Fut: Future<Output = Result<T, E>>,
{
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()"));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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,
}
}
@@ -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 {
File diff suppressed because it is too large Load Diff
@@ -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<PersistedAudioAsset, AppError> {
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<PersistedAudioAsset, AppError> {
asset_object_id: String,
asset_binding_id: String,
) -> Result<PreparedAudioAsset, AppError> {
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,
})
@@ -144,7 +144,8 @@ pub struct EditorImageEditResult {
pub prompt: String,
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
#[serde(default)]
pub provider: Option<String>,
pub task_id: String,
pub resource: Option<Value>,
pub asset: Option<Value>,
@@ -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);
}
}
@@ -160,7 +160,8 @@ pub struct EditorImageGenerationResult {
pub prompt: String,
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
#[serde(default)]
pub provider: Option<String>,
pub task_id: String,
pub resource: Option<Value>,
pub asset: Option<Value>,
@@ -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);
}
}
@@ -12,3 +12,4 @@ oss-contracts = []
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
@@ -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"}"#)
);
}
}
@@ -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;
@@ -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,
};
@@ -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 {
@@ -135,6 +135,50 @@ pub struct EditorSpritesheetSliceBatchPersistRecord {
pub items: Vec<EditorSpritesheetSlicePersistedItemRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorGenerationResultPersistItemRecordInput {
pub slot: String,
pub asset_object: Option<module_assets::AssetObjectUpsertInput>,
pub project_resource: Option<EditorProjectResourceCreateRecordInput>,
pub asset: Option<EditorAssetCreateRecordInput>,
pub binding: Option<module_assets::AssetEntityBindingInput>,
}
#[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<EditorGenerationResultPersistItemRecordInput>,
pub canvas_layout: Option<EditorProjectLayoutSaveV2RecordInput>,
pub job_completion: Option<ExternalGenerationJobCompleteRecordInput>,
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<module_assets::AssetObjectUpsertSnapshot>,
pub project_resource: Option<EditorProjectResourceRecord>,
pub asset: Option<EditorAssetRecord>,
pub binding: Option<module_assets::AssetEntityBindingSnapshot>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorGenerationResultPersistRecord {
pub status: EditorGenerationResultPersistStatusRecord,
pub items: Vec<EditorGenerationResultPersistedItemRecord>,
pub project: Option<EditorProjectRecord>,
pub job: Option<ExternalGenerationJobRecord>,
}
impl From<EditorSpritesheetSlicePersistItemRecordInput>
for crate::module_bindings::EditorSpritesheetSlicePersistItemInput
{
@@ -162,6 +206,37 @@ impl From<EditorSpritesheetSliceBatchPersistRecordInput>
}
}
impl From<EditorGenerationResultPersistItemRecordInput>
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<EditorGenerationResultPersistRecordInput>
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<EditorGenerationResultPersistRecord, SpacetimeClientError> {
self.persist_editor_generation_result_with_dispatch(input, || {})
.await
}
pub async fn persist_editor_generation_result_with_dispatch<F>(
&self,
input: EditorGenerationResultPersistRecordInput,
on_dispatch: F,
) -> Result<EditorGenerationResultPersistRecord, SpacetimeClientError>
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<EditorGenerationResultPersistRecord, SpacetimeClientError> {
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::<Result<Vec<_>, 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::*;
@@ -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,
@@ -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 {
@@ -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<EditorCanvasGenerationDialog>,
editor_canvas_layer: __sdk::TableUpdate<EditorCanvasLayer>,
editor_canvas_layout_migration: __sdk::TableUpdate<EditorCanvasLayoutMigration>,
editor_generation_operation: __sdk::TableUpdate<EditorGenerationOperation>,
editor_generation_pricing_config: __sdk::TableUpdate<EditorGenerationPricingConfig>,
editor_generation_runtime_identity_rotation:
__sdk::TableUpdate<EditorGenerationRuntimeIdentityRotation>,
@@ -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::<EditorGenerationOperation>(
"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::<EditorGenerationPricingConfig>(
"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::<EditorGenerationOperation>(
"editor_generation_operation",
&self.editor_generation_operation,
event,
);
callbacks.invoke_table_row_callbacks::<EditorGenerationPricingConfig>(
"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<RemoteModule>,
>
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>
{
}
impl<
Ctx: __sdk::DbContext<
Ctx: __sdk::DbContext<
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>,
> 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",

Some files were not shown because too many files have changed in this diff Show More