feat: workerize external generation
This commit is contained in:
@@ -48,6 +48,14 @@
|
||||
- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误;进入平台首页不弹“平台首页:creation_entry_disabled”;关闭态入口卡显示锁定状态且不显示 `10-20泥点数`。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-03 外部内容生成改为持久队列加 worker 角色
|
||||
|
||||
- 背景:拼图首图、图集、音频等外部生成链路长期占用 `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 当前任务。
|
||||
- 影响范围:`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`。
|
||||
|
||||
## 2026-06-03 最近创作只复用创作模板入口
|
||||
|
||||
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
|
||||
@@ -1247,3 +1255,11 @@
|
||||
- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`server-rs/crates/spacetime-client/src/auth.rs`、对应生成 bindings。
|
||||
- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:spacetime-schema`、`npm run check:encoding`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 2026-06-03 外部生成 worker lease 使用 SpacetimeDB 时间和 token 栅栏
|
||||
|
||||
- 背景:外部生成 worker 支持多进程动态缩扩容后,长任务超过单次 lease、worker 本机时钟漂移或复用 worker id 都可能导致同一任务被重复领取并被过期执行者回写。
|
||||
- 决策:`external_generation_job` 新增末尾字段 `lease_token`;`claim` 使用 SpacetimeDB `ctx.timestamp` 计算 lease,生成本次 claim token;worker 执行期间调用 `renew_external_generation_job_lease_and_return` 续租;`complete/fail` 必须带 `worker_id + lease_token` 才能回写。拼图 `compile_puzzle_draft` 的 dedupe key 包含本次 `extgen-` job id,避免同一 session 的失败或完成 job 吞掉后续重新生成。拼图首图前置 `compile_puzzle_agent_draft`、图片保存、UI 背景与失败态业务写回同样必须携带 lease guard,并在 `compile_puzzle_agent_draft`、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed`、`mark_puzzle_level_generation_failed` 的 SpacetimeDB 事务内校验。
|
||||
- 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/module-puzzle/src/commands.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/puzzle.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/generation.rs`。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`GENARRATIVE_PROCESS_ROLE=all npm run dev` 后检查 `/healthz`。
|
||||
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
@@ -15,6 +15,38 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 外部生成 worker 业务失败重试会撞上钱包扣退费幂等
|
||||
|
||||
- 现象:同一个外部生成 job 如果第一次业务失败后退款,再用同一个业务资源 ID 自动重试并成功,钱包 `consume` ledger 可能因为同 ID 已存在而跳过,最终出现“失败已退、成功不再扣”的余额漂移。
|
||||
- 原因:资产操作扣费和退款都用稳定 ledger id 做幂等;这能保护 lease 过期后的崩溃重领不重复扣费,但不适合“已明确失败且已退款”的自动业务重试。
|
||||
- 处理:拼图 `puzzle_compile_draft` 首期设置 `max_attempts=1`,业务失败直接 failed,只保留 running lease 过期后的崩溃重领。后续若要恢复自动 retry,必须先引入 attempt-aware billing 或可配对撤销的账本接口。
|
||||
- 验证:检查 `external_generation_job.max_attempts`、worker 失败回写和钱包 ledger;失败后草稿进入 failed,重试应由用户重新触发新任务,而不是旧 job 自动 pending。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/asset_billing.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
## 外部生成队列不再由 HTTP 进程兜底执行
|
||||
|
||||
- 现象:拼图首关生成接口返回 `queued`,但生成页长时间不完成,重启 `genarrative-api.service` 也没有推进任务。
|
||||
- 原因:HTTP 角色只入队,不再直接调用外部 provider;如果没有运行 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 或 `all` 的进程,`external_generation_job` 会停留在 `pending/running`,直到有 worker claim。
|
||||
- 处理:生产用 `systemctl enable --now genarrative-external-generation-worker@1.service` 启动至少一个 worker;首次 API deploy 会在默认 worker pattern 下自动启用并启动 `@1`,并等待 worker active。扩容继续启动 `@2.service` 等实例,缩容停止多余实例;worker 收到停机信号后会停止 claim 新任务并等待当前任务完成。本地 smoke 可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev`。
|
||||
- 验证:`systemctl status 'genarrative-external-generation-worker@*.service'` 能看到 worker 实例;队列任务被 claim 后 `worker_id` 与 `lease_expires_at` 会更新,完成后 session 进入 ready 或 failed。
|
||||
- 关联:`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/env/external-generation-worker.env.example`、`server-rs/crates/spacetime-module/src/external_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 外部生成 worker 业务写回必须同事务校验 lease guard
|
||||
|
||||
- 现象:worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
|
||||
- 原因:队列状态栅栏只保护 `external_generation_job` 自身,不会自动保护玩法 procedure。业务写回必须自己带 claim 后的 `job_id / worker_id / lease_token`,并在同一个 SpacetimeDB transaction 内校验 job 仍为 `running`、lease 未过期、job kind、owner 和 source entity 匹配。
|
||||
- 处理:拼图首图 worker 的前置 `compile_puzzle_agent_draft`、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 已接入 `external_generation_job` lease guard;api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,错误文本包含 `external_generation_job 当前不是 running 状态` 或 `external_generation_job 不存在` 时也按 stale guard 处理。后续迁移其它玩法 worker 时必须复用该模式,不能只在 worker 进程内保存一份 token。
|
||||
- 验证:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/asset_billing.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
## 外部生成 worker 核心业务写回失败不能完成 job
|
||||
|
||||
- 现象:worker 已经生成图片并拿到本地合成 session 快照,但 SpacetimeDB 业务写回因连接、旧 wasm 或 lease guard 失败没有真实落库;如果此时仍把 `external_generation_job` 标成 `completed`,前端只会看到队列完成而 session 长时间不变化,后续也没有 worker 会重领修复。
|
||||
- 原因:同步 HTTP handler 的“外部 provider 已成功但 SpacetimeDB 短暂不可用时返回内存快照”降级语义,不能直接搬进异步 worker。worker 的完成状态必须代表核心业务事实已经持久化。
|
||||
- 处理:worker 路径的 `save_puzzle_generated_images` / `save_puzzle_ui_background` 等核心业务写回失败时直接返回错误;只有核心写回已经成功后的非关键投影回写才允许降级记录 warning。业务失败态也必须先写回 session / work profile,写回成功后才允许把队列 job 标为 failed;失败态未写回时保留租约,等待 lease 过期后重领。生产首装和首次 API deploy 都必须至少启用一个 worker 实例,例如 `systemctl enable --now genarrative-external-generation-worker@1.service`。
|
||||
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`,并在 smoke 时确认 queued 任务被 worker 消费后 session 真实更新。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
## 平台异步错误必须带来源弹窗,不要只显示裸错误
|
||||
|
||||
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。
|
||||
@@ -1761,3 +1793,11 @@
|
||||
- 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。
|
||||
- 验证:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖访客态纵向滑动不弹登录且触发下一条推荐。
|
||||
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||
|
||||
## 外部生成 worker 不能只靠 worker_id 判定 lease owner
|
||||
|
||||
- 现象:外部生成任务超过单次 lease、worker 机器时钟漂移,或 systemd 实例误复用同一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID` 时,同一 job 可能被新 worker 重领,但旧 worker 仍在执行并尝试 complete/fail。
|
||||
- 原因:如果队列只校验 `worker_id`,过期执行者仍可能覆盖当前 lease;如果 claim 使用 worker 本机绝对时间,动态扩缩容时的时钟漂移会造成提前抢占或长期锁死。
|
||||
- 处理:`external_generation_job` 使用 SpacetimeDB `ctx.timestamp` 计算 claim/renew/complete/fail 时间,并在 claim 时生成 `lease_token`;worker 长任务期间调用 renew,complete/fail 必须携带同一个 `worker_id + lease_token`,且 lease 尚未过期。排查时先看 job 快照里的 `attempt`、`worker_id`、`lease_expires_at` 和 `lease_token` 是否按 claim 递增切换。
|
||||
- 验证:`cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml` 应覆盖 token 和时长 helper;`npm run check:spacetime-schema` 应确认新增字段在表末尾且有默认值。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
@@ -9,11 +9,12 @@ Docker Compose
|
||||
├─ spacetimedb :3101,独立数据卷,供 api-server 连接
|
||||
├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
|
||||
├─ api-server :8082,Linux release 构建,连接 compose 内 SpacetimeDB
|
||||
├─ external-generation-worker,独立 worker 进程,消费 external_generation_job 队列
|
||||
├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs
|
||||
└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
|
||||
```
|
||||
|
||||
当前容器模拟参数按 `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`、`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 内存。
|
||||
当前容器模拟参数按 `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 上限。
|
||||
Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
||||
生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
|
||||
@@ -74,6 +75,7 @@ curl -sS http://127.0.0.1:18080/api/runtime/puzzle/gallery
|
||||
```bash
|
||||
npm run container:logs -- nginx
|
||||
npm run container:logs -- api-server
|
||||
npm run container:logs -- external-generation-worker
|
||||
npm run container:logs -- otelcol
|
||||
```
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ GENARRATIVE_API_PORT=8082
|
||||
GENARRATIVE_API_LOG=info,tower_http=info
|
||||
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||
GENARRATIVE_API_WORKER_THREADS=4
|
||||
# 容器 smoke 可临时设 all;压测或预发按 api / external-generation-worker 拆进程。
|
||||
GENARRATIVE_PROCESS_ROLE=api
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
||||
|
||||
@@ -69,6 +69,32 @@ services:
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
external-generation-worker:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/container/api-server.Dockerfile
|
||||
target: api-runtime
|
||||
cpus: "2.0"
|
||||
mem_limit: 1g
|
||||
env_file:
|
||||
- ./api-server.env
|
||||
environment:
|
||||
GENARRATIVE_PROCESS_ROLE: external-generation-worker
|
||||
GENARRATIVE_TRACKING_OUTBOX_DIR: /var/lib/genarrative/tracking-outbox-worker
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4318
|
||||
OTEL_SERVICE_NAME: genarrative-external-generation-worker
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 4096
|
||||
hard: 4096
|
||||
depends_on:
|
||||
spacetimedb:
|
||||
condition: service_healthy
|
||||
otelcol:
|
||||
condition: service_started
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: ../..
|
||||
|
||||
6
deploy/env/api-server.env.example
vendored
6
deploy/env/api-server.env.example
vendored
@@ -7,6 +7,12 @@ GENARRATIVE_API_PORT=8082
|
||||
GENARRATIVE_API_LOG=info,tower_http=info
|
||||
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||
GENARRATIVE_API_WORKER_THREADS=4
|
||||
# api 只监听 HTTP;外部生成 worker 用独立进程设置为 external-generation-worker 后横向扩缩。
|
||||
GENARRATIVE_PROCESS_ROLE=api
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
||||
|
||||
11
deploy/env/external-generation-worker.env.example
vendored
Normal file
11
deploy/env/external-generation-worker.env.example
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# 复制到 /etc/genarrative/external-generation-worker.env 后按机器容量调整。
|
||||
# 该文件只覆盖 worker 专属参数;SpacetimeDB、外部 provider 密钥继续复用 api-server.env。
|
||||
# systemd 模板会强制设置 GENARRATIVE_PROCESS_ROLE=external-generation-worker
|
||||
# 和 GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i,避免多实例 ID 冲突。
|
||||
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||
# 单次 lease 会由 worker 自动续租;该值覆盖心跳抖动窗口即可。
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
||||
GENARRATIVE_API_LOG=info,tower_http=info
|
||||
OTEL_SERVICE_NAME=genarrative-external-generation-worker
|
||||
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=Genarrative External Generation Worker %i
|
||||
After=network-online.target spacetimedb.service
|
||||
Wants=network-online.target
|
||||
Requires=spacetimedb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=genarrative
|
||||
Group=genarrative
|
||||
WorkingDirectory=/opt/genarrative/current
|
||||
EnvironmentFile=/etc/genarrative/api-server.env
|
||||
EnvironmentFile=-/etc/genarrative/external-generation-worker.env
|
||||
ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=external-generation-worker GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox/%H-%i OTEL_SERVICE_NAME=genarrative-external-generation-worker /opt/genarrative/current/api-server
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=7200
|
||||
LimitNOFILE=65535
|
||||
TasksMax=2048
|
||||
|
||||
# worker 复用 api-server 发布目录;外部生成审计与临时运行态只写服务端私有目录。
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/genarrative /var/lib/genarrative
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
153
docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
Normal file
153
docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 外部生成 Worker 化方案
|
||||
|
||||
更新时间:`2026-06-03`
|
||||
|
||||
## 背景
|
||||
|
||||
当前 VectorEngine `gpt-image-2`、音频、LLM 等外部生成链路多数由 `api-server` 的 HTTP handler 直接等待上游、OSS 持久化和 SpacetimeDB 回写完成。前端虽然有生成页和会话轮询,但 HTTP 进程仍承担长耗时副作用,导致接入更多玩法或大图生成时只能放大 API 进程,而不能单独扩展外部生成吞吐。
|
||||
|
||||
## 目标
|
||||
|
||||
- `api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。
|
||||
- 外部生成副作用由独立 `external-generation-worker` 角色执行。
|
||||
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
|
||||
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。
|
||||
- 已接入拼图 `compile_puzzle_draft` 与结果页 `generate_puzzle_images`;后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。
|
||||
|
||||
## Module 与 Interface
|
||||
|
||||
新增深一点的 **外部生成任务 Module**,Interface 收敛为:
|
||||
|
||||
- `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 后被重复领取。
|
||||
- `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`。
|
||||
|
||||
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
|
||||
|
||||
## 任务表
|
||||
|
||||
新增私有表 `external_generation_job`:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `job_id` | 主键,`extgen-` 前缀 UUID |
|
||||
| `dedupe_key` | 唯一键,建议为 `play/action/session/scope` |
|
||||
| `job_kind` | 执行类型,当前拼图为 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background` |
|
||||
| `owner_user_id` | 触发用户 |
|
||||
| `source_module` | 玩法或能力名,例如 `puzzle` |
|
||||
| `source_entity_id` | session/profile/work 等作用域 |
|
||||
| `request_label` | 排障标签 |
|
||||
| `request_payload_json` | worker 执行入参 JSON |
|
||||
| `status` | `pending/running/completed/failed/cancelled` |
|
||||
| `attempt` / `max_attempts` | 当前尝试次数与最大尝试次数 |
|
||||
| `last_error_message` | 最近失败原因 |
|
||||
| `worker_id` | 当前 lease owner |
|
||||
| `lease_expires_at` | lease 到期时间 |
|
||||
| `lease_token` | 本次 claim 的 fencing token,用于阻止过期 worker 回写 |
|
||||
| `available_at` | 下次可领取时间 |
|
||||
| `result_payload_json` | 完成摘要 |
|
||||
| `created_at/started_at/completed_at/updated_at` | 审计时间 |
|
||||
|
||||
索引:
|
||||
|
||||
- `by_external_generation_job_status_available(status, available_at)`
|
||||
- `by_external_generation_job_worker_id(worker_id)`
|
||||
- `by_external_generation_job_source(source_module, source_entity_id)`
|
||||
- `by_external_generation_job_owner_user_id(owner_user_id)`
|
||||
|
||||
## 状态机
|
||||
|
||||
```text
|
||||
pending -> running -> completed
|
||||
pending -> running -> pending (可重试失败)
|
||||
pending -> running -> failed (达到最大重试次数)
|
||||
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 的同一账本扣费。
|
||||
|
||||
## 进程角色
|
||||
|
||||
同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换:
|
||||
|
||||
- `api`:只启动 HTTP server。
|
||||
- `external-generation-worker`:只启动外部生成 worker,不监听 HTTP。
|
||||
- `all`:本地开发可同时启动 HTTP 与 worker。
|
||||
|
||||
worker 配置:
|
||||
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`:实例 ID;未配置时用 hostname/pid 派生。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY`:单进程并发领取/执行数量。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS`:空队列轮询间隔。
|
||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长;worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。
|
||||
|
||||
动态缩扩容方式:生产通过 `deploy/systemd/genarrative-external-generation-worker@.service` 或进程管理器启动更多 `external-generation-worker` 实例;无需改变 HTTP 进程数。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。
|
||||
|
||||
## 已接入的拼图纵切
|
||||
|
||||
`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 后续重新生成吞掉。
|
||||
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` 快照后入队 `puzzle_generate_images`,返回 `operation.status = queued/running/completed/failed`。
|
||||
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`。
|
||||
2. worker 执行原结果页 UI 背景链路:归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。
|
||||
3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job,让前端轮询能收敛。
|
||||
|
||||
Match3D、Wooden Fish、Visual Novel 音频等后续外部生成 action 按同一模式迁移。
|
||||
|
||||
## 验收
|
||||
|
||||
基础检查:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
定向测试:
|
||||
|
||||
```bash
|
||||
cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p spacetime-module level_generation_failure --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p api-server external_generation_worker --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:
|
||||
|
||||
```bash
|
||||
GENARRATIVE_PROCESS_ROLE=all npm run dev
|
||||
curl -f http://127.0.0.1:<api-port>/healthz
|
||||
```
|
||||
|
||||
生产 smoke 需要至少启动一个 `api` 角色和一个 `external-generation-worker` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,并等待 worker active。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。
|
||||
|
||||
systemd 生产扩缩容示例:
|
||||
|
||||
```bash
|
||||
systemctl enable --now genarrative-external-generation-worker@1.service
|
||||
systemctl start genarrative-external-generation-worker@2.service
|
||||
systemctl stop genarrative-external-generation-worker@2.service
|
||||
systemctl status 'genarrative-external-generation-worker@*.service'
|
||||
```
|
||||
@@ -98,7 +98,7 @@ npm run check:server-rs-ddd
|
||||
- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、`PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State<PuzzleApiState>`,不得重新改回 `State<AppState>`。
|
||||
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。
|
||||
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。
|
||||
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
|
||||
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt 和初始资产就绪校验。
|
||||
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
|
||||
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
|
||||
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
|
||||
@@ -203,6 +203,12 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`AiTaskStage`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/ai/stages.rs`
|
||||
|
||||
### `external_generation_job`
|
||||
|
||||
- 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,失败态未写回时保留租约等待后续重领。
|
||||
|
||||
### `ai_text_chunk`
|
||||
|
||||
- Rust 结构体:`AiTextChunk`
|
||||
|
||||
@@ -51,6 +51,8 @@ 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。
|
||||
|
||||
微信小程序虚拟支付使用 `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`。
|
||||
|
||||
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
|
||||
@@ -255,6 +257,8 @@ 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 退款和重试成功之间产生钱包账本漂移。
|
||||
|
||||
`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 配置和被移动的默认站点。
|
||||
|
||||
50 HTTP req/s 首版压测优化口径:
|
||||
|
||||
@@ -475,10 +475,16 @@ function loadBaseSources(baseRef) {
|
||||
|
||||
function getChangedFiles(baseRef) {
|
||||
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
|
||||
const untrackedOutput =
|
||||
const untrackedModuleOutput =
|
||||
tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? '';
|
||||
const untrackedBindingsOutput =
|
||||
tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? '';
|
||||
return new Set(
|
||||
[...diffOutput.split(/\u0000/u), ...untrackedOutput.split(/\u0000/u)]
|
||||
[
|
||||
...diffOutput.split(/\u0000/u),
|
||||
...untrackedModuleOutput.split(/\u0000/u),
|
||||
...untrackedBindingsOutput.split(/\u0000/u),
|
||||
]
|
||||
.map(normalizePath)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
@@ -5,10 +5,11 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
|
||||
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
|
||||
|
||||
说明:
|
||||
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
|
||||
默认同时重启已加载的外部生成 worker 实例;未启用 worker 单元时会自动跳过。
|
||||
若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
|
||||
失败时保留维护模式。
|
||||
EOF
|
||||
@@ -223,12 +224,106 @@ ensure_runtime_env_and_dirs() {
|
||||
fi
|
||||
}
|
||||
|
||||
list_worker_services() {
|
||||
local pattern="$1"
|
||||
|
||||
if [[ -z "${pattern}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
systemctl list-units --all --plain --no-legend "${pattern}" 2>/dev/null | awk '{print $1}' | sort -u
|
||||
}
|
||||
|
||||
ensure_default_worker_service() {
|
||||
local pattern="$1"
|
||||
local default_service="genarrative-external-generation-worker@1.service"
|
||||
local template_service="genarrative-external-generation-worker@.service"
|
||||
local services=()
|
||||
|
||||
if [[ -z "${pattern}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${pattern}" != "genarrative-external-generation-worker@*.service" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! systemctl cat "${template_service}" >/dev/null 2>&1; then
|
||||
echo "[production-api-deploy] 缺少外部生成 worker systemd 模板: ${template_service}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
mapfile -t services < <(list_worker_services "${pattern}")
|
||||
if [[ "${#services[@]}" -gt 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[production-api-deploy] 未发现外部生成 worker 实例,启用并启动默认实例: ${default_service}"
|
||||
systemctl enable --now "${default_service}"
|
||||
}
|
||||
|
||||
restart_worker_services() {
|
||||
local pattern="$1"
|
||||
local services=()
|
||||
|
||||
if [[ -z "${pattern}" ]]; then
|
||||
echo "[production-api-deploy] 跳过外部生成 worker 重启。"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_default_worker_service "${pattern}"
|
||||
mapfile -t services < <(list_worker_services "${pattern}")
|
||||
if [[ "${#services[@]}" -eq 0 ]]; then
|
||||
echo "[production-api-deploy] 未发现已加载的外部生成 worker 单元: ${pattern}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[production-api-deploy] 重启外部生成 worker: ${services[*]}"
|
||||
systemctl restart "${services[@]}"
|
||||
}
|
||||
|
||||
wait_for_worker_services() {
|
||||
local pattern="$1"
|
||||
local services=()
|
||||
local all_active
|
||||
|
||||
if [[ -z "${pattern}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mapfile -t services < <(list_worker_services "${pattern}")
|
||||
if [[ "${#services[@]}" -eq 0 ]]; then
|
||||
echo "[production-api-deploy] 外部生成 worker 单元不存在,发布失败: ${pattern}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[production-api-deploy] 等待外部生成 worker active: ${services[*]}"
|
||||
for _ in {1..30}; do
|
||||
all_active=1
|
||||
for service in "${services[@]}"; do
|
||||
if ! systemctl is-active --quiet "${service}"; then
|
||||
all_active=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "${all_active}" -eq 1 ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
systemctl --no-pager --full status "${services[@]}" || true
|
||||
echo "[production-api-deploy] 外部生成 worker 未在超时时间内进入 active,发布失败。" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SOURCE_DIR=""
|
||||
VERSION=""
|
||||
RELEASE_ROOT="/opt/genarrative/releases"
|
||||
CURRENT_LINK="/opt/genarrative/current"
|
||||
SERVICE_NAME="genarrative-api.service"
|
||||
WORKER_SERVICE_PATTERN="genarrative-external-generation-worker@*.service"
|
||||
HEALTH_URL="http://127.0.0.1:8082/readyz"
|
||||
API_ENV_FILE="/etc/genarrative/api-server.env"
|
||||
DATABASE=""
|
||||
@@ -261,6 +356,14 @@ while [[ $# -gt 0 ]]; do
|
||||
SERVICE_NAME="${2:?缺少 --service 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--worker-service-pattern)
|
||||
WORKER_SERVICE_PATTERN="${2:?缺少 --worker-service-pattern 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--no-worker-services)
|
||||
WORKER_SERVICE_PATTERN=""
|
||||
shift
|
||||
;;
|
||||
--health-url)
|
||||
HEALTH_URL="${2:?缺少 --health-url 的值}"
|
||||
shift 2
|
||||
@@ -362,6 +465,8 @@ ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}"
|
||||
|
||||
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
restart_worker_services "${WORKER_SERVICE_PATTERN}"
|
||||
wait_for_worker_services "${WORKER_SERVICE_PATTERN}"
|
||||
|
||||
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
|
||||
for _ in {1..30}; do
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
|
||||
SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}"
|
||||
OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}"
|
||||
WORKER_ENV_FILE="${WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}"
|
||||
|
||||
require_non_root_relative_path() {
|
||||
local label="$1"
|
||||
@@ -417,6 +418,10 @@ render_api_env_example() {
|
||||
deploy/env/api-server.env.example
|
||||
}
|
||||
|
||||
render_external_generation_worker_env_example() {
|
||||
cat deploy/env/external-generation-worker.env.example
|
||||
}
|
||||
|
||||
render_otelcol_service() {
|
||||
cat deploy/systemd/otelcol-contrib.service
|
||||
}
|
||||
@@ -603,6 +608,18 @@ render_api_service() {
|
||||
deploy/systemd/genarrative-api.service
|
||||
}
|
||||
|
||||
render_external_generation_worker_service() {
|
||||
local current_escaped api_env_escaped worker_env_escaped
|
||||
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
||||
api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
|
||||
worker_env_escaped="$(escape_sed_replacement "${WORKER_ENV_FILE}")"
|
||||
sed \
|
||||
-e "s|/opt/genarrative/current|${current_escaped}|g" \
|
||||
-e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \
|
||||
-e "s|/etc/genarrative/external-generation-worker.env|${worker_env_escaped}|g" \
|
||||
deploy/systemd/genarrative-external-generation-worker@.service
|
||||
}
|
||||
|
||||
render_database_backup_service() {
|
||||
local current_escaped env_escaped
|
||||
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
||||
@@ -615,6 +632,7 @@ render_database_backup_service() {
|
||||
|
||||
require_path deploy/systemd/spacetimedb.service
|
||||
require_path deploy/systemd/genarrative-api.service
|
||||
require_path deploy/systemd/genarrative-external-generation-worker@.service
|
||||
require_path deploy/systemd/genarrative-database-backup.service
|
||||
require_path deploy/systemd/genarrative-database-backup.timer
|
||||
require_path deploy/systemd/otelcol-contrib.service
|
||||
@@ -623,6 +641,7 @@ require_path deploy/nginx/genarrative.conf
|
||||
require_path deploy/nginx/genarrative-dev-http.conf
|
||||
require_path deploy/nginx/snippets/genarrative-maintenance.conf
|
||||
require_path deploy/env/api-server.env.example
|
||||
require_path deploy/env/external-generation-worker.env.example
|
||||
require_path scripts/deploy/maintenance-on.sh
|
||||
require_path scripts/deploy/maintenance-off.sh
|
||||
require_path scripts/deploy/maintenance-status.sh
|
||||
@@ -665,15 +684,18 @@ sync_spacetime_install "${SPACETIME_ROOT}"
|
||||
|
||||
spacetimedb_service="$(mktemp)"
|
||||
api_service="$(mktemp)"
|
||||
external_generation_worker_service="$(mktemp)"
|
||||
database_backup_service="$(mktemp)"
|
||||
render_spacetimedb_service >"${spacetimedb_service}"
|
||||
render_api_service >"${api_service}"
|
||||
render_external_generation_worker_service >"${external_generation_worker_service}"
|
||||
render_database_backup_service >"${database_backup_service}"
|
||||
install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644
|
||||
install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644
|
||||
install_file "${external_generation_worker_service}" /etc/systemd/system/genarrative-external-generation-worker@.service 0644
|
||||
install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644
|
||||
install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644
|
||||
rm -f "${spacetimedb_service}" "${api_service}" "${database_backup_service}"
|
||||
rm -f "${spacetimedb_service}" "${api_service}" "${external_generation_worker_service}" "${database_backup_service}"
|
||||
|
||||
if [[ ! -f "${API_ENV_FILE}" ]]; then
|
||||
echo "+ create ${API_ENV_FILE} from example"
|
||||
@@ -687,6 +709,17 @@ else
|
||||
fi
|
||||
ensure_api_runtime_env_defaults
|
||||
|
||||
if [[ ! -f "${WORKER_ENV_FILE}" ]]; then
|
||||
echo "+ create ${WORKER_ENV_FILE} from example"
|
||||
if [[ "${DRY_RUN}" != "true" ]]; then
|
||||
render_external_generation_worker_env_example >"${WORKER_ENV_FILE}"
|
||||
chmod 0600 "${WORKER_ENV_FILE}"
|
||||
chown root:root "${WORKER_ENV_FILE}"
|
||||
fi
|
||||
else
|
||||
echo "[server-provision] 已存在 worker 环境文件,保留不覆盖: ${WORKER_ENV_FILE}"
|
||||
fi
|
||||
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
sync_otelcol_install
|
||||
otelcol_service="$(mktemp)"
|
||||
@@ -708,7 +741,7 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
run_cmd systemctl enable otelcol-contrib.service
|
||||
fi
|
||||
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer
|
||||
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-external-generation-worker@1.service
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
run_cmd systemctl restart otelcol-contrib.service
|
||||
fi
|
||||
@@ -717,8 +750,10 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
|
||||
ensure_spacetime_owner_client_token
|
||||
if [[ -x "${CURRENT_LINK}/api-server" ]]; then
|
||||
run_cmd systemctl restart genarrative-api.service
|
||||
run_cmd systemctl enable --now genarrative-external-generation-worker@1.service
|
||||
run_cmd systemctl restart genarrative-external-generation-worker@1.service
|
||||
else
|
||||
echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server 首次启动。后续 API deploy 会重启服务。"
|
||||
echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server 和外部生成 worker 首次启动。后续 API deploy 会启用并启动默认 worker 实例。"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -4119,8 +4119,7 @@ mod tests {
|
||||
.await
|
||||
.expect("banners body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("banners payload should be json");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json");
|
||||
|
||||
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
|
||||
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");
|
||||
|
||||
@@ -48,7 +48,7 @@ where
|
||||
match operation.await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(error) => {
|
||||
if points_consumed {
|
||||
if points_consumed && should_refund_asset_operation_error(&error) {
|
||||
refund_asset_operation_points(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -63,6 +63,20 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn should_refund_asset_operation_error(error: &AppError) -> bool {
|
||||
let message = error.body_text();
|
||||
// 中文注释:worker lease guard 拒绝表示当前进程已失去队列写权限;
|
||||
// 这类 stale worker 失败不能补偿退款,否则可能冲掉后续合法 worker 的同一账本扣费。
|
||||
!(message.contains("external_generation_job")
|
||||
&& (message.contains("lease")
|
||||
|| message.contains("worker")
|
||||
|| message.contains("job_kind")
|
||||
|| message.contains("source_")
|
||||
|| message.contains("owner_user_id")
|
||||
|| message.contains("不存在")
|
||||
|| message.contains("不是 running 状态")))
|
||||
}
|
||||
|
||||
/// 资产操作统一预扣泥点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
||||
async fn consume_asset_operation_points(
|
||||
state: &AppState,
|
||||
@@ -200,4 +214,31 @@ mod tests {
|
||||
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_operation_billing_does_not_refund_stale_worker_lease_errors() {
|
||||
let stale_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": "external_generation_job lease 已过期",
|
||||
}));
|
||||
let completed_job_error =
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": "external_generation_job 当前不是 running 状态",
|
||||
}));
|
||||
let missing_job_error =
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": "external_generation_job 不存在",
|
||||
}));
|
||||
let ordinary_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "图片生成失败",
|
||||
}));
|
||||
|
||||
assert!(!should_refund_asset_operation_error(&stale_error));
|
||||
assert!(!should_refund_asset_operation_error(&completed_job_error));
|
||||
assert!(!should_refund_asset_operation_error(&missing_job_error));
|
||||
assert!(should_refund_asset_operation_error(&ordinary_error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ pub struct AppConfig {
|
||||
pub bind_port: u16,
|
||||
pub listen_backlog: i32,
|
||||
pub worker_threads: Option<usize>,
|
||||
pub process_role: ProcessRole,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_worker_concurrency: usize,
|
||||
pub external_generation_worker_poll_interval: Duration,
|
||||
pub external_generation_worker_lease: Duration,
|
||||
pub max_concurrent_requests: Option<usize>,
|
||||
pub gallery_max_concurrent_requests: Option<usize>,
|
||||
pub detail_max_concurrent_requests: Option<usize>,
|
||||
@@ -159,6 +164,31 @@ pub struct AppConfig {
|
||||
pub slow_request_threshold_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ProcessRole {
|
||||
Api,
|
||||
ExternalGenerationWorker,
|
||||
All,
|
||||
}
|
||||
|
||||
impl ProcessRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Api => "api",
|
||||
Self::ExternalGenerationWorker => "external-generation-worker",
|
||||
Self::All => "all",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runs_http(self) -> bool {
|
||||
matches!(self, Self::Api | Self::All)
|
||||
}
|
||||
|
||||
pub fn runs_external_generation_worker(self) -> bool {
|
||||
matches!(self, Self::ExternalGenerationWorker | Self::All)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -166,6 +196,11 @@ impl Default for AppConfig {
|
||||
bind_port: 3000,
|
||||
listen_backlog: 1024,
|
||||
worker_threads: None,
|
||||
process_role: ProcessRole::Api,
|
||||
external_generation_worker_id: default_external_generation_worker_id(),
|
||||
external_generation_worker_concurrency: 2,
|
||||
external_generation_worker_poll_interval: Duration::from_millis(2_000),
|
||||
external_generation_worker_lease: Duration::from_secs(3_600),
|
||||
max_concurrent_requests: None,
|
||||
gallery_max_concurrent_requests: None,
|
||||
detail_max_concurrent_requests: None,
|
||||
@@ -347,6 +382,30 @@ impl AppConfig {
|
||||
if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) {
|
||||
config.worker_threads = Some(worker_threads);
|
||||
}
|
||||
if let Some(process_role) = read_first_process_role_env(&["GENARRATIVE_PROCESS_ROLE"]) {
|
||||
config.process_role = process_role;
|
||||
}
|
||||
if let Some(worker_id) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID"])
|
||||
{
|
||||
config.external_generation_worker_id = worker_id;
|
||||
}
|
||||
if let Some(concurrency) =
|
||||
read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY"])
|
||||
{
|
||||
config.external_generation_worker_concurrency = concurrency.max(1);
|
||||
}
|
||||
if let Some(poll_interval_ms) = read_first_positive_u64_env(&[
|
||||
"GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS",
|
||||
]) {
|
||||
config.external_generation_worker_poll_interval =
|
||||
Duration::from_millis(poll_interval_ms);
|
||||
}
|
||||
if let Some(lease_seconds) = read_first_duration_seconds_env(&[
|
||||
"GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS",
|
||||
]) {
|
||||
config.external_generation_worker_lease = Duration::from_secs(lease_seconds.max(1));
|
||||
}
|
||||
if let Some(max_concurrent_requests) =
|
||||
read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"])
|
||||
{
|
||||
@@ -979,6 +1038,14 @@ fn read_first_llm_provider_env(keys: &[&str]) -> Option<LlmProvider> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_process_role_env(keys: &[&str]) -> Option<ProcessRole> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| parse_process_role(&value))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
@@ -1026,6 +1093,36 @@ fn read_first_u8_env(keys: &[&str]) -> Option<u8> {
|
||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value)))
|
||||
}
|
||||
|
||||
fn default_external_generation_worker_id() -> String {
|
||||
let host = env::var("HOSTNAME")
|
||||
.or_else(|_| env::var("COMPUTERNAME"))
|
||||
.unwrap_or_else(|_| "local".to_string());
|
||||
format!("{}-{}", host.trim(), std::process::id())
|
||||
}
|
||||
|
||||
fn parse_process_role(value: &str) -> Option<ProcessRole> {
|
||||
match trim_quoted_env_value(value).to_ascii_lowercase().as_str() {
|
||||
"api" => Some(ProcessRole::Api),
|
||||
"external-generation-worker" | "external_generation_worker" | "worker" => {
|
||||
Some(ProcessRole::ExternalGenerationWorker)
|
||||
}
|
||||
"all" => Some(ProcessRole::All),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_quoted_env_value(raw: &str) -> &str {
|
||||
let raw = raw.trim();
|
||||
raw.strip_prefix('"')
|
||||
.and_then(|value| value.strip_suffix('"'))
|
||||
.or_else(|| {
|
||||
raw.strip_prefix('\'')
|
||||
.and_then(|value| value.strip_suffix('\''))
|
||||
})
|
||||
.unwrap_or(raw)
|
||||
.trim()
|
||||
}
|
||||
|
||||
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
@@ -1146,7 +1243,8 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool,
|
||||
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, ProcessRole,
|
||||
parse_bool, parse_process_role,
|
||||
};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
@@ -1188,6 +1286,32 @@ mod tests {
|
||||
assert_eq!(parse_bool("'off'"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_role_controls_http_and_external_generation_worker_roles() {
|
||||
assert_eq!(parse_process_role("api"), Some(ProcessRole::Api));
|
||||
assert_eq!(
|
||||
parse_process_role("\"external-generation-worker\""),
|
||||
Some(ProcessRole::ExternalGenerationWorker)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_process_role("'external_generation_worker'"),
|
||||
Some(ProcessRole::ExternalGenerationWorker)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_process_role("worker"),
|
||||
Some(ProcessRole::ExternalGenerationWorker)
|
||||
);
|
||||
assert_eq!(parse_process_role("all"), Some(ProcessRole::All));
|
||||
assert_eq!(parse_process_role("unknown"), None);
|
||||
|
||||
assert!(ProcessRole::Api.runs_http());
|
||||
assert!(!ProcessRole::Api.runs_external_generation_worker());
|
||||
assert!(!ProcessRole::ExternalGenerationWorker.runs_http());
|
||||
assert!(ProcessRole::ExternalGenerationWorker.runs_external_generation_worker());
|
||||
assert!(ProcessRole::All.runs_http());
|
||||
assert!(ProcessRole::All.runs_external_generation_worker());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
|
||||
let _guard = ENV_LOCK
|
||||
|
||||
572
server-rs/crates/api-server/src/external_generation_worker.rs
Normal file
572
server-rs/crates/api-server/src/external_generation_worker.rs
Normal file
@@ -0,0 +1,572 @@
|
||||
use std::{future::Future, io, pin::Pin, time::Duration};
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use serde_json::json;
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use spacetime_client::{
|
||||
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
|
||||
ExternalGenerationJobFailRecordInput, ExternalGenerationJobRecord,
|
||||
ExternalGenerationJobRenewLeaseRecordInput,
|
||||
};
|
||||
use tokio::{
|
||||
task::JoinSet,
|
||||
time::{Instant, sleep},
|
||||
};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
puzzle::{
|
||||
ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload,
|
||||
PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload,
|
||||
execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job,
|
||||
execute_puzzle_generate_ui_background_worker_job,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::{AppState, PuzzleApiState},
|
||||
};
|
||||
|
||||
pub(crate) const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft";
|
||||
pub(crate) const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images";
|
||||
pub(crate) const PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND: &str = "puzzle_generate_ui_background";
|
||||
|
||||
pub(crate) async fn run_external_generation_worker(state: AppState) -> Result<(), io::Error> {
|
||||
let worker_id = state.config.external_generation_worker_id.clone();
|
||||
let concurrency = state.config.external_generation_worker_concurrency.max(1);
|
||||
let poll_interval = state.config.external_generation_worker_poll_interval;
|
||||
let lease = state.config.external_generation_worker_lease;
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut shutdown = external_generation_worker_shutdown_signal();
|
||||
|
||||
info!(
|
||||
worker_id,
|
||||
concurrency,
|
||||
poll_interval_ms = poll_interval.as_millis(),
|
||||
lease_seconds = lease.as_secs(),
|
||||
"external generation worker 已启动"
|
||||
);
|
||||
|
||||
loop {
|
||||
while tasks.len() >= concurrency {
|
||||
if await_worker_task_or_shutdown(&mut tasks, &mut shutdown).await {
|
||||
drain_external_generation_worker_tasks(&mut tasks).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let available = concurrency.saturating_sub(tasks.len()).max(1);
|
||||
let now_micros = current_utc_micros();
|
||||
let lease_expires_at_micros = now_micros.saturating_add(duration_micros_i64(lease));
|
||||
|
||||
let claim_jobs = state.spacetime_client().claim_external_generation_jobs(
|
||||
ExternalGenerationJobClaimRecordInput {
|
||||
worker_id: worker_id.clone(),
|
||||
limit: available.min(u32::MAX as usize) as u32,
|
||||
lease_expires_at_micros,
|
||||
claimed_at_micros: now_micros,
|
||||
},
|
||||
);
|
||||
tokio::pin!(claim_jobs);
|
||||
let jobs = match tokio::select! {
|
||||
_ = shutdown.as_mut() => {
|
||||
drain_external_generation_worker_tasks(&mut tasks).await;
|
||||
return Ok(());
|
||||
}
|
||||
result = &mut claim_jobs => result
|
||||
} {
|
||||
Ok(jobs) => jobs,
|
||||
Err(error) => {
|
||||
error!(error = %error, "领取外部生成任务失败,等待下一轮重试");
|
||||
if await_one_task_or_sleep_or_shutdown(
|
||||
&mut tasks,
|
||||
sleep(poll_interval),
|
||||
&mut shutdown,
|
||||
)
|
||||
.await
|
||||
{
|
||||
drain_external_generation_worker_tasks(&mut tasks).await;
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if jobs.is_empty() {
|
||||
if await_one_task_or_sleep_or_shutdown(&mut tasks, sleep(poll_interval), &mut shutdown)
|
||||
.await
|
||||
{
|
||||
drain_external_generation_worker_tasks(&mut tasks).await;
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for job in jobs {
|
||||
let state = state.clone();
|
||||
let worker_id = worker_id.clone();
|
||||
tasks.spawn(async move {
|
||||
if let Err(error) =
|
||||
process_external_generation_job(state, worker_id, lease, job).await
|
||||
{
|
||||
error!(error = %error, "external generation worker 执行任务失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ExternalGenerationShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||
|
||||
fn external_generation_worker_shutdown_signal() -> ExternalGenerationShutdownSignal {
|
||||
Box::pin(async {
|
||||
wait_for_external_generation_worker_shutdown_signal().await;
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn wait_for_external_generation_worker_shutdown_signal() {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
let mut sigterm = signal(SignalKind::terminate()).ok();
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => {
|
||||
if let Err(error) = result {
|
||||
warn!(error = %error, "external generation worker 监听 SIGINT 失败");
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(sigterm) = sigterm.as_mut() {
|
||||
sigterm.recv().await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
} => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn wait_for_external_generation_worker_shutdown_signal() {
|
||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
||||
warn!(error = %error, "external generation worker 监听 Ctrl-C 失败");
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_worker_task(tasks: &mut JoinSet<()>) {
|
||||
if let Some(result) = tasks.join_next().await
|
||||
&& let Err(error) = result
|
||||
{
|
||||
error!(error = %error, "external generation worker 子任务 panic");
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_worker_task_or_shutdown(
|
||||
tasks: &mut JoinSet<()>,
|
||||
shutdown: &mut ExternalGenerationShutdownSignal,
|
||||
) -> bool {
|
||||
tokio::select! {
|
||||
_ = shutdown.as_mut() => true,
|
||||
_ = await_worker_task(tasks) => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_one_task_or_sleep_or_shutdown(
|
||||
tasks: &mut JoinSet<()>,
|
||||
sleeper: impl Future<Output = ()>,
|
||||
shutdown: &mut ExternalGenerationShutdownSignal,
|
||||
) -> bool {
|
||||
tokio::pin!(sleeper);
|
||||
if tasks.is_empty() {
|
||||
tokio::select! {
|
||||
_ = shutdown.as_mut() => true,
|
||||
_ = &mut sleeper => false,
|
||||
}
|
||||
} else {
|
||||
tokio::select! {
|
||||
_ = shutdown.as_mut() => true,
|
||||
_ = &mut sleeper => false,
|
||||
result = tasks.join_next() => {
|
||||
if let Some(Err(error)) = result {
|
||||
error!(error = %error, "external generation worker 子任务 panic");
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_external_generation_worker_tasks(tasks: &mut JoinSet<()>) {
|
||||
info!(
|
||||
in_flight_jobs = tasks.len(),
|
||||
"external generation worker 收到停机信号,停止领取新任务并等待当前任务完成"
|
||||
);
|
||||
while !tasks.is_empty() {
|
||||
await_worker_task(tasks).await;
|
||||
}
|
||||
info!("external generation worker 已完成优雅停机");
|
||||
}
|
||||
|
||||
async fn process_external_generation_job(
|
||||
state: AppState,
|
||||
worker_id: String,
|
||||
lease: Duration,
|
||||
job: ExternalGenerationJobRecord,
|
||||
) -> Result<(), String> {
|
||||
let heartbeat_interval = external_generation_worker_heartbeat_interval(lease);
|
||||
let work = process_external_generation_job_once(state.clone(), worker_id.clone(), job.clone());
|
||||
tokio::pin!(work);
|
||||
let heartbeat = sleep(heartbeat_interval);
|
||||
tokio::pin!(heartbeat);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = &mut work => return result,
|
||||
_ = &mut heartbeat => {
|
||||
renew_job_lease(&state, &worker_id, &job, lease).await?;
|
||||
heartbeat.as_mut().reset(Instant::now() + heartbeat_interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_external_generation_job_once(
|
||||
state: AppState,
|
||||
worker_id: String,
|
||||
job: ExternalGenerationJobRecord,
|
||||
) -> Result<(), String> {
|
||||
match job.job_kind.as_str() {
|
||||
PUZZLE_COMPILE_DRAFT_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<PuzzleCompileDraftWorkerPayload>(
|
||||
job.request_payload_json.as_str(),
|
||||
) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("拼图生成任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = RequestContext::new(
|
||||
format!("external-generation-worker-{}", job.job_id),
|
||||
format!("external-generation-worker {}", job.job_kind),
|
||||
std::time::Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
let puzzle_state = PuzzleApiState::from_ref(&state);
|
||||
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
|
||||
match execute_puzzle_compile_draft_worker_job(
|
||||
&puzzle_state,
|
||||
&request_context,
|
||||
payload,
|
||||
write_guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
complete_job(
|
||||
&state,
|
||||
&worker_id,
|
||||
&job,
|
||||
Some(
|
||||
json!({
|
||||
"sessionId": session.session_id,
|
||||
"progressPercent": session.progress_percent,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message)
|
||||
.await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
PUZZLE_GENERATE_IMAGES_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<PuzzleGenerateImagesWorkerPayload>(
|
||||
job.request_payload_json.as_str(),
|
||||
) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("拼图关卡图片生成任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = RequestContext::new(
|
||||
format!("external-generation-worker-{}", job.job_id),
|
||||
format!("external-generation-worker {}", job.job_kind),
|
||||
std::time::Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
let puzzle_state = PuzzleApiState::from_ref(&state);
|
||||
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
|
||||
match execute_puzzle_generate_images_worker_job(
|
||||
&puzzle_state,
|
||||
&request_context,
|
||||
payload,
|
||||
write_guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
complete_job(
|
||||
&state,
|
||||
&worker_id,
|
||||
&job,
|
||||
Some(
|
||||
json!({
|
||||
"sessionId": session.session_id,
|
||||
"progressPercent": session.progress_percent,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message)
|
||||
.await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND => {
|
||||
let payload = match serde_json::from_str::<PuzzleGenerateUiBackgroundWorkerPayload>(
|
||||
job.request_payload_json.as_str(),
|
||||
) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = format!("拼图 UI 背景图生成任务参数解析失败:{error}");
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let request_context = RequestContext::new(
|
||||
format!("external-generation-worker-{}", job.job_id),
|
||||
format!("external-generation-worker {}", job.job_kind),
|
||||
std::time::Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
let puzzle_state = PuzzleApiState::from_ref(&state);
|
||||
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
|
||||
match execute_puzzle_generate_ui_background_worker_job(
|
||||
&puzzle_state,
|
||||
&request_context,
|
||||
payload,
|
||||
write_guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => {
|
||||
complete_job(
|
||||
&state,
|
||||
&worker_id,
|
||||
&job,
|
||||
Some(
|
||||
json!({
|
||||
"sessionId": session.session_id,
|
||||
"progressPercent": session.progress_percent,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message)
|
||||
.await?;
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
unknown => {
|
||||
warn!(
|
||||
job_id = job.job_id,
|
||||
job_kind = unknown,
|
||||
"external generation worker 收到暂不支持的任务类型"
|
||||
);
|
||||
fail_job(
|
||||
&state,
|
||||
&worker_id,
|
||||
&job,
|
||||
format!("暂不支持的外部生成任务类型:{unknown}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fail_queue_job_after_worker_error(
|
||||
state: &AppState,
|
||||
worker_id: &str,
|
||||
job: &ExternalGenerationJobRecord,
|
||||
error: &crate::puzzle::PuzzleExternalGenerationWorkerError,
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
if error.should_fail_queue_job() {
|
||||
return fail_job(state, worker_id, job, message.to_string()).await;
|
||||
}
|
||||
|
||||
warn!(
|
||||
job_id = job.job_id,
|
||||
job_kind = job.job_kind,
|
||||
"external generation worker 业务失败态尚未写回,保留任务租约等待后续重试"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn complete_job(
|
||||
state: &AppState,
|
||||
worker_id: &str,
|
||||
job: &ExternalGenerationJobRecord,
|
||||
result_payload_json: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.spacetime_client()
|
||||
.complete_external_generation_job(ExternalGenerationJobCompleteRecordInput {
|
||||
job_id: job.job_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
lease_token: require_job_lease_token(job)?,
|
||||
result_payload_json,
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn fail_job(
|
||||
state: &AppState,
|
||||
worker_id: &str,
|
||||
job: &ExternalGenerationJobRecord,
|
||||
error_message: String,
|
||||
) -> Result<(), String> {
|
||||
let now_micros = current_utc_micros();
|
||||
state
|
||||
.spacetime_client()
|
||||
.fail_external_generation_job(ExternalGenerationJobFailRecordInput {
|
||||
job_id: job.job_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
lease_token: require_job_lease_token(job)?,
|
||||
error_message,
|
||||
retry_after_micros: now_micros.saturating_add(60_000_000),
|
||||
failed_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn renew_job_lease(
|
||||
state: &AppState,
|
||||
worker_id: &str,
|
||||
job: &ExternalGenerationJobRecord,
|
||||
lease: Duration,
|
||||
) -> Result<(), String> {
|
||||
let now_micros = current_utc_micros();
|
||||
state
|
||||
.spacetime_client()
|
||||
.renew_external_generation_job_lease(ExternalGenerationJobRenewLeaseRecordInput {
|
||||
job_id: job.job_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
lease_token: require_job_lease_token(job)?,
|
||||
lease_expires_at_micros: now_micros.saturating_add(duration_micros_i64(lease)),
|
||||
renewed_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn require_job_lease_token(job: &ExternalGenerationJobRecord) -> Result<String, String> {
|
||||
job.lease_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("external_generation_job {} 缺少 lease token", job.job_id))
|
||||
}
|
||||
|
||||
fn build_external_generation_write_lease_guard(
|
||||
worker_id: &str,
|
||||
job: &ExternalGenerationJobRecord,
|
||||
) -> Result<ExternalGenerationWriteLeaseGuard, String> {
|
||||
Ok(ExternalGenerationWriteLeaseGuard {
|
||||
job_id: job.job_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
lease_token: require_job_lease_token(job)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn duration_micros_i64(duration: Duration) -> i64 {
|
||||
duration.as_micros().min(i64::MAX as u128) as i64
|
||||
}
|
||||
|
||||
fn external_generation_worker_heartbeat_interval(lease: Duration) -> Duration {
|
||||
let heartbeat_millis = (lease.as_millis() / 3).clamp(250, 30_000) as u64;
|
||||
Duration::from_millis(heartbeat_millis)
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn worker_write_guard_uses_claimed_job_lease_token() {
|
||||
let job = external_generation_job_record_fixture(Some("lease-1"));
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_write_guard_requires_claimed_job_lease_token() {
|
||||
let job = external_generation_job_record_fixture(None);
|
||||
|
||||
let error = build_external_generation_write_lease_guard("worker-a", &job)
|
||||
.expect_err("missing token should fail");
|
||||
|
||||
assert!(error.contains("缺少 lease token"));
|
||||
}
|
||||
|
||||
fn external_generation_job_record_fixture(
|
||||
lease_token: Option<&str>,
|
||||
) -> ExternalGenerationJobRecord {
|
||||
ExternalGenerationJobRecord {
|
||||
job_id: "extgen-1".to_string(),
|
||||
dedupe_key: "puzzle:generate_puzzle_images:session-1:extgen-1".to_string(),
|
||||
job_kind: PUZZLE_GENERATE_IMAGES_JOB_KIND.to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_module: "puzzle".to_string(),
|
||||
source_entity_id: "session-1:puzzle-level-1".to_string(),
|
||||
request_label: "拼图关卡图片生成".to_string(),
|
||||
request_payload_json: "{}".to_string(),
|
||||
status: "running".to_string(),
|
||||
attempt: 1,
|
||||
max_attempts: 1,
|
||||
last_error_message: None,
|
||||
worker_id: Some("worker-a".to_string()),
|
||||
lease_expires_at: Some("2026-06-03T00:00:00Z".to_string()),
|
||||
available_at: "2026-06-03T00:00:00Z".to_string(),
|
||||
result_payload_json: None,
|
||||
created_at: "2026-06-03T00:00:00Z".to_string(),
|
||||
started_at: Some("2026-06-03T00:00:00Z".to_string()),
|
||||
completed_at: None,
|
||||
updated_at: "2026-06-03T00:00:00Z".to_string(),
|
||||
lease_token: lease_token.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ mod edutainment_baby_drawing;
|
||||
mod edutainment_baby_object;
|
||||
mod error_middleware;
|
||||
mod external_api_audit;
|
||||
mod external_generation_worker;
|
||||
pub(crate) mod generated_asset_sheets;
|
||||
mod generated_image_assets;
|
||||
mod health;
|
||||
@@ -114,6 +115,7 @@ use tracing::{error, info, warn};
|
||||
use crate::{
|
||||
app::{build_router, build_spacetime_unavailable_router},
|
||||
config::AppConfig,
|
||||
external_generation_worker::run_external_generation_worker,
|
||||
state::{AppState, AppStateInitError},
|
||||
tracking_outbox::TrackingOutbox,
|
||||
};
|
||||
@@ -164,20 +166,47 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
process_metrics::register_process_metrics();
|
||||
telemetry::register_http_runtime_metrics();
|
||||
|
||||
if !config.process_role.runs_http() {
|
||||
return run_worker_only(config).await;
|
||||
}
|
||||
|
||||
run_http_role(config).await
|
||||
}
|
||||
|
||||
async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
|
||||
let process_role = config.process_role;
|
||||
let state = restore_app_state_for_startup(config)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"初始化 external generation worker 状态失败:{error}"
|
||||
))
|
||||
})?;
|
||||
spawn_app_state_background_workers(&state);
|
||||
info!(
|
||||
process_role = process_role.as_str(),
|
||||
"api-server 以 worker 角色启动"
|
||||
);
|
||||
run_external_generation_worker(state).await
|
||||
}
|
||||
|
||||
async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
|
||||
let bind_address = config.bind_socket_addr();
|
||||
let listen_backlog = config.listen_backlog;
|
||||
let worker_threads = config.worker_threads;
|
||||
let otel_enabled = config.otel_enabled;
|
||||
let process_role = config.process_role;
|
||||
let outbox_flush_timeout = config.shutdown_outbox_flush_timeout;
|
||||
let listener = build_tcp_listener(bind_address, listen_backlog)?;
|
||||
|
||||
let (router, shutdown_context) = match restore_app_state_for_startup(config).await {
|
||||
let (router, shutdown_context, worker_state) = match restore_app_state_for_startup(config).await
|
||||
{
|
||||
Ok(state) => {
|
||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||
spawn_app_state_background_workers(&state);
|
||||
let tracking_outbox = state.tracking_outbox();
|
||||
if let Some(outbox) = tracking_outbox.clone() {
|
||||
outbox.spawn_worker();
|
||||
}
|
||||
let worker_state = process_role
|
||||
.runs_external_generation_worker()
|
||||
.then(|| state.clone());
|
||||
(
|
||||
build_router(state.clone()),
|
||||
ShutdownContext {
|
||||
@@ -185,6 +214,7 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
tracking_outbox,
|
||||
outbox_flush_timeout,
|
||||
},
|
||||
worker_state,
|
||||
)
|
||||
}
|
||||
Err(AppStateInitError::DependencyUnavailable(message)) => (
|
||||
@@ -194,6 +224,7 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
tracking_outbox: None,
|
||||
outbox_flush_timeout,
|
||||
},
|
||||
None,
|
||||
),
|
||||
Err(error) => {
|
||||
return Err(std::io::Error::other(format!(
|
||||
@@ -207,12 +238,20 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
listen_backlog,
|
||||
worker_threads = worker_threads.unwrap_or(0),
|
||||
otel_enabled,
|
||||
process_role = process_role.as_str(),
|
||||
"api-server 已完成 tracing 初始化并开始监听"
|
||||
);
|
||||
|
||||
let result = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown_signal(shutdown_context.clone()))
|
||||
.await;
|
||||
let http_server = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown_signal(shutdown_context.clone()));
|
||||
let result = if let Some(worker_state) = worker_state {
|
||||
tokio::select! {
|
||||
result = http_server => result,
|
||||
result = run_external_generation_worker(worker_state) => result,
|
||||
}
|
||||
} else {
|
||||
http_server.await
|
||||
};
|
||||
finalize_shutdown(shutdown_context).await;
|
||||
result
|
||||
}
|
||||
@@ -304,6 +343,13 @@ async fn finalize_shutdown(context: ShutdownContext) {
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_app_state_background_workers(state: &AppState) {
|
||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||
if let Some(outbox) = state.tracking_outbox() {
|
||||
outbox.spawn_worker();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tcp_listener(
|
||||
bind_address: SocketAddr,
|
||||
listen_backlog: i32,
|
||||
|
||||
@@ -52,21 +52,21 @@ use shared_contracts::{
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::{
|
||||
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
|
||||
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput,
|
||||
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
|
||||
PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
ExternalGenerationJobEnqueueRecordInput, PuzzleAgentMessageRecord,
|
||||
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord,
|
||||
PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleLevelGenerationFailureRecordInput,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
|
||||
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
@@ -78,6 +78,10 @@ use crate::{
|
||||
should_skip_asset_operation_billing_for_connectivity,
|
||||
},
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
external_generation_worker::{
|
||||
PUZZLE_COMPILE_DRAFT_JOB_KIND, PUZZLE_GENERATE_IMAGES_JOB_KIND,
|
||||
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND,
|
||||
},
|
||||
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
|
||||
http_error::AppError,
|
||||
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
|
||||
@@ -130,6 +134,43 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PuzzleExternalGenerationWorkerError {
|
||||
error: AppError,
|
||||
should_fail_queue_job: bool,
|
||||
}
|
||||
|
||||
impl PuzzleExternalGenerationWorkerError {
|
||||
pub(crate) fn with_failure_state_written(error: AppError) -> Self {
|
||||
Self {
|
||||
error,
|
||||
should_fail_queue_job: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_failure_state_pending(error: AppError) -> Self {
|
||||
Self {
|
||||
error,
|
||||
should_fail_queue_job: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn body_text(&self) -> String {
|
||||
self.error.body_text()
|
||||
}
|
||||
|
||||
pub(crate) fn should_fail_queue_job(&self) -> bool {
|
||||
self.should_fail_queue_job
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
|
||||
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
|
||||
}
|
||||
@@ -152,7 +193,7 @@ mod mappers;
|
||||
use self::mappers::*;
|
||||
|
||||
mod draft;
|
||||
use self::draft::*;
|
||||
pub(crate) use self::draft::*;
|
||||
|
||||
mod tags;
|
||||
|
||||
@@ -161,7 +202,7 @@ use self::tags::*;
|
||||
mod generation;
|
||||
mod vector_engine;
|
||||
|
||||
use self::generation::*;
|
||||
pub(crate) use self::generation::*;
|
||||
use self::vector_engine::*;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -137,6 +137,124 @@ pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing(
|
||||
Ok(replacement.session_id)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PuzzleCompileDraftWorkerPayload {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub billing_asset_id: String,
|
||||
pub ai_redraw: bool,
|
||||
#[serde(default)]
|
||||
pub prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_model: Option<String>,
|
||||
pub requested_at_micros: i64,
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_puzzle_compile_draft_worker_job(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
payload: PuzzleCompileDraftWorkerPayload,
|
||||
external_generation_guard: ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
|
||||
let now = current_utc_micros();
|
||||
let session = if payload.ai_redraw {
|
||||
execute_billable_asset_operation_with_cost(
|
||||
state.root_state(),
|
||||
&payload.owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&payload.billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
state,
|
||||
request_context,
|
||||
payload.session_id.clone(),
|
||||
payload.owner_user_id.clone(),
|
||||
payload.prompt_text.as_deref(),
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.image_model.as_deref(),
|
||||
now,
|
||||
&external_generation_guard,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
compile_puzzle_draft_with_uploaded_cover(
|
||||
state,
|
||||
request_context,
|
||||
payload.session_id.clone(),
|
||||
payload.owner_user_id.clone(),
|
||||
payload.prompt_text.as_deref(),
|
||||
payload.reference_image_src.as_deref(),
|
||||
now,
|
||||
&external_generation_guard,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
match session {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error) => {
|
||||
match mark_puzzle_compile_failure_for_worker(
|
||||
state,
|
||||
&payload.session_id,
|
||||
&payload.owner_user_id,
|
||||
error.body_text(),
|
||||
now,
|
||||
&external_generation_guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
|
||||
}
|
||||
Err(mark_error) => {
|
||||
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_puzzle_compile_failure_for_worker(
|
||||
state: &PuzzleApiState,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
error_message: String,
|
||||
failed_at_micros: i64,
|
||||
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<(), AppError> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
|
||||
session_id: session_id.to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
error_message,
|
||||
failed_at_micros,
|
||||
external_generation_job_id: external_generation_guard.job_id.clone(),
|
||||
external_generation_worker_id: external_generation_guard.worker_id.clone(),
|
||||
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
message = %error,
|
||||
"拼图 worker 草稿失败态回写失败"
|
||||
);
|
||||
return Err(map_puzzle_client_error(error));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn select_puzzle_level_for_api(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
level_id: Option<&str>,
|
||||
@@ -1186,10 +1304,18 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
reference_image_src: Option<&str>,
|
||||
image_model: Option<&str>,
|
||||
now: i64,
|
||||
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let compiled_session = state
|
||||
.spacetime_client()
|
||||
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
|
||||
.compile_puzzle_agent_draft_with_external_generation_guard(
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
now,
|
||||
external_generation_guard.job_id.clone(),
|
||||
external_generation_guard.worker_id.clone(),
|
||||
external_generation_guard.lease_token.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_compile_error)?;
|
||||
let draft = compiled_session.draft.clone().ok_or_else(|| {
|
||||
@@ -1332,7 +1458,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
"message": format!("拼图候选图序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let (saved_session, save_used_fallback) = state
|
||||
let saved_session = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
@@ -1341,42 +1467,12 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
levels_json: levels_json_with_generated_name.clone(),
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
external_generation_job_id: external_generation_guard.job_id.clone(),
|
||||
external_generation_worker_id: external_generation_guard.worker_id.clone(),
|
||||
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
.map(|session| (session, false))
|
||||
.or_else(|error| {
|
||||
if is_spacetimedb_connectivity_app_error(&error) {
|
||||
// 中文注释:首图已落 OSS 时,SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compiled_session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
message = %error.body_text(),
|
||||
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||
);
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
apply_generated_puzzle_levels_to_session_snapshot(
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
updated_levels.clone(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
candidates.into_records(),
|
||||
reference_image_src,
|
||||
now,
|
||||
);
|
||||
Ok((session, true))
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
match state
|
||||
.spacetime_client()
|
||||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||
@@ -1413,9 +1509,6 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
);
|
||||
if save_used_fallback {
|
||||
return Ok(saved_session);
|
||||
}
|
||||
match state
|
||||
.spacetime_client()
|
||||
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
||||
@@ -1454,6 +1547,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
prompt_text: Option<&str>,
|
||||
reference_image_src: Option<&str>,
|
||||
now: i64,
|
||||
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let uploaded_image_src = reference_image_src
|
||||
.map(str::trim)
|
||||
@@ -1488,7 +1582,14 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
})?;
|
||||
let compiled_session = state
|
||||
.spacetime_client()
|
||||
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
|
||||
.compile_puzzle_agent_draft_with_external_generation_guard(
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
now,
|
||||
external_generation_guard.job_id.clone(),
|
||||
external_generation_guard.worker_id.clone(),
|
||||
external_generation_guard.lease_token.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_compile_error)?;
|
||||
let draft = compiled_session.draft.clone().ok_or_else(|| {
|
||||
@@ -1628,7 +1729,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
"message": format!("拼图上传图候选序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let (saved_session, save_used_fallback) = state
|
||||
let saved_session = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
@@ -1637,41 +1738,12 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
levels_json: levels_json_with_generated_name.clone(),
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
external_generation_job_id: external_generation_guard.job_id.clone(),
|
||||
external_generation_worker_id: external_generation_guard.worker_id.clone(),
|
||||
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
.map(|session| (session, false))
|
||||
.or_else(|error| {
|
||||
if is_spacetimedb_connectivity_app_error(&error) {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compiled_session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
message = %error.body_text(),
|
||||
"拼图上传图草稿回写不可用,降级返回本地快照"
|
||||
);
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
apply_generated_puzzle_levels_to_session_snapshot(
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
updated_levels.clone(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
vec![candidate.clone()],
|
||||
reference_image_src,
|
||||
now,
|
||||
);
|
||||
Ok((session, true))
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
|
||||
match state
|
||||
.spacetime_client()
|
||||
@@ -1709,9 +1781,6 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
);
|
||||
if save_used_fallback {
|
||||
return Ok(saved_session);
|
||||
}
|
||||
match state
|
||||
.spacetime_client()
|
||||
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
||||
@@ -1742,6 +1811,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
@@ -1794,23 +1864,7 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_levels_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
levels: Vec<PuzzleDraftLevelRecord>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
if levels.is_empty() {
|
||||
return session;
|
||||
}
|
||||
draft.levels = levels;
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
@@ -1863,45 +1917,6 @@ pub(crate) fn apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_metadata_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
previous_level_name: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
|
||||
draft.levels[target_index].level_name = metadata.level_name.clone();
|
||||
if metadata.ui_background_prompt.is_some() {
|
||||
draft.levels[target_index].ui_background_prompt = metadata.ui_background_prompt.clone();
|
||||
}
|
||||
|
||||
if target_index == 0 {
|
||||
apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft,
|
||||
metadata,
|
||||
previous_level_name,
|
||||
updated_at_micros,
|
||||
);
|
||||
} else {
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft: &mut PuzzleResultDraftRecord,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
@@ -1951,45 +1966,3 @@ pub(crate) fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResu
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replace_puzzle_session_draft_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
draft: PuzzleResultDraftRecord,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
session.draft = Some(draft);
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_ui_background_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
prompt: String,
|
||||
image_src: String,
|
||||
image_object_key: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
let level = &mut draft.levels[target_index];
|
||||
level.ui_background_prompt = Some(prompt);
|
||||
level.ui_background_image_src = Some(image_src);
|
||||
level.ui_background_image_object_key = image_object_key;
|
||||
if target_index == 0 {
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
session.progress_percent = session.progress_percent.max(96);
|
||||
session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string());
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
@@ -22,6 +22,510 @@ pub(crate) fn should_use_uploaded_puzzle_image_directly(
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PuzzleGenerateImagesWorkerPayload {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub billing_asset_id: String,
|
||||
#[serde(default)]
|
||||
pub level_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_srcs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_asset_object_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_asset_object_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub image_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ai_redraw: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub should_auto_name_level: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub work_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub work_description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub picture_description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub theme_tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub levels_json: Option<String>,
|
||||
pub requested_at_micros: i64,
|
||||
}
|
||||
|
||||
impl PuzzleGenerateImagesWorkerPayload {
|
||||
fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest {
|
||||
ExecutePuzzleAgentActionRequest {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: self.prompt_text.clone(),
|
||||
reference_image_src: self.reference_image_src.clone(),
|
||||
reference_image_srcs: self.reference_image_srcs.clone(),
|
||||
reference_image_asset_object_id: self.reference_image_asset_object_id.clone(),
|
||||
reference_image_asset_object_ids: self.reference_image_asset_object_ids.clone(),
|
||||
image_model: self.image_model.clone(),
|
||||
ai_redraw: self.ai_redraw,
|
||||
candidate_count: Some(1),
|
||||
should_auto_name_level: self.should_auto_name_level,
|
||||
candidate_id: None,
|
||||
level_id: self.level_id.clone(),
|
||||
work_title: self.work_title.clone(),
|
||||
work_description: self.work_description.clone(),
|
||||
picture_description: self.picture_description.clone(),
|
||||
level_name: None,
|
||||
summary: self.summary.clone(),
|
||||
theme_tags: self.theme_tags.clone(),
|
||||
levels_json: self.levels_json.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PuzzleGenerateUiBackgroundWorkerPayload {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub billing_asset_id: String,
|
||||
#[serde(default)]
|
||||
pub level_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub levels_json: Option<String>,
|
||||
pub requested_at_micros: i64,
|
||||
}
|
||||
|
||||
impl PuzzleGenerateUiBackgroundWorkerPayload {
|
||||
fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest {
|
||||
ExecutePuzzleAgentActionRequest {
|
||||
action: "generate_puzzle_ui_background".to_string(),
|
||||
prompt_text: self.prompt_text.clone(),
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_image_asset_object_id: None,
|
||||
reference_image_asset_object_ids: Vec::new(),
|
||||
image_model: None,
|
||||
ai_redraw: None,
|
||||
candidate_count: None,
|
||||
should_auto_name_level: None,
|
||||
candidate_id: None,
|
||||
level_id: self.level_id.clone(),
|
||||
work_title: None,
|
||||
work_description: None,
|
||||
picture_description: None,
|
||||
level_name: None,
|
||||
summary: None,
|
||||
theme_tags: None,
|
||||
levels_json: self.levels_json.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_puzzle_generate_images_worker_job(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
payload: PuzzleGenerateImagesWorkerPayload,
|
||||
external_generation_guard: ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
|
||||
let now = current_utc_micros();
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
state.root_state(),
|
||||
&payload.owner_user_id,
|
||||
"puzzle_generated_image",
|
||||
&payload.billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
execute_puzzle_generate_images_worker_job_inner(
|
||||
state,
|
||||
request_context,
|
||||
&payload,
|
||||
now,
|
||||
&external_generation_guard,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match session {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error) => {
|
||||
match mark_puzzle_level_generation_failure_for_worker(
|
||||
state,
|
||||
&payload,
|
||||
error.body_text(),
|
||||
now,
|
||||
&external_generation_guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
|
||||
}
|
||||
Err(mark_error) => {
|
||||
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_puzzle_generate_ui_background_worker_job(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
payload: PuzzleGenerateUiBackgroundWorkerPayload,
|
||||
external_generation_guard: ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
|
||||
let now = current_utc_micros();
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
state.root_state(),
|
||||
&payload.owner_user_id,
|
||||
"puzzle_ui_background_image",
|
||||
&payload.billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
execute_puzzle_generate_ui_background_worker_job_inner(
|
||||
state,
|
||||
request_context,
|
||||
&payload,
|
||||
now,
|
||||
&external_generation_guard,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match session {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error) => {
|
||||
match mark_puzzle_level_generation_failure_for_external_generation(
|
||||
state,
|
||||
&payload.session_id,
|
||||
&payload.owner_user_id,
|
||||
payload.level_id.clone(),
|
||||
payload.levels_json.clone(),
|
||||
error.body_text(),
|
||||
now,
|
||||
&external_generation_guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
|
||||
}
|
||||
Err(mark_error) => {
|
||||
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_puzzle_generate_images_worker_job_inner(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
payload: &PuzzleGenerateImagesWorkerPayload,
|
||||
now: i64,
|
||||
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let action_payload = payload.to_action_request();
|
||||
let target_level_id = payload.level_id.clone();
|
||||
let levels_json = payload.levels_json.clone();
|
||||
let session = get_puzzle_session_for_image_generation(
|
||||
state,
|
||||
payload.session_id.clone(),
|
||||
payload.owner_user_id.clone(),
|
||||
&action_payload,
|
||||
levels_json.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
let mut draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
if let Some(levels_json) = levels_json.as_ref() {
|
||||
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
||||
}
|
||||
let mut target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
|
||||
let prompt = resolve_puzzle_level_image_prompt(
|
||||
payload.prompt_text.as_deref(),
|
||||
&target_level.picture_description,
|
||||
&draft.summary,
|
||||
);
|
||||
let should_auto_name_level = payload
|
||||
.should_auto_name_level
|
||||
.unwrap_or_else(|| target_level.level_name.trim().is_empty());
|
||||
if should_auto_name_level {
|
||||
let naming =
|
||||
generate_puzzle_first_level_name(state, target_level.picture_description.as_str())
|
||||
.await;
|
||||
target_level.level_name = naming.level_name.clone();
|
||||
target_level.ui_background_prompt = naming.ui_background_prompt.clone();
|
||||
}
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
payload.reference_image_asset_object_id.as_deref(),
|
||||
payload.reference_image_asset_object_ids.as_slice(),
|
||||
);
|
||||
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
|
||||
// 中文注释:拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_start_index = target_level.candidates.len();
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
let mut candidates =
|
||||
if should_use_uploaded_puzzle_image_directly(primary_reference_image_src, ai_redraw) {
|
||||
vec![
|
||||
create_uploaded_puzzle_image_candidate(
|
||||
state,
|
||||
payload.owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
&prompt,
|
||||
primary_reference_image_src.expect("checked reference image"),
|
||||
candidate_start_index,
|
||||
)
|
||||
.await?,
|
||||
]
|
||||
} else {
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id);
|
||||
generate_puzzle_image_candidates(
|
||||
state,
|
||||
payload.owner_user_id.as_str(),
|
||||
Some(profile_id.as_str()),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
&prompt,
|
||||
primary_reference_image_src,
|
||||
ai_redraw,
|
||||
payload.image_model.as_deref(),
|
||||
1,
|
||||
candidate_start_index,
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?
|
||||
};
|
||||
if candidates.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图候选图生成结果为空",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
|
||||
state,
|
||||
target_level.picture_description.as_str(),
|
||||
&candidates[0].downloaded_image,
|
||||
)
|
||||
.await
|
||||
.filter(|_| should_auto_name_level)
|
||||
{
|
||||
target_level.level_name = refined_naming.level_name.clone();
|
||||
if refined_naming.ui_background_prompt.is_some() {
|
||||
target_level.ui_background_prompt = refined_naming.ui_background_prompt.clone();
|
||||
}
|
||||
}
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, primary_reference_image_src);
|
||||
for candidate in &mut candidates {
|
||||
candidate.record.prompt = prompt.clone();
|
||||
}
|
||||
let selected_candidate = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.record.selected)
|
||||
.or_else(|| candidates.first())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图候选图生成结果为空",
|
||||
}))
|
||||
})?;
|
||||
let asset_bundle = generate_puzzle_level_asset_bundle_required(
|
||||
state,
|
||||
request_context,
|
||||
payload.owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level,
|
||||
&selected_candidate.downloaded_image,
|
||||
)
|
||||
.await?;
|
||||
attach_puzzle_level_asset_bundle(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
asset_bundle,
|
||||
);
|
||||
attach_selected_puzzle_candidate_to_levels(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
&selected_candidate.record,
|
||||
);
|
||||
let levels_json_with_generated_name =
|
||||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
.map(|candidate| to_puzzle_generated_image_candidate(&candidate.record))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图候选图序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: payload.owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: levels_json_with_generated_name,
|
||||
candidates_json,
|
||||
saved_at_micros: now,
|
||||
external_generation_job_id: external_generation_guard.job_id.clone(),
|
||||
external_generation_worker_id: external_generation_guard.worker_id.clone(),
|
||||
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
}
|
||||
|
||||
async fn execute_puzzle_generate_ui_background_worker_job_inner(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
payload: &PuzzleGenerateUiBackgroundWorkerPayload,
|
||||
now: i64,
|
||||
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let action_payload = payload.to_action_request();
|
||||
let target_level_id = payload.level_id.clone();
|
||||
let levels_json = payload.levels_json.clone();
|
||||
let session = get_puzzle_session_for_image_generation(
|
||||
state,
|
||||
payload.session_id.clone(),
|
||||
payload.owner_user_id.clone(),
|
||||
&action_payload,
|
||||
levels_json.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
let mut draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
if let Some(levels_json) = levels_json.as_ref() {
|
||||
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
||||
}
|
||||
let target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
|
||||
let raw_prompt = payload
|
||||
.prompt_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let resolved_prompt =
|
||||
normalize_puzzle_ui_background_prompt(raw_prompt.as_str(), &draft, &target_level);
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
state,
|
||||
request_context,
|
||||
payload.owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
resolved_prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: payload.owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json,
|
||||
prompt: resolved_prompt.clone(),
|
||||
image_src: generated.image_src.clone(),
|
||||
image_object_key: Some(generated.object_key.clone()),
|
||||
saved_at_micros: now,
|
||||
external_generation_job_id: external_generation_guard.job_id.clone(),
|
||||
external_generation_worker_id: external_generation_guard.worker_id.clone(),
|
||||
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_puzzle_level_generation_failure_for_worker(
|
||||
state: &PuzzleApiState,
|
||||
payload: &PuzzleGenerateImagesWorkerPayload,
|
||||
error_message: String,
|
||||
failed_at_micros: i64,
|
||||
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<(), AppError> {
|
||||
mark_puzzle_level_generation_failure_for_external_generation(
|
||||
state,
|
||||
&payload.session_id,
|
||||
&payload.owner_user_id,
|
||||
payload.level_id.clone(),
|
||||
payload.levels_json.clone(),
|
||||
error_message,
|
||||
failed_at_micros,
|
||||
external_generation_guard,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mark_puzzle_level_generation_failure_for_external_generation(
|
||||
state: &PuzzleApiState,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
level_id: Option<String>,
|
||||
levels_json: Option<String>,
|
||||
error_message: String,
|
||||
failed_at_micros: i64,
|
||||
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<(), AppError> {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.mark_puzzle_level_generation_failed(PuzzleLevelGenerationFailureRecordInput {
|
||||
session_id: session_id.to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
level_id,
|
||||
levels_json,
|
||||
error_message,
|
||||
failed_at_micros,
|
||||
external_generation_job_id: external_generation_guard.job_id.clone(),
|
||||
external_generation_worker_id: external_generation_guard.worker_id.clone(),
|
||||
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
message = %error,
|
||||
"拼图 worker 关卡生图失败态回写失败"
|
||||
);
|
||||
return Err(map_puzzle_client_error(error));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn create_uploaded_puzzle_image_candidate(
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
|
||||
@@ -609,35 +609,6 @@ pub async fn execute_puzzle_agent_action(
|
||||
"拼图 Agent action 开始执行"
|
||||
);
|
||||
|
||||
let mark_puzzle_compile_failure = |error: &AppError, compile_session_id: &str| {
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let error_message = error.body_text();
|
||||
let session_id = compile_session_id.to_string();
|
||||
let log_session_id = session_id.clone();
|
||||
let log_owner_user_id = owner_user_id.clone();
|
||||
async move {
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
error_message,
|
||||
failed_at_micros: now,
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %log_session_id,
|
||||
owner_user_id = %log_owner_user_id,
|
||||
message = %error,
|
||||
"拼图草稿失败态回写失败,继续返回原始错误"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
@@ -667,61 +638,88 @@ pub async fn execute_puzzle_agent_action(
|
||||
Ok(next_session_id) => next_session_id,
|
||||
Err(response) => return Err(response),
|
||||
};
|
||||
let session = if ai_redraw {
|
||||
execute_billable_asset_operation_with_cost(
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
&request_context,
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
primary_reference_image_src,
|
||||
payload.image_model.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
compile_puzzle_draft_with_uploaded_cover(
|
||||
&state,
|
||||
&request_context,
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
primary_reference_image_src,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
let worker_payload = PuzzleCompileDraftWorkerPayload {
|
||||
session_id: compile_session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
billing_asset_id: billing_asset_id.clone(),
|
||||
ai_redraw,
|
||||
prompt_text: prompt_text.map(ToOwned::to_owned),
|
||||
reference_image_src: primary_reference_image_src.map(ToOwned::to_owned),
|
||||
image_model: payload.image_model.clone(),
|
||||
requested_at_micros: now,
|
||||
};
|
||||
let session = match session {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error) => {
|
||||
mark_puzzle_compile_failure(&error, &compile_session_id).await;
|
||||
Err(puzzle_error_response(
|
||||
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
error,
|
||||
))
|
||||
}
|
||||
};
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"首关拼图草稿",
|
||||
if ai_redraw {
|
||||
"已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。"
|
||||
} else {
|
||||
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
|
||||
},
|
||||
session,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图生成任务参数序列化失败:{error}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let external_generation_job_id = build_prefixed_uuid_id("extgen-");
|
||||
let job = state
|
||||
.spacetime_client()
|
||||
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
|
||||
job_id: external_generation_job_id.clone(),
|
||||
dedupe_key: format!(
|
||||
"puzzle:compile_puzzle_draft:{compile_session_id}:{external_generation_job_id}"
|
||||
),
|
||||
job_kind: PUZZLE_COMPILE_DRAFT_JOB_KIND.to_string(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
source_module: "puzzle".to_string(),
|
||||
source_entity_id: compile_session_id.clone(),
|
||||
request_label: "拼图首关草稿生成".to_string(),
|
||||
request_payload_json,
|
||||
max_attempts: 1,
|
||||
available_at_micros: now,
|
||||
created_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let (status, progress) = match job.status.as_str() {
|
||||
"completed" => ("completed", 100),
|
||||
"running" => ("running", session.progress_percent.max(10)),
|
||||
"failed" => ("failed", session.progress_percent),
|
||||
_ => ("queued", session.progress_percent.max(5)),
|
||||
};
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
operation: PuzzleAgentOperationResponse {
|
||||
operation_id: job.job_id,
|
||||
operation_type: "compile_puzzle_draft".to_string(),
|
||||
status: status.to_string(),
|
||||
phase_label: "首关拼图草稿".to_string(),
|
||||
phase_detail: if ai_redraw {
|
||||
"首关草稿生成已进入后台队列。".to_string()
|
||||
} else {
|
||||
"首关草稿编译已进入后台队列。".to_string()
|
||||
},
|
||||
progress,
|
||||
error: job.last_error_message,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
));
|
||||
}
|
||||
"save_puzzle_form_draft" => {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
@@ -783,367 +781,205 @@ pub async fn execute_puzzle_agent_action(
|
||||
payload.levels_json.as_deref(),
|
||||
)
|
||||
.map_err(|message| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": message,
|
||||
}))
|
||||
});
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_generated_image",
|
||||
&billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
let levels_json = levels_json?;
|
||||
let session = get_puzzle_session_for_image_generation(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
&payload,
|
||||
levels_json.as_deref(),
|
||||
now,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let mut draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
if let Some(levels_json) = levels_json.as_ref() {
|
||||
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
||||
}
|
||||
let mut target_level =
|
||||
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
|
||||
let fallback_level_name = target_level.level_name.clone();
|
||||
let prompt = resolve_puzzle_level_image_prompt(
|
||||
payload.prompt_text.as_deref(),
|
||||
&target_level.picture_description,
|
||||
&draft.summary,
|
||||
);
|
||||
let should_auto_name_level = payload
|
||||
.should_auto_name_level
|
||||
.unwrap_or_else(|| target_level.level_name.trim().is_empty());
|
||||
let mut generated_naming = if should_auto_name_level {
|
||||
let naming = generate_puzzle_first_level_name(
|
||||
&state,
|
||||
target_level.picture_description.as_str(),
|
||||
)
|
||||
.await;
|
||||
target_level.level_name = naming.level_name.clone();
|
||||
target_level.ui_background_prompt = naming.ui_background_prompt.clone();
|
||||
Some(naming)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
payload.reference_image_asset_object_id.as_deref(),
|
||||
payload.reference_image_asset_object_ids.as_slice(),
|
||||
);
|
||||
let primary_reference_image_src =
|
||||
reference_image_sources.first().map(String::as_str);
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_start_index = target_level.candidates.len();
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
let mut candidates = if should_use_uploaded_puzzle_image_directly(
|
||||
primary_reference_image_src,
|
||||
ai_redraw,
|
||||
) {
|
||||
vec![
|
||||
create_uploaded_puzzle_image_candidate(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
&prompt,
|
||||
primary_reference_image_src.expect("checked reference image"),
|
||||
candidate_start_index,
|
||||
)
|
||||
.await?,
|
||||
]
|
||||
} else {
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id);
|
||||
generate_puzzle_image_candidates(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
Some(profile_id.as_str()),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
&prompt,
|
||||
primary_reference_image_src,
|
||||
ai_redraw,
|
||||
payload.image_model.as_deref(),
|
||||
1,
|
||||
candidate_start_index,
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?
|
||||
};
|
||||
if candidates.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
|
||||
json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图候选图生成结果为空",
|
||||
}),
|
||||
));
|
||||
}
|
||||
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
|
||||
&state,
|
||||
target_level.picture_description.as_str(),
|
||||
&candidates[0].downloaded_image,
|
||||
)
|
||||
.await
|
||||
.filter(|_| should_auto_name_level)
|
||||
{
|
||||
target_level.level_name = refined_naming.level_name.clone();
|
||||
if refined_naming.ui_background_prompt.is_some() {
|
||||
target_level.ui_background_prompt =
|
||||
refined_naming.ui_background_prompt.clone();
|
||||
}
|
||||
generated_naming = Some(refined_naming);
|
||||
}
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let mut updated_levels = build_puzzle_levels_with_primary_update(
|
||||
&draft,
|
||||
&target_level,
|
||||
primary_reference_image_src,
|
||||
);
|
||||
for candidate in &mut candidates {
|
||||
candidate.record.prompt = prompt.clone();
|
||||
}
|
||||
let selected_candidate = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.record.selected)
|
||||
.or_else(|| candidates.first())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图候选图生成结果为空",
|
||||
}))
|
||||
})?;
|
||||
let asset_bundle = generate_puzzle_level_asset_bundle_required(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level,
|
||||
&selected_candidate.downloaded_image,
|
||||
)
|
||||
.await?;
|
||||
attach_puzzle_level_asset_bundle(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
asset_bundle,
|
||||
);
|
||||
attach_selected_puzzle_candidate_to_levels(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
&selected_candidate.record,
|
||||
);
|
||||
let levels_json_with_generated_name =
|
||||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
.map(|candidate| to_puzzle_generated_image_candidate(&candidate.record))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图候选图序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let save_result = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: session.session_id.clone(),
|
||||
let worker_payload = PuzzleGenerateImagesWorkerPayload {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: levels_json_with_generated_name,
|
||||
candidates_json,
|
||||
saved_at_micros: now,
|
||||
})
|
||||
.await;
|
||||
match save_result {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error)
|
||||
if should_skip_asset_operation_billing_for_connectivity(&error) =>
|
||||
{
|
||||
// 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
error = %error,
|
||||
"拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||
);
|
||||
let fallback_session =
|
||||
replace_puzzle_session_draft_snapshot(session, draft, now);
|
||||
let fallback_session = if should_auto_name_level {
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
fallback_session,
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
)
|
||||
} else {
|
||||
fallback_session
|
||||
billing_asset_id: billing_asset_id.clone(),
|
||||
level_id: target_level_id.clone(),
|
||||
prompt_text: payload.prompt_text.clone(),
|
||||
reference_image_src: payload.reference_image_src.clone(),
|
||||
reference_image_srcs: payload.reference_image_srcs.clone(),
|
||||
reference_image_asset_object_id: payload.reference_image_asset_object_id.clone(),
|
||||
reference_image_asset_object_ids: payload.reference_image_asset_object_ids.clone(),
|
||||
image_model: payload.image_model.clone(),
|
||||
ai_redraw: payload.ai_redraw,
|
||||
should_auto_name_level: payload.should_auto_name_level,
|
||||
work_title: payload.work_title.clone(),
|
||||
work_description: payload.work_description.clone(),
|
||||
picture_description: payload.picture_description.clone(),
|
||||
summary: payload.summary.clone(),
|
||||
theme_tags: payload.theme_tags.clone(),
|
||||
levels_json,
|
||||
requested_at_micros: now,
|
||||
};
|
||||
let mut fallback_session =
|
||||
apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
apply_generated_puzzle_levels_to_session_snapshot(
|
||||
fallback_session,
|
||||
updated_levels,
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
candidates.into_records(),
|
||||
primary_reference_image_src,
|
||||
now,
|
||||
);
|
||||
if let Some(generated_naming) = generated_naming.as_ref() {
|
||||
fallback_session =
|
||||
apply_generated_puzzle_metadata_to_session_snapshot(
|
||||
fallback_session,
|
||||
target_level.level_id.as_str(),
|
||||
generated_naming,
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
);
|
||||
}
|
||||
Ok(fallback_session)
|
||||
}
|
||||
Err(error) => Err(map_puzzle_client_error(error)),
|
||||
}
|
||||
},
|
||||
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图关卡图片生成任务参数序列化失败:{error}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let external_generation_job_id = build_prefixed_uuid_id("extgen-");
|
||||
let source_entity_id = target_level_id
|
||||
.as_deref()
|
||||
.map(|level_id| format!("{session_id}:{level_id}"))
|
||||
.unwrap_or_else(|| session_id.clone());
|
||||
let job = state
|
||||
.spacetime_client()
|
||||
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
|
||||
job_id: external_generation_job_id.clone(),
|
||||
dedupe_key: format!(
|
||||
"puzzle:generate_puzzle_images:{session_id}:{external_generation_job_id}"
|
||||
),
|
||||
job_kind: PUZZLE_GENERATE_IMAGES_JOB_KIND.to_string(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
source_module: "puzzle".to_string(),
|
||||
source_entity_id,
|
||||
request_label: "拼图关卡图片生成".to_string(),
|
||||
request_payload_json,
|
||||
max_attempts: 1,
|
||||
available_at_micros: now,
|
||||
created_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
});
|
||||
(
|
||||
"generate_puzzle_images",
|
||||
"拼图图片生成",
|
||||
"已生成并替换当前拼图图片。",
|
||||
session,
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let (status, progress) = match job.status.as_str() {
|
||||
"completed" => ("completed", 100),
|
||||
"running" => ("running", 35),
|
||||
"failed" => ("failed", 0),
|
||||
_ => ("queued", 8),
|
||||
};
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
operation: PuzzleAgentOperationResponse {
|
||||
operation_id: job.job_id,
|
||||
operation_type: "generate_puzzle_images".to_string(),
|
||||
status: status.to_string(),
|
||||
phase_label: "拼图图片生成".to_string(),
|
||||
phase_detail: "关卡图片生成已进入后台队列。".to_string(),
|
||||
progress,
|
||||
error: job.last_error_message,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
));
|
||||
}
|
||||
"generate_puzzle_ui_background" => {
|
||||
let target_level_id = payload.level_id.clone();
|
||||
let raw_prompt = payload
|
||||
.prompt_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let levels_json = normalize_puzzle_levels_json_for_module(
|
||||
payload.levels_json.as_deref(),
|
||||
)
|
||||
.map_err(|message| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": message,
|
||||
}))
|
||||
});
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_ui_background_image",
|
||||
&billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
let levels_json = levels_json?;
|
||||
let session = get_puzzle_session_for_image_generation(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
&payload,
|
||||
levels_json.as_deref(),
|
||||
now,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let mut draft = session.draft.clone().ok_or_else(|| {
|
||||
})?;
|
||||
let worker_payload = PuzzleGenerateUiBackgroundWorkerPayload {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
billing_asset_id: billing_asset_id.clone(),
|
||||
level_id: target_level_id.clone(),
|
||||
prompt_text: payload.prompt_text.clone(),
|
||||
levels_json,
|
||||
requested_at_micros: now,
|
||||
};
|
||||
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
"message": format!("拼图 UI 背景图生成任务参数序列化失败:{error}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
if let Some(levels_json) = levels_json.as_ref() {
|
||||
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
||||
}
|
||||
let target_level =
|
||||
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
|
||||
let resolved_prompt = normalize_puzzle_ui_background_prompt(
|
||||
raw_prompt.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
);
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
resolved_prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
let save_result = state
|
||||
let external_generation_job_id = build_prefixed_uuid_id("extgen-");
|
||||
let source_entity_id = target_level_id
|
||||
.as_deref()
|
||||
.map(|level_id| format!("{session_id}:{level_id}"))
|
||||
.unwrap_or_else(|| session_id.clone());
|
||||
let job = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
|
||||
session_id: session.session_id.clone(),
|
||||
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
|
||||
job_id: external_generation_job_id.clone(),
|
||||
dedupe_key: format!(
|
||||
"puzzle:generate_puzzle_ui_background:{session_id}:{external_generation_job_id}"
|
||||
),
|
||||
job_kind: PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND.to_string(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json,
|
||||
prompt: resolved_prompt.clone(),
|
||||
image_src: generated.image_src.clone(),
|
||||
image_object_key: Some(generated.object_key.clone()),
|
||||
saved_at_micros: now,
|
||||
source_module: "puzzle".to_string(),
|
||||
source_entity_id,
|
||||
request_label: "拼图 UI 背景图生成".to_string(),
|
||||
request_payload_json,
|
||||
max_attempts: 1,
|
||||
available_at_micros: now,
|
||||
created_at_micros: now,
|
||||
})
|
||||
.await;
|
||||
match save_result {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error)
|
||||
if should_skip_asset_operation_billing_for_connectivity(&error) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
error = %error,
|
||||
"拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||
);
|
||||
let fallback_session =
|
||||
replace_puzzle_session_draft_snapshot(session, draft, now);
|
||||
Ok(apply_generated_puzzle_ui_background_to_session_snapshot(
|
||||
fallback_session,
|
||||
target_level.level_id.as_str(),
|
||||
resolved_prompt,
|
||||
generated.image_src,
|
||||
Some(generated.object_key),
|
||||
now,
|
||||
))
|
||||
}
|
||||
Err(error) => Err(map_puzzle_client_error(error)),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
});
|
||||
(
|
||||
"generate_puzzle_ui_background",
|
||||
"UI 背景图生成",
|
||||
"已生成拼图 UI 背景图。",
|
||||
session,
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let (status, progress) = match job.status.as_str() {
|
||||
"completed" => ("completed", 100),
|
||||
"running" => ("running", session.progress_percent.max(55)),
|
||||
"failed" => ("failed", session.progress_percent),
|
||||
_ => ("queued", session.progress_percent.max(12)),
|
||||
};
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
operation: PuzzleAgentOperationResponse {
|
||||
operation_id: job.job_id,
|
||||
operation_type: "generate_puzzle_ui_background".to_string(),
|
||||
status: status.to_string(),
|
||||
phase_label: "UI 背景图生成".to_string(),
|
||||
phase_detail: "拼图 UI 背景图生成已进入后台队列。".to_string(),
|
||||
progress,
|
||||
error: job.last_error_message,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
));
|
||||
}
|
||||
"generate_puzzle_tags" => {
|
||||
let work_title = payload
|
||||
|
||||
@@ -484,6 +484,108 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_generate_images_worker_payload_keeps_action_snapshot() {
|
||||
let raw_levels_json = serde_json::to_string(&vec![json!({
|
||||
"levelId": "puzzle-level-2",
|
||||
"levelName": "",
|
||||
"pictureDescription": "新关卡里有一座发光钟楼。",
|
||||
"candidates": [],
|
||||
"selectedCandidateId": null,
|
||||
"coverImageSrc": null,
|
||||
"coverAssetId": null,
|
||||
"generationStatus": "generating",
|
||||
})])
|
||||
.expect("levels json");
|
||||
let levels_json = normalize_puzzle_levels_json_for_module(Some(raw_levels_json.as_str()))
|
||||
.expect("levels should normalize")
|
||||
.expect("levels json should exist");
|
||||
let payload = PuzzleGenerateImagesWorkerPayload {
|
||||
session_id: "puzzle-session-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
billing_asset_id: "puzzle-session-1:123".to_string(),
|
||||
level_id: Some("puzzle-level-2".to_string()),
|
||||
prompt_text: Some("发光钟楼".to_string()),
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: vec!["data:image/png;base64,abc".to_string()],
|
||||
reference_image_asset_object_id: Some("asset-object-1".to_string()),
|
||||
reference_image_asset_object_ids: vec!["asset-object-2".to_string()],
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: Some(true),
|
||||
should_auto_name_level: Some(true),
|
||||
work_title: Some("暖灯猫街作品".to_string()),
|
||||
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
|
||||
picture_description: None,
|
||||
summary: Some("一套雨夜猫街主题拼图。".to_string()),
|
||||
theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]),
|
||||
levels_json: Some(levels_json.clone()),
|
||||
requested_at_micros: 123,
|
||||
};
|
||||
|
||||
let encoded = serde_json::to_string(&payload).expect("payload should serialize");
|
||||
let decoded: PuzzleGenerateImagesWorkerPayload =
|
||||
serde_json::from_str(encoded.as_str()).expect("payload should deserialize");
|
||||
|
||||
assert_eq!(decoded.level_id.as_deref(), Some("puzzle-level-2"));
|
||||
assert_eq!(decoded.reference_image_srcs.len(), 1);
|
||||
assert_eq!(
|
||||
decoded.reference_image_asset_object_ids,
|
||||
vec!["asset-object-2".to_string()]
|
||||
);
|
||||
assert_eq!(decoded.should_auto_name_level, Some(true));
|
||||
let records = parse_puzzle_level_records_from_module_json(
|
||||
decoded.levels_json.as_deref().expect("levels json"),
|
||||
)
|
||||
.expect("levels should parse as module json");
|
||||
assert_eq!(records[0].level_id, "puzzle-level-2");
|
||||
assert_eq!(records[0].generation_status, "generating");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_generate_ui_background_worker_payload_keeps_action_snapshot() {
|
||||
let raw_levels_json = serde_json::to_string(&vec![json!({
|
||||
"levelId": "puzzle-level-3",
|
||||
"levelName": "钟楼回廊",
|
||||
"pictureDescription": "新关卡里有一座发光钟楼。",
|
||||
"uiBackgroundPrompt": "发光钟楼延展成竖屏回廊,远处有暖色窗光。",
|
||||
"candidates": [],
|
||||
"selectedCandidateId": null,
|
||||
"coverImageSrc": null,
|
||||
"coverAssetId": null,
|
||||
"generationStatus": "generating",
|
||||
})])
|
||||
.expect("levels json");
|
||||
let levels_json = normalize_puzzle_levels_json_for_module(Some(raw_levels_json.as_str()))
|
||||
.expect("levels should normalize")
|
||||
.expect("levels json should exist");
|
||||
let payload = PuzzleGenerateUiBackgroundWorkerPayload {
|
||||
session_id: "puzzle-session-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
billing_asset_id: "puzzle-session-1:456".to_string(),
|
||||
level_id: Some("puzzle-level-3".to_string()),
|
||||
prompt_text: Some("发光钟楼延展成竖屏回廊".to_string()),
|
||||
levels_json: Some(levels_json.clone()),
|
||||
requested_at_micros: 456,
|
||||
};
|
||||
|
||||
let encoded = serde_json::to_string(&payload).expect("payload should serialize");
|
||||
let decoded: PuzzleGenerateUiBackgroundWorkerPayload =
|
||||
serde_json::from_str(encoded.as_str()).expect("payload should deserialize");
|
||||
|
||||
assert_eq!(decoded.level_id.as_deref(), Some("puzzle-level-3"));
|
||||
assert_eq!(
|
||||
decoded.prompt_text.as_deref(),
|
||||
Some("发光钟楼延展成竖屏回廊")
|
||||
);
|
||||
assert_eq!(decoded.requested_at_micros, 456);
|
||||
let records = parse_puzzle_level_records_from_module_json(
|
||||
decoded.levels_json.as_deref().expect("levels json"),
|
||||
)
|
||||
.expect("levels should parse as module json");
|
||||
assert_eq!(records[0].level_id, "puzzle-level-3");
|
||||
assert_eq!(records[0].generation_status, "generating");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -66,6 +66,9 @@ pub struct PuzzleDraftCompileInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub compiled_at_micros: i64,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -75,6 +78,23 @@ 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,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleLevelGenerationFailureInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub level_id: Option<String>,
|
||||
pub levels_json: Option<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,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -86,6 +106,9 @@ pub struct PuzzleGeneratedImagesSaveInput {
|
||||
pub levels_json: Option<String>,
|
||||
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,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -99,6 +122,9 @@ pub struct PuzzleUiBackgroundSaveInput {
|
||||
pub image_src: String,
|
||||
pub image_object_key: Option<String>,
|
||||
pub saved_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
|
||||
@@ -161,9 +161,8 @@ fn normalize_creation_entry_announcement_banner_value(
|
||||
);
|
||||
}
|
||||
|
||||
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
|
||||
object.clone(),
|
||||
))
|
||||
let banner =
|
||||
serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(object.clone()))
|
||||
.map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?;
|
||||
normalize_creation_entry_event_banner_response(index, banner)
|
||||
}
|
||||
@@ -327,10 +326,7 @@ fn normalize_banner_html_code(
|
||||
}
|
||||
let lower_html_code = html_code.to_ascii_lowercase();
|
||||
if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") {
|
||||
return Err(format!(
|
||||
"第 {} 条 HTML 公告含有不允许的脚本代码",
|
||||
index + 1
|
||||
));
|
||||
return Err(format!("第 {} 条 HTML 公告含有不允许的脚本代码", index + 1));
|
||||
}
|
||||
|
||||
Ok(Some(html_code))
|
||||
|
||||
@@ -172,18 +172,8 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
|
||||
vec![
|
||||
unified_creation_field("title", "text", "作品标题", true),
|
||||
unified_creation_field("themeDescription", "text", "主题/场景描述", true),
|
||||
unified_creation_field(
|
||||
"playerImageDescription",
|
||||
"text",
|
||||
"玩家形象描述",
|
||||
true,
|
||||
),
|
||||
unified_creation_field(
|
||||
"opponentImageDescription",
|
||||
"text",
|
||||
"对手形象描述",
|
||||
true,
|
||||
),
|
||||
unified_creation_field("playerImageDescription", "text", "玩家形象描述", true),
|
||||
unified_creation_field("opponentImageDescription", "text", "对手形象描述", true),
|
||||
unified_creation_field("onomatopoeia", "text", "拟声词", false),
|
||||
unified_creation_field("difficultyPreset", "select", "难度", true),
|
||||
],
|
||||
|
||||
129
server-rs/crates/spacetime-client/src/external_generation.rs
Normal file
129
server-rs/crates/spacetime-client/src/external_generation.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use super::*;
|
||||
use crate::mapper::*;
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn enqueue_external_generation_job(
|
||||
&self,
|
||||
input: ExternalGenerationJobEnqueueRecordInput,
|
||||
) -> Result<ExternalGenerationJobRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"enqueue_external_generation_job_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.enqueue_external_generation_job_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 claim_external_generation_jobs(
|
||||
&self,
|
||||
input: ExternalGenerationJobClaimRecordInput,
|
||||
) -> Result<Vec<ExternalGenerationJobRecord>, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"claim_external_generation_jobs_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.claim_external_generation_jobs_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_external_generation_job_claim_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn complete_external_generation_job(
|
||||
&self,
|
||||
input: ExternalGenerationJobCompleteRecordInput,
|
||||
) -> Result<ExternalGenerationJobRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"complete_external_generation_job_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.complete_external_generation_job_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 renew_external_generation_job_lease(
|
||||
&self,
|
||||
input: ExternalGenerationJobRenewLeaseRecordInput,
|
||||
) -> Result<ExternalGenerationJobRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"renew_external_generation_job_lease_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.renew_external_generation_job_lease_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,
|
||||
) -> Result<ExternalGenerationJobRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"fail_external_generation_job_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.fail_external_generation_job_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
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,15 @@ pub use mapper::{
|
||||
CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord,
|
||||
CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord,
|
||||
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
||||
CustomWorldWorkSummaryRecord, JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType,
|
||||
JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
||||
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
|
||||
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
|
||||
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
|
||||
CustomWorldWorkSummaryRecord, ExternalGenerationJobClaimRecordInput,
|
||||
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
|
||||
ExternalGenerationJobFailRecordInput, ExternalGenerationJobRecord,
|
||||
ExternalGenerationJobRenewLeaseRecordInput, JumpHopActionRequest, JumpHopActionResponse,
|
||||
JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse,
|
||||
JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse,
|
||||
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult,
|
||||
JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse,
|
||||
JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
|
||||
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse,
|
||||
@@ -55,13 +58,13 @@ pub use mapper::{
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput,
|
||||
PuzzleLevelGenerationFailureRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
|
||||
@@ -103,6 +106,7 @@ pub use bark_battle::{
|
||||
pub mod big_fish;
|
||||
pub mod combat;
|
||||
pub mod custom_world;
|
||||
pub mod external_generation;
|
||||
pub mod inventory;
|
||||
pub mod jump_hop;
|
||||
pub mod match3d;
|
||||
|
||||
@@ -8,6 +8,7 @@ mod big_fish;
|
||||
mod combat;
|
||||
mod common;
|
||||
mod custom_world;
|
||||
mod external_generation;
|
||||
mod inventory;
|
||||
mod jump_hop;
|
||||
mod match3d;
|
||||
@@ -67,6 +68,11 @@ pub use self::common::{
|
||||
VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput,
|
||||
VisualNovelWorkCompileRecordInput,
|
||||
};
|
||||
pub use self::external_generation::{
|
||||
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
|
||||
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput,
|
||||
ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput,
|
||||
};
|
||||
pub use self::jump_hop::{
|
||||
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
||||
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
||||
@@ -104,13 +110,13 @@ pub use self::puzzle::{
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput,
|
||||
PuzzleLevelGenerationFailureRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
};
|
||||
@@ -165,6 +171,9 @@ pub(crate) use self::custom_world::{
|
||||
parse_rpg_agent_operation_status_record, parse_rpg_agent_operation_type_record,
|
||||
parse_rpg_agent_stage_record,
|
||||
};
|
||||
pub(crate) use self::external_generation::{
|
||||
map_external_generation_job_claim_result, map_external_generation_job_procedure_result,
|
||||
};
|
||||
pub(crate) use self::inventory::{
|
||||
map_runtime_inventory_state_procedure_result, map_runtime_item_reward_item_snapshot,
|
||||
map_runtime_item_reward_item_snapshot_back,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::*;
|
||||
|
||||
impl From<ExternalGenerationJobEnqueueRecordInput> for ExternalGenerationJobEnqueueInput {
|
||||
fn from(input: ExternalGenerationJobEnqueueRecordInput) -> Self {
|
||||
Self {
|
||||
job_id: input.job_id,
|
||||
dedupe_key: input.dedupe_key,
|
||||
job_kind: input.job_kind,
|
||||
owner_user_id: input.owner_user_id,
|
||||
source_module: input.source_module,
|
||||
source_entity_id: input.source_entity_id,
|
||||
request_label: input.request_label,
|
||||
request_payload_json: input.request_payload_json,
|
||||
max_attempts: input.max_attempts,
|
||||
available_at_micros: input.available_at_micros,
|
||||
created_at_micros: input.created_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalGenerationJobClaimRecordInput> for ExternalGenerationJobClaimInput {
|
||||
fn from(input: ExternalGenerationJobClaimRecordInput) -> Self {
|
||||
Self {
|
||||
worker_id: input.worker_id,
|
||||
limit: input.limit,
|
||||
lease_expires_at_micros: input.lease_expires_at_micros,
|
||||
claimed_at_micros: input.claimed_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalGenerationJobCompleteRecordInput> for ExternalGenerationJobCompleteInput {
|
||||
fn from(input: ExternalGenerationJobCompleteRecordInput) -> Self {
|
||||
Self {
|
||||
job_id: input.job_id,
|
||||
worker_id: input.worker_id,
|
||||
lease_token: input.lease_token,
|
||||
result_payload_json: input.result_payload_json,
|
||||
completed_at_micros: input.completed_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalGenerationJobRenewLeaseRecordInput> for ExternalGenerationJobRenewLeaseInput {
|
||||
fn from(input: ExternalGenerationJobRenewLeaseRecordInput) -> Self {
|
||||
Self {
|
||||
job_id: input.job_id,
|
||||
worker_id: input.worker_id,
|
||||
lease_token: input.lease_token,
|
||||
lease_expires_at_micros: input.lease_expires_at_micros,
|
||||
renewed_at_micros: input.renewed_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalGenerationJobFailRecordInput> for ExternalGenerationJobFailInput {
|
||||
fn from(input: ExternalGenerationJobFailRecordInput) -> Self {
|
||||
Self {
|
||||
job_id: input.job_id,
|
||||
worker_id: input.worker_id,
|
||||
lease_token: input.lease_token,
|
||||
error_message: input.error_message,
|
||||
retry_after_micros: input.retry_after_micros,
|
||||
failed_at_micros: input.failed_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_external_generation_job_procedure_result(
|
||||
result: ExternalGenerationJobProcedureResult,
|
||||
) -> Result<ExternalGenerationJobRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
let job = result
|
||||
.job
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("external_generation_job 快照"))?;
|
||||
|
||||
Ok(map_external_generation_job_snapshot(job))
|
||||
}
|
||||
|
||||
pub(crate) fn map_external_generation_job_claim_result(
|
||||
result: ExternalGenerationJobProcedureResult,
|
||||
) -> Result<Vec<ExternalGenerationJobRecord>, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
Ok(result
|
||||
.jobs
|
||||
.into_iter()
|
||||
.map(map_external_generation_job_snapshot)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn map_external_generation_job_snapshot(
|
||||
snapshot: ExternalGenerationJobSnapshot,
|
||||
) -> ExternalGenerationJobRecord {
|
||||
ExternalGenerationJobRecord {
|
||||
job_id: snapshot.job_id,
|
||||
dedupe_key: snapshot.dedupe_key,
|
||||
job_kind: snapshot.job_kind,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
source_module: snapshot.source_module,
|
||||
source_entity_id: snapshot.source_entity_id,
|
||||
request_label: snapshot.request_label,
|
||||
request_payload_json: snapshot.request_payload_json,
|
||||
status: snapshot.status,
|
||||
attempt: snapshot.attempt,
|
||||
max_attempts: snapshot.max_attempts,
|
||||
last_error_message: snapshot.last_error_message,
|
||||
worker_id: snapshot.worker_id,
|
||||
lease_expires_at: snapshot
|
||||
.lease_expires_at_micros
|
||||
.map(format_timestamp_micros),
|
||||
available_at: format_timestamp_micros(snapshot.available_at_micros),
|
||||
result_payload_json: snapshot.result_payload_json,
|
||||
created_at: format_timestamp_micros(snapshot.created_at_micros),
|
||||
started_at: snapshot.started_at_micros.map(format_timestamp_micros),
|
||||
completed_at: snapshot.completed_at_micros.map(format_timestamp_micros),
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
lease_token: snapshot.lease_token,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalGenerationJobEnqueueRecordInput {
|
||||
pub job_id: String,
|
||||
pub dedupe_key: String,
|
||||
pub job_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_payload_json: String,
|
||||
pub max_attempts: u32,
|
||||
pub available_at_micros: i64,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalGenerationJobClaimRecordInput {
|
||||
pub worker_id: String,
|
||||
pub limit: u32,
|
||||
pub lease_expires_at_micros: i64,
|
||||
pub claimed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalGenerationJobCompleteRecordInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub result_payload_json: Option<String>,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalGenerationJobRenewLeaseRecordInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub lease_expires_at_micros: i64,
|
||||
pub renewed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalGenerationJobFailRecordInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub error_message: String,
|
||||
pub retry_after_micros: i64,
|
||||
pub failed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalGenerationJobRecord {
|
||||
pub job_id: String,
|
||||
pub dedupe_key: String,
|
||||
pub job_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_payload_json: String,
|
||||
pub status: String,
|
||||
pub attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
pub last_error_message: Option<String>,
|
||||
pub worker_id: Option<String>,
|
||||
pub lease_expires_at: Option<String>,
|
||||
pub available_at: String,
|
||||
pub result_payload_json: Option<String>,
|
||||
pub created_at: String,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub updated_at: String,
|
||||
pub lease_token: Option<String>,
|
||||
}
|
||||
@@ -642,6 +642,22 @@ 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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleLevelGenerationFailureRecordInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub level_id: Option<String>,
|
||||
pub levels_json: Option<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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -652,6 +668,9 @@ pub struct PuzzleGeneratedImagesSaveRecordInput {
|
||||
pub levels_json: Option<String>,
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -664,6 +683,9 @@ pub struct PuzzleUiBackgroundSaveRecordInput {
|
||||
pub image_src: String,
|
||||
pub image_object_key: Option<String>,
|
||||
pub saved_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -201,6 +201,7 @@ pub mod chapter_progression_snapshot_type;
|
||||
pub mod chapter_progression_table;
|
||||
pub mod chapter_progression_type;
|
||||
pub mod checkpoint_wooden_fish_run_procedure;
|
||||
pub mod claim_external_generation_jobs_and_return_procedure;
|
||||
pub mod claim_profile_task_reward_and_return_procedure;
|
||||
pub mod claim_puzzle_work_point_incentive_procedure;
|
||||
pub mod clear_database_migration_import_chunks_procedure;
|
||||
@@ -217,6 +218,7 @@ pub mod compile_visual_novel_work_profile_procedure;
|
||||
pub mod compile_wooden_fish_draft_procedure;
|
||||
pub mod complete_ai_stage_and_return_procedure;
|
||||
pub mod complete_ai_task_and_return_procedure;
|
||||
pub mod complete_external_generation_job_and_return_procedure;
|
||||
pub mod confirm_asset_object_and_return_procedure;
|
||||
pub mod confirm_asset_object_reducer;
|
||||
pub mod consume_inventory_item_input_type;
|
||||
@@ -335,12 +337,23 @@ pub mod delete_square_hole_work_procedure;
|
||||
pub mod delete_visual_novel_work_procedure;
|
||||
pub mod drag_puzzle_piece_or_group_procedure;
|
||||
pub mod drop_square_hole_shape_procedure;
|
||||
pub mod enqueue_external_generation_job_and_return_procedure;
|
||||
pub mod ensure_analytics_date_dimension_for_date_reducer;
|
||||
pub mod equip_inventory_item_input_type;
|
||||
pub mod execute_custom_world_agent_action_procedure;
|
||||
pub mod export_auth_store_snapshot_from_tables_procedure;
|
||||
pub mod export_database_migration_to_file_procedure;
|
||||
pub mod external_generation_job_claim_input_type;
|
||||
pub mod external_generation_job_complete_input_type;
|
||||
pub mod external_generation_job_enqueue_input_type;
|
||||
pub mod external_generation_job_fail_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;
|
||||
pub mod external_generation_job_table;
|
||||
pub mod external_generation_job_type;
|
||||
pub mod fail_ai_task_and_return_procedure;
|
||||
pub mod fail_external_generation_job_and_return_procedure;
|
||||
pub mod finalize_big_fish_agent_message_turn_procedure;
|
||||
pub mod finalize_custom_world_agent_message_turn_procedure;
|
||||
pub mod finalize_match_3_d_agent_message_turn_procedure;
|
||||
@@ -475,6 +488,7 @@ pub mod list_visual_novel_works_procedure;
|
||||
pub mod list_wooden_fish_works_procedure;
|
||||
pub mod mark_profile_recharge_order_paid_and_return_procedure;
|
||||
pub mod mark_puzzle_draft_generation_failed_procedure;
|
||||
pub mod mark_puzzle_level_generation_failed_procedure;
|
||||
pub mod match_3_d_agent_message_finalize_input_type;
|
||||
pub mod match_3_d_agent_message_row_type;
|
||||
pub mod match_3_d_agent_message_snapshot_type;
|
||||
@@ -625,6 +639,7 @@ pub mod puzzle_leaderboard_entry_row_type;
|
||||
pub mod puzzle_leaderboard_entry_table;
|
||||
pub mod puzzle_leaderboard_entry_type;
|
||||
pub mod puzzle_leaderboard_submit_input_type;
|
||||
pub mod puzzle_level_generation_failure_input_type;
|
||||
pub mod puzzle_merged_group_state_type;
|
||||
pub mod puzzle_piece_state_type;
|
||||
pub mod puzzle_publication_status_type;
|
||||
@@ -708,6 +723,7 @@ pub mod refund_profile_wallet_points_and_return_procedure;
|
||||
pub mod remix_big_fish_work_procedure;
|
||||
pub mod remix_custom_world_profile_procedure;
|
||||
pub mod remix_puzzle_work_procedure;
|
||||
pub mod renew_external_generation_job_lease_and_return_procedure;
|
||||
pub mod resolve_combat_action_and_return_procedure;
|
||||
pub mod resolve_combat_action_input_type;
|
||||
pub mod resolve_combat_action_procedure_result_type;
|
||||
@@ -1242,6 +1258,7 @@ pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot;
|
||||
pub use chapter_progression_table::*;
|
||||
pub use chapter_progression_type::ChapterProgression;
|
||||
pub use checkpoint_wooden_fish_run_procedure::checkpoint_wooden_fish_run;
|
||||
pub use claim_external_generation_jobs_and_return_procedure::claim_external_generation_jobs_and_return;
|
||||
pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return;
|
||||
pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive;
|
||||
pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks;
|
||||
@@ -1258,6 +1275,7 @@ pub use compile_visual_novel_work_profile_procedure::compile_visual_novel_work_p
|
||||
pub use compile_wooden_fish_draft_procedure::compile_wooden_fish_draft;
|
||||
pub use complete_ai_stage_and_return_procedure::complete_ai_stage_and_return;
|
||||
pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return;
|
||||
pub use complete_external_generation_job_and_return_procedure::complete_external_generation_job_and_return;
|
||||
pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return;
|
||||
pub use confirm_asset_object_reducer::confirm_asset_object;
|
||||
pub use consume_inventory_item_input_type::ConsumeInventoryItemInput;
|
||||
@@ -1376,12 +1394,23 @@ pub use delete_square_hole_work_procedure::delete_square_hole_work;
|
||||
pub use delete_visual_novel_work_procedure::delete_visual_novel_work;
|
||||
pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group;
|
||||
pub use drop_square_hole_shape_procedure::drop_square_hole_shape;
|
||||
pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_generation_job_and_return;
|
||||
pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date;
|
||||
pub use equip_inventory_item_input_type::EquipInventoryItemInput;
|
||||
pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action;
|
||||
pub use export_auth_store_snapshot_from_tables_procedure::export_auth_store_snapshot_from_tables;
|
||||
pub use export_database_migration_to_file_procedure::export_database_migration_to_file;
|
||||
pub use external_generation_job_claim_input_type::ExternalGenerationJobClaimInput;
|
||||
pub use external_generation_job_complete_input_type::ExternalGenerationJobCompleteInput;
|
||||
pub use external_generation_job_enqueue_input_type::ExternalGenerationJobEnqueueInput;
|
||||
pub use external_generation_job_fail_input_type::ExternalGenerationJobFailInput;
|
||||
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;
|
||||
pub use external_generation_job_table::*;
|
||||
pub use external_generation_job_type::ExternalGenerationJob;
|
||||
pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return;
|
||||
pub use fail_external_generation_job_and_return_procedure::fail_external_generation_job_and_return;
|
||||
pub use finalize_big_fish_agent_message_turn_procedure::finalize_big_fish_agent_message_turn;
|
||||
pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn;
|
||||
pub use finalize_match_3_d_agent_message_turn_procedure::finalize_match_3_d_agent_message_turn;
|
||||
@@ -1516,6 +1545,7 @@ pub use list_visual_novel_works_procedure::list_visual_novel_works;
|
||||
pub use list_wooden_fish_works_procedure::list_wooden_fish_works;
|
||||
pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return;
|
||||
pub use mark_puzzle_draft_generation_failed_procedure::mark_puzzle_draft_generation_failed;
|
||||
pub use mark_puzzle_level_generation_failed_procedure::mark_puzzle_level_generation_failed;
|
||||
pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput;
|
||||
pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow;
|
||||
pub use match_3_d_agent_message_snapshot_type::Match3DAgentMessageSnapshot;
|
||||
@@ -1666,6 +1696,7 @@ pub use puzzle_leaderboard_entry_row_type::PuzzleLeaderboardEntryRow;
|
||||
pub use puzzle_leaderboard_entry_table::*;
|
||||
pub use puzzle_leaderboard_entry_type::PuzzleLeaderboardEntry;
|
||||
pub use puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput;
|
||||
pub use puzzle_level_generation_failure_input_type::PuzzleLevelGenerationFailureInput;
|
||||
pub use puzzle_merged_group_state_type::PuzzleMergedGroupState;
|
||||
pub use puzzle_piece_state_type::PuzzlePieceState;
|
||||
pub use puzzle_publication_status_type::PuzzlePublicationStatus;
|
||||
@@ -1749,6 +1780,7 @@ pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet
|
||||
pub use remix_big_fish_work_procedure::remix_big_fish_work;
|
||||
pub use remix_custom_world_profile_procedure::remix_custom_world_profile;
|
||||
pub use remix_puzzle_work_procedure::remix_puzzle_work;
|
||||
pub use renew_external_generation_job_lease_and_return_procedure::renew_external_generation_job_lease_and_return;
|
||||
pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return;
|
||||
pub use resolve_combat_action_input_type::ResolveCombatActionInput;
|
||||
pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult;
|
||||
@@ -2399,6 +2431,7 @@ pub struct DbUpdate {
|
||||
custom_world_session: __sdk::TableUpdate<CustomWorldSession>,
|
||||
database_migration_import_chunk: __sdk::TableUpdate<DatabaseMigrationImportChunk>,
|
||||
database_migration_operator: __sdk::TableUpdate<DatabaseMigrationOperator>,
|
||||
external_generation_job: __sdk::TableUpdate<ExternalGenerationJob>,
|
||||
inventory_slot: __sdk::TableUpdate<InventorySlot>,
|
||||
jump_hop_agent_session: __sdk::TableUpdate<JumpHopAgentSessionRow>,
|
||||
jump_hop_event: __sdk::TableUpdate<JumpHopEventRow>,
|
||||
@@ -2603,6 +2636,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
|
||||
"database_migration_operator" => db_update.database_migration_operator.append(
|
||||
database_migration_operator_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"external_generation_job" => db_update.external_generation_job.append(
|
||||
external_generation_job_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"inventory_slot" => db_update
|
||||
.inventory_slot
|
||||
.append(inventory_slot_table::parse_table_update(table_update)?),
|
||||
@@ -3035,6 +3071,12 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
&self.database_migration_operator,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.operator_identity);
|
||||
diff.external_generation_job = cache
|
||||
.apply_diff_to_table::<ExternalGenerationJob>(
|
||||
"external_generation_job",
|
||||
&self.external_generation_job,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.job_id);
|
||||
diff.inventory_slot = cache
|
||||
.apply_diff_to_table::<InventorySlot>("inventory_slot", &self.inventory_slot)
|
||||
.with_updates_by_pk(|row| &row.slot_id);
|
||||
@@ -3517,6 +3559,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"database_migration_operator" => db_update
|
||||
.database_migration_operator
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"external_generation_job" => db_update
|
||||
.external_generation_job
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"inventory_slot" => db_update
|
||||
.inventory_slot
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
@@ -3860,6 +3905,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"database_migration_operator" => db_update
|
||||
.database_migration_operator
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"external_generation_job" => db_update
|
||||
.external_generation_job
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"inventory_slot" => db_update
|
||||
.inventory_slot
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
@@ -4129,6 +4177,7 @@ pub struct AppliedDiff<'r> {
|
||||
custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>,
|
||||
database_migration_import_chunk: __sdk::TableAppliedDiff<'r, DatabaseMigrationImportChunk>,
|
||||
database_migration_operator: __sdk::TableAppliedDiff<'r, DatabaseMigrationOperator>,
|
||||
external_generation_job: __sdk::TableAppliedDiff<'r, ExternalGenerationJob>,
|
||||
inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>,
|
||||
jump_hop_agent_session: __sdk::TableAppliedDiff<'r, JumpHopAgentSessionRow>,
|
||||
jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>,
|
||||
@@ -4401,6 +4450,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
||||
&self.database_migration_operator,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<ExternalGenerationJob>(
|
||||
"external_generation_job",
|
||||
&self.external_generation_job,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<InventorySlot>(
|
||||
"inventory_slot",
|
||||
&self.inventory_slot,
|
||||
@@ -5443,6 +5497,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
custom_world_session_table::register_table(client_cache);
|
||||
database_migration_import_chunk_table::register_table(client_cache);
|
||||
database_migration_operator_table::register_table(client_cache);
|
||||
external_generation_job_table::register_table(client_cache);
|
||||
inventory_slot_table::register_table(client_cache);
|
||||
jump_hop_agent_session_table::register_table(client_cache);
|
||||
jump_hop_event_table::register_table(client_cache);
|
||||
@@ -5555,6 +5610,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
"custom_world_session",
|
||||
"database_migration_import_chunk",
|
||||
"database_migration_operator",
|
||||
"external_generation_job",
|
||||
"inventory_slot",
|
||||
"jump_hop_agent_session",
|
||||
"jump_hop_event",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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_claim_input_type::ExternalGenerationJobClaimInput;
|
||||
use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct ClaimExternalGenerationJobsAndReturnArgs {
|
||||
pub input: ExternalGenerationJobClaimInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ClaimExternalGenerationJobsAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `claim_external_generation_jobs_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait claim_external_generation_jobs_and_return {
|
||||
fn claim_external_generation_jobs_and_return(&self, input: ExternalGenerationJobClaimInput) {
|
||||
self.claim_external_generation_jobs_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn claim_external_generation_jobs_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl claim_external_generation_jobs_and_return for super::RemoteProcedures {
|
||||
fn claim_external_generation_jobs_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobClaimInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
|
||||
"claim_external_generation_jobs_and_return",
|
||||
ClaimExternalGenerationJobsAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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_complete_input_type::ExternalGenerationJobCompleteInput;
|
||||
use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct CompleteExternalGenerationJobAndReturnArgs {
|
||||
pub input: ExternalGenerationJobCompleteInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CompleteExternalGenerationJobAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `complete_external_generation_job_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait complete_external_generation_job_and_return {
|
||||
fn complete_external_generation_job_and_return(
|
||||
&self,
|
||||
input: ExternalGenerationJobCompleteInput,
|
||||
) {
|
||||
self.complete_external_generation_job_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn complete_external_generation_job_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobCompleteInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl complete_external_generation_job_and_return for super::RemoteProcedures {
|
||||
fn complete_external_generation_job_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobCompleteInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
|
||||
"complete_external_generation_job_and_return",
|
||||
CompleteExternalGenerationJobAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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_enqueue_input_type::ExternalGenerationJobEnqueueInput;
|
||||
use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct EnqueueExternalGenerationJobAndReturnArgs {
|
||||
pub input: ExternalGenerationJobEnqueueInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EnqueueExternalGenerationJobAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `enqueue_external_generation_job_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait enqueue_external_generation_job_and_return {
|
||||
fn enqueue_external_generation_job_and_return(&self, input: ExternalGenerationJobEnqueueInput) {
|
||||
self.enqueue_external_generation_job_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn enqueue_external_generation_job_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobEnqueueInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl enqueue_external_generation_job_and_return for super::RemoteProcedures {
|
||||
fn enqueue_external_generation_job_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobEnqueueInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
|
||||
"enqueue_external_generation_job_and_return",
|
||||
EnqueueExternalGenerationJobAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ExternalGenerationJobClaimInput {
|
||||
pub worker_id: String,
|
||||
pub limit: u32,
|
||||
pub lease_expires_at_micros: i64,
|
||||
pub claimed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalGenerationJobClaimInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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 ExternalGenerationJobCompleteInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub result_payload_json: Option<String>,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalGenerationJobCompleteInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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 ExternalGenerationJobEnqueueInput {
|
||||
pub job_id: String,
|
||||
pub dedupe_key: String,
|
||||
pub job_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_payload_json: String,
|
||||
pub max_attempts: u32,
|
||||
pub available_at_micros: i64,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalGenerationJobEnqueueInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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 ExternalGenerationJobFailInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub error_message: String,
|
||||
pub retry_after_micros: i64,
|
||||
pub failed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalGenerationJobFailInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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_snapshot_type::ExternalGenerationJobSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct ExternalGenerationJobProcedureResult {
|
||||
pub ok: bool,
|
||||
pub job: Option<ExternalGenerationJobSnapshot>,
|
||||
pub jobs: Vec<ExternalGenerationJobSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalGenerationJobProcedureResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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 ExternalGenerationJobRenewLeaseInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub lease_expires_at_micros: i64,
|
||||
pub renewed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalGenerationJobRenewLeaseInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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 ExternalGenerationJobSnapshot {
|
||||
pub job_id: String,
|
||||
pub dedupe_key: String,
|
||||
pub job_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_payload_json: String,
|
||||
pub status: String,
|
||||
pub attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
pub last_error_message: Option<String>,
|
||||
pub worker_id: Option<String>,
|
||||
pub lease_expires_at_micros: Option<i64>,
|
||||
pub available_at_micros: i64,
|
||||
pub result_payload_json: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub started_at_micros: Option<i64>,
|
||||
pub completed_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
pub lease_token: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalGenerationJobSnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// 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 super::external_generation_job_type::ExternalGenerationJob;
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
/// Table handle for the table `external_generation_job`.
|
||||
///
|
||||
/// Obtain a handle from the [`ExternalGenerationJobTableAccess::external_generation_job`] method on [`super::RemoteTables`],
|
||||
/// like `ctx.db.external_generation_job()`.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.external_generation_job().on_insert(...)`.
|
||||
pub struct ExternalGenerationJobTableHandle<'ctx> {
|
||||
imp: __sdk::TableHandle<ExternalGenerationJob>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the table `external_generation_job`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteTables`].
|
||||
pub trait ExternalGenerationJobTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Obtain a [`ExternalGenerationJobTableHandle`], which mediates access to the table `external_generation_job`.
|
||||
fn external_generation_job(&self) -> ExternalGenerationJobTableHandle<'_>;
|
||||
}
|
||||
|
||||
impl ExternalGenerationJobTableAccess for super::RemoteTables {
|
||||
fn external_generation_job(&self) -> ExternalGenerationJobTableHandle<'_> {
|
||||
ExternalGenerationJobTableHandle {
|
||||
imp: self
|
||||
.imp
|
||||
.get_table::<ExternalGenerationJob>("external_generation_job"),
|
||||
ctx: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExternalGenerationJobInsertCallbackId(__sdk::CallbackId);
|
||||
pub struct ExternalGenerationJobDeleteCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::Table for ExternalGenerationJobTableHandle<'ctx> {
|
||||
type Row = ExternalGenerationJob;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = ExternalGenerationJob> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
|
||||
type InsertCallbackId = ExternalGenerationJobInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> ExternalGenerationJobInsertCallbackId {
|
||||
ExternalGenerationJobInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: ExternalGenerationJobInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
|
||||
type DeleteCallbackId = ExternalGenerationJobDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> ExternalGenerationJobDeleteCallbackId {
|
||||
ExternalGenerationJobDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: ExternalGenerationJobDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExternalGenerationJobUpdateCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::TableWithPrimaryKey for ExternalGenerationJobTableHandle<'ctx> {
|
||||
type UpdateCallbackId = ExternalGenerationJobUpdateCallbackId;
|
||||
|
||||
fn on_update(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
|
||||
) -> ExternalGenerationJobUpdateCallbackId {
|
||||
ExternalGenerationJobUpdateCallbackId(self.imp.on_update(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_update(&self, callback: ExternalGenerationJobUpdateCallbackId) {
|
||||
self.imp.remove_on_update(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Access to the `job_id` unique index on the table `external_generation_job`,
|
||||
/// which allows point queries on the field of the same name
|
||||
/// via the [`ExternalGenerationJobJobIdUnique::find`] method.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.external_generation_job().job_id().find(...)`.
|
||||
pub struct ExternalGenerationJobJobIdUnique<'ctx> {
|
||||
imp: __sdk::UniqueConstraintHandle<ExternalGenerationJob, String>,
|
||||
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
impl<'ctx> ExternalGenerationJobTableHandle<'ctx> {
|
||||
/// Get a handle on the `job_id` unique index on the table `external_generation_job`.
|
||||
pub fn job_id(&self) -> ExternalGenerationJobJobIdUnique<'ctx> {
|
||||
ExternalGenerationJobJobIdUnique {
|
||||
imp: self.imp.get_unique_constraint::<String>("job_id"),
|
||||
phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> ExternalGenerationJobJobIdUnique<'ctx> {
|
||||
/// Find the subscribed row whose `job_id` column value is equal to `col_val`,
|
||||
/// if such a row is present in the client cache.
|
||||
pub fn find(&self, col_val: &String) -> Option<ExternalGenerationJob> {
|
||||
self.imp.find(col_val)
|
||||
}
|
||||
}
|
||||
|
||||
/// Access to the `dedupe_key` unique index on the table `external_generation_job`,
|
||||
/// which allows point queries on the field of the same name
|
||||
/// via the [`ExternalGenerationJobDedupeKeyUnique::find`] method.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.external_generation_job().dedupe_key().find(...)`.
|
||||
pub struct ExternalGenerationJobDedupeKeyUnique<'ctx> {
|
||||
imp: __sdk::UniqueConstraintHandle<ExternalGenerationJob, String>,
|
||||
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
impl<'ctx> ExternalGenerationJobTableHandle<'ctx> {
|
||||
/// Get a handle on the `dedupe_key` unique index on the table `external_generation_job`.
|
||||
pub fn dedupe_key(&self) -> ExternalGenerationJobDedupeKeyUnique<'ctx> {
|
||||
ExternalGenerationJobDedupeKeyUnique {
|
||||
imp: self.imp.get_unique_constraint::<String>("dedupe_key"),
|
||||
phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> ExternalGenerationJobDedupeKeyUnique<'ctx> {
|
||||
/// Find the subscribed row whose `dedupe_key` column value is equal to `col_val`,
|
||||
/// if such a row is present in the client cache.
|
||||
pub fn find(&self, col_val: &String) -> Option<ExternalGenerationJob> {
|
||||
self.imp.find(col_val)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table = client_cache.get_or_make_table::<ExternalGenerationJob>("external_generation_job");
|
||||
_table.add_unique_constraint::<String>("job_id", |row| &row.job_id);
|
||||
_table.add_unique_constraint::<String>("dedupe_key", |row| &row.dedupe_key);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<ExternalGenerationJob>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<ExternalGenerationJob>", "TableUpdate")
|
||||
.with_cause(e)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for query builder access to the table `ExternalGenerationJob`.
|
||||
///
|
||||
/// Implemented for [`__sdk::QueryTableAccessor`].
|
||||
pub trait external_generation_jobQueryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Get a query builder for the table `ExternalGenerationJob`.
|
||||
fn external_generation_job(&self) -> __sdk::__query_builder::Table<ExternalGenerationJob>;
|
||||
}
|
||||
|
||||
impl external_generation_jobQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn external_generation_job(&self) -> __sdk::__query_builder::Table<ExternalGenerationJob> {
|
||||
__sdk::__query_builder::Table::new("external_generation_job")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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 ExternalGenerationJob {
|
||||
pub job_id: String,
|
||||
pub dedupe_key: String,
|
||||
pub job_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_payload_json: String,
|
||||
pub status: String,
|
||||
pub attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
pub last_error_message: Option<String>,
|
||||
pub worker_id: Option<String>,
|
||||
pub lease_expires_at: Option<__sdk::Timestamp>,
|
||||
pub available_at: __sdk::Timestamp,
|
||||
pub result_payload_json: Option<String>,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub started_at: Option<__sdk::Timestamp>,
|
||||
pub completed_at: Option<__sdk::Timestamp>,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
pub lease_token: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalGenerationJob {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `ExternalGenerationJob`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct ExternalGenerationJobCols {
|
||||
pub job_id: __sdk::__query_builder::Col<ExternalGenerationJob, String>,
|
||||
pub dedupe_key: __sdk::__query_builder::Col<ExternalGenerationJob, String>,
|
||||
pub job_kind: __sdk::__query_builder::Col<ExternalGenerationJob, String>,
|
||||
pub owner_user_id: __sdk::__query_builder::Col<ExternalGenerationJob, String>,
|
||||
pub source_module: __sdk::__query_builder::Col<ExternalGenerationJob, String>,
|
||||
pub source_entity_id: __sdk::__query_builder::Col<ExternalGenerationJob, String>,
|
||||
pub request_label: __sdk::__query_builder::Col<ExternalGenerationJob, String>,
|
||||
pub request_payload_json: __sdk::__query_builder::Col<ExternalGenerationJob, String>,
|
||||
pub status: __sdk::__query_builder::Col<ExternalGenerationJob, String>,
|
||||
pub attempt: __sdk::__query_builder::Col<ExternalGenerationJob, u32>,
|
||||
pub max_attempts: __sdk::__query_builder::Col<ExternalGenerationJob, u32>,
|
||||
pub last_error_message: __sdk::__query_builder::Col<ExternalGenerationJob, Option<String>>,
|
||||
pub worker_id: __sdk::__query_builder::Col<ExternalGenerationJob, Option<String>>,
|
||||
pub lease_expires_at:
|
||||
__sdk::__query_builder::Col<ExternalGenerationJob, Option<__sdk::Timestamp>>,
|
||||
pub available_at: __sdk::__query_builder::Col<ExternalGenerationJob, __sdk::Timestamp>,
|
||||
pub result_payload_json: __sdk::__query_builder::Col<ExternalGenerationJob, Option<String>>,
|
||||
pub created_at: __sdk::__query_builder::Col<ExternalGenerationJob, __sdk::Timestamp>,
|
||||
pub started_at: __sdk::__query_builder::Col<ExternalGenerationJob, Option<__sdk::Timestamp>>,
|
||||
pub completed_at: __sdk::__query_builder::Col<ExternalGenerationJob, Option<__sdk::Timestamp>>,
|
||||
pub updated_at: __sdk::__query_builder::Col<ExternalGenerationJob, __sdk::Timestamp>,
|
||||
pub lease_token: __sdk::__query_builder::Col<ExternalGenerationJob, Option<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for ExternalGenerationJob {
|
||||
type Cols = ExternalGenerationJobCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
ExternalGenerationJobCols {
|
||||
job_id: __sdk::__query_builder::Col::new(table_name, "job_id"),
|
||||
dedupe_key: __sdk::__query_builder::Col::new(table_name, "dedupe_key"),
|
||||
job_kind: __sdk::__query_builder::Col::new(table_name, "job_kind"),
|
||||
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
|
||||
source_module: __sdk::__query_builder::Col::new(table_name, "source_module"),
|
||||
source_entity_id: __sdk::__query_builder::Col::new(table_name, "source_entity_id"),
|
||||
request_label: __sdk::__query_builder::Col::new(table_name, "request_label"),
|
||||
request_payload_json: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"request_payload_json",
|
||||
),
|
||||
status: __sdk::__query_builder::Col::new(table_name, "status"),
|
||||
attempt: __sdk::__query_builder::Col::new(table_name, "attempt"),
|
||||
max_attempts: __sdk::__query_builder::Col::new(table_name, "max_attempts"),
|
||||
last_error_message: __sdk::__query_builder::Col::new(table_name, "last_error_message"),
|
||||
worker_id: __sdk::__query_builder::Col::new(table_name, "worker_id"),
|
||||
lease_expires_at: __sdk::__query_builder::Col::new(table_name, "lease_expires_at"),
|
||||
available_at: __sdk::__query_builder::Col::new(table_name, "available_at"),
|
||||
result_payload_json: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"result_payload_json",
|
||||
),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
started_at: __sdk::__query_builder::Col::new(table_name, "started_at"),
|
||||
completed_at: __sdk::__query_builder::Col::new(table_name, "completed_at"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
lease_token: __sdk::__query_builder::Col::new(table_name, "lease_token"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `ExternalGenerationJob`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct ExternalGenerationJobIxCols {
|
||||
pub dedupe_key: __sdk::__query_builder::IxCol<ExternalGenerationJob, String>,
|
||||
pub job_id: __sdk::__query_builder::IxCol<ExternalGenerationJob, String>,
|
||||
pub owner_user_id: __sdk::__query_builder::IxCol<ExternalGenerationJob, String>,
|
||||
pub worker_id: __sdk::__query_builder::IxCol<ExternalGenerationJob, Option<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for ExternalGenerationJob {
|
||||
type IxCols = ExternalGenerationJobIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
ExternalGenerationJobIxCols {
|
||||
dedupe_key: __sdk::__query_builder::IxCol::new(table_name, "dedupe_key"),
|
||||
job_id: __sdk::__query_builder::IxCol::new(table_name, "job_id"),
|
||||
owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"),
|
||||
worker_id: __sdk::__query_builder::IxCol::new(table_name, "worker_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for ExternalGenerationJob {}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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_fail_input_type::ExternalGenerationJobFailInput;
|
||||
use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct FailExternalGenerationJobAndReturnArgs {
|
||||
pub input: ExternalGenerationJobFailInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for FailExternalGenerationJobAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `fail_external_generation_job_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait fail_external_generation_job_and_return {
|
||||
fn fail_external_generation_job_and_return(&self, input: ExternalGenerationJobFailInput) {
|
||||
self.fail_external_generation_job_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn fail_external_generation_job_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobFailInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl fail_external_generation_job_and_return for super::RemoteProcedures {
|
||||
fn fail_external_generation_job_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobFailInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
|
||||
"fail_external_generation_job_and_return",
|
||||
FailExternalGenerationJobAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult;
|
||||
use super::puzzle_level_generation_failure_input_type::PuzzleLevelGenerationFailureInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct MarkPuzzleLevelGenerationFailedArgs {
|
||||
pub input: PuzzleLevelGenerationFailureInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for MarkPuzzleLevelGenerationFailedArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `mark_puzzle_level_generation_failed`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait mark_puzzle_level_generation_failed {
|
||||
fn mark_puzzle_level_generation_failed(&self, input: PuzzleLevelGenerationFailureInput) {
|
||||
self.mark_puzzle_level_generation_failed_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn mark_puzzle_level_generation_failed_then(
|
||||
&self,
|
||||
input: PuzzleLevelGenerationFailureInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl mark_puzzle_level_generation_failed for super::RemoteProcedures {
|
||||
fn mark_puzzle_level_generation_failed_then(
|
||||
&self,
|
||||
input: PuzzleLevelGenerationFailureInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>(
|
||||
"mark_puzzle_level_generation_failed",
|
||||
MarkPuzzleLevelGenerationFailedArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +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,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleDraftCompileFailureInput {
|
||||
|
||||
@@ -10,6 +10,9 @@ pub struct PuzzleDraftCompileInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub compiled_at_micros: i64,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleDraftCompileInput {
|
||||
|
||||
@@ -13,6 +13,9 @@ pub struct PuzzleGeneratedImagesSaveInput {
|
||||
pub levels_json: Option<String>,
|
||||
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,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleGeneratedImagesSaveInput {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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 PuzzleLevelGenerationFailureInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub level_id: Option<String>,
|
||||
pub levels_json: Option<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,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleLevelGenerationFailureInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -15,6 +15,9 @@ pub struct PuzzleUiBackgroundSaveInput {
|
||||
pub image_src: String,
|
||||
pub image_object_key: Option<String>,
|
||||
pub saved_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleUiBackgroundSaveInput {
|
||||
|
||||
@@ -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_procedure_result_type::ExternalGenerationJobProcedureResult;
|
||||
use super::external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct RenewExternalGenerationJobLeaseAndReturnArgs {
|
||||
pub input: ExternalGenerationJobRenewLeaseInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RenewExternalGenerationJobLeaseAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `renew_external_generation_job_lease_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait renew_external_generation_job_lease_and_return {
|
||||
fn renew_external_generation_job_lease_and_return(
|
||||
&self,
|
||||
input: ExternalGenerationJobRenewLeaseInput,
|
||||
) {
|
||||
self.renew_external_generation_job_lease_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn renew_external_generation_job_lease_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobRenewLeaseInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl renew_external_generation_job_lease_and_return for super::RemoteProcedures {
|
||||
fn renew_external_generation_job_lease_and_return_then(
|
||||
&self,
|
||||
input: ExternalGenerationJobRenewLeaseInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
|
||||
"renew_external_generation_job_lease_and_return",
|
||||
RenewExternalGenerationJobLeaseAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,10 +147,55 @@ impl SpacetimeClient {
|
||||
owner_user_id: String,
|
||||
compiled_at_micros: i64,
|
||||
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
|
||||
self.compile_puzzle_agent_draft_inner(session_id, owner_user_id, compiled_at_micros, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn compile_puzzle_agent_draft_with_external_generation_guard(
|
||||
&self,
|
||||
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,
|
||||
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
|
||||
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
|
||||
}
|
||||
|
||||
async fn compile_puzzle_agent_draft_inner(
|
||||
&self,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
compiled_at_micros: i64,
|
||||
external_generation_guard: Option<(String, String, String)>,
|
||||
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
|
||||
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));
|
||||
let procedure_input = PuzzleDraftCompileInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
compiled_at_micros,
|
||||
external_generation_job_id,
|
||||
external_generation_worker_id,
|
||||
external_generation_lease_token,
|
||||
};
|
||||
|
||||
self.call_after_connect("compile_puzzle_agent_draft", move |connection, sender| {
|
||||
@@ -176,6 +221,9 @@ impl SpacetimeClient {
|
||||
owner_user_id: input.owner_user_id,
|
||||
error_message: input.error_message,
|
||||
failed_at_micros: input.failed_at_micros,
|
||||
external_generation_job_id: input.external_generation_job_id,
|
||||
external_generation_worker_id: input.external_generation_worker_id,
|
||||
external_generation_lease_token: input.external_generation_lease_token,
|
||||
};
|
||||
|
||||
self.call_after_connect(
|
||||
@@ -194,6 +242,38 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_puzzle_level_generation_failed(
|
||||
&self,
|
||||
input: PuzzleLevelGenerationFailureRecordInput,
|
||||
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
|
||||
let procedure_input = PuzzleLevelGenerationFailureInput {
|
||||
session_id: input.session_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
level_id: input.level_id,
|
||||
levels_json: input.levels_json,
|
||||
error_message: input.error_message,
|
||||
failed_at_micros: input.failed_at_micros,
|
||||
external_generation_job_id: input.external_generation_job_id,
|
||||
external_generation_worker_id: input.external_generation_worker_id,
|
||||
external_generation_lease_token: input.external_generation_lease_token,
|
||||
};
|
||||
|
||||
self.call_after_connect(
|
||||
"mark_puzzle_level_generation_failed",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.mark_puzzle_level_generation_failed_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_puzzle_agent_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_puzzle_generated_images(
|
||||
&self,
|
||||
input: PuzzleGeneratedImagesSaveRecordInput,
|
||||
@@ -205,6 +285,9 @@ impl SpacetimeClient {
|
||||
levels_json: input.levels_json,
|
||||
candidates_json: input.candidates_json,
|
||||
saved_at_micros: input.saved_at_micros,
|
||||
external_generation_job_id: input.external_generation_job_id,
|
||||
external_generation_worker_id: input.external_generation_worker_id,
|
||||
external_generation_lease_token: input.external_generation_lease_token,
|
||||
};
|
||||
|
||||
self.call_after_connect("save_puzzle_generated_images", move |connection, sender| {
|
||||
@@ -234,6 +317,9 @@ impl SpacetimeClient {
|
||||
image_src: input.image_src,
|
||||
image_object_key: input.image_object_key,
|
||||
saved_at_micros: input.saved_at_micros,
|
||||
external_generation_job_id: input.external_generation_job_id,
|
||||
external_generation_worker_id: input.external_generation_worker_id,
|
||||
external_generation_lease_token: input.external_generation_lease_token,
|
||||
};
|
||||
|
||||
self.call_after_connect("save_puzzle_ui_background", move |connection, sender| {
|
||||
|
||||
766
server-rs/crates/spacetime-module/src/external_generation.rs
Normal file
766
server-rs/crates/spacetime-module/src/external_generation.rs
Normal file
@@ -0,0 +1,766 @@
|
||||
use crate::*;
|
||||
|
||||
const EXTERNAL_GENERATION_STATUS_PENDING: &str = "pending";
|
||||
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";
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = external_generation_job,
|
||||
index(
|
||||
accessor = by_external_generation_job_status_available,
|
||||
btree(columns = [status, available_at])
|
||||
),
|
||||
index(
|
||||
accessor = by_external_generation_job_worker_id,
|
||||
btree(columns = [worker_id])
|
||||
),
|
||||
index(
|
||||
accessor = by_external_generation_job_source,
|
||||
btree(columns = [source_module, source_entity_id])
|
||||
),
|
||||
index(
|
||||
accessor = by_external_generation_job_owner_user_id,
|
||||
btree(columns = [owner_user_id])
|
||||
)
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
pub struct ExternalGenerationJob {
|
||||
#[primary_key]
|
||||
pub(crate) job_id: String,
|
||||
#[unique]
|
||||
pub(crate) dedupe_key: String,
|
||||
pub(crate) job_kind: String,
|
||||
pub(crate) owner_user_id: String,
|
||||
pub(crate) source_module: String,
|
||||
pub(crate) source_entity_id: String,
|
||||
pub(crate) request_label: String,
|
||||
pub(crate) request_payload_json: String,
|
||||
pub(crate) status: String,
|
||||
pub(crate) attempt: u32,
|
||||
pub(crate) max_attempts: u32,
|
||||
pub(crate) last_error_message: Option<String>,
|
||||
pub(crate) worker_id: Option<String>,
|
||||
pub(crate) lease_expires_at: Option<Timestamp>,
|
||||
pub(crate) available_at: Timestamp,
|
||||
pub(crate) result_payload_json: Option<String>,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) started_at: Option<Timestamp>,
|
||||
pub(crate) completed_at: Option<Timestamp>,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobEnqueueInput {
|
||||
pub job_id: String,
|
||||
pub dedupe_key: String,
|
||||
pub job_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_payload_json: String,
|
||||
pub max_attempts: u32,
|
||||
pub available_at_micros: i64,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobClaimInput {
|
||||
pub worker_id: String,
|
||||
pub limit: u32,
|
||||
pub lease_expires_at_micros: i64,
|
||||
pub claimed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobRenewLeaseInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub lease_expires_at_micros: i64,
|
||||
pub renewed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobCompleteInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub result_payload_json: Option<String>,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobFailInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub error_message: String,
|
||||
pub retry_after_micros: i64,
|
||||
pub failed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobSnapshot {
|
||||
pub job_id: String,
|
||||
pub dedupe_key: String,
|
||||
pub job_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_payload_json: String,
|
||||
pub status: String,
|
||||
pub attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
pub last_error_message: Option<String>,
|
||||
pub worker_id: Option<String>,
|
||||
pub lease_expires_at_micros: Option<i64>,
|
||||
pub available_at_micros: i64,
|
||||
pub result_payload_json: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub started_at_micros: Option<i64>,
|
||||
pub completed_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
pub lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobProcedureResult {
|
||||
pub ok: bool,
|
||||
pub job: Option<ExternalGenerationJobSnapshot>,
|
||||
pub jobs: Vec<ExternalGenerationJobSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn enqueue_external_generation_job_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobEnqueueInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| enqueue_external_generation_job_tx(tx, input.clone())) {
|
||||
Ok(job) => single_external_generation_job_result(job),
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn claim_external_generation_jobs_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobClaimInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| claim_external_generation_jobs_tx(tx, input.clone())) {
|
||||
Ok(jobs) => ExternalGenerationJobProcedureResult {
|
||||
ok: true,
|
||||
job: None,
|
||||
jobs,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn complete_external_generation_job_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobCompleteInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| complete_external_generation_job_tx(tx, input.clone())) {
|
||||
Ok(job) => single_external_generation_job_result(job),
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn renew_external_generation_job_lease_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobRenewLeaseInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| renew_external_generation_job_lease_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,
|
||||
input: ExternalGenerationJobFailInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| fail_external_generation_job_tx(tx, input.clone())) {
|
||||
Ok(job) => single_external_generation_job_result(job),
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_external_generation_job_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobEnqueueInput,
|
||||
) -> Result<ExternalGenerationJobSnapshot, String> {
|
||||
validate_required("external_generation_job.job_id", &input.job_id)?;
|
||||
validate_required("external_generation_job.dedupe_key", &input.dedupe_key)?;
|
||||
validate_required("external_generation_job.job_kind", &input.job_kind)?;
|
||||
validate_required(
|
||||
"external_generation_job.owner_user_id",
|
||||
&input.owner_user_id,
|
||||
)?;
|
||||
validate_required(
|
||||
"external_generation_job.source_module",
|
||||
&input.source_module,
|
||||
)?;
|
||||
validate_required(
|
||||
"external_generation_job.source_entity_id",
|
||||
&input.source_entity_id,
|
||||
)?;
|
||||
validate_required(
|
||||
"external_generation_job.request_label",
|
||||
&input.request_label,
|
||||
)?;
|
||||
validate_required(
|
||||
"external_generation_job.request_payload_json",
|
||||
&input.request_payload_json,
|
||||
)?;
|
||||
|
||||
if let Some(row) = ctx
|
||||
.db
|
||||
.external_generation_job()
|
||||
.dedupe_key()
|
||||
.find(&input.dedupe_key)
|
||||
{
|
||||
return Ok(map_external_generation_job_row(row));
|
||||
}
|
||||
if ctx
|
||||
.db
|
||||
.external_generation_job()
|
||||
.job_id()
|
||||
.find(&input.job_id)
|
||||
.is_some()
|
||||
{
|
||||
return Err("external_generation_job.job_id 已存在".to_string());
|
||||
}
|
||||
|
||||
let now = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
|
||||
let available_at = Timestamp::from_micros_since_unix_epoch(input.available_at_micros);
|
||||
let row = ExternalGenerationJob {
|
||||
job_id: input.job_id.trim().to_string(),
|
||||
dedupe_key: input.dedupe_key.trim().to_string(),
|
||||
job_kind: input.job_kind.trim().to_string(),
|
||||
owner_user_id: input.owner_user_id.trim().to_string(),
|
||||
source_module: input.source_module.trim().to_string(),
|
||||
source_entity_id: input.source_entity_id.trim().to_string(),
|
||||
request_label: input.request_label.trim().to_string(),
|
||||
request_payload_json: input.request_payload_json.trim().to_string(),
|
||||
status: EXTERNAL_GENERATION_STATUS_PENDING.to_string(),
|
||||
attempt: 0,
|
||||
max_attempts: input.max_attempts.max(1),
|
||||
last_error_message: None,
|
||||
worker_id: None,
|
||||
lease_expires_at: None,
|
||||
available_at,
|
||||
result_payload_json: None,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
updated_at: now,
|
||||
lease_token: None,
|
||||
};
|
||||
ctx.db.external_generation_job().insert(row.clone());
|
||||
Ok(map_external_generation_job_row(row))
|
||||
}
|
||||
|
||||
fn claim_external_generation_jobs_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobClaimInput,
|
||||
) -> Result<Vec<ExternalGenerationJobSnapshot>, String> {
|
||||
validate_required("external_generation_job.worker_id", &input.worker_id)?;
|
||||
if input.limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let claim_time = ctx.timestamp;
|
||||
let lease_duration_micros = duration_between_micros(
|
||||
input.lease_expires_at_micros,
|
||||
input.claimed_at_micros,
|
||||
"external_generation_job.lease_duration",
|
||||
)?;
|
||||
let lease_expires_at = timestamp_after_micros(claim_time, lease_duration_micros);
|
||||
let worker_id = input.worker_id.trim().to_string();
|
||||
let limit = input.limit.min(64) as usize;
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
candidates.extend(
|
||||
ctx.db
|
||||
.external_generation_job()
|
||||
.by_external_generation_job_status_available()
|
||||
.filter(&EXTERNAL_GENERATION_STATUS_PENDING.to_string())
|
||||
.filter(|row| is_external_generation_job_claimable(row, claim_time)),
|
||||
);
|
||||
candidates.extend(
|
||||
ctx.db
|
||||
.external_generation_job()
|
||||
.by_external_generation_job_status_available()
|
||||
.filter(&EXTERNAL_GENERATION_STATUS_RUNNING.to_string())
|
||||
.filter(|row| is_external_generation_job_claimable(row, claim_time)),
|
||||
);
|
||||
|
||||
candidates.sort_by(|left, right| {
|
||||
left.available_at
|
||||
.to_micros_since_unix_epoch()
|
||||
.cmp(&right.available_at.to_micros_since_unix_epoch())
|
||||
.then_with(|| {
|
||||
left.created_at
|
||||
.to_micros_since_unix_epoch()
|
||||
.cmp(&right.created_at.to_micros_since_unix_epoch())
|
||||
})
|
||||
.then_with(|| left.job_id.cmp(&right.job_id))
|
||||
});
|
||||
|
||||
let mut claimed = Vec::new();
|
||||
for mut row in candidates.into_iter().take(limit) {
|
||||
let next_attempt = row.attempt.saturating_add(1);
|
||||
let lease_token = build_external_generation_lease_token(
|
||||
&row.job_id,
|
||||
&worker_id,
|
||||
next_attempt,
|
||||
claim_time,
|
||||
);
|
||||
row.status = EXTERNAL_GENERATION_STATUS_RUNNING.to_string();
|
||||
row.worker_id = Some(worker_id.clone());
|
||||
row.lease_expires_at = Some(lease_expires_at);
|
||||
row.lease_token = Some(lease_token);
|
||||
row.attempt = next_attempt;
|
||||
if row.started_at.is_none() {
|
||||
row.started_at = Some(claim_time);
|
||||
}
|
||||
row.updated_at = claim_time;
|
||||
persist_external_generation_job_row(ctx, row.clone());
|
||||
claimed.push(map_external_generation_job_row(row));
|
||||
}
|
||||
|
||||
Ok(claimed)
|
||||
}
|
||||
|
||||
fn complete_external_generation_job_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobCompleteInput,
|
||||
) -> Result<ExternalGenerationJobSnapshot, String> {
|
||||
let mut row = get_worker_owned_external_generation_job(
|
||||
ctx,
|
||||
&input.job_id,
|
||||
&input.worker_id,
|
||||
&input.lease_token,
|
||||
)?;
|
||||
let completed_at = ctx.timestamp;
|
||||
row.status = EXTERNAL_GENERATION_STATUS_COMPLETED.to_string();
|
||||
row.result_payload_json = input
|
||||
.result_payload_json
|
||||
.and_then(|value| normalize_optional_text(value.as_str()));
|
||||
row.lease_expires_at = None;
|
||||
row.completed_at = Some(completed_at);
|
||||
row.updated_at = completed_at;
|
||||
persist_external_generation_job_row(ctx, row.clone());
|
||||
Ok(map_external_generation_job_row(row))
|
||||
}
|
||||
|
||||
fn renew_external_generation_job_lease_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobRenewLeaseInput,
|
||||
) -> Result<ExternalGenerationJobSnapshot, String> {
|
||||
let mut row = get_worker_owned_external_generation_job(
|
||||
ctx,
|
||||
&input.job_id,
|
||||
&input.worker_id,
|
||||
&input.lease_token,
|
||||
)?;
|
||||
let renewed_at = ctx.timestamp;
|
||||
let lease_duration_micros = duration_between_micros(
|
||||
input.lease_expires_at_micros,
|
||||
input.renewed_at_micros,
|
||||
"external_generation_job.lease_duration",
|
||||
)?;
|
||||
row.lease_expires_at = Some(timestamp_after_micros(renewed_at, lease_duration_micros));
|
||||
row.updated_at = renewed_at;
|
||||
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,
|
||||
) -> Result<ExternalGenerationJobSnapshot, String> {
|
||||
let error_message = input.error_message.trim();
|
||||
if error_message.is_empty() {
|
||||
return Err("external_generation_job.error_message 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut row = get_worker_owned_external_generation_job(
|
||||
ctx,
|
||||
&input.job_id,
|
||||
&input.worker_id,
|
||||
&input.lease_token,
|
||||
)?;
|
||||
let failed_at = ctx.timestamp;
|
||||
let retry_delay_micros = duration_between_micros(
|
||||
input.retry_after_micros,
|
||||
input.failed_at_micros,
|
||||
"external_generation_job.retry_delay",
|
||||
)?;
|
||||
row.last_error_message = Some(error_message.to_string());
|
||||
row.lease_expires_at = None;
|
||||
row.worker_id = None;
|
||||
row.lease_token = None;
|
||||
row.updated_at = failed_at;
|
||||
|
||||
if row.attempt < row.max_attempts {
|
||||
row.status = EXTERNAL_GENERATION_STATUS_PENDING.to_string();
|
||||
row.available_at = timestamp_after_micros(failed_at, retry_delay_micros);
|
||||
} else {
|
||||
row.status = EXTERNAL_GENERATION_STATUS_FAILED.to_string();
|
||||
row.completed_at = Some(failed_at);
|
||||
}
|
||||
|
||||
persist_external_generation_job_row(ctx, row.clone());
|
||||
Ok(map_external_generation_job_row(row))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_external_generation_job_lease_for_tx(
|
||||
ctx: &ReducerContext,
|
||||
job_id: &str,
|
||||
worker_id: &str,
|
||||
lease_token: &str,
|
||||
expected_job_kinds: &[&str],
|
||||
expected_owner_user_id: &str,
|
||||
expected_source_module: &str,
|
||||
expected_source_entity_ids: &[String],
|
||||
) -> Result<(), String> {
|
||||
let row = get_worker_owned_external_generation_job(ctx, job_id, worker_id, lease_token)?;
|
||||
if !expected_job_kinds.is_empty()
|
||||
&& !expected_job_kinds
|
||||
.iter()
|
||||
.any(|expected| row.job_kind.trim() == expected.trim())
|
||||
{
|
||||
return Err("external_generation_job job_kind 与业务写回不匹配".to_string());
|
||||
}
|
||||
if row.owner_user_id.trim() != expected_owner_user_id.trim() {
|
||||
return Err("external_generation_job owner_user_id 与业务写回不匹配".to_string());
|
||||
}
|
||||
if row.source_module.trim() != expected_source_module.trim() {
|
||||
return Err("external_generation_job source_module 与业务写回不匹配".to_string());
|
||||
}
|
||||
if !expected_source_entity_ids
|
||||
.iter()
|
||||
.any(|expected| row.source_entity_id.trim() == expected.trim())
|
||||
{
|
||||
return Err("external_generation_job source_entity_id 与业务写回不匹配".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_worker_owned_external_generation_job(
|
||||
ctx: &ReducerContext,
|
||||
job_id: &str,
|
||||
worker_id: &str,
|
||||
lease_token: &str,
|
||||
) -> Result<ExternalGenerationJob, String> {
|
||||
validate_required("external_generation_job.job_id", job_id)?;
|
||||
validate_required("external_generation_job.worker_id", worker_id)?;
|
||||
validate_required("external_generation_job.lease_token", lease_token)?;
|
||||
let row = ctx
|
||||
.db
|
||||
.external_generation_job()
|
||||
.job_id()
|
||||
.find(&job_id.trim().to_string())
|
||||
.ok_or_else(|| "external_generation_job 不存在".to_string())?;
|
||||
if row.status != EXTERNAL_GENERATION_STATUS_RUNNING {
|
||||
return Err("external_generation_job 当前不是 running 状态".to_string());
|
||||
}
|
||||
if !is_external_generation_job_owned_by_worker(&row, worker_id) {
|
||||
return Err("external_generation_job worker lease 不匹配".to_string());
|
||||
}
|
||||
if !is_external_generation_job_owned_by_lease_token(&row, lease_token) {
|
||||
return Err("external_generation_job lease token 不匹配".to_string());
|
||||
}
|
||||
if !is_external_generation_job_lease_active(&row, ctx.timestamp) {
|
||||
return Err("external_generation_job lease 已过期".to_string());
|
||||
}
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
fn is_external_generation_job_owned_by_worker(
|
||||
row: &ExternalGenerationJob,
|
||||
worker_id: &str,
|
||||
) -> bool {
|
||||
row.worker_id.as_deref() == Some(worker_id.trim())
|
||||
}
|
||||
|
||||
fn is_external_generation_job_owned_by_lease_token(
|
||||
row: &ExternalGenerationJob,
|
||||
lease_token: &str,
|
||||
) -> bool {
|
||||
row.lease_token.as_deref() == Some(lease_token.trim())
|
||||
}
|
||||
|
||||
fn is_external_generation_job_lease_active(row: &ExternalGenerationJob, now: Timestamp) -> bool {
|
||||
row.lease_expires_at
|
||||
.map(|lease_expires_at| lease_expires_at > now)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_external_generation_job_claimable(row: &ExternalGenerationJob, now: Timestamp) -> bool {
|
||||
match row.status.as_str() {
|
||||
EXTERNAL_GENERATION_STATUS_PENDING => row.available_at <= now,
|
||||
EXTERNAL_GENERATION_STATUS_RUNNING => row
|
||||
.lease_expires_at
|
||||
.map(|lease_expires_at| lease_expires_at <= now)
|
||||
.unwrap_or(true),
|
||||
EXTERNAL_GENERATION_STATUS_COMPLETED
|
||||
| EXTERNAL_GENERATION_STATUS_FAILED
|
||||
| EXTERNAL_GENERATION_STATUS_CANCELLED => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_external_generation_job_row(ctx: &ReducerContext, row: ExternalGenerationJob) {
|
||||
ctx.db
|
||||
.external_generation_job()
|
||||
.job_id()
|
||||
.delete(&row.job_id);
|
||||
ctx.db.external_generation_job().insert(row);
|
||||
}
|
||||
|
||||
fn map_external_generation_job_row(row: ExternalGenerationJob) -> ExternalGenerationJobSnapshot {
|
||||
ExternalGenerationJobSnapshot {
|
||||
job_id: row.job_id,
|
||||
dedupe_key: row.dedupe_key,
|
||||
job_kind: row.job_kind,
|
||||
owner_user_id: row.owner_user_id,
|
||||
source_module: row.source_module,
|
||||
source_entity_id: row.source_entity_id,
|
||||
request_label: row.request_label,
|
||||
request_payload_json: row.request_payload_json,
|
||||
status: row.status,
|
||||
attempt: row.attempt,
|
||||
max_attempts: row.max_attempts,
|
||||
last_error_message: row.last_error_message,
|
||||
worker_id: row.worker_id,
|
||||
lease_expires_at_micros: row
|
||||
.lease_expires_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
available_at_micros: row.available_at.to_micros_since_unix_epoch(),
|
||||
result_payload_json: row.result_payload_json,
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
started_at_micros: row
|
||||
.started_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
completed_at_micros: row
|
||||
.completed_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
lease_token: row.lease_token,
|
||||
}
|
||||
}
|
||||
|
||||
fn single_external_generation_job_result(
|
||||
job: ExternalGenerationJobSnapshot,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
ExternalGenerationJobProcedureResult {
|
||||
ok: true,
|
||||
job: Some(job),
|
||||
jobs: Vec::new(),
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_external_generation_job_result(message: String) -> ExternalGenerationJobProcedureResult {
|
||||
ExternalGenerationJobProcedureResult {
|
||||
ok: false,
|
||||
job: None,
|
||||
jobs: Vec::new(),
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_required(field: &str, value: &str) -> Result<(), String> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{field} 不能为空"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn duration_between_micros(later: i64, earlier: i64, field: &str) -> Result<i64, String> {
|
||||
let duration = later.saturating_sub(earlier);
|
||||
if duration <= 0 {
|
||||
return Err(format!("{field} 必须大于 0"));
|
||||
}
|
||||
Ok(duration)
|
||||
}
|
||||
|
||||
fn timestamp_after_micros(timestamp: Timestamp, duration_micros: i64) -> Timestamp {
|
||||
Timestamp::from_micros_since_unix_epoch(
|
||||
timestamp
|
||||
.to_micros_since_unix_epoch()
|
||||
.saturating_add(duration_micros.max(0)),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_external_generation_lease_token(
|
||||
job_id: &str,
|
||||
worker_id: &str,
|
||||
attempt: u32,
|
||||
claimed_at: Timestamp,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}:{}:{}:{}",
|
||||
job_id.trim(),
|
||||
worker_id.trim(),
|
||||
attempt,
|
||||
claimed_at.to_micros_since_unix_epoch()
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: &str) -> Option<String> {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_generation_job_result_failure_is_structured() {
|
||||
let result = failed_external_generation_job_result("失败".to_string());
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.error_message.as_deref(), Some("失败"));
|
||||
assert!(result.jobs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_job_is_claimable_only_after_available_time() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING);
|
||||
row.available_at = micros(1_000);
|
||||
|
||||
assert!(!is_external_generation_job_claimable(&row, micros(999)));
|
||||
assert!(is_external_generation_job_claimable(&row, micros(1_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_job_is_claimable_only_after_lease_expires() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
row.lease_expires_at = Some(micros(2_000));
|
||||
|
||||
assert!(!is_external_generation_job_claimable(&row, micros(1_999)));
|
||||
assert!(is_external_generation_job_claimable(&row, micros(2_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_job_is_not_claimable() {
|
||||
for status in [
|
||||
EXTERNAL_GENERATION_STATUS_COMPLETED,
|
||||
EXTERNAL_GENERATION_STATUS_FAILED,
|
||||
EXTERNAL_GENERATION_STATUS_CANCELLED,
|
||||
] {
|
||||
let row = external_generation_job_fixture(status);
|
||||
|
||||
assert!(!is_external_generation_job_claimable(&row, micros(10_000)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_ownership_requires_matching_trimmed_worker_id() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
row.worker_id = Some("worker-a".to_string());
|
||||
|
||||
assert!(is_external_generation_job_owned_by_worker(
|
||||
&row,
|
||||
" worker-a "
|
||||
));
|
||||
assert!(!is_external_generation_job_owned_by_worker(
|
||||
&row, "worker-b"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_ownership_requires_matching_trimmed_lease_token() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
row.lease_token = Some("job-1:worker-a:1:1000".to_string());
|
||||
|
||||
assert!(is_external_generation_job_owned_by_lease_token(
|
||||
&row,
|
||||
" job-1:worker-a:1:1000 "
|
||||
));
|
||||
assert!(!is_external_generation_job_owned_by_lease_token(
|
||||
&row,
|
||||
"job-1:worker-a:2:2000"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lease_is_active_only_before_expiry() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
row.lease_expires_at = Some(micros(2_000));
|
||||
|
||||
assert!(is_external_generation_job_lease_active(&row, micros(1_999)));
|
||||
assert!(!is_external_generation_job_lease_active(
|
||||
&row,
|
||||
micros(2_000)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lease_token_changes_with_claim_attempt() {
|
||||
let first =
|
||||
build_external_generation_lease_token("extgen-test", "worker-a", 1, micros(1_000));
|
||||
let second =
|
||||
build_external_generation_lease_token("extgen-test", "worker-a", 2, micros(2_000));
|
||||
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_duration_between_client_times_is_preserved() {
|
||||
assert_eq!(
|
||||
duration_between_micros(3_500, 1_000, "external_generation_job.lease_duration"),
|
||||
Ok(2_500),
|
||||
);
|
||||
assert!(duration_between_micros(1_000, 1_000, "duration").is_err());
|
||||
}
|
||||
|
||||
fn external_generation_job_fixture(status: &str) -> ExternalGenerationJob {
|
||||
ExternalGenerationJob {
|
||||
job_id: "extgen-test".to_string(),
|
||||
dedupe_key: "puzzle:compile:test".to_string(),
|
||||
job_kind: "puzzle_compile_draft".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_module: "puzzle".to_string(),
|
||||
source_entity_id: "session-1".to_string(),
|
||||
request_label: "拼图首关草稿生成".to_string(),
|
||||
request_payload_json: r#"{"sessionId":"session-1"}"#.to_string(),
|
||||
status: status.to_string(),
|
||||
attempt: 0,
|
||||
max_attempts: 1,
|
||||
last_error_message: None,
|
||||
worker_id: None,
|
||||
lease_expires_at: None,
|
||||
available_at: micros(0),
|
||||
result_payload_json: None,
|
||||
created_at: micros(0),
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
updated_at: micros(0),
|
||||
lease_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn micros(value: i64) -> Timestamp {
|
||||
Timestamp::from_micros_since_unix_epoch(value)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ mod big_fish;
|
||||
mod custom_world;
|
||||
mod domain_types;
|
||||
mod entry;
|
||||
mod external_generation;
|
||||
mod gameplay;
|
||||
mod jump_hop;
|
||||
mod match3d;
|
||||
@@ -49,6 +50,7 @@ pub use big_fish::*;
|
||||
pub use custom_world::*;
|
||||
pub use domain_types::*;
|
||||
pub use entry::*;
|
||||
pub use external_generation::*;
|
||||
pub use gameplay::*;
|
||||
pub use jump_hop::*;
|
||||
pub use match3d::*;
|
||||
|
||||
@@ -178,6 +178,7 @@ macro_rules! migration_tables {
|
||||
ai_text_chunk,
|
||||
ai_result_reference,
|
||||
ai_task_event,
|
||||
external_generation_job,
|
||||
runtime_snapshot,
|
||||
runtime_setting,
|
||||
creation_entry_config,
|
||||
|
||||
@@ -12,16 +12,17 @@ use module_puzzle::{
|
||||
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput,
|
||||
PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
|
||||
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus,
|
||||
PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput,
|
||||
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
|
||||
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
|
||||
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput,
|
||||
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||
PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
|
||||
PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
||||
apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed,
|
||||
build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack,
|
||||
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleLevelGenerationFailureInput,
|
||||
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft,
|
||||
PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput,
|
||||
PuzzleRunProcedureResult, PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput,
|
||||
PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
|
||||
PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
|
||||
PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput,
|
||||
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput,
|
||||
PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft,
|
||||
apply_selected_candidate, build_form_draft_from_seed, build_result_preview,
|
||||
compile_result_draft_from_seed, create_work_profile, infer_anchor_pack,
|
||||
mark_failed_puzzle_result_draft_generation, normalize_puzzle_draft, normalize_puzzle_levels,
|
||||
normalize_theme_tags, publish_work_profile, replace_puzzle_level, select_next_profiles,
|
||||
selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score,
|
||||
@@ -36,9 +37,14 @@ use spacetimedb::{
|
||||
};
|
||||
|
||||
use crate::auth::user_account;
|
||||
use crate::validate_external_generation_job_lease_for_tx;
|
||||
|
||||
const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0;
|
||||
const WORK_VISIBLE_DEFAULT: bool = true;
|
||||
const PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE: &str = "puzzle";
|
||||
const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft";
|
||||
const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images";
|
||||
const PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND: &str = "puzzle_generate_ui_background";
|
||||
|
||||
/// 拼图 Agent session 真相表。
|
||||
/// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。
|
||||
@@ -388,6 +394,25 @@ pub fn mark_puzzle_draft_generation_failed(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn mark_puzzle_level_generation_failed(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: PuzzleLevelGenerationFailureInput,
|
||||
) -> PuzzleAgentSessionProcedureResult {
|
||||
match ctx.try_with_tx(|tx| mark_puzzle_level_generation_failed_tx(tx, input.clone())) {
|
||||
Ok(session) => PuzzleAgentSessionProcedureResult {
|
||||
ok: true,
|
||||
session: Some(session),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||||
ok: false,
|
||||
session: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存拼图入口表单草稿。
|
||||
/// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。
|
||||
#[spacetimedb::procedure]
|
||||
@@ -978,6 +1003,26 @@ fn compile_puzzle_agent_draft_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleDraftCompileInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
match (
|
||||
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()),
|
||||
}
|
||||
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());
|
||||
@@ -1028,6 +1073,16 @@ fn mark_puzzle_draft_generation_failed_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleDraftCompileFailureInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
&input.external_generation_job_id,
|
||||
&input.external_generation_worker_id,
|
||||
&input.external_generation_lease_token,
|
||||
&[PUZZLE_COMPILE_DRAFT_JOB_KIND],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
None,
|
||||
)?;
|
||||
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);
|
||||
let draft = match deserialize_optional_draft(&row.draft_json)? {
|
||||
@@ -1079,6 +1134,88 @@ fn mark_puzzle_draft_generation_failed_tx(
|
||||
)
|
||||
}
|
||||
|
||||
fn mark_puzzle_level_generation_failed_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleLevelGenerationFailureInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
&input.external_generation_job_id,
|
||||
&input.external_generation_worker_id,
|
||||
&input.external_generation_lease_token,
|
||||
&[
|
||||
PUZZLE_GENERATE_IMAGES_JOB_KIND,
|
||||
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND,
|
||||
],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
input.level_id.as_deref(),
|
||||
)?;
|
||||
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);
|
||||
let mut draft = match deserialize_optional_draft(&row.draft_json)? {
|
||||
Some(draft) => draft,
|
||||
None => {
|
||||
let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?;
|
||||
let messages = list_session_messages(ctx, &row.session_id);
|
||||
compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text))
|
||||
}
|
||||
};
|
||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||
// 中文注释:新增关卡可能还没完成自动保存,失败回写必须以本次 action 快照作为目标集合。
|
||||
draft.levels = levels;
|
||||
}
|
||||
draft = mark_puzzle_level_generation_failed_draft(draft, input.level_id.as_deref())?;
|
||||
let next_stage = resolve_failed_puzzle_agent_stage(row.stage, &draft);
|
||||
upsert_puzzle_draft_work_profile(
|
||||
ctx,
|
||||
&row.session_id,
|
||||
&row.owner_user_id,
|
||||
&draft,
|
||||
input.failed_at_micros,
|
||||
)?;
|
||||
|
||||
replace_puzzle_agent_session(
|
||||
ctx,
|
||||
&row,
|
||||
PuzzleAgentSessionRow {
|
||||
session_id: row.session_id.clone(),
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
seed_text: row.seed_text.clone(),
|
||||
current_turn: row.current_turn,
|
||||
progress_percent: row.progress_percent.max(94),
|
||||
stage: next_stage,
|
||||
anchor_pack_json: row.anchor_pack_json.clone(),
|
||||
draft_json: Some(serialize_json(&draft)),
|
||||
last_assistant_reply: Some(input.error_message),
|
||||
published_profile_id: row.published_profile_id.clone(),
|
||||
created_at: row.created_at,
|
||||
updated_at,
|
||||
},
|
||||
);
|
||||
|
||||
get_puzzle_agent_session_tx(
|
||||
ctx,
|
||||
PuzzleAgentSessionGetInput {
|
||||
session_id: input.session_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn mark_puzzle_level_generation_failed_draft(
|
||||
draft: PuzzleResultDraft,
|
||||
level_id: Option<&str>,
|
||||
) -> Result<PuzzleResultDraft, String> {
|
||||
let target_level =
|
||||
selected_puzzle_level(&draft, level_id).ok_or_else(|| "拼图关卡不存在".to_string())?;
|
||||
let mut next_level = target_level;
|
||||
next_level.generation_status = "failed".to_string();
|
||||
let mut draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
|
||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
fn resolve_failed_puzzle_agent_stage(
|
||||
current_stage: PuzzleAgentStage,
|
||||
draft: &PuzzleResultDraft,
|
||||
@@ -1094,6 +1231,34 @@ fn resolve_failed_puzzle_agent_stage(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_puzzle_external_generation_write_guard(
|
||||
ctx: &TxContext,
|
||||
job_id: &str,
|
||||
worker_id: &str,
|
||||
lease_token: &str,
|
||||
expected_job_kinds: &[&str],
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
level_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let session_entity_id = session_id.trim().to_string();
|
||||
let mut source_entity_ids = vec![session_entity_id.clone()];
|
||||
if let Some(level_id) = level_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
source_entity_ids.push(format!("{session_entity_id}:{level_id}"));
|
||||
}
|
||||
|
||||
validate_external_generation_job_lease_for_tx(
|
||||
ctx.as_ref(),
|
||||
job_id,
|
||||
worker_id,
|
||||
lease_token,
|
||||
expected_job_kinds,
|
||||
owner_user_id,
|
||||
PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE,
|
||||
&source_entity_ids,
|
||||
)
|
||||
}
|
||||
|
||||
fn save_puzzle_form_draft_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleFormDraftSaveInput,
|
||||
@@ -1151,6 +1316,19 @@ fn save_puzzle_generated_images_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleGeneratedImagesSaveInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
&input.external_generation_job_id,
|
||||
&input.external_generation_worker_id,
|
||||
&input.external_generation_lease_token,
|
||||
&[
|
||||
PUZZLE_COMPILE_DRAFT_JOB_KIND,
|
||||
PUZZLE_GENERATE_IMAGES_JOB_KIND,
|
||||
],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
input.level_id.as_deref(),
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let mut draft = deserialize_draft_required(&row.draft_json)?;
|
||||
let previous_primary_level_name = draft.level_name.clone();
|
||||
@@ -1235,6 +1413,16 @@ fn save_puzzle_ui_background_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleUiBackgroundSaveInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
&input.external_generation_job_id,
|
||||
&input.external_generation_worker_id,
|
||||
&input.external_generation_lease_token,
|
||||
&[PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
input.level_id.as_deref(),
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let mut draft = deserialize_draft_required(&row.draft_json)?;
|
||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||
@@ -3897,6 +4085,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_generation_failure_only_marks_target_level_failed() {
|
||||
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
|
||||
let mut draft = compile_result_draft_from_seed(
|
||||
&anchor_pack,
|
||||
&[],
|
||||
Some("画面描述:一只猫在雨夜灯牌下回头。"),
|
||||
);
|
||||
draft.levels[0].generation_status = "ready".to_string();
|
||||
draft.levels[0].cover_image_src = Some("/generated-puzzle-assets/first.png".to_string());
|
||||
let mut second_level = draft.levels[0].clone();
|
||||
second_level.level_id = "puzzle-level-2".to_string();
|
||||
second_level.level_name = "第二关".to_string();
|
||||
second_level.picture_description = "第二关画面".to_string();
|
||||
second_level.cover_image_src = None;
|
||||
second_level.cover_asset_id = None;
|
||||
second_level.candidates = Vec::new();
|
||||
second_level.selected_candidate_id = None;
|
||||
second_level.generation_status = "generating".to_string();
|
||||
draft.levels.push(second_level);
|
||||
|
||||
let failed = mark_puzzle_level_generation_failed_draft(draft, Some("puzzle-level-2"))
|
||||
.expect("target level should be marked failed");
|
||||
|
||||
assert_eq!(failed.levels[0].generation_status, "ready");
|
||||
assert_eq!(
|
||||
failed.levels[0].cover_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/first.png")
|
||||
);
|
||||
assert_eq!(failed.levels[1].generation_status, "failed");
|
||||
assert_eq!(failed.generation_status, "ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_recommendation_score_prefers_same_author_weight() {
|
||||
let left = PuzzleWorkProfile {
|
||||
|
||||
@@ -112,6 +112,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
|
||||
import {
|
||||
buildPublicWorkStagePath,
|
||||
pushAppHistoryPath,
|
||||
resolvePathForSelectionStage,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
|
||||
import {
|
||||
@@ -623,9 +624,143 @@ async function buildRecommendRuntimeAuthOptions(
|
||||
return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||
}
|
||||
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
|
||||
const PUZZLE_BACKGROUND_ACTION_POLL_INTERVAL_MS = 3000;
|
||||
const PUZZLE_BACKGROUND_ACTION_MAX_POLL_ATTEMPTS = 160;
|
||||
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
|
||||
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3;
|
||||
|
||||
function isPuzzleBackgroundAction(payload: PuzzleAgentActionRequest) {
|
||||
return (
|
||||
payload.action === 'generate_puzzle_images' ||
|
||||
payload.action === 'generate_puzzle_ui_background'
|
||||
);
|
||||
}
|
||||
|
||||
function findPuzzleActionLevel(
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined,
|
||||
payload: PuzzleAgentActionRequest,
|
||||
) {
|
||||
const levels = session?.draft?.levels ?? [];
|
||||
const targetLevelId =
|
||||
'levelId' in payload ? payload.levelId?.trim() : undefined;
|
||||
if (targetLevelId) {
|
||||
return levels.find((level) => level.levelId === targetLevelId) ?? null;
|
||||
}
|
||||
|
||||
return levels[0] ?? null;
|
||||
}
|
||||
|
||||
function buildPuzzleGeneratedImageSignature(
|
||||
level: PuzzleDraftLevel | null | undefined,
|
||||
) {
|
||||
if (!level) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [
|
||||
level.levelName,
|
||||
level.selectedCandidateId ?? '',
|
||||
level.coverImageSrc ?? '',
|
||||
level.coverAssetId ?? '',
|
||||
level.levelSceneImageSrc ?? '',
|
||||
level.levelSceneImageObjectKey ?? '',
|
||||
level.uiSpritesheetImageSrc ?? '',
|
||||
level.uiSpritesheetImageObjectKey ?? '',
|
||||
level.levelBackgroundImageSrc ?? '',
|
||||
level.levelBackgroundImageObjectKey ?? '',
|
||||
(level.candidates ?? [])
|
||||
.map((candidate) =>
|
||||
[
|
||||
candidate.candidateId,
|
||||
candidate.imageSrc,
|
||||
candidate.assetId,
|
||||
String(candidate.selected),
|
||||
].join(':'),
|
||||
)
|
||||
.join('|'),
|
||||
].join('::');
|
||||
}
|
||||
|
||||
function buildPuzzleUiBackgroundSignature(
|
||||
level: PuzzleDraftLevel | null | undefined,
|
||||
) {
|
||||
if (!level) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [
|
||||
level.uiBackgroundPrompt ?? '',
|
||||
level.uiBackgroundImageSrc ?? '',
|
||||
level.uiBackgroundImageObjectKey ?? '',
|
||||
].join('::');
|
||||
}
|
||||
|
||||
function buildPuzzleBackgroundActionSignature(
|
||||
payload: PuzzleAgentActionRequest,
|
||||
level: PuzzleDraftLevel | null | undefined,
|
||||
) {
|
||||
if (payload.action === 'generate_puzzle_ui_background') {
|
||||
return buildPuzzleUiBackgroundSignature(level);
|
||||
}
|
||||
|
||||
return buildPuzzleGeneratedImageSignature(level);
|
||||
}
|
||||
|
||||
function hasPuzzleBackgroundActionAsset(
|
||||
payload: PuzzleAgentActionRequest,
|
||||
level: PuzzleDraftLevel | null | undefined,
|
||||
) {
|
||||
if (!level) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.action === 'generate_puzzle_ui_background') {
|
||||
return Boolean(level.uiBackgroundImageSrc?.trim());
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
level.coverImageSrc?.trim() ||
|
||||
level.candidates?.some((candidate) => candidate.imageSrc.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
function isPuzzleBackgroundActionSettled(
|
||||
payload: PuzzleAgentActionRequest,
|
||||
baselineSession: PuzzleAgentSessionSnapshot,
|
||||
latestSession: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const latestLevel = findPuzzleActionLevel(latestSession, payload);
|
||||
if (!latestLevel) {
|
||||
return false;
|
||||
}
|
||||
if (latestLevel.generationStatus === 'failed') {
|
||||
return true;
|
||||
}
|
||||
if (latestLevel.generationStatus === 'generating') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baselineSignature = buildPuzzleBackgroundActionSignature(
|
||||
payload,
|
||||
findPuzzleActionLevel(baselineSession, payload),
|
||||
);
|
||||
const latestSignature = buildPuzzleBackgroundActionSignature(
|
||||
payload,
|
||||
latestLevel,
|
||||
);
|
||||
|
||||
return (
|
||||
hasPuzzleBackgroundActionAsset(payload, latestLevel) &&
|
||||
latestSignature !== baselineSignature
|
||||
);
|
||||
}
|
||||
|
||||
function waitForPuzzleBackgroundActionPollTick() {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, PUZZLE_BACKGROUND_ACTION_POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
|
||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||
const timestamp = new Date(rawTime).getTime();
|
||||
@@ -2361,7 +2496,9 @@ function buildMatch3DFormPayloadFromSession(
|
||||
seedText: themeText,
|
||||
themeText,
|
||||
referenceImageSrc:
|
||||
session.config?.referenceImageSrc ?? session.draft?.referenceImageSrc ?? null,
|
||||
session.config?.referenceImageSrc ??
|
||||
session.draft?.referenceImageSrc ??
|
||||
null,
|
||||
clearCount:
|
||||
session.config?.clearCount ??
|
||||
session.draft?.clearCount ??
|
||||
@@ -2579,6 +2716,22 @@ function hasRecoverableGeneratedPuzzleDraft(
|
||||
);
|
||||
}
|
||||
|
||||
function hasFailedPuzzleDraftGeneration(session: PuzzleAgentSessionSnapshot) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isPersistedDraftFailed(draft.generationStatus) ||
|
||||
Boolean(
|
||||
draft.levels?.some((level) =>
|
||||
isPersistedDraftFailed(level.generationStatus),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] {
|
||||
switch (item.source.kind) {
|
||||
case 'rpg':
|
||||
@@ -3071,7 +3224,8 @@ function buildPuzzleCompileActionFromFormPayload(
|
||||
const pictureDescription =
|
||||
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
|
||||
const workTitle = payload?.workTitle?.trim();
|
||||
const workDescription = payload?.workDescription?.trim() || pictureDescription;
|
||||
const workDescription =
|
||||
payload?.workDescription?.trim() || pictureDescription;
|
||||
|
||||
return {
|
||||
action: 'compile_puzzle_draft',
|
||||
@@ -3736,6 +3890,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
useState<MiniGameDraftGenerationState | null>(null);
|
||||
const [puzzleBackgroundCompileTasks, setPuzzleBackgroundCompileTasks] =
|
||||
useState<Record<string, PuzzleBackgroundCompileTask>>({});
|
||||
const puzzleGenerationViewSnapshotRef = useRef<{
|
||||
payload: CreatePuzzleAgentSessionRequest | null;
|
||||
generationState: MiniGameDraftGenerationState | null;
|
||||
}>({ payload: null, generationState: null });
|
||||
const puzzleRuntimeReturnSessionRef =
|
||||
useRef<PuzzleAgentSessionSnapshot | null>(null);
|
||||
const [miniGameGenerationProgressNowMs, setMiniGameGenerationProgressNowMs] =
|
||||
useState(() => Date.now());
|
||||
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
|
||||
@@ -4109,6 +4269,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
viewedImmediately,
|
||||
);
|
||||
setProfileTaskRefreshKey((current) => current + 1);
|
||||
if (viewedImmediately) {
|
||||
setPendingPlatformTaskCompletionDialog(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const completedAtMs = Date.now();
|
||||
setPendingPlatformTaskCompletionDialog({
|
||||
key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`,
|
||||
@@ -4384,7 +4549,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
const ensureEnoughDraftGenerationPointsFromServer = useCallback(
|
||||
async (pointsCost: number) => {
|
||||
try {
|
||||
const latestDashboard = await getPlatformProfileDashboardWithLocalWalletDelta(
|
||||
const latestDashboard =
|
||||
await getPlatformProfileDashboardWithLocalWalletDelta(
|
||||
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
);
|
||||
platformBootstrap.setProfileDashboard(latestDashboard);
|
||||
@@ -5916,7 +6082,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftFailed('match3d', session.sessionId);
|
||||
markDraftFailed(
|
||||
'match3d',
|
||||
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
|
||||
[
|
||||
session.draft?.profileId,
|
||||
session.publishedProfileId,
|
||||
session.sessionId,
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
try {
|
||||
@@ -6198,7 +6368,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftFailed('square-hole', session.sessionId);
|
||||
markDraftFailed(
|
||||
'square-hole',
|
||||
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
|
||||
[
|
||||
session.draft?.profileId,
|
||||
session.publishedProfileId,
|
||||
session.sessionId,
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
void refreshSquareHoleShelf().catch(() => undefined);
|
||||
@@ -6275,6 +6449,81 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
if (payload.action === 'compile_puzzle_draft') {
|
||||
const openResult = selectionStageRef.current === 'puzzle-generating';
|
||||
if (response.operation.status !== 'completed') {
|
||||
const nextPayload =
|
||||
formPayload ?? buildPuzzleFormPayloadFromSession(response.session);
|
||||
activePuzzleGenerationSessionIdRef.current =
|
||||
response.session.sessionId;
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
setSelectionStage('puzzle-generating');
|
||||
|
||||
if (response.operation.status === 'failed') {
|
||||
const errorMessage =
|
||||
response.operation.error ?? '拼图草稿生成失败,请稍后再试。';
|
||||
const failedGenerationState =
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
createPuzzleDraftGenerationStateFromPayload(
|
||||
nextPayload,
|
||||
response.session,
|
||||
),
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[response.session.sessionId]: {
|
||||
session: response.session,
|
||||
payload: nextPayload,
|
||||
generationState: failedGenerationState,
|
||||
error: errorMessage,
|
||||
},
|
||||
}));
|
||||
setPuzzleGenerationState(failedGenerationState);
|
||||
markPendingDraftFailed('puzzle', response.session.sessionId);
|
||||
markDraftFailed(
|
||||
'puzzle',
|
||||
[
|
||||
response.session.sessionId,
|
||||
buildPuzzleResultWorkId(response.session.sessionId),
|
||||
response.session.publishedProfileId,
|
||||
buildPuzzleResultProfileId(response.session.sessionId),
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
void refreshPuzzleShelf();
|
||||
return { openResult: false };
|
||||
}
|
||||
|
||||
const generatingState = mergePuzzleSessionProgressIntoGenerationState(
|
||||
createPuzzleDraftGenerationStateFromPayload(
|
||||
nextPayload,
|
||||
response.session,
|
||||
),
|
||||
response.session,
|
||||
);
|
||||
setPuzzleGenerationState(generatingState);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[response.session.sessionId]: {
|
||||
session: response.session,
|
||||
payload: nextPayload,
|
||||
generationState: generatingState,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
markDraftGenerating('puzzle', [
|
||||
response.session.sessionId,
|
||||
buildPuzzleResultWorkId(response.session.sessionId),
|
||||
response.session.publishedProfileId,
|
||||
buildPuzzleResultProfileId(response.session.sessionId),
|
||||
]);
|
||||
markPendingDraftGenerating(
|
||||
'puzzle',
|
||||
response.session.sessionId,
|
||||
buildPendingPuzzleDraftMetadata(nextPayload),
|
||||
);
|
||||
return { openResult: false };
|
||||
}
|
||||
setPuzzleGenerationState((current) =>
|
||||
current
|
||||
? resolveFinishedMiniGameDraftGenerationState(current, 'ready', {
|
||||
@@ -6328,6 +6577,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
puzzleRuntimeReturnSessionRef.current = response.session;
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, null),
|
||||
@@ -6756,6 +7006,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
activePuzzleBackgroundCompileTask?.payload ?? puzzleFormDraftPayload;
|
||||
const puzzleGenerationViewError =
|
||||
activePuzzleBackgroundCompileTask?.error ?? puzzleError;
|
||||
puzzleGenerationViewSnapshotRef.current = {
|
||||
payload: puzzleGenerationViewPayload,
|
||||
generationState: puzzleGenerationViewState,
|
||||
};
|
||||
const isPuzzleGenerationViewBusy =
|
||||
isPuzzleBusy ||
|
||||
isMiniGameDraftGenerating(
|
||||
@@ -7170,6 +7424,60 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
setPuzzleSession(latestSession);
|
||||
const snapshot = puzzleGenerationViewSnapshotRef.current;
|
||||
const pollPayload =
|
||||
snapshot.payload ?? buildPuzzleFormPayloadFromSession(latestSession);
|
||||
const pollGenerationState =
|
||||
snapshot.generationState ??
|
||||
createPuzzleDraftGenerationStateFromPayload(
|
||||
pollPayload,
|
||||
latestSession,
|
||||
);
|
||||
if (hasRecoverableGeneratedPuzzleDraft(latestSession)) {
|
||||
await recoverCompletedPuzzleDraftGeneration({
|
||||
sessionId: activePuzzleGenerationSessionId,
|
||||
payload: pollPayload,
|
||||
generationState: pollGenerationState,
|
||||
setSession: setPuzzleSession,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hasFailedPuzzleDraftGeneration(latestSession)) {
|
||||
const errorMessage =
|
||||
latestSession.lastAssistantReply?.trim() ||
|
||||
'拼图草稿生成失败,请稍后再试。';
|
||||
const failedGenerationState =
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
pollGenerationState,
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[activePuzzleGenerationSessionId]: {
|
||||
session: latestSession,
|
||||
payload: pollPayload,
|
||||
generationState: failedGenerationState,
|
||||
error: errorMessage,
|
||||
},
|
||||
}));
|
||||
setPuzzleGenerationState(failedGenerationState);
|
||||
markPendingDraftFailed('puzzle', activePuzzleGenerationSessionId);
|
||||
markDraftFailed(
|
||||
'puzzle',
|
||||
[
|
||||
latestSession.sessionId,
|
||||
buildPuzzleResultWorkId(latestSession.sessionId),
|
||||
latestSession.publishedProfileId,
|
||||
buildPuzzleResultProfileId(latestSession.sessionId),
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
puzzleErrorSetterRef.current(errorMessage);
|
||||
void refreshPuzzleShelf();
|
||||
refreshPlatformDashboardSilently();
|
||||
return;
|
||||
}
|
||||
setPuzzleBackgroundCompileTasks((current) => {
|
||||
const task = current[activePuzzleGenerationSessionId];
|
||||
if (!task) {
|
||||
@@ -7212,6 +7520,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
};
|
||||
}, [
|
||||
activePuzzleGenerationSessionId,
|
||||
markDraftFailed,
|
||||
markPendingDraftFailed,
|
||||
recoverCompletedPuzzleDraftGeneration,
|
||||
refreshPlatformDashboardSilently,
|
||||
refreshPuzzleShelf,
|
||||
shouldPollPuzzleGenerationSession,
|
||||
setPuzzleSession,
|
||||
]);
|
||||
@@ -7561,7 +7874,79 @@ export function PlatformEntryFlowShellImpl({
|
||||
actionPayload,
|
||||
);
|
||||
setPuzzleOperation(response.operation);
|
||||
const openResult = isViewingPuzzleGeneration(nextSession.sessionId);
|
||||
activePuzzleGenerationSessionIdRef.current = response.session.sessionId;
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
const openResult = selectionStageRef.current === 'puzzle-generating';
|
||||
if (response.operation.status !== 'completed') {
|
||||
if (response.operation.status === 'failed') {
|
||||
const errorMessage =
|
||||
response.operation.error ?? '拼图草稿生成失败,请稍后再试。';
|
||||
const failedGenerationState =
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[response.session.sessionId]: {
|
||||
session: response.session,
|
||||
payload,
|
||||
generationState: failedGenerationState,
|
||||
error: errorMessage,
|
||||
},
|
||||
}));
|
||||
markPendingDraftFailed('puzzle', response.session.sessionId);
|
||||
markDraftFailed(
|
||||
'puzzle',
|
||||
[
|
||||
response.session.sessionId,
|
||||
buildPuzzleResultWorkId(response.session.sessionId),
|
||||
response.session.publishedProfileId,
|
||||
buildPuzzleResultProfileId(response.session.sessionId),
|
||||
],
|
||||
errorMessage,
|
||||
!openResult,
|
||||
);
|
||||
void refreshPuzzleShelf();
|
||||
if (openResult) {
|
||||
puzzleFlow.setSession(response.session);
|
||||
setPuzzleError(errorMessage);
|
||||
setPuzzleGenerationState(failedGenerationState);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const generatingState = mergePuzzleSessionProgressIntoGenerationState(
|
||||
generationState,
|
||||
response.session,
|
||||
);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[response.session.sessionId]: {
|
||||
session: response.session,
|
||||
payload,
|
||||
generationState: generatingState,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
markDraftGenerating('puzzle', [
|
||||
response.session.sessionId,
|
||||
buildPuzzleResultWorkId(response.session.sessionId),
|
||||
response.session.publishedProfileId,
|
||||
buildPuzzleResultProfileId(response.session.sessionId),
|
||||
]);
|
||||
markPendingDraftGenerating(
|
||||
'puzzle',
|
||||
response.session.sessionId,
|
||||
buildPendingPuzzleDraftMetadata(payload),
|
||||
);
|
||||
if (openResult) {
|
||||
puzzleFlow.setSession(response.session);
|
||||
setPuzzleGenerationState(generatingState);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const readyGenerationState =
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
@@ -7580,8 +7965,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
|
||||
puzzleFlow.setSession(response.session);
|
||||
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
|
||||
setPuzzleGenerationState(readyGenerationState);
|
||||
}
|
||||
|
||||
@@ -7631,6 +8016,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
puzzleRuntimeReturnSessionRef.current = response.session;
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, null),
|
||||
@@ -10295,6 +10681,67 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const executePuzzleAction = puzzleFlow.executeAction;
|
||||
|
||||
const pollPuzzleBackgroundActionUntilSettled = useCallback(
|
||||
(
|
||||
payload: PuzzleAgentActionRequest,
|
||||
baselineSession: PuzzleAgentSessionSnapshot,
|
||||
) => {
|
||||
if (!isPuzzleBackgroundAction(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < PUZZLE_BACKGROUND_ACTION_MAX_POLL_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
await waitForPuzzleBackgroundActionPollTick();
|
||||
try {
|
||||
const response = await getPuzzleAgentSession(
|
||||
baselineSession.sessionId,
|
||||
);
|
||||
puzzleFlow.setSession(response.session);
|
||||
if (
|
||||
isPuzzleBackgroundActionSettled(
|
||||
payload,
|
||||
baselineSession,
|
||||
response.session,
|
||||
)
|
||||
) {
|
||||
refreshPlatformDashboardSilently();
|
||||
await Promise.allSettled([
|
||||
refreshPuzzleShelf(),
|
||||
refreshPuzzleGallery(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
} catch (pollError) {
|
||||
if (attempt >= 2) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(
|
||||
pollError,
|
||||
'刷新拼图图片生成结果失败。',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPuzzleError('拼图图片仍在后台生成,请稍后刷新草稿查看结果。');
|
||||
})();
|
||||
},
|
||||
[
|
||||
puzzleFlow,
|
||||
refreshPlatformDashboardSilently,
|
||||
refreshPuzzleGallery,
|
||||
refreshPuzzleShelf,
|
||||
resolvePuzzleErrorMessage,
|
||||
setPuzzleError,
|
||||
],
|
||||
);
|
||||
|
||||
const executePuzzleBackgroundAction = useCallback(
|
||||
async (payload: PuzzleAgentActionRequest) => {
|
||||
const targetSession = puzzleSession;
|
||||
@@ -10315,6 +10762,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
setPuzzleOperation(response.operation);
|
||||
puzzleFlow.setSession(response.session);
|
||||
if (
|
||||
isPuzzleBackgroundAction(payload) &&
|
||||
(response.operation.status === 'queued' ||
|
||||
response.operation.status === 'running')
|
||||
) {
|
||||
pollPuzzleBackgroundActionUntilSettled(payload, targetSession);
|
||||
}
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。'));
|
||||
} finally {
|
||||
@@ -10329,6 +10783,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
puzzleFlow,
|
||||
puzzleSession,
|
||||
pollPuzzleBackgroundActionUntilSettled,
|
||||
refreshPlatformDashboardSilently,
|
||||
resolvePuzzleErrorMessage,
|
||||
setPuzzleError,
|
||||
@@ -11010,6 +11465,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
puzzleRuntimeReturnSessionRef.current =
|
||||
puzzleSession?.draft && !isPuzzleFormOnlyDraft(puzzleSession)
|
||||
? puzzleSession
|
||||
: null;
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, options.levelId ?? null),
|
||||
@@ -11033,6 +11492,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const returnFromPuzzleRuntime = useCallback(() => {
|
||||
const targetStage = puzzleRuntimeReturnStage;
|
||||
if (
|
||||
targetStage === 'puzzle-result' &&
|
||||
(!puzzleSession?.draft || isPuzzleFormOnlyDraft(puzzleSession)) &&
|
||||
puzzleRuntimeReturnSessionRef.current
|
||||
) {
|
||||
puzzleFlow.setSession(puzzleRuntimeReturnSessionRef.current);
|
||||
}
|
||||
clearPuzzleRuntimeUrlState();
|
||||
pushAppHistoryPath(resolvePathForSelectionStage(targetStage));
|
||||
selectionStageRef.current = targetStage;
|
||||
setSelectionStage(targetStage);
|
||||
}, [puzzleFlow, puzzleRuntimeReturnStage, puzzleSession, setSelectionStage]);
|
||||
|
||||
const submitBigFishInput = useCallback(
|
||||
async (payload: SubmitBigFishInputRequest) => {
|
||||
if (
|
||||
@@ -17971,7 +18445,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
<UnifiedCreationPage
|
||||
spec={getUnifiedSpec('visual-novel')}
|
||||
onBack={leaveVisualNovelFlow}
|
||||
isBackDisabled={isVisualNovelBusy || isVisualNovelStreamingReply}
|
||||
isBackDisabled={
|
||||
isVisualNovelBusy || isVisualNovelStreamingReply
|
||||
}
|
||||
>
|
||||
<VisualNovelAgentWorkspace
|
||||
session={visualNovelSession}
|
||||
@@ -18205,9 +18681,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
error={puzzleError}
|
||||
hideBackButton={Boolean(puzzleOnboardingDraft)}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
}}
|
||||
onBack={returnFromPuzzleRuntime}
|
||||
onRemodelWork={
|
||||
selectedPuzzleDetail?.publicationStatus === 'published'
|
||||
? remodelCurrentPuzzleRuntimeWork
|
||||
|
||||
@@ -4769,6 +4769,60 @@ test('running puzzle form generation creates a new puzzle draft on same template
|
||||
});
|
||||
});
|
||||
|
||||
test('queued puzzle form generation stays on generation progress', async () => {
|
||||
const user = userEvent.setup();
|
||||
const queuedSession = buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-queued-session-1',
|
||||
progressPercent: 5,
|
||||
lastAssistantReply: '拼图生成任务已进入后台队列。',
|
||||
});
|
||||
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
|
||||
session: queuedSession,
|
||||
});
|
||||
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
|
||||
operation: {
|
||||
operationId: 'compile-puzzle-queued-1',
|
||||
type: 'compile_puzzle_draft',
|
||||
status: 'queued',
|
||||
phaseLabel: '已进入后台队列',
|
||||
phaseDetail: '拼图草稿生成已进入后台队列。',
|
||||
progress: 5,
|
||||
error: null,
|
||||
},
|
||||
session: queuedSession,
|
||||
});
|
||||
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
|
||||
session: {
|
||||
...queuedSession,
|
||||
progressPercent: 12,
|
||||
},
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
|
||||
const progressbar = await screen.findByRole('progressbar', {
|
||||
name: '拼图图片生成进度',
|
||||
});
|
||||
expect(progressbar).toBeTruthy();
|
||||
expect(updatePuzzleWork).not.toHaveBeenCalled();
|
||||
expect(startLocalPuzzleRun).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
expect(window.location.pathname).not.toBe('/runtime/puzzle');
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('saves')).getAllByRole('button', {
|
||||
name: /继续创作《[^》]+》,生成中/u,
|
||||
}).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('failed parallel puzzle generations stay as separate non-generating drafts', async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstSession = buildMockPuzzleAgentSession({
|
||||
|
||||
Reference in New Issue
Block a user