diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ca371cf90..2496537ca 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-13 外部生成任务持久化真实执行阶段 + +- 背景:图片画布任务列表此前把所有 `running` 任务固定映射为“正在生成”,角色生图、图标/UI spritesheet、角色动作和手动去背景进入抠图后仍无法展示“正在处理”;前端按耗时推断阶段会产生新的非正式业务真相。 +- 决策:不新增 DB 表,在既有 `external_generation_job` 与 `external_generation_job_summary` 末尾追加带默认值的可选 `phase`。worker claim 时写 `generating`;角色生图、图标 spritesheet、UI 素材提取在调用 BgFilter 前,角色动作在视频生成返回并开始抽帧/逐帧抠图前,手动去背景在执行开始时,通过 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。BFF 将 `running + processing` 映射为“正在处理”,其它 `running`(含旧数据 `phase=None`)映射为“正在生成”;前端只展示后端投影。 +- 影响范围:`external_generation_job`、`external_generation_job_summary`、SpacetimeDB procedure / typed client / bindings、图片画布生成 worker、任务列表 BFF 与相关文档。 +- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、外部生成 module/client/api-server 定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-07-13 角色动作逐帧开启 BgFilter cross-check - 背景:角色动作逐帧抠图此前为减少额外推理开销固定传 `cross_check=off`,但动作帧同样需要保留发丝、镂空和运动边缘质量。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index fdea14f6f..94a0872e7 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -25,7 +25,7 @@ - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;单帧首次失败立即重试 `1` 次,第二次仍失败才进入阿里云/本地降级链。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 -- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 +- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或独立去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板与素材侧栏、图层侧栏、右上角任务侧栏互斥,打开 Agent 时必须收起其它侧栏,打开其它侧栏或任务侧栏时也必须收起 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 6908adc2c..cefdcf63c 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -14,7 +14,7 @@ - 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。 - SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 - 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 -- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。 +- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。 - 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。 ## Module 与 Interface @@ -24,6 +24,7 @@ - `enqueue_external_generation_job_and_return`:按 `dedupe_key` 幂等创建或返回现有任务。 - `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`,并同步现有摘要投影;不新增阶段任务或阶段表。 - `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。 - `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。 - `list_external_generation_jobs_and_return`:按当前账号读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格和完成提示确认状态。 @@ -39,6 +40,7 @@ - `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 - `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 +- 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或独立去背景时切换为 `processing`,BFF 显示“正在处理”。旧任务 `phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。 - `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 - `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index be867894c..7333741bf 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -270,14 +270,14 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,但用户可见任务列表、价格、状态、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 +- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。 ### `external_generation_job_summary` - Rust 结构体:`ExternalGenerationJobSummary` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。 +- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、可选 `phase`、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、phase update、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure;`running + processing` 映射为“正在处理”,其它 running(含旧行 `phase=None`)映射为“正在生成”。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。 - 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return` 与 `backfill_external_generation_job_summaries_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。 ### `external_generation_job_event` diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 7fcbe6949..fbd654242 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -65,7 +65,8 @@ use crate::{ }, editor_project::{ EDITOR_BGFILTER_CROSS_CHECK_ENABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, - EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest, + EditorCanvasGeneratedLayerInput, EditorGenerationPhaseReporter, + PersistEditorGeneratedAssetRequest, apply_editor_screen_background_decision_to_generation_inputs, build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, persist_editor_generated_media_asset, @@ -605,6 +606,7 @@ pub async fn generate_editor_character_animation( request_context, owner_user_id, Ok(Json(payload)), + None, ) .await } @@ -614,6 +616,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( request_context: RequestContext, owner_user_id: String, payload: Result, JsonRejection>, + phase_reporter: Option, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { character_animation_error_response( @@ -728,6 +731,9 @@ pub(crate) async fn generate_editor_character_animation_for_owner( source_data_url.as_str(), ) .await?; + if let Some(reporter) = phase_reporter.as_ref() { + reporter.report_processing(&state).await?; + } let frames = extract_and_persist_editor_character_animation_frames( &state, owner_user_id.as_str(), @@ -5689,6 +5695,21 @@ mod tests { ); } + #[test] + fn editor_character_animation_reports_processing_after_video_generation() { + let source = include_str!("character_animation_assets.rs"); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + &[ + "request_editor_character_animation_preview", + "reporter.report_processing(&state).await?", + "extract_and_persist_editor_character_animation_frames", + ], + ); + } + #[test] fn editor_character_animation_frames_use_three_stage_matting_fallback() { let source = include_str!("character_animation_assets.rs"); diff --git a/server-rs/crates/api-server/src/editor_agent.rs b/server-rs/crates/api-server/src/editor_agent.rs index 6ac84f022..3b3b052ac 100644 --- a/server-rs/crates/api-server/src/editor_agent.rs +++ b/server-rs/crates/api-server/src/editor_agent.rs @@ -1669,6 +1669,7 @@ async fn execute_editor_agent_tool_call( owner_user_id: conversation.owner_user_id.clone(), audit_subject_user_id: Some(conversation.owner_user_id.clone()), audit_project_id: Some(conversation.project_id.clone()), + phase_reporter: None, }; let tool_request_context = editor_agent_tool_request_context(request_context, tool_call_id); let attachment_sources = editor_agent_attachment_sources(user_message.attachments.as_slice()); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 73042bbd1..6e4789e89 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -43,7 +43,7 @@ use spacetime_client::{ EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, - SpacetimeClientError, + ExternalGenerationJobPhaseUpdateRecordInput, SpacetimeClientError, }; use crate::{ @@ -314,6 +314,7 @@ pub(crate) struct EditorGenerationCaller { pub(crate) owner_user_id: String, pub(crate) audit_subject_user_id: Option, pub(crate) audit_project_id: Option, + pub(crate) phase_reporter: Option, } impl EditorGenerationCaller { @@ -323,8 +324,52 @@ impl EditorGenerationCaller { audit_subject_user_id: Some(owner_user_id.clone()), owner_user_id, audit_project_id: None, + phase_reporter: None, } } + + async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> { + if let Some(reporter) = self.phase_reporter.as_ref() { + reporter.report_processing(state).await?; + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct EditorGenerationPhaseReporter { + job_id: String, + worker_id: String, + lease_token: String, +} + +impl EditorGenerationPhaseReporter { + pub(crate) fn new(job_id: String, worker_id: String, lease_token: String) -> Self { + Self { + job_id, + worker_id, + lease_token, + } + } + + pub(crate) async fn report_processing(&self, state: &AppState) -> Result<(), AppError> { + state + .spacetime_client() + .update_external_generation_job_phase(ExternalGenerationJobPhaseUpdateRecordInput { + job_id: self.job_id.clone(), + worker_id: self.worker_id.clone(), + lease_token: self.lease_token.clone(), + phase: "processing".to_string(), + }) + .await + .map(|_| ()) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "external-generation-phase", + "message": format!("更新外部生成任务处理阶段失败:{error}"), + })) + }) + } } #[derive(Debug, Serialize)] @@ -1575,6 +1620,7 @@ pub(crate) async fn generate_editor_image_for_owner( "character-image", ) .await?; + caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -2296,6 +2342,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { let started_at = Instant::now(); + caller.report_processing_phase(state).await?; let source_image = parse_editor_reference_image( state, caller.owner_user_id.as_str(), @@ -3257,6 +3304,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheet", ) .await?; + caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -3559,6 +3607,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "spritesheet", ) .await?; + caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -7873,6 +7922,7 @@ mod tests { "fn normalize_editor_image_generation_size", &[ "persist_editor_green_screen_source_image", + "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", "if is_character_generation", @@ -7894,6 +7944,7 @@ mod tests { "pub async fn extract_editor_ui_design_assets", &[ "persist_editor_green_screen_source_image", + "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], @@ -7904,10 +7955,20 @@ mod tests { "pub async fn extract_editor_ui_design_assets", &[ "persist_editor_green_screen_source_image", + "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], ); + assert_function_contains_in_order( + source, + "pub(crate) async fn remove_editor_image_background_for_owner", + "struct EditorBackgroundRemovalImage", + &[ + "caller.report_processing_phase(state).await?", + "request_editor_background_removal_image", + ], + ); assert_function_contains( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index eb5ac1a69..196e39041 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -682,6 +682,7 @@ pub async fn generate_external_editor_character_animation( request_context, principal.owner_user_id().to_string(), payload, + None, ) .await } @@ -748,6 +749,7 @@ fn editor_generation_caller( owner_user_id: principal.owner_user_id().to_string(), audit_subject_user_id: Some(principal.owner_user_id().to_string()), audit_project_id: normalize_optional_string(project_id), + phase_reporter: None, } } diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs index 5d3bacc4b..55e43521c 100644 --- a/server-rs/crates/api-server/src/external_generation.rs +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -167,6 +167,9 @@ fn map_external_generation_job_status( ) -> ExternalGenerationJobStatusRecord { let (status, phase_detail, progress) = match job.status.as_str() { "completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100), + "running" if job.phase.as_deref() == Some("processing") => { + (ExternalGenerationJobStatus::Running, "正在处理。", 70) + } "running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35), "failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0), _ => (ExternalGenerationJobStatus::Queued, "排队中。", 8), @@ -293,9 +296,70 @@ mod tests { refund_ledger_id: None, notification_acknowledged_at: None, notification_acknowledged_at_micros: None, + phase: None, }); assert_eq!(task.request_prompt.as_deref(), Some("发光主视觉")); assert_eq!(task.status, ExternalGenerationJobStatus::Completed); } + + #[test] + fn maps_running_processing_phase_from_backend_projection() { + let status = map_external_generation_job_status(ExternalGenerationJobSummaryRecord { + job_id: "task-processing".to_string(), + job_kind: "editor_character_animation_generation".to_string(), + owner_user_id: "user-1".to_string(), + source_module: "editor-canvas".to_string(), + source_entity_id: "project-1".to_string(), + request_label: "角色动作生成".to_string(), + request_prompt: None, + status: "running".to_string(), + last_error_message: None, + created_at: "2026-07-13T08:00:00Z".to_string(), + started_at: Some("2026-07-13T08:00:01Z".to_string()), + completed_at: None, + updated_at: "2026-07-13T08:00:10Z".to_string(), + updated_at_micros: 1_000, + price_mud_points: 4, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: Some("processing".to_string()), + }); + + assert_eq!(status.status, ExternalGenerationJobStatus::Running); + assert_eq!(status.phase_detail, "正在处理。"); + assert_eq!(status.progress, 70); + } + + #[test] + fn maps_legacy_running_job_without_phase_as_generating() { + let mut job = ExternalGenerationJobSummaryRecord { + job_id: "task-legacy".to_string(), + job_kind: "editor_image_generation".to_string(), + owner_user_id: "user-1".to_string(), + source_module: "editor-canvas".to_string(), + source_entity_id: "project-1".to_string(), + request_label: "图片生成".to_string(), + request_prompt: None, + status: "running".to_string(), + last_error_message: None, + created_at: "2026-07-13T08:00:00Z".to_string(), + started_at: Some("2026-07-13T08:00:01Z".to_string()), + completed_at: None, + updated_at: "2026-07-13T08:00:10Z".to_string(), + updated_at_micros: 1_000, + price_mud_points: 4, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: None, + }; + + let legacy = map_external_generation_job_status(job.clone()); + assert_eq!(legacy.phase_detail, "正在生成。"); + job.phase = Some("generating".to_string()); + let generating = map_external_generation_job_status(job); + assert_eq!(generating.phase_detail, "正在生成。"); + } } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 993a75ad9..2427661b6 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -28,7 +28,7 @@ use crate::{ EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, }, editor_project::{ - EditorBackgroundRemovalRequest, EditorGenerationCaller, + EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationPhaseReporter, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner, @@ -666,7 +666,7 @@ async fn process_external_generation_job_once( match generate_editor_image_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -694,7 +694,7 @@ async fn process_external_generation_job_once( match edit_editor_image_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -723,7 +723,7 @@ async fn process_external_generation_job_once( match remove_editor_image_background_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -751,7 +751,7 @@ async fn process_external_generation_job_once( match generate_editor_icon_spritesheet_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -779,7 +779,7 @@ async fn process_external_generation_job_once( match extract_editor_ui_design_assets_for_owner( &state, &request_context, - editor_generation_worker_caller(&job), + editor_generation_worker_caller(&worker_id, &job)?, payload, ) .await @@ -810,6 +810,7 @@ async fn process_external_generation_job_once( request_context, job.owner_user_id.clone(), Ok(Json(payload)), + Some(editor_generation_phase_reporter(&worker_id, &job)?), ) .await { @@ -993,12 +994,27 @@ fn worker_request_context(job: &ExternalGenerationJobRecord) -> RequestContext { ) } -fn editor_generation_worker_caller(job: &ExternalGenerationJobRecord) -> EditorGenerationCaller { - EditorGenerationCaller { +fn editor_generation_worker_caller( + worker_id: &str, + job: &ExternalGenerationJobRecord, +) -> Result { + Ok(EditorGenerationCaller { owner_user_id: job.owner_user_id.clone(), 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)?), + }) +} + +fn editor_generation_phase_reporter( + worker_id: &str, + job: &ExternalGenerationJobRecord, +) -> Result { + Ok(EditorGenerationPhaseReporter::new( + job.job_id.clone(), + worker_id.to_string(), + require_job_lease_token(job)?, + )) } async fn complete_editor_generation_job( @@ -1180,6 +1196,16 @@ mod tests { assert_eq!(guard.lease_token.as_deref(), Some("lease-1")); } + #[test] + fn editor_worker_caller_carries_phase_reporter() { + let job = external_generation_job_record_fixture(Some("lease-1")); + + let caller = editor_generation_worker_caller("worker-a", &job) + .expect("worker caller should include claimed job phase reporter"); + + assert!(caller.phase_reporter.is_some()); + } + #[test] fn worker_write_guard_requires_claimed_job_lease_token() { let job = external_generation_job_record_fixture(None); @@ -1314,6 +1340,7 @@ mod tests { refund_ledger_id: None, notification_acknowledged_at: None, notification_acknowledged_at_micros: None, + phase: Some("generating".to_string()), } } } diff --git a/server-rs/crates/spacetime-client/src/external_generation.rs b/server-rs/crates/spacetime-client/src/external_generation.rs index 6d70087ea..5ee02ade6 100644 --- a/server-rs/crates/spacetime-client/src/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/external_generation.rs @@ -257,6 +257,31 @@ impl SpacetimeClient { .await } + pub async fn update_external_generation_job_phase( + &self, + input: ExternalGenerationJobPhaseUpdateRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "update_external_generation_job_phase_and_return", + move |connection, sender| { + connection + .procedures() + .update_external_generation_job_phase_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn fail_external_generation_job( &self, input: ExternalGenerationJobFailRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 2ae6f57ca..172ab2e6a 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -56,9 +56,10 @@ pub use mapper::{ ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobListRecord, - ExternalGenerationJobListRecordInput, ExternalGenerationJobRecord, - ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobSummaryListRecord, - ExternalGenerationJobSummaryRecord, ExternalGenerationQueueStatsRecord, + ExternalGenerationJobListRecordInput, ExternalGenerationJobPhaseUpdateRecordInput, + ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, + ExternalGenerationJobSummaryListRecord, ExternalGenerationJobSummaryRecord, + ExternalGenerationQueueStatsRecord, FeatureGateConfigRecord, JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index c29d71cde..d0fe61822 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -106,9 +106,9 @@ pub use self::external_generation::{ ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobListRecord, ExternalGenerationJobListRecordInput, - ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, - ExternalGenerationJobSummaryListRecord, ExternalGenerationJobSummaryRecord, - ExternalGenerationQueueStatsRecord, + ExternalGenerationJobPhaseUpdateRecordInput, ExternalGenerationJobRecord, + ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobSummaryListRecord, + ExternalGenerationJobSummaryRecord, ExternalGenerationQueueStatsRecord, }; pub use self::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs index fa1901aac..77535e7b6 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -54,6 +54,17 @@ impl From for ExternalGenerationJobR } } +impl From for ExternalGenerationJobPhaseUpdateInput { + fn from(input: ExternalGenerationJobPhaseUpdateRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + phase: input.phase, + } + } +} + impl From for ExternalGenerationJobFailInput { fn from(input: ExternalGenerationJobFailRecordInput) -> Self { Self { @@ -237,6 +248,7 @@ fn map_external_generation_job_snapshot( .notification_acknowledged_at_micros .map(format_timestamp_micros), notification_acknowledged_at_micros: snapshot.notification_acknowledged_at_micros, + phase: snapshot.phase, } } @@ -264,6 +276,7 @@ fn map_external_generation_job_summary_snapshot( .notification_acknowledged_at_micros .map(format_timestamp_micros), notification_acknowledged_at_micros: snapshot.notification_acknowledged_at_micros, + phase: snapshot.phase, } } @@ -309,6 +322,14 @@ pub struct ExternalGenerationJobRenewLeaseRecordInput { pub renewed_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobPhaseUpdateRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub phase: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExternalGenerationJobFailRecordInput { pub job_id: String, @@ -369,6 +390,7 @@ pub struct ExternalGenerationJobRecord { pub refund_ledger_id: Option, pub notification_acknowledged_at: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -400,6 +422,7 @@ pub struct ExternalGenerationJobSummaryRecord { pub refund_ledger_id: Option, pub notification_acknowledged_at: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index c0db8c832..974675948 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -485,6 +485,7 @@ pub mod external_generation_job_get_input_type; pub mod external_generation_job_list_input_type; pub mod external_generation_job_payload_compaction_input_type; pub mod external_generation_job_payload_compaction_procedure_result_type; +pub mod external_generation_job_phase_update_input_type; pub mod external_generation_job_procedure_result_type; pub mod external_generation_job_renew_lease_input_type; pub mod external_generation_job_snapshot_type; @@ -1232,6 +1233,7 @@ pub mod update_editor_asset_and_return_procedure; pub mod update_editor_asset_folder_and_return_procedure; pub mod update_editor_project_resource_showcase_and_return_procedure; pub mod update_editor_showcase_asset_display_and_return_procedure; +pub mod update_external_generation_job_phase_and_return_procedure; pub mod update_jump_hop_work_procedure; pub mod update_match_3_d_work_procedure; pub mod update_puzzle_clear_work_procedure; @@ -1823,6 +1825,7 @@ pub use external_generation_job_get_input_type::ExternalGenerationJobGetInput; pub use external_generation_job_list_input_type::ExternalGenerationJobListInput; pub use external_generation_job_payload_compaction_input_type::ExternalGenerationJobPayloadCompactionInput; pub use external_generation_job_payload_compaction_procedure_result_type::ExternalGenerationJobPayloadCompactionProcedureResult; +pub use external_generation_job_phase_update_input_type::ExternalGenerationJobPhaseUpdateInput; pub use external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; @@ -2570,6 +2573,7 @@ pub use update_editor_asset_and_return_procedure::update_editor_asset_and_return pub use update_editor_asset_folder_and_return_procedure::update_editor_asset_folder_and_return; pub use update_editor_project_resource_showcase_and_return_procedure::update_editor_project_resource_showcase_and_return; pub use update_editor_showcase_asset_display_and_return_procedure::update_editor_showcase_asset_display_and_return; +pub use update_external_generation_job_phase_and_return_procedure::update_external_generation_job_phase_and_return; pub use update_jump_hop_work_procedure::update_jump_hop_work; pub use update_match_3_d_work_procedure::update_match_3_d_work; pub use update_puzzle_clear_work_procedure::update_puzzle_clear_work; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_input_type.rs new file mode 100644 index 000000000..4d91f5eea --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationJobPhaseUpdateInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub phase: String, +} + +impl __sdk::InModule for ExternalGenerationJobPhaseUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs index a5a208caf..8778d141c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs @@ -31,6 +31,7 @@ pub struct ExternalGenerationJobSnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } impl __sdk::InModule for ExternalGenerationJobSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs index 492102908..653af336c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs @@ -23,6 +23,7 @@ pub struct ExternalGenerationJobSummarySnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } impl __sdk::InModule for ExternalGenerationJobSummarySnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs index 685be2b15..56fa51c24 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs @@ -23,6 +23,7 @@ pub struct ExternalGenerationJobSummary { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at: Option<__sdk::Timestamp>, + pub phase: Option, } impl __sdk::InModule for ExternalGenerationJobSummary { @@ -53,6 +54,7 @@ pub struct ExternalGenerationJobSummaryCols { pub refund_ledger_id: __sdk::__query_builder::Col>, pub notification_acknowledged_at: __sdk::__query_builder::Col>, + pub phase: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ExternalGenerationJobSummary { @@ -78,6 +80,7 @@ impl __sdk::__query_builder::HasCols for ExternalGenerationJobSummary { table_name, "notification_acknowledged_at", ), + phase: __sdk::__query_builder::Col::new(table_name, "phase"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs index ec62daa60..f63d494a4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs @@ -31,6 +31,7 @@ pub struct ExternalGenerationJob { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at: Option<__sdk::Timestamp>, + pub phase: Option, } impl __sdk::InModule for ExternalGenerationJob { @@ -67,6 +68,7 @@ pub struct ExternalGenerationJobCols { pub refund_ledger_id: __sdk::__query_builder::Col>, pub notification_acknowledged_at: __sdk::__query_builder::Col>, + pub phase: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ExternalGenerationJob { @@ -106,6 +108,7 @@ impl __sdk::__query_builder::HasCols for ExternalGenerationJob { table_name, "notification_acknowledged_at", ), + phase: __sdk::__query_builder::Col::new(table_name, "phase"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs new file mode 100644 index 000000000..76a8bce83 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::external_generation_job_phase_update_input_type::ExternalGenerationJobPhaseUpdateInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdateExternalGenerationJobPhaseAndReturnArgs { + pub input: ExternalGenerationJobPhaseUpdateInput, +} + +impl __sdk::InModule for UpdateExternalGenerationJobPhaseAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_external_generation_job_phase_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_external_generation_job_phase_and_return { + fn update_external_generation_job_phase_and_return( + &self, + input: ExternalGenerationJobPhaseUpdateInput, + ) { + self.update_external_generation_job_phase_and_return_then(input, |_, _| {}); + } + + fn update_external_generation_job_phase_and_return_then( + &self, + input: ExternalGenerationJobPhaseUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_external_generation_job_phase_and_return for super::RemoteProcedures { + fn update_external_generation_job_phase_and_return_then( + &self, + input: ExternalGenerationJobPhaseUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "update_external_generation_job_phase_and_return", + UpdateExternalGenerationJobPhaseAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index c4bf6c665..6c62cf888 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -7,6 +7,8 @@ const EXTERNAL_GENERATION_STATUS_RUNNING: &str = "running"; const EXTERNAL_GENERATION_STATUS_COMPLETED: &str = "completed"; const EXTERNAL_GENERATION_STATUS_FAILED: &str = "failed"; const EXTERNAL_GENERATION_STATUS_CANCELLED: &str = "cancelled"; +const EXTERNAL_GENERATION_PHASE_GENERATING: &str = "generating"; +const EXTERNAL_GENERATION_PHASE_PROCESSING: &str = "processing"; const EXTERNAL_GENERATION_EVENT_ENQUEUED: &str = "enqueued"; const EXTERNAL_GENERATION_EVENT_CLAIMED: &str = "claimed"; const EXTERNAL_GENERATION_EVENT_LEASE_RENEWED: &str = "lease_renewed"; @@ -82,6 +84,8 @@ pub struct ExternalGenerationJob { pub(crate) refund_ledger_id: Option, #[default(None::)] pub(crate) notification_acknowledged_at: Option, + #[default(None::)] + pub(crate) phase: Option, } #[spacetimedb::table( @@ -134,6 +138,8 @@ pub struct ExternalGenerationJobSummary { pub(crate) price_mud_points: u64, pub(crate) refund_ledger_id: Option, pub(crate) notification_acknowledged_at: Option, + #[default(None::)] + pub(crate) phase: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -169,6 +175,14 @@ pub struct ExternalGenerationJobRenewLeaseInput { pub renewed_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobPhaseUpdateInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub phase: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ExternalGenerationJobCompleteInput { pub job_id: String, @@ -252,6 +266,7 @@ pub struct ExternalGenerationJobSnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -284,6 +299,7 @@ pub struct ExternalGenerationJobSummarySnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub phase: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -424,6 +440,23 @@ pub fn renew_external_generation_job_lease_and_return( } } +#[spacetimedb::procedure] +pub fn update_external_generation_job_phase_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobPhaseUpdateInput, +) -> ExternalGenerationJobProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + update_external_generation_job_phase_tx(tx, input.clone()) + }) { + Ok(job) => single_external_generation_job_result(job), + Err(message) => failed_external_generation_job_result(message), + } +} + #[spacetimedb::procedure] pub fn fail_external_generation_job_and_return( ctx: &mut ProcedureContext, @@ -678,6 +711,7 @@ fn enqueue_external_generation_job_tx( price_mud_points: input.price_mud_points, refund_ledger_id: None, notification_acknowledged_at: None, + phase: None, }; persist_external_generation_job_row(ctx, row.clone()); insert_external_generation_job_event( @@ -752,6 +786,7 @@ fn claim_external_generation_jobs_tx( claim_time, ); row.status = EXTERNAL_GENERATION_STATUS_RUNNING.to_string(); + row.phase = Some(EXTERNAL_GENERATION_PHASE_GENERATING.to_string()); row.worker_id = Some(worker_id.clone()); row.lease_expires_at = Some(lease_expires_at); row.lease_token = Some(lease_token); @@ -1268,6 +1303,23 @@ fn renew_external_generation_job_lease_tx( Ok(map_external_generation_job_row(row)) } +fn update_external_generation_job_phase_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobPhaseUpdateInput, +) -> Result { + let phase = normalize_external_generation_job_phase(&input.phase)?; + let mut row = get_worker_owned_external_generation_job( + ctx, + &input.job_id, + &input.worker_id, + &input.lease_token, + )?; + row.phase = Some(phase); + row.updated_at = ctx.timestamp; + persist_external_generation_job_row(ctx, row.clone()); + Ok(map_external_generation_job_row(row)) +} + fn fail_external_generation_job_tx( ctx: &ReducerContext, input: ExternalGenerationJobFailInput, @@ -1672,6 +1724,18 @@ fn normalize_external_generation_job_status_filter(statuses: &[String]) -> Vec<& .collect() } +fn normalize_external_generation_job_phase(phase: &str) -> Result { + match phase.trim() { + EXTERNAL_GENERATION_PHASE_GENERATING => { + Ok(EXTERNAL_GENERATION_PHASE_GENERATING.to_string()) + } + EXTERNAL_GENERATION_PHASE_PROCESSING => { + Ok(EXTERNAL_GENERATION_PHASE_PROCESSING.to_string()) + } + _ => Err("external_generation_job.phase 只支持 generating 或 processing".to_string()), + } +} + fn record_external_generation_claimable_age( stats: &mut ExternalGenerationQueueStatsSnapshot, row: &ExternalGenerationJob, @@ -1760,6 +1824,7 @@ fn build_external_generation_job_summary_row( price_mud_points: row.price_mud_points, refund_ledger_id: row.refund_ledger_id.clone(), notification_acknowledged_at: row.notification_acknowledged_at, + phase: row.phase.clone(), } } @@ -1976,6 +2041,7 @@ fn map_external_generation_job_row(row: ExternalGenerationJob) -> ExternalGenera price_mud_points: row.price_mud_points, refund_ledger_id: row.refund_ledger_id, notification_acknowledged_at_micros, + phase: row.phase, } } @@ -2005,6 +2071,7 @@ fn map_external_generation_job_summary_row( notification_acknowledged_at_micros: row .notification_acknowledged_at .map(|value| value.to_micros_since_unix_epoch()), + phase: row.phase, } } @@ -2041,6 +2108,7 @@ fn map_external_generation_job_summary_to_compat_snapshot( price_mud_points: summary.price_mud_points, refund_ledger_id: summary.refund_ledger_id, notification_acknowledged_at_micros: summary.notification_acknowledged_at_micros, + phase: summary.phase, } } @@ -2368,6 +2436,15 @@ fn normalize_optional_text(value: &str) -> Option { mod tests { use super::*; + #[test] + fn external_generation_phase_only_accepts_known_execution_phases() { + assert_eq!( + normalize_external_generation_job_phase(" processing ").as_deref(), + Ok(EXTERNAL_GENERATION_PHASE_PROCESSING) + ); + assert!(normalize_external_generation_job_phase("uploading").is_err()); + } + #[test] fn external_generation_job_result_failure_is_structured() { let result = failed_external_generation_job_result("失败".to_string()); @@ -2849,6 +2926,7 @@ mod tests { price_mud_points: 10, refund_ledger_id: None, notification_acknowledged_at: None, + phase: None, } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 0dc48da85..e760788f7 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1350,6 +1350,15 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } + if table_name == "external_generation_job" || table_name == "external_generation_job_summary" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:执行阶段晚于外部生成主表和摘要投影加入,旧迁移包按未知阶段兼容; + // BFF 会把 running + phase=null 视为 generating。 + object + .entry("phase".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "big_fish_creation_session" { if let Some(object) = next_value.as_object_mut() { // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index ce29b9bda..3a0874626 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -219,6 +219,8 @@ describe('ImageCanvasTaskSidebarView', () => { createExternalTask({ jobId: 'active-current', startedAt, + phaseDetail: '正在处理。', + progress: 70, }), createExternalTask({ jobId: 'active-other-project', @@ -258,7 +260,7 @@ describe('ImageCanvasTaskSidebarView', () => { expect(await screen.findByText('图片画布生成图片')).toBeTruthy(); expect(screen.getByText(/发光猫咪主视觉/u)).toBeTruthy(); - expect(screen.getByText('正在生成第 2/4 段。')).toBeTruthy(); + expect(screen.getByText('正在处理。')).toBeTruthy(); expect(screen.queryByText(/35%/u)).toBeNull(); expect(screen.getByText(/已用时 1分/u)).toBeTruthy(); expect(screen.queryByText(/总进度/u)).toBeNull();