From 4bb6d0bd1e57bdeb28c3911dd1bd3d07f2f8ad43 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 00:56:53 +0800 Subject: [PATCH] feat: add inline external generation mode --- .hermes/shared-memory/decision-log.md | 1 + deploy/container/README.md | 3 + deploy/container/api-server.env.example | 2 + deploy/env/api-server.env.example | 2 + ...端架构】外部生成Worker化方案-2026-06-03.md | 26 ++-- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- server-rs/crates/api-server/src/config.rs | 93 +++++++++++- .../api-server/src/creation_entry_config.rs | 8 +- .../src/external_generation_worker.rs | 16 +-- server-rs/crates/api-server/src/puzzle.rs | 28 +++- .../crates/api-server/src/puzzle/handlers.rs | 133 ++++++++++++++++++ .../crates/module-puzzle/src/commands.rs | 24 ++-- .../spacetime-client/src/mapper/puzzle.rs | 24 ++-- ...puzzle_draft_compile_failure_input_type.rs | 6 +- ...puzzle_generated_images_save_input_type.rs | 6 +- ...zle_level_generation_failure_input_type.rs | 6 +- .../puzzle_ui_background_save_input_type.rs | 6 +- .../crates/spacetime-client/src/puzzle.rs | 27 ++-- .../crates/spacetime-module/src/puzzle.rs | 90 +++++++----- 20 files changed, 393 insertions(+), 114 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5ab3e4ea..90b10fc2 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -52,6 +52,7 @@ - 背景:拼图首图、图集、音频等外部生成链路长期占用 `api-server` HTTP handler,导致扩容只能放大 API 进程,且 HTTP 超时和外部 provider 波动会直接影响创作入口。 - 决策:外部生成任务统一进入 SpacetimeDB `external_generation_job` 持久队列,由 `api-server` 的 `external-generation-worker` 进程角色 claim lease 后执行;HTTP 角色只做鉴权、表单/状态初始化、入队和返回 `queued/running/completed/failed` 操作状态。生产通过 systemd worker 模板增加实例数或提高 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩容,`GENARRATIVE_PROCESS_ROLE=all` 仅用于本地 smoke。拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与 `generate_puzzle_ui_background` 已接入 worker;业务写回必须在 SpacetimeDB transaction 内校验 `external_generation_job` 的 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,其中首图 worker 的前置 `compile_puzzle_agent_draft` 也必须带 guard。worker 核心业务写回失败不能返回内存快照并把 job 标成 completed;失败态业务写回成功后才能把 job 标成 failed,失败态未写回则保留租约等待后续重领。拼图业务失败不自动重试,只保留 lease 过期后的崩溃重领,避免钱包扣退费幂等漂移。生产发布会启用默认 `genarrative-external-generation-worker@1.service` 并等待 worker active,worker 停机时停止 claim 新任务并 drain 当前任务。 +- 2026-06-07 追加:`GENARRATIVE_EXTERNAL_GENERATION_MODE` 新增 `queue|inline` 显式策略,默认和生产仍为 `queue`;`inline` 只用于本地或小流量同步排查,由 HTTP handler 复用同一 worker executor 直接返回 `completed`,不创建 `external_generation_job`,不支持 worker 动态扩缩容。拼图写回 guard 字段改为可选,queue 路径仍必须完整校验 `job_id + worker_id + lease_token`;inline 路径只允许三项同时为空,半空 guard 仍拒绝。 - 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`deploy/systemd/genarrative-external-generation-worker@.service`、`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、拼图 `compile_puzzle_draft`、拼图 `generate_puzzle_images`、拼图 `generate_puzzle_ui_background`、生产 env 模板和运维文档。 - 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,并用 `GENARRATIVE_PROCESS_ROLE=all npm run dev` smoke 至少一次 queued -> worker 完成链路。 - 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/deploy/container/README.md b/deploy/container/README.md index 9a210649..7987a3c3 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -16,6 +16,7 @@ Docker Compose 当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。 容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。 +容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,用于验证 `api-server -> external_generation_job -> external-generation-worker` 链路;如只想本地同步排查 provider/OSS/SpacetimeDB 写回,可在本机 env 临时改为 `inline`,但该模式不会覆盖 worker 动态扩缩容验证。 Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。 生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。 @@ -100,6 +101,8 @@ npm run container:up -- --scale external-generation-worker=3 external-generation npm run container:up -- --scale external-generation-worker=1 external-generation-worker ``` +动态扩缩容验证必须保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`;`inline` 模式下生成请求由 `api-server` 同步执行,不会被这些 worker 实例消费。 + 停止: ```bash diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index 3b7e45f3..9c95130a 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -10,6 +10,8 @@ GENARRATIVE_API_LISTEN_BACKLOG=1024 GENARRATIVE_API_WORKER_THREADS=4 # 容器 smoke 可临时设 all;压测或预发按 api / external-generation-worker 拆进程。 GENARRATIVE_PROCESS_ROLE=api +# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。 +GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID= GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2 GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000 diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index d32e9fb4..b967c534 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -9,6 +9,8 @@ GENARRATIVE_API_LISTEN_BACKLOG=1024 GENARRATIVE_API_WORKER_THREADS=4 # api 只监听 HTTP;外部生成 worker 用独立进程设置为 external-generation-worker 后横向扩缩。 GENARRATIVE_PROCESS_ROLE=api +# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。 +GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID= GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2 GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 5c567ba6..290c90a5 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -1,6 +1,6 @@ # 外部生成 Worker 化方案 -更新时间:`2026-06-03` +更新时间:`2026-06-07` ## 背景 @@ -8,11 +8,12 @@ ## 目标 -- `api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。 +- 默认 `queue` 模式下,`api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。 - 外部生成副作用由独立 `external-generation-worker` 角色执行。 - 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。 +- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。 - SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 -- 已接入拼图 `compile_puzzle_draft` 与结果页 `generate_puzzle_images`;后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。 +- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。 ## Module 与 Interface @@ -68,9 +69,14 @@ 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` 会带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写 session / work profile。worker 路径的核心业务写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领,避免队列状态和真实 session 脱节。api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,避免旧 worker 冲掉后续合法 worker 的同一账本扣费。 +玩法业务写回也必须在 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 的同一账本扣费。 -## 进程角色 +## 执行模式与进程角色 + +外部生成执行模式由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制: + +- `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。 +- `inline`:HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。 同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换: @@ -91,8 +97,8 @@ worker 配置: `compile_puzzle_draft`: -1. HTTP handler 保存拼图表单草稿;`queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。 -2. HTTP handler 入队 `puzzle_compile_draft`,返回 `operation.status = queued` 和当前 session。拼图 dedupe key 包含本次 `extgen-` job id,只保证同一任务行唯一,不把同一 session 后续重新生成吞掉。 +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 草稿。 @@ -100,14 +106,14 @@ worker 配置: `generate_puzzle_images`: -1. HTTP handler 校验本次 `levelsJson` 快照后入队 `puzzle_generate_images`,返回 `operation.status = queued/running/completed/failed`。 +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` 快照后入队 `puzzle_generate_ui_background`,返回 `operation.status = queued/running/completed/failed`。 +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,让前端轮询能收敛。 @@ -141,7 +147,7 @@ GENARRATIVE_PROCESS_ROLE=all npm run dev curl -f http://127.0.0.1:/healthz ``` -生产 smoke 需要至少启动一个 `api` 角色和一个 `external-generation-worker` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,并等待 worker active。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。 +本地同步排查可显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline npm run dev:api-server`,用于确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行;该模式不覆盖 worker 队列 smoke。生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色和一个 `external-generation-worker` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,并等待 worker active。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。 systemd 生产扩缩容示例: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 664cd808..4c7f9392 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -207,7 +207,7 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成 worker 的持久任务队列;`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `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。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 +- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `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;`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 ### `ai_text_chunk` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 6773154e..8d0c23a2 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -51,7 +51,7 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 -本地排查外部内容生成 worker 时,可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列。该模式只用于 smoke;生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费。拼图首图 `compile_puzzle_draft`、结果页关卡图片 `generate_puzzle_images` 和结果页 UI 背景 `generate_puzzle_ui_background` 都走该队列;worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。 +本地排查外部内容生成 worker 时,可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列。该模式只用于 smoke;生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费。外部生成执行策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制,默认 `queue` 会让拼图首图 `compile_puzzle_draft`、结果页关卡图片 `generate_puzzle_images` 和结果页 UI 背景 `generate_puzzle_ui_background` 进入持久队列;worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。本地或小流量排查同步等待链路时可显式设为 `inline`,由 `api-server` handler 直接复用 worker executor 并在完成后返回 `completed`,不创建 `external_generation_job`,也不提供动态扩缩容能力。 微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 @@ -257,7 +257,7 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。 -`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`all` 仅用于本地或临时 smoke。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;扩容时增加 worker 进程或提高单进程并发,缩容时停止多余 worker。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会默认 enable 首个 `genarrative-external-generation-worker@1.service`,并在已存在 `/opt/genarrative/current/api-server` 时随 API 一起重启;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active。手动持久化首个实例可用 `systemctl enable --now genarrative-external-generation-worker@1.service`,横向扩容用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。当前拼图首关生成只做 lease 崩溃重领,不做业务失败自动重试,避免 worker 退款和重试成功之间产生钱包账本漂移。 +`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`all` 仅用于本地或临时 smoke。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制,生产和容器压测默认保持 `queue`;`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;扩容时增加 worker 进程或提高单进程并发,缩容时停止多余 worker。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会默认 enable 首个 `genarrative-external-generation-worker@1.service`,并在已存在 `/opt/genarrative/current/api-server` 时随 API 一起重启;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active。手动持久化首个实例可用 `systemctl enable --now genarrative-external-generation-worker@1.service`,横向扩容用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。当前拼图首关生成只做 lease 崩溃重领,不做业务失败自动重试,避免 worker 退款和重试成功之间产生钱包账本漂移。 `Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。 diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 2fec2c73..9e56cbfd 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -22,6 +22,7 @@ pub struct AppConfig { pub listen_backlog: i32, pub worker_threads: Option, pub process_role: ProcessRole, + pub external_generation_mode: ExternalGenerationMode, pub external_generation_worker_id: String, pub external_generation_worker_concurrency: usize, pub external_generation_worker_poll_interval: Duration, @@ -171,6 +172,25 @@ pub enum ProcessRole { All, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExternalGenerationMode { + Inline, + Queue, +} + +impl ExternalGenerationMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Inline => "inline", + Self::Queue => "queue", + } + } + + pub fn is_inline(self) -> bool { + matches!(self, Self::Inline) + } +} + impl ProcessRole { pub fn as_str(self) -> &'static str { match self { @@ -197,6 +217,7 @@ impl Default for AppConfig { listen_backlog: 1024, worker_threads: None, process_role: ProcessRole::Api, + external_generation_mode: ExternalGenerationMode::Queue, external_generation_worker_id: default_external_generation_worker_id(), external_generation_worker_concurrency: 2, external_generation_worker_poll_interval: Duration::from_millis(2_000), @@ -385,6 +406,11 @@ impl AppConfig { if let Some(process_role) = read_first_process_role_env(&["GENARRATIVE_PROCESS_ROLE"]) { config.process_role = process_role; } + if let Some(external_generation_mode) = + read_first_external_generation_mode_env(&["GENARRATIVE_EXTERNAL_GENERATION_MODE"]) + { + config.external_generation_mode = external_generation_mode; + } if let Some(worker_id) = read_first_non_empty_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID"]) { @@ -1046,6 +1072,14 @@ fn read_first_process_role_env(keys: &[&str]) -> Option { }) } +fn read_first_external_generation_mode_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + env::var(key) + .ok() + .and_then(|value| parse_external_generation_mode(&value)) + }) +} + fn read_first_positive_u32_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) @@ -1111,6 +1145,16 @@ fn parse_process_role(value: &str) -> Option { } } +fn parse_external_generation_mode(value: &str) -> Option { + match trim_quoted_env_value(value).to_ascii_lowercase().as_str() { + "inline" | "sync" | "synchronous" => Some(ExternalGenerationMode::Inline), + "queue" | "queued" | "worker" | "async" | "asynchronous" => { + Some(ExternalGenerationMode::Queue) + } + _ => None, + } +} + fn trim_quoted_env_value(raw: &str) -> &str { let raw = raw.trim(); raw.strip_prefix('"') @@ -1243,8 +1287,8 @@ fn parse_positive_u16(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, ProcessRole, - parse_bool, parse_process_role, + AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, ExternalGenerationMode, + LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role, }; use std::sync::{Mutex, OnceLock}; @@ -1312,6 +1356,51 @@ mod tests { assert!(ProcessRole::All.runs_external_generation_worker()); } + #[test] + fn external_generation_mode_parses_inline_and_queue_aliases() { + assert_eq!( + parse_external_generation_mode("inline"), + Some(ExternalGenerationMode::Inline) + ); + assert_eq!( + parse_external_generation_mode("'sync'"), + Some(ExternalGenerationMode::Inline) + ); + assert_eq!( + parse_external_generation_mode("\"queue\""), + Some(ExternalGenerationMode::Queue) + ); + assert_eq!( + parse_external_generation_mode("worker"), + Some(ExternalGenerationMode::Queue) + ); + assert_eq!(parse_external_generation_mode("unknown"), None); + + assert!(ExternalGenerationMode::Inline.is_inline()); + assert!(!ExternalGenerationMode::Queue.is_inline()); + } + + #[test] + fn from_env_reads_external_generation_mode() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock"); + unsafe { + std::env::set_var("GENARRATIVE_EXTERNAL_GENERATION_MODE", "inline"); + } + + let config = AppConfig::from_env(); + + assert_eq!( + config.external_generation_mode, + ExternalGenerationMode::Inline + ); + unsafe { + std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_MODE"); + } + } + #[test] fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 70b4d70d..9e404837 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -77,17 +77,13 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { { return Some("puzzle"); } - if normalized.starts_with("/api/runtime/puzzle/gallery/") - && normalized.ends_with("/remix") - { + if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") { return Some("puzzle"); } if normalized == "/api/runtime/big-fish/agent/sessions" { return Some("big-fish"); } - if normalized.starts_with("/api/runtime/big-fish/gallery/") - && normalized.ends_with("/remix") - { + if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") { return Some("big-fish"); } if normalized == "/api/runtime/custom-world/agent/sessions" 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 959274bc..bb553ef2 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -496,11 +496,11 @@ fn build_external_generation_write_lease_guard( worker_id: &str, job: &ExternalGenerationJobRecord, ) -> Result { - Ok(ExternalGenerationWriteLeaseGuard { - job_id: job.job_id.clone(), - worker_id: worker_id.to_string(), - lease_token: require_job_lease_token(job)?, - }) + Ok(ExternalGenerationWriteLeaseGuard::from_claimed_job( + job.job_id.clone(), + worker_id.to_string(), + require_job_lease_token(job)?, + )) } fn duration_micros_i64(duration: Duration) -> i64 { @@ -527,9 +527,9 @@ mod tests { let guard = build_external_generation_write_lease_guard("worker-a", &job) .expect("guard should build"); - assert_eq!(guard.job_id, "extgen-1"); - assert_eq!(guard.worker_id, "worker-a"); - assert_eq!(guard.lease_token, "lease-1"); + assert_eq!(guard.job_id.as_deref(), Some("extgen-1")); + assert_eq!(guard.worker_id.as_deref(), Some("worker-a")); + assert_eq!(guard.lease_token.as_deref(), Some("lease-1")); } #[test] diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 68c31493..4e7889e2 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -136,9 +136,27 @@ const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536"; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ExternalGenerationWriteLeaseGuard { - pub(crate) job_id: String, - pub(crate) worker_id: String, - pub(crate) lease_token: String, + pub(crate) job_id: Option, + pub(crate) worker_id: Option, + pub(crate) lease_token: Option, +} + +impl ExternalGenerationWriteLeaseGuard { + pub(crate) fn inline() -> Self { + Self { + job_id: None, + worker_id: None, + lease_token: None, + } + } + + pub(crate) fn from_claimed_job(job_id: String, worker_id: String, lease_token: String) -> Self { + Self { + job_id: Some(job_id), + worker_id: Some(worker_id), + lease_token: Some(lease_token), + } + } } #[derive(Debug)] @@ -166,6 +184,10 @@ impl PuzzleExternalGenerationWorkerError { self.error.body_text() } + pub(crate) fn into_app_error(self) -> AppError { + self.error + } + pub(crate) fn should_fail_queue_job(&self) -> bool { self.should_fail_queue_job } diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index c0ffb282..238179dc 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -648,6 +648,53 @@ pub async fn execute_puzzle_agent_action( image_model: payload.image_model.clone(), requested_at_micros: now, }; + if state + .root_state() + .config + .external_generation_mode + .is_inline() + { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compile_session_id, + owner_user_id = %owner_user_id, + external_generation_mode = state.root_state().config.external_generation_mode.as_str(), + "拼图首关草稿生成使用 inline 模式同步执行" + ); + let session = execute_puzzle_compile_draft_worker_job( + &state, + &request_context, + worker_payload, + ExternalGenerationWriteLeaseGuard::inline(), + ) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + error.into_app_error(), + ) + })?; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: build_prefixed_uuid_id("extgen-inline-"), + operation_type: "compile_puzzle_draft".to_string(), + status: "completed".to_string(), + phase_label: "首关拼图草稿".to_string(), + phase_detail: if ai_redraw { + "首关草稿生成已完成。".to_string() + } else { + "首关草稿编译已完成。".to_string() + }, + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| { puzzle_error_response( &request_context, @@ -811,6 +858,49 @@ pub async fn execute_puzzle_agent_action( levels_json, requested_at_micros: now, }; + if state + .root_state() + .config + .external_generation_mode + .is_inline() + { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + external_generation_mode = state.root_state().config.external_generation_mode.as_str(), + "拼图关卡图片生成使用 inline 模式同步执行" + ); + let session = execute_puzzle_generate_images_worker_job( + &state, + &request_context, + worker_payload, + ExternalGenerationWriteLeaseGuard::inline(), + ) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + error.into_app_error(), + ) + })?; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: build_prefixed_uuid_id("extgen-inline-"), + operation_type: "generate_puzzle_images".to_string(), + status: "completed".to_string(), + phase_label: "拼图图片生成".to_string(), + phase_detail: "关卡图片生成已完成。".to_string(), + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| { puzzle_error_response( &request_context, @@ -908,6 +998,49 @@ pub async fn execute_puzzle_agent_action( levels_json, requested_at_micros: now, }; + if state + .root_state() + .config + .external_generation_mode + .is_inline() + { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + external_generation_mode = state.root_state().config.external_generation_mode.as_str(), + "拼图 UI 背景图生成使用 inline 模式同步执行" + ); + let session = execute_puzzle_generate_ui_background_worker_job( + &state, + &request_context, + worker_payload, + ExternalGenerationWriteLeaseGuard::inline(), + ) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + error.into_app_error(), + ) + })?; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: build_prefixed_uuid_id("extgen-inline-"), + operation_type: "generate_puzzle_ui_background".to_string(), + status: "completed".to_string(), + phase_label: "UI 背景图生成".to_string(), + phase_detail: "拼图 UI 背景图生成已完成。".to_string(), + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| { puzzle_error_response( &request_context, diff --git a/server-rs/crates/module-puzzle/src/commands.rs b/server-rs/crates/module-puzzle/src/commands.rs index d0932f2f..5b27473a 100644 --- a/server-rs/crates/module-puzzle/src/commands.rs +++ b/server-rs/crates/module-puzzle/src/commands.rs @@ -78,9 +78,9 @@ pub struct PuzzleDraftCompileFailureInput { pub owner_user_id: String, pub error_message: String, pub failed_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -92,9 +92,9 @@ pub struct PuzzleLevelGenerationFailureInput { pub levels_json: Option, pub error_message: String, pub failed_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -106,9 +106,9 @@ pub struct PuzzleGeneratedImagesSaveInput { pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -122,9 +122,9 @@ pub struct PuzzleUiBackgroundSaveInput { pub image_src: String, pub image_object_key: Option, pub saved_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index 404f1b8a..df332945 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -642,9 +642,9 @@ pub struct PuzzleDraftCompileFailureRecordInput { pub owner_user_id: String, pub error_message: String, pub failed_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -655,9 +655,9 @@ pub struct PuzzleLevelGenerationFailureRecordInput { pub levels_json: Option, pub error_message: String, pub failed_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -668,9 +668,9 @@ pub struct PuzzleGeneratedImagesSaveRecordInput { pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -683,9 +683,9 @@ pub struct PuzzleUiBackgroundSaveRecordInput { pub image_src: String, pub image_object_key: Option, pub saved_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs index 91eb88b4..cfa6818b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs @@ -11,9 +11,9 @@ pub struct PuzzleDraftCompileFailureInput { pub owner_user_id: String, pub error_message: String, pub failed_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } impl __sdk::InModule for PuzzleDraftCompileFailureInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs index a3057fac..d246f74a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs @@ -13,9 +13,9 @@ pub struct PuzzleGeneratedImagesSaveInput { pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } impl __sdk::InModule for PuzzleGeneratedImagesSaveInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_level_generation_failure_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_level_generation_failure_input_type.rs index 84b8dde2..6d96e480 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_level_generation_failure_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_level_generation_failure_input_type.rs @@ -13,9 +13,9 @@ pub struct PuzzleLevelGenerationFailureInput { pub levels_json: Option, pub error_message: String, pub failed_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } impl __sdk::InModule for PuzzleLevelGenerationFailureInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_ui_background_save_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_ui_background_save_input_type.rs index bd2189d2..ebdeea95 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_ui_background_save_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_ui_background_save_input_type.rs @@ -15,9 +15,9 @@ pub struct PuzzleUiBackgroundSaveInput { pub image_src: String, pub image_object_key: Option, pub saved_at_micros: i64, - pub external_generation_job_id: String, - pub external_generation_worker_id: String, - pub external_generation_lease_token: String, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } impl __sdk::InModule for PuzzleUiBackgroundSaveInput { diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index be133790..252fab96 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -147,8 +147,13 @@ impl SpacetimeClient { owner_user_id: String, compiled_at_micros: i64, ) -> Result { - self.compile_puzzle_agent_draft_inner(session_id, owner_user_id, compiled_at_micros, None) - .await + self.compile_puzzle_agent_draft_inner( + session_id, + owner_user_id, + compiled_at_micros, + (None, None, None), + ) + .await } pub async fn compile_puzzle_agent_draft_with_external_generation_guard( @@ -156,19 +161,19 @@ impl SpacetimeClient { session_id: String, owner_user_id: String, compiled_at_micros: i64, - external_generation_job_id: String, - external_generation_worker_id: String, - external_generation_lease_token: String, + external_generation_job_id: Option, + external_generation_worker_id: Option, + external_generation_lease_token: Option, ) -> Result { self.compile_puzzle_agent_draft_inner( session_id, owner_user_id, compiled_at_micros, - Some(( + ( external_generation_job_id, external_generation_worker_id, external_generation_lease_token, - )), + ), ) .await } @@ -178,17 +183,13 @@ impl SpacetimeClient { session_id: String, owner_user_id: String, compiled_at_micros: i64, - external_generation_guard: Option<(String, String, String)>, + external_generation_guard: (Option, Option, Option), ) -> Result { let ( external_generation_job_id, external_generation_worker_id, external_generation_lease_token, - ) = external_generation_guard - .map(|(job_id, worker_id, lease_token)| { - (Some(job_id), Some(worker_id), Some(lease_token)) - }) - .unwrap_or((None, None, None)); + ) = external_generation_guard; let procedure_input = PuzzleDraftCompileInput { session_id, owner_user_id, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a9855136..ae2aa462 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1003,26 +1003,17 @@ fn compile_puzzle_agent_draft_tx( ctx: &TxContext, input: PuzzleDraftCompileInput, ) -> Result { - match ( + validate_optional_puzzle_external_generation_write_guard( + ctx, input.external_generation_job_id.as_deref(), input.external_generation_worker_id.as_deref(), input.external_generation_lease_token.as_deref(), - ) { - (Some(job_id), Some(worker_id), Some(lease_token)) => { - validate_puzzle_external_generation_write_guard( - ctx, - job_id, - worker_id, - lease_token, - &[PUZZLE_COMPILE_DRAFT_JOB_KIND], - &input.session_id, - &input.owner_user_id, - None, - )?; - } - (None, None, None) => {} - _ => return Err("拼图草稿编译外部生成 guard 不完整".to_string()), - } + &[PUZZLE_COMPILE_DRAFT_JOB_KIND], + &input.session_id, + &input.owner_user_id, + None, + "拼图草稿编译外部生成 guard 不完整", + )?; let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; if row.seed_text.trim().is_empty() { return Err("请先填写拼图作品信息".to_string()); @@ -1073,15 +1064,16 @@ fn mark_puzzle_draft_generation_failed_tx( ctx: &TxContext, input: PuzzleDraftCompileFailureInput, ) -> Result { - validate_puzzle_external_generation_write_guard( + validate_optional_puzzle_external_generation_write_guard( ctx, - &input.external_generation_job_id, - &input.external_generation_worker_id, - &input.external_generation_lease_token, + input.external_generation_job_id.as_deref(), + input.external_generation_worker_id.as_deref(), + input.external_generation_lease_token.as_deref(), &[PUZZLE_COMPILE_DRAFT_JOB_KIND], &input.session_id, &input.owner_user_id, None, + "拼图草稿失败态外部生成 guard 不完整", )?; let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros); @@ -1138,11 +1130,11 @@ fn mark_puzzle_level_generation_failed_tx( ctx: &TxContext, input: PuzzleLevelGenerationFailureInput, ) -> Result { - validate_puzzle_external_generation_write_guard( + validate_optional_puzzle_external_generation_write_guard( ctx, - &input.external_generation_job_id, - &input.external_generation_worker_id, - &input.external_generation_lease_token, + input.external_generation_job_id.as_deref(), + input.external_generation_worker_id.as_deref(), + input.external_generation_lease_token.as_deref(), &[ PUZZLE_GENERATE_IMAGES_JOB_KIND, PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND, @@ -1150,6 +1142,7 @@ fn mark_puzzle_level_generation_failed_tx( &input.session_id, &input.owner_user_id, input.level_id.as_deref(), + "拼图关卡失败态外部生成 guard 不完整", )?; let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros); @@ -1259,6 +1252,35 @@ fn validate_puzzle_external_generation_write_guard( ) } +fn validate_optional_puzzle_external_generation_write_guard( + ctx: &TxContext, + job_id: Option<&str>, + worker_id: Option<&str>, + lease_token: Option<&str>, + expected_job_kinds: &[&str], + session_id: &str, + owner_user_id: &str, + level_id: Option<&str>, + incomplete_message: &str, +) -> Result<(), String> { + match (job_id, worker_id, lease_token) { + (Some(job_id), Some(worker_id), Some(lease_token)) => { + validate_puzzle_external_generation_write_guard( + ctx, + job_id, + worker_id, + lease_token, + expected_job_kinds, + session_id, + owner_user_id, + level_id, + ) + } + (None, None, None) => Ok(()), + _ => Err(incomplete_message.to_string()), + } +} + fn save_puzzle_form_draft_tx( ctx: &TxContext, input: PuzzleFormDraftSaveInput, @@ -1316,11 +1338,11 @@ fn save_puzzle_generated_images_tx( ctx: &TxContext, input: PuzzleGeneratedImagesSaveInput, ) -> Result { - validate_puzzle_external_generation_write_guard( + validate_optional_puzzle_external_generation_write_guard( ctx, - &input.external_generation_job_id, - &input.external_generation_worker_id, - &input.external_generation_lease_token, + input.external_generation_job_id.as_deref(), + input.external_generation_worker_id.as_deref(), + input.external_generation_lease_token.as_deref(), &[ PUZZLE_COMPILE_DRAFT_JOB_KIND, PUZZLE_GENERATE_IMAGES_JOB_KIND, @@ -1328,6 +1350,7 @@ fn save_puzzle_generated_images_tx( &input.session_id, &input.owner_user_id, input.level_id.as_deref(), + "拼图图片保存外部生成 guard 不完整", )?; let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let mut draft = deserialize_draft_required(&row.draft_json)?; @@ -1413,15 +1436,16 @@ fn save_puzzle_ui_background_tx( ctx: &TxContext, input: PuzzleUiBackgroundSaveInput, ) -> Result { - validate_puzzle_external_generation_write_guard( + validate_optional_puzzle_external_generation_write_guard( ctx, - &input.external_generation_job_id, - &input.external_generation_worker_id, - &input.external_generation_lease_token, + input.external_generation_job_id.as_deref(), + input.external_generation_worker_id.as_deref(), + input.external_generation_lease_token.as_deref(), &[PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND], &input.session_id, &input.owner_user_id, input.level_id.as_deref(), + "拼图 UI 背景保存外部生成 guard 不完整", )?; let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let mut draft = deserialize_draft_required(&row.draft_json)?;