diff --git a/deploy/systemd/genarrative-database-backup.service b/deploy/systemd/genarrative-database-backup.service index 18c5b7e7c..276b9f9ab 100644 --- a/deploy/systemd/genarrative-database-backup.service +++ b/deploy/systemd/genarrative-database-backup.service @@ -9,9 +9,10 @@ User=root Group=root WorkingDirectory=/opt/genarrative/current EnvironmentFile=/etc/genarrative/api-server.env -ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service +ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service # 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。 +# 停止 SpacetimeDB 会连带停止 Requires 它的 API / worker / controller,冷备份后必须显式拉起。 PrivateTmp=true ProtectSystem=full ReadWritePaths=/stdb /var/lib/genarrative diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 82123a2f7..7eed4c0c5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-24 外部生成队列升级为正式生成任务列表 + +- 背景:外部生成队列已经承载画板和玩法的付费生成,但前端只展示排队概览,缺少可追溯任务列表、后端确认状态、完成提示补弹和退款记录到任务的追踪关系。 +- 决策:`external_generation_job` 同时作为正式生成任务列表事实源,保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`;新增 `external_generation_job_event` 追加状态转换审计。BFF 新增当前账号任务列表和 acknowledge 接口;前端只展示后端任务状态,完成 / 失败提示关闭时由后端写确认时间,未确认终态任务在下次登录后按列表集中弹出。任务触发的钱包扣费 / 退款流水 metadata 必须写 `externalGenerationJobId`,本机退款 outbox 重放也保留该任务 ID。 +- 影响范围:`spacetime-module` 外部生成 schema / procedure、`spacetime-client` bindings/facade、`api-server` 外部生成 BFF、worker 失败回写和资产计费退款链路、平台入口“我的”页任务卡和完成提示弹窗。 +- 验证方式:运行 `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 wallet_refund_outbox --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-23 编辑器宣发素材固定 gpt-image-2 - 背景:画板宣发素材的游戏首图、详情五图和运营海报只应使用稳定的宣发图生成链路,不能被图片模型上次选择或旧请求切到 `nanobanana2`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c10e12f88..1e729c8e4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -23,6 +23,14 @@ - 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。 - 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasAssetCanvasBridge.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 +## 画板外部生成排队超时不是失败 + +- 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。 +- 原因:画板生成已经接入后端外部生成任务队列,`queued` / `running` 是正式任务状态;旧前端轮询等待窗口到期时直接抛错,导致正常排队被提交流程 catch 成失败 UI。 +- 处理:`waitForEditorGenerationQueue` 等待超时只返回“仍在后端继续执行”,调用方停止本次前端等待并保留生成中状态;只有后端任务终态为 `failed` 才展示失败。 +- 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。 +- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`。 + ## 编辑器生成按钮显示泥点后仍要查真实钱包预扣 - 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。 @@ -319,12 +327,12 @@ - 验证:`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`。 -## 生产冷备份后 API 不能只依赖 SpacetimeDB 自恢复 +## 生产冷备份后 API 和外部生成 worker 不能只依赖 SpacetimeDB 自恢复 -- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped。 -- 原因:`genarrative-api.service` 配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时 API 会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service` 只传 `--stop-service spacetimedb.service` 而漏掉 `--restart-service-after genarrative-api.service`,备份脚本只会恢复数据库,不会再拉起 API。 -- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service`;仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。 -- 验证:`systemctl cat genarrative-database-backup.service` 应包含该参数;`systemctl is-active spacetimedb.service genarrative-api.service nginx.service` 全为 `active`;`curl -fsS http://127.0.0.1:3101/v1/ping`、`/healthz`、`/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功。 +- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped;或图片画布生成请求返回队列态后长期显示排队,`external_generation_job` 有 claimable pending,但 `genarrative-external-generation-worker@1.service` / controller 是 inactive。 +- 原因:`genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时这些服务会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service` 只恢复数据库或只重启 API,外部生成队列就不会被消费。 +- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service`、`--restart-service-after genarrative-external-generation-worker@1.service` 和 `--restart-service-after genarrative-external-generation-controller.service`;仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。 +- 验证:`systemctl cat genarrative-database-backup.service` 应包含这些参数;`systemctl is-active spacetimedb.service genarrative-api.service genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service nginx.service` 全为 `active`;`curl -fsS http://127.0.0.1:3101/v1/ping`、`/healthz`、`/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功;`get_external_generation_queue_stats_and_return` 不应长期出现 claimable pending。 - 关联:`deploy/systemd/genarrative-database-backup.service`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## Pingora Brotli 不能只看 Content-Encoding @@ -2619,6 +2627,14 @@ - 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。 - 关联:`src/services/jump-hop/jumpHopClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 画布生成完成态不能被旧 autosave 覆盖 + +- 现象:release 外部生成 worker 补跑完成后,生成图已进入素材库或项目资源,但画布生成器仍显示 `generating`;刷新后可能仍看到历史生成框卡住。 +- 原因:画布前端在提交生成后会把 `generating` layout 放入 450ms 自动保存队列;worker 完成后后端会写入 `idle + generatedLayerId + 生成层`,但旧的 pending / in-flight layout save 可能晚到并覆盖完成态。另有历史 inline 请求在 api-server 重启时只留下前端已保存的 `generating` 框,没有终态任务或生成资源。 +- 处理:前端 `applyProjectSnapshot` 必须取消 pending layout save,并跳过一次由后端快照恢复触发的 autosave;后端 `save_editor_project_layout` 要保护已完成的 generation dialog,如果传入旧 `generating` 且无 `generatedLayerId`,而当前 layout 已有同一 dialog 的完成态,则保留完成态和生成层。线上脏数据只在确认无任务 / 无资源时标成 `failed` 并保留原 prompt 供用户重试。 +- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx`;`cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml editor_project_storage --lib`;release 排障用 `list_editor_projects_and_return` / `get_editor_project_and_return` 查 `generation-dialog` 状态,不要只看素材库。 +- 关联:`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`。 + ## Pingora 静态缓存不能只写 Cache-Control - 现象:直连 Pingora 后,HTML 入口虽然是 `Cache-Control: no-cache`,但浏览器每次都重新下载完整入口页或普通静态文件;或者 Vite 指纹资源长期缓存正常,但旧标签页刷新时协商缓存行为和 Nginx 直连不同。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 709d3c21e..6ce4f841d 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -1,6 +1,6 @@ # 外部生成 Worker 化方案 -更新时间:`2026-06-22` +更新时间:`2026-06-24` ## 背景 @@ -26,6 +26,8 @@ - `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`。 +- `list_external_generation_jobs_and_return`:按当前账号读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格和完成提示确认状态。 +- `acknowledge_external_generation_jobs_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入 `notification_acknowledged_at` 并追加审计事件。 - `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。 - `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 @@ -35,10 +37,12 @@ 队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table: -- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于 `我的` 页签、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领。 +- `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 +- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务。 +- `POST /api/runtime/external-generation/jobs/acknowledge`:用户关闭生成完成 / 失败提示时调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 - `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。 -BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见队列概览放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。 +BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端关闭提示只能调用 acknowledge 接口,不能本地永久吞掉任务。 ## 任务表 @@ -63,6 +67,11 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍 | `available_at` | 下次可领取时间 | | `result_payload_json` | 完成摘要 | | `created_at/started_at/completed_at/updated_at` | 审计时间 | +| `price_mud_points` | 后端计算的本任务价格,用于任务列表展示和排障 | +| `refund_ledger_id` | 失败退款产生的钱包退款流水 ID,便于从任务追到退款记录 | +| `notification_acknowledged_at` | 用户已确认完成 / 失败提示的时间,未确认终态任务下次登录继续集中弹出 | + +新增私有审计表 `external_generation_job_event`,记录 `enqueued/claimed/lease_renewed/completed/failed/acknowledged` 等事件。事件表只追加状态转换事实,不作为当前状态源;排障时先看 `external_generation_job` 当前状态,再按 `job_id` 追 `external_generation_job_event` 时间线。 索引: @@ -205,7 +214,7 @@ curl -f http://127.0.0.1:/healthz 本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。 -生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认队列概览 BFF 可读、单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。 +生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、acknowledge 后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。 systemd 生产 controller 与手动兜底示例: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4a3ec9efe..cef069bba 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -1,6 +1,6 @@ # server-rs 与 SpacetimeDB 数据契约 -更新时间:`2026-06-12` +更新时间:`2026-06-24` ## 后端主线 @@ -193,7 +193,7 @@ npm run check:server-rs-ddd 3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。 4. 资产操作的预扣费必须 fail-closed:钱包或 SpacetimeDB 预扣费不可达、超时或返回业务错误时,`api-server` 直接返回错误,不允许继续调用图片、音频、GLB 等外部生成 provider。 5. 需要支持 HTTP retry 的计费 ledger id 必须包含当前请求的 `request_id`;前端 `fetchWithApiAuth` 同一次业务请求的静默刷新重试复用同一个 `x-request-id`,后端不得再使用 prompt 指纹或随机 asset id 作为扣费幂等键。 -6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`。 +6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`。外部生成任务触发的扣费和退款必须在 `profile_wallet_ledger.metadata_json` 中写入 `externalGenerationJobId`,outbox 重放也必须保留同一任务 ID,便于从退款记录追溯到正式生成任务。 7. 拼图首图后台生成的跨实例互斥锁必须落在 SpacetimeDB `puzzle_background_compile_task` 表,claim id 由 `task_id + request_id` 构成,释放时必须校验 claim id,避免旧后台任务释放新请求抢到的租约。 ## 用户钱包与编辑器生成扣费契约 @@ -245,7 +245,13 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 +- 用途:外部生成 worker 的持久任务队列和用户可见生成任务列表;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行同时保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`,BFF 通过 `GET /api/runtime/external-generation/jobs` 返回当前账号的正式生成任务列表、价格、状态和未确认终态数量;前端只能展示该后端事实,完成 / 失败提示关闭时必须调用 `POST /api/runtime/external-generation/jobs/acknowledge` 由后端写确认时间,未确认终态任务会在下次登录后再次集中弹出。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 + +### `external_generation_job_event` + +- Rust 结构体:`ExternalGenerationJobEvent` +- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` +- 用途:外部生成任务审计事件表,按 `job_id` 和 `owner_user_id` 记录 `enqueued`、`claimed`、`lease_renewed`、`completed`、`failed`、`acknowledged` 等状态转换事实。状态转换只能由 SpacetimeDB procedure 写入,不由前端或 worker 直接改表;该表用于追溯任务生命周期和排障,不替代 `external_generation_job` 当前状态。 ### `ai_text_chunk` @@ -679,6 +685,7 @@ npm run check:server-rs-ddd - Rust 结构体:`ProfileWalletLedger` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 说明:账号钱包流水表。`metadata_json` 为可选 JSON 对象字符串,旧行缺失时读取层按 `{}` 归一;外部生成扣费 / 退款写入 `externalGenerationJobId`,使退款记录可以追溯到对应 `external_generation_job`。 ### `profile_wallet_config` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d9df1461e..fbca02498 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -229,10 +229,10 @@ UI 相关修改要重点验证: 数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为 `scripts/database-backup-to-oss.mjs`(npm 命令 `npm run database:backup:oss`);生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份: ```bash -npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service +npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service ``` -脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service` 依赖 `spacetimedb.service`,生产定时冷备份还必须传入 `--restart-service-after genarrative-api.service`,确保备份后 API 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉该参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node -- ... --upload-archive ` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 +脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node -- ... --upload-archive ` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 生产环境变量模板在 `deploy/env/api-server.env.example`: diff --git a/packages/shared/src/contracts/externalGeneration.ts b/packages/shared/src/contracts/externalGeneration.ts index 8fe24f6f8..c2ab99ef3 100644 --- a/packages/shared/src/contracts/externalGeneration.ts +++ b/packages/shared/src/contracts/externalGeneration.ts @@ -7,6 +7,7 @@ export type ExternalGenerationJobStatus = export interface ExternalGenerationQueueOverview { pendingCount: number; runningCount: number; + unacknowledgedTerminalCount: number; updatedAtMicros: number; } @@ -27,3 +28,37 @@ export interface ExternalGenerationJobStatusRecord { export interface ExternalGenerationJobStatusResponse { job: ExternalGenerationJobStatusRecord; } + +export interface ExternalGenerationTaskRecord { + jobId: string; + jobKind: string; + sourceModule: string; + sourceEntityId: string; + requestLabel: string; + status: ExternalGenerationJobStatus; + phaseLabel: string; + phaseDetail: string; + progress: number; + error?: string | null; + priceMudPoints: number; + refundLedgerId?: string | null; + notificationAcknowledgedAt?: string | null; + createdAt: string; + startedAt?: string | null; + completedAt?: string | null; + updatedAt: string; + updatedAtMicros: number; +} + +export interface ExternalGenerationTaskListResponse { + tasks: ExternalGenerationTaskRecord[]; + overview: ExternalGenerationQueueOverview; +} + +export interface ExternalGenerationTaskAcknowledgeRequest { + jobIds: string[]; +} + +export interface ExternalGenerationTaskAcknowledgeResponse { + acknowledgedTasks: ExternalGenerationTaskRecord[]; +} diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 948de009a..4186bf8db 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -8,6 +8,16 @@ const checks = [ includes: '--restart-service-after genarrative-api.service', reason: '生产冷备份恢复 SpacetimeDB 后必须显式拉起依赖它的 API 服务。', }, + { + file: 'deploy/systemd/genarrative-database-backup.service', + includes: '--restart-service-after genarrative-external-generation-worker@1.service', + reason: '生产冷备份恢复 SpacetimeDB 后必须显式拉起保底外部生成 worker。', + }, + { + file: 'deploy/systemd/genarrative-database-backup.service', + includes: '--restart-service-after genarrative-external-generation-controller.service', + reason: '生产冷备份恢复 SpacetimeDB 后必须显式拉起外部生成 worker controller。', + }, { file: 'deploy/systemd/genarrative-database-backup.service', includes: 'ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file', diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index bc2dc80b8..c16ded945 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -12,6 +12,22 @@ use crate::{ pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1; +tokio::task_local! { + static CURRENT_EXTERNAL_GENERATION_JOB_ID: String; +} + +pub(crate) async fn with_external_generation_billing_task_id( + job_id: String, + future: Fut, +) -> T +where + Fut: Future, +{ + CURRENT_EXTERNAL_GENERATION_JOB_ID + .scope(job_id, future) + .await +} + /// 资产操作统一执行入口:业务层只声明操作类型与资源 ID,钱包扣退费由服务层收口。 pub(crate) async fn execute_billable_asset_operation( state: &AppState, @@ -99,11 +115,12 @@ async fn consume_asset_operation_points( ); match state .spacetime_client() - .consume_profile_wallet_points( + .consume_profile_wallet_points_with_metadata( owner_user_id.to_string(), points_cost, ledger_id, current_utc_micros(), + current_wallet_metadata_json(), ) .await { @@ -125,13 +142,16 @@ async fn refund_asset_operation_points( owner_user_id, asset_kind, asset_id ); let created_at_micros = current_utc_micros(); + let external_generation_job_id = current_external_generation_job_id(); + let metadata_json = wallet_metadata_json(external_generation_job_id.as_deref()); if let Err(error) = state .spacetime_client() - .refund_profile_wallet_points( + .refund_profile_wallet_points_with_metadata( owner_user_id.to_string(), points_cost, ledger_id.clone(), created_at_micros, + metadata_json, ) .await { @@ -145,6 +165,7 @@ async fn refund_asset_operation_points( created_at_micros, asset_kind: asset_kind.to_string(), asset_id: asset_id.to_string(), + external_generation_job_id: external_generation_job_id.clone(), }) .await { @@ -153,6 +174,7 @@ async fn refund_asset_operation_points( owner_user_id, asset_kind, asset_id, + external_generation_job_id, ledger_id, error = %refund_error, "资产操作失败后的泥点退款立即执行失败,已写入 wallet refund outbox" @@ -164,6 +186,7 @@ async fn refund_asset_operation_points( owner_user_id, asset_kind, asset_id, + external_generation_job_id, ledger_id, reason, error = %refund_error, @@ -176,6 +199,7 @@ async fn refund_asset_operation_points( owner_user_id, asset_kind, asset_id, + external_generation_job_id, ledger_id, refund_error = %refund_error, outbox_error = %outbox_error, @@ -189,6 +213,7 @@ async fn refund_asset_operation_points( owner_user_id, asset_kind, asset_id, + external_generation_job_id, ledger_id, error = %refund_error, "资产操作失败后的泥点退款失败,且 wallet refund outbox 未启用" @@ -196,6 +221,32 @@ async fn refund_asset_operation_points( } } +fn current_external_generation_job_id() -> Option { + CURRENT_EXTERNAL_GENERATION_JOB_ID + .try_with(|value| value.trim().to_string()) + .ok() + .filter(|value| !value.is_empty()) +} + +fn current_wallet_metadata_json() -> String { + let external_generation_job_id = current_external_generation_job_id(); + wallet_metadata_json(external_generation_job_id.as_deref()) +} + +fn wallet_metadata_json(external_generation_job_id: Option<&str>) -> String { + let Some(external_generation_job_id) = external_generation_job_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(); + }; + + json!({ + "externalGenerationJobId": external_generation_job_id, + }) + .to_string() +} + pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { let message = error.to_string(); tracing::warn!( diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index fc3a325d3..f13006a39 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -545,6 +545,18 @@ pub async fn generate_editor_character_animation( })?; let owner_user_id = authenticated.claims().user_id().to_string(); if !state.config.external_generation_mode.is_inline() { + let pricing = state.editor_generation_pricing().map_err(|error| { + character_animation_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })), + ) + })?; + let normalized = + normalize_editor_character_animation_request_with_pricing(payload.clone(), &pricing) + .map_err(|error| character_animation_error_response(&request_context, error))?; let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), payload.source_layer_id.as_str(), @@ -556,6 +568,7 @@ pub async fn generate_editor_character_animation( EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, source_entity_id, "图片画布生成角色动作", + u64::from(normalized.price_mud_points), &payload, ) .await @@ -760,6 +773,17 @@ pub async fn generate_editor_video( })?; let owner_user_id = authenticated.claims().user_id().to_string(); if !state.config.external_generation_mode.is_inline() { + let pricing = state.editor_generation_pricing().map_err(|error| { + editor_video_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })), + ) + })?; + let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing) + .map_err(|error| editor_video_error_response(&request_context, error))?; let source_entity_id = editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video"); let queue_job = enqueue_editor_generation_job( @@ -769,6 +793,7 @@ pub async fn generate_editor_video( EDITOR_VIDEO_GENERATION_JOB_KIND, source_entity_id, "图片画布生成视频", + u64::from(normalized.price_mud_points), &payload, ) .await diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 44793ef52..b9baf69d5 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1,4 +1,4 @@ -use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; +use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; use platform_llm::{ DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, @@ -1113,21 +1113,17 @@ impl AppConfig { config.ark_character_video_model = ark_character_video_model; } - if let Some(character_animation_ffmpeg_path) = - read_first_non_empty_env(&[ - "GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH", - "CHARACTER_ANIMATION_FFMPEG_PATH", - ]) - { + if let Some(character_animation_ffmpeg_path) = read_first_non_empty_env(&[ + "GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH", + "CHARACTER_ANIMATION_FFMPEG_PATH", + ]) { config.character_animation_ffmpeg_path = character_animation_ffmpeg_path; } - if let Some(character_animation_ffprobe_path) = - read_first_non_empty_env(&[ - "GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH", - "CHARACTER_ANIMATION_FFPROBE_PATH", - ]) - { + if let Some(character_animation_ffprobe_path) = read_first_non_empty_env(&[ + "GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH", + "CHARACTER_ANIMATION_FFPROBE_PATH", + ]) { config.character_animation_ffprobe_path = character_animation_ffprobe_path; } diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index ab41ec044..69c046c74 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -95,10 +95,8 @@ impl EditorGenerationPricingConfig { image_size: Option<&str>, ) -> u32 { let normalized_model = normalize_editor_image_model(model); - let normalized_size = normalize_editor_generation_image_price_size( - normalized_model, - image_size, - ); + let normalized_size = + normalize_editor_generation_image_price_size(normalized_model, image_size); read_tier_price( &self.models, normalized_model, @@ -358,7 +356,10 @@ fn normalize_editor_image_model(model: Option<&str>) -> &'static str { } } -fn normalize_editor_generation_image_price_size(model: &str, image_size: Option<&str>) -> &'static str { +fn normalize_editor_generation_image_price_size( + model: &str, + image_size: Option<&str>, +) -> &'static str { match image_size.map(str::trim).filter(|value| !value.is_empty()) { Some("0.5K" | "0.5k") if model == EDITOR_IMAGE_MODEL_NANOBANANA2 => IMAGE_PRICE_SIZE_0_5K, Some("2K" | "2k") => IMAGE_PRICE_SIZE_2K, @@ -575,7 +576,11 @@ mod tests { EditorGenerationPricingUnit::PerSecond ); assert!(!config.models.contains_key("spec:gpt-image-2")); - assert!(!config.models.contains_key("character-animation:seedance2.0-fast")); + assert!( + !config + .models + .contains_key("character-animation:seedance2.0-fast") + ); } #[test] @@ -597,19 +602,11 @@ mod tests { 12 ); assert_eq!( - editor_image_generation_mud_points( - Some("ui-design"), - Some("gpt-image-2"), - Some("1K") - ), + editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("1K")), 3 ); assert_eq!( - editor_image_generation_mud_points( - Some("ui-design"), - Some("gpt-image-2"), - Some("2K") - ), + editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("2K")), 5 ); assert_eq!( @@ -697,7 +694,10 @@ mod tests { let config = load_editor_generation_pricing_from_paths(Some(&override_path)) .expect("pricing should load"); - assert_eq!(config.image_model_mud_points(Some("gpt-image-2"), Some("2K")), 62); + assert_eq!( + config.image_model_mud_points(Some("gpt-image-2"), Some("2K")), + 62 + ); assert_eq!(config.spec_model_mud_points(Some("gpt-image-2")), 62); assert_eq!( config.video_model_mud_points(Some("seedance2.0"), "720p", 5), diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 08355bdc6..047b6ad75 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -38,6 +38,7 @@ pub(crate) async fn enqueue_editor_generation_job( job_kind: &str, source_entity_id: impl Into, request_label: impl Into, + price_mud_points: u64, payload: &T, ) -> Result where @@ -65,6 +66,7 @@ where max_attempts: 1, available_at_micros: now_micros, created_at_micros: now_micros, + price_mud_points, }) .await .map_err(|error| { diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 04cb30a1d..b15c30b55 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -963,6 +963,34 @@ pub async fn generate_editor_image( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + let normalized_kind = payload.kind.as_deref().map(str::trim); + let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); + let is_publication_material_generation = + matches!(normalized_kind, Some("publication-material")); + let generation_options = normalize_editor_generation_options( + if is_ui_design_generation || is_publication_material_generation { + Some(GPT_IMAGE_2_MODEL) + } else { + payload.model.as_deref() + }, + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + ); + let price_mud_points = u64::from( + state + .editor_generation_pricing() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + })? + .image_generation_mud_points( + normalized_kind, + Some(generation_options.model), + Some(generation_options.image_size), + ), + ); let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-image-generation", @@ -974,6 +1002,7 @@ pub async fn generate_editor_image( EDITOR_IMAGE_GENERATION_JOB_KIND, source_entity_id, "图片画布生成图片", + price_mud_points, &payload, ) .await?; @@ -1439,6 +1468,7 @@ pub async fn edit_editor_image( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + let price_mud_points = u64::from(resolve_editor_image_edit_price(&state)?); let source_entity_id = editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit"); let queue_job = enqueue_editor_generation_job( @@ -1448,6 +1478,7 @@ pub async fn edit_editor_image( EDITOR_IMAGE_EDIT_JOB_KIND, source_entity_id, "图片画布修改图片", + price_mud_points, &payload, ) .await?; @@ -1592,6 +1623,16 @@ pub async fn generate_editor_icon_spritesheet( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + let generation_options = normalize_editor_generation_options( + payload.model.as_deref(), + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + ); + let price_mud_points = u64::from(resolve_editor_icon_spritesheet_price( + &state, + Some(generation_options.model), + Some(generation_options.image_size), + )?); let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-icon-spritesheet-generation", @@ -1603,6 +1644,7 @@ pub async fn generate_editor_icon_spritesheet( EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, source_entity_id, "图片画布生成图标素材", + price_mud_points, &payload, ) .await?; @@ -1860,6 +1902,14 @@ pub async fn extract_editor_ui_design_assets( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + let generation_options = normalize_editor_ui_design_asset_extraction_options( + payload.aspect_ratio.as_str(), + payload.image_size.as_str(), + )?; + let price_mud_points = u64::from(resolve_editor_ui_design_asset_extraction_price( + &state, + Some(generation_options.image_size), + )?); let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-ui-design-asset-extraction", @@ -1871,6 +1921,7 @@ pub async fn extract_editor_ui_design_assets( EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, source_entity_id, "图片画布提取UI设计图素材", + price_mud_points, &payload, ) .await?; diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs index 345dbe493..2ae45190d 100644 --- a/server-rs/crates/api-server/src/external_generation.rs +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -1,18 +1,22 @@ use axum::{ Json, - extract::{Extension, Path, State}, + extract::{Extension, Path, Query, State}, http::StatusCode, response::Response, }; +use serde::Deserialize; use serde_json::json; use shared_contracts::external_generation::{ ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview, - ExternalGenerationQueueOverviewResponse, + ExternalGenerationQueueOverviewResponse, ExternalGenerationTaskAcknowledgeRequest, + ExternalGenerationTaskAcknowledgeResponse, ExternalGenerationTaskListResponse, + ExternalGenerationTaskRecord, }; use spacetime_client::{ - ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, - ExternalGenerationQueueStatsRecord, SpacetimeClientError, + ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobGetRecordInput, + ExternalGenerationJobListRecord, ExternalGenerationJobListRecordInput, + ExternalGenerationJobRecord, SpacetimeClientError, }; use crate::{ @@ -22,20 +26,92 @@ use crate::{ const EXTERNAL_GENERATION_PROVIDER: &str = "external_generation"; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationTaskListQuery { + limit: Option, + include_acknowledged_terminal: Option, +} + pub async fn get_external_generation_queue_overview( State(state): State, Extension(request_context): Extension, + Extension(authenticated): Extension, ) -> Result, Response> { - let stats = state + let owner_user_id = authenticated.claims().user_id().to_string(); + let list = state .spacetime_client() - .get_external_generation_queue_stats() + .list_external_generation_jobs(ExternalGenerationJobListRecordInput { + owner_user_id, + limit: 1, + include_acknowledged_terminal: false, + }) .await .map_err(|error| external_generation_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), ExternalGenerationQueueOverviewResponse { - overview: map_external_generation_queue_overview(stats), + overview: map_external_generation_queue_overview(&list), + }, + )) +} + +pub async fn list_external_generation_tasks( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Query(query): Query, +) -> Result, Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let list = state + .spacetime_client() + .list_external_generation_jobs(ExternalGenerationJobListRecordInput { + owner_user_id, + limit: query.limit.unwrap_or(50).clamp(1, 100), + include_acknowledged_terminal: query.include_acknowledged_terminal.unwrap_or(false), + }) + .await + .map_err(|error| external_generation_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + ExternalGenerationTaskListResponse { + overview: map_external_generation_queue_overview(&list), + tasks: list + .jobs + .into_iter() + .map(map_external_generation_task_record) + .collect(), + }, + )) +} + +pub async fn acknowledge_external_generation_tasks( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let acknowledged = state + .spacetime_client() + .acknowledge_external_generation_jobs(ExternalGenerationJobAcknowledgeRecordInput { + owner_user_id, + job_ids: payload.job_ids, + acknowledged_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| external_generation_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + ExternalGenerationTaskAcknowledgeResponse { + acknowledged_tasks: acknowledged + .jobs + .into_iter() + .map(map_external_generation_task_record) + .collect(), }, )) } @@ -65,12 +141,13 @@ pub async fn get_external_generation_job_status( } fn map_external_generation_queue_overview( - stats: ExternalGenerationQueueStatsRecord, + list: &ExternalGenerationJobListRecord, ) -> ExternalGenerationQueueOverview { ExternalGenerationQueueOverview { - pending_count: stats.pending_count, - running_count: stats.running_active_count, - updated_at_micros: stats.now_micros, + pending_count: list.pending_count, + running_count: list.running_count, + unacknowledged_terminal_count: list.unacknowledged_terminal_count, + updated_at_micros: list.now_micros, } } @@ -85,16 +162,46 @@ fn map_external_generation_job_status( }; ExternalGenerationJobStatusRecord { - operation_id: job.job_id, + operation_id: job.job_id.clone(), status, - phase_label: job.request_label, + phase_label: job.request_label.clone(), phase_detail: phase_detail.to_string(), progress, - error: job.last_error_message, + error: job.last_error_message.clone(), updated_at_micros: job.updated_at_micros, } } +fn map_external_generation_task_record( + job: ExternalGenerationJobRecord, +) -> ExternalGenerationTaskRecord { + let status_record = map_external_generation_job_status(job.clone()); + ExternalGenerationTaskRecord { + job_id: job.job_id, + job_kind: job.job_kind, + source_module: job.source_module, + source_entity_id: job.source_entity_id, + request_label: job.request_label, + status: status_record.status, + phase_label: status_record.phase_label, + phase_detail: status_record.phase_detail, + progress: status_record.progress, + error: status_record.error, + price_mud_points: job.price_mud_points, + refund_ledger_id: job.refund_ledger_id, + notification_acknowledged_at: job.notification_acknowledged_at, + created_at: job.created_at, + started_at: job.started_at, + completed_at: job.completed_at, + updated_at: job.updated_at, + updated_at_micros: job.updated_at_micros, + } +} + +fn current_utc_micros() -> i64 { + shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc()) +} + fn external_generation_error_response( request_context: &RequestContext, error: SpacetimeClientError, diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index d8f7da577..a84440158 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -15,6 +15,7 @@ use tokio::{ use tracing::{error, info, warn}; use crate::{ + asset_billing::with_external_generation_billing_task_id, character_animation_assets::{ generate_editor_character_animation_for_owner, generate_editor_video_for_owner, }, @@ -242,7 +243,10 @@ async fn process_external_generation_job( 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()); + let work = with_external_generation_billing_task_id( + job.job_id.clone(), + process_external_generation_job_once(state.clone(), worker_id.clone(), job.clone()), + ); tokio::pin!(work); let heartbeat = sleep(heartbeat_interval); tokio::pin!(heartbeat); @@ -918,6 +922,7 @@ async fn fail_job( error_message, retry_after_micros: now_micros.saturating_add(60_000_000), failed_at_micros: now_micros, + refund_ledger_id: None, }) .await .map(|_| ()) @@ -1030,6 +1035,10 @@ mod tests { updated_at: "2026-06-03T00:00:00Z".to_string(), updated_at_micros: 1_780_444_800_000_000, lease_token: lease_token.map(ToOwned::to_owned), + price_mud_points: 2, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, } } } diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index bdd44c586..411ceae29 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -301,6 +301,7 @@ async fn enqueue_jump_hop_compile_draft_job( ) -> Result { let job_id = build_prefixed_uuid_id("extgen-"); let now_micros = current_utc_micros(); + let price_mud_points = resolve_jump_hop_generation_points_cost(state).await; let request_payload_json = serde_json::to_string(&JumpHopCompileDraftWorkerPayload { session_id: session_id.to_string(), owner_user_id: owner_user_id.to_string(), @@ -329,6 +330,7 @@ async fn enqueue_jump_hop_compile_draft_job( max_attempts: 1, available_at_micros: now_micros, created_at_micros: now_micros, + price_mud_points, }) .await .map_err(|error| { diff --git a/server-rs/crates/api-server/src/modules/external_generation.rs b/server-rs/crates/api-server/src/modules/external_generation.rs index b65c832d9..1bc073bbd 100644 --- a/server-rs/crates/api-server/src/modules/external_generation.rs +++ b/server-rs/crates/api-server/src/modules/external_generation.rs @@ -1,9 +1,13 @@ -use axum::{Router, middleware, routing::get}; +use axum::{ + Router, middleware, + routing::{get, post}, +}; use crate::{ auth::require_bearer_auth, external_generation::{ - get_external_generation_job_status, get_external_generation_queue_overview, + acknowledge_external_generation_tasks, get_external_generation_job_status, + get_external_generation_queue_overview, list_external_generation_tasks, }, state::AppState, }; @@ -16,6 +20,19 @@ pub fn router(state: AppState) -> Router { middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) + .route( + "/api/runtime/external-generation/jobs", + get(list_external_generation_tasks).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/external-generation/jobs/acknowledge", + post(acknowledge_external_generation_tasks).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/runtime/external-generation/jobs/{job_id}", get(get_external_generation_job_status).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/prompt/character_animation.rs b/server-rs/crates/api-server/src/prompt/character_animation.rs index 492f72e16..ccb28a559 100644 --- a/server-rs/crates/api-server/src/prompt/character_animation.rs +++ b/server-rs/crates/api-server/src/prompt/character_animation.rs @@ -123,7 +123,8 @@ fn build_npc_animation_prompt( "角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(), "动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(), if use_chroma_key { - "背景为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + "背景为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,无其他人物和场景元素,方便后期抠像。" + .to_string() } else { "背景简洁纯净,无复杂场景。".to_string() }, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 721dc729a..e9336e76e 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -820,6 +820,7 @@ pub async fn execute_puzzle_agent_action( max_attempts: 1, available_at_micros: now, created_at_micros: now, + price_mud_points: puzzle_draft_generation_points_cost, }) .await .map_err(|error| { @@ -1045,6 +1046,7 @@ pub async fn execute_puzzle_agent_action( max_attempts: 1, available_at_micros: now, created_at_micros: now, + price_mud_points: PUZZLE_IMAGE_GENERATION_POINTS_COST, }) .await .map_err(|error| { @@ -1187,6 +1189,7 @@ pub async fn execute_puzzle_agent_action( max_attempts: 1, available_at_micros: now, created_at_micros: now, + price_mud_points: PUZZLE_IMAGE_GENERATION_POINTS_COST, }) .await .map_err(|error| { diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 8c88dd71e..139bd161d 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -284,6 +284,7 @@ async fn enqueue_puzzle_clear_compile_draft_job( })), ) })?; + let price_mud_points = resolve_puzzle_clear_generation_points_cost(state).await; state .spacetime_client() .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { @@ -298,6 +299,7 @@ async fn enqueue_puzzle_clear_compile_draft_job( max_attempts: 1, available_at_micros: now_micros, created_at_micros: now_micros, + price_mud_points, }) .await .map_err(|error| { @@ -309,6 +311,15 @@ async fn enqueue_puzzle_clear_compile_draft_job( }) } +async fn resolve_puzzle_clear_generation_points_cost(state: &AppState) -> u64 { + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + PUZZLE_CLEAR_TEMPLATE_ID, + u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ) + .await +} + fn map_puzzle_clear_queue_job_status( job: ExternalGenerationJobRecord, ) -> ExternalGenerationJobStatusRecord { diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index 0296deb8a..2141c3cfe 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -143,6 +143,17 @@ pub async fn generate_editor_sound_effect( let Json(payload) = parse_json_payload(&request_context, payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); if !state.config.external_generation_mode.is_inline() { + let pricing = state.editor_generation_pricing().map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + .into_response_with_context(Some(&request_context)) + })?; + let normalized = + normalize_editor_sound_effect_request_with_pricing(payload.clone(), &pricing) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-sound-effect", @@ -154,6 +165,7 @@ pub async fn generate_editor_sound_effect( EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, source_entity_id, "图片画布生成音效", + u64::from(normalized.price_mud_points), &payload, ) .await @@ -306,6 +318,17 @@ pub async fn generate_editor_background_music( let Json(payload) = parse_json_payload(&request_context, payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); if !state.config.external_generation_mode.is_inline() { + let pricing = state.editor_generation_pricing().map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + .into_response_with_context(Some(&request_context)) + })?; + let normalized = + normalize_editor_background_music_request_with_pricing(payload.clone(), &pricing) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-background-music", @@ -317,6 +340,7 @@ pub async fn generate_editor_background_music( EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, source_entity_id, "图片画布生成背景音乐", + u64::from(normalized.price_mud_points), &payload, ) .await diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs index 0169cd2a2..25e7f9060 100644 --- a/server-rs/crates/api-server/src/wallet_refund_outbox.rs +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -42,6 +42,8 @@ pub(crate) struct WalletRefundOutboxRecord { pub created_at_micros: i64, pub asset_kind: String, pub asset_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_generation_job_id: Option, } #[derive(Debug)] @@ -163,11 +165,12 @@ impl WalletRefundOutbox { match self .spacetime_client - .refund_profile_wallet_points( + .refund_profile_wallet_points_with_metadata( record.owner_user_id.clone(), record.amount, record.ledger_id.clone(), record.created_at_micros, + refund_metadata_json(record.external_generation_job_id.as_deref()), ) .await { @@ -183,6 +186,7 @@ impl WalletRefundOutbox { owner_user_id = %record.owner_user_id, asset_kind = %record.asset_kind, asset_id = %record.asset_id, + external_generation_job_id = ?record.external_generation_job_id, path = %path.display(), "wallet refund outbox 退款已重放并删除文件" ); @@ -237,6 +241,20 @@ impl WalletRefundOutbox { } } +fn refund_metadata_json(external_generation_job_id: Option<&str>) -> String { + let Some(external_generation_job_id) = external_generation_job_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(); + }; + + serde_json::json!({ + "externalGenerationJobId": external_generation_job_id, + }) + .to_string() +} + impl fmt::Debug for WalletRefundOutbox { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WalletRefundOutbox") @@ -344,6 +362,7 @@ mod tests { created_at_micros: 1_713_680_000_000_000, asset_kind: "puzzle_initial_image".to_string(), asset_id: "asset-1".to_string(), + external_generation_job_id: Some("extgen-test".to_string()), } } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index fc2d22684..5b8cc0243 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -287,6 +287,7 @@ async fn enqueue_wooden_fish_generate_image_assets_job( ) -> Result { let job_id = build_prefixed_uuid_id("extgen-"); let now_micros = current_utc_micros(); + let price_mud_points = resolve_wooden_fish_generation_points_cost(state).await; let request_payload_json = serde_json::to_string(&WoodenFishGenerateImageAssetsWorkerPayload { session_id: session_id.to_string(), owner_user_id: owner_user_id.to_string(), @@ -316,6 +317,7 @@ async fn enqueue_wooden_fish_generate_image_assets_job( max_attempts: 1, available_at_micros: now_micros, created_at_micros: now_micros, + price_mud_points, }) .await .map_err(|error| { diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index cca5263e4..4a92491a0 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -839,6 +839,7 @@ pub fn build_runtime_profile_wallet_ledger_entry_record( source_type: snapshot.source_type, created_at: format_utc_micros(snapshot.created_at_micros), created_at_micros: snapshot.created_at_micros, + metadata_json: snapshot.metadata_json, } } diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 9afe3b9b3..0db69e337 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -243,6 +243,22 @@ pub fn build_runtime_profile_wallet_adjustment_input( amount: u64, ledger_id: String, created_at_micros: i64, +) -> Result { + build_runtime_profile_wallet_adjustment_input_with_metadata( + user_id, + amount, + ledger_id, + created_at_micros, + PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(), + ) +} + +pub fn build_runtime_profile_wallet_adjustment_input_with_metadata( + user_id: String, + amount: u64, + ledger_id: String, + created_at_micros: i64, + metadata_json: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let ledger_id = @@ -250,11 +266,13 @@ pub fn build_runtime_profile_wallet_adjustment_input( if amount == 0 || amount > i64::MAX as u64 { return Err(RuntimeProfileFieldError::InvalidWalletAmount); } + let metadata_json = normalize_wallet_metadata_json(metadata_json)?; Ok(RuntimeProfileWalletAdjustmentInput { user_id, amount, ledger_id, created_at_micros, + metadata_json, }) } @@ -998,6 +1016,21 @@ fn normalize_tracking_metadata_json(value: String) -> Result Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()); + } + + let parsed = serde_json::from_str::(trimmed) + .map_err(|_| RuntimeProfileFieldError::InvalidWalletMetadata)?; + if !parsed.is_object() { + return Err(RuntimeProfileFieldError::InvalidWalletMetadata); + } + + serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidWalletMetadata) +} + fn normalize_profile_task_id(value: String) -> Result { normalize_required_string(value).ok_or(RuntimeProfileFieldError::MissingTaskId) } diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 35158b0e2..7f6d08ae5 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1323,6 +1323,7 @@ pub struct RuntimeProfileWalletLedgerEntrySnapshot { pub balance_after: u64, pub source_type: RuntimeProfileWalletLedgerSourceType, pub created_at_micros: i64, + pub metadata_json: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1354,6 +1355,7 @@ pub struct RuntimeProfileWalletAdjustmentInput { pub amount: u64, pub ledger_id: String, pub created_at_micros: i64, + pub metadata_json: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1652,6 +1654,7 @@ pub struct RuntimeProfileWalletLedgerEntryRecord { pub source_type: RuntimeProfileWalletLedgerSourceType, pub created_at: String, pub created_at_micros: i64, + pub metadata_json: String, } #[derive(Clone, Debug, PartialEq)] diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index a9222c0a7..4154505cb 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -46,6 +46,7 @@ pub enum RuntimeProfileFieldError { MissingUserId, MissingLedgerId, InvalidWalletAmount, + InvalidWalletMetadata, InvalidInitialWalletPoints, WalletAmountOverflow, WalletBalanceOverflow, @@ -112,6 +113,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::MissingUserId => f.write_str("profile.user_id 不能为空"), Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"), Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"), + Self::InvalidWalletMetadata => { + f.write_str("profile.wallet_metadata 必须是合法 JSON object") + } Self::InvalidInitialWalletPoints => { f.write_str("profile_wallet_config.initial_mud_points 必须大于 0") } diff --git a/server-rs/crates/shared-contracts/src/external_generation.rs b/server-rs/crates/shared-contracts/src/external_generation.rs index 86de93771..87318593b 100644 --- a/server-rs/crates/shared-contracts/src/external_generation.rs +++ b/server-rs/crates/shared-contracts/src/external_generation.rs @@ -14,6 +14,7 @@ pub enum ExternalGenerationJobStatus { pub struct ExternalGenerationQueueOverview { pub pending_count: u32, pub running_count: u32, + pub unacknowledged_terminal_count: u32, pub updated_at_micros: i64, } @@ -40,3 +41,45 @@ pub struct ExternalGenerationJobStatusRecord { pub struct ExternalGenerationJobStatusResponse { pub job: ExternalGenerationJobStatusRecord, } + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationTaskRecord { + pub job_id: String, + pub job_kind: String, + pub source_module: String, + pub source_entity_id: String, + pub request_label: String, + pub status: ExternalGenerationJobStatus, + pub phase_label: String, + pub phase_detail: String, + pub progress: u8, + pub error: Option, + pub price_mud_points: u64, + pub refund_ledger_id: Option, + pub notification_acknowledged_at: Option, + pub created_at: String, + pub started_at: Option, + pub completed_at: Option, + pub updated_at: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationTaskListResponse { + pub tasks: Vec, + pub overview: ExternalGenerationQueueOverview, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationTaskAcknowledgeRequest { + pub job_ids: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationTaskAcknowledgeResponse { + pub acknowledged_tasks: Vec, +} diff --git a/server-rs/crates/spacetime-client/src/external_generation.rs b/server-rs/crates/spacetime-client/src/external_generation.rs index 7c2635ded..dfa77805b 100644 --- a/server-rs/crates/spacetime-client/src/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/external_generation.rs @@ -152,6 +152,56 @@ impl SpacetimeClient { .await } + pub async fn list_external_generation_jobs( + &self, + input: ExternalGenerationJobListRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "list_external_generation_jobs_and_return", + move |connection, sender| { + connection + .procedures() + .list_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_list_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn acknowledge_external_generation_jobs( + &self, + input: ExternalGenerationJobAcknowledgeRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "acknowledge_external_generation_jobs_and_return", + move |connection, sender| { + connection + .procedures() + .acknowledge_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_list_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn get_external_generation_queue_stats( &self, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index ad2d5c9b3..a4fbd17e6 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -40,16 +40,17 @@ pub use mapper::{ EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput, - ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, - ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, - ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, - ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord, - JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, - JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, - JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, - JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, - JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, - JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, + ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobClaimRecordInput, + ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, + ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, + ExternalGenerationJobListRecord, ExternalGenerationJobListRecordInput, + ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, + ExternalGenerationQueueStatsRecord, 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, @@ -233,7 +234,7 @@ use module_runtime::{ build_runtime_profile_task_config_admin_disable_input, build_runtime_profile_task_config_admin_list_input, build_runtime_profile_task_config_admin_upsert_input, build_runtime_profile_task_config_record, - build_runtime_profile_wallet_adjustment_input, + build_runtime_profile_wallet_adjustment_input_with_metadata, build_runtime_profile_wallet_config_admin_get_input, build_runtime_profile_wallet_config_admin_upsert_input, build_runtime_profile_wallet_config_record, build_runtime_profile_wallet_ledger_entry_record, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 6d8fb2ca1..da294c8d3 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -88,10 +88,12 @@ pub use self::external_api_key::{ ExternalApiKeyRevokeRecordInput, }; pub use self::external_generation::{ - ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, - ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, - ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, - ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord, + ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobClaimRecordInput, + ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, + ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, + ExternalGenerationJobListRecord, ExternalGenerationJobListRecordInput, + ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, + ExternalGenerationQueueStatsRecord, }; pub use self::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, @@ -214,8 +216,8 @@ pub(crate) use self::external_api_key::{ map_external_api_key_list_procedure_result, map_external_api_key_single_procedure_result, }; pub(crate) use self::external_generation::{ - map_external_generation_job_claim_result, map_external_generation_job_procedure_result, - map_external_generation_queue_stats_result, + map_external_generation_job_claim_result, map_external_generation_job_list_result, + map_external_generation_job_procedure_result, map_external_generation_queue_stats_result, }; pub(crate) use self::inventory::{ map_runtime_inventory_state_procedure_result, map_runtime_item_reward_item_snapshot, diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs index 9e3729835..54cd7e046 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -14,6 +14,7 @@ impl From for ExternalGenerationJobEnqu max_attempts: input.max_attempts, available_at_micros: input.available_at_micros, created_at_micros: input.created_at_micros, + price_mud_points: input.price_mud_points, } } } @@ -62,6 +63,7 @@ impl From for ExternalGenerationJobFailInp error_message: input.error_message, retry_after_micros: input.retry_after_micros, failed_at_micros: input.failed_at_micros, + refund_ledger_id: input.refund_ledger_id, } } } @@ -75,6 +77,26 @@ impl From for ExternalGenerationJobGetInput } } +impl From for ExternalGenerationJobListInput { + fn from(input: ExternalGenerationJobListRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + limit: input.limit, + include_acknowledged_terminal: input.include_acknowledged_terminal, + } + } +} + +impl From for ExternalGenerationJobAcknowledgeInput { + fn from(input: ExternalGenerationJobAcknowledgeRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + job_ids: input.job_ids, + acknowledged_at_micros: input.acknowledged_at_micros, + } + } +} + pub(crate) fn map_external_generation_job_procedure_result( result: ExternalGenerationJobProcedureResult, ) -> Result { @@ -103,6 +125,26 @@ pub(crate) fn map_external_generation_job_claim_result( .collect()) } +pub(crate) fn map_external_generation_job_list_result( + result: ExternalGenerationJobProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(ExternalGenerationJobListRecord { + jobs: result + .jobs + .into_iter() + .map(map_external_generation_job_snapshot) + .collect(), + pending_count: result.pending_count, + running_count: result.running_count, + unacknowledged_terminal_count: result.unacknowledged_terminal_count, + now_micros: result.now_micros, + }) +} + pub(crate) fn map_external_generation_queue_stats_result( result: ExternalGenerationQueueStatsProcedureResult, ) -> Result { @@ -155,6 +197,12 @@ fn map_external_generation_job_snapshot( updated_at: format_timestamp_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, lease_token: snapshot.lease_token, + price_mud_points: snapshot.price_mud_points, + refund_ledger_id: snapshot.refund_ledger_id, + notification_acknowledged_at: snapshot + .notification_acknowledged_at_micros + .map(format_timestamp_micros), + notification_acknowledged_at_micros: snapshot.notification_acknowledged_at_micros, } } @@ -171,6 +219,7 @@ pub struct ExternalGenerationJobEnqueueRecordInput { pub max_attempts: u32, pub available_at_micros: i64, pub created_at_micros: i64, + pub price_mud_points: u64, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -207,6 +256,7 @@ pub struct ExternalGenerationJobFailRecordInput { pub error_message: String, pub retry_after_micros: i64, pub failed_at_micros: i64, + pub refund_ledger_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -215,6 +265,20 @@ pub struct ExternalGenerationJobGetRecordInput { pub owner_user_id: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobListRecordInput { + pub owner_user_id: String, + pub limit: u32, + pub include_acknowledged_terminal: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobAcknowledgeRecordInput { + pub owner_user_id: String, + pub job_ids: Vec, + pub acknowledged_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExternalGenerationJobRecord { pub job_id: String, @@ -239,6 +303,19 @@ pub struct ExternalGenerationJobRecord { pub updated_at: String, pub updated_at_micros: i64, pub lease_token: Option, + pub price_mud_points: u64, + pub refund_ledger_id: Option, + pub notification_acknowledged_at: Option, + pub notification_acknowledged_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobListRecord { + pub jobs: Vec, + pub pending_count: u32, + pub running_count: u32, + pub unacknowledged_terminal_count: u32, + pub now_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs index a206a0e88..eda44488b 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs @@ -49,6 +49,7 @@ impl From amount: input.amount, ledger_id: input.ledger_id, created_at_micros: input.created_at_micros, + metadata_json: input.metadata_json, } } } @@ -813,6 +814,7 @@ pub(crate) fn map_runtime_profile_wallet_ledger_entry_snapshot( balance_after: snapshot.balance_after, source_type: map_runtime_profile_wallet_ledger_source_type_back(snapshot.source_type), created_at_micros: snapshot.created_at_micros, + metadata_json: snapshot.metadata_json, } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 8eaba5357..b0c6686ec 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -7,6 +7,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub mod accept_quest_reducer; +pub mod acknowledge_external_generation_jobs_and_return_procedure; pub mod acknowledge_quest_completion_reducer; pub mod admin_disable_profile_redeem_code_procedure; pub mod admin_disable_profile_task_config_procedure; @@ -413,11 +414,15 @@ pub mod external_api_key_revoke_input_type; pub mod external_api_key_snapshot_type; pub mod external_api_key_table; pub mod external_api_key_type; +pub mod external_generation_job_acknowledge_input_type; 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_event_table; +pub mod external_generation_job_event_type; pub mod external_generation_job_fail_input_type; pub mod external_generation_job_get_input_type; +pub mod external_generation_job_list_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; @@ -567,6 +572,7 @@ pub mod list_custom_world_profiles_procedure; pub mod list_custom_world_works_procedure; pub mod list_editor_projects_and_return_procedure; pub mod list_external_api_keys_and_return_procedure; +pub mod list_external_generation_jobs_and_return_procedure; pub mod list_jump_hop_works_procedure; pub mod list_match_3_d_works_procedure; pub mod list_platform_browse_history_procedure; @@ -1224,6 +1230,7 @@ pub mod wooden_fish_works_list_input_type; pub mod wooden_fish_works_procedure_result_type; pub use accept_quest_reducer::accept_quest; +pub use acknowledge_external_generation_jobs_and_return_procedure::acknowledge_external_generation_jobs_and_return; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use admin_disable_profile_task_config_procedure::admin_disable_profile_task_config; @@ -1630,11 +1637,15 @@ pub use external_api_key_revoke_input_type::ExternalApiKeyRevokeInput; pub use external_api_key_snapshot_type::ExternalApiKeySnapshot; pub use external_api_key_table::*; pub use external_api_key_type::ExternalApiKey; +pub use external_generation_job_acknowledge_input_type::ExternalGenerationJobAcknowledgeInput; 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_event_table::*; +pub use external_generation_job_event_type::ExternalGenerationJobEvent; pub use external_generation_job_fail_input_type::ExternalGenerationJobFailInput; pub use external_generation_job_get_input_type::ExternalGenerationJobGetInput; +pub use external_generation_job_list_input_type::ExternalGenerationJobListInput; 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; @@ -1784,6 +1795,7 @@ pub use list_custom_world_profiles_procedure::list_custom_world_profiles; pub use list_custom_world_works_procedure::list_custom_world_works; pub use list_editor_projects_and_return_procedure::list_editor_projects_and_return; pub use list_external_api_keys_and_return_procedure::list_external_api_keys_and_return; +pub use list_external_generation_jobs_and_return_procedure::list_external_generation_jobs_and_return; pub use list_jump_hop_works_procedure::list_jump_hop_works; pub use list_match_3_d_works_procedure::list_match_3_d_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; @@ -2758,6 +2770,7 @@ pub struct DbUpdate { editor_project_resource: __sdk::TableUpdate, external_api_key: __sdk::TableUpdate, external_generation_job: __sdk::TableUpdate, + external_generation_job_event: __sdk::TableUpdate, inventory_slot: __sdk::TableUpdate, jump_hop_agent_session: __sdk::TableUpdate, jump_hop_event: __sdk::TableUpdate, @@ -2992,6 +3005,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "external_generation_job" => db_update.external_generation_job.append( external_generation_job_table::parse_table_update(table_update)?, ), + "external_generation_job_event" => db_update.external_generation_job_event.append( + external_generation_job_event_table::parse_table_update(table_update)?, + ), "inventory_slot" => db_update .inventory_slot .append(inventory_slot_table::parse_table_update(table_update)?), @@ -3485,6 +3501,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.external_generation_job, ) .with_updates_by_pk(|row| &row.job_id); + diff.external_generation_job_event = cache + .apply_diff_to_table::( + "external_generation_job_event", + &self.external_generation_job_event, + ) + .with_updates_by_pk(|row| &row.event_id); diff.inventory_slot = cache .apply_diff_to_table::("inventory_slot", &self.inventory_slot) .with_updates_by_pk(|row| &row.slot_id); @@ -4039,6 +4061,9 @@ impl __sdk::DbUpdate for DbUpdate { "external_generation_job" => db_update .external_generation_job .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "external_generation_job_event" => db_update + .external_generation_job_event + .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)?), @@ -4430,6 +4455,9 @@ impl __sdk::DbUpdate for DbUpdate { "external_generation_job" => db_update .external_generation_job .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "external_generation_job_event" => db_update + .external_generation_job_event + .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)?), @@ -4733,6 +4761,7 @@ pub struct AppliedDiff<'r> { editor_project_resource: __sdk::TableAppliedDiff<'r, EditorProjectResource>, external_api_key: __sdk::TableAppliedDiff<'r, ExternalApiKey>, external_generation_job: __sdk::TableAppliedDiff<'r, ExternalGenerationJob>, + external_generation_job_event: __sdk::TableAppliedDiff<'r, ExternalGenerationJobEvent>, inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>, jump_hop_agent_session: __sdk::TableAppliedDiff<'r, JumpHopAgentSessionRow>, jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>, @@ -5049,6 +5078,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.external_generation_job, event, ); + callbacks.invoke_table_row_callbacks::( + "external_generation_job_event", + &self.external_generation_job_event, + event, + ); callbacks.invoke_table_row_callbacks::( "inventory_slot", &self.inventory_slot, @@ -6143,6 +6177,7 @@ impl __sdk::SpacetimeModule for RemoteModule { editor_project_resource_table::register_table(client_cache); external_api_key_table::register_table(client_cache); external_generation_job_table::register_table(client_cache); + external_generation_job_event_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); @@ -6271,6 +6306,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "editor_project_resource", "external_api_key", "external_generation_job", + "external_generation_job_event", "inventory_slot", "jump_hop_agent_session", "jump_hop_event", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_jobs_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_jobs_and_return_procedure.rs new file mode 100644 index 000000000..e9b1c18fe --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/acknowledge_external_generation_jobs_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::external_generation_job_acknowledge_input_type::ExternalGenerationJobAcknowledgeInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AcknowledgeExternalGenerationJobsAndReturnArgs { + pub input: ExternalGenerationJobAcknowledgeInput, +} + +impl __sdk::InModule for AcknowledgeExternalGenerationJobsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `acknowledge_external_generation_jobs_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait acknowledge_external_generation_jobs_and_return { + fn acknowledge_external_generation_jobs_and_return( + &self, + input: ExternalGenerationJobAcknowledgeInput, + ) { + self.acknowledge_external_generation_jobs_and_return_then(input, |_, _| {}); + } + + fn acknowledge_external_generation_jobs_and_return_then( + &self, + input: ExternalGenerationJobAcknowledgeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl acknowledge_external_generation_jobs_and_return for super::RemoteProcedures { + fn acknowledge_external_generation_jobs_and_return_then( + &self, + input: ExternalGenerationJobAcknowledgeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "acknowledge_external_generation_jobs_and_return", + AcknowledgeExternalGenerationJobsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_acknowledge_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_acknowledge_input_type.rs new file mode 100644 index 000000000..af4e47720 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_acknowledge_input_type.rs @@ -0,0 +1,17 @@ +// 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 ExternalGenerationJobAcknowledgeInput { + pub owner_user_id: String, + pub job_ids: Vec, + pub acknowledged_at_micros: i64, +} + +impl __sdk::InModule for ExternalGenerationJobAcknowledgeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_enqueue_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_enqueue_input_type.rs index 760a5c3e1..0451277ee 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_enqueue_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_enqueue_input_type.rs @@ -18,6 +18,7 @@ pub struct ExternalGenerationJobEnqueueInput { pub max_attempts: u32, pub available_at_micros: i64, pub created_at_micros: i64, + pub price_mud_points: u64, } impl __sdk::InModule for ExternalGenerationJobEnqueueInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_table.rs new file mode 100644 index 000000000..c808b8594 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_table.rs @@ -0,0 +1,166 @@ +// 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_event_type::ExternalGenerationJobEvent; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `external_generation_job_event`. +/// +/// Obtain a handle from the [`ExternalGenerationJobEventTableAccess::external_generation_job_event`] method on [`super::RemoteTables`], +/// like `ctx.db.external_generation_job_event()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.external_generation_job_event().on_insert(...)`. +pub struct ExternalGenerationJobEventTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `external_generation_job_event`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ExternalGenerationJobEventTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ExternalGenerationJobEventTableHandle`], which mediates access to the table `external_generation_job_event`. + fn external_generation_job_event(&self) -> ExternalGenerationJobEventTableHandle<'_>; +} + +impl ExternalGenerationJobEventTableAccess for super::RemoteTables { + fn external_generation_job_event(&self) -> ExternalGenerationJobEventTableHandle<'_> { + ExternalGenerationJobEventTableHandle { + imp: self + .imp + .get_table::("external_generation_job_event"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ExternalGenerationJobEventInsertCallbackId(__sdk::CallbackId); +pub struct ExternalGenerationJobEventDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ExternalGenerationJobEventTableHandle<'ctx> { + type Row = ExternalGenerationJobEvent; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ExternalGenerationJobEventInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ExternalGenerationJobEventInsertCallbackId { + ExternalGenerationJobEventInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ExternalGenerationJobEventInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ExternalGenerationJobEventDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ExternalGenerationJobEventDeleteCallbackId { + ExternalGenerationJobEventDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ExternalGenerationJobEventDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ExternalGenerationJobEventUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ExternalGenerationJobEventTableHandle<'ctx> { + type UpdateCallbackId = ExternalGenerationJobEventUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ExternalGenerationJobEventUpdateCallbackId { + ExternalGenerationJobEventUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ExternalGenerationJobEventUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `event_id` unique index on the table `external_generation_job_event`, +/// which allows point queries on the field of the same name +/// via the [`ExternalGenerationJobEventEventIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.external_generation_job_event().event_id().find(...)`. +pub struct ExternalGenerationJobEventEventIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ExternalGenerationJobEventTableHandle<'ctx> { + /// Get a handle on the `event_id` unique index on the table `external_generation_job_event`. + pub fn event_id(&self) -> ExternalGenerationJobEventEventIdUnique<'ctx> { + ExternalGenerationJobEventEventIdUnique { + imp: self.imp.get_unique_constraint::("event_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ExternalGenerationJobEventEventIdUnique<'ctx> { + /// Find the subscribed row whose `event_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 { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("external_generation_job_event"); + _table.add_unique_constraint::("event_id", |row| &row.event_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ExternalGenerationJobEvent`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait external_generation_job_eventQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ExternalGenerationJobEvent`. + fn external_generation_job_event( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl external_generation_job_eventQueryTableAccess for __sdk::QueryTableAccessor { + fn external_generation_job_event( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("external_generation_job_event") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_type.rs new file mode 100644 index 000000000..32c820175 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_event_type.rs @@ -0,0 +1,70 @@ +// 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 ExternalGenerationJobEvent { + pub event_id: String, + pub job_id: String, + pub owner_user_id: String, + pub event_kind: String, + pub status: String, + pub message: Option, + pub worker_id: Option, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ExternalGenerationJobEvent { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ExternalGenerationJobEvent`. +/// +/// Provides typed access to columns for query building. +pub struct ExternalGenerationJobEventCols { + pub event_id: __sdk::__query_builder::Col, + pub job_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub event_kind: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub message: __sdk::__query_builder::Col>, + pub worker_id: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ExternalGenerationJobEvent { + type Cols = ExternalGenerationJobEventCols; + fn cols(table_name: &'static str) -> Self::Cols { + ExternalGenerationJobEventCols { + event_id: __sdk::__query_builder::Col::new(table_name, "event_id"), + job_id: __sdk::__query_builder::Col::new(table_name, "job_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + event_kind: __sdk::__query_builder::Col::new(table_name, "event_kind"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + message: __sdk::__query_builder::Col::new(table_name, "message"), + worker_id: __sdk::__query_builder::Col::new(table_name, "worker_id"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `ExternalGenerationJobEvent`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ExternalGenerationJobEventIxCols { + pub event_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ExternalGenerationJobEvent { + type IxCols = ExternalGenerationJobEventIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ExternalGenerationJobEventIxCols { + event_id: __sdk::__query_builder::IxCol::new(table_name, "event_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ExternalGenerationJobEvent {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_fail_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_fail_input_type.rs index 54bbaf3c2..5c34d1682 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_fail_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_fail_input_type.rs @@ -13,6 +13,7 @@ pub struct ExternalGenerationJobFailInput { pub error_message: String, pub retry_after_micros: i64, pub failed_at_micros: i64, + pub refund_ledger_id: Option, } impl __sdk::InModule for ExternalGenerationJobFailInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_list_input_type.rs new file mode 100644 index 000000000..03ca6384b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_list_input_type.rs @@ -0,0 +1,17 @@ +// 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 ExternalGenerationJobListInput { + pub owner_user_id: String, + pub limit: u32, + pub include_acknowledged_terminal: bool, +} + +impl __sdk::InModule for ExternalGenerationJobListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_procedure_result_type.rs index 4f3c0f810..48065171c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_procedure_result_type.rs @@ -12,6 +12,10 @@ pub struct ExternalGenerationJobProcedureResult { pub ok: bool, pub job: Option, pub jobs: Vec, + pub pending_count: u32, + pub running_count: u32, + pub unacknowledged_terminal_count: u32, + pub now_micros: i64, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs index 8449e819c..a5a208caf 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs @@ -28,6 +28,9 @@ pub struct ExternalGenerationJobSnapshot { pub completed_at_micros: Option, pub updated_at_micros: i64, pub lease_token: Option, + pub price_mud_points: u64, + pub refund_ledger_id: Option, + pub notification_acknowledged_at_micros: Option, } impl __sdk::InModule for ExternalGenerationJobSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs index 4a4e0afab..ec62daa60 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs @@ -28,6 +28,9 @@ pub struct ExternalGenerationJob { pub completed_at: Option<__sdk::Timestamp>, pub updated_at: __sdk::Timestamp, pub lease_token: Option, + pub price_mud_points: u64, + pub refund_ledger_id: Option, + pub notification_acknowledged_at: Option<__sdk::Timestamp>, } impl __sdk::InModule for ExternalGenerationJob { @@ -60,6 +63,10 @@ pub struct ExternalGenerationJobCols { pub completed_at: __sdk::__query_builder::Col>, pub updated_at: __sdk::__query_builder::Col, pub lease_token: __sdk::__query_builder::Col>, + pub price_mud_points: __sdk::__query_builder::Col, + pub refund_ledger_id: __sdk::__query_builder::Col>, + pub notification_acknowledged_at: + __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ExternalGenerationJob { @@ -93,6 +100,12 @@ impl __sdk::__query_builder::HasCols for ExternalGenerationJob { 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"), + price_mud_points: __sdk::__query_builder::Col::new(table_name, "price_mud_points"), + refund_ledger_id: __sdk::__query_builder::Col::new(table_name, "refund_ledger_id"), + notification_acknowledged_at: __sdk::__query_builder::Col::new( + table_name, + "notification_acknowledged_at", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_jobs_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_jobs_and_return_procedure.rs new file mode 100644 index 000000000..05f3b53ef --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_external_generation_jobs_and_return_procedure.rs @@ -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_list_input_type::ExternalGenerationJobListInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListExternalGenerationJobsAndReturnArgs { + pub input: ExternalGenerationJobListInput, +} + +impl __sdk::InModule for ListExternalGenerationJobsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_external_generation_jobs_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_external_generation_jobs_and_return { + fn list_external_generation_jobs_and_return(&self, input: ExternalGenerationJobListInput) { + self.list_external_generation_jobs_and_return_then(input, |_, _| {}); + } + + fn list_external_generation_jobs_and_return_then( + &self, + input: ExternalGenerationJobListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_external_generation_jobs_and_return for super::RemoteProcedures { + fn list_external_generation_jobs_and_return_then( + &self, + input: ExternalGenerationJobListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "list_external_generation_jobs_and_return", + ListExternalGenerationJobsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_type.rs index 78364d5aa..1a1df9fcd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_ledger_type.rs @@ -15,6 +15,7 @@ pub struct ProfileWalletLedger { pub balance_after: u64, pub source_type: RuntimeProfileWalletLedgerSourceType, pub created_at: __sdk::Timestamp, + pub metadata_json: Option, } impl __sdk::InModule for ProfileWalletLedger { @@ -32,6 +33,7 @@ pub struct ProfileWalletLedgerCols { pub source_type: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, + pub metadata_json: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ProfileWalletLedger { @@ -44,6 +46,7 @@ impl __sdk::__query_builder::HasCols for ProfileWalletLedger { balance_after: __sdk::__query_builder::Col::new(table_name, "balance_after"), source_type: __sdk::__query_builder::Col::new(table_name, "source_type"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_input_type.rs index 653156930..7f8cbe735 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_adjustment_input_type.rs @@ -11,6 +11,7 @@ pub struct RuntimeProfileWalletAdjustmentInput { pub amount: u64, pub ledger_id: String, pub created_at_micros: i64, + pub metadata_json: String, } impl __sdk::InModule for RuntimeProfileWalletAdjustmentInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_entry_snapshot_type.rs index 8513884ee..84e427747 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_entry_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_entry_snapshot_type.rs @@ -15,6 +15,7 @@ pub struct RuntimeProfileWalletLedgerEntrySnapshot { pub balance_after: u64, pub source_type: RuntimeProfileWalletLedgerSourceType, pub created_at_micros: i64, + pub metadata_json: String, } impl __sdk::InModule for RuntimeProfileWalletLedgerEntrySnapshot { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 0eed3f271..2c71c3a9a 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -322,11 +322,30 @@ impl SpacetimeClient { ledger_id: String, created_at_micros: i64, ) -> Result { - let procedure_input = build_runtime_profile_wallet_adjustment_input( + self.consume_profile_wallet_points_with_metadata( user_id, amount, ledger_id, created_at_micros, + module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(), + ) + .await + } + + pub async fn consume_profile_wallet_points_with_metadata( + &self, + user_id: String, + amount: u64, + ledger_id: String, + created_at_micros: i64, + metadata_json: String, + ) -> Result { + let procedure_input = build_runtime_profile_wallet_adjustment_input_with_metadata( + user_id, + amount, + ledger_id, + created_at_micros, + metadata_json, ) .map_err(SpacetimeClientError::validation_failed)? .into(); @@ -357,11 +376,30 @@ impl SpacetimeClient { ledger_id: String, created_at_micros: i64, ) -> Result { - let procedure_input = build_runtime_profile_wallet_adjustment_input( + self.refund_profile_wallet_points_with_metadata( user_id, amount, ledger_id, created_at_micros, + module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(), + ) + .await + } + + pub async fn refund_profile_wallet_points_with_metadata( + &self, + user_id: String, + amount: u64, + ledger_id: String, + created_at_micros: i64, + metadata_json: String, + ) -> Result { + let procedure_input = build_runtime_profile_wallet_adjustment_input_with_metadata( + user_id, + amount, + ledger_id, + created_at_micros, + metadata_json, ) .map_err(SpacetimeClientError::validation_failed)? .into(); diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 59cdd6b28..28379ddd3 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1,4 +1,5 @@ use crate::*; +use std::collections::{BTreeMap, BTreeSet}; const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; const EDITOR_CANVAS_DEFAULT_TITLE: &str = "默认画布"; @@ -853,6 +854,8 @@ fn persist_editor_project_layout( canvas.layers_json.clone(), now, )?; + let input_layers_json = + merge_completed_canvas_generation_items(canvas.layers_json.as_str(), input_layers_json)?; let layers_json = migrate_canvas_layer_asset_metadata( ctx, project.project_id.as_str(), @@ -1613,6 +1616,146 @@ fn normalize_layout_json(value: String) -> Result { Ok(value) } +fn merge_completed_canvas_generation_items( + current_layers_json: &str, + incoming_layers_json: String, +) -> Result { + let current_layout = serde_json::from_str::(current_layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + let mut incoming_layout = serde_json::from_str::(&incoming_layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + let Some(current_items) = current_layout.as_array() else { + return Ok(incoming_layers_json); + }; + let Some(incoming_items) = incoming_layout.as_array_mut() else { + return Ok(incoming_layers_json); + }; + + let mut completed_dialogs_by_id = BTreeMap::new(); + let mut current_layers_by_id = BTreeMap::new(); + for item in current_items { + if let Some(layer_id) = canvas_layout_layer_id(item) { + current_layers_by_id.insert(layer_id, item.clone()); + } + let Some(dialog) = canvas_generation_dialog(item) else { + continue; + }; + let Some(dialog_id) = canvas_generation_dialog_id(dialog) else { + continue; + }; + if canvas_generation_dialog_status(dialog) != Some("generating") + && canvas_generation_dialog_generated_layer_id(dialog).is_some() + { + completed_dialogs_by_id.insert(dialog_id, dialog.clone()); + } + } + + if completed_dialogs_by_id.is_empty() { + return Ok(incoming_layers_json); + } + + let mut incoming_layer_ids = incoming_items + .iter() + .filter_map(canvas_layout_layer_id) + .collect::>(); + let mut restore_layer_ids = BTreeSet::new(); + let mut changed = false; + for item in incoming_items.iter_mut() { + let Some(object) = item.as_object_mut() else { + continue; + }; + if object.get("itemType").and_then(JsonValue::as_str) != Some("generation-dialog") { + continue; + } + let Some(dialog) = object.get("dialog") else { + continue; + }; + if canvas_generation_dialog_status(dialog) != Some("generating") + || canvas_generation_dialog_generated_layer_id(dialog).is_some() + { + continue; + } + let Some(dialog_id) = canvas_generation_dialog_id(dialog) else { + continue; + }; + let Some(completed_dialog) = completed_dialogs_by_id.get(&dialog_id) else { + continue; + }; + if let Some(generated_layer_id) = + canvas_generation_dialog_generated_layer_id(completed_dialog) + { + restore_layer_ids.insert(generated_layer_id); + } + object.insert("dialog".to_string(), completed_dialog.clone()); + changed = true; + } + + for layer_id in restore_layer_ids { + if incoming_layer_ids.contains(&layer_id) { + continue; + } + let Some(layer) = current_layers_by_id.get(&layer_id) else { + continue; + }; + let insert_at = incoming_items + .iter() + .position(is_canvas_generation_dialog_layout_item) + .unwrap_or(incoming_items.len()); + incoming_items.insert(insert_at, layer.clone()); + incoming_layer_ids.insert(layer_id); + changed = true; + } + + if !changed { + return Ok(incoming_layers_json); + } + serde_json::to_string(&incoming_layout).map_err(|_| "图片画布图层布局无法序列化".to_string()) +} + +fn is_canvas_generation_dialog_layout_item(item: &JsonValue) -> bool { + item.get("itemType").and_then(JsonValue::as_str) == Some("generation-dialog") +} + +fn canvas_generation_dialog(item: &JsonValue) -> Option<&JsonValue> { + if !is_canvas_generation_dialog_layout_item(item) { + return None; + } + item.get("dialog") +} + +fn canvas_generation_dialog_id(dialog: &JsonValue) -> Option { + dialog + .get("id") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn canvas_generation_dialog_status(dialog: &JsonValue) -> Option<&str> { + dialog + .get("status") + .and_then(JsonValue::as_str) + .map(str::trim) +} + +fn canvas_generation_dialog_generated_layer_id(dialog: &JsonValue) -> Option { + dialog + .get("generatedLayerId") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn canvas_layout_layer_id(item: &JsonValue) -> Option { + item.get("layerId") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + fn migrate_canvas_layer_asset_metadata( ctx: &ReducerContext, project_id: &str, @@ -1818,3 +1961,134 @@ fn editor_asset_error(message: String) -> EditorAssetProcedureResult { error_message: Some(message), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn merge_completed_canvas_generation_items_keeps_completed_dialog_and_layer() { + let current = json!([ + { + "layerId": "layer-generated", + "resourceId": "editor-resource-generated", + "title": "生成结果", + "sourceType": "generated", + "zIndex": 1 + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:generation-dialog-1", + "resourceId": "generation-dialog:generation-dialog-1", + "dialog": { + "id": "generation-dialog-1", + "mode": "generate", + "prompt": "农田", + "status": "idle", + "composerOpen": true, + "generatedLayerId": "layer-generated" + } + } + ]); + let incoming = json!([ + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:generation-dialog-1", + "resourceId": "generation-dialog:generation-dialog-1", + "dialog": { + "id": "generation-dialog-1", + "mode": "generate", + "prompt": "农田", + "status": "generating", + "composerOpen": false + } + } + ]); + + let merged = merge_completed_canvas_generation_items( + current.to_string().as_str(), + incoming.to_string(), + ) + .expect("layout merge should succeed"); + let merged: JsonValue = serde_json::from_str(merged.as_str()).expect("merged json"); + let items = merged.as_array().expect("merged layout should be an array"); + + assert!( + items + .iter() + .any(|item| item["layerId"] == "layer-generated") + ); + let dialog = items + .iter() + .find(|item| item["itemType"] == "generation-dialog") + .and_then(|item| item.get("dialog")) + .expect("dialog should remain"); + assert_eq!(dialog["status"], json!("idle")); + assert_eq!(dialog["generatedLayerId"], json!("layer-generated")); + } + + #[test] + fn merge_completed_canvas_generation_items_does_not_touch_active_retry() { + let current = json!([ + { + "layerId": "layer-generated", + "resourceId": "editor-resource-generated", + "title": "生成结果", + "sourceType": "generated", + "zIndex": 1 + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:generation-dialog-1", + "resourceId": "generation-dialog:generation-dialog-1", + "dialog": { + "id": "generation-dialog-1", + "mode": "generate", + "prompt": "农田", + "status": "idle", + "composerOpen": true, + "generatedLayerId": "layer-generated" + } + } + ]); + let incoming = json!([ + { + "layerId": "layer-generated", + "resourceId": "editor-resource-generated", + "title": "生成结果", + "sourceType": "generated", + "zIndex": 1 + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:generation-dialog-1", + "resourceId": "generation-dialog:generation-dialog-1", + "dialog": { + "id": "generation-dialog-1", + "mode": "generate", + "prompt": "继续生成一版", + "status": "generating", + "composerOpen": false, + "generatedLayerId": "layer-generated" + } + } + ]); + let incoming_json = incoming.to_string(); + + let merged = + merge_completed_canvas_generation_items(current.to_string().as_str(), incoming_json) + .expect("layout merge should succeed"); + let merged: JsonValue = serde_json::from_str(merged.as_str()).expect("merged json"); + let dialog = merged + .as_array() + .expect("merged layout should be an array") + .iter() + .find(|item| item["itemType"] == "generation-dialog") + .and_then(|item| item.get("dialog")) + .expect("dialog should remain"); + + assert_eq!(dialog["status"], json!("generating")); + assert_eq!(dialog["prompt"], json!("继续生成一版")); + } +} diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 0de980e1b..d37a69a0a 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -5,6 +5,12 @@ const EXTERNAL_GENERATION_STATUS_RUNNING: &str = "running"; const EXTERNAL_GENERATION_STATUS_COMPLETED: &str = "completed"; const EXTERNAL_GENERATION_STATUS_FAILED: &str = "failed"; const EXTERNAL_GENERATION_STATUS_CANCELLED: &str = "cancelled"; +const EXTERNAL_GENERATION_EVENT_ENQUEUED: &str = "enqueued"; +const EXTERNAL_GENERATION_EVENT_CLAIMED: &str = "claimed"; +const EXTERNAL_GENERATION_EVENT_LEASE_RENEWED: &str = "lease_renewed"; +const EXTERNAL_GENERATION_EVENT_COMPLETED: &str = "completed"; +const EXTERNAL_GENERATION_EVENT_FAILED: &str = "failed"; +const EXTERNAL_GENERATION_EVENT_ACKNOWLEDGED: &str = "acknowledged"; #[spacetimedb::table( accessor = external_generation_job, @@ -51,6 +57,36 @@ pub struct ExternalGenerationJob { pub(crate) updated_at: Timestamp, #[default(None::)] pub(crate) lease_token: Option, + #[default(0u64)] + pub(crate) price_mud_points: u64, + #[default(None::)] + pub(crate) refund_ledger_id: Option, + #[default(None::)] + pub(crate) notification_acknowledged_at: Option, +} + +#[spacetimedb::table( + accessor = external_generation_job_event, + index( + accessor = by_external_generation_job_event_job_id, + btree(columns = [job_id, created_at]) + ), + index( + accessor = by_external_generation_job_event_owner, + btree(columns = [owner_user_id, created_at]) + ) +)] +#[derive(Clone)] +pub struct ExternalGenerationJobEvent { + #[primary_key] + pub(crate) event_id: String, + pub(crate) job_id: String, + pub(crate) owner_user_id: String, + pub(crate) event_kind: String, + pub(crate) status: String, + pub(crate) message: Option, + pub(crate) worker_id: Option, + pub(crate) created_at: Timestamp, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -66,6 +102,7 @@ pub struct ExternalGenerationJobEnqueueInput { pub max_attempts: u32, pub available_at_micros: i64, pub created_at_micros: i64, + pub price_mud_points: u64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -102,6 +139,7 @@ pub struct ExternalGenerationJobFailInput { pub error_message: String, pub retry_after_micros: i64, pub failed_at_micros: i64, + pub refund_ledger_id: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -110,6 +148,20 @@ pub struct ExternalGenerationJobGetInput { pub owner_user_id: String, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobListInput { + pub owner_user_id: String, + pub limit: u32, + pub include_acknowledged_terminal: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobAcknowledgeInput { + pub owner_user_id: String, + pub job_ids: Vec, + pub acknowledged_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ExternalGenerationJobSnapshot { pub job_id: String, @@ -133,6 +185,9 @@ pub struct ExternalGenerationJobSnapshot { pub completed_at_micros: Option, pub updated_at_micros: i64, pub lease_token: Option, + pub price_mud_points: u64, + pub refund_ledger_id: Option, + pub notification_acknowledged_at_micros: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -140,6 +195,10 @@ pub struct ExternalGenerationJobProcedureResult { pub ok: bool, pub job: Option, pub jobs: Vec, + pub pending_count: u32, + pub running_count: u32, + pub unacknowledged_terminal_count: u32, + pub now_micros: i64, pub error_message: Option, } @@ -185,6 +244,10 @@ pub fn claim_external_generation_jobs_and_return( ok: true, job: None, jobs, + pending_count: 0, + running_count: 0, + unacknowledged_terminal_count: 0, + now_micros: ctx.timestamp.to_micros_since_unix_epoch(), error_message: None, }, Err(message) => failed_external_generation_job_result(message), @@ -235,6 +298,28 @@ pub fn get_external_generation_job_and_return( } } +#[spacetimedb::procedure] +pub fn list_external_generation_jobs_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobListInput, +) -> ExternalGenerationJobProcedureResult { + match ctx.try_with_tx(|tx| list_external_generation_jobs_tx(tx, input.clone())) { + Ok(result) => result, + Err(message) => failed_external_generation_job_result(message), + } +} + +#[spacetimedb::procedure] +pub fn acknowledge_external_generation_jobs_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobAcknowledgeInput, +) -> ExternalGenerationJobProcedureResult { + match ctx.try_with_tx(|tx| acknowledge_external_generation_jobs_tx(tx, input.clone())) { + Ok(result) => result, + Err(message) => failed_external_generation_job_result(message), + } +} + #[spacetimedb::procedure] pub fn get_external_generation_queue_stats_and_return( ctx: &mut ProcedureContext, @@ -323,8 +408,19 @@ fn enqueue_external_generation_job_tx( completed_at: None, updated_at: now, lease_token: None, + price_mud_points: input.price_mud_points, + refund_ledger_id: None, + notification_acknowledged_at: None, }; ctx.db.external_generation_job().insert(row.clone()); + insert_external_generation_job_event( + ctx, + &row, + EXTERNAL_GENERATION_EVENT_ENQUEUED, + Some("任务已入队".to_string()), + None, + now, + ); Ok(map_external_generation_job_row(row)) } @@ -394,6 +490,14 @@ fn claim_external_generation_jobs_tx( } row.updated_at = claim_time; persist_external_generation_job_row(ctx, row.clone()); + insert_external_generation_job_event( + ctx, + &row, + EXTERNAL_GENERATION_EVENT_CLAIMED, + Some("worker 已领取任务".to_string()), + Some(worker_id.clone()), + claim_time, + ); claimed.push(map_external_generation_job_row(row)); } @@ -419,6 +523,14 @@ fn complete_external_generation_job_tx( row.completed_at = Some(completed_at); row.updated_at = completed_at; persist_external_generation_job_row(ctx, row.clone()); + insert_external_generation_job_event( + ctx, + &row, + EXTERNAL_GENERATION_EVENT_COMPLETED, + Some("任务已完成".to_string()), + Some(input.worker_id), + completed_at, + ); Ok(map_external_generation_job_row(row)) } @@ -444,6 +556,132 @@ fn get_external_generation_job_tx( Ok(map_external_generation_job_row(row)) } +fn list_external_generation_jobs_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobListInput, +) -> Result { + validate_required( + "external_generation_job.owner_user_id", + &input.owner_user_id, + )?; + let owner_user_id = input.owner_user_id.trim().to_string(); + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); + let (pending_count, running_count, unacknowledged_terminal_count) = + count_external_generation_jobs_for_owner(ctx, &owner_user_id); + let mut rows = Vec::new(); + + for row in ctx + .db + .external_generation_job() + .by_external_generation_job_owner_user_id() + .filter(&owner_user_id) + { + let should_include = input.include_acknowledged_terminal + || !is_external_generation_job_terminal(&row) + || row.notification_acknowledged_at.is_none(); + if should_include { + rows.push(row); + } + } + + rows.sort_by(|left, right| { + external_generation_job_sort_bucket(left) + .cmp(&external_generation_job_sort_bucket(right)) + .then_with(|| { + right + .updated_at + .to_micros_since_unix_epoch() + .cmp(&left.updated_at.to_micros_since_unix_epoch()) + }) + .then_with(|| left.job_id.cmp(&right.job_id)) + }); + + let limit = input.limit.clamp(1, 100) as usize; + rows.truncate(limit); + + Ok(ExternalGenerationJobProcedureResult { + ok: true, + job: None, + jobs: rows + .into_iter() + .map(map_external_generation_job_row) + .collect(), + pending_count, + running_count, + unacknowledged_terminal_count, + now_micros, + error_message: None, + }) +} + +fn acknowledge_external_generation_jobs_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobAcknowledgeInput, +) -> Result { + validate_required( + "external_generation_job.owner_user_id", + &input.owner_user_id, + )?; + if input.job_ids.is_empty() { + return Ok(ExternalGenerationJobProcedureResult { + ok: true, + job: None, + jobs: Vec::new(), + pending_count: 0, + running_count: 0, + unacknowledged_terminal_count: 0, + now_micros: ctx.timestamp.to_micros_since_unix_epoch(), + error_message: None, + }); + } + + let owner_user_id = input.owner_user_id.trim().to_string(); + let acknowledged_at = Timestamp::from_micros_since_unix_epoch(input.acknowledged_at_micros); + let mut acknowledged = Vec::new(); + + for job_id in input.job_ids.iter().take(100) { + let Some(mut row) = ctx + .db + .external_generation_job() + .job_id() + .find(&job_id.trim().to_string()) + else { + continue; + }; + if row.owner_user_id.trim() != owner_user_id || !is_external_generation_job_terminal(&row) { + continue; + } + if row.notification_acknowledged_at.is_some() { + continue; + } + row.notification_acknowledged_at = Some(acknowledged_at); + row.updated_at = acknowledged_at; + persist_external_generation_job_row(ctx, row.clone()); + insert_external_generation_job_event( + ctx, + &row, + EXTERNAL_GENERATION_EVENT_ACKNOWLEDGED, + Some("用户已确认任务通知".to_string()), + None, + acknowledged_at, + ); + acknowledged.push(map_external_generation_job_row(row)); + } + let (pending_count, running_count, unacknowledged_terminal_count) = + count_external_generation_jobs_for_owner(ctx, &owner_user_id); + + Ok(ExternalGenerationJobProcedureResult { + ok: true, + job: None, + jobs: acknowledged, + pending_count, + running_count, + unacknowledged_terminal_count, + now_micros: ctx.timestamp.to_micros_since_unix_epoch(), + error_message: None, + }) +} + fn renew_external_generation_job_lease_tx( ctx: &ReducerContext, input: ExternalGenerationJobRenewLeaseInput, @@ -463,6 +701,14 @@ fn renew_external_generation_job_lease_tx( 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()); + insert_external_generation_job_event( + ctx, + &row, + EXTERNAL_GENERATION_EVENT_LEASE_RENEWED, + Some("worker 已续租任务".to_string()), + Some(input.worker_id), + renewed_at, + ); Ok(map_external_generation_job_row(row)) } @@ -488,6 +734,9 @@ fn fail_external_generation_job_tx( "external_generation_job.retry_delay", )?; row.last_error_message = Some(error_message.to_string()); + row.refund_ledger_id = input + .refund_ledger_id + .and_then(|value| normalize_optional_text(value.as_str())); row.lease_expires_at = None; row.worker_id = None; row.lease_token = None; @@ -502,6 +751,14 @@ fn fail_external_generation_job_tx( } persist_external_generation_job_row(ctx, row.clone()); + insert_external_generation_job_event( + ctx, + &row, + EXTERNAL_GENERATION_EVENT_FAILED, + Some(error_message.to_string()), + Some(input.worker_id), + failed_at, + ); Ok(map_external_generation_job_row(row)) } @@ -654,6 +911,61 @@ fn is_external_generation_job_claimable(row: &ExternalGenerationJob, now: Timest } } +fn is_external_generation_job_terminal(row: &ExternalGenerationJob) -> bool { + matches!( + row.status.as_str(), + EXTERNAL_GENERATION_STATUS_COMPLETED + | EXTERNAL_GENERATION_STATUS_FAILED + | EXTERNAL_GENERATION_STATUS_CANCELLED + ) +} + +fn count_external_generation_jobs_for_owner( + ctx: &ReducerContext, + owner_user_id: &str, +) -> (u32, u32, u32) { + let normalized_owner_user_id = owner_user_id.trim().to_string(); + let mut pending_count = 0u32; + let mut running_count = 0u32; + let mut unacknowledged_terminal_count = 0u32; + + for row in ctx + .db + .external_generation_job() + .by_external_generation_job_owner_user_id() + .filter(&normalized_owner_user_id) + { + match row.status.as_str() { + EXTERNAL_GENERATION_STATUS_PENDING => pending_count = pending_count.saturating_add(1), + EXTERNAL_GENERATION_STATUS_RUNNING => running_count = running_count.saturating_add(1), + EXTERNAL_GENERATION_STATUS_COMPLETED | EXTERNAL_GENERATION_STATUS_FAILED => { + if row.notification_acknowledged_at.is_none() { + unacknowledged_terminal_count = unacknowledged_terminal_count.saturating_add(1); + } + } + _ => {} + } + } + + (pending_count, running_count, unacknowledged_terminal_count) +} + +fn external_generation_job_sort_bucket(row: &ExternalGenerationJob) -> u8 { + match row.status.as_str() { + EXTERNAL_GENERATION_STATUS_RUNNING => 0, + EXTERNAL_GENERATION_STATUS_PENDING => 1, + EXTERNAL_GENERATION_STATUS_COMPLETED | EXTERNAL_GENERATION_STATUS_FAILED + if row.notification_acknowledged_at.is_none() => + { + 2 + } + EXTERNAL_GENERATION_STATUS_COMPLETED | EXTERNAL_GENERATION_STATUS_FAILED => 3, + EXTERNAL_GENERATION_STATUS_CANCELLED if row.notification_acknowledged_at.is_none() => 4, + EXTERNAL_GENERATION_STATUS_CANCELLED => 5, + _ => 6, + } +} + fn record_external_generation_claimable_age( stats: &mut ExternalGenerationQueueStatsSnapshot, row: &ExternalGenerationJob, @@ -678,7 +990,42 @@ fn persist_external_generation_job_row(ctx: &ReducerContext, row: ExternalGenera ctx.db.external_generation_job().insert(row); } +fn insert_external_generation_job_event( + ctx: &ReducerContext, + row: &ExternalGenerationJob, + event_kind: &str, + message: Option, + worker_id: Option, + created_at: Timestamp, +) { + let event_id = build_external_generation_event_id(row, event_kind, created_at); + if ctx + .db + .external_generation_job_event() + .event_id() + .find(&event_id) + .is_some() + { + return; + } + ctx.db + .external_generation_job_event() + .insert(ExternalGenerationJobEvent { + event_id, + job_id: row.job_id.clone(), + owner_user_id: row.owner_user_id.clone(), + event_kind: event_kind.to_string(), + status: row.status.clone(), + message, + worker_id, + created_at, + }); +} + fn map_external_generation_job_row(row: ExternalGenerationJob) -> ExternalGenerationJobSnapshot { + let notification_acknowledged_at_micros = row + .notification_acknowledged_at + .map(|value| value.to_micros_since_unix_epoch()); ExternalGenerationJobSnapshot { job_id: row.job_id, dedupe_key: row.dedupe_key, @@ -707,6 +1054,9 @@ fn map_external_generation_job_row(row: ExternalGenerationJob) -> ExternalGenera .map(|value| value.to_micros_since_unix_epoch()), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), lease_token: row.lease_token, + price_mud_points: row.price_mud_points, + refund_ledger_id: row.refund_ledger_id, + notification_acknowledged_at_micros, } } @@ -717,6 +1067,10 @@ fn single_external_generation_job_result( ok: true, job: Some(job), jobs: Vec::new(), + pending_count: 0, + running_count: 0, + unacknowledged_terminal_count: 0, + now_micros: 0, error_message: None, } } @@ -726,6 +1080,10 @@ fn failed_external_generation_job_result(message: String) -> ExternalGenerationJ ok: false, job: None, jobs: Vec::new(), + pending_count: 0, + running_count: 0, + unacknowledged_terminal_count: 0, + now_micros: 0, error_message: Some(message), } } @@ -768,6 +1126,21 @@ fn build_external_generation_lease_token( ) } +fn build_external_generation_event_id( + row: &ExternalGenerationJob, + event_kind: &str, + created_at: Timestamp, +) -> String { + format!( + "{}:{}:{}:{}:{}", + row.job_id.trim(), + event_kind.trim(), + row.status.trim(), + row.attempt, + created_at.to_micros_since_unix_epoch() + ) +} + fn normalize_optional_text(value: &str) -> Option { let normalized = value.trim(); if normalized.is_empty() { @@ -820,6 +1193,45 @@ mod tests { } } + #[test] + fn terminal_helper_includes_completed_failed_and_cancelled() { + 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_terminal(&row)); + } + + let pending = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING); + assert!(!is_external_generation_job_terminal(&pending)); + } + + #[test] + fn task_list_sort_bucket_prioritizes_active_then_unacknowledged_terminal() { + let running = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); + let pending = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING); + let completed_unack = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_COMPLETED); + let mut completed_ack = + external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_COMPLETED); + completed_ack.notification_acknowledged_at = Some(micros(2_000)); + + assert!( + external_generation_job_sort_bucket(&running) + < external_generation_job_sort_bucket(&pending) + ); + assert!( + external_generation_job_sort_bucket(&pending) + < external_generation_job_sort_bucket(&completed_unack) + ); + assert!( + external_generation_job_sort_bucket(&completed_unack) + < external_generation_job_sort_bucket(&completed_ack) + ); + } + #[test] fn worker_ownership_requires_matching_trimmed_worker_id() { let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); @@ -927,6 +1339,9 @@ mod tests { completed_at: None, updated_at: micros(0), lease_token: None, + price_mud_points: 10, + refund_ledger_id: None, + notification_acknowledged_at: None, } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index ef3c77d51..f1babe02a 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -184,6 +184,7 @@ macro_rules! migration_tables { ai_result_reference, ai_task_event, external_generation_job, + external_generation_job_event, runtime_snapshot, runtime_setting, creation_entry_config, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 81bed7882..d8adf26c8 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -36,6 +36,8 @@ pub struct ProfileWalletLedger { pub(crate) balance_after: u64, pub(crate) source_type: RuntimeProfileWalletLedgerSourceType, pub(crate) created_at: Timestamp, + #[default(None::)] + pub(crate) metadata_json: Option, } #[spacetimedb::table(accessor = profile_wallet_config)] @@ -1799,6 +1801,7 @@ fn sync_profile_dashboard_from_snapshot( balance_after: next_wallet_balance, source_type: RuntimeProfileWalletLedgerSourceType::SnapshotSync, created_at: saved_at, + metadata_json: Some(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()), }); } @@ -3886,6 +3889,7 @@ fn apply_profile_wallet_delta( ledger_id, created_at, false, + PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON, ) } @@ -3913,11 +3917,12 @@ fn apply_profile_wallet_adjustment( source_type: RuntimeProfileWalletLedgerSourceType, consume: bool, ) -> Result { - let validated_input = build_runtime_profile_wallet_adjustment_input( + let validated_input = build_runtime_profile_wallet_adjustment_input_with_metadata( input.user_id, input.amount, input.ledger_id, input.created_at_micros, + input.metadata_json, ) .map_err(|error| error.to_string())?; let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); @@ -3937,6 +3942,7 @@ fn apply_profile_wallet_adjustment( &validated_input.ledger_id, created_at, true, + &validated_input.metadata_json, )?; get_profile_dashboard_snapshot( ctx, @@ -3954,6 +3960,7 @@ fn apply_profile_wallet_signed_delta( ledger_id: &str, created_at: Timestamp, idempotent: bool, + metadata_json: &str, ) -> Result { if idempotent && ctx @@ -4012,6 +4019,7 @@ fn apply_profile_wallet_signed_delta( balance_after: next_balance, source_type, created_at, + metadata_json: Some(metadata_json.to_string()), }); Ok(next_balance) @@ -4156,6 +4164,10 @@ fn build_profile_wallet_ledger_snapshot_from_row( balance_after: row.balance_after, source_type: row.source_type, created_at_micros: row.created_at.to_micros_since_unix_epoch(), + metadata_json: row + .metadata_json + .clone() + .unwrap_or_else(|| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()), } } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index c12aa4b1d..6db08db9e 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1120,6 +1120,79 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); + it('keeps a queued generation pending when the frontend queue wait expires', async () => { + vi.useFakeTimers(); + try { + const refreshWalletBalance = vi.fn(); + const runningState = createQueueState({ + status: 'running', + phaseLabel: '生成中', + phaseDetail: '后端任务继续执行中', + progress: 0.35, + }); + generateEditorImageMock.mockResolvedValueOnce({ + ...createGenerated({ + imageSrc: '/generated-editor-images/project/image.png', + width: 1024, + height: 1024, + prompt: '队列生成', + }), + queueState: runningState, + }); + getExternalGenerationJobStatusMock.mockResolvedValue({ + job: runningState, + }); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600); + }); + + expect(refreshWalletBalance).toHaveBeenCalledTimes(1); + expect(loadEditorProjectMock).not.toHaveBeenCalled(); + expect(getExternalGenerationJobStatusMock).toHaveBeenCalled(); + expect(screen.getByTestId('dialog').textContent).toBe( + 'generate:generating:closed:-:placeholder:-', + ); + expect(screen.getByTestId('dialog').textContent).not.toContain( + '生成任务仍在队列中', + ); + expect(screen.getByTestId('layers').textContent).not.toContain( + 'layer-generated-1', + ); + } finally { + vi.useRealTimers(); + } + }); + it('does not add a local layer when project generation returns without a backend snapshot', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 2b6c3da20..cf3e14023 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -199,12 +199,12 @@ async function runEditorGenerationWithWalletRefresh( async function waitForEditorGenerationQueue( queueState: ExternalGenerationJobStatusRecord, -) { +): Promise { const startedAt = Date.now(); let current = queueState; while (current.status === 'queued' || current.status === 'running') { if (Date.now() - startedAt > EDITOR_GENERATION_QUEUE_TIMEOUT_MS) { - throw new Error('生成任务仍在队列中,请稍后刷新画布查看结果'); + return null; } await delay(EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS); current = ( @@ -227,11 +227,15 @@ async function applyQueuedEditorGenerationProject( if (!queueState) { return false; } + let terminalState: ExternalGenerationJobStatusRecord | null = null; try { - await waitForEditorGenerationQueue(queueState); + terminalState = await waitForEditorGenerationQueue(queueState); } finally { notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged); } + if (!terminalState) { + return true; + } if (projectId && applyProjectSnapshot) { applyProjectSnapshot(await loadEditorProject(projectId)); } diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 33b03fa37..dbc56dd03 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -274,6 +274,67 @@ function ProjectPersistenceHarness({ > move generation again + ); } @@ -731,6 +792,62 @@ describe('useImageCanvasProjectPersistence', () => { vi.useRealTimers(); }); + it('drops stale pending generating saves when a backend completion snapshot is applied', async () => { + const savedDialog: CanvasGenerationDialogState = { + id: 'generation-dialog-1', + mode: 'generate', + prompt: '刷新后继续生成', + status: 'generating', + composerOpen: false, + placeholder: { + x: 42, + y: 56, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, + }, + }; + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '空画布项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-1', + resourceId: 'generation-dialog:generation-dialog-1', + dialog: savedDialog, + }, + ], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + + render(); + + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'move generation' }).click(); + }); + act(() => { + screen.getByRole('button', { name: 'apply completed project' }).click(); + }); + expect(screen.getByTestId('generation-dialogs').textContent).toBe( + 'generation-dialog-1:后端完成生成器:42', + ); + expect(screen.getByTestId('layers').textContent).toBe( + 'layer-generated:resource-generated:-:-:generated/result.png:asset-object-result', + ); + act(() => { + vi.advanceTimersByTime(451); + }); + + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + it('saves generated layers together with their generator snapshots', async () => { const savedDialog: CanvasGenerationDialogState = { id: 'generation-dialog-1', diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 99f661919..d4bc1b2e5 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -260,6 +260,7 @@ export function useImageCanvasProjectPersistence({ const pendingProjectLayoutSaveRef = useRef(null); const isProjectLayoutSaveRunningRef = useRef(false); + const skipNextProjectLayoutSaveRef = useRef(false); const saveTimerRef = useRef(null); const projectTitleRef = useRef('未命名画布'); const [projectId, setProjectId] = useState(null); @@ -274,6 +275,14 @@ export function useImageCanvasProjectPersistence({ restoreCanvasGenerationDialogs, } = setters; + const clearPendingProjectLayoutSave = useCallback(() => { + pendingProjectLayoutSaveRef.current = null; + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + }, []); + const runPendingProjectLayoutSave = useCallback( function runPendingProjectLayoutSave() { if (isProjectLayoutSaveRunningRef.current) { @@ -422,6 +431,8 @@ export function useImageCanvasProjectPersistence({ const applyProjectSnapshot = useCallback( (project: EditorProjectSnapshot) => { + clearPendingProjectLayoutSave(); + skipNextProjectLayoutSaveRef.current = true; projectIdRef.current = project.projectId; setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; @@ -471,6 +482,7 @@ export function useImageCanvasProjectPersistence({ restoreCanvasGenerationDialogs(generationDialogs); }, [ + clearPendingProjectLayoutSave, refs, restoreCanvasGenerationDialogs, selectSingleLayer, @@ -558,6 +570,10 @@ export function useImageCanvasProjectPersistence({ if (!projectId || !isProjectReady) { return undefined; } + if (skipNextProjectLayoutSaveRef.current) { + skipNextProjectLayoutSaveRef.current = false; + return undefined; + } queueProjectLayoutSave( projectId, { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts index 8ad30277b..d59d0d094 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts @@ -69,6 +69,7 @@ describe('buildExternalGenerationQueueStatus', () => { { pendingCount: 7, runningCount: 3, + unacknowledgedTerminalCount: 1, updatedAtMicros: 1_781_222_400_000_000, }, { @@ -85,6 +86,8 @@ describe('buildExternalGenerationQueueStatus', () => { currentProgress: 35, pendingCount: 7, runningCount: 3, + unacknowledgedTerminalCount: 1, + tasks: null, }); }); @@ -99,6 +102,7 @@ describe('buildExternalGenerationQueueStatus', () => { currentProgress: 42.4, pendingCount: 2, runningCount: 1, + unacknowledgedTerminalCount: 0, }), ).toEqual({ statusLabel: '生成中', @@ -107,7 +111,9 @@ describe('buildExternalGenerationQueueStatus', () => { runningLabel: '1', pendingCount: 2, runningCount: 1, + unacknowledgedTerminalCount: 0, progress: 42, + tasks: [], shouldShow: true, }); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 1f1df82e3..5946e8727 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -41,7 +41,7 @@ import type { } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { ExternalGenerationJobStatusRecord, - ExternalGenerationQueueOverview, + ExternalGenerationTaskRecord, } from '../../../packages/shared/src/contracts/externalGeneration'; import type { JumpHopJumpRequest, @@ -193,7 +193,10 @@ import { regenerateBabyObjectMatchDraftAssets, saveBabyObjectMatchDraft, } from '../../services/edutainment-baby-object'; -import { getExternalGenerationQueueOverview } from '../../services/external-generation'; +import { + acknowledgeExternalGenerationTasks, + listExternalGenerationTasks, +} from '../../services/external-generation'; import { jumpHopClient, type JumpHopGalleryCardResponse, @@ -720,6 +723,54 @@ function isExternalGenerationQueueStage(selectionStage: SelectionStage) { ); } +function isUnacknowledgedExternalGenerationTerminalTask( + task: ExternalGenerationTaskRecord, +) { + return ( + (task.status === 'completed' || task.status === 'failed') && + !task.notificationAcknowledgedAt + ); +} + +function resolveExternalGenerationTaskCompletionDialog( + tasks: readonly ExternalGenerationTaskRecord[], +): PlatformTaskCompletionDialogState | null { + const terminalTasks = tasks.filter( + isUnacknowledgedExternalGenerationTerminalTask, + ); + if (terminalTasks.length === 0) { + return null; + } + + const jobIds = terminalTasks.map((task) => task.jobId); + const latestUpdatedAtMicros = terminalTasks.reduce( + (latest, task) => Math.max(latest, task.updatedAtMicros), + 0, + ); + const completedAtMs = + latestUpdatedAtMicros > 0 + ? Math.floor(latestUpdatedAtMicros / 1000) + : Date.now(); + + return { + key: `external-generation:${jobIds.join('|')}:${latestUpdatedAtMicros}`, + source: '生成任务', + message: + terminalTasks.length > 1 + ? `${terminalTasks.length} 个生成任务已结束。` + : '生成任务已结束。', + completedAtMs, + taskJobIds: jobIds, + items: terminalTasks.map((task) => ({ + source: task.requestLabel.trim() || task.jobKind || task.jobId, + message: + task.status === 'failed' + ? (task.error?.trim() || '生成失败。') + : '生成已完成。', + })), + }; +} + type PuzzleDetailReturnTarget = { tab: PlatformHomeTab; }; @@ -1839,8 +1890,16 @@ export function PlatformEntryFlowShellImpl({ const [puzzleOperation, setPuzzleOperation] = useState( null, ); - const [externalGenerationQueueOverview, setExternalGenerationQueueOverview] = - useState(null); + const [externalGenerationTasks, setExternalGenerationTasks] = useState< + ExternalGenerationTaskRecord[] + >([]); + const [externalGenerationTaskOverview, setExternalGenerationTaskOverview] = + useState<{ + pendingCount: number; + runningCount: number; + unacknowledgedTerminalCount: number; + updatedAtMicros: number; + } | null>(null); const [jumpHopQueueState, setJumpHopQueueState] = useState(null); const [puzzleClearQueueState, setPuzzleClearQueueState] = @@ -4922,18 +4981,21 @@ export function PlatformEntryFlowShellImpl({ platformBootstrap.platformTab === 'profile' && platformBootstrap.canReadProtectedData); const isHostNetworkOnline = useHostNetworkOnline(); + const shouldPollExternalGenerationTasks = + platformBootstrap.canReadProtectedData && Boolean(authUi?.user?.id); const externalGenerationQueueOwnerKey = - shouldShowExternalGenerationQueueStatus + shouldPollExternalGenerationTasks ? (authUi?.user?.id ?? 'anonymous') : null; useEffect(() => { - setExternalGenerationQueueOverview(null); + setExternalGenerationTasks([]); + setExternalGenerationTaskOverview(null); }, [externalGenerationQueueOwnerKey]); useEffect(() => { if ( - !shouldShowExternalGenerationQueueStatus || + !shouldPollExternalGenerationTasks || !isHostNetworkOnline ) { return; @@ -4942,24 +5004,30 @@ export function PlatformEntryFlowShellImpl({ let disposed = false; let controller: AbortController | null = null; - const refreshQueueOverview = () => { + const refreshTaskList = () => { controller?.abort(); controller = new AbortController(); - getExternalGenerationQueueOverview(controller.signal) + listExternalGenerationTasks({ + limit: 20, + includeAcknowledgedTerminal: false, + signal: controller.signal, + }) .then((response) => { if (!disposed) { - setExternalGenerationQueueOverview(response.overview); + setExternalGenerationTasks(response.tasks); + setExternalGenerationTaskOverview(response.overview); } }) .catch(() => { if (!disposed) { - setExternalGenerationQueueOverview(null); + setExternalGenerationTasks([]); + setExternalGenerationTaskOverview(null); } }); }; - refreshQueueOverview(); - const intervalId = window.setInterval(refreshQueueOverview, 4000); + refreshTaskList(); + const intervalId = window.setInterval(refreshTaskList, 4000); return () => { disposed = true; @@ -4969,7 +5037,7 @@ export function PlatformEntryFlowShellImpl({ }, [ externalGenerationQueueOwnerKey, isHostNetworkOnline, - shouldShowExternalGenerationQueueStatus, + shouldPollExternalGenerationTasks, ]); const activeExternalGenerationJobState = useMemo(() => { const candidates = [ @@ -4998,10 +5066,15 @@ export function PlatformEntryFlowShellImpl({ const externalGenerationQueueStatus = useMemo( () => buildExternalGenerationQueueStatus( - externalGenerationQueueOverview, + externalGenerationTaskOverview, activeExternalGenerationJobState, + externalGenerationTasks, ), - [activeExternalGenerationJobState, externalGenerationQueueOverview], + [ + activeExternalGenerationJobState, + externalGenerationTaskOverview, + externalGenerationTasks, + ], ); const profileExternalGenerationQueueStatus = platformBootstrap.canReadProtectedData && @@ -5235,13 +5308,24 @@ export function PlatformEntryFlowShellImpl({ woodenFishRun?.runId, woodenFishSession?.sessionId, ]); + const externalGenerationTaskCompletionDialog = + useMemo( + () => resolveExternalGenerationTaskCompletionDialog(externalGenerationTasks), + [externalGenerationTasks], + ); const currentPlatformTaskCompletionDialog = useMemo( - () => pendingPlatformTaskCompletionDialog, - [pendingPlatformTaskCompletionDialog], + () => + pendingPlatformTaskCompletionDialog ?? + externalGenerationTaskCompletionDialog, + [ + externalGenerationTaskCompletionDialog, + pendingPlatformTaskCompletionDialog, + ], ); const activePlatformTaskCompletionDialog = - selectionStage === 'image-editor' + selectionStage === 'image-editor' && + !(currentPlatformTaskCompletionDialog?.taskJobIds?.length ?? 0) ? null : resolveActivePlatformDialog( currentPlatformTaskCompletionDialog, @@ -5374,6 +5458,36 @@ export function PlatformEntryFlowShellImpl({ if (dismissKey) { setDismissedPlatformTaskCompletionDialogKey(dismissKey); } + const taskJobIds = currentPlatformTaskCompletionDialog.taskJobIds ?? []; + if (taskJobIds.length > 0) { + const acknowledgedJobIds = new Set(taskJobIds); + setExternalGenerationTasks((current) => + current.filter((task) => !acknowledgedJobIds.has(task.jobId)), + ); + setExternalGenerationTaskOverview((current) => + current + ? { + ...current, + unacknowledgedTerminalCount: Math.max( + 0, + current.unacknowledgedTerminalCount - acknowledgedJobIds.size, + ), + } + : current, + ); + void acknowledgeExternalGenerationTasks({ jobIds: taskJobIds }) + .then((response) => { + setExternalGenerationTasks((current) => + current.filter( + (task) => + !response.acknowledgedTasks.some( + (acknowledgedTask) => acknowledgedTask.jobId === task.jobId, + ), + ), + ); + }) + .catch(() => undefined); + } setPendingPlatformTaskCompletionDialog(null); }, [currentPlatformTaskCompletionDialog]); const shouldPollPuzzleGenerationSession = diff --git a/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx b/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx index fc6e7cae2..72a7e789e 100644 --- a/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx +++ b/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx @@ -69,7 +69,37 @@ export function PlatformProfileGenerationQueueCard({ fillClassName="bg-[linear-gradient(90deg,#e47631,#ce5f2a)]" /> -
+ {presentation.tasks.length > 0 ? ( +
+ {presentation.tasks.map((task) => ( +
+
+ + {task.title} + + + {task.statusLabel} + +
+
+ {task.detail} + + {task.priceLabel} + +
+
+ ))} +
+ ) : null} + +
排队 {presentation.pendingLabel} @@ -78,6 +108,10 @@ export function PlatformProfileGenerationQueueCard({ 生成 {presentation.runningLabel}
+
+ 待提示 + {presentation.unacknowledgedTerminalCount} +
); diff --git a/src/components/platform-entry/PlatformTaskCompletionDialog.tsx b/src/components/platform-entry/PlatformTaskCompletionDialog.tsx index 2e6a089bf..48520b408 100644 --- a/src/components/platform-entry/PlatformTaskCompletionDialog.tsx +++ b/src/components/platform-entry/PlatformTaskCompletionDialog.tsx @@ -3,6 +3,10 @@ import { PlatformReportDialog } from '../common/PlatformReportDialog'; export type PlatformTaskCompletionDialogPayload = { source: string; message: string; + items?: Array<{ + source: string; + message: string; + }>; }; type PlatformTaskCompletionDialogProps = { @@ -26,17 +30,23 @@ export function PlatformTaskCompletionDialog({ copyIdleLabel="复制内容" fields={ completion - ? [ - { - label: '来源', - value: completion.source, - }, - { - label: '状态', - value: completion.message, + ? completion.items?.length + ? completion.items.map((item, index) => ({ + label: item.source || `任务 ${index + 1}`, + value: item.message, multiline: true, - }, - ] + })) + : [ + { + label: '来源', + value: completion.source, + }, + { + label: '状态', + value: completion.message, + multiline: true, + }, + ] : [] } overlayClassName={overlayClassName} diff --git a/src/components/platform-entry/platformDialogStateModel.ts b/src/components/platform-entry/platformDialogStateModel.ts index 796cc1fd5..1b3235ed4 100644 --- a/src/components/platform-entry/platformDialogStateModel.ts +++ b/src/components/platform-entry/platformDialogStateModel.ts @@ -13,6 +13,7 @@ export type PlatformTaskCompletionDialogState = PlatformTaskCompletionDialogPayload & { key: string; completedAtMs: number | null; + taskJobIds?: string[]; }; export type PlatformDialogCandidate = { @@ -68,7 +69,7 @@ export function buildPlatformTaskCompletionDialogDismissKey( completion: PlatformTaskCompletionDialogState | null, ) { return completion - ? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}` + ? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}:${completion.taskJobIds?.join(',') ?? ''}` : null; } diff --git a/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts index 180dbc612..3cbcc8e58 100644 --- a/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts +++ b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts @@ -1,6 +1,7 @@ import type { ExternalGenerationJobStatusRecord, ExternalGenerationQueueOverview, + ExternalGenerationTaskRecord, } from '../../../packages/shared/src/contracts/externalGeneration'; export type ExternalGenerationQueueStatus = { @@ -8,6 +9,17 @@ export type ExternalGenerationQueueStatus = { currentProgress?: number | null; pendingCount?: number | null; runningCount?: number | null; + tasks?: ExternalGenerationTaskRecord[] | null; + unacknowledgedTerminalCount?: number | null; +}; + +export type ExternalGenerationQueueTaskPresentation = { + id: string; + title: string; + statusLabel: string; + priceLabel: string; + detail: string; + tone: 'profile' | 'danger' | 'success'; }; export type ExternalGenerationQueuePresentation = { @@ -17,15 +29,18 @@ export type ExternalGenerationQueuePresentation = { runningLabel: string; pendingCount: number; runningCount: number; + unacknowledgedTerminalCount: number; progress: number | null; + tasks: ExternalGenerationQueueTaskPresentation[]; shouldShow: boolean; }; export function buildExternalGenerationQueueStatus( overview: ExternalGenerationQueueOverview | null, job: ExternalGenerationJobStatusRecord | null, + tasks: ExternalGenerationTaskRecord[] | null = null, ): ExternalGenerationQueueStatus | null { - if (!overview && !job) { + if (!overview && !job && !tasks?.length) { return null; } @@ -34,6 +49,9 @@ export function buildExternalGenerationQueueStatus( currentProgress: job?.progress ?? null, pendingCount: overview?.pendingCount ?? null, runningCount: overview?.runningCount ?? null, + unacknowledgedTerminalCount: + overview?.unacknowledgedTerminalCount ?? null, + tasks, }; } @@ -94,6 +112,26 @@ export function normalizeExternalGenerationQueueProgress( return Math.max(0, Math.min(100, Math.round(value))); } +export function buildExternalGenerationTaskPresentation( + task: ExternalGenerationTaskRecord, +): ExternalGenerationQueueTaskPresentation { + const statusLabel = resolveExternalGenerationQueueStatusLabel(task.status) ?? '待处理'; + return { + id: task.jobId, + title: task.requestLabel.trim() || task.jobKind, + statusLabel, + priceLabel: + task.priceMudPoints > 0 ? `${task.priceMudPoints}泥点` : '未计费', + detail: task.error?.trim() || task.phaseDetail || task.updatedAt, + tone: + task.status === 'failed' + ? 'danger' + : task.status === 'completed' + ? 'success' + : 'profile', + }; +} + export function buildExternalGenerationQueuePresentation( status: ExternalGenerationQueueStatus | null | undefined, ): ExternalGenerationQueuePresentation { @@ -103,6 +141,9 @@ export function buildExternalGenerationQueuePresentation( const runningCount = normalizeExternalGenerationQueueCount( status?.runningCount, ); + const unacknowledgedTerminalCount = normalizeExternalGenerationQueueCount( + status?.unacknowledgedTerminalCount, + ); const progress = normalizeExternalGenerationQueueProgress( status?.currentProgress, ); @@ -126,7 +167,17 @@ export function buildExternalGenerationQueuePresentation( runningLabel: runningCount.toString(), pendingCount, runningCount, + unacknowledgedTerminalCount, progress: progressValue, - shouldShow: Boolean(statusLabel || pendingCount > 0 || runningCount > 0), + tasks: (status?.tasks ?? []) + .slice(0, 5) + .map(buildExternalGenerationTaskPresentation), + shouldShow: Boolean( + statusLabel || + pendingCount > 0 || + runningCount > 0 || + unacknowledgedTerminalCount > 0 || + (status?.tasks?.length ?? 0) > 0, + ), }; } diff --git a/src/services/external-generation/externalGenerationClient.ts b/src/services/external-generation/externalGenerationClient.ts index f91875489..9ca396409 100644 --- a/src/services/external-generation/externalGenerationClient.ts +++ b/src/services/external-generation/externalGenerationClient.ts @@ -1,6 +1,9 @@ import type { ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverviewResponse, + ExternalGenerationTaskAcknowledgeRequest, + ExternalGenerationTaskAcknowledgeResponse, + ExternalGenerationTaskListResponse, } from '../../../packages/shared/src/contracts/externalGeneration'; import { BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson } from '../apiClient'; @@ -43,7 +46,54 @@ export async function getExternalGenerationJobStatus( ); } +export async function listExternalGenerationTasks( + options: { + limit?: number; + includeAcknowledgedTerminal?: boolean; + signal?: AbortSignal; + } = {}, +) { + const params = new URLSearchParams(); + if (typeof options.limit === 'number') { + params.set('limit', String(options.limit)); + } + if (typeof options.includeAcknowledgedTerminal === 'boolean') { + params.set( + 'includeAcknowledgedTerminal', + options.includeAcknowledgedTerminal ? 'true' : 'false', + ); + } + const query = params.toString(); + return requestJson( + `${EXTERNAL_GENERATION_API_BASE}/jobs${query ? `?${query}` : ''}`, + { + method: 'GET', + signal: options.signal, + }, + '读取生成任务列表失败', + EXTERNAL_GENERATION_READ_OPTIONS, + ); +} + +export async function acknowledgeExternalGenerationTasks( + payload: ExternalGenerationTaskAcknowledgeRequest, + signal?: AbortSignal, +) { + return requestJson( + `${EXTERNAL_GENERATION_API_BASE}/jobs/acknowledge`, + { + method: 'POST', + body: JSON.stringify(payload), + signal, + }, + '确认生成任务通知失败', + EXTERNAL_GENERATION_READ_OPTIONS, + ); +} + export const externalGenerationClient = { getQueueOverview: getExternalGenerationQueueOverview, getJobStatus: getExternalGenerationJobStatus, + listTasks: listExternalGenerationTasks, + acknowledgeTasks: acknowledgeExternalGenerationTasks, }; diff --git a/src/services/external-generation/index.ts b/src/services/external-generation/index.ts index 83c70bad9..164d55aa1 100644 --- a/src/services/external-generation/index.ts +++ b/src/services/external-generation/index.ts @@ -1,5 +1,7 @@ export { + acknowledgeExternalGenerationTasks, externalGenerationClient, getExternalGenerationJobStatus, getExternalGenerationQueueOverview, + listExternalGenerationTasks, } from './externalGenerationClient';