Merge remote-tracking branch 'origin/master' into feat/fail-as-event

This commit is contained in:
2026-09-23 11:50:42 +08:00
566 changed files with 16462 additions and 42729 deletions
@@ -1,13 +1,10 @@
# 外部生成 Worker 化方案
> 文档状态:`historical`
本文仅用于历史追溯,不作为当前实现依据。
> 2026-07-18 退役覆盖:旧创作模板 job 类型、玩法写回和玩法恢复链路均已退出现役 worker。当前 worker 只领取 `source_module = editor-canvas` 的任务;本文涉及拼图、跳一跳、拼消消、敲木鱼等玩法的内容仅作为历史设计记录,历史队列行不得被领取或改写。
本文记录 external generation worker 的稳定设计边界;当前实现细节以代码和现行架构文档为准。
> 2026-07-21 已实施、待生产压测专题:BgFilter 作为受限内部资源,仍遵守“单用户动作一个外部生成 job”;用户可见层与调度层都只有父 `external_generation_job`。父 future 保持原 lease 和 attempt,在当前调用栈内同步请求唯一 `bgfilter-worker` 的内部 HTTP,成功图片字节直接返回父流程。首版不新增 SpacetimeDB 子任务表、父 checkpoint / continuation 或 raw 中间结果 OSS。完整边界见 [`BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)`](./【后端架构】BgFilter受限资源调度方案-2026-07-21.md)。
更新时间:`2026-07-31`
更新时间:`2026-09-23`
## 背景
@@ -20,8 +17,8 @@ VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
- 本地或小流量站内同步排查可显式启用 `inline` 模式,由站内 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。External v1 不继承此例外,始终异步入队。
- 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。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。
- 已接入图片画布编辑器的图片、改图、手动去背景、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。外部生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。
- 外部生成队列粒度固定为“单个用户动作对应单个 job”。例如一次图片生成、改图、视频生成或角色动作生成各自入一个 job;job 内部可以串行或并行调用 provider、OSS 和 SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 处理 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。
- 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。
## Module 与 Interface
@@ -45,19 +42,19 @@ External API job 复用同一个 `result_payload_json` 列,但只额外保存
不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿和编辑器业务结果回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
## BFF 状态接口
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table:
- `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。
- `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于轻量状态读取。返回 pending、running、未确认终态数量和更新时间。
- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、可选 `warning`、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。
- 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或手动去背景时切换为 `processing`,BFF 显示“正在处理”。旧任务 `phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。
- 任务被 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 状态,用于生成页轮询某次动作。返回 `operationId`(即任务 ID)、`status`、`phaseLabel`、`phaseDetail`、`progress`、`error`、`updatedAtMicros`,以及可选、可直接展示的 `warning` 完整文案。生成页轮询只依赖状态、阶段、进度、错误和警告;`jobKind`、source 和完整时间信息继续由任务列表接口或业务快照提供。`attempt` / `maxAttempts` 属于 worker 调度事实,不向该前端契约暴露;若未来需要面向用户展示,必须单独完成产品、契约和摘要投影设计。
BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、lease、执行和计费事实仍以 `external_generation_job` 为准,用户可见任务列表、单任务状态、执行阶段和通知确认的正式读取事实源为 `external_generation_job_summary`,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。
BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、lease、执行和计费事实仍以 `external_generation_job` 为准,用户可见任务列表、单任务状态、执行阶段和通知确认的正式读取事实源为 `external_generation_job_summary`,编辑器业务结果以 `editor_project`、`editor_canvas`、`editor_project_resource` 与 `editor_asset` 为准。用户可见任务列表放在 `我的` 页签,单 job 状态用于补充轮询和排障信息,最终结果仍以编辑器项目快照收敛。队列接口不替代编辑器结果接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。
## 任务表
@@ -66,11 +63,11 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、
| 字段 | 说明 |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `job_id` | 主键,`extgen-` 前缀 UUID |
| `dedupe_key` | 唯一键,建议为 `play/action/session/scope` |
| `job_kind` | 执行类型,当前覆盖 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background`、跳一跳 / 拼消消 / 敲木鱼生成动作,以及 `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` |
| `dedupe_key` | 唯一键,按 owner、job kind 与稳定业务键派生 |
| `job_kind` | 执行类型,当前覆盖 `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` |
| `owner_user_id` | 触发用户 |
| `source_module` | 玩法或能力名,例如 `puzzle` |
| `source_entity_id` | session/profile/work 等作用域 |
| `source_module` | 来源模块,当前为 `editor-canvas` |
| `source_entity_id` | 编辑器项目等稳定业务作用域 |
| `request_label` | 排障标签 |
| `request_payload_json` | worker 执行入参 JSON |
| `status` | `pending/running/completed/failed/cancelled` |
@@ -91,7 +88,7 @@ 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 巧合/历史部分记录”。
另新增私有 `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 巧合或不完整记录”。
索引:
@@ -111,7 +108,7 @@ pending/running -> cancelled (预留)
`claim` 只领取 `pending` 且 `available_at <= now` 的任务,或 `running` 且 `lease_expires_at <= now` 的任务。领取时递增 `attempt`、写入 `worker_id`、`started_at`、新的 `lease_expires_at` 和 `lease_token`。SpacetimeDB procedure 使用 `ctx.timestamp` 作为状态流转时间,只从 worker 入参读取“时长差值”,不信任 worker 本机绝对时间。worker 每次执行只处理自己 claim 到的任务;续租、完成或失败时必须带同一个 `worker_id + lease_token`,且当前 lease 尚未过期,防止过期 worker 覆盖新 lease。
玩法业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。拼图的 `compile_puzzle_agent_draft` worker 调用、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 在 `queue` 模式下会带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写 session / work profile。`inline` 模式不创建 `external_generation_job`,因此这三个 guard 字段必须同时为空;transaction 只把三项全空识别为 api-server 受控同步写回,三项半空仍按非法请求拒绝。worker 路径的核心业务写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领,避免队列状态和真实 session 脱节。api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,避免旧 worker 冲掉后续合法 worker 的同一账本扣费。
编辑器业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。`queue` 模式下,`persist_editor_generation_result_and_return` 会携带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写正式结果。`inline` 模式不创建 `external_generation_job`,这三个 guard 字段必须同时为空;transaction 只把三项全空识别为 api-server 受控同步写回,三项半空仍按非法请求拒绝。worker 路径的结果写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领。api-server 的资产扣费包装遇到 stale worker lease guard 错误时不执行补偿退款,避免过期 worker 冲掉后续合法 worker 的同一账本扣费。
## 执行模式与进程角色
@@ -154,51 +151,7 @@ controller 配置:
动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`,controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。VectorEngine 图片链路会先于整个 job 执行预算停止 provider 发送 / 重试,以便 worker 在有效 lease 内完成终态写回;若其他业务 future 仍长时间无返回,执行预算到期后 worker 会停止续租并释放槽位,在途 future 继续运行至租约仲裁窗口;有效租约内的写回仍可完成,租约过期后才会由其它 worker 重新认领,避免客户端取消与服务端写回竞态。本次预算收口不改变 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽收口和原子退款语义。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。
## 已接入的拼图纵切
### 拼图
`compile_puzzle_draft`:
1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。
2. `queue` 模式下 HTTP handler 入队 `puzzle_compile_draft`,返回 `operation.status = queued` 和当前 session。拼图 dedupe key 包含本次 `extgen-` job id,只保证同一任务行唯一,不把同一 session 后续重新生成吞掉。`inline` 模式下 HTTP handler 复用同一 executor 同步执行,成功后直接返回 `completed` 和最新 session。
3. 前端保持 `puzzle-generating`,继续轮询 `getPuzzleAgentSession`;首期不把 `queued/running` 写回 `puzzle_agent_session`,因此刷新或跨设备恢复生成中状态仍是后续 read model 工作。
4. worker claim 后执行原有 `compile_puzzle_draft_with_initial_cover` 或 `compile_puzzle_draft_with_uploaded_cover`;前置 `compile_puzzle_agent_draft` 也必须携带本次 `job_id / worker_id / lease_token`,防止过期 worker 先把草稿卡和 session 写到 ready。
5. 成功后沿原有 SpacetimeDB 拼图会话/作品写回,前端轮询看到 `progressPercent >= 94/96/100` 和 ready 草稿。
6. 失败后调用 `mark_puzzle_draft_generation_failed`,拼图首期业务失败直接进入 failed;只有失败态写回成功才把队列 job 标为 failed,失败态写回失败则保留租约等待重领。队列仍保留 lease 过期后的崩溃重领,避免 worker 退款后再次成功导致钱包账本漂移。前端通过现有失败草稿/弹窗机制展示来源错误。
`generate_puzzle_images`:
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_images` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
2. worker 执行原结果页关卡图链路:自动命名、VectorEngine / 上传图直用、关卡场景图、UI spritesheet、关卡背景资产包、OSS 持久化和 SpacetimeDB 回写。
3. 成功后 `save_puzzle_generated_images` 写回目标关卡和草稿卡;失败后 `mark_puzzle_level_generation_failed` 只标记目标关卡 `failed`,不污染已 ready 的其它关卡。队列 job 只有在目标关卡失败态写回成功后才进入 failed。
4. 前端结果页对 `queued/running` 操作继续轮询 `getPuzzleAgentSession`,目标关卡变为 ready 或 failed 后收敛。
`generate_puzzle_ui_background`:
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_ui_background` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
2. worker 执行原结果页 UI 背景链路:归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。
3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job,让前端轮询能收敛。
### 跳一跳、拼消消和敲木鱼扩展范围
以下动作按同一 worker 模式迁移。命名以现有玩法 action 为准,队列 `job_kind` 采用后端稳定 snake_case,不新增平行队列:
- 跳一跳 `jump-hop`
- `compile-draft`:草稿编译阶段需要生成地块 / 视觉资产时入队,例如 `jump_hop_compile_draft`。
- `regenerate-tiles`:结果页地块图集重生入队,例如 `jump_hop_regenerate_tiles`。
- 拼消消 `puzzle-clear`
- `compile-draft`:草稿编译阶段需要生成场地底图和卡片 atlas 时入队,例如 `puzzle_clear_compile_draft`。
- `regenerate-atlas`:结果页素材 atlas 重生入队,例如 `puzzle_clear_regenerate_atlas`。
- 敲木鱼 `wooden-fish`
- `compile-draft`:草稿编译阶段需要生成背景、敲击物或其它图片资产时入队,例如 `wooden_fish_compile_draft`。
- `regenerate-hit-object`:结果页敲击物图片重生入队,例如 `wooden_fish_regenerate_hit_object`。
这些动作首版都保持“单动作单 job”:一次 `compile-draft` 或一次 `regenerate-*` 请求只创建一个 job,worker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。
每个玩法迁移时必须同时接入业务写回 lease guard:worker 路径带 `external_generation_job_id / worker_id / lease_token`,inline 路径三项同时为空。过期 worker 不得写 session / work profile;业务失败态写回成功后才允许 job 进入 `failed`。
### 图片画布编辑器
## 图片画布编辑器
图片画布 `/editor/canvas` 下所有会调用外部生成 provider 的入口在 `queue` 模式下入 `external_generation_job`,HTTP handler 只返回 `queueState`:
@@ -252,8 +205,6 @@ cargo test -p spacetime-module level_generation_failure --manifest-path server-r
cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml
cargo test -p api-server external_editor_generation --manifest-path server-rs/Cargo.toml
cargo test -p api-server external_mcp --manifest-path server-rs/Cargo.toml
npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps generation progress visible"
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "compile_puzzle_draft"
```
本地 smoke:
@@ -265,7 +216,7 @@ curl -f http://127.0.0.1:<api-port>/healthz
本地 `npm run dev` 与 `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。需要验证生产式拆分角色、lease 重领或扩缩容时,再分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`,也可以使用隔离容器 smoke。
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。External smoke 还必须证明生成 POST 返回 `202`、同幂等键不重复创建任务、统一查询能读到 compact completed result、跨 owner 返回 `404`,并通过托管 MCP 调用同一提交/查询工具链。
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到编辑器项目快照中的 completed 或 failed。External smoke 还必须证明生成 POST 返回 `202`、同幂等键不重复创建任务、统一查询能读到 compact completed result、跨 owner 返回 `404`,并通过托管 MCP 调用同一提交/查询工具链。
systemd 生产 controller 与手动兜底示例:
@@ -77,6 +77,7 @@ EditorGenerationResultPersistInput {
## api-server 接入
- 旧分段持久化、worker 独立完成及其重复结果序列化实现,在失去现役调用方后直接删除;不保留只供旧测试调用的生产副本。废弃实现的专属测试随实现删除,不因清理而将旧用例改接到现役函数;现有现役行为测试保持原有覆盖。手动拆分、上传等现役路径使用的底层 helper 继续保留,HTTP / DTO、inline 模式和历史任务解析兼容不受清理影响。
- 通用持久化改为 `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。
@@ -0,0 +1,431 @@
> 文档状态:`historical`(原始需求存档,仅用于来源追溯,不作为当前实施依据)
归档日期:2026-09-21。下方保留原始正文;其中目标边界、异常会话、上传阶段等口径已由后续决策调整。当前合同见[客户端本地埋点与主站入库契约](./【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md)。
# Game Agent 埋点设计方案|早期地基版 v1.0
状态:正式方案;待技术负责人拆解实施
日期:2026-09-05
适用产品:当前 Game Agent 桌面端编辑器 / 项目工作台
## 1. 方案目的
第一阶段不建设完整数据平台,也不一次性覆盖所有细粒度编辑动作。本方案只解决三个问题:
1. 能不能知道用户进入了编辑器、创建或打开了哪个项目。
2. 能不能把一次创作任务和 Agent 执行、项目变化、预览结果串起来。
3. 能不能判断用户是否回到同一个项目继续创作。
本版本新增**编辑器前台时长**。它表示编辑器窗口处于前台/获得焦点的累计时间,不等于用户持续操作,也不等于真实编辑时长。本版本仍不统计编辑器活跃编辑时长和单个项目完整创作时长。
核心分析对象从旧版的“浏览/消费行为”切换为当前产品的“持续创作行为”。
## 2. 当前用户路径
```text
进入编辑器
→ 创建项目 / 打开已有项目
→ 提交一次创作任务
→ Agent 执行
→ 用户澄清、确认或追加指令
→ 文件、代码或资源发生有效变化
→ 项目产生 revision
→ 预览就绪
→ 用户试玩或继续修改
→ 保存并退出
→ 之后重新打开同一项目继续创作
```
第一阶段不要求把 Asset Canvas、Resource Editor、UI Editor 的每一个点击都拆成独立事件;先通过项目、任务、运行、revision 和前台时长关系判断用户是否真的在使用创作工作台。
## 3. 第一阶段事件清单
| 事件 | 所在环节 | 触发条件 | 可回答的问题 |
|---|---|---|---|
| `editor_session_start` | 进入编辑器 | 编辑器启动并完成可用初始化 | 有多少编辑器会话、用户从哪里开始 |
| `editor_session_end` | 离开编辑器 | 正常退出或明确关闭编辑器 | 正常结束的会话数;粗略会话时长 |
| `session_timeout` | 异常离开 | 超过约定时间没有心跳或前台状态 | 哪些会话可能异常中断;不可替代真实退出 |
| `editor_focus_start` | 进入前台 | 编辑器窗口获得焦点并处于可交互前台 | 用户把多少时间留在编辑器前台 |
| `editor_focus_end` | 离开前台 | 编辑器失去焦点、最小化或退出 | 前台时长区间和累计前台时长 |
| `project_create_success` | 创建项目 | 项目创建成功且拿到稳定 `project_id` | 创建项目人数、创建成功率 |
| `project_open` | 打开项目 | 项目被成功加载并进入工作区 | 回访项目数、项目复访率 |
| `creative_task_submit` | 发起创作 | 用户提交一次可执行的创作请求 | 用户发起了多少次真实创作任务 |
| `agent_run_completed` | Agent 执行结束 | 一次 Agent run 正常完成 | Agent 任务完成率、耗时、重试情况 |
| `agent_run_failed` | Agent 执行结束 | 一次 Agent run 明确失败 | 失败率、错误类型、失败后的修复行为 |
| `project_revision_created` | 产生有效变化 | 项目产生可识别的新 revision | Agent 或人工操作是否真正改变了项目 |
| `preview_ready` | 预览 | 当前项目预览达到可打开/可运行状态 | 有多少项目走到可预览;从任务到预览的转化 |
| `project_save` | 保存 | 用户或系统完成一次项目保存 | 用户是否保存成果;保存与继续创作关系 |
说明:`project_revision_created` 是项目变化事件,不等于用户满意;`preview_ready` 是技术/产品中间成功,不等于用户完成试玩或认可结果。
## 4. 公共事件字段
每条正式产品事件使用统一 envelope。`properties` 只放该事件特有字段,不重复创造新的顶层 ID。
| 字段 | 类型 | 是否必填 | 字段说明 |
|---|---|---:|---|
| `event_id` | string | 是 | 单条事件唯一 ID,用于去重;建议 UUID |
| `event_name` | string | 是 | 事件英文名,如 `creative_task_submit` |
| `event_time` | datetime | 是 | 事件发生时间,统一 ISO 8601;不要只记录上传时间 |
| `user_id` | string/null | 条件必填 | 稳定用户标识;没有登录用户时明确为空,不用设备 ID 冒充 |
| `editor_session_id` | string | 是 | 一次编辑器打开到结束/超时的会话 ID |
| `project_id` | string/null | 条件必填 | 当前项目的稳定 ID;编辑器入口事件可以为空 |
| `creative_task_id` | string/null | 条件必填 | 一次用户创作任务的 ID;任务相关事件必须携带 |
| `agent_run_id` | string/null | 条件必填 | 一次 Agent 执行的 ID;仅 Agent run 相关事件填写 |
| `agent_turn_id` | string/null | 否 | Direct 的底层 turn 技术记录 ID;不能替代 `agent_run_id` |
| `status` | string/null | 条件必填 | `success`、`failed`、`timeout`、`cancelled` 等有限枚举 |
| `error_code` | string/null | 失败时必填 | 稳定错误码;不要把整段异常堆栈当作分析字段 |
| `source` | string | 是 | 事件来源,如 `editor`、`supervisor`、`direct`、`asset_canvas`、`ui_editor`、`manual`、`system` |
| `client_version` | string | 是 | 客户端/编辑器版本,用于按版本比较问题 |
| `properties` | object | 是 | 事件专属属性;允许为空对象 |
### 4.1 ID 语义规则
- `editor_session_id`:编辑器会话,不能使用 Runtime 的 `sessionId`。
- `creative_task_id`:用户的一次创作意图,可能包含多次 Agent run 和多轮追加指令。
- `agent_run_id`:一次可独立判断成功/失败的 Agent 执行。Direct 需要单独生成,不能把 `clientTurnId` 直接当作 run ID。
- `agent_turn_id`:底层技术 turn 记录,用于排错和技术审计,不直接作为产品任务口径。
- `project_id`:项目身份,优先使用 manifest 中稳定的项目 ID,不使用路径作为长期主键。
## 5. 各事件最小字段
### 5.1 编辑器会话
`editor_session_start`:
```text
entry_source
first_project_id
client_version
```
`editor_session_end` / `session_timeout`:
```text
end_reason
session_duration_ms(若可可靠计算)
last_project_id
```
`editor_focus_start`:
```text
focus_reason
active_project_id
```
`editor_focus_end`:
```text
blur_reason
focus_duration_ms(若可可靠计算)
active_project_id
```
前台时长计算规则:同一 `editor_session_id` 下,将成对的 `editor_focus_start` 与 `editor_focus_end` 区间相加。正常退出时补齐最后一个区间;崩溃、断电或强制结束造成的未闭合区间必须标记为不完整,不估算为完整前台时长。
### 5.2 项目
`project_create_success`:
```text
project_template_id(如有)
creation_source
```
`project_open`:
```text
open_source
is_first_open
```
`project_revision_created`:
```text
revision_id
revision_source
change_kind
files_changed_count(如可得)
```
`project_save`:
```text
save_source
revision_id(如有)
```
`revision_source` 建议至少使用:`agent`、`asset_canvas`、`resource_editor`、`ui_editor`、`manual_edit`、`system_projection`。
### 5.3 创作任务与 Agent
`creative_task_submit`:
第一阶段只要求带上:
```text
creative_task_id
project_id
source
```
不记录完整自然语言 prompt,也不要求第一阶段记录任务类型、输入方式、指令长度或附件信息。上述属性属于后续需要分析任务结构时再增加的可选字段。
`agent_run_completed` / `agent_run_failed`:
```text
agent_type
run_source
duration_ms
retry_index
output_change_detected
revision_id(如已产生)
```
这里的 `agent_run_id` 只是一次 Agent 执行的技术关联 ID,不代表要记录每次执行的提示词内容。完成事件只能说明执行状态。`output_change_detected` 和后续 `project_revision_created` 用于区分“跑完了但没改变项目”。
### 5.4 预览
`preview_ready`:
```text
preview_source
preview_version
ready_duration_ms(从触发构建到就绪,如可得)
```
第一阶段的 `preview_ready` 必须有明确技术触发条件,例如预览服务确认可访问或本地运行状态确认 ready;不能用“返回了 URL”直接代替。
## 6. 可以看的数据
### 6.1 创作漏斗
```text
编辑器进入
→ 创建/打开项目
→ 提交创作任务
→ Agent 完成
→ 项目产生 revision
→ 预览就绪
→ 保存
→ 后续重新打开项目
```
可计算:
- 编辑器到项目创建/打开转化率。
- 项目到首次创作任务转化率。
- 创作任务提交率:有项目用户中发生 `creative_task_submit` 的用户数 / 有项目用户数。
- Agent 完成率:`agent_run_completed` /(`agent_run_completed` + `agent_run_failed`)。
- 有效变化率:产生 `project_revision_created` 的任务数 / 创作任务数。
- 预览到达率:产生 `preview_ready` 的任务数 / 创作任务数。
- 保存率:产生 `project_save` 的项目用户数 / 产生 revision 的项目用户数。
### 6.2 创作行为
第一阶段可以看:
- 用户每次会话提交多少创作任务。
- 一个项目累计发生多少次任务、run 和 revision。
- Agent 完成后是否真的产生项目变化。
- 失败后是否重试、追加指令或重新打开项目。
- 用户是一次性尝试,还是回到同一个项目继续创作。
- 不同来源、版本、任务类型的成功率差异。
第一阶段暂时不能可靠看:
- 编辑器活跃时长。
- 完整项目创作总时长。
- 用户是否满意或接受 Agent 结果。
- 可靠的试玩成功率。
- 仅凭这些事件直接得到 D1/D3/D7 留存,除非先确认 `user_id` 稳定且会话事件可靠落库。
## 7. 留存、LTV 与 ARPU 的当前口径
### 7.1 留存
当前先定义“创作者回访留存”,不定义泛产品活跃留存:
```text
某 cohort 用户在 D0 发生 project_create_success 或 creative_task_submit
在 D1/D3/D7 再次发生 project_open、creative_task_submit 或 project_revision_created
```
公式:
```text
Dk 创作者留存率 = D0 cohort 中在第 k 天至少发生一次创作相关事件的用户数 / D0 cohort 用户数
```
前提是 `user_id` 稳定、事件可靠落库、日期按统一时区计算。当前代码审计结论是这些条件尚未全部确认,因此先把公式写入方案,不把结果宣称为已可用。
### 7.2 LTV 与单用户 ARPU
埋点本身不能产生 LTV 或 ARPU。需要另外存在可靠的订单/扣费/退款事实表,并用 `user_id` 关联。
```text
ARPU = 统计周期内总收入 / 统计周期内活跃用户数
```
如果看创作者商业价值,可另算:
```text
创作者 ARPU = 统计周期内创作者收入 / 统计周期内发生创作行为的去重用户数
```
```text
LTV = 用户在定义生命周期内的累计净收入 / cohort 用户数
```
其中净收入应扣除退款、赠送额度和必要的渠道/支付成本,具体财务口径需要业务和财务确认。当前早期地基埋点只负责提供用户行为侧的 cohort 和创作分群,不负责替代收入系统。
## 8. 第一阶段建议看板
只建议做四组:
1. **基础使用**:编辑器会话数、创建项目用户数、打开项目用户数、项目复访数。
2. **创作漏斗**:任务提交、Agent 成功/失败、revision、preview ready、保存。
3. **失败与恢复**:失败错误码、失败后重试率、失败后产生 revision 的比例。
4. **回访创作**:D1/D3/D7 创作者回访,按任务类型、客户端版本、入口来源分组。
不要在第一阶段做几十个按钮点击看板,也不要把技术 JSONL、Runtime 状态和正式产品事件混成一张业务报表。
## 8.1 编辑时长的边界
本版本已经纳入前台时长事件,并区分三种时长:
- **会话时长**:`editor_session_start` 到 `editor_session_end`,包含用户离开电脑或切到其他窗口的时间,不等于编辑时长。
- **前台时长**:`editor_focus_start` 到 `editor_focus_end` 的累计时间。
- **活跃编辑时长**:前台期间发生有效编辑、任务提交、预览、保存等行为的累计时间。
本版本只承诺会话时长和前台时长两个粗粒度指标;即使记录了 `focus_duration_ms`,也不能把它解释成编辑器活跃时长。
前台时长的解释限制:
- 编辑器在前台但用户没有操作,仍会被计入。
- 多窗口、系统锁屏、远程桌面或窗口状态异常时,可能出现边界误差。
- 前台时长适合看停留和使用深度,不适合直接作为生产效率指标。
## 8.2 数据可靠性原则
已确认采用:
- 事件先写本地短暂 outbox。
- 网络恢复后自动重试上传。
- 服务端或接收端使用 `event_id` 去重。
- 关闭、断网、崩溃导致的可能丢数需要在技术验收中明确记录。
## 9. 当前已收口的产品口径
根据当前讨论,本版本采用以下口径:
1. “创作成功”采用分层口径:`agent_run_completed` 表示执行完成,`project_revision_created` 表示项目发生有效变化,`preview_ready` 表示达到可预览状态;第一阶段不加入用户满意/接受结果事件。
2. `creative_task_id` 表示用户一次创作意图;第一阶段不记录完整 Prompt,也不拆分 Prompt 内容。
3. 第一阶段不加入 `task_type`、`input_mode`、指令长度和附件属性,避免早期方案过重。
4. 事件允许先写本地短暂 outbox;网络恢复后自动重试;接收端按 `event_id` 去重。
5. 创作者 D1/D3/D7 暂按 `project_open`、`creative_task_submit`、`project_revision_created` 作为回访事件,但只有在 `user_id` 和数据落库可靠后才正式出数。
6. 编辑器前台时长纳入本版本;编辑器活跃编辑时长留到后续阶段。
## 9.1 仍需你确认的一项产品边界
只剩一个可能影响报表口径的问题:用户对同一个创作目标进行澄清或追加指令时,是否始终沿用同一个 `creative_task_id`。本方案默认沿用同一个任务 ID,只有用户明确开始新的创作目标时才生成新的任务 ID。
如果你没有特别异议,后续按这个默认口径执行即可;其余未收口项属于技术实现确认,不需要你继续定义。
## 10. 需要 master 开发对话回答的技术问题
开发对话只回答代码事实和实现成本:
- 是否已有服务端 analytics 接收接口和正式数据落点。
- 若没有,第一阶段事件落本地 outbox、现有服务端 tracking,还是其他已有入口。
- Tauri/Rust 是否能统一生成 `event_id`、`event_time`、`project_id`、`client_version`。
- 当前能否稳定取得 `user_id`。
- Direct 是否需要新增独立 `agent_run_id`。
- `editor_session_id`、`creative_task_id` 是否能在现有生命周期生成。
- `project_revision_created` 和 `preview_ready` 的可靠触发点在哪里。
- 是否需要本地缓存、重试和去重;哪些异常场景会丢数。
- 每个第一阶段事件对应的代码文件、触发函数、测试方式和估算成本。
前台时长还需要确认:
- Tauri 当前是否能可靠监听窗口 focus、blur、minimize、restore 和退出事件。
- 窗口失焦后是否立即落一条 `editor_focus_end`,还是由统一会话管理器补齐。
- 锁屏、系统休眠、崩溃和强制结束时,如何标记未闭合前台区间。
- 多窗口场景是否存在;如存在,`editor_session_id` 是按应用实例还是按窗口生成。
## 11. 第一阶段验收标准
技术实现完成后,至少能够用一条测试链路证明:
```text
editor_session_start
→ editor_focus_start
→ project_create_success
→ creative_task_submit
→ agent_run_completed 或 agent_run_failed
→ project_revision_created(如果确实发生变化)
→ preview_ready(如果确实达到 ready)
→ project_save
→ editor_focus_end
→ editor_session_end
```
并满足:
- 同一次链路中的 ID 能串联。
- 重复上传不会制造重复事件。
- Agent 失败不会被记成成功。
- Agent 完成但没有项目变化时,不能伪造 `project_revision_created`。
- 预览 URL 返回但不可访问时,不能伪造 `preview_ready`。
- 关闭、断网、崩溃等场景的丢数风险已明确记录。
- 能按 `user_id`、`project_id`、`creative_task_id`、`client_version` 做基本筛选。
- 正常切换到其他窗口时,能闭合前台区间并计算 `focus_duration_ms`。
- 最小化、恢复、正常退出至少有明确的 focus 结束/重新开始行为。
- 崩溃或强制结束不会伪造一条完整的前台时长;未闭合区间必须可识别。
## 12. 对抗性自检审查
### 12.1 是否把技术完成误当创作成功
没有。方案明确区分 Agent 执行完成、项目产生 revision、预览就绪。三者分别是执行层、项目变化层和可预览层,不代表用户满意。
### 12.2 是否把前台时长误当编辑时长
没有。事件名、字段名和看板解释统一使用“前台时长”;用户没有操作但窗口保持前台的时间会被计入,并在文档中标明限制。
### 12.3 是否记录得过细、造成第一阶段过重
当前 P0 不记录完整 Prompt、任务类型、输入方式、指令长度和附件属性。保留的是会话、项目、任务、Agent 状态、revision、预览、保存和前台区间,属于基础漏斗与创作回访所需的最小集合。
### 12.4 前台事件是否会制造大量噪音
会比核心业务事件多,但仍是成对的窗口状态事件,不是按键或鼠标级事件。前台事件只用于时长聚合,不作为单独的产品成功指标。
### 12.5 断网、退出和崩溃是否会导致数据不可信
不能完全消除,但通过本地 outbox、重试和 `event_id` 去重降低风险。未闭合的 focus 区间必须标记不完整,不把估算时间写成事实。
### 12.6 是否能直接计算 D1/D3/D7、LTV、ARPU
不能直接保证。留存依赖稳定 `user_id` 和可靠落库;LTV/ARPU 还依赖订单、扣费、退款和净收入事实表。当前方案只提供行为 cohort 和创作者分群基础。
### 12.7 是否仍有未收口问题
有,但已经集中到技术实现确认,不影响产品方案定稿:
- 当前服务端 analytics 接口和正式落点是否存在。
- Direct 是否能在现有生命周期生成独立 `agent_run_id`。
- `project_revision_created` 和 `preview_ready` 的实际可靠触发点。
- Tauri focus/blur 等窗口事件在当前多窗口、锁屏、休眠和崩溃场景下的行为。
- `user_id` 是否稳定,以及匿名用户后续是否需要身份合并。
这些不是继续扩展事件的理由,而是技术负责人需要逐项确认的实现事实。
## 13. 当前结论
这套早期地基版埋点足以回答:用户有没有进入编辑器、在前台停留了多久、有没有创建项目、有没有发起创作、Agent 是否完成、项目是否真的变化、是否走到预览、是否回到同一项目继续创作。
它暂时不能回答:用户是否喜欢结果、是否完成试玩、编辑器真正活跃了多久、完整 LTV/ARPU,以及在用户身份和数据落库尚未确认前的可靠 D1/D3/D7 留存。
因此本方案的产品层已经基本收口。下一步是把第 10 节交给最新 master 开发对话核实,再由技术负责人拆成最小实现任务;如果技术核实发现 focus/blur 触发不稳定,只需要调整前台时长实现方式或降级为会话时长,不需要推翻整个埋点方案。