diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 2c4a975c..8df29d5d 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -296,7 +296,7 @@ - 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。 - 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。 - 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。 -- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;Puzzle `pause` 与 `props` 仍保留原账号态 auth options,不直接接入 runtime guest auth。 +- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard / pause / props 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;只要调用方传入 Runtime Guest Token,所有正式 runtime 请求都统一带局部 Authorization、`skipAuth` 与 `skipRefresh`。 - 追加决策:Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`;Wooden Fish 的 `clientEventId` 生成仍留在木鱼 client,Visual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。 - 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。 - 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 @@ -445,7 +445,7 @@ ## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式 - 背景:抓大鹅游玩阶段 UI 需要继续对齐拼图运行态的观感,同时移除右上角设置入口、灰白半透底板和显眼锅壳,让棋盘区域更专注。 -- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo.png` 产品 logo;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。 +- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo-runtime-hud.webp` 产品 logo 小图;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。 - 影响范围:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`src/index.css`、抓大鹅玩法链路文档。 - 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -1745,3 +1745,11 @@ - 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存校验、后台入口开关页摘要和前端 fallback spec。 - 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 等于已保存契约内容;后台只修改入口名称时不应隐式改写已保存的统一创作页表头。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-11 资产计费边界改为 fail-closed 并补偿退款 + +- 背景:图片 / 资产生成入口曾在钱包或 SpacetimeDB 预扣费连通性异常时允许继续生成,且失败后同步退款如果遇到 SpacetimeDB 短暂不可用缺少本地补偿;拼图首图后台任务还使用 api-server 进程内 HashSet 互斥,多实例下不能防重复。 +- 决策:暂不实现 token 限流。所有资产生成预扣费改为 fail-closed,预扣费失败直接返回错误;支持 retry 的计费 ledger id 统一包含 HTTP `request_id`,前端静默刷新重试复用同一个 `x-request-id`。生成失败后的退款先同步调用 SpacetimeDB,失败则写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。拼图首图后台生成互斥改为 SpacetimeDB `puzzle_background_compile_task` 表,使用 `task_id + request_id` 作为 claim id,释放时校验 claim id,避免旧任务误删新租约。 +- 影响范围:`api-server` 资产计费包裹、钱包退款补偿、拼图首图后台生成、`spacetime-module` 拼图 task 表、`spacetime-client` bindings/facade、前端 API request id 复用和后端架构文档。 +- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`node scripts/check-server-rs-ddd-boundaries.mjs`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml wallet_refund_outbox`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml asset_operation`、`npm run test -- src/services/apiClient.test.ts`、`npm run check:encoding`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 78adbe07..fb18fa67 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1181,6 +1181,7 @@ - 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。 - 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。 - 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。 +- 追加处理:未登录推荐页启动任一公开正式玩法时,`/api/runtime/*` 局内路由必须使用 `RuntimePrincipal`,前端通过 `PlatformEntryFlowShellImpl` 的统一 request options helper 给 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作透传 runtime guest token;公开 runtime detail 读取如跳一跳、敲木鱼必须显式 `skipAuth/skipRefresh`,匿名推荐流不能补读受保护创作详情,否则会在真正开局前打出 `/api/auth/refresh 401`。 - 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。 - 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。 @@ -2221,3 +2222,11 @@ - 待处理:将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,按背景、返回按钮、图集、切片、持久化、写草稿分阶段落库;统一后端全局生成 deadline、VectorEngine 重试预算、前端等待窗口和失败态回写。超时后再次进入同一 session 应优先恢复正在运行或已完成的任务,不应重复生图。 - 验证:模拟首张 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`。 + +## SpacetimeDB 连接池租约必须有 Drop 兜底,acquire 不允许无界自旋 + +- 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。 +- 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。 +- 处理:租约持有 `Arc` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。 +- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(`dropped_lease_releases_slot_and_permit`、`acquire_times_out_at_pool_acquire_when_pool_is_busy`)。 +- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。 diff --git a/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md b/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md new file mode 100644 index 00000000..e72bec65 --- /dev/null +++ b/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md @@ -0,0 +1,40 @@ +# SpacetimeDB 连接池租约 Drop 兜底与取消安全 + +- 日期:2026-06-11 +- 关联故障:release 环境 api-server 周期性全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),重启后临时恢复。 +- 涉及代码:`server-rs/crates/spacetime-client/src/lib.rs` + +## 故障根因 + +修复前的连接池存在两个叠加缺陷: + +1. **租约没有 Drop 兜底**。`PooledConnectionLease` 只能通过显式 `release_connection` 归还。当 HTTP 请求方在等待 StDB 回包期间断开(前端超时、用户刷新、Nginx 截断),axum/hyper 会直接丢弃 handler future,租约被 Drop:permit 因 `OwnedSemaphorePermit` 自动归还,但槽位的 `in_use` 标记永远不会复位。 +2. **acquire 在槽位泄漏后永久空转**。后续请求拿到 permit 后进入 `loop { 扫描槽位; yield_now }`,找不到空闲槽位就无限自旋,且这段自旋不受 `procedure_timeout` 约束,自旋期间 permit 不归还。 + +叠加效果:StDB 一旦变慢(请求占用连接接近 45 秒),客户端取消请求的概率大增,每次取消泄漏一个槽位并连带吞掉一个 permit;泄漏数量达到 `pool_size`(release 为 8)后,所有业务请求与健康检查全部在 `pool_acquire` 阶段 45 秒超时,服务表现为"连不上 StDB",只有重启能恢复。 + +## 本地复现 + +不需要真实 SpacetimeDB,单元测试即可复现机制(位于 `spacetime-client` tests 模块): + +- 修复前:将一个槽位置为 `in_use=true` 后调用 `acquire_connection_with_timeout(200ms)`,acquire 在 5 秒守护窗口内不返回(永久自旋),测试红。 +- `dropped_lease_releases_slot_and_permit`:模拟"请求被取消、租约未经 release 直接 Drop",断言槽位与 permit 都被复位归还。 +- `acquire_times_out_at_pool_acquire_when_pool_is_busy`:池内 permit 全部被占用时,acquire 必须在超时窗口内返回 `PoolAcquire + Timeout`,不允许无限等待。 + +## 修复方案 + +1. `PooledConnectionSlot` 改为 `in_use: AtomicBool + connection: Mutex>`,槽位占用标记不再依赖异步锁。 +2. `PooledConnectionLease` 持有 `Arc` 并实现 `Drop`:无论显式归还还是 future 被取消,统一在 Drop 中复位槽位、按 broken 状态决定连接是否回池,permit 随后自动归还。Drop 体先复位 `in_use` 再释放 permit(字段在 Drop 体之后析构),保证新请求拿到 permit 时必有空闲槽位。 +3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 `pool_size`,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。 +4. `release_connection` 退化为 `drop(lease)`,显式与隐式归还共用同一条兜底路径。 + +## 验收 + +- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(35 通过,含上述新测试) +- `cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz`(2 通过) +- `cargo check -p api-server --manifest-path server-rs/Cargo.toml` + +## 运维提示 + +- 此修复解决的是"取消导致的永久泄漏"。StDB 真慢时仍会出现成批 45 秒超时(连接被在途请求合法占用),那是容量/上游问题,应结合 `GENARRATIVE_SPACETIME_POOL_SIZE` 与 StDB 负载排查,不要再怀疑池泄漏。 +- 健康检查 `/readyz` 在池被在途请求占满时仍可能短暂 503(stage=pool_acquire),恢复后自动转好,无需重启。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b550573f..dd491a15 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -125,11 +125,14 @@ npm run check:server-rs-ddd `/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。 +公开正式 runtime 的启动与局内同步动作统一接受 `RuntimePrincipal`,包括拼图、拼消消、跳一跳、敲木鱼、抓大鹅 Match3D、方洞挑战、视觉小说、大鱼吃小鱼和汪汪声浪。登录用户仍使用账号 Bearer;未登录推荐页或公开运行态使用 Runtime Guest Token,后端以 `principal.subject()` 作为本局 owner / player subject,并用 `WorkPlayTrackingDraft::runtime_principal(...)` 记录游玩。创作、个人作品、删除、发布、Remix、点赞等账号或所有权动作不得改成 runtime guest 鉴权。 + 抓大鹅 Match3D `api-server` 内部拆分: - `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。 - `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。 - `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。 +- `/api/runtime/match3d/works/{profile_id}/runs`、`/api/runtime/match3d/runs/{run_id}`、`/click`、`/stop`、`/restart` 与 `/time-up` 属于正式运行态局部请求,必须接受 `RuntimePrincipal`;登录用户使用账号 Bearer,推荐页匿名游客使用 runtime guest token,后端以 principal subject 作为本局 owner,不得退回只认普通 Bearer 的路由。 - `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。 - `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。 - `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。 @@ -187,6 +190,10 @@ npm run check:server-rs-ddd 1. `creation_entry_type_config.unified_creation_spec_json` 内的 `mudPointCost` 是玩法新建草稿初始生成的泥点成本真相源,同时供入口卡展示和前端余额前置校验使用;旧契约缺失时允许按代码默认成本兜底。 2. `api-server` 执行拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成时,必须通过 `GET /api/creation-entry/config` 同源配置解析对应玩法成本后再调用钱包扣费 procedure,不得继续使用前端或后端硬编码常量作为实际扣费真相。 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-*`。 +7. 拼图首图后台生成的跨实例互斥锁必须落在 SpacetimeDB `puzzle_background_compile_task` 表,claim id 由 `task_id + request_id` 构成,释放时必须校验 claim id,避免旧后台任务释放新请求抢到的租约。 ## 外部服务与资产 @@ -643,6 +650,12 @@ npm run check:server-rs-ddd - Rust 结构体:`PuzzleAgentSessionRow` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` +### `puzzle_background_compile_task` + +- Rust 结构体:`PuzzleBackgroundCompileTaskRow` +- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` +- 说明:拼图首图后台生成的跨 api-server 实例互斥 claim 表,只保存活动任务租约,不表达最终生成结果;`task_id` 为主键,`claim_id` 用于释放时防止误删新租约,租约超时时间为 30 分钟。 + ### `puzzle_event` - Rust 结构体:`PuzzleEvent` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 00e3bf72..790b0d31 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -146,12 +146,13 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。 - 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。 - 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。 -- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 +- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,正式 runtime 启动与后续局内动作继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。平台壳统一通过 `buildRecommendRuntimeRequestOptions(...)` 为各玩法的 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作生成局部 request options,不允许每个玩法各写一套匿名分支。后端 `/api/runtime/*` 正式运行态写请求统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 +- 推荐页作品队列只能通过 `buildPlatformRecommendFeedEntries(...)` 生成,首页卡片窗口、桌面推荐格、嵌入 runtime 自动启动和上一条 / 下一条切换都必须消费同一队列。不得在首页和 `PlatformEntryFlowShellImpl` 内分别按“最新列表顺序”和“评分推荐顺序”各算一套相邻作品,否则连续切换会出现视觉上跳过作品或回跳。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 - 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。 -- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。 +- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo-runtime-hud.webp` 卡通形象小图;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。 - 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。 - 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。 - 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。 @@ -304,7 +305,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。 - 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。 - 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。 -- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo.png` 产品 logo;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。 +- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo-runtime-hud.webp` 产品 logo 小图;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。 - generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。 - `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。 - 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。 diff --git a/media/logo-runtime-hud.webp b/media/logo-runtime-hud.webp new file mode 100644 index 00000000..6f4992f8 Binary files /dev/null and b/media/logo-runtime-hud.webp differ diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 5436ee39..bc2dc80b 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -4,7 +4,11 @@ use axum::http::StatusCode; use serde_json::json; use spacetime_client::SpacetimeClientError; -use crate::{http_error::AppError, state::AppState}; +use crate::{ + http_error::AppError, + state::AppState, + wallet_refund_outbox::{WalletRefundOutboxEnqueueOutcome, WalletRefundOutboxRecord}, +}; pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1; @@ -104,22 +108,11 @@ async fn consume_asset_operation_points( .await { Ok(_) => Ok(true), - Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { - // 中文注释:外部生图不应被 Maincloud 钱包短暂 503 阻断;此时跳过扣费,让业务链路继续,避免用户重复点击。 - tracing::warn!( - owner_user_id, - asset_kind, - asset_id, - error = %error, - "资产操作泥点预扣因 SpacetimeDB 连接不可用而降级跳过" - ); - Ok(false) - } Err(error) => Err(map_asset_operation_wallet_error(error)), } } -/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。 +/// 外部生成或发布 mutation 失败后补偿退款;立即退款失败会进入 outbox,避免覆盖原始业务错误。 async fn refund_asset_operation_points( state: &AppState, owner_user_id: &str, @@ -131,22 +124,74 @@ async fn refund_asset_operation_points( "asset_operation_refund:{}:{}:{}", owner_user_id, asset_kind, asset_id ); + let created_at_micros = current_utc_micros(); if let Err(error) = state .spacetime_client() .refund_profile_wallet_points( owner_user_id.to_string(), points_cost, - ledger_id, - current_utc_micros(), + ledger_id.clone(), + created_at_micros, ) .await { + let refund_error = error.to_string(); + if let Some(outbox) = state.wallet_refund_outbox() { + match outbox + .enqueue(WalletRefundOutboxRecord { + owner_user_id: owner_user_id.to_string(), + amount: points_cost, + ledger_id: ledger_id.clone(), + created_at_micros, + asset_kind: asset_kind.to_string(), + asset_id: asset_id.to_string(), + }) + .await + { + Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) => { + tracing::warn!( + owner_user_id, + asset_kind, + asset_id, + ledger_id, + error = %refund_error, + "资产操作失败后的泥点退款立即执行失败,已写入 wallet refund outbox" + ); + return; + } + Ok(WalletRefundOutboxEnqueueOutcome::Dropped { reason }) => { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + ledger_id, + reason, + error = %refund_error, + "资产操作失败后的泥点退款立即执行失败,且 wallet refund outbox 因容量限制丢弃" + ); + return; + } + Err(outbox_error) => { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + ledger_id, + refund_error = %refund_error, + outbox_error = %outbox_error, + "资产操作失败后的泥点退款立即执行失败,且写入 wallet refund outbox 失败" + ); + return; + } + } + } tracing::error!( owner_user_id, asset_kind, asset_id, - error = %error, - "资产操作失败后的泥点退款失败" + ledger_id, + error = %refund_error, + "资产操作失败后的泥点退款失败,且 wallet refund outbox 未启用" ); } } @@ -199,7 +244,7 @@ mod tests { use super::*; #[test] - fn asset_operation_billing_skips_spacetime_connectivity_errors() { + fn asset_operation_connectivity_errors_are_classified_for_non_billing_fallbacks() { assert_eq!(ASSET_OPERATION_POINTS_COST, 1); assert!(should_skip_asset_operation_billing_for_connectivity( &SpacetimeClientError::ConnectDropped diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index 8e54b0a6..51f775ce 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -37,7 +37,7 @@ use time::{Duration as TimeDuration, OffsetDateTime}; use crate::{ api_response::json_success_body, asset_billing::execute_billable_asset_operation_with_cost, - auth::AuthenticatedAccessToken, + auth::{AuthenticatedAccessToken, RuntimePrincipal}, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, @@ -306,11 +306,12 @@ pub async fn generate_bark_battle_image_asset( .filter(|value| !value.is_empty()) .map(ToString::to_string); let points_cost = resolve_bark_battle_image_asset_points_cost(&state, &payload).await; + let billing_asset_id = request_context.request_id().to_string(); let result = execute_billable_asset_operation_with_cost( &state, &owner_user_id, bark_battle_slot_asset_kind(&slot), - asset_id.as_str(), + billing_asset_id.as_str(), points_cost, async { generate_and_persist_bark_battle_image_asset( @@ -506,13 +507,13 @@ pub async fn get_bark_battle_runtime_config( State(state): State, Path(work_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &work_id, "workId")?; let config = state .spacetime_client() - .get_bark_battle_runtime_config(work_id, Some(authenticated.claims().user_id().to_string())) + .get_bark_battle_runtime_config(work_id, Some(principal.subject().to_string())) .await .map_err(|error| { bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) @@ -526,7 +527,7 @@ pub async fn start_bark_battle_run( State(state): State, Path(work_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let maybe_payload = payload.ok().map(|Json(payload)| payload); @@ -543,7 +544,7 @@ pub async fn start_bark_battle_run( }; ensure_non_empty(&request_context, &work_id, "workId")?; - let owner_user_id = authenticated.claims().user_id().to_string(); + let owner_user_id = principal.subject().to_string(); let runtime_config = state .spacetime_client() .get_bark_battle_runtime_config(work_id.clone(), Some(owner_user_id.clone())) @@ -593,12 +594,13 @@ pub async fn start_bark_battle_run( record_work_play_start_after_success( &state, &request_context, - WorkPlayTrackingDraft::new( + WorkPlayTrackingDraft::runtime_principal( BARK_BATTLE_PLAY_TYPE_ID, work_id.clone(), - &authenticated, + &principal, "/api/runtime/bark-battle/...", ) + .owner_user_id(owner_user_id.clone()) .extra(json!({ "runId": run_snapshot.run_id, "workId": work_id, @@ -607,6 +609,7 @@ pub async fn start_bark_battle_run( "difficultyPreset": runtime_config.difficulty_preset, "sourceRoute": request.source_route, "clientRuntimeVersion": request.client_runtime_version, + "principalKind": principal.kind().as_str(), })), ) .await; @@ -638,12 +641,12 @@ pub async fn get_bark_battle_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let run = state .spacetime_client() - .get_bark_battle_run(run_id, authenticated.claims().user_id().to_string()) + .get_bark_battle_run(run_id, principal.subject().to_string()) .await .map_err(|error| { bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) @@ -657,7 +660,7 @@ pub async fn finish_bark_battle_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = bark_battle_json(payload, &request_context)?; @@ -698,7 +701,7 @@ pub async fn finish_bark_battle_run( .finish_bark_battle_run(BarkBattleRunFinishRecordInput { run_id, run_token: payload.run_token, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), work_id: payload.work_id.clone(), config_version: u64::from(payload.config_version), ruleset_version: payload.ruleset_version.clone(), diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index b45dfb2e..e8969117 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -63,7 +63,7 @@ use crate::{ }, api_response::json_success_body, asset_billing::execute_billable_asset_operation, - auth::AuthenticatedAccessToken, + auth::{AuthenticatedAccessToken, RuntimePrincipal}, character_visual_assets::try_apply_background_alpha_to_png, http_error::AppError, platform_errors::map_oss_error, @@ -224,7 +224,7 @@ pub async fn record_big_fish_play( State(state): State, Path(session_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -242,7 +242,7 @@ pub async fn record_big_fish_play( .spacetime_client() .record_big_fish_play(BigFishPlayReportRecordInput { session_id: session_id.clone(), - user_id: authenticated.claims().user_id().to_string(), + user_id: principal.subject().to_string(), elapsed_ms: payload.elapsed_ms.unwrap_or(0), reported_at_micros: current_utc_micros(), }) @@ -254,13 +254,14 @@ pub async fn record_big_fish_play( record_work_play_start_after_success( &state, &request_context, - WorkPlayTrackingDraft::new( + WorkPlayTrackingDraft::runtime_principal( "big-fish", session_id.clone(), - &authenticated, + &principal, "/api/runtime/big-fish/sessions/{session_id}/play", ) - .run_id(session_id.clone()), + .run_id(session_id.clone()) + .owner_user_id(principal.subject().to_string()), ) .await; @@ -279,7 +280,7 @@ pub async fn start_big_fish_run( State(state): State, Path(session_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; @@ -288,7 +289,7 @@ pub async fn start_big_fish_run( .start_big_fish_run(BigFishRunStartRecordInput { run_id: build_prefixed_uuid_id("big-fish-run-"), session_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), started_at_micros: current_utc_micros(), }) .await @@ -339,13 +340,13 @@ pub async fn get_big_fish_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let run = state .spacetime_client() - .get_big_fish_run(run_id, authenticated.claims().user_id().to_string()) + .get_big_fish_run(run_id, principal.subject().to_string()) .await .map_err(|error| { big_fish_error_response(&request_context, map_big_fish_client_error(error)) @@ -363,7 +364,7 @@ pub async fn submit_big_fish_input( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -384,7 +385,7 @@ pub async fn submit_big_fish_input( .spacetime_client() .submit_big_fish_input(BigFishInputSubmitRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), x: payload.x, y: payload.y, submitted_at_micros: current_utc_micros(), @@ -721,7 +722,7 @@ pub async fn execute_big_fish_action( "big_fish_publish_game" => Some("big_fish_publish_game"), _ => None, }; - let billing_asset_id = format!("{session_id}:{now}"); + let billing_asset_id = format!("{}:{}:{}", session_id, action, request_context.request_id()); let session_operation = async { match action.as_str() { "big_fish_compile_draft" => { diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 7c52d86d..0ff1f7aa 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -38,6 +38,11 @@ pub struct AppConfig { pub tracking_outbox_batch_size: usize, pub tracking_outbox_flush_interval: Duration, pub tracking_outbox_max_bytes: u64, + pub wallet_refund_outbox_enabled: bool, + pub wallet_refund_outbox_dir: PathBuf, + pub wallet_refund_outbox_batch_size: usize, + pub wallet_refund_outbox_flush_interval: Duration, + pub wallet_refund_outbox_max_bytes: u64, pub log_filter: String, pub otel_enabled: bool, pub admin_username: Option, @@ -239,6 +244,11 @@ impl Default for AppConfig { tracking_outbox_batch_size: 500, tracking_outbox_flush_interval: Duration::from_millis(1_000), tracking_outbox_max_bytes: 256 * 1024 * 1024, + wallet_refund_outbox_enabled: true, + wallet_refund_outbox_dir: PathBuf::from("server-rs/.data/wallet-refund-outbox"), + wallet_refund_outbox_batch_size: 100, + wallet_refund_outbox_flush_interval: Duration::from_millis(1_000), + wallet_refund_outbox_max_bytes: 64 * 1024 * 1024, log_filter: "info,tower_http=info".to_string(), otel_enabled: false, admin_username: None, @@ -494,6 +504,27 @@ impl AppConfig { { config.tracking_outbox_max_bytes = max_bytes; } + if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"]) { + config.wallet_refund_outbox_enabled = enabled; + } + if let Some(dir) = read_first_non_empty_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"]) { + config.wallet_refund_outbox_dir = PathBuf::from(dir); + } + if let Some(batch_size) = + read_first_usize_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"]) + { + config.wallet_refund_outbox_batch_size = batch_size; + } + if let Some(flush_interval_ms) = + read_first_positive_u64_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"]) + { + config.wallet_refund_outbox_flush_interval = Duration::from_millis(flush_interval_ms); + } + if let Some(max_bytes) = + read_first_positive_u64_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"]) + { + config.wallet_refund_outbox_max_bytes = max_bytes; + } if let Some(otel_enabled) = read_first_bool_env(&["GENARRATIVE_OTEL_ENABLED"]) { config.otel_enabled = otel_enabled; } @@ -1593,6 +1624,11 @@ mod tests { std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"); std::env::remove_var("GENARRATIVE_OTEL_ENABLED"); std::env::set_var("GENARRATIVE_API_LISTEN_BACKLOG", "2048"); std::env::set_var("GENARRATIVE_API_WORKER_THREADS", "6"); @@ -1609,6 +1645,14 @@ mod tests { std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576"); + std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED", "false"); + std::env::set_var( + "GENARRATIVE_WALLET_REFUND_OUTBOX_DIR", + "/tmp/genarrative-wallet-refund-outbox", + ); + std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE", "50"); + std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS", "3000"); + std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES", "524288"); std::env::set_var("GENARRATIVE_OTEL_ENABLED", "true"); } @@ -1634,6 +1678,17 @@ mod tests { std::time::Duration::from_millis(2_000) ); assert_eq!(config.tracking_outbox_max_bytes, 1_048_576); + assert!(!config.wallet_refund_outbox_enabled); + assert_eq!( + config.wallet_refund_outbox_dir, + std::path::PathBuf::from("/tmp/genarrative-wallet-refund-outbox") + ); + assert_eq!(config.wallet_refund_outbox_batch_size, 50); + assert_eq!( + config.wallet_refund_outbox_flush_interval, + std::time::Duration::from_millis(3_000) + ); + assert_eq!(config.wallet_refund_outbox_max_bytes, 524_288); assert!(config.otel_enabled); unsafe { @@ -1649,6 +1704,11 @@ mod tests { std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"); + std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"); std::env::remove_var("GENARRATIVE_OTEL_ENABLED"); } } diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index d235f028..be751aa4 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -547,11 +547,12 @@ pub async fn generate_custom_world_scene_image( require_openai_image_settings(&state) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-scene-{}", current_utc_millis()); + let billing_asset_id = request_context.request_id().to_string(); let asset = execute_billable_asset_operation( &state, &owner_user_id, "scene_image", - asset_id.as_str(), + billing_asset_id.as_str(), async { let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( &request_context, @@ -806,11 +807,12 @@ pub async fn generate_custom_world_cover_image( require_dashscope_settings(&state) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-cover-{}", current_utc_millis()); + let billing_asset_id = request_context.request_id().to_string(); let asset = execute_billable_asset_operation( &state, &owner_user_id, "custom_world_cover", - asset_id.as_str(), + billing_asset_id.as_str(), async { let settings = require_dashscope_settings(&state)?; let http_client = build_dashscope_http_client(&settings)?; @@ -1011,11 +1013,12 @@ pub async fn generate_custom_world_opening_cg( .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let opening_cg_id = normalized.opening_cg_id.clone(); + let billing_asset_id = request_context.request_id().to_string(); let generated = execute_billable_asset_operation_with_cost( &state, &owner_user_id, "custom_world_opening_cg", - opening_cg_id.as_str(), + billing_asset_id.as_str(), OPENING_CG_POINTS_COST, async { let image_settings = require_openai_image_settings(&state)? 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 bb553ef2..6e989b10 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -19,7 +19,7 @@ use crate::{ ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload, PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload, execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job, - execute_puzzle_generate_ui_background_worker_job, + execute_puzzle_generate_ui_background_worker_job, release_puzzle_compile_background_claim, }, request_context::RequestContext, state::{AppState, PuzzleApiState}, @@ -256,13 +256,13 @@ async fn process_external_generation_job_once( match execute_puzzle_compile_draft_worker_job( &puzzle_state, &request_context, - payload, + payload.clone(), write_guard, ) .await { Ok(session) => { - complete_job( + let result = complete_job( &state, &worker_id, &job, @@ -274,12 +274,23 @@ async fn process_external_generation_job_once( .to_string(), ), ) - .await + .await; + if result.is_ok() { + release_puzzle_compile_background_claim(&puzzle_state, &payload); + } + result } Err(error) => { let message = error.body_text(); - fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message) - .await?; + let should_release_claim = error.should_fail_queue_job(); + let result = fail_queue_job_after_worker_error( + &state, &worker_id, &job, &error, &message, + ) + .await; + if result.is_ok() && should_release_claim { + release_puzzle_compile_background_claim(&puzzle_state, &payload); + } + result?; Err(message) } } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 95d52f61..3b6765ed 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -90,6 +90,7 @@ mod tracking_outbox; mod vector_engine_audio_generation; mod visual_novel; mod volcengine_speech; +mod wallet_refund_outbox; mod wechat; mod wooden_fish; mod work_author; @@ -117,6 +118,7 @@ use crate::{ external_generation_worker::run_external_generation_worker, state::{AppState, AppStateInitError}, tracking_outbox::TrackingOutbox, + wallet_refund_outbox::WalletRefundOutbox, }; const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; @@ -127,6 +129,7 @@ const AUTH_STORE_STARTUP_RETRY_INTERVAL: Duration = Duration::from_secs(5); struct ShutdownContext { app_state: Option, tracking_outbox: Option>, + wallet_refund_outbox: Option>, outbox_flush_timeout: Duration, } @@ -204,6 +207,7 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { Ok(state) => { spawn_app_state_background_workers(&state); let tracking_outbox = state.tracking_outbox(); + let wallet_refund_outbox = state.wallet_refund_outbox(); let worker_state = process_role .runs_external_generation_worker() .then(|| state.clone()); @@ -212,6 +216,7 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { ShutdownContext { app_state: Some(state), tracking_outbox, + wallet_refund_outbox, outbox_flush_timeout, }, worker_state, @@ -222,6 +227,7 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { ShutdownContext { app_state: None, tracking_outbox: None, + wallet_refund_outbox: None, outbox_flush_timeout, }, None, @@ -310,12 +316,8 @@ async fn finalize_shutdown(context: ShutdownContext) { state.mark_not_ready(); } - let Some(outbox) = context.tracking_outbox else { - return; - }; - if context.outbox_flush_timeout.is_zero() { - warn!("api-server 退出时 tracking outbox flush timeout 为 0,跳过主动 flush"); + warn!("api-server 退出时 outbox flush timeout 为 0,跳过主动 flush"); return; } @@ -323,22 +325,45 @@ async fn finalize_shutdown(context: ShutdownContext) { .outbox_flush_timeout .as_millis() .min(u128::from(u64::MAX)) as u64; - info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox"); - match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await { - Ok(Ok(())) => { - info!("api-server 退出前 tracking outbox flush 完成"); + if let Some(outbox) = context.tracking_outbox { + info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox"); + match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await { + Ok(Ok(())) => { + info!("api-server 退出前 tracking outbox flush 完成"); + } + Ok(Err(error)) => { + warn!( + error = %error, + "api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试" + ); + } + Err(_) => { + warn!( + timeout_ms, + "api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试" + ); + } } - Ok(Err(error)) => { - warn!( - error = %error, - "api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试" - ); - } - Err(_) => { - warn!( - timeout_ms, - "api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试" - ); + } + + if let Some(outbox) = context.wallet_refund_outbox { + info!(timeout_ms, "api-server 退出前 flush wallet refund outbox"); + match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await { + Ok(Ok(())) => { + info!("api-server 退出前 wallet refund outbox flush 完成"); + } + Ok(Err(error)) => { + warn!( + error = %error, + "api-server 退出前 wallet refund outbox flush 未完成,已保留本地文件等待下次启动重试" + ); + } + Err(_) => { + warn!( + timeout_ms, + "api-server 退出前 wallet refund outbox flush 超时,已保留本地文件等待下次启动重试" + ); + } } } } @@ -348,6 +373,9 @@ fn spawn_app_state_background_workers(state: &AppState) { if let Some(outbox) = state.tracking_outbox() { outbox.spawn_worker(); } + if let Some(outbox) = state.wallet_refund_outbox() { + outbox.spawn_worker(); + } } fn build_tcp_listener( diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 5517c6bd..61828c65 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::BTreeMap, convert::Infallible, future::Future, @@ -65,11 +65,8 @@ use spacetime_client::{ use crate::{ api_response::json_success_body, - asset_billing::{ - execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error, - should_skip_asset_operation_billing_for_connectivity, - }, - auth::AuthenticatedAccessToken, + asset_billing::{execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error}, + auth::{AuthenticatedAccessToken, RuntimePrincipal}, config::AppConfig, generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha, http_error::AppError, @@ -354,13 +351,6 @@ impl Match3DItemAssetsGenerationPlan { Self::Replace(plan) => plan.requested_item_names.len(), } } - - fn billing_fingerprint_source(&self) -> String { - match self { - Self::Append(plan) => format!("append:{}", plan.requested_item_names.join("|")), - Self::Replace(plan) => format!("replace:{}", plan.requested_item_names.join("|")), - } - } } fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option { diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index eb99ec38..3c8a8c2d 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -162,7 +162,12 @@ pub(super) async fn compile_match3d_draft_for_session( let initial_tags = requested_tags .clone() .unwrap_or_else(|| fallback_work_metadata.tags.clone()); - let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros()); + let billing_asset_id = format!( + "{}:{}:{}", + session_id, + profile_id, + request_context.request_id() + ); let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost( state, "match3d", @@ -514,15 +519,6 @@ async fn consume_match3d_draft_generation_points( .await { Ok(_) => Ok(true), - Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { - tracing::warn!( - owner_user_id, - billing_asset_id, - error = %error, - "抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过" - ); - Ok(false) - } Err(error) => Err(match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, diff --git a/server-rs/crates/api-server/src/match3d/handlers.rs b/server-rs/crates/api-server/src/match3d/handlers.rs index 6d41626f..bc3462ef 100644 --- a/server-rs/crates/api-server/src/match3d/handlers.rs +++ b/server-rs/crates/api-server/src/match3d/handlers.rs @@ -751,7 +751,6 @@ pub async fn generate_match3d_background_image_for_work( )?; let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; - let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) @@ -763,7 +762,12 @@ pub async fn generate_match3d_background_image_for_work( config, assets, } = context; - let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, prompt_fingerprint); + let billing_asset_id = format!( + "{}:{}:{}", + session_id, + profile_id, + request_context.request_id() + ); let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( &state, owner_user_id.as_str(), @@ -860,7 +864,6 @@ pub async fn generate_match3d_container_image_for_work( )?; let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; - let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) @@ -874,7 +877,9 @@ pub async fn generate_match3d_container_image_for_work( } = context; let billing_asset_id = format!( "{}:{}:{}:container", - session_id, profile_id, prompt_fingerprint + session_id, + profile_id, + request_context.request_id() ); let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( &state, @@ -1017,7 +1022,7 @@ pub async fn generate_match3d_item_assets_for_work( session_id, profile_id, billed_item_count, - build_match3d_prompt_fingerprint(generation_plan.billing_fingerprint_source().as_str()) + request_context.request_id() ); let generated_assets = execute_billable_asset_operation_with_cost( &state, @@ -1171,7 +1176,7 @@ pub async fn start_match3d_run( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let maybe_payload = payload.ok().map(|Json(payload)| payload); @@ -1191,7 +1196,7 @@ pub async fn start_match3d_run( .spacetime_client() .start_match3d_run(Match3DRunStartRecordInput { run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), profile_id: profile_id.clone(), started_at_ms: current_utc_ms(), item_type_count_override: maybe_payload @@ -1211,15 +1216,17 @@ pub async fn start_match3d_run( record_work_play_start_after_success( &state, &request_context, - WorkPlayTrackingDraft::new( + WorkPlayTrackingDraft::runtime_principal( "match3d", profile_id.clone(), - &authenticated, + &principal, "/api/runtime/match3d/...", ) .profile_id(profile_id.clone()) + .owner_user_id(principal.subject().to_string()) .extra(json!({ "runId": run.run_id, + "principalKind": principal.kind().as_str(), })), ) .await; @@ -1236,13 +1243,13 @@ pub async fn get_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() - .get_match3d_run(run_id, authenticated.claims().user_id().to_string()) + .get_match3d_run(run_id, principal.subject().to_string()) .await .map_err(|error| { match3d_error_response( @@ -1264,7 +1271,7 @@ pub async fn click_match3d_item( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?; @@ -1286,7 +1293,7 @@ pub async fn click_match3d_item( .spacetime_client() .click_match3d_item(Match3DRunClickRecordInput { run_id: payload.run_id.unwrap_or(run_id), - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), item_instance_id: payload.item_instance_id, client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32, client_event_id: payload.client_event_id, @@ -1313,7 +1320,7 @@ pub async fn stop_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let _ = payload.ok(); @@ -1323,7 +1330,7 @@ pub async fn stop_match3d_run( .spacetime_client() .stop_match3d_run(Match3DRunStopRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), stopped_at_ms: current_utc_ms(), }) .await @@ -1347,7 +1354,7 @@ pub async fn restart_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; @@ -1356,7 +1363,7 @@ pub async fn restart_match3d_run( .restart_match3d_run(Match3DRunRestartRecordInput { source_run_id: run_id, next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), restarted_at_ms: current_utc_ms(), }) .await @@ -1380,7 +1387,7 @@ pub async fn finish_match3d_time_up( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; @@ -1388,7 +1395,7 @@ pub async fn finish_match3d_time_up( .spacetime_client() .finish_match3d_time_up(Match3DRunTimeUpRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), finished_at_ms: current_utc_ms(), }) .await diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 670d3ca8..5de20f2e 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1208,14 +1208,6 @@ pub(super) fn normalize_match3d_background_prompt(raw: &str) -> String { .to_string() } -pub(super) fn build_match3d_prompt_fingerprint(value: &str) -> String { - let mut hash = 0u32; - for character in value.chars() { - hash = hash.wrapping_mul(31).wrapping_add(character as u32); - } - format!("{hash:08x}") -} - pub(super) fn build_fallback_match3d_background_prompt(config: &Match3DConfigJson) -> String { let theme = config.theme_text.trim(); let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; diff --git a/server-rs/crates/api-server/src/modules/bark_battle.rs b/server-rs/crates/api-server/src/modules/bark_battle.rs index 6184150e..5021acf0 100644 --- a/server-rs/crates/api-server/src/modules/bark_battle.rs +++ b/server-rs/crates/api-server/src/modules/bark_battle.rs @@ -4,7 +4,7 @@ use axum::{ }; use crate::{ - auth::require_bearer_auth, + auth::{require_bearer_auth, require_runtime_principal_auth}, bark_battle::{ create_bark_battle_draft, delete_bark_battle_work, finish_bark_battle_run, generate_bark_battle_image_asset, get_bark_battle_run, get_bark_battle_runtime_config, @@ -66,26 +66,28 @@ pub fn router(state: AppState) -> Router { "/api/runtime/bark-battle/works/{work_id}/config", get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/bark-battle/works/{work_id}/runs", post(start_bark_battle_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/bark-battle/runs/{run_id}", get(get_bark_battle_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/bark-battle/runs/{run_id}/finish", - post(finish_bark_battle_run) - .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)), + post(finish_bark_battle_run).route_layer(middleware::from_fn_with_state( + state, + require_runtime_principal_auth, + )), ) } diff --git a/server-rs/crates/api-server/src/modules/big_fish.rs b/server-rs/crates/api-server/src/modules/big_fish.rs index 9ce7bbd2..8de88c5b 100644 --- a/server-rs/crates/api-server/src/modules/big_fish.rs +++ b/server-rs/crates/api-server/src/modules/big_fish.rs @@ -4,7 +4,7 @@ use axum::{ }; use crate::{ - auth::require_bearer_auth, + auth::{require_bearer_auth, require_runtime_principal_auth}, big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run, get_big_fish_session, get_big_fish_works, list_big_fish_gallery, @@ -85,35 +85,35 @@ pub fn router(state: AppState) -> Router { "/api/runtime/big-fish/sessions/{session_id}/play", post(record_big_fish_play).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/big-fish/works/{session_id}/play", post(record_big_fish_play).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/big-fish/sessions/{session_id}/runs", post(start_big_fish_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/big-fish/runs/{run_id}", get(get_big_fish_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/big-fish/runs/{run_id}/input", post(submit_big_fish_input).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) } diff --git a/server-rs/crates/api-server/src/modules/match3d.rs b/server-rs/crates/api-server/src/modules/match3d.rs index 90728fa7..a331035c 100644 --- a/server-rs/crates/api-server/src/modules/match3d.rs +++ b/server-rs/crates/api-server/src/modules/match3d.rs @@ -4,7 +4,7 @@ use axum::{ }; use crate::{ - auth::require_bearer_auth, + auth::{require_bearer_auth, require_runtime_principal_auth}, match3d::{ click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session, delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up, @@ -139,42 +139,42 @@ pub fn router(state: AppState) -> Router { "/api/runtime/match3d/works/{profile_id}/runs", post(start_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}", get(get_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/click", post(click_match3d_item).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/stop", post(stop_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/restart", post(restart_match3d_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/match3d/runs/{run_id}/time-up", post(finish_match3d_time_up).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) } diff --git a/server-rs/crates/api-server/src/modules/square_hole.rs b/server-rs/crates/api-server/src/modules/square_hole.rs index 93abd129..d074c0d9 100644 --- a/server-rs/crates/api-server/src/modules/square_hole.rs +++ b/server-rs/crates/api-server/src/modules/square_hole.rs @@ -4,7 +4,7 @@ use axum::{ }; use crate::{ - auth::require_bearer_auth, + auth::{require_bearer_auth, require_runtime_principal_auth}, square_hole::{ compile_square_hole_agent_draft, create_square_hole_agent_session, delete_square_hole_work, drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up, @@ -101,42 +101,42 @@ pub fn router(state: AppState) -> Router { "/api/runtime/square-hole/works/{profile_id}/runs", post(start_square_hole_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}", get(get_square_hole_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}/drop", post(drop_square_hole_shape).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}/stop", post(stop_square_hole_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}/restart", post(restart_square_hole_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/square-hole/runs/{run_id}/time-up", post(finish_square_hole_time_up).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) } diff --git a/server-rs/crates/api-server/src/modules/visual_novel.rs b/server-rs/crates/api-server/src/modules/visual_novel.rs index 521122ca..e7a373ab 100644 --- a/server-rs/crates/api-server/src/modules/visual_novel.rs +++ b/server-rs/crates/api-server/src/modules/visual_novel.rs @@ -4,7 +4,7 @@ use axum::{ }; use crate::{ - auth::require_bearer_auth, + auth::{require_bearer_auth, require_runtime_principal_auth}, state::AppState, vector_engine_audio_generation::{ create_background_music_task, create_sound_effect_task, @@ -151,33 +151,35 @@ pub fn router(state: AppState) -> Router { "/api/runtime/visual-novel/works/{profile_id}/runs", post(start_visual_novel_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/visual-novel/runs/{run_id}", get(get_visual_novel_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/visual-novel/runs/{run_id}/actions/stream", post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/visual-novel/runs/{run_id}/history", get(list_visual_novel_history).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/visual-novel/runs/{run_id}/regenerate", - post(regenerate_visual_novel_run) - .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)), + post(regenerate_visual_novel_run).route_layer(middleware::from_fn_with_state( + state, + require_runtime_principal_auth, + )), ) } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 1d227ee9..cc9d0237 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -55,7 +55,8 @@ use spacetime_client::{ ExternalGenerationJobEnqueueRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, - PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, + PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput, + PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, @@ -138,6 +139,75 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024"; const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536"; +fn build_puzzle_background_compile_task_id(session_id: &str) -> String { + format!("puzzle_initial_background:{session_id}") +} + +fn build_puzzle_background_compile_claim_id(task_id: &str, request_id: &str) -> String { + format!("{task_id}:{request_id}") +} + +async fn release_claimed_puzzle_background_compile_task( + state: &PuzzleApiState, + task_id: &str, + claim_id: &str, + session_id: &str, + owner_user_id: &str, +) { + let result = state + .spacetime_client() + .release_puzzle_background_compile_task(PuzzleBackgroundCompileTaskReleaseRecordInput { + task_id: task_id.to_string(), + claim_id: claim_id.to_string(), + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await; + match result { + Ok(true) => {} + Ok(false) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + task_id, + claim_id, + session_id, + owner_user_id, + "拼图首图后台生成任务释放未命中当前 claim" + ); + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + task_id, + claim_id, + session_id, + owner_user_id, + error = %error, + "拼图首图后台生成任务释放失败" + ); + } + } +} + +pub(crate) fn spawn_release_claimed_puzzle_background_compile_task( + state: PuzzleApiState, + task_id: String, + claim_id: String, + session_id: String, + owner_user_id: String, +) { + tokio::spawn(async move { + release_claimed_puzzle_background_compile_task( + &state, + &task_id, + &claim_id, + &session_id, + &owner_user_id, + ) + .await; + }); +} + fn has_puzzle_cover_image_src(value: &Option) -> bool { value .as_deref() diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index ce050e55..0f6e949e 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -157,6 +157,10 @@ pub(crate) struct PuzzleCompileDraftWorkerPayload { #[serde(default)] pub image_model: Option, pub requested_at_micros: i64, + #[serde(default)] + pub background_task_id: Option, + #[serde(default)] + pub background_claim_id: Option, } pub(crate) async fn execute_puzzle_compile_draft_worker_job( @@ -228,6 +232,11 @@ pub(crate) async fn execute_puzzle_compile_draft_worker_job( ) .await; } + release_inline_puzzle_compile_background_claim( + state, + &payload, + &external_generation_guard, + ); Ok(session) } Err(error) => { @@ -255,6 +264,11 @@ pub(crate) async fn execute_puzzle_compile_draft_worker_job( }, ) .await; + release_inline_puzzle_compile_background_claim( + state, + &payload, + &external_generation_guard, + ); Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error)) } Err(mark_error) => { @@ -265,6 +279,37 @@ pub(crate) async fn execute_puzzle_compile_draft_worker_job( } } +fn release_inline_puzzle_compile_background_claim( + state: &PuzzleApiState, + payload: &PuzzleCompileDraftWorkerPayload, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, +) { + if external_generation_guard.job_id.is_some() { + return; + } + release_puzzle_compile_background_claim(state, payload); +} + +pub(crate) fn release_puzzle_compile_background_claim( + state: &PuzzleApiState, + payload: &PuzzleCompileDraftWorkerPayload, +) { + let (Some(task_id), Some(claim_id)) = ( + payload.background_task_id.as_ref(), + payload.background_claim_id.as_ref(), + ) else { + return; + }; + + spawn_release_claimed_puzzle_background_compile_task( + state.clone(), + task_id.clone(), + claim_id.clone(), + payload.session_id.clone(), + payload.owner_user_id.clone(), + ); +} + pub(crate) async fn mark_puzzle_compile_failure_for_worker( state: &PuzzleApiState, session_id: &str, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 47123c8f..e5e24dda 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -588,7 +588,7 @@ pub async fn execute_puzzle_agent_action( let owner_user_id = authenticated.claims().user_id().to_string(); let now = current_utc_micros(); let action = payload.action.trim().to_string(); - let billing_asset_id = format!("{session_id}:{now}"); + let billing_asset_id = format!("{}:{}:{}", session_id, action, request_context.request_id()); tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, @@ -658,6 +658,79 @@ pub async fn execute_puzzle_agent_action( reference_image_src: primary_reference_image_src.map(ToOwned::to_owned), image_model: payload.image_model.clone(), requested_at_micros: now, + background_task_id: None, + background_claim_id: None, + }; + let worker_payload = if ai_redraw { + let background_task_id = + build_puzzle_background_compile_task_id(&compile_session_id); + let background_claim_id = build_puzzle_background_compile_claim_id( + &background_task_id, + request_context.request_id(), + ); + let claim_result = state + .spacetime_client() + .claim_puzzle_background_compile_task( + PuzzleBackgroundCompileTaskClaimRecordInput { + task_id: background_task_id.clone(), + claim_id: background_claim_id.clone(), + session_id: compile_session_id.clone(), + owner_user_id: owner_user_id.clone(), + claimed_at_micros: current_utc_micros(), + }, + ) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + if !claim_result { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compile_session_id, + owner_user_id = %owner_user_id, + task_id = %background_task_id, + "拼图首图后台生成任务已存在,本次 action 直接返回生成中会话" + ); + let session = state + .spacetime_client() + .get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone()) + .await + .map(mark_puzzle_initial_generation_started_snapshot) + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: background_task_id, + operation_type: "compile_puzzle_draft".to_string(), + status: "running".to_string(), + phase_label: "首关拼图草稿".to_string(), + phase_detail: "首关草稿生成已在后台处理中。".to_string(), + progress: session.progress_percent.max(10), + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } + + PuzzleCompileDraftWorkerPayload { + background_task_id: Some(background_task_id), + background_claim_id: Some(background_claim_id), + ..worker_payload + } + } else { + worker_payload }; if state .root_state() @@ -675,7 +748,7 @@ pub async fn execute_puzzle_agent_action( let session = execute_puzzle_compile_draft_worker_job( &state, &request_context, - worker_payload, + worker_payload.clone(), ExternalGenerationWriteLeaseGuard::inline(), ) .await @@ -707,6 +780,18 @@ pub async fn execute_puzzle_agent_action( )); } let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| { + if let (Some(task_id), Some(claim_id)) = ( + worker_payload.background_task_id.as_deref(), + worker_payload.background_claim_id.as_deref(), + ) { + spawn_release_claimed_puzzle_background_compile_task( + state.clone(), + task_id.to_string(), + claim_id.to_string(), + compile_session_id.clone(), + owner_user_id.clone(), + ); + } puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, @@ -736,6 +821,18 @@ pub async fn execute_puzzle_agent_action( }) .await .map_err(|error| { + if let (Some(task_id), Some(claim_id)) = ( + worker_payload.background_task_id.as_deref(), + worker_payload.background_claim_id.as_deref(), + ) { + spawn_release_claimed_puzzle_background_compile_task( + state.clone(), + task_id.to_string(), + claim_id.to_string(), + compile_session_id.clone(), + owner_user_id.clone(), + ); + } puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, @@ -2034,7 +2131,7 @@ pub async fn use_puzzle_runtime_prop( } }; let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time"); - let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); + let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, request_context.request_id()); let reducer_owner_user_id = owner_user_id.clone(); let reducer_run_id = run_id.clone(); let fallback_run_id = run_id.clone(); diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index dcfd3ddd..ca8c4f3b 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -69,7 +69,7 @@ use crate::generated_image_assets::{ use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, api_response::json_success_body, - auth::AuthenticatedAccessToken, + auth::{AuthenticatedAccessToken, RuntimePrincipal}, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation, @@ -739,7 +739,7 @@ pub async fn start_square_hole_run( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let maybe_payload = payload.ok().map(|Json(payload)| payload); @@ -758,7 +758,7 @@ pub async fn start_square_hole_run( .spacetime_client() .start_square_hole_run(SquareHoleRunStartRecordInput { run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX), - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), profile_id: profile_id.clone(), started_at_ms: current_utc_ms(), }) @@ -774,15 +774,17 @@ pub async fn start_square_hole_run( record_work_play_start_after_success( &state, &request_context, - WorkPlayTrackingDraft::new( + WorkPlayTrackingDraft::runtime_principal( "square-hole", profile_id.clone(), - &authenticated, + &principal, "/api/runtime/square-hole/...", ) .profile_id(profile_id.clone()) + .owner_user_id(principal.subject().to_string()) .extra(json!({ "runId": run.run_id, + "principalKind": principal.kind().as_str(), })), ) .await; @@ -799,7 +801,7 @@ pub async fn get_square_hole_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, @@ -810,7 +812,7 @@ pub async fn get_square_hole_run( let run = state .spacetime_client() - .get_square_hole_run(run_id, authenticated.claims().user_id().to_string()) + .get_square_hole_run(run_id, principal.subject().to_string()) .await .map_err(|error| { square_hole_error_response( @@ -832,7 +834,7 @@ pub async fn drop_square_hole_shape( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_RUNTIME_PROVIDER)?; @@ -859,7 +861,7 @@ pub async fn drop_square_hole_shape( .spacetime_client() .drop_square_hole_shape(SquareHoleRunDropRecordInput { run_id: payload.run_id.unwrap_or(run_id), - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), hole_id: payload.hole_id, client_snapshot_version: payload.client_snapshot_version, client_event_id: payload.client_event_id, @@ -887,7 +889,7 @@ pub async fn stop_square_hole_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let _ = payload.ok(); @@ -902,7 +904,7 @@ pub async fn stop_square_hole_run( .spacetime_client() .stop_square_hole_run(SquareHoleRunStopRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), stopped_at_ms: current_utc_ms(), }) .await @@ -926,7 +928,7 @@ pub async fn restart_square_hole_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, @@ -940,7 +942,7 @@ pub async fn restart_square_hole_run( .restart_square_hole_run(SquareHoleRunRestartRecordInput { source_run_id: run_id, next_run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX), - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), restarted_at_ms: current_utc_ms(), }) .await @@ -964,7 +966,7 @@ pub async fn finish_square_hole_time_up( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, @@ -977,7 +979,7 @@ pub async fn finish_square_hole_time_up( .spacetime_client() .finish_square_hole_time_up(SquareHoleRunTimeUpRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), finished_at_ms: current_utc_ms(), }) .await diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index d8882747..57653736 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -41,6 +41,7 @@ use tracing::{info, warn}; use crate::config::AppConfig; use crate::puzzle_gallery_cache::PuzzleGalleryCache; use crate::tracking_outbox::TrackingOutbox; +use crate::wallet_refund_outbox::WalletRefundOutbox; use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; use crate::wechat::provider::build_wechat_provider; use crate::work_author::{ @@ -263,6 +264,7 @@ pub struct AppStateInner { spacetime_client: SpacetimeClient, puzzle_gallery_cache: PuzzleGalleryCache, tracking_outbox: Option>, + wallet_refund_outbox: Option>, llm_client: Option, creative_agent_gpt5_client: Option, creative_agent_executor: Arc, @@ -406,6 +408,8 @@ impl AppState { procedure_timeout: config.spacetime_procedure_timeout, }); let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone()); + let wallet_refund_outbox = + WalletRefundOutbox::from_config(&config, spacetime_client.clone()); let llm_client = build_llm_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); @@ -441,6 +445,7 @@ impl AppState { spacetime_client, puzzle_gallery_cache: PuzzleGalleryCache::new(), tracking_outbox, + wallet_refund_outbox, llm_client, creative_agent_gpt5_client, creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), @@ -922,6 +927,10 @@ impl AppState { self.tracking_outbox.clone() } + pub fn wallet_refund_outbox(&self) -> Option> { + self.wallet_refund_outbox.clone() + } + pub fn llm_client(&self) -> Option<&LlmClient> { self.llm_client.as_ref() } diff --git a/server-rs/crates/api-server/src/visual_novel.rs b/server-rs/crates/api-server/src/visual_novel.rs index 228c147b..d97ffea0 100644 --- a/server-rs/crates/api-server/src/visual_novel.rs +++ b/server-rs/crates/api-server/src/visual_novel.rs @@ -30,7 +30,7 @@ use time::OffsetDateTime; use crate::{ api_response::json_success_body, - auth::AuthenticatedAccessToken, + auth::{AuthenticatedAccessToken, RuntimePrincipal}, http_error::AppError, prompt::visual_novel as vn_prompt, request_context::RequestContext, @@ -434,7 +434,7 @@ pub async fn start_visual_novel_run( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; @@ -453,7 +453,7 @@ pub async fn start_visual_novel_run( .spacetime_client() .start_visual_novel_run(VisualNovelRunStartRecordInput { run_id: build_prefixed_uuid_id(domain::VISUAL_NOVEL_RUN_ID_PREFIX), - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), profile_id: profile_id.clone(), mode: run_mode_to_wire(&payload.mode).to_string(), snapshot_json: None, @@ -467,16 +467,18 @@ pub async fn start_visual_novel_run( record_work_play_start_after_success( &state, &request_context, - WorkPlayTrackingDraft::new( + WorkPlayTrackingDraft::runtime_principal( "visual-novel", profile_id.clone(), - &authenticated, + &principal, "/api/runtime/visual-novel/...", ) .profile_id(profile_id.clone()) + .owner_user_id(principal.subject().to_string()) .extra(json!({ "mode": run_mode_to_wire(&payload.mode), "runId": run.run_id, + "principalKind": principal.kind().as_str(), })), ) .await; @@ -493,12 +495,12 @@ pub async fn get_visual_novel_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&run_id, "runId")?; let run = state .spacetime_client() - .get_visual_novel_run(run_id, authenticated.claims().user_id().to_string()) + .get_visual_novel_run(run_id, principal.subject().to_string()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) @@ -516,13 +518,13 @@ pub async fn stream_visual_novel_action( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = parse_json_payload(&request_context, payload)?; ensure_non_empty(&run_id, "runId")?; ensure_non_empty(&payload.client_event_id, "clientEventId")?; - let owner_user_id = authenticated.claims().user_id().to_string(); + let owner_user_id = principal.subject().to_string(); let run = state .spacetime_client() .get_visual_novel_run(run_id.clone(), owner_user_id.clone()) @@ -569,12 +571,12 @@ pub async fn list_visual_novel_history( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&run_id, "runId")?; let history = state .spacetime_client() - .list_visual_novel_runtime_history(run_id, authenticated.claims().user_id().to_string()) + .list_visual_novel_runtime_history(run_id, principal.subject().to_string()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) @@ -595,13 +597,13 @@ pub async fn regenerate_visual_novel_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; ensure_non_empty(&run_id, "runId")?; ensure_non_empty(&payload.history_entry_id, "historyEntryId")?; - let owner_user_id = authenticated.claims().user_id().to_string(); + let owner_user_id = principal.subject().to_string(); let run = state .spacetime_client() .get_visual_novel_run(run_id.clone(), owner_user_id.clone()) diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs new file mode 100644 index 00000000..0169cd2a --- /dev/null +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -0,0 +1,463 @@ +use std::{ + fmt, + path::{Path, PathBuf}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use spacetime_client::{SpacetimeClient, SpacetimeClientError}; +use tokio::{ + fs::{self, File, OpenOptions}, + io::{AsyncReadExt, AsyncWriteExt}, + sync::{Mutex, Notify}, + time::sleep, +}; +use tracing::{debug, warn}; + +use crate::config::AppConfig; + +const PENDING_FILE_PREFIX: &str = "refund-"; +const CORRUPT_FILE_PREFIX: &str = "corrupt-"; +const TEMP_FILE_PREFIX: &str = "tmp-"; +const OUTBOX_FILE_EXTENSION: &str = ".json"; + +#[derive(Clone)] +pub struct WalletRefundOutbox { + dir: PathBuf, + batch_size: usize, + flush_interval: Duration, + max_bytes: u64, + spacetime_client: SpacetimeClient, + enqueue_lock: Arc>, + flush_notify: Arc, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct WalletRefundOutboxRecord { + pub owner_user_id: String, + pub amount: u64, + pub ledger_id: String, + pub created_at_micros: i64, + pub asset_kind: String, + pub asset_id: String, +} + +#[derive(Debug)] +pub enum WalletRefundOutboxEnqueueOutcome { + Enqueued, + Dropped { reason: &'static str }, +} + +#[derive(Debug)] +pub enum WalletRefundOutboxError { + Io(std::io::Error), + Json(serde_json::Error), + Spacetime(SpacetimeClientError), +} + +impl WalletRefundOutbox { + pub fn from_config(config: &AppConfig, spacetime_client: SpacetimeClient) -> Option> { + if !config.wallet_refund_outbox_enabled { + return None; + } + + Some(Arc::new(Self { + dir: config.wallet_refund_outbox_dir.clone(), + batch_size: config.wallet_refund_outbox_batch_size.max(1), + flush_interval: config.wallet_refund_outbox_flush_interval, + max_bytes: config.wallet_refund_outbox_max_bytes, + spacetime_client, + enqueue_lock: Arc::new(Mutex::new(())), + flush_notify: Arc::new(Notify::new()), + })) + } + + pub async fn enqueue( + &self, + record: WalletRefundOutboxRecord, + ) -> Result { + let _guard = self.enqueue_lock.lock().await; + fs::create_dir_all(&self.dir).await?; + + let pending_path = self.pending_path_for_ledger(&record.ledger_id); + if fs::metadata(&pending_path).await.is_ok() { + self.flush_notify.notify_one(); + return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); + } + + let bytes = serde_json::to_vec(&record)?; + let line_bytes = bytes.len().min(u64::MAX as usize) as u64; + let current_bytes = directory_size_if_exists(&self.dir).unwrap_or(0); + if current_bytes.saturating_add(line_bytes) > self.max_bytes { + return Ok(WalletRefundOutboxEnqueueOutcome::Dropped { + reason: "max_bytes", + }); + } + + let temp_path = self.temp_path(); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .await?; + file.write_all(&bytes).await?; + file.flush().await?; + file.sync_data().await?; + drop(file); + if fs::metadata(&pending_path).await.is_ok() { + let _ = fs::remove_file(&temp_path).await; + self.flush_notify.notify_one(); + return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); + } + fs::rename(&temp_path, &pending_path).await?; + sync_directory_metadata(&self.dir).await?; + self.flush_notify.notify_one(); + Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) + } + + pub fn spawn_worker(self: Arc) { + tokio::spawn(async move { + loop { + tokio::select! { + _ = sleep(self.flush_interval) => { + if let Err(error) = self.flush_pending_files_once().await { + warn!(error = %error, "wallet refund outbox 重放退款失败,将保留文件等待重试"); + } + } + _ = self.flush_notify.notified() => { + if let Err(error) = self.flush_pending_files_once().await { + warn!(error = %error, "wallet refund outbox 主动重放退款失败,将保留文件等待重试"); + } + } + } + } + }); + } + + pub async fn flush_for_shutdown(&self) -> Result<(), WalletRefundOutboxError> { + self.flush_pending_files_once().await + } + + async fn flush_pending_files_once(&self) -> Result<(), WalletRefundOutboxError> { + fs::create_dir_all(&self.dir).await?; + let pending_files = self.list_pending_files().await?; + for path in pending_files.into_iter().take(self.batch_size) { + let record = match read_refund_record(&path).await { + Ok(record) => record, + Err(error) if error.is_data_corruption() => { + let corrupt_path = self.corrupt_path_for(&path); + fs::rename(&path, &corrupt_path).await?; + sync_directory_metadata(&self.dir).await?; + warn!( + error = %error, + source = %path.display(), + target = %corrupt_path.display(), + "wallet refund outbox 文件无法解析,已隔离" + ); + continue; + } + Err(error) => return Err(error), + }; + + match self + .spacetime_client + .refund_profile_wallet_points( + record.owner_user_id.clone(), + record.amount, + record.ledger_id.clone(), + record.created_at_micros, + ) + .await + { + Ok(_) => { + match fs::remove_file(&path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + sync_directory_metadata(&self.dir).await?; + debug!( + ledger_id = %record.ledger_id, + owner_user_id = %record.owner_user_id, + asset_kind = %record.asset_kind, + asset_id = %record.asset_id, + path = %path.display(), + "wallet refund outbox 退款已重放并删除文件" + ); + } + Err(error) => return Err(WalletRefundOutboxError::Spacetime(error)), + } + } + Ok(()) + } + + async fn list_pending_files(&self) -> Result, WalletRefundOutboxError> { + let mut entries = fs::read_dir(&self.dir).await?; + let mut files = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name.starts_with(PENDING_FILE_PREFIX) && name.ends_with(OUTBOX_FILE_EXTENSION) { + files.push(path); + } + } + files.sort(); + Ok(files) + } + + fn pending_path_for_ledger(&self, ledger_id: &str) -> PathBuf { + self.dir.join(format!( + "{PENDING_FILE_PREFIX}{}{OUTBOX_FILE_EXTENSION}", + ledger_id_hash(ledger_id) + )) + } + + fn temp_path(&self) -> PathBuf { + self.dir.join(format!( + "{TEMP_FILE_PREFIX}{}-{uuid}{OUTBOX_FILE_EXTENSION}", + current_unix_micros(), + uuid = uuid::Uuid::new_v4() + )) + } + + fn corrupt_path_for(&self, path: &Path) -> PathBuf { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("unknown.json"); + self.dir.join(format!( + "{CORRUPT_FILE_PREFIX}{}-{uuid}-{name}", + current_unix_micros(), + uuid = uuid::Uuid::new_v4() + )) + } +} + +impl fmt::Debug for WalletRefundOutbox { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WalletRefundOutbox") + .field("dir", &self.dir) + .field("batch_size", &self.batch_size) + .field("flush_interval", &self.flush_interval) + .field("max_bytes", &self.max_bytes) + .finish() + } +} + +impl fmt::Display for WalletRefundOutboxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "{error}"), + Self::Json(error) => write!(f, "{error}"), + Self::Spacetime(error) => write!(f, "{error}"), + } + } +} + +impl From for WalletRefundOutboxError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for WalletRefundOutboxError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +impl WalletRefundOutboxError { + fn is_data_corruption(&self) -> bool { + matches!(self, Self::Json(_)) + } +} + +async fn read_refund_record( + path: &Path, +) -> Result { + let mut file = File::open(path).await?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).await?; + Ok(serde_json::from_slice::(&bytes)?) +} + +fn directory_size_if_exists(path: &Path) -> Result { + if !path.is_dir() { + return Ok(0); + } + + let mut total = 0u64; + for entry in std::fs::read_dir(path)? { + let entry = entry?; + if !is_pending_outbox_file_name(&entry.file_name()) { + continue; + } + let metadata = entry.metadata()?; + if metadata.is_file() { + total = total.saturating_add(metadata.len()); + } + } + Ok(total) +} + +fn current_unix_micros() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros() +} + +fn ledger_id_hash(ledger_id: &str) -> String { + hex::encode(Sha256::digest(ledger_id.as_bytes())) +} + +fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|value| { + value.starts_with(PENDING_FILE_PREFIX) && value.ends_with(OUTBOX_FILE_EXTENSION) + }) +} + +async fn sync_directory_metadata(path: &Path) -> Result<(), WalletRefundOutboxError> { + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || { + let dir = std::fs::File::open(path)?; + dir.sync_all() + }) + .await + .map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error.to_string()))??; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_record(ledger_id: &str) -> WalletRefundOutboxRecord { + WalletRefundOutboxRecord { + owner_user_id: "user-1".to_string(), + amount: 2, + ledger_id: ledger_id.to_string(), + created_at_micros: 1_713_680_000_000_000, + asset_kind: "puzzle_initial_image".to_string(), + asset_id: "asset-1".to_string(), + } + } + + fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "genarrative-wallet-refund-outbox-{name}-{}", + current_unix_micros() + )); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + fn test_outbox(dir: PathBuf, max_bytes: u64) -> Arc { + let config = AppConfig { + wallet_refund_outbox_dir: dir, + wallet_refund_outbox_batch_size: 500, + wallet_refund_outbox_flush_interval: Duration::from_secs(60), + wallet_refund_outbox_max_bytes: max_bytes, + ..AppConfig::default() + }; + WalletRefundOutbox::from_config( + &config, + SpacetimeClient::new(spacetime_client::SpacetimeClientConfig { + server_url: "http://127.0.0.1:1".to_string(), + database: "missing".to_string(), + token: None, + pool_size: 1, + procedure_timeout: Duration::from_millis(10), + }), + ) + .expect("outbox should be enabled") + } + + #[tokio::test] + async fn enqueue_is_idempotent_per_ledger_id() { + let dir = test_dir("idempotent"); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + + outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + + let pending_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| is_pending_outbox_file_name(&entry.file_name())) + .count(); + assert_eq!(pending_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn enqueue_drops_when_outbox_exceeds_max_bytes() { + let dir = test_dir("max-bytes"); + let outbox = test_outbox(dir.clone(), 1); + + let outcome = outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + + assert!(matches!( + outcome, + WalletRefundOutboxEnqueueOutcome::Dropped { + reason: "max_bytes" + } + )); + assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn flush_quarantines_corrupt_file() { + let dir = test_dir("corrupt"); + std::fs::create_dir_all(&dir).unwrap(); + let pending_path = dir.join(format!("{PENDING_FILE_PREFIX}bad{OUTBOX_FILE_EXTENSION}")); + std::fs::write(&pending_path, b"{not-json}").unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + + outbox.flush_pending_files_once().await.unwrap(); + + assert!(!pending_path.exists()); + let corrupt_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX)) + }) + .count(); + assert_eq!(corrupt_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn shutdown_flush_keeps_file_when_spacetime_is_unavailable() { + let dir = test_dir("shutdown"); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + + outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + let result = outbox.flush_for_shutdown().await; + + assert!( + matches!(result, Err(WalletRefundOutboxError::Spacetime(_))), + "missing test SpacetimeDB should keep refund file for retry" + ); + let pending_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| is_pending_outbox_file_name(&entry.file_name())) + .count(); + assert_eq!(pending_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index eca056e2..ee1061ba 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -20,6 +20,14 @@ pub struct PuzzleAgentSessionProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleBackgroundCompileTaskProcedureResult { + pub ok: bool, + pub claimed: bool, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorksProcedureResult { diff --git a/server-rs/crates/module-puzzle/src/commands.rs b/server-rs/crates/module-puzzle/src/commands.rs index 5b27473a..e38c60e4 100644 --- a/server-rs/crates/module-puzzle/src/commands.rs +++ b/server-rs/crates/module-puzzle/src/commands.rs @@ -71,6 +71,25 @@ pub struct PuzzleDraftCompileInput { pub external_generation_lease_token: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleBackgroundCompileTaskClaimInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleBackgroundCompileTaskReleaseInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleDraftCompileFailureInput { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 09e4f65c..22465f95 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -54,7 +54,8 @@ pub use mapper::{ PublicWorkGalleryEntryRecord, PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, - PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, + PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput, + PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleClearActionRequest, PuzzleClearActionResponse, PuzzleClearActionType, PuzzleClearBoardCell, PuzzleClearBoardSnapshot, PuzzleClearCardAsset, PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset, PuzzleClearNextLevelRequest, @@ -353,7 +354,7 @@ type ProcedureResultSender = type ReducerResultSender = Arc>>>>; struct SpacetimeConnectionPool { - slots: Vec>, + slots: Vec, permits: Arc, } @@ -376,8 +377,10 @@ impl SpacetimeStageError { } struct PooledConnectionSlot { - connection: Option, - in_use: bool, + // 槽位占用标记独立成原子量:抢占/复位不依赖锁,租约 Drop 兜底可以同步完成。 + in_use: AtomicBool, + // in_use=true 的持有者独占本槽连接,正常情况下锁上不会有竞争。 + connection: tokio::sync::Mutex>, } struct PooledConnection { @@ -390,9 +393,28 @@ struct PooledConnection { struct PooledConnectionLease { slot_index: usize, connection: Option, + pool: Arc, _permit: OwnedSemaphorePermit, } +impl Drop for PooledConnectionLease { + // 租约 Drop 兜底:请求 future 被取消(如客户端断开导致 handler 被丢弃)时, + // 也必须归还连接并复位槽位,否则槽位会永久停留在 in_use 状态、连接池逐渐耗尽。 + fn drop(&mut self) { + let slot = &self.pool.slots[self.slot_index]; + if let Some(connection) = self.connection.take() { + if !connection.is_broken() { + if let Ok(mut slot_connection) = slot.connection.try_lock() { + *slot_connection = Some(connection); + } + // try_lock 理论上不会失败(in_use 持有者独占);万一失败只丢弃连接,不丢槽位。 + } + } + slot.in_use.store(false, Ordering::Release); + // _permit 随 Drop 自动归还信号量。 + } +} + impl SpacetimeClient { pub fn new(config: SpacetimeClientConfig) -> Self { let pool_size = config.pool_size.max(1) as usize; @@ -405,11 +427,9 @@ impl SpacetimeClient { ..config }; let slots = (0..pool_size) - .map(|_| { - tokio::sync::Mutex::new(PooledConnectionSlot { - connection: None, - in_use: false, - }) + .map(|_| PooledConnectionSlot { + in_use: AtomicBool::new(false), + connection: tokio::sync::Mutex::new(None), }) .collect::>(); let pool = Arc::new(SpacetimeConnectionPool { @@ -683,42 +703,49 @@ impl SpacetimeClient { ) })?; - loop { - for (slot_index, slot) in self.pool.slots.iter().enumerate() { - if let Ok(mut slot_guard) = slot.try_lock() { - if slot_guard.in_use { - continue; - } - let reusable_connection = slot_guard - .connection - .take() - .filter(|connection| !connection.is_broken()); - slot_guard.in_use = true; - drop(slot_guard); + // 持有 permit 即保证最多 pool_size 个并发持有者,必然能抢到一个空闲槽位; + // CAS 抢占后立即构造租约,后续任何失败/取消都由租约 Drop 兜底复位槽位。 + let slot_index = self + .pool + .slots + .iter() + .position(|slot| { + slot.in_use + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + }) + .ok_or_else(|| { + SpacetimeStageError::new( + SpacetimeClientStage::PoolAcquire, + SpacetimeClientError::Runtime( + "SpacetimeDB 连接池 permit 与槽位状态不一致".to_string(), + ), + ) + })?; - let connection = if let Some(connection) = reusable_connection { - connection - } else { - match self.build_pooled_connection(operation_timeout).await { - Ok(connection) => connection, - Err(error) => { - let mut slot_guard = self.pool.slots[slot_index].lock().await; - slot_guard.in_use = false; - return Err(error); - } - } - }; + let mut lease = PooledConnectionLease { + slot_index, + connection: None, + pool: self.pool.clone(), + _permit: permit, + }; - return Ok(PooledConnectionLease { - slot_index, - connection: Some(connection), - _permit: permit, - }); - } - } + let reusable_connection = self.pool.slots[slot_index] + .connection + .lock() + .await + .take() + .filter(|connection| !connection.is_broken()); - tokio::task::yield_now().await; - } + let connection = if let Some(connection) = reusable_connection { + connection + } else { + // 建连失败时直接返回错误,槽位与 permit 由 lease Drop 自动归还。 + self.build_pooled_connection(operation_timeout).await? + }; + + lease.connection = Some(connection); + Ok(lease) } async fn build_pooled_connection( @@ -916,18 +943,10 @@ impl SpacetimeClient { Ok(subscription) } - async fn release_connection(&self, mut lease: PooledConnectionLease) { - let mut slot_guard = self.pool.slots[lease.slot_index].lock().await; - slot_guard.in_use = false; - let Some(connection) = lease.connection.take() else { - slot_guard.connection = None; - return; - }; - if connection.is_broken() { - slot_guard.connection = None; - } else { - slot_guard.connection = Some(connection); - } + async fn release_connection(&self, lease: PooledConnectionLease) { + // 显式归还与“请求被取消”的隐式归还共用同一套租约 Drop 兜底逻辑, + // 保证任何路径下槽位与 permit 都会复位,连接池不会被慢慢泄漏占满。 + drop(lease); } // 超时后必须统一归还租约;若连接已先一步断开则回传断线,否则标记坏连接并回传超时。 @@ -1132,4 +1151,78 @@ mod tests { SpacetimeClientError::Runtime(_) )); } + + fn test_client(pool_size: u32, procedure_timeout: Duration) -> SpacetimeClient { + SpacetimeClient::new(SpacetimeClientConfig { + // 指向本机不可达端口:测试只验证连接池行为,不需要真实 SpacetimeDB。 + server_url: "http://127.0.0.1:9".to_string(), + database: "pool-test".to_string(), + token: None, + pool_size, + procedure_timeout, + }) + } + + /// 复现线上故障机制:修复前请求 future 被取消时租约不会归还,槽位永久停留在 in_use, + /// 后续 acquire 拿着 permit 空转挂死。修复后租约 Drop 必须同时复位槽位与 permit。 + #[tokio::test] + async fn dropped_lease_releases_slot_and_permit() { + let client = test_client(1, Duration::from_millis(200)); + let permit = client + .pool + .permits + .clone() + .acquire_owned() + .await + .expect("permit should acquire"); + client.pool.slots[0].in_use.store(true, Ordering::SeqCst); + assert_eq!(client.pool.permits.available_permits(), 0); + + // 模拟请求被取消:租约未经过 release_connection 直接被 Drop。 + let lease = PooledConnectionLease { + slot_index: 0, + connection: None, + pool: client.pool.clone(), + _permit: permit, + }; + drop(lease); + + assert!( + !client.pool.slots[0].in_use.load(Ordering::SeqCst), + "租约 Drop 后槽位必须复位,否则连接池会被泄漏占满" + ); + assert_eq!( + client.pool.permits.available_permits(), + 1, + "租约 Drop 后 permit 必须归还" + ); + } + + /// 池内 permit 全部被占用(持续在途请求)时,acquire 必须在超时窗口内返回 + /// pool_acquire 超时,而不是无限等待。 + #[tokio::test] + async fn acquire_times_out_at_pool_acquire_when_pool_is_busy() { + let client = test_client(1, Duration::from_millis(200)); + let _held_permit = client + .pool + .permits + .clone() + .acquire_owned() + .await + .expect("permit should acquire"); + + let result = tokio::time::timeout( + Duration::from_secs(5), + client.acquire_connection_with_timeout(Duration::from_millis(200)), + ) + .await + .expect("acquire 必须在超时窗口内返回,而不是空转挂死"); + + let error = match result { + Ok(_) => panic!("池占满时应返回 pool_acquire 超时"), + Err(error) => error, + }; + assert_eq!(error.stage, SpacetimeClientStage::PoolAcquire); + assert!(matches!(error.error, SpacetimeClientError::Timeout)); + } } diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index d7cc2db6..c64108f6 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -107,7 +107,8 @@ pub use self::puzzle::{ PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, - PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, + PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput, + PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, @@ -208,10 +209,10 @@ pub(crate) use self::public_work::{ map_public_work_gallery_entry, map_public_work_gallery_entry_to_detail_entry, }; pub(crate) use self::puzzle::{ - map_puzzle_agent_session_procedure_result, map_puzzle_gallery_card_view_row, - map_puzzle_run_procedure_result, map_puzzle_work_procedure_result, - map_puzzle_works_procedure_result, map_runtime_profile_wallet_ledger_source_type_back, - parse_puzzle_agent_stage_record, + map_puzzle_agent_session_procedure_result, map_puzzle_background_compile_task_procedure_result, + map_puzzle_gallery_card_view_row, map_puzzle_run_procedure_result, + map_puzzle_work_procedure_result, map_puzzle_works_procedure_result, + map_runtime_profile_wallet_ledger_source_type_back, parse_puzzle_agent_stage_record, }; pub(crate) use self::puzzle_clear::{ map_puzzle_clear_agent_session_procedure_result, map_puzzle_clear_gallery_card_view_row, diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index df332945..2ce8b6a9 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -13,6 +13,16 @@ pub(crate) fn map_puzzle_agent_session_procedure_result( Ok(map_puzzle_agent_session_snapshot(session)) } +pub(crate) fn map_puzzle_background_compile_task_procedure_result( + result: PuzzleBackgroundCompileTaskProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result.claimed) +} + pub(crate) fn map_puzzle_work_procedure_result( result: PuzzleWorkProcedureResult, ) -> Result { @@ -614,6 +624,23 @@ pub struct PuzzleFormDraftSaveRecordInput { pub saved_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleBackgroundCompileTaskClaimRecordInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleBackgroundCompileTaskReleaseRecordInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAgentMessageSubmitRecordInput { pub session_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 9efae740..c3ffbcab 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -205,6 +205,7 @@ pub mod chapter_progression_type; pub mod checkpoint_wooden_fish_run_procedure; pub mod claim_external_generation_jobs_and_return_procedure; pub mod claim_profile_task_reward_and_return_procedure; +pub mod claim_puzzle_background_compile_task_procedure; pub mod claim_puzzle_work_point_incentive_procedure; pub mod clear_database_migration_import_chunks_procedure; pub mod clear_platform_browse_history_and_return_procedure; @@ -642,6 +643,11 @@ pub mod puzzle_anchor_item_type; pub mod puzzle_anchor_pack_type; pub mod puzzle_anchor_status_type; pub mod puzzle_audio_asset_type; +pub mod puzzle_background_compile_task_claim_input_type; +pub mod puzzle_background_compile_task_procedure_result_type; +pub mod puzzle_background_compile_task_release_input_type; +pub mod puzzle_background_compile_task_row_type; +pub mod puzzle_background_compile_task_table; pub mod puzzle_board_snapshot_type; pub mod puzzle_cell_position_type; pub mod puzzle_clear_agent_session_create_input_type; @@ -781,6 +787,7 @@ pub mod redeem_profile_reward_code_procedure; pub mod refresh_session_table; pub mod refresh_session_type; pub mod refund_profile_wallet_points_and_return_procedure; +pub mod release_puzzle_background_compile_task_procedure; pub mod remix_big_fish_work_procedure; pub mod remix_custom_world_profile_procedure; pub mod remix_puzzle_work_procedure; @@ -1329,6 +1336,7 @@ pub use chapter_progression_type::ChapterProgression; pub use checkpoint_wooden_fish_run_procedure::checkpoint_wooden_fish_run; pub use claim_external_generation_jobs_and_return_procedure::claim_external_generation_jobs_and_return; pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return; +pub use claim_puzzle_background_compile_task_procedure::claim_puzzle_background_compile_task; pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks; pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return; @@ -1766,6 +1774,11 @@ pub use puzzle_anchor_item_type::PuzzleAnchorItem; pub use puzzle_anchor_pack_type::PuzzleAnchorPack; pub use puzzle_anchor_status_type::PuzzleAnchorStatus; pub use puzzle_audio_asset_type::PuzzleAudioAsset; +pub use puzzle_background_compile_task_claim_input_type::PuzzleBackgroundCompileTaskClaimInput; +pub use puzzle_background_compile_task_procedure_result_type::PuzzleBackgroundCompileTaskProcedureResult; +pub use puzzle_background_compile_task_release_input_type::PuzzleBackgroundCompileTaskReleaseInput; +pub use puzzle_background_compile_task_row_type::PuzzleBackgroundCompileTaskRow; +pub use puzzle_background_compile_task_table::*; pub use puzzle_board_snapshot_type::PuzzleBoardSnapshot; pub use puzzle_cell_position_type::PuzzleCellPosition; pub use puzzle_clear_agent_session_create_input_type::PuzzleClearAgentSessionCreateInput; @@ -1905,6 +1918,7 @@ pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; pub use refresh_session_table::*; pub use refresh_session_type::RefreshSession; pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; +pub use release_puzzle_background_compile_task_procedure::release_puzzle_background_compile_task; pub use remix_big_fish_work_procedure::remix_big_fish_work; pub use remix_custom_world_profile_procedure::remix_custom_world_profile; pub use remix_puzzle_work_procedure::remix_puzzle_work; @@ -2602,6 +2616,7 @@ pub struct DbUpdate { public_work_play_daily_stat: __sdk::TableUpdate, puzzle_agent_message: __sdk::TableUpdate, puzzle_agent_session: __sdk::TableUpdate, + puzzle_background_compile_task: __sdk::TableUpdate, puzzle_clear_agent_session: __sdk::TableUpdate, puzzle_clear_event: __sdk::TableUpdate, puzzle_clear_gallery_card_view: __sdk::TableUpdate, @@ -2890,6 +2905,11 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "puzzle_agent_session" => db_update.puzzle_agent_session.append( puzzle_agent_session_table::parse_table_update(table_update)?, ), + "puzzle_background_compile_task" => { + db_update.puzzle_background_compile_task.append( + puzzle_background_compile_task_table::parse_table_update(table_update)?, + ) + } "puzzle_clear_agent_session" => db_update.puzzle_clear_agent_session.append( puzzle_clear_agent_session_table::parse_table_update(table_update)?, ), @@ -3415,6 +3435,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.puzzle_agent_session, ) .with_updates_by_pk(|row| &row.session_id); + diff.puzzle_background_compile_task = cache + .apply_diff_to_table::( + "puzzle_background_compile_task", + &self.puzzle_background_compile_task, + ) + .with_updates_by_pk(|row| &row.task_id); diff.puzzle_clear_agent_session = cache .apply_diff_to_table::( "puzzle_clear_agent_session", @@ -3873,6 +3899,9 @@ impl __sdk::DbUpdate for DbUpdate { "puzzle_agent_session" => db_update .puzzle_agent_session .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "puzzle_background_compile_task" => db_update + .puzzle_background_compile_task + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "puzzle_clear_agent_session" => db_update .puzzle_clear_agent_session .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4240,6 +4269,9 @@ impl __sdk::DbUpdate for DbUpdate { "puzzle_agent_session" => db_update .puzzle_agent_session .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "puzzle_background_compile_task" => db_update + .puzzle_background_compile_task + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "puzzle_clear_agent_session" => db_update .puzzle_clear_agent_session .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4459,6 +4491,7 @@ pub struct AppliedDiff<'r> { public_work_play_daily_stat: __sdk::TableAppliedDiff<'r, PublicWorkPlayDailyStat>, puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>, puzzle_agent_session: __sdk::TableAppliedDiff<'r, PuzzleAgentSessionRow>, + puzzle_background_compile_task: __sdk::TableAppliedDiff<'r, PuzzleBackgroundCompileTaskRow>, puzzle_clear_agent_session: __sdk::TableAppliedDiff<'r, PuzzleClearAgentSessionRow>, puzzle_clear_event: __sdk::TableAppliedDiff<'r, PuzzleClearEventRow>, puzzle_clear_gallery_card_view: __sdk::TableAppliedDiff<'r, PuzzleClearGalleryCardViewRow>, @@ -4883,6 +4916,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.puzzle_agent_session, event, ); + callbacks.invoke_table_row_callbacks::( + "puzzle_background_compile_task", + &self.puzzle_background_compile_task, + event, + ); callbacks.invoke_table_row_callbacks::( "puzzle_clear_agent_session", &self.puzzle_clear_agent_session, @@ -5821,6 +5859,7 @@ impl __sdk::SpacetimeModule for RemoteModule { public_work_play_daily_stat_table::register_table(client_cache); puzzle_agent_message_table::register_table(client_cache); puzzle_agent_session_table::register_table(client_cache); + puzzle_background_compile_task_table::register_table(client_cache); puzzle_clear_agent_session_table::register_table(client_cache); puzzle_clear_event_table::register_table(client_cache); puzzle_clear_gallery_card_view_table::register_table(client_cache); @@ -5941,6 +5980,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "public_work_play_daily_stat", "puzzle_agent_message", "puzzle_agent_session", + "puzzle_background_compile_task", "puzzle_clear_agent_session", "puzzle_clear_event", "puzzle_clear_gallery_card_view", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_background_compile_task_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_background_compile_task_procedure.rs new file mode 100644 index 00000000..45b9de6d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_background_compile_task_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::puzzle_background_compile_task_claim_input_type::PuzzleBackgroundCompileTaskClaimInput; +use super::puzzle_background_compile_task_procedure_result_type::PuzzleBackgroundCompileTaskProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClaimPuzzleBackgroundCompileTaskArgs { + pub input: PuzzleBackgroundCompileTaskClaimInput, +} + +impl __sdk::InModule for ClaimPuzzleBackgroundCompileTaskArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `claim_puzzle_background_compile_task`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait claim_puzzle_background_compile_task { + fn claim_puzzle_background_compile_task(&self, input: PuzzleBackgroundCompileTaskClaimInput) { + self.claim_puzzle_background_compile_task_then(input, |_, _| {}); + } + + fn claim_puzzle_background_compile_task_then( + &self, + input: PuzzleBackgroundCompileTaskClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl claim_puzzle_background_compile_task for super::RemoteProcedures { + fn claim_puzzle_background_compile_task_then( + &self, + input: PuzzleBackgroundCompileTaskClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleBackgroundCompileTaskProcedureResult>( + "claim_puzzle_background_compile_task", + ClaimPuzzleBackgroundCompileTaskArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_claim_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_claim_input_type.rs new file mode 100644 index 00000000..f721ad91 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_claim_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleBackgroundCompileTaskClaimInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + +impl __sdk::InModule for PuzzleBackgroundCompileTaskClaimInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_procedure_result_type.rs new file mode 100644 index 00000000..8c85082e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_procedure_result_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 PuzzleBackgroundCompileTaskProcedureResult { + pub ok: bool, + pub claimed: bool, + pub error_message: Option, +} + +impl __sdk::InModule for PuzzleBackgroundCompileTaskProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_release_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_release_input_type.rs new file mode 100644 index 00000000..f4d983fa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_release_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleBackgroundCompileTaskReleaseInput { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for PuzzleBackgroundCompileTaskReleaseInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_row_type.rs new file mode 100644 index 00000000..49e1bf07 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_row_type.rs @@ -0,0 +1,66 @@ +// 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 PuzzleBackgroundCompileTaskRow { + pub task_id: String, + pub claim_id: String, + pub session_id: String, + pub owner_user_id: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PuzzleBackgroundCompileTaskRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PuzzleBackgroundCompileTaskRow`. +/// +/// Provides typed access to columns for query building. +pub struct PuzzleBackgroundCompileTaskRowCols { + pub task_id: __sdk::__query_builder::Col, + pub claim_id: __sdk::__query_builder::Col, + pub session_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PuzzleBackgroundCompileTaskRow { + type Cols = PuzzleBackgroundCompileTaskRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + PuzzleBackgroundCompileTaskRowCols { + task_id: __sdk::__query_builder::Col::new(table_name, "task_id"), + claim_id: __sdk::__query_builder::Col::new(table_name, "claim_id"), + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PuzzleBackgroundCompileTaskRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PuzzleBackgroundCompileTaskRowIxCols { + pub session_id: __sdk::__query_builder::IxCol, + pub task_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PuzzleBackgroundCompileTaskRow { + type IxCols = PuzzleBackgroundCompileTaskRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PuzzleBackgroundCompileTaskRowIxCols { + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PuzzleBackgroundCompileTaskRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_table.rs new file mode 100644 index 00000000..227085b5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_background_compile_task_table.rs @@ -0,0 +1,169 @@ +// 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::puzzle_background_compile_task_row_type::PuzzleBackgroundCompileTaskRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `puzzle_background_compile_task`. +/// +/// Obtain a handle from the [`PuzzleBackgroundCompileTaskTableAccess::puzzle_background_compile_task`] method on [`super::RemoteTables`], +/// like `ctx.db.puzzle_background_compile_task()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.puzzle_background_compile_task().on_insert(...)`. +pub struct PuzzleBackgroundCompileTaskTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `puzzle_background_compile_task`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PuzzleBackgroundCompileTaskTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PuzzleBackgroundCompileTaskTableHandle`], which mediates access to the table `puzzle_background_compile_task`. + fn puzzle_background_compile_task(&self) -> PuzzleBackgroundCompileTaskTableHandle<'_>; +} + +impl PuzzleBackgroundCompileTaskTableAccess for super::RemoteTables { + fn puzzle_background_compile_task(&self) -> PuzzleBackgroundCompileTaskTableHandle<'_> { + PuzzleBackgroundCompileTaskTableHandle { + imp: self + .imp + .get_table::("puzzle_background_compile_task"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PuzzleBackgroundCompileTaskInsertCallbackId(__sdk::CallbackId); +pub struct PuzzleBackgroundCompileTaskDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PuzzleBackgroundCompileTaskTableHandle<'ctx> { + type Row = PuzzleBackgroundCompileTaskRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PuzzleBackgroundCompileTaskInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleBackgroundCompileTaskInsertCallbackId { + PuzzleBackgroundCompileTaskInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PuzzleBackgroundCompileTaskInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PuzzleBackgroundCompileTaskDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleBackgroundCompileTaskDeleteCallbackId { + PuzzleBackgroundCompileTaskDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PuzzleBackgroundCompileTaskDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PuzzleBackgroundCompileTaskUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PuzzleBackgroundCompileTaskTableHandle<'ctx> { + type UpdateCallbackId = PuzzleBackgroundCompileTaskUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PuzzleBackgroundCompileTaskUpdateCallbackId { + PuzzleBackgroundCompileTaskUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PuzzleBackgroundCompileTaskUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `task_id` unique index on the table `puzzle_background_compile_task`, +/// which allows point queries on the field of the same name +/// via the [`PuzzleBackgroundCompileTaskTaskIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.puzzle_background_compile_task().task_id().find(...)`. +pub struct PuzzleBackgroundCompileTaskTaskIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PuzzleBackgroundCompileTaskTableHandle<'ctx> { + /// Get a handle on the `task_id` unique index on the table `puzzle_background_compile_task`. + pub fn task_id(&self) -> PuzzleBackgroundCompileTaskTaskIdUnique<'ctx> { + PuzzleBackgroundCompileTaskTaskIdUnique { + imp: self.imp.get_unique_constraint::("task_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PuzzleBackgroundCompileTaskTaskIdUnique<'ctx> { + /// Find the subscribed row whose `task_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::("puzzle_background_compile_task"); + _table.add_unique_constraint::("task_id", |row| &row.task_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 `PuzzleBackgroundCompileTaskRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait puzzle_background_compile_taskQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PuzzleBackgroundCompileTaskRow`. + fn puzzle_background_compile_task( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl puzzle_background_compile_taskQueryTableAccess for __sdk::QueryTableAccessor { + fn puzzle_background_compile_task( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("puzzle_background_compile_task") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/release_puzzle_background_compile_task_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/release_puzzle_background_compile_task_procedure.rs new file mode 100644 index 00000000..3b85afae --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/release_puzzle_background_compile_task_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::puzzle_background_compile_task_procedure_result_type::PuzzleBackgroundCompileTaskProcedureResult; +use super::puzzle_background_compile_task_release_input_type::PuzzleBackgroundCompileTaskReleaseInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ReleasePuzzleBackgroundCompileTaskArgs { + pub input: PuzzleBackgroundCompileTaskReleaseInput, +} + +impl __sdk::InModule for ReleasePuzzleBackgroundCompileTaskArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `release_puzzle_background_compile_task`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait release_puzzle_background_compile_task { + fn release_puzzle_background_compile_task( + &self, + input: PuzzleBackgroundCompileTaskReleaseInput, + ) { + self.release_puzzle_background_compile_task_then(input, |_, _| {}); + } + + fn release_puzzle_background_compile_task_then( + &self, + input: PuzzleBackgroundCompileTaskReleaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl release_puzzle_background_compile_task for super::RemoteProcedures { + fn release_puzzle_background_compile_task_then( + &self, + input: PuzzleBackgroundCompileTaskReleaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleBackgroundCompileTaskProcedureResult>( + "release_puzzle_background_compile_task", + ReleasePuzzleBackgroundCompileTaskArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 252fab96..080e1353 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -1,8 +1,10 @@ use super::*; use crate::mapper::*; +use crate::module_bindings::claim_puzzle_background_compile_task_procedure::claim_puzzle_background_compile_task; use crate::module_bindings::claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; use crate::module_bindings::delete_puzzle_work_procedure::delete_puzzle_work; use crate::module_bindings::record_puzzle_work_like_procedure::record_puzzle_work_like; +use crate::module_bindings::release_puzzle_background_compile_task_procedure::release_puzzle_background_compile_task; use crate::module_bindings::remix_puzzle_work_procedure::remix_puzzle_work; use crate::module_bindings::save_puzzle_ui_background_procedure::save_puzzle_ui_background; @@ -275,6 +277,67 @@ impl SpacetimeClient { .await } + pub async fn claim_puzzle_background_compile_task( + &self, + input: PuzzleBackgroundCompileTaskClaimRecordInput, + ) -> Result { + let procedure_input = PuzzleBackgroundCompileTaskClaimInput { + task_id: input.task_id, + claim_id: input.claim_id, + session_id: input.session_id, + owner_user_id: input.owner_user_id, + claimed_at_micros: input.claimed_at_micros, + }; + + self.call_after_connect( + "claim_puzzle_background_compile_task", + move |connection, sender| { + connection + .procedures() + .claim_puzzle_background_compile_task_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_background_compile_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn release_puzzle_background_compile_task( + &self, + input: PuzzleBackgroundCompileTaskReleaseRecordInput, + ) -> Result { + let procedure_input = PuzzleBackgroundCompileTaskReleaseInput { + task_id: input.task_id, + claim_id: input.claim_id, + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }; + + self.call_after_connect( + "release_puzzle_background_compile_task", + move |connection, sender| { + connection + .procedures() + .release_puzzle_background_compile_task_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_background_compile_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn save_puzzle_generated_images( &self, input: PuzzleGeneratedImagesSaveRecordInput, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index dd3af450..8734e09c 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -20,8 +20,8 @@ use crate::match3d::tables::{ match_3_d_work_profile, match3d_agent_message, match3d_agent_session, match3d_runtime_run, }; use crate::puzzle::{ - puzzle_agent_message, puzzle_agent_session, puzzle_event, puzzle_leaderboard_entry, - puzzle_runtime_run, puzzle_work_profile, + puzzle_agent_message, puzzle_agent_session, puzzle_background_compile_task, puzzle_event, + puzzle_leaderboard_entry, puzzle_runtime_run, puzzle_work_profile, }; use crate::puzzle_clear::tables::{ puzzle_clear_agent_session, puzzle_clear_event, puzzle_clear_runtime_run, @@ -230,6 +230,7 @@ macro_rules! migration_tables { asset_entity_binding, asset_event, puzzle_agent_session, + puzzle_background_compile_task, puzzle_agent_message, puzzle_work_profile, puzzle_event, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index ae2aa462..6fded8c1 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -10,19 +10,20 @@ use module_puzzle::{ PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, - PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput, - PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, - PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleLevelGenerationFailureInput, - PuzzlePublicationStatus, PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, - PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, - PuzzleRunProcedureResult, PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, - PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, - PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, - PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput, - PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, - PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, - apply_selected_candidate, build_form_draft_from_seed, build_result_preview, - compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, + PuzzleAgentStage, PuzzleAnchorPack, PuzzleBackgroundCompileTaskClaimInput, + PuzzleBackgroundCompileTaskProcedureResult, PuzzleBackgroundCompileTaskReleaseInput, + PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, + PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, + PuzzleLeaderboardSubmitInput, PuzzleLevelGenerationFailureInput, PuzzlePublicationStatus, + PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, + PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, + PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, + PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, + PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, + PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, + apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed, + build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, mark_failed_puzzle_result_draft_generation, normalize_puzzle_draft, normalize_puzzle_levels, normalize_theme_tags, publish_work_profile, replace_puzzle_level, select_next_profiles, selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score, @@ -40,6 +41,7 @@ use crate::auth::user_account; use crate::validate_external_generation_job_lease_for_tx; const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0; +const PUZZLE_BACKGROUND_COMPILE_TASK_LEASE_MICROS: i64 = 30 * 60 * 1_000_000; const WORK_VISIBLE_DEFAULT: bool = true; const PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE: &str = "puzzle"; const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft"; @@ -68,6 +70,22 @@ pub struct PuzzleAgentSessionRow { updated_at: Timestamp, } +/// 拼图首图后台编译活动任务表。 +/// 中文注释:该表只保存跨 api-server 实例互斥 claim,不表达最终生成结果。 +#[spacetimedb::table( + accessor = puzzle_background_compile_task, + index(accessor = by_puzzle_background_compile_task_session_id, btree(columns = [session_id])) +)] +pub struct PuzzleBackgroundCompileTaskRow { + #[primary_key] + task_id: String, + claim_id: String, + session_id: String, + owner_user_id: String, + created_at: Timestamp, + updated_at: Timestamp, +} + /// 拼图 Agent 消息真相表。 #[spacetimedb::table( accessor = puzzle_agent_message, @@ -413,6 +431,43 @@ pub fn mark_puzzle_level_generation_failed( } } +#[spacetimedb::procedure] +pub fn claim_puzzle_background_compile_task( + ctx: &mut ProcedureContext, + input: PuzzleBackgroundCompileTaskClaimInput, +) -> PuzzleBackgroundCompileTaskProcedureResult { + match ctx.try_with_tx(|tx| claim_puzzle_background_compile_task_tx(tx, input.clone())) { + Ok(claimed) => PuzzleBackgroundCompileTaskProcedureResult { + ok: true, + claimed, + error_message: None, + }, + Err(message) => PuzzleBackgroundCompileTaskProcedureResult { + ok: false, + claimed: false, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn release_puzzle_background_compile_task( + ctx: &mut ProcedureContext, + input: PuzzleBackgroundCompileTaskReleaseInput, +) -> PuzzleBackgroundCompileTaskProcedureResult { + match ctx.try_with_tx(|tx| release_puzzle_background_compile_task_tx(tx, input.clone())) { + Ok(released) => PuzzleBackgroundCompileTaskProcedureResult { + ok: true, + claimed: released, + error_message: None, + }, + Err(message) => PuzzleBackgroundCompileTaskProcedureResult { + ok: false, + claimed: false, + error_message: Some(message), + }, + } +} /// 保存拼图入口表单草稿。 /// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。 #[spacetimedb::procedure] @@ -1060,6 +1115,84 @@ fn compile_puzzle_agent_draft_tx( ) } +fn claim_puzzle_background_compile_task_tx( + ctx: &TxContext, + input: PuzzleBackgroundCompileTaskClaimInput, +) -> Result { + let task_id = normalize_required_puzzle_task_field(&input.task_id, "拼图后台任务 ID")?; + let claim_id = normalize_required_puzzle_task_field(&input.claim_id, "拼图后台任务 claim ID")?; + let session_id = normalize_required_puzzle_task_field(&input.session_id, "拼图 session ID")?; + let owner_user_id = normalize_required_puzzle_task_field(&input.owner_user_id, "拼图用户 ID")?; + let claimed_at = Timestamp::from_micros_since_unix_epoch(input.claimed_at_micros); + + get_owned_session_row(ctx, &session_id, &owner_user_id)?; + if let Some(existing) = ctx + .db + .puzzle_background_compile_task() + .task_id() + .find(&task_id) + { + if !is_stale_puzzle_background_compile_task(&existing, input.claimed_at_micros) { + return Ok(false); + } + ctx.db + .puzzle_background_compile_task() + .task_id() + .delete(&task_id); + } + + ctx.db + .puzzle_background_compile_task() + .insert(PuzzleBackgroundCompileTaskRow { + task_id, + claim_id, + session_id, + owner_user_id, + created_at: claimed_at, + updated_at: claimed_at, + }); + Ok(true) +} + +fn release_puzzle_background_compile_task_tx( + ctx: &TxContext, + input: PuzzleBackgroundCompileTaskReleaseInput, +) -> Result { + let task_id = normalize_required_puzzle_task_field(&input.task_id, "拼图后台任务 ID")?; + let claim_id = normalize_required_puzzle_task_field(&input.claim_id, "拼图后台任务 claim ID")?; + let session_id = normalize_required_puzzle_task_field(&input.session_id, "拼图 session ID")?; + let owner_user_id = normalize_required_puzzle_task_field(&input.owner_user_id, "拼图用户 ID")?; + + let Some(row) = ctx + .db + .puzzle_background_compile_task() + .task_id() + .find(&task_id) + else { + return Ok(false); + }; + if row.session_id != session_id || row.owner_user_id != owner_user_id { + return Err("无权释放该拼图后台任务".to_string()); + } + if row.claim_id != claim_id { + return Ok(false); + } + + ctx.db + .puzzle_background_compile_task() + .task_id() + .delete(&task_id); + Ok(true) +} + +fn is_stale_puzzle_background_compile_task( + row: &PuzzleBackgroundCompileTaskRow, + now_micros: i64, +) -> bool { + now_micros.saturating_sub(row.updated_at.to_micros_since_unix_epoch()) + >= PUZZLE_BACKGROUND_COMPILE_TASK_LEASE_MICROS +} + fn mark_puzzle_draft_generation_failed_tx( ctx: &TxContext, input: PuzzleDraftCompileFailureInput, @@ -3162,6 +3295,14 @@ fn get_owned_session_row( Ok(row) } +fn normalize_required_puzzle_task_field(value: &str, field_name: &str) -> Result { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(format!("{field_name} 不能为空")); + } + Ok(normalized.to_string()) +} + fn get_owned_run_row( ctx: &TxContext, run_id: &str, diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 8d691720..9ecc2beb 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -11,7 +11,7 @@ import { useState, } from 'react'; -import jumpHopRuntimeLevelLogo from '../../../media/logo.png'; +import jumpHopRuntimeLevelLogo from '../../../media/logo-runtime-hud.webp'; import type { JumpHopRuntimeRunSnapshotResponse, JumpHopTileFaceAsset, diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index 2ddef563..b713340a 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -15,7 +15,7 @@ import { useState, } from 'react'; -import match3DRuntimeLevelLogo from '../../../media/logo.png'; +import match3DRuntimeLevelLogo from '../../../media/logo-runtime-hud.webp'; import type { Match3DClickItemRequest, Match3DClickItemResult, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 67210122..aafab8dc 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -318,6 +318,7 @@ import { submitRpgProfileFeedback, } from '../../services/rpg-entry/rpgProfileClient'; import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest'; +import { type RuntimeGuestRequestOptions } from '../../services/runtimeGuestAuth'; import { squareHoleCreationClient } from '../../services/square-hole-creation'; import { dropSquareHoleShape, @@ -372,13 +373,16 @@ import { type CreationWorkShelfItem, isPersistedBarkBattleDraftGenerating, } from '../custom-world-home/creationWorkShelf'; -import { selectAdjacentPlatformRecommendEntry } from '../rpg-entry/rpgEntryPublicGalleryViewModel'; +import { + buildPlatformRecommendFeedEntries, + selectAdjacentPlatformRecommendEntry, +} from '../rpg-entry/rpgEntryPublicGalleryViewModel'; import { isBigFishGalleryEntry, isEdutainmentGalleryEntry, isJumpHopGalleryEntry, - isPuzzleGalleryEntry, isPuzzleClearGalleryEntry, + isPuzzleGalleryEntry, mapPuzzleClearWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, @@ -492,7 +496,6 @@ import { import { canExposePublicWork, EDUTAINMENT_HIDDEN_MESSAGE, - filterGeneralPublicWorks, } from './platformEdutainmentVisibility'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; @@ -523,7 +526,6 @@ import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; import { PlatformErrorDialog } from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel'; -import { buildPlatformRecommendedEntries } from './platformRecommendation'; import { buildMatch3DProfileFromSession, hasMatch3DRuntimeAsset, @@ -1610,6 +1612,8 @@ export function PlatformEntryFlowShellImpl({ useState(null); const [jumpHopError, setJumpHopError] = useState(null); const [isJumpHopBusy, setIsJumpHopBusy] = useState(false); + const [barkBattleRuntimeRequestOptions, setBarkBattleRuntimeRequestOptions] = + useState(null); const [puzzleClearSession, setPuzzleClearSession] = useState(null); const [puzzleClearRun, setPuzzleClearRun] = useState< @@ -1713,6 +1717,34 @@ export function PlatformEntryFlowShellImpl({ useState('puzzle-gallery-detail'); const [puzzleRuntimeAuthMode, setPuzzleRuntimeAuthMode] = useState('default'); + const buildRecommendRuntimeRequestOptions = useCallback( + async ( + input: { + kind?: RecommendRuntimeKind; + embedded?: boolean; + forcePublicRuntime?: boolean; + } = {}, + ) => { + const shouldUseRuntimeOptions = Boolean( + input.forcePublicRuntime || + input.embedded || + (input.kind && activeRecommendRuntimeKind === input.kind), + ); + + return shouldUseRuntimeOptions + ? buildRecommendRuntimeAuthOptions(authUi, true) + : {}; + }, + [activeRecommendRuntimeKind, authUi], + ); + const buildPuzzleRuntimeRequestOptions = useCallback( + () => + buildRecommendRuntimeRequestOptions({ + kind: 'puzzle', + forcePublicRuntime: puzzleRuntimeAuthMode === 'isolated', + }), + [buildRecommendRuntimeRequestOptions, puzzleRuntimeAuthMode], + ); const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false); const submittedPuzzleLeaderboardKeysRef = useRef(new Set()); const puzzleStartInFlightKeyRef = useRef(null); @@ -3046,10 +3078,10 @@ export function PlatformEntryFlowShellImpl({ } = publicGalleryFeeds; const recommendRuntimeEntries = useMemo( () => - buildPlatformRecommendedEntries({ - featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries), - latestEntries: filterGeneralPublicWorks(latestGalleryEntries), - }), + buildPlatformRecommendFeedEntries( + featuredGalleryEntries, + latestGalleryEntries, + ), [featuredGalleryEntries, latestGalleryEntries], ); @@ -7110,15 +7142,17 @@ export function PlatformEntryFlowShellImpl({ profileId: targetProfileId, mode: 'play' as const, }; - const runtimeGuestOptions = - options.embedded || workDetail.summary.publishStatus === 'draft' - ? await buildRecommendRuntimeAuthOptions(authUi, true) - : {}; + const runtimeRequestOptions = await buildRecommendRuntimeRequestOptions( + { + kind: 'visual-novel', + embedded: options.embedded, + }, + ); const { run } = options.embedded ? await startVisualNovelRun( targetProfileId, startRunPayload, - runtimeGuestOptions, + runtimeRequestOptions, ) : await startVisualNovelRun(targetProfileId, startRunPayload); setVisualNovelWork(workDetail); @@ -7144,7 +7178,7 @@ export function PlatformEntryFlowShellImpl({ } }, [ - authUi, + buildRecommendRuntimeRequestOptions, resolvePuzzleErrorMessage, setIsVisualNovelBusy, setSelectionStage, @@ -7166,14 +7200,14 @@ export function PlatformEntryFlowShellImpl({ setVisualNovelError(null); setIsVisualNovelBusy(true); try { - const runtimeGuestOptions = - activeRecommendRuntimeKind === 'visual-novel' - ? await buildRecommendRuntimeAuthOptions(authUi, true) - : {}; + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'visual-novel', + }); const nextRun = await streamVisualNovelRuntimeAction( visualNovelRun.runId, payload, - runtimeGuestOptions, + runtimeRequestOptions, ); setVisualNovelRun(nextRun); } catch (error) { @@ -7185,8 +7219,7 @@ export function PlatformEntryFlowShellImpl({ } }, [ - activeRecommendRuntimeKind, - authUi, + buildRecommendRuntimeRequestOptions, isVisualNovelBusy, resolvePuzzleErrorMessage, setIsVisualNovelBusy, @@ -7635,22 +7668,15 @@ export function PlatformEntryFlowShellImpl({ setJumpHopError(null); setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail'); try { - const runtimeGuestOptions = - options.embedded || shouldUseRecommendRuntimeGuestAuth(authUi) - ? await buildRecommendRuntimeAuthOptions(authUi, true) - : RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'jump-hop', + embedded: options.embedded, + forcePublicRuntime: shouldUseRecommendRuntimeGuestAuth(authUi), + }); setJumpHopRuntimeRequestOptions( - runtimeGuestOptions.runtimeGuestToken?.trim() - ? { - runtimeGuestToken: runtimeGuestOptions.runtimeGuestToken, - authImpact: runtimeGuestOptions.authImpact, - skipAuth: runtimeGuestOptions.skipAuth, - skipRefresh: runtimeGuestOptions.skipRefresh, - notifyAuthStateChange: - runtimeGuestOptions.notifyAuthStateChange, - clearAuthOnUnauthorized: - runtimeGuestOptions.clearAuthOnUnauthorized, - } + Object.keys(runtimeRequestOptions).length > 0 + ? runtimeRequestOptions : null, ); const [detail, runResponse] = await Promise.all([ @@ -7660,7 +7686,7 @@ export function PlatformEntryFlowShellImpl({ .getWorkDetail(normalizedProfileId) .catch(() => null), jumpHopClient.startRun(normalizedProfileId, { - ...runtimeGuestOptions, + ...runtimeRequestOptions, runtimeMode: 'published', }), ]); @@ -7687,7 +7713,7 @@ export function PlatformEntryFlowShellImpl({ setIsJumpHopBusy(false); } }, - [authUi, setSelectionStage], + [authUi, buildRecommendRuntimeRequestOptions, setSelectionStage], ); useEffect(() => { @@ -8128,15 +8154,16 @@ export function PlatformEntryFlowShellImpl({ setPuzzleClearError(null); setPuzzleClearRuntimeReturnStage(options.returnStage ?? 'work-detail'); try { - const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( - authUi, - options.embedded, - ); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'puzzle-clear', + embedded: options.embedded, + }); const [detail, runResponse] = await Promise.all([ puzzleClearClient .getRuntimeWorkDetail(normalizedProfileId) .catch(() => null), - puzzleClearClient.startRun(normalizedProfileId, runtimeGuestOptions), + puzzleClearClient.startRun(normalizedProfileId, runtimeRequestOptions), ]); if (detail?.item) { setPuzzleClearWork(detail.item); @@ -8161,7 +8188,7 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleClearBusy(false); } }, - [authUi, setSelectionStage], + [buildRecommendRuntimeRequestOptions, setSelectionStage], ); const retryPuzzleClearLevelRun = useCallback(async () => { @@ -8186,7 +8213,14 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleClearBusy(true); setPuzzleClearError(null); try { - const response = await puzzleClearClient.retryLevel(runId); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'puzzle-clear', + }); + const response = await puzzleClearClient.retryLevel( + runId, + runtimeRequestOptions, + ); setPuzzleClearRun(response.run); } catch (error) { setPuzzleClearError( @@ -8198,6 +8232,7 @@ export function PlatformEntryFlowShellImpl({ }, [ puzzleClearRun, puzzleClearWork, + buildRecommendRuntimeRequestOptions, setSelectionStage, startPuzzleClearTestRunFromProfile, ]); @@ -8223,7 +8258,14 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleClearBusy(true); setPuzzleClearError(null); try { - const response = await puzzleClearClient.advanceNextLevel(runId); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'puzzle-clear', + }); + const response = await puzzleClearClient.advanceNextLevel( + runId, + runtimeRequestOptions, + ); setPuzzleClearRun(response.run); } catch (error) { setPuzzleClearError( @@ -8232,7 +8274,12 @@ export function PlatformEntryFlowShellImpl({ } finally { setIsPuzzleClearBusy(false); } - }, [puzzleClearRun, puzzleClearWork, setSelectionStage]); + }, [ + puzzleClearRun, + puzzleClearWork, + buildRecommendRuntimeRequestOptions, + setSelectionStage, + ]); const markPuzzleClearLevelTimeUp = useCallback(async () => { const runId = puzzleClearRun?.runId; @@ -8246,14 +8293,21 @@ export function PlatformEntryFlowShellImpl({ } try { - const response = await puzzleClearClient.markTimeUp(runId); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'puzzle-clear', + }); + const response = await puzzleClearClient.markTimeUp( + runId, + runtimeRequestOptions, + ); setPuzzleClearRun(response.run); } catch (error) { setPuzzleClearError( resolveRpgCreationErrorMessage(error, '同步拼消消倒计时失败。'), ); } - }, [puzzleClearRun]); + }, [puzzleClearRun, buildRecommendRuntimeRequestOptions]); const swapPuzzleClearCardsInRun = useCallback( async (payload: { @@ -8272,7 +8326,15 @@ export function PlatformEntryFlowShellImpl({ return; } try { - const response = await puzzleClearClient.swapCards(runId, payload); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'puzzle-clear', + }); + const response = await puzzleClearClient.swapCards( + runId, + payload, + runtimeRequestOptions, + ); setPuzzleClearRun(response.run); } catch (error) { setPuzzleClearError( @@ -8280,7 +8342,7 @@ export function PlatformEntryFlowShellImpl({ ); } }, - [puzzleClearRun], + [puzzleClearRun, buildRecommendRuntimeRequestOptions], ); const compileWoodenFishSession = useCallback( @@ -8632,16 +8694,17 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishError(null); setWoodenFishRuntimeReturnStage(options.returnStage ?? 'work-detail'); try { - const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( - authUi, - options.embedded, - ); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'wooden-fish', + embedded: options.embedded, + }); const [detail, runResponse] = await Promise.all([ woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null), options.embedded ? woodenFishClient.startRun( normalizedProfileId, - runtimeGuestOptions, + runtimeRequestOptions, ) : woodenFishClient.startRun(normalizedProfileId), ]); @@ -8668,7 +8731,7 @@ export function PlatformEntryFlowShellImpl({ setIsWoodenFishBusy(false); } }, - [authUi, setSelectionStage], + [buildRecommendRuntimeRequestOptions, setSelectionStage], ); const checkpointWoodenFishRuntimeRun = useCallback( @@ -8680,10 +8743,18 @@ export function PlatformEntryFlowShellImpl({ if (!runId) { return; } - const response = await woodenFishClient.checkpointRun(runId, payload); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'wooden-fish', + }); + const response = await woodenFishClient.checkpointRun( + runId, + payload, + runtimeRequestOptions, + ); setWoodenFishRun(response.run); }, - [woodenFishRun?.runId], + [buildRecommendRuntimeRequestOptions, woodenFishRun?.runId], ); const executePuzzleAction = puzzleFlow.executeAction; @@ -9269,9 +9340,12 @@ export function PlatformEntryFlowShellImpl({ try { let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary = profile; + const canReadProtectedMatch3DDetail = + !options.embedded || !shouldUseRecommendRuntimeGuestAuth(authUi); if ( - !hasMatch3DRuntimeAsset(profile.generatedItemAssets) || - !hasMatch3DRuntimeBackgroundAsset(profile) + canReadProtectedMatch3DDetail && + (!hasMatch3DRuntimeAsset(profile.generatedItemAssets) || + !hasMatch3DRuntimeBackgroundAsset(profile)) ) { try { const { item } = await getMatch3DWorkDetail(profile.profileId); @@ -9297,12 +9371,14 @@ export function PlatformEntryFlowShellImpl({ runtimeProfile.generatedBackgroundAsset, { expireSeconds: 300 }, ); - const runtimeGuestOptions = - options.authMode === 'isolated' - ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS - : await buildRecommendRuntimeAuthOptions(authUi, options.embedded); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'match3d', + embedded: options.embedded, + forcePublicRuntime: options.authMode === 'isolated', + }); const runtimeOptions = { - ...runtimeGuestOptions, + ...runtimeRequestOptions, ...(typeof options.itemTypeCountOverride === 'number' ? { itemTypeCountOverride: options.itemTypeCountOverride } : {}), @@ -9351,6 +9427,7 @@ export function PlatformEntryFlowShellImpl({ [ isMatch3DBusy, authUi, + buildRecommendRuntimeRequestOptions, match3dFlow, resolveMatch3DErrorMessage, resolveMatch3DRuntimeAdapter, @@ -9374,12 +9451,13 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleError(null); try { - const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( - authUi, - options.embedded, - ); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'square-hole', + embedded: options.embedded, + }); const { run } = options.embedded - ? await startSquareHoleRun(profile.profileId, runtimeGuestOptions) + ? await startSquareHoleRun(profile.profileId, runtimeRequestOptions) : await startSquareHoleRun(profile.profileId); setSquareHoleRun(run); setSquareHoleRuntimeReturnStage(returnStage); @@ -9411,7 +9489,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isSquareHoleBusy, - authUi, + buildRecommendRuntimeRequestOptions, resolveSquareHoleErrorMessage, setSelectionStage, setSquareHoleError, @@ -9554,14 +9632,14 @@ export function PlatformEntryFlowShellImpl({ bigFishInputInFlightRef.current = true; try { - const runtimeGuestOptions = - activeRecommendRuntimeKind === 'big-fish' - ? await buildRecommendRuntimeAuthOptions(authUi, true) - : {}; + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'big-fish', + }); const { run } = await submitBigFishRuntimeInput( bigFishRun.runId, payload, - runtimeGuestOptions, + runtimeRequestOptions, ); setBigFishRun(run); } catch (error) { @@ -9573,8 +9651,7 @@ export function PlatformEntryFlowShellImpl({ } }, [ - activeRecommendRuntimeKind, - authUi, + buildRecommendRuntimeRequestOptions, bigFishRun, resolveBigFishErrorMessage, setBigFishError, @@ -9589,21 +9666,18 @@ export function PlatformEntryFlowShellImpl({ const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt); setBigFishRuntimeStartedAt(null); - const reportPromise = - activeRecommendRuntimeKind === 'big-fish' - ? buildRecommendRuntimeAuthOptions(authUi, true).then( - (runtimeAuthOptions) => - recordBigFishPlay(sessionId, { elapsedMs }, runtimeAuthOptions), - ) - : recordBigFishPlay(sessionId, { elapsedMs }); + const reportPromise = buildRecommendRuntimeRequestOptions({ + kind: 'big-fish', + }).then((runtimeRequestOptions) => + recordBigFishPlay(sessionId, { elapsedMs }, runtimeRequestOptions), + ); void reportPromise.catch((error) => { setBigFishError( resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'), ); }); }, [ - activeRecommendRuntimeKind, - authUi, + buildRecommendRuntimeRequestOptions, bigFishRun?.sessionId, bigFishRuntimeStartedAt, resolveBigFishErrorMessage, @@ -9921,14 +9995,11 @@ export function PlatformEntryFlowShellImpl({ profileId: currentLevel.profileId, levelId: resolvePuzzleRestartLevelId(currentRun, detailItem), }; - const runtimeGuestOptions = - puzzleRuntimeAuthMode === 'isolated' - ? await buildRecommendRuntimeGuestOptions() - : {}; - const { run } = - puzzleRuntimeAuthMode === 'isolated' - ? await startPuzzleRun(startRunPayload, runtimeGuestOptions) - : await startPuzzleRun(startRunPayload); + const runtimeRequestOptions = await buildPuzzleRuntimeRequestOptions(); + const { run } = await startPuzzleRun( + startRunPayload, + runtimeRequestOptions, + ); setSelectedPuzzleDetail(detailItem); puzzleRunRef.current = run; setPuzzleRun(run); @@ -9942,7 +10013,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isPuzzleBusy, puzzleRun, - puzzleRuntimeAuthMode, + buildPuzzleRuntimeRequestOptions, resolvePuzzleErrorMessage, selectedPuzzleDetail, setIsPuzzleBusy, @@ -10054,16 +10125,10 @@ export function PlatformEntryFlowShellImpl({ return; } - const submitLeaderboardPromise = - puzzleRuntimeAuthMode === 'isolated' - ? buildRecommendRuntimeGuestOptions().then((runtimeGuestOptions) => - submitPuzzleLeaderboard( - puzzleRun.runId, - payload, - runtimeGuestOptions, - ), - ) - : submitPuzzleLeaderboard(puzzleRun.runId, payload); + const submitLeaderboardPromise = buildPuzzleRuntimeRequestOptions().then( + (runtimeRequestOptions) => + submitPuzzleLeaderboard(puzzleRun.runId, payload, runtimeRequestOptions), + ); void submitLeaderboardPromise .then(({ run }) => { @@ -10087,7 +10152,7 @@ export function PlatformEntryFlowShellImpl({ authUi?.user?.displayName, platformBootstrap, puzzleRun, - puzzleRuntimeAuthMode, + buildPuzzleRuntimeRequestOptions, resolvePuzzleErrorMessage, setPuzzleError, ]); @@ -10117,10 +10182,7 @@ export function PlatformEntryFlowShellImpl({ return; } - const runtimeGuestOptions = - puzzleRuntimeAuthMode === 'isolated' - ? await buildRecommendRuntimeGuestOptions() - : {}; + const runtimeRequestOptions = await buildPuzzleRuntimeRequestOptions(); const targetProfileId = _target?.profileId?.trim() ?? ''; if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) { const itemPromise = @@ -10129,18 +10191,13 @@ export function PlatformEntryFlowShellImpl({ : getPuzzleGalleryDetail(targetProfileId).then( (response) => response.item, ); - const advancePromise = - puzzleRuntimeAuthMode === 'isolated' - ? advancePuzzleNextLevel( - puzzleRun.runId, - { - targetProfileId, - }, - runtimeGuestOptions, - ) - : advancePuzzleNextLevel(puzzleRun.runId, { - targetProfileId, - }); + const advancePromise = advancePuzzleNextLevel( + puzzleRun.runId, + { + targetProfileId, + }, + runtimeRequestOptions, + ); const [{ run }, item] = await Promise.all([ advancePromise, itemPromise, @@ -10157,14 +10214,11 @@ export function PlatformEntryFlowShellImpl({ return; } - const { run } = - puzzleRuntimeAuthMode === 'isolated' - ? await advancePuzzleNextLevel( - puzzleRun.runId, - {}, - runtimeGuestOptions, - ) - : await advancePuzzleNextLevel(puzzleRun.runId, {}); + const { run } = await advancePuzzleNextLevel( + puzzleRun.runId, + {}, + runtimeRequestOptions, + ); setPuzzleRun(run); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); @@ -10177,7 +10231,7 @@ export function PlatformEntryFlowShellImpl({ isPuzzleBusy, isPuzzleLeaderboardBusy, puzzleRun, - puzzleRuntimeAuthMode, + buildPuzzleRuntimeRequestOptions, resolvePuzzleErrorMessage, selectedPuzzleDetail, setIsPuzzleBusy, @@ -12715,12 +12769,13 @@ export function PlatformEntryFlowShellImpl({ setBigFishRuntimeReturnStage(returnStage); setBigFishRun(null); try { - const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( - authUi, - options.embedded, - ); + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'big-fish', + embedded: options.embedded, + }); const { run } = options.embedded - ? await startBigFishRuntimeRun(sessionId, runtimeGuestOptions) + ? await startBigFishRuntimeRun(sessionId, runtimeRequestOptions) : await startBigFishRuntimeRun(sessionId); setBigFishRuntimeStartedAt(Date.now()); setBigFishRun(run); @@ -12731,7 +12786,7 @@ export function PlatformEntryFlowShellImpl({ ); } const recordPlayPromise = options.embedded - ? recordBigFishPlay(sessionId, { elapsedMs: 0 }, runtimeGuestOptions) + ? recordBigFishPlay(sessionId, { elapsedMs: 0 }, runtimeRequestOptions) : recordBigFishPlay(sessionId, { elapsedMs: 0 }); void recordPlayPromise.catch((error) => { setBigFishError( @@ -12747,7 +12802,7 @@ export function PlatformEntryFlowShellImpl({ } }, [ - authUi, + buildRecommendRuntimeRequestOptions, bigFishFlow, resolveBigFishErrorMessage, setBigFishError, @@ -12774,12 +12829,18 @@ export function PlatformEntryFlowShellImpl({ ); setBarkBattleRuntimeReturnStage(returnStage); try { - const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( - authUi, - options.embedded, + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ + kind: 'bark-battle', + embedded: options.embedded, + }); + setBarkBattleRuntimeRequestOptions( + Object.keys(runtimeRequestOptions).length > 0 + ? runtimeRequestOptions + : null, ); const runResponse = options.embedded - ? await startBarkBattleRun(item.workId, {}, runtimeGuestOptions) + ? await startBarkBattleRun(item.workId, {}, runtimeRequestOptions) : await startBarkBattleRun(item.workId); void runResponse; selectionStageRef.current = 'bark-battle-runtime'; @@ -12800,7 +12861,11 @@ export function PlatformEntryFlowShellImpl({ return false; } }, - [authUi, resolveBarkBattleErrorMessage, setSelectionStage], + [ + buildRecommendRuntimeRequestOptions, + resolveBarkBattleErrorMessage, + setSelectionStage, + ], ); const startSelectedPublicWork = useCallback(() => { @@ -13254,10 +13319,12 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void resolveMatch3DRuntimeAdapter( - activeMatch3DRuntimeProfile?.profileId, - ) - .restartRun(match3dRun.runId) + void buildRecommendRuntimeRequestOptions({ kind: 'match3d' }) + .then((runtimeRequestOptions) => + resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ).restartRun(match3dRun.runId, runtimeRequestOptions), + ) .then(({ run }) => { setMatch3DRun(run); }) @@ -13271,24 +13338,28 @@ export function PlatformEntryFlowShellImpl({ }); }} onOptimisticRunChange={setMatch3DRun} - onClickItem={(payload) => { + onClickItem={async (payload) => { const runId = payload.runId ?? match3dRun?.runId; if (!runId) { return Promise.reject(new Error('抓大鹅运行态缺少 runId。')); } + const runtimeRequestOptions = + await buildRecommendRuntimeRequestOptions({ kind: 'match3d' }); return resolveMatch3DRuntimeAdapter( activeMatch3DRuntimeProfile?.profileId, - ).clickItem(runId, payload); + ).clickItem(runId, payload, runtimeRequestOptions); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void resolveMatch3DRuntimeAdapter( - activeMatch3DRuntimeProfile?.profileId, - ) - .finishTimeUp(match3dRun.runId) + void buildRecommendRuntimeRequestOptions({ kind: 'match3d' }) + .then((runtimeRequestOptions) => + resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ).finishTimeUp(match3dRun.runId, runtimeRequestOptions), + ) .then(({ run }) => { setMatch3DRun(run); }) @@ -13422,9 +13493,17 @@ export function PlatformEntryFlowShellImpl({ squareHoleRun?.runId && squareHoleRun.status.toLowerCase() === 'running' ) { - void stopSquareHoleRun(squareHoleRun.runId).catch( - () => undefined, - ); + void buildRecommendRuntimeRequestOptions({ + kind: 'square-hole', + }) + .then((runtimeRequestOptions) => + stopSquareHoleRun( + squareHoleRun.runId, + undefined, + runtimeRequestOptions, + ), + ) + .catch(() => undefined); } setActiveRecommendRuntimeKind(null); }} @@ -13435,7 +13514,12 @@ export function PlatformEntryFlowShellImpl({ squareHoleFlow.setIsBusy(true); setSquareHoleError(null); - void restartSquareHoleRun(squareHoleRun.runId) + void buildRecommendRuntimeRequestOptions({ + kind: 'square-hole', + }) + .then((runtimeRequestOptions) => + restartSquareHoleRun(squareHoleRun.runId, runtimeRequestOptions), + ) .then(({ run }) => { setSquareHoleRun(run); }) @@ -13457,14 +13541,26 @@ export function PlatformEntryFlowShellImpl({ if (!runId) { return Promise.reject(new Error('方洞挑战运行态缺少 runId。')); } - return dropSquareHoleShape(runId, payload); + return buildRecommendRuntimeRequestOptions({ + kind: 'square-hole', + }).then((runtimeRequestOptions) => + dropSquareHoleShape(runId, payload, runtimeRequestOptions), + ); }} onTimeExpired={() => { if (!squareHoleRun?.runId) { return; } - void finishSquareHoleTimeUp(squareHoleRun.runId) + void buildRecommendRuntimeRequestOptions({ + kind: 'square-hole', + }) + .then((runtimeRequestOptions) => + finishSquareHoleTimeUp( + squareHoleRun.runId, + runtimeRequestOptions, + ), + ) .then(({ run }) => { setSquareHoleRun(run); }) @@ -13509,6 +13605,7 @@ export function PlatformEntryFlowShellImpl({ workId={barkBattlePublishedConfig.workId} publishedConfig={barkBattlePublishedConfig} runtimeMode="published" + runtimeRequestOptions={barkBattleRuntimeRequestOptions ?? undefined} onExit={() => { setActiveRecommendRuntimeKind(null); }} @@ -13539,7 +13636,9 @@ export function PlatformEntryFlowShellImpl({ activeRecommendEntryKey, activeRecommendRuntimeKind, barkBattlePublishedConfig, + barkBattleRuntimeRequestOptions, babyObjectMatchDraft, + buildRecommendRuntimeRequestOptions, bigFishError, bigFishRun, bigFishRuntimeShare, @@ -13576,6 +13675,7 @@ export function PlatformEntryFlowShellImpl({ recommendRuntimeEntries, remodelCurrentPuzzleRuntimeWork, resolveMatch3DErrorMessage, + resolveMatch3DRuntimeAdapter, resolveSquareHoleErrorMessage, reportBigFishObservedPlayTime, restartBigFishRun, @@ -16934,6 +17034,9 @@ export function PlatformEntryFlowShellImpl({ workId={barkBattlePublishedConfig.workId} publishedConfig={barkBattlePublishedConfig} runtimeMode={barkBattleRuntimeMode} + runtimeRequestOptions={ + barkBattleRuntimeRequestOptions ?? undefined + } onExit={() => { if ( barkBattleRuntimeReturnStage === 'bark-battle-result' && diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx index a12fe475..888affb7 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx @@ -724,7 +724,7 @@ test('顶部不显示作者,关卡标题和倒计时使用游戏铭牌结构', const levelLogo = screen.getByTestId( 'puzzle-runtime-level-logo', ) as HTMLImageElement; - expect(levelLogo.getAttribute('src')).toContain('logo.png'); + expect(levelLogo.getAttribute('src')).toContain('logo-runtime-hud.webp'); expect(levelLogo.closest('.puzzle-runtime-level-logo')).toBeTruthy(); expect(document.querySelector('.puzzle-runtime-level-mascot')).toBeNull(); expect(timer.closest('.puzzle-runtime-timer-card')).toBeTruthy(); diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index cfbd941d..4d48416b 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -18,7 +18,7 @@ import { } from 'react'; import { createPortal } from 'react-dom'; -import puzzleLevelLogo from '../../../media/logo.png'; +import puzzleLevelLogo from '../../../media/logo-runtime-hud.webp'; import type { DragPuzzlePieceRequest, PuzzleBoardSnapshot, diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index a3774a19..9f91a4b5 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -7864,6 +7864,113 @@ test('logged out home recommendation next starts the next puzzle work', async () }); }); +test('home recommendation next follows the same scored queue shown in preview', async () => { + const user = userEvent.setup(); + const quietWork = { + workId: 'puzzle-work-public-quiet', + profileId: 'puzzle-profile-public-quiet', + ownerUserId: 'user-2', + sourceSessionId: 'puzzle-session-public-quiet', + authorDisplayName: '拼图作者', + levelName: '安静拼图', + summary: '列表里排在前面但热度较低。', + themeTags: ['安静', '拼图'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T10:00:00.000Z', + publishedAt: '2026-04-25T10:00:00.000Z', + playCount: 40, + likeCount: 0, + publishReady: true, + } satisfies PuzzleWorkSummary; + const hotWork = { + ...quietWork, + workId: 'puzzle-work-public-hot', + profileId: 'puzzle-profile-public-hot', + sourceSessionId: 'puzzle-session-public-hot', + levelName: '热门拼图', + summary: '推荐评分更高,应该先展示。', + playCount: 120, + updatedAt: '2026-04-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + } satisfies PuzzleWorkSummary; + const middleWork = { + ...quietWork, + workId: 'puzzle-work-public-middle', + profileId: 'puzzle-profile-public-middle', + sourceSessionId: 'puzzle-session-public-middle', + levelName: '中间拼图', + summary: '推荐评分排在后面。', + playCount: 0, + updatedAt: '2026-04-25T08:00:00.000Z', + publishedAt: '2026-04-25T08:00:00.000Z', + } satisfies PuzzleWorkSummary; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [quietWork, hotWork, middleWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({ + item: + profileId === hotWork.profileId + ? hotWork + : profileId === middleWork.profileId + ? middleWork + : quietWork, + })); + vi.mocked(startPuzzleRun).mockImplementation(async (payload) => ({ + run: buildMockPuzzleRun( + payload.profileId, + payload.profileId === hotWork.profileId + ? hotWork.levelName + : payload.profileId === middleWork.profileId + ? middleWork.levelName + : quietWork.levelName, + ), + })); + + render(); + + await waitFor(() => { + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: hotWork.profileId, + levelId: null, + }, + LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS, + ); + }); + expect( + await screen.findByLabelText('热门拼图 作品信息', undefined, { + timeout: 3000, + }), + ).toBeTruthy(); + const nextPreview = document.querySelector( + '.platform-recommend-swipe-page--next', + ); + expect(nextPreview).toBeTruthy(); + expect( + within(nextPreview as HTMLElement).getByLabelText('安静拼图 作品信息'), + ).toBeTruthy(); + + await user.click(await screen.findByRole('button', { name: '下一个' })); + + await waitFor(() => { + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: quietWork.profileId, + levelId: null, + }, + LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS, + ); + }); + expect( + await screen.findByLabelText('安静拼图 作品信息', undefined, { + timeout: 3000, + }), + ).toBeTruthy(); +}); + test('home recommendation keeps cover while switching during a pending puzzle start', async () => { const user = userEvent.setup(); const firstWork = { @@ -8196,6 +8303,54 @@ test('home recommendation Match3D runtime keeps profile generated models when ca }); }); +test('logged out home recommendation Match3D runtime skips protected detail and starts with guest auth', async () => { + const match3dCard: Match3DWorkSummary = { + workId: 'match3d-work-card-guest', + profileId: 'match3d-profile-card-guest', + ownerUserId: 'user-2', + sourceSessionId: 'match3d-session-card-guest', + gameName: '游客抓大鹅', + themeText: '游客果园', + summary: '游客可直接游玩。', + tags: ['果园', '抓大鹅'], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 3, + difficulty: 5, + publicationStatus: 'published', + playCount: 3, + updatedAt: '2026-04-25T10:30:00.000Z', + publishedAt: '2026-04-25T10:30:00.000Z', + publishReady: true, + generatedItemAssets: [], + }; + + vi.mocked(listMatch3DGallery).mockResolvedValue({ + items: [match3dCard], + }); + vi.mocked(getMatch3DWorkDetail).mockResolvedValue({ + item: match3dCard, + }); + match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({ + run: buildMockMatch3DRun(match3dCard.profileId), + }); + + render(); + + await waitFor(() => { + expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith( + 'match3d-profile-card-guest', + expect.objectContaining({ + runtimeGuestToken: 'runtime-guest-token', + skipRefresh: true, + }), + ); + }); + expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith( + 'match3d-profile-card-guest', + ); +}); + test('home recommendation Match3D runtime keeps image, music and UI assets without requiring models', async () => { const match3dCard: Match3DWorkSummary = { workId: 'match3d-work-card-image-only', @@ -9411,6 +9566,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa elapsedMs: 18_000, nickname: '测试玩家', }, + LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS, ); }); @@ -9431,6 +9587,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa expect(advancePuzzleNextLevel).toHaveBeenCalledWith( clearedFirstLevel.runId, {}, + LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS, ); }); expect( @@ -9593,6 +9750,7 @@ test('formal puzzle similar work keeps current run level progression', async () expect(advancePuzzleNextLevel).toHaveBeenCalledWith( clearedThirdLevel.runId, { targetProfileId: 'puzzle-profile-similar-2' }, + LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS, ); }); expect(startPuzzleRun).not.toHaveBeenCalled(); diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts index 3071ce05..351590a8 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts @@ -189,6 +189,27 @@ test('public gallery ViewModel builds recommend feed from general public entries ).toEqual([latestPuzzle]); }); +test('public gallery ViewModel keeps recommend feed in scored runtime order', () => { + const quietPuzzle = buildPuzzleEntry({ + profileId: 'quiet', + worldName: '安静拼图', + playCount: 0, + updatedAt: '2026-05-03T00:00:00.000Z', + publishedAt: '2026-05-03T00:00:00.000Z', + }); + const hotPuzzle = buildPuzzleEntry({ + profileId: 'hot', + worldName: '热门拼图', + playCount: 120, + updatedAt: '2026-05-02T00:00:00.000Z', + publishedAt: '2026-05-02T00:00:00.000Z', + }); + + expect(buildPlatformRecommendFeedEntries([], [quietPuzzle, hotPuzzle])).toEqual( + [hotPuzzle, quietPuzzle], + ); +}); + test('public gallery ViewModel selects recommend feed window with wraparound neighbors', () => { const firstEntry = buildPuzzleEntry({ profileId: 'first' }); const secondEntry = buildJumpHopEntry({ profileId: 'second' }); diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts index 6c2a4e85..f0194e85 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts @@ -1,5 +1,6 @@ import { filterGeneralPublicWorks } from '../platform-entry/platformEdutainmentVisibility'; import { getPlatformPublicGalleryEntryKey } from '../platform-entry/platformPublicGalleryFlow'; +import { buildPlatformRecommendedEntries } from '../platform-entry/platformRecommendation'; import { buildPlatformWorldDisplayTags, isBarkBattleGalleryEntry, @@ -146,9 +147,10 @@ export function buildPlatformRecommendFeedEntries( featuredEntries: PlatformPublicGalleryCard[], latestEntries: PlatformPublicGalleryCard[], ) { - return dedupePlatformPublicGalleryEntries( - filterGeneralPublicWorks([...featuredEntries, ...latestEntries]), - ); + return buildPlatformRecommendedEntries({ + featuredEntries: filterGeneralPublicWorks(featuredEntries), + latestEntries: filterGeneralPublicWorks(latestEntries), + }); } export function selectAdjacentPlatformRecommendEntry( diff --git a/src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx b/src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx index 6c9c5382..71780d6b 100644 --- a/src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx +++ b/src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react'; -import woodenFishRuntimeLogo from '../../../media/logo.png'; +import woodenFishRuntimeLogo from '../../../media/logo-runtime-hud.webp'; import type { WoodenFishRuntimeRunSnapshotResponse, WoodenFishWordCounter, diff --git a/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx b/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx index 0a233871..20918be7 100644 --- a/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx +++ b/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx @@ -8,6 +8,7 @@ import type { BarkBattleServerResult, } from '../../../../packages/shared/src/contracts/barkBattle'; import { + type BarkBattleRuntimeRequestOptions, finishBarkBattleRun, startBarkBattleRun, } from '../../../services/bark-battle-runtime'; @@ -31,6 +32,7 @@ type BarkBattleRuntimeShellProps = { workId?: string; publishedConfig?: BarkBattlePublishedConfig | null; runtimeMode?: BarkBattleRuntimeMode; + runtimeRequestOptions?: BarkBattleRuntimeRequestOptions; onExit?: () => void; }; @@ -266,6 +268,7 @@ export function BarkBattleRuntimeShell({ workId, publishedConfig, runtimeMode = 'draft', + runtimeRequestOptions, onExit, }: BarkBattleRuntimeShellProps) { const initialConfig = useMemo( @@ -404,20 +407,24 @@ export function BarkBattleRuntimeShell({ 0, runtimeConfigRef.current.roundDurationMs - nextSnapshot.remainingMs, ); - void finishBarkBattleRun(activeRun.runId, { - runId: activeRun.runId, - runToken: activeRun.runToken, - workId: activeRun.workId, - configVersion: activeRun.configVersion, - rulesetVersion: activeRun.rulesetVersion, - difficultyPreset: activeRun.difficultyPreset, - clientStartedAt: startedAt, - clientFinishedAt: finishedAt, - durationMs, - derivedMetrics: buildDerivedMetrics(), - clientResult: resolveClientResult(nextSnapshot.winner), - clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION, - }) + void finishBarkBattleRun( + activeRun.runId, + { + runId: activeRun.runId, + runToken: activeRun.runToken, + workId: activeRun.workId, + configVersion: activeRun.configVersion, + rulesetVersion: activeRun.rulesetVersion, + difficultyPreset: activeRun.difficultyPreset, + clientStartedAt: startedAt, + clientFinishedAt: finishedAt, + durationMs, + derivedMetrics: buildDerivedMetrics(), + clientResult: resolveClientResult(nextSnapshot.winner), + clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION, + }, + runtimeRequestOptions, + ) .then(() => { appendDebugEvent('正式成绩已提交'); }) @@ -433,6 +440,7 @@ export function BarkBattleRuntimeShell({ buildDerivedMetrics, controller, isPublishedRuntime, + runtimeRequestOptions, ], ); @@ -447,14 +455,18 @@ export function BarkBattleRuntimeShell({ pendingRunStartRef.current = (async () => { try { setRuntimeError(null); - const started = await startBarkBattleRun(replacementConfig.workId, { - // 中文注释:公开广场摘要可能滞后于已发布运行配置;正式开局以服务端当前发布快照为准。 - sourceRoute: - typeof window === 'undefined' - ? 'bark-battle-runtime' - : window.location.pathname, - clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION, - }); + const started = await startBarkBattleRun( + replacementConfig.workId, + { + // 中文注释:公开广场摘要可能滞后于已发布运行配置;正式开局以服务端当前发布快照为准。 + sourceRoute: + typeof window === 'undefined' + ? 'bark-battle-runtime' + : window.location.pathname, + clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION, + }, + runtimeRequestOptions, + ); const serverRuntimeConfig = buildRuntimeConfigFromServerConfig( started.runtimeConfig, ); @@ -491,7 +503,13 @@ export function BarkBattleRuntimeShell({ })(); } return pendingRunStartRef.current ?? Promise.resolve(true); - }, [appendDebugEvent, controller, isPublishedRuntime, replacementConfig]); + }, [ + appendDebugEvent, + controller, + isPublishedRuntime, + replacementConfig, + runtimeRequestOptions, + ]); const syncSnapshot = useCallback(() => { const nextSnapshot = controller.getSnapshot(); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 9f657156..d57a6dac 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -66,6 +66,9 @@ describe('apiClient', () => { dispatchEvent: dispatchEventMock, localStorage: createLocalStorageMock(), }); + vi.stubGlobal('crypto', { + randomUUID: () => '11111111-2222-3333-4444-555555555555', + }); fetchMock.mockReset(); dispatchEventMock.mockReset(); clearStoredAccessToken({ emit: false }); @@ -121,6 +124,7 @@ describe('apiClient', () => { credentials: 'same-origin', headers: expect.objectContaining({ Authorization: 'Bearer expired-token', + 'x-request-id': 'web-11111111-2222-3333-4444-555555555555', 'x-genarrative-response-envelope': 'v1', }), }), @@ -140,6 +144,7 @@ describe('apiClient', () => { credentials: 'same-origin', headers: expect.objectContaining({ Authorization: 'Bearer fresh-token', + 'x-request-id': 'web-11111111-2222-3333-4444-555555555555', }), }), ); diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 7d106555..bf8eacd4 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -40,6 +40,8 @@ export type ApiRequestOptions = { notifyAuthStateChange?: boolean; // 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。 clearAuthOnUnauthorized?: boolean; + // 同一次业务请求在客户端重试时复用 request id,后端据此做计费幂等。 + requestId?: string; }; export const BACKGROUND_AUTH_REQUEST_OPTIONS = { @@ -99,6 +101,22 @@ function normalizeHeaders(headers?: HeadersInit) { return nextHeaders; } +function buildClientRequestId() { + const randomId = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return `web-${randomId}`; +} + +function resolveRequestIdHeader(headers: Record, options: ApiRequestOptions) { + const explicitRequestId = options.requestId?.trim(); + const existingRequestId = Object.entries(headers).find( + ([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(), + )?.[1]; + return explicitRequestId || existingRequestId || buildClientRequestId(); +} + function coerceMeta(value: unknown): Partial { if (!isRecord(value)) { return {}; @@ -582,12 +600,14 @@ export async function fetchWithApiAuth( const retry = resolveRetryOptions(method, options.retry); const authFailurePolicy = resolveAuthFailurePolicy(options); const requestSignal = init.signal ?? undefined; + const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options); let attempt = 0; let refreshAttempted = false; for (;;) { try { let requestHeaders = withAuthorizationHeaders(init.headers, options); + requestHeaders[REQUEST_ID_HEADER] = requestId; let hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || requestHeaders.authorization?.trim(), @@ -603,6 +623,7 @@ export async function fetchWithApiAuth( // 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。 await ensureStoredAccessToken(); requestHeaders = withAuthorizationHeaders(init.headers, options); + requestHeaders[REQUEST_ID_HEADER] = requestId; hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || requestHeaders.authorization?.trim(), diff --git a/src/services/big-fish-runtime/bigFishRuntimeClient.ts b/src/services/big-fish-runtime/bigFishRuntimeClient.ts index 39407713..b5526dd9 100644 --- a/src/services/big-fish-runtime/bigFishRuntimeClient.ts +++ b/src/services/big-fish-runtime/bigFishRuntimeClient.ts @@ -58,10 +58,14 @@ export function startBigFishRun( }); } -export function getBigFishRun(runId: string) { +export function getBigFishRun( + runId: string, + options: BigFishRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath(BIG_FISH_RUNTIME_API_BASE, 'runs', runId), fallbackMessage: '读取大鱼吃小鱼玩法失败', + requestOptions: options, }); } diff --git a/src/services/jump-hop/jumpHopClient.test.ts b/src/services/jump-hop/jumpHopClient.test.ts index eef764cb..0a4931ce 100644 --- a/src/services/jump-hop/jumpHopClient.test.ts +++ b/src/services/jump-hop/jumpHopClient.test.ts @@ -139,4 +139,14 @@ test('jump hop work detail preserves flattened back button asset', async () => { const response = await jumpHopClient.getWorkDetail('profile-1'); expect(response.item.backButtonAsset).toEqual(backButtonAsset); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/jump-hop/works/profile-1', + { method: 'GET' }, + '读取跳一跳作品详情失败', + expect.objectContaining({ + retry: expect.objectContaining({ maxRetries: 1 }), + skipAuth: true, + skipRefresh: true, + }), + ); }); diff --git a/src/services/jump-hop/jumpHopClient.ts b/src/services/jump-hop/jumpHopClient.ts index dfb1f245..1fc34824 100644 --- a/src/services/jump-hop/jumpHopClient.ts +++ b/src/services/jump-hop/jumpHopClient.ts @@ -19,6 +19,7 @@ import type { JumpHopWorkSummaryResponse, } from '../../../packages/shared/src/contracts/jumpHop'; import { + type ApiRequestOptions, type ApiRetryOptions, requestJson, } from '../apiClient'; @@ -196,10 +197,19 @@ export async function getJumpHopWorkDetail( options.audience === 'creation' ? JUMP_HOP_WORKS_API_BASE : `${JUMP_HOP_RUNTIME_API_BASE}/works`; + const requestOptions: ApiRequestOptions = + options.audience === 'creation' + ? {} + : { + retry: JUMP_HOP_RUNTIME_READ_RETRY, + skipAuth: true, + skipRefresh: true, + }; const response = await requestJson( `${base}/${encodeURIComponent(profileId)}`, { method: 'GET' }, '读取跳一跳作品详情失败', + requestOptions, ); return normalizeJumpHopWorkDetailResponse(response); } diff --git a/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts b/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts index 5a1951d4..ea34f105 100644 --- a/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts +++ b/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts @@ -64,30 +64,63 @@ test('server Match3D runtime adapter forwards the full runtime seam lazily', asy stopRun: vi.fn().mockResolvedValue(stopResponse), }; const adapter = createServerMatch3DRuntimeAdapter(dependencies); + const runtimeRequestOptions = { + runtimeGuestToken: 'runtime-guest-token', + skipRefresh: true, + }; - expect(await adapter.startRun('server-profile-1', { skipRefresh: true })).toBe( - startResponse, + expect( + await adapter.startRun('server-profile-1', runtimeRequestOptions), + ).toBe(startResponse); + expect(await adapter.getRun('server-run-start', runtimeRequestOptions)).toBe( + getResponse, ); - expect(await adapter.getRun('server-run-start')).toBe(getResponse); - expect(await adapter.clickItem('server-run-start', clickPayload)).toEqual({ + expect( + await adapter.clickItem( + 'server-run-start', + clickPayload, + runtimeRequestOptions, + ), + ).toEqual({ status: 'Accepted', run: buildMockRun('server-run-click'), }); - expect(await adapter.restartRun('server-run-start')).toBe(restartResponse); - expect(await adapter.stopRun('server-run-restart')).toBe(stopResponse); - expect(await adapter.finishTimeUp('server-run-start')).toBe(finishResponse); + expect( + await adapter.restartRun('server-run-start', runtimeRequestOptions), + ).toBe(restartResponse); + expect(await adapter.stopRun('server-run-restart', runtimeRequestOptions)).toBe( + stopResponse, + ); + expect( + await adapter.finishTimeUp('server-run-start', runtimeRequestOptions), + ).toBe(finishResponse); - expect(dependencies.startRun).toHaveBeenCalledWith('server-profile-1', { - skipRefresh: true, - }); - expect(dependencies.getRun).toHaveBeenCalledWith('server-run-start'); + expect(dependencies.startRun).toHaveBeenCalledWith( + 'server-profile-1', + runtimeRequestOptions, + ); + expect(dependencies.getRun).toHaveBeenCalledWith( + 'server-run-start', + runtimeRequestOptions, + ); expect(dependencies.clickItem).toHaveBeenCalledWith( 'server-run-start', clickPayload, + runtimeRequestOptions, + ); + expect(dependencies.restartRun).toHaveBeenCalledWith( + 'server-run-start', + runtimeRequestOptions, + ); + expect(dependencies.stopRun).toHaveBeenCalledWith( + 'server-run-restart', + undefined, + runtimeRequestOptions, + ); + expect(dependencies.finishTimeUp).toHaveBeenCalledWith( + 'server-run-start', + runtimeRequestOptions, ); - expect(dependencies.restartRun).toHaveBeenCalledWith('server-run-start'); - expect(dependencies.stopRun).toHaveBeenCalledWith('server-run-restart'); - expect(dependencies.finishTimeUp).toHaveBeenCalledWith('server-run-start'); }); test('local Match3D runtime adapter exposes the same runtime seam as the server client', async () => { diff --git a/src/services/match3d-runtime/match3dRuntimeAdapter.ts b/src/services/match3d-runtime/match3dRuntimeAdapter.ts index 9d70d405..9e47957b 100644 --- a/src/services/match3d-runtime/match3dRuntimeAdapter.ts +++ b/src/services/match3d-runtime/match3dRuntimeAdapter.ts @@ -24,14 +24,27 @@ export type Match3DRuntimeAdapter = { profileId: string, options?: Match3DRuntimeRequestOptions, ) => Promise; - getRun: (runId: string) => Promise; + getRun: ( + runId: string, + options?: Match3DRuntimeRequestOptions, + ) => Promise; clickItem: ( runId: string, payload: Match3DClickItemRequest, + options?: Match3DRuntimeRequestOptions, ) => Promise; - restartRun: (runId: string) => Promise; - stopRun: (runId: string) => Promise; - finishTimeUp: (runId: string) => Promise; + restartRun: ( + runId: string, + options?: Match3DRuntimeRequestOptions, + ) => Promise; + stopRun: ( + runId: string, + options?: Match3DRuntimeRequestOptions, + ) => Promise; + finishTimeUp: ( + runId: string, + options?: Match3DRuntimeRequestOptions, + ) => Promise; }; export type LocalMatch3DRuntimeAdapterOptions = { @@ -63,12 +76,13 @@ export function createServerMatch3DRuntimeAdapter( defaultServerMatch3DRuntimeAdapterDependencies, ): Match3DRuntimeAdapter { return { - clickItem: (runId, payload) => dependencies.clickItem(runId, payload), - finishTimeUp: (runId) => dependencies.finishTimeUp(runId), - getRun: (runId) => dependencies.getRun(runId), - restartRun: (runId) => dependencies.restartRun(runId), + clickItem: (runId, payload, options) => + dependencies.clickItem(runId, payload, options), + finishTimeUp: (runId, options) => dependencies.finishTimeUp(runId, options), + getRun: (runId, options) => dependencies.getRun(runId, options), + restartRun: (runId, options) => dependencies.restartRun(runId, options), startRun: (profileId, options) => dependencies.startRun(profileId, options), - stopRun: (runId) => dependencies.stopRun(runId), + stopRun: (runId, options) => dependencies.stopRun(runId, undefined, options), }; } diff --git a/src/services/match3d-runtime/match3dRuntimeClient.ts b/src/services/match3d-runtime/match3dRuntimeClient.ts index 721306c5..fa2e4e24 100644 --- a/src/services/match3d-runtime/match3dRuntimeClient.ts +++ b/src/services/match3d-runtime/match3dRuntimeClient.ts @@ -89,11 +89,15 @@ export function startMatch3DRun( /** * 读取抓大鹅运行态快照。 */ -export function getMatch3DRun(runId: string) { +export function getMatch3DRun( + runId: string, + options: Match3DRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId), fallbackMessage: '读取抓大鹅运行快照失败', retry: MATCH3D_RUNTIME_READ_RETRY, + requestOptions: options, }); } @@ -103,6 +107,7 @@ export function getMatch3DRun(runId: string) { export async function clickMatch3DItem( runId: string, payload: Match3DClickItemRequest, + options: Match3DRuntimeRequestOptions = {}, ) { const response = await requestRuntimeJson({ url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'click'), @@ -113,6 +118,7 @@ export async function clickMatch3DItem( }, fallbackMessage: '确认抓大鹅点击失败', retry: MATCH3D_RUNTIME_WRITE_RETRY, + requestOptions: options, }); return mapClickConfirmation(payload, response.confirmation); @@ -126,6 +132,7 @@ export function stopMatch3DRun( payload: StopMatch3DRunRequest = { clientActionId: `match3d-stop-${Date.now()}`, }, + options: Match3DRuntimeRequestOptions = {}, ) { return requestRuntimeJson({ url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'stop'), @@ -133,30 +140,39 @@ export function stopMatch3DRun( jsonBody: payload, fallbackMessage: '停止抓大鹅玩法失败', retry: MATCH3D_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } /** * 基于当前 run 重开一局。 */ -export function restartMatch3DRun(runId: string) { +export function restartMatch3DRun( + runId: string, + options: Match3DRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'restart'), method: 'POST', fallbackMessage: '重新开始抓大鹅玩法失败', retry: MATCH3D_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } /** * 前端倒计时归零后通知后端确认失败状态。 */ -export function finishMatch3DTimeUp(runId: string) { +export function finishMatch3DTimeUp( + runId: string, + options: Match3DRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'time-up'), method: 'POST', fallbackMessage: '同步抓大鹅倒计时失败', retry: MATCH3D_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.test.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.test.ts index 68f3accc..fc02c911 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.test.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.test.ts @@ -61,7 +61,7 @@ describe('puzzleRuntimeClient', () => { ); }); - it('keeps pause requests on account auth options instead of guest auth', async () => { + it('uses runtime guest auth for pause requests when provided', async () => { await updatePuzzleRunPause( 'run/1', { paused: true }, @@ -76,17 +76,19 @@ describe('puzzleRuntimeClient', () => { expect(init).toEqual( expect.objectContaining({ method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer runtime-guest-token', + }, body: JSON.stringify({ paused: true }), }), ); - expect(init.headers).not.toHaveProperty('Authorization'); expect(options).toEqual( expect.objectContaining({ authImpact: 'local', + skipAuth: true, skipRefresh: true, }), ); - expect(options).not.toMatchObject({ skipAuth: true }); }); }); diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index 1e314fe4..158e1c37 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -8,10 +8,7 @@ import type { UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest, } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; -import { - type ApiRetryOptions, - requestJson, -} from '../apiClient'; +import { type ApiRetryOptions } from '../apiClient'; import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; @@ -52,11 +49,15 @@ export async function startPuzzleRun( /** * 读取拼图运行态快照。 */ -export async function getPuzzleRun(runId: string) { +export async function getPuzzleRun( + runId: string, + options: PuzzleRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId), fallbackMessage: '读取拼图运行快照失败', retry: PUZZLE_RUNTIME_READ_RETRY, + requestOptions: options, }); } @@ -66,6 +67,7 @@ export async function getPuzzleRun(runId: string) { export async function swapPuzzlePieces( runId: string, payload: SwapPuzzlePiecesRequest, + options: PuzzleRuntimeRequestOptions = {}, ) { return requestRuntimeJson({ url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'swap'), @@ -73,6 +75,7 @@ export async function swapPuzzlePieces( jsonBody: payload, fallbackMessage: '交换拼图块失败', retry: PUZZLE_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } @@ -82,6 +85,7 @@ export async function swapPuzzlePieces( export async function dragPuzzlePieceOrGroup( runId: string, payload: DragPuzzlePieceRequest, + options: PuzzleRuntimeRequestOptions = {}, ) { return requestRuntimeJson({ url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'drag'), @@ -89,6 +93,7 @@ export async function dragPuzzlePieceOrGroup( jsonBody: payload, fallbackMessage: '拖动拼图块失败', retry: PUZZLE_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } @@ -141,22 +146,14 @@ export async function updatePuzzleRunPause( payload: UpdatePuzzleRuntimePauseRequest, options: PuzzleRuntimeRequestOptions = {}, ) { - return requestJson( - buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'), - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - '更新拼图计时状态失败', - { - retry: PUZZLE_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '更新拼图计时状态失败', + retry: PUZZLE_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } /** @@ -167,22 +164,14 @@ export async function usePuzzleRuntimeProp( payload: UsePuzzleRuntimePropRequest, options: PuzzleRuntimeRequestOptions = {}, ) { - return requestJson( - buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'), - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - '使用拼图道具失败', - { - retry: PUZZLE_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '使用拼图道具失败', + retry: PUZZLE_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } export const puzzleRuntimeClient = { diff --git a/src/services/recommendedRuntimeGuestLaunch.test.ts b/src/services/recommendedRuntimeGuestLaunch.test.ts index e7cd8345..5844cb1c 100644 --- a/src/services/recommendedRuntimeGuestLaunch.test.ts +++ b/src/services/recommendedRuntimeGuestLaunch.test.ts @@ -13,17 +13,56 @@ vi.mock('./apiClient', async () => { }; }); -import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient'; -import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient'; +import { + finishBarkBattleRun, + getBarkBattleRuntimeConfig, + startBarkBattleRun, +} from './bark-battle-runtime/barkBattleRuntimeClient'; +import { + getBigFishRun, + recordBigFishPlay, + startBigFishRun, + submitBigFishInput, +} from './big-fish-runtime/bigFishRuntimeClient'; import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient'; -import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient'; +import { + clickMatch3DItem, + finishMatch3DTimeUp, + getMatch3DRun, + restartMatch3DRun, + startMatch3DRun, + stopMatch3DRun, +} from './match3d-runtime/match3dRuntimeClient'; import { advancePuzzleNextLevel, + dragPuzzlePieceOrGroup, + getPuzzleRun, startPuzzleRun, submitPuzzleLeaderboard, + swapPuzzlePieces, + updatePuzzleRunPause, + usePuzzleRuntimeProp, } from './puzzle-runtime/puzzleRuntimeClient'; -import { startSquareHoleRun } from './square-hole-runtime/squareHoleRuntimeClient'; -import { startVisualNovelRun } from './visual-novel-runtime/visualNovelRuntimeClient'; +import { puzzleClearClient } from './puzzle-clear/puzzleClearClient'; +import { + dropSquareHoleShape, + finishSquareHoleTimeUp, + getSquareHoleRun, + restartSquareHoleRun, + startSquareHoleRun, + stopSquareHoleRun, +} from './square-hole-runtime/squareHoleRuntimeClient'; +import { + checkpointWoodenFishRun, + finishWoodenFishRun, + startWoodenFishRuntimeRun, +} from './wooden-fish/woodenFishClient'; +import { + getVisualNovelHistory, + getVisualNovelRun, + regenerateVisualNovelRun, + startVisualNovelRun, +} from './visual-novel-runtime/visualNovelRuntimeClient'; describe('recommended runtime guest launch clients', () => { beforeEach(() => { @@ -31,6 +70,25 @@ describe('recommended runtime guest launch clients', () => { apiClientMocks.requestJson.mockResolvedValue({ run: {} }); }); + function expectRuntimeGuestRequest(expectedUrl: string, expectedMethod: string) { + const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0]; + expect(url).toBe(expectedUrl); + expect(init).toEqual( + expect.objectContaining({ + method: expectedMethod, + headers: expect.objectContaining({ + Authorization: 'Bearer runtime-guest-token', + }), + }), + ); + expect(options).toEqual( + expect.objectContaining({ + skipAuth: true, + skipRefresh: true, + }), + ); + } + it.each([ { name: 'jump-hop', @@ -82,6 +140,14 @@ describe('recommended runtime guest launch clients', () => { }), expectedUrl: '/api/runtime/bark-battle/works/bark-battle-work-1/runs', }, + { + name: 'wooden-fish', + start: () => + startWoodenFishRuntimeRun('wooden-fish-profile-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/wooden-fish/runs', + }, { name: 'puzzle', start: () => @@ -187,4 +253,397 @@ describe('recommended runtime guest launch clients', () => { }), ); }); + + it.each([ + { + name: 'puzzle get run', + run: () => + getPuzzleRun('run-puzzle-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1', + expectedMethod: 'GET', + }, + { + name: 'puzzle swap', + run: () => + swapPuzzlePieces( + 'run-puzzle-1', + { firstPieceId: 'piece-a', secondPieceId: 'piece-b' }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/swap', + expectedMethod: 'POST', + }, + { + name: 'puzzle drag', + run: () => + dragPuzzlePieceOrGroup( + 'run-puzzle-1', + { pieceId: 'piece-a', targetRow: 1, targetCol: 2 }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/drag', + expectedMethod: 'POST', + }, + { + name: 'puzzle pause', + run: () => + updatePuzzleRunPause( + 'run-puzzle-1', + { paused: true }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/pause', + expectedMethod: 'POST', + }, + { + name: 'puzzle prop', + run: () => + usePuzzleRuntimeProp( + 'run-puzzle-1', + { propKind: 'extendTime' }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/props', + expectedMethod: 'POST', + }, + { + name: 'puzzle-clear get run', + run: () => + puzzleClearClient.getRun('puzzle-clear-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1', + expectedMethod: 'GET', + }, + { + name: 'puzzle-clear swap', + run: () => + puzzleClearClient.swapCards( + 'puzzle-clear-run-1', + { fromRow: 0, fromCol: 0, toRow: 0, toCol: 1 }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/swap', + expectedMethod: 'POST', + }, + { + name: 'puzzle-clear retry', + run: () => + puzzleClearClient.retryLevel('puzzle-clear-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: + '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/retry-level', + expectedMethod: 'POST', + }, + { + name: 'puzzle-clear next', + run: () => + puzzleClearClient.advanceNextLevel('puzzle-clear-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: + '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/next-level', + expectedMethod: 'POST', + }, + { + name: 'puzzle-clear time up', + run: () => + puzzleClearClient.markTimeUp('puzzle-clear-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/time-up', + expectedMethod: 'POST', + }, + { + name: 'square-hole get run', + run: () => + getSquareHoleRun('square-hole-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1', + expectedMethod: 'GET', + }, + { + name: 'square-hole drop', + run: () => + dropSquareHoleShape( + 'square-hole-run-1', + { + holeId: 'hole-1', + clientSnapshotVersion: 1, + clientEventId: 'event-1', + droppedAtMs: 1_700_000_000_000, + }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/drop', + expectedMethod: 'POST', + }, + { + name: 'square-hole stop', + run: () => + stopSquareHoleRun( + 'square-hole-run-1', + { clientActionId: 'stop-1' }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/stop', + expectedMethod: 'POST', + }, + { + name: 'square-hole restart', + run: () => + restartSquareHoleRun('square-hole-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/restart', + expectedMethod: 'POST', + }, + { + name: 'square-hole time up', + run: () => + finishSquareHoleTimeUp('square-hole-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/time-up', + expectedMethod: 'POST', + }, + { + name: 'big-fish get run', + run: () => + getBigFishRun('big-fish-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/big-fish/runs/big-fish-run-1', + expectedMethod: 'GET', + }, + { + name: 'big-fish input', + run: () => + submitBigFishInput( + 'big-fish-run-1', + { x: 0.25, y: 0.75 }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/big-fish/runs/big-fish-run-1/input', + expectedMethod: 'POST', + }, + { + name: 'big-fish play report', + run: () => + recordBigFishPlay( + 'big-fish-session-1', + { elapsedMs: 1_000 }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: + '/api/runtime/big-fish/sessions/big-fish-session-1/play', + expectedMethod: 'POST', + }, + { + name: 'bark-battle config', + run: () => + getBarkBattleRuntimeConfig('bark-battle-work-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: + '/api/runtime/bark-battle/works/bark-battle-work-1/config', + expectedMethod: 'GET', + }, + { + name: 'bark-battle finish', + run: () => + finishBarkBattleRun( + 'bark-battle-run-1', + { + runId: 'bark-battle-run-1', + runToken: 'run-token', + workId: 'bark-battle-work-1', + configVersion: 1, + rulesetVersion: 'v1', + difficultyPreset: 'normal', + clientStartedAt: '2026-06-10T00:00:00Z', + clientFinishedAt: '2026-06-10T00:00:10Z', + durationMs: 10_000, + derivedMetrics: { + triggerCount: 1, + maxVolume: 0.8, + averageVolume: 0.4, + finalEnergy: 10, + comboMax: 1, + }, + }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/bark-battle/runs/bark-battle-run-1/finish', + expectedMethod: 'POST', + }, + { + name: 'wooden-fish checkpoint', + run: () => + checkpointWoodenFishRun( + 'wooden-fish-run-1', + { totalTapCount: 8, wordCounters: [] }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: + '/api/runtime/wooden-fish/runs/wooden-fish-run-1/checkpoint', + expectedMethod: 'POST', + }, + { + name: 'wooden-fish finish', + run: () => + finishWoodenFishRun( + 'wooden-fish-run-1', + { totalTapCount: 8, wordCounters: [] }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/wooden-fish/runs/wooden-fish-run-1/finish', + expectedMethod: 'POST', + }, + { + name: 'visual-novel get run', + run: () => + getVisualNovelRun('visual-novel-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/visual-novel/runs/visual-novel-run-1', + expectedMethod: 'GET', + }, + { + name: 'visual-novel history', + run: () => + getVisualNovelHistory('visual-novel-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: + '/api/runtime/visual-novel/runs/visual-novel-run-1/history', + expectedMethod: 'GET', + }, + { + name: 'visual-novel regenerate', + run: () => + regenerateVisualNovelRun( + 'visual-novel-run-1', + { historyEntryId: 'history-1', clientEventId: 'event-1' }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: + '/api/runtime/visual-novel/runs/visual-novel-run-1/regenerate', + expectedMethod: 'POST', + }, + ])( + '$name uses the shared runtime guest bearer token without touching login auth', + async ({ run, expectedUrl, expectedMethod }) => { + await run(); + + expectRuntimeGuestRequest(expectedUrl, expectedMethod); + }, + ); + + it.each([ + { + name: 'get run', + run: () => + getMatch3DRun('match3d-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/match3d/runs/match3d-run-1', + expectedMethod: 'GET', + }, + { + name: 'restart', + run: () => + restartMatch3DRun('match3d-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/match3d/runs/match3d-run-1/restart', + expectedMethod: 'POST', + }, + { + name: 'stop', + run: () => + stopMatch3DRun( + 'match3d-run-1', + { clientActionId: 'stop-1' }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/match3d/runs/match3d-run-1/stop', + expectedMethod: 'POST', + }, + { + name: 'time up', + run: () => + finishMatch3DTimeUp('match3d-run-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/match3d/runs/match3d-run-1/time-up', + expectedMethod: 'POST', + }, + ])( + 'match3d $name uses the runtime guest bearer token without touching login auth', + async ({ run, expectedUrl, expectedMethod }) => { + await run(); + + const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0]; + expect(url).toBe(expectedUrl); + expect(init).toEqual( + expect.objectContaining({ + method: expectedMethod, + headers: expect.objectContaining({ + Authorization: 'Bearer runtime-guest-token', + }), + }), + ); + expect(options).toEqual( + expect.objectContaining({ + skipAuth: true, + skipRefresh: true, + }), + ); + }, + ); + + it('match3d click uses the runtime guest bearer token without touching login auth', async () => { + apiClientMocks.requestJson.mockResolvedValueOnce({ + confirmation: { + accepted: true, + run: {}, + clearedItemInstanceIds: [], + }, + }); + + await clickMatch3DItem( + 'match3d-run-1', + { + runId: 'match3d-run-1', + itemInstanceId: 'item-1', + clientActionId: 'action-1', + clientEventId: 'event-1', + clickedAtMs: 1_700_000_000_000, + clientSnapshotVersion: 1, + }, + { runtimeGuestToken: 'runtime-guest-token' }, + ); + + const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0]; + expect(url).toBe('/api/runtime/match3d/runs/match3d-run-1/click'); + expect(init).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer runtime-guest-token', + }), + }), + ); + expect(options).toEqual( + expect.objectContaining({ + skipAuth: true, + skipRefresh: true, + }), + ); + }); }); diff --git a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts index e26d7907..53173916 100644 --- a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts +++ b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts @@ -49,11 +49,15 @@ export function startSquareHoleRun( /** * 读取方洞挑战运行态快照。 */ -export function getSquareHoleRun(runId: string) { +export function getSquareHoleRun( + runId: string, + options: SquareHoleRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId), fallbackMessage: '读取方洞挑战运行快照失败', retry: SQUARE_HOLE_RUNTIME_READ_RETRY, + requestOptions: options, }); } @@ -63,6 +67,7 @@ export function getSquareHoleRun(runId: string) { export function dropSquareHoleShape( runId: string, payload: DropSquareHoleShapeRequest, + options: SquareHoleRuntimeRequestOptions = {}, ) { return requestRuntimeJson({ url: buildRuntimeApiPath( @@ -78,6 +83,7 @@ export function dropSquareHoleShape( }, fallbackMessage: '确认方洞挑战投入失败', retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } @@ -89,6 +95,7 @@ export function stopSquareHoleRun( payload: StopSquareHoleRunRequest = { clientActionId: `square-hole-stop-${Date.now()}`, }, + options: SquareHoleRuntimeRequestOptions = {}, ) { return requestRuntimeJson({ url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId, 'stop'), @@ -96,13 +103,17 @@ export function stopSquareHoleRun( jsonBody: payload, fallbackMessage: '停止方洞挑战失败', retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } /** * 基于当前 run 重开一局。 */ -export function restartSquareHoleRun(runId: string) { +export function restartSquareHoleRun( + runId: string, + options: SquareHoleRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath( SQUARE_HOLE_RUNTIME_API_BASE, @@ -113,13 +124,17 @@ export function restartSquareHoleRun(runId: string) { method: 'POST', fallbackMessage: '重新开始方洞挑战失败', retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } /** * 前端倒计时归零后通知后端确认失败状态。 */ -export function finishSquareHoleTimeUp(runId: string) { +export function finishSquareHoleTimeUp( + runId: string, + options: SquareHoleRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath( SQUARE_HOLE_RUNTIME_API_BASE, @@ -130,6 +145,7 @@ export function finishSquareHoleTimeUp(runId: string) { method: 'POST', fallbackMessage: '同步方洞挑战倒计时失败', retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } diff --git a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts index 527d165b..b767baa9 100644 --- a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts +++ b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts @@ -135,15 +135,22 @@ export async function startVisualNovelRun( ); } -export async function getVisualNovelRun(runId: string) { +export async function getVisualNovelRun( + runId: string, + options: VisualNovelRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId), fallbackMessage: '读取视觉小说运行快照失败', retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, + requestOptions: options, }); } -export async function getVisualNovelHistory(runId: string) { +export async function getVisualNovelHistory( + runId: string, + options: VisualNovelRuntimeRequestOptions = {}, +) { return requestRuntimeJson({ url: buildRuntimeApiPath( VISUAL_NOVEL_RUNTIME_API_BASE, @@ -153,6 +160,7 @@ export async function getVisualNovelHistory(runId: string) { ), fallbackMessage: '读取视觉小说历史失败', retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, + requestOptions: options, }); } @@ -185,6 +193,7 @@ export async function streamVisualNovelRuntimeAction( export async function regenerateVisualNovelRun( runId: string, payload: VisualNovelRegenerateRequest, + options: VisualNovelRuntimeRequestOptions = {}, ) { return requestRuntimeJson({ url: buildRuntimeApiPath( @@ -197,6 +206,7 @@ export async function regenerateVisualNovelRun( jsonBody: payload, fallbackMessage: '重生成视觉小说历史失败', retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, + requestOptions: options, }); } diff --git a/src/services/wooden-fish/woodenFishClient.test.ts b/src/services/wooden-fish/woodenFishClient.test.ts index aea47b59..c6c961a1 100644 --- a/src/services/wooden-fish/woodenFishClient.test.ts +++ b/src/services/wooden-fish/woodenFishClient.test.ts @@ -55,6 +55,30 @@ test('wooden fish list works uses creation works endpoint', async () => { ); }); +test('wooden fish runtime work detail reads public profile without auth refresh coupling', async () => { + const { woodenFishClient } = await import('./woodenFishClient'); + requestJsonMock.mockResolvedValueOnce({ + item: { + summary: { + profileId: 'profile-1', + }, + }, + }); + + await woodenFishClient.getWorkDetail('profile-1'); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/wooden-fish/works/profile-1', + { method: 'GET' }, + '读取敲木鱼作品详情失败', + expect.objectContaining({ + retry: expect.objectContaining({ maxRetries: 1 }), + skipAuth: true, + skipRefresh: true, + }), + ); +}); + test('wooden fish start run uses runtime guest json skeleton', async () => { const { woodenFishClient } = await import('./woodenFishClient'); requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } }); diff --git a/src/services/wooden-fish/woodenFishClient.ts b/src/services/wooden-fish/woodenFishClient.ts index 2a3832b5..0778ed08 100644 --- a/src/services/wooden-fish/woodenFishClient.ts +++ b/src/services/wooden-fish/woodenFishClient.ts @@ -17,7 +17,11 @@ import type { WoodenFishWorksResponse, WoodenFishWorkSummaryResponse, } from '../../../packages/shared/src/contracts/woodenFish'; -import { type ApiRetryOptions, requestJson } from '../apiClient'; +import { + type ApiRequestOptions, + type ApiRetryOptions, + requestJson, +} from '../apiClient'; import { createCreationAgentClient } from '../creation-agent'; import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; @@ -176,11 +180,19 @@ export function executeWoodenFishCreationAction( .then(normalizeWoodenFishActionResponse); } -export async function getWoodenFishWorkDetail(profileId: string) { +export async function getWoodenFishWorkDetail( + profileId: string, + options: ApiRequestOptions = { + retry: WOODEN_FISH_RUNTIME_READ_RETRY, + skipAuth: true, + skipRefresh: true, + }, +) { const response = await requestJson( `${WOODEN_FISH_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`, { method: 'GET' }, '读取敲木鱼作品详情失败', + options, ); return normalizeWoodenFishWorkDetailResponse(response); }