合并主分支
处理合并冲突
This commit is contained in:
@@ -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 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。
|
||||
|
||||
@@ -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 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。
|
||||
|
||||
@@ -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 原图右侧继续排列。
|
||||
|
||||
|
||||
@@ -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/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` 分段组合。
|
||||
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 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 快照仍可读取。
|
||||
|
||||
Generated
+1
@@ -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
Reference in New Issue
Block a user