@@ -48,6 +48,8 @@ AUTH_REFRESH_COOKIE_SAME_SITE="Lax"
|
||||
AUTH_REFRESH_COOKIE_SECURE="false"
|
||||
# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。
|
||||
GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json"
|
||||
# 开发期便捷开关:true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。
|
||||
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false"
|
||||
|
||||
# 手机号验证码登录配置(阿里云 PNVS)。
|
||||
# 正式环境请改成你自己的 AccessKey 和短信签名/模板。
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# 后端重写任务清单入口
|
||||
|
||||
完整总纲与拆分后的任务文件已统一整理到根目录新建目录:
|
||||
|
||||
- [backend-rewrite-tasklist/README.md](./backend-rewrite-tasklist/README.md)
|
||||
|
||||
其中:
|
||||
|
||||
- 总纲主清单:[backend-rewrite-tasklist/00_MASTER_TASKLIST.md](./backend-rewrite-tasklist/00_MASTER_TASKLIST.md)
|
||||
- 阶段拆分文件入口:[backend-rewrite-tasklist/README.md](./backend-rewrite-tasklist/README.md)
|
||||
|
||||
后续如继续细化任务,请优先在该目录内维护,避免根目录散落多份版本。
|
||||
@@ -1,154 +0,0 @@
|
||||
# SpacetimeDB + Axum + 阿里云 OSS 后端重写任务总纲
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
关联设计文档:
|
||||
|
||||
- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
|
||||
- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md)
|
||||
- [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
|
||||
- [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
|
||||
|
||||
关联拆分任务:
|
||||
|
||||
- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md)
|
||||
- [02_M3_RUNTIME_PROFILE.md](./02_M3_RUNTIME_PROFILE.md)
|
||||
- [03_M4_STORY_AND_GAMEPLAY.md](./03_M4_STORY_AND_GAMEPLAY.md)
|
||||
- [04_M5_CUSTOM_WORLD_AND_AGENT.md](./04_M5_CUSTOM_WORLD_AND_AGENT.md)
|
||||
- [05_M6_ASSETS_OSS_EDITOR.md](./05_M6_ASSETS_OSS_EDITOR.md)
|
||||
- [06_M7_TEST_DEPLOY_CUTOVER.md](./06_M7_TEST_DEPLOY_CUTOVER.md)
|
||||
- [07_CROSS_CUTTING_AND_ACCEPTANCE.md](./07_CROSS_CUTTING_AND_ACCEPTANCE.md)
|
||||
|
||||
## 0. 使用说明
|
||||
|
||||
这份总纲用于把控整体重写节奏,拆分文件用于落地执行。
|
||||
|
||||
执行原则:
|
||||
|
||||
1. 第一阶段优先兼容当前 `/api/*`、`/healthz`、`/generated-*` 访问习惯。
|
||||
2. 不允许先删旧能力再补新能力,必须按能力面平移。
|
||||
3. 以当前 Node 后端 `96` 条路由、`6` 个挂载面、`12` 个模块为最低覆盖基线。
|
||||
4. 每个阶段完成后,都要形成可运行、可回归、可继续迭代的中间态。
|
||||
|
||||
## 1. 总体里程碑
|
||||
|
||||
- [x] `M0`:冻结当前后端能力清单与迁移边界
|
||||
- [ ] `M1`:搭建 Rust 工作区、Axum 主入口与基础中间件
|
||||
- [ ] `M2`:完成鉴权、会话、JWT、refresh cookie 主链迁移
|
||||
- [ ] `M3`:完成 runtime snapshot / settings / profile 迁移
|
||||
- [ ] `M4`:完成 story action 主循环与核心 gameplay reducer 迁移
|
||||
- [ ] `M5`:完成 custom world / agent 主链迁移
|
||||
- [ ] `M6`:完成 assets / OSS 主链迁移
|
||||
- [ ] `M7`:完成联调、回归、部署与切流准备
|
||||
|
||||
## 2. 阶段导航
|
||||
|
||||
### `M0 ~ M2`
|
||||
|
||||
重点:
|
||||
|
||||
1. 冻结能力清单
|
||||
2. 搭建 Rust workspace
|
||||
3. 搭建 Axum 基础设施
|
||||
4. 迁移鉴权、会话、JWT、refresh cookie
|
||||
|
||||
详见:
|
||||
|
||||
- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md)
|
||||
|
||||
### `M3`
|
||||
|
||||
重点:
|
||||
|
||||
1. 迁移 runtime snapshot
|
||||
2. 迁移 settings
|
||||
3. 迁移 profile dashboard / browse history / save archive
|
||||
|
||||
详见:
|
||||
|
||||
- [02_M3_RUNTIME_PROFILE.md](./02_M3_RUNTIME_PROFILE.md)
|
||||
|
||||
### `M4`
|
||||
|
||||
重点:
|
||||
|
||||
1. 迁移 RPG runtime story 主循环
|
||||
2. 迁移 RPG 入口 / session / runtime 对应的后端边界与编译职责
|
||||
3. 兼容当前 story view model 与 state 恢复接口,并与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory` 口径对齐
|
||||
|
||||
详见:
|
||||
|
||||
- [03_M4_STORY_AND_GAMEPLAY.md](./03_M4_STORY_AND_GAMEPLAY.md)
|
||||
|
||||
### `M5`
|
||||
|
||||
重点:
|
||||
|
||||
1. 迁移 RPG 创作主链:Agent session、result preview、published profile
|
||||
2. 迁移 works / library / gallery / publish / enter-world 配套链路
|
||||
3. 旧 `custom-world/sessions` 传统问答流只按历史兼容台账处理,不再作为当前主链扩展目标
|
||||
|
||||
详见:
|
||||
|
||||
- [04_M5_CUSTOM_WORLD_AND_AGENT.md](./04_M5_CUSTOM_WORLD_AND_AGENT.md)
|
||||
|
||||
### `M6`
|
||||
|
||||
重点:
|
||||
|
||||
1. 迁移 assets
|
||||
2. 接入阿里云 OSS
|
||||
3. 做旧静态资源路径兼容
|
||||
|
||||
详见:
|
||||
|
||||
- [05_M6_ASSETS_OSS_EDITOR.md](./05_M6_ASSETS_OSS_EDITOR.md)
|
||||
|
||||
### `M7`
|
||||
|
||||
重点:
|
||||
|
||||
1. 联调
|
||||
2. 回归
|
||||
3. 部署
|
||||
4. 观测
|
||||
5. 灰度切流
|
||||
6. 收口 `spacetime-module` 主工程结构,拆分过大的 `src/lib.rs`
|
||||
|
||||
详见:
|
||||
|
||||
- [06_M7_TEST_DEPLOY_CUTOVER.md](./06_M7_TEST_DEPLOY_CUTOVER.md)
|
||||
|
||||
## 3. 横向专项
|
||||
|
||||
以下专项贯穿整个迁移期:
|
||||
|
||||
1. contract 与前端兼容
|
||||
2. SpacetimeDB schema 演进治理
|
||||
3. 大对象与缓存治理
|
||||
4. 文档持续维护
|
||||
|
||||
详见:
|
||||
|
||||
- [07_CROSS_CUTTING_AND_ACCEPTANCE.md](./07_CROSS_CUTTING_AND_ACCEPTANCE.md)
|
||||
|
||||
## 4. 第一优先级建议执行顺序
|
||||
|
||||
1. 先做 `M0`,冻结基线,避免迁移过程中口径漂移。
|
||||
2. 再做 `M1 + M2`,先把 Axum 壳与鉴权打稳。
|
||||
3. 当前执行顺序允许前置 `M6` 的 OSS 基础设施与直传票据能力,为后续各阶段复用统一资产入口。
|
||||
4. 再做 `M3`,优先跑通快照、设置、profile。
|
||||
5. 再做 `M4`,把 story action 主循环真正迁走。
|
||||
6. 然后做 `M5`,迁 custom world 与 agent。
|
||||
7. 最后收口 `M6` 余下资产绑定、`M7` 部署与切流。
|
||||
|
||||
## 5. 最终验收清单
|
||||
|
||||
- [ ] 当前 `96` 条后端接口已全部迁移或有兼容替代
|
||||
- [ ] 当前 `6` 个挂载面已全部迁移
|
||||
- [ ] 当前 `12` 个内部模块已完成新架构落位
|
||||
- [ ] Axum 已成为唯一 HTTP / SSE / 副作用边界
|
||||
- [ ] SpacetimeDB 已成为唯一运行时状态真相源
|
||||
- [ ] 阿里云 OSS 已成为唯一资产对象仓
|
||||
- [ ] 前端主流程在不大改 UI 的前提下可跑通
|
||||
- [ ] 能完成灰度切流,并保留可回退能力
|
||||
@@ -1,266 +0,0 @@
|
||||
# M0 ~ M2:基础设施与鉴权任务清单
|
||||
|
||||
## M0:冻结能力与重写边界
|
||||
|
||||
### 能力冻结
|
||||
|
||||
- [x] 整理当前后端 6 个挂载面并锁定为重写验收基线
|
||||
交付物:[M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md](./M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md)
|
||||
- [x] 整理当前后端 96 条路由并生成一份“旧接口 -> 新实现”映射表
|
||||
交付物:[M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md](./M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md)
|
||||
- [x] 整理当前 12 个内部模块并锁定迁移归属
|
||||
交付物:[M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md)
|
||||
- [x] 整理当前所有 SSE 接口与事件格式
|
||||
交付物:[M0_SSE_INTERFACE_BASELINE_2026-04-20.md](./M0_SSE_INTERFACE_BASELINE_2026-04-20.md)
|
||||
- [x] 整理当前所有 `/generated-*` 静态资源前缀
|
||||
交付物:[M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md](./M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md)
|
||||
- [x] 整理当前前端直接依赖的响应头、envelope、错误格式
|
||||
交付物:[M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md](./M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md)
|
||||
|
||||
### 仓库边界
|
||||
|
||||
- [x] 确认 Rust 后端新目录名与根目录落位方案
|
||||
交付物:[M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md)
|
||||
- [x] 确认旧 `server-node/` 在迁移期继续保留,不提前删除
|
||||
交付物:[M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md)
|
||||
- [x] 确认前端第一阶段仍然只访问 Axum,不直连 SpacetimeDB
|
||||
交付物:[M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md)
|
||||
- [x] 确认外部副作用统一收口在 Axum,不放进 SpacetimeDB 模块
|
||||
交付物:[M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md)
|
||||
|
||||
### 交付物
|
||||
|
||||
- [x] 新增“接口映射表”文档
|
||||
交付物:[M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md](./M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md)
|
||||
- [x] 新增“模块迁移清单”文档
|
||||
交付物:[M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md)
|
||||
- [x] 新增“阶段验收矩阵”文档
|
||||
交付物:[M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](./M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md)
|
||||
|
||||
## M1:Rust 工作区与 Axum 基础设施
|
||||
|
||||
### 工作区搭建
|
||||
|
||||
- [x] 在根目录新增 `server-rs/`
|
||||
交付物:[../server-rs/README.md](../server-rs/README.md)
|
||||
- [x] 创建 workspace `Cargo.toml`
|
||||
交付物:[../server-rs/Cargo.toml](../server-rs/Cargo.toml)
|
||||
- [x] 创建 `crates/api-server`
|
||||
交付物:[../server-rs/crates/api-server/README.md](../server-rs/crates/api-server/README.md)
|
||||
- [x] 创建 `crates/spacetime-module`
|
||||
交付物:[../server-rs/crates/spacetime-module/README.md](../server-rs/crates/spacetime-module/README.md)
|
||||
- [x] 创建 `crates/module-auth`
|
||||
交付物:[../server-rs/crates/module-auth/README.md](../server-rs/crates/module-auth/README.md)
|
||||
- [x] 创建 `crates/module-runtime`
|
||||
交付物:[../server-rs/crates/module-runtime/README.md](../server-rs/crates/module-runtime/README.md)
|
||||
- [x] 创建 `crates/module-story`
|
||||
交付物:[../server-rs/crates/module-story/README.md](../server-rs/crates/module-story/README.md)
|
||||
- [x] 创建 `crates/module-combat`
|
||||
交付物:[../server-rs/crates/module-combat/README.md](../server-rs/crates/module-combat/README.md)
|
||||
- [x] 创建 `crates/module-inventory`
|
||||
交付物:[../server-rs/crates/module-inventory/README.md](../server-rs/crates/module-inventory/README.md)
|
||||
- [x] 创建 `crates/module-npc`
|
||||
交付物:[../server-rs/crates/module-npc/README.md](../server-rs/crates/module-npc/README.md)
|
||||
- [x] 创建 `crates/module-progression`
|
||||
交付物:[../server-rs/crates/module-progression/README.md](../server-rs/crates/module-progression/README.md)
|
||||
- [x] 创建 `crates/module-quest`
|
||||
交付物:[../server-rs/crates/module-quest/README.md](../server-rs/crates/module-quest/README.md)
|
||||
- [x] 创建 `crates/module-runtime-item`
|
||||
交付物:[../server-rs/crates/module-runtime-item/README.md](../server-rs/crates/module-runtime-item/README.md)
|
||||
- [x] 创建 `crates/module-custom-world`
|
||||
交付物:[../server-rs/crates/module-custom-world/README.md](../server-rs/crates/module-custom-world/README.md)
|
||||
- [x] 创建 `crates/module-assets`
|
||||
交付物:[../server-rs/crates/module-assets/README.md](../server-rs/crates/module-assets/README.md)
|
||||
- [x] 创建 `crates/module-ai`
|
||||
交付物:[../server-rs/crates/module-ai/README.md](../server-rs/crates/module-ai/README.md)
|
||||
- [x] 创建 `crates/shared-contracts`
|
||||
交付物:[../server-rs/crates/shared-contracts/README.md](../server-rs/crates/shared-contracts/README.md)
|
||||
- [x] 创建 `crates/shared-kernel`
|
||||
交付物:[../server-rs/crates/shared-kernel/README.md](../server-rs/crates/shared-kernel/README.md)
|
||||
- [x] 创建 `crates/shared-logging`
|
||||
交付物:[../server-rs/crates/shared-logging/README.md](../server-rs/crates/shared-logging/README.md)
|
||||
- [x] 创建 `crates/platform-auth`
|
||||
交付物:[../server-rs/crates/platform-auth/README.md](../server-rs/crates/platform-auth/README.md)
|
||||
- [x] 创建 `crates/platform-oss`
|
||||
交付物:[../server-rs/crates/platform-oss/README.md](../server-rs/crates/platform-oss/README.md)
|
||||
- [x] 创建 `crates/platform-llm`
|
||||
交付物:[../server-rs/crates/platform-llm/README.md](../server-rs/crates/platform-llm/README.md)
|
||||
- [x] 创建 `crates/spacetime-client`
|
||||
交付物:[../server-rs/crates/spacetime-client/README.md](../server-rs/crates/spacetime-client/README.md)
|
||||
- [x] 创建 `crates/tests-support`
|
||||
交付物:[../server-rs/crates/tests-support/README.md](../server-rs/crates/tests-support/README.md)
|
||||
|
||||
### Axum 基础能力
|
||||
|
||||
- [x] 搭建 `main.rs` / `Router` / `with_state`
|
||||
交付物:[../server-rs/crates/api-server/src/main.rs](../server-rs/crates/api-server/src/main.rs)
|
||||
- [x] 接入统一配置加载
|
||||
交付物:[../server-rs/crates/api-server/src/config.rs](../server-rs/crates/api-server/src/config.rs)
|
||||
- [x] 接入统一日志与 tracing
|
||||
交付物:[../docs/technical/RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md](../docs/technical/RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md)、[../server-rs/crates/shared-logging/src/lib.rs](../server-rs/crates/shared-logging/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/api-server/src/main.rs](../server-rs/crates/api-server/src/main.rs)
|
||||
- [x] 接入 `request_id` 中间件
|
||||
交付物:[../server-rs/crates/api-server/src/request_context.rs](../server-rs/crates/api-server/src/request_context.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 接入统一错误处理中间件
|
||||
交付物:[../server-rs/crates/api-server/src/http_error.rs](../server-rs/crates/api-server/src/http_error.rs)、[../server-rs/crates/api-server/src/error_middleware.rs](../server-rs/crates/api-server/src/error_middleware.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 接入当前项目兼容的 response envelope
|
||||
交付物:[../server-rs/crates/api-server/src/api_response.rs](../server-rs/crates/api-server/src/api_response.rs)、[../server-rs/crates/api-server/src/request_context.rs](../server-rs/crates/api-server/src/request_context.rs)、[../server-rs/crates/api-server/src/http_error.rs](../server-rs/crates/api-server/src/http_error.rs)
|
||||
- [x] 接入 `x-request-id`
|
||||
交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 接入 `x-api-version`
|
||||
交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)
|
||||
- [x] 接入 `x-route-version`
|
||||
交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)
|
||||
- [x] 接入 `x-response-time-ms`
|
||||
交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)、[../server-rs/crates/api-server/src/request_context.rs](../server-rs/crates/api-server/src/request_context.rs)
|
||||
- [x] 实现 `/healthz`
|
||||
交付物:[../server-rs/crates/api-server/src/health.rs](../server-rs/crates/api-server/src/health.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
|
||||
### 基础工程脚本
|
||||
|
||||
- [x] 新增本地开发脚本
|
||||
交付物:[../server-rs/scripts/dev.ps1](../server-rs/scripts/dev.ps1)、[../server-rs/scripts/dev.sh](../server-rs/scripts/dev.sh)
|
||||
- [x] 新增测试脚本
|
||||
交付物:[../server-rs/scripts/test.ps1](../server-rs/scripts/test.ps1)、[../server-rs/scripts/test.sh](../server-rs/scripts/test.sh)
|
||||
- [x] 新增 lint / fmt / clippy / check 脚本
|
||||
交付物:[../server-rs/scripts/check.ps1](../server-rs/scripts/check.ps1)、[../server-rs/scripts/check.sh](../server-rs/scripts/check.sh)
|
||||
- [x] 新增 smoke 脚本
|
||||
交付物:[../server-rs/scripts/smoke.ps1](../server-rs/scripts/smoke.ps1)、[../server-rs/scripts/smoke.sh](../server-rs/scripts/smoke.sh)
|
||||
- [x] 新增 SpacetimeDB 本地开发脚本
|
||||
交付物:[../server-rs/scripts/spacetime-dev.ps1](../server-rs/scripts/spacetime-dev.ps1)、[../server-rs/scripts/spacetime-dev.sh](../server-rs/scripts/spacetime-dev.sh)
|
||||
|
||||
### 阶段验收
|
||||
|
||||
- [x] Axum 服务可独立启动
|
||||
证据:`./server-rs/scripts/smoke.ps1` 已通过,覆盖临时启动 `api-server`、等待 `/healthz` 就绪并验证 raw / envelope 协议。
|
||||
- [x] `/healthz` 返回与当前工程兼容
|
||||
- [x] 基础 response envelope 与 request id 行为稳定
|
||||
证据:`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖 envelope 协商与 `/healthz` 头部回写。
|
||||
- [x] Rust workspace 能完整编译通过
|
||||
证据:`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 已通过。
|
||||
|
||||
## M2:鉴权、会话、JWT 与 refresh cookie
|
||||
|
||||
### SpacetimeDB 身份表
|
||||
|
||||
- [x] 设计 `user_account`
|
||||
交付物:[../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)
|
||||
- [x] 设计 `auth_identity`
|
||||
交付物:[../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
|
||||
- [x] 设计 `refresh_session`
|
||||
交付物:[../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)
|
||||
- [x] 设计 `auth_audit_log`
|
||||
交付物:[../docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md)
|
||||
- [x] 设计 `auth_risk_block`
|
||||
交付物:[../docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md)
|
||||
- [x] 设计 `sms_auth_event`
|
||||
交付物:[../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)
|
||||
- [x] 设计 `wechat_auth_state`
|
||||
交付物:[../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md)
|
||||
|
||||
### Axum 鉴权服务
|
||||
|
||||
- [x] 实现密码登录
|
||||
交付物:[../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现账号自动创建 / 幂等登录兼容策略
|
||||
交付物:[../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 Bearer JWT 校验
|
||||
交付物:[../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 refresh cookie 读取
|
||||
交付物:[../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/config.rs](../server-rs/crates/api-server/src/config.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 refresh token 轮换
|
||||
交付物:[../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现多端会话身份建模与会话列表查询
|
||||
交付物:[../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md)、[../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)、[../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/session_client.rs](../server-rs/crates/api-server/src/session_client.rs)、[../server-rs/crates/api-server/src/auth_sessions.rs](../server-rs/crates/api-server/src/auth_sessions.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../packages/shared/src/contracts/auth.ts](../packages/shared/src/contracts/auth.ts)
|
||||
- [x] 实现会话吊销
|
||||
交付物:[../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/logout.rs](../server-rs/crates/api-server/src/logout.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现全端登出
|
||||
交付物:[../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/logout_all.rs](../server-rs/crates/api-server/src/logout_all.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 `me` 查询
|
||||
交付物:[../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
|
||||
### 手机验证码登录
|
||||
|
||||
- [ ] 接入阿里云短信发送 adapter
|
||||
- [x] 实现发送验证码接口
|
||||
交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现验证码校验接口
|
||||
交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现手机号绑定
|
||||
交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)
|
||||
- [ ] 实现手机号换绑
|
||||
- [x] 实现发送频率限制
|
||||
交付物:[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现验证码失败次数限制
|
||||
交付物:[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 实现 captcha 触发逻辑
|
||||
- [ ] 实现风控封禁与解除
|
||||
|
||||
### 微信登录
|
||||
|
||||
- [x] 接入微信 OAuth adapter
|
||||
交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_provider.rs](../server-rs/crates/api-server/src/wechat_provider.rs)、[../server-rs/crates/api-server/src/state.rs](../server-rs/crates/api-server/src/state.rs)
|
||||
- [x] 实现 `wechat/start`
|
||||
交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 `wechat/callback`
|
||||
交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现微信身份绑定
|
||||
交付物:[../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)
|
||||
- [x] 实现微信账号补绑手机号
|
||||
交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现桌面端 / 微信内打开场景区分
|
||||
交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/session_client.rs](../server-rs/crates/api-server/src/session_client.rs)
|
||||
|
||||
### OIDC 与 SpacetimeDB 身份透传
|
||||
|
||||
- [x] 设计 JWT claims
|
||||
交付物:[../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
|
||||
- [x] 确认 `iss/sub/sid/provider/roles` 字段
|
||||
交付物:[../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
|
||||
- [x] 让 Axum 自身可校验 JWT
|
||||
交付物:[../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/README.md](../server-rs/crates/platform-auth/README.md)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)
|
||||
- [ ] 让 SpacetimeDB 可识别 Axum 签发的身份令牌
|
||||
- [ ] 验证 reducer / view 可读取用户身份上下文
|
||||
|
||||
### 当前接口兼容
|
||||
|
||||
- [x] 兼容 `/api/auth/login-options`
|
||||
交付物:[../docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/login_options.rs](../server-rs/crates/api-server/src/login_options.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 兼容 `/api/auth/entry`
|
||||
交付物:[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 兼容 `/api/auth/me`
|
||||
交付物:[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 兼容 `/api/auth/logout`
|
||||
交付物:[../server-rs/crates/api-server/src/logout.rs](../server-rs/crates/api-server/src/logout.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 兼容 `/api/auth/logout-all`
|
||||
交付物:[../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/logout_all.rs](../server-rs/crates/api-server/src/logout_all.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)
|
||||
- [x] 兼容 `/api/auth/refresh`
|
||||
交付物:[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 兼容 `/api/auth/sessions`
|
||||
交付物:[../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/auth_sessions.rs](../server-rs/crates/api-server/src/auth_sessions.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)
|
||||
- [ ] 兼容 `/api/auth/sessions/:sessionId/revoke`
|
||||
- [ ] 兼容 `/api/auth/audit-logs`
|
||||
- [ ] 兼容 `/api/auth/risk-blocks`
|
||||
- [ ] 兼容 `/api/auth/risk-blocks/:scopeType/lift`
|
||||
- [x] 兼容 `/api/auth/phone/send-code`
|
||||
交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)
|
||||
- [x] 兼容 `/api/auth/phone/login`
|
||||
交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)
|
||||
- [ ] 兼容 `/api/auth/phone/change`
|
||||
- [x] 兼容 `/api/auth/wechat/start`
|
||||
交付物:[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../src/services/authService.ts](../src/services/authService.ts)
|
||||
- [x] 兼容 `/api/auth/wechat/callback`
|
||||
交付物:[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../src/services/authService.ts](../src/services/authService.ts)
|
||||
- [x] 兼容 `/api/auth/wechat/bind-phone`
|
||||
交付物:[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../src/services/authService.ts](../src/services/authService.ts)
|
||||
|
||||
### 阶段验收
|
||||
|
||||
- [x] 密码登录主链可用
|
||||
证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖自动建号、重复登录复用、错密码 `401`、非法用户名 `400` 与 refresh cookie 写回。
|
||||
- [x] refresh cookie 主链可用
|
||||
证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖 refresh 成功轮换、旧 token 失效、缺少 cookie `401` 与失败时清理 cookie。
|
||||
- [x] 手机验证码主链可用
|
||||
证据:`cargo test -p module-auth phone --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server phone --manifest-path server-rs/Cargo.toml -- --nocapture` 已通过,覆盖发送验证码、同场景冷却 `429`、验证码错误次数耗尽 `429`、重新发送后恢复登录,以及手机号登录建号/复用与 refresh cookie 写回。
|
||||
- [x] 微信登录主链可用
|
||||
证据:`cargo test -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth --manifest-path server-rs/Cargo.toml` 已通过,覆盖 `wechat/start`、`wechat/callback`、待绑定会话签发、手机号补绑并入已有账号,以及 `unionid` 命中后新 `openid` 映射回写。
|
||||
- [ ] 所有旧鉴权接口可通过 contract 回归
|
||||
@@ -1,69 +0,0 @@
|
||||
# M3:runtime snapshot / settings / profile 任务清单
|
||||
|
||||
## 1. SpacetimeDB 运行时主表
|
||||
|
||||
- [x] 设计 `runtime_snapshot`
|
||||
- [x] 设计 `runtime_setting`
|
||||
- [x] 设计 `profile_dashboard_state`
|
||||
- [x] 设计 `profile_wallet_ledger`
|
||||
- [x] 设计 `profile_played_world`
|
||||
- [x] 设计 `profile_save_archive`
|
||||
- [x] 设计 `user_browse_history`
|
||||
|
||||
## 2. 兼容快照策略
|
||||
|
||||
- [x] 设计“领域表真相 + 兼容聚合快照”策略
|
||||
- [x] 设计 snapshot projection 刷新机制
|
||||
- [x] 迁移当前 snapshot hydration / normalize 规则
|
||||
- [x] 迁移当前 save archive 聚合逻辑
|
||||
- [x] 迁移当前 browse history 去重与排序逻辑
|
||||
|
||||
## 3. Axum facade
|
||||
|
||||
- [x] 兼容 `GET /api/runtime/save/snapshot`
|
||||
- [x] 兼容 `PUT /api/runtime/save/snapshot`
|
||||
- [x] 兼容 `DELETE /api/runtime/save/snapshot`
|
||||
- [x] 兼容 `GET /api/runtime/settings`
|
||||
- [x] 兼容 `PUT /api/runtime/settings`
|
||||
- [x] 兼容 `GET /api/runtime/profile/dashboard`
|
||||
- [x] 兼容 `GET /api/profile/dashboard`
|
||||
- [x] 兼容 `GET /api/runtime/profile/wallet-ledger`
|
||||
- [x] 兼容 `GET /api/profile/wallet-ledger`
|
||||
- [x] 兼容 `GET /api/runtime/profile/play-stats`
|
||||
- [x] 兼容 `GET /api/profile/play-stats`
|
||||
- [x] 兼容 `GET /api/runtime/profile/save-archives`
|
||||
- [x] 兼容 `GET /api/profile/save-archives`
|
||||
- [x] 兼容 `POST /api/runtime/profile/save-archives/:worldKey`
|
||||
- [x] 兼容 `POST /api/profile/save-archives/:worldKey`
|
||||
- [x] 兼容 `GET /api/runtime/profile/browse-history`
|
||||
- [x] 兼容 `POST /api/runtime/profile/browse-history`
|
||||
- [x] 兼容 `DELETE /api/runtime/profile/browse-history`
|
||||
- [x] 兼容 `GET /api/profile/browse-history`
|
||||
- [x] 兼容 `POST /api/profile/browse-history`
|
||||
- [x] 兼容 `DELETE /api/profile/browse-history`
|
||||
|
||||
## 4. 阶段验收
|
||||
|
||||
- [ ] 登录用户可正常保存、读取、删除存档
|
||||
- [x] 兼容路径与主路径返回一致
|
||||
- [x] profile dashboard / browse history / save archive 行为一致
|
||||
- [ ] 前端当前恢复流程可在不改 UI 的前提下跑通
|
||||
|
||||
## 5. 本轮进展记录
|
||||
|
||||
- `2026-04-21`:已完成 `runtime_setting` 首版设计与 `GET/PUT /api/runtime/settings` 的 Rust 主链迁移。
|
||||
- 本轮已落地 `module-runtime`、`spacetime-module`、`spacetime-client`、`api-server` 四层串联,并补齐定向测试。
|
||||
- 详细设计与字段冻结见:
|
||||
- [../docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](../docs/technical/M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
|
||||
- `2026-04-22`:已完成 `user_browse_history` 表设计冻结、去重与排序规则迁移,以及 `/api/runtime/profile/browse-history` 与 `/api/profile/browse-history` 双路径 facade 落地。
|
||||
- `2026-04-22`:已补 `browse history` 的 API 入口必填字段校验、批量 shape 兼容与定向测试,详细设计见:
|
||||
- [../docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](../docs/technical/M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
|
||||
- `2026-04-22`:已冻结 `profile_dashboard_state`、`profile_wallet_ledger`、`profile_played_world` 三张 projection 表,以及 `dashboard / wallet-ledger / play-stats` 的 Axum + SpacetimeDB 读链设计。
|
||||
- `2026-04-22`:已完成 `api-server` 的 `runtime_profile` facade 编译与定向测试收口,`/api/runtime/profile/*` 与 `/api/profile/*` 六条只读路由均已接通。
|
||||
- `2026-04-22`:已通过 `cargo check -p api-server --tests --message-format short`、`cargo test -p shared-contracts --lib`、`cargo test -p api-server runtime_profile::tests:: -- --nocapture` 验证本轮 profile projection 读链。
|
||||
- 详细设计见:
|
||||
- [../docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md](../docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md)
|
||||
- `2026-04-22`:已完成 `runtime_snapshot`、`profile_save_archive` 与“领域表真相 + 兼容聚合快照”方案落地,接通 `/api/runtime/save/snapshot`、`/api/runtime/profile/save-archives`、`/api/profile/save-archives` 与恢复存档双路径 facade。
|
||||
- `2026-04-22`:已通过 `cargo test -p shared-kernel --lib`、`cargo test -p module-runtime --lib`、`cargo check -p spacetime-module --message-format short`、`cargo build -p spacetime-module --target wasm32-unknown-unknown --release --message-format short`、`cargo check -p spacetime-client --message-format short`、`cargo check -p api-server --tests --message-format short`、`cargo test -p api-server runtime_save::tests:: -- --nocapture` 验证 snapshot/save archive 主链编译与 facade。
|
||||
- 详细设计见:
|
||||
- [../docs/technical/M3_RUNTIME_SNAPSHOT_SAVE_ARCHIVE_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md](../docs/technical/M3_RUNTIME_SNAPSHOT_SAVE_ARCHIVE_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md)
|
||||
@@ -1,318 +0,0 @@
|
||||
# M4:story action 与 gameplay reducer 任务清单
|
||||
|
||||
## 0. 当前执行基线
|
||||
|
||||
本阶段与当前仓库里的 RPG 入口与运行时主链重构直接对应,统一以以下文档为准:
|
||||
|
||||
1. [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
|
||||
2. [../docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](../docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md)
|
||||
3. [../docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](../docs/technical/M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md)
|
||||
|
||||
当前任务清单只维护 Axum / SpacetimeDB 重写侧的后端迁移项,不再把旧 `GameShell / runtimeRoutes.ts / storyActionService.ts` 命名视为新架构目标。
|
||||
|
||||
### 当前进展(`2026-04-22`)
|
||||
|
||||
本阶段首轮已先把 `server-rs` 从“只有 `module-story` 占位目录”推进到“SpacetimeDB 侧 story 会话基座真实可编译”:
|
||||
|
||||
1. 已新增 `server-rs/crates/module-story` 真实 crate。
|
||||
2. 已冻结 `story_session / story_event` 的首版领域类型、状态枚举和字段校验 helper。
|
||||
3. 已在 `server-rs/crates/spacetime-module` 中新增 `story_session`、`story_event` 两张表。
|
||||
4. 已新增 `begin_story_session`、`continue_story` 两个 reducer,形成最小会话事件链。
|
||||
5. 已新增 `begin_story_session_and_return`、`continue_story_and_return` 两个 procedure,形成可同步返回快照的最小 story session contract。
|
||||
6. 已重新执行 `spacetime generate`,把 `story_session / story_event` Rust bindings 刷入 `spacetime-client/src/module_bindings`。
|
||||
7. 已在 `server-rs/crates/spacetime-client` 中新增 `begin_story_session(...)`、`continue_story(...)` facade。
|
||||
8. 已在 `server-rs/crates/api-server` 中新增:
|
||||
- `POST /api/story/sessions`
|
||||
- `POST /api/story/sessions/continue`
|
||||
9. 已执行 `cargo check -p module-story -p spacetime-module -p spacetime-client -p api-server` 并通过。
|
||||
6. 已新增 `docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `battle_state` 与 `resolve_combat_action` 的首版字段与规则口径。
|
||||
7. 已新增 `server-rs/crates/module-runtime-item` 真实 crate。
|
||||
8. 已冻结 runtime item 侧奖励快照与物品写回基线,为后续奖励链并入 inventory / quest / combat 提供统一底层能力。
|
||||
9. 已在 `server-rs/crates/spacetime-module` 中补齐 runtime item / inventory / quest / combat 所需的奖励落表与回写依赖。
|
||||
10. 当前 M4 runtime story compat bridge 已明确移除旧 `treasure_*` 遭遇动作概念,不再把宝箱遭遇视作本阶段 runtime story 主链目标。
|
||||
11. 已新增 `docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `inventory_slot` 与 `apply_inventory_mutation` 的首版字段与规则口径。
|
||||
12. 已新增 `server-rs/crates/module-inventory` 真实 crate。
|
||||
13. 已在 `server-rs/crates/spacetime-module` 中新增 `inventory_slot` 表。
|
||||
14. 已新增 `apply_inventory_mutation` reducer,形成最小背包主链。
|
||||
15. 已新增 `docs/technical/M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `npc_state`、`resolve_npc_social_action` 与 `resolve_npc_interaction` 的首版字段与交互口径。
|
||||
16. 已新增 `server-rs/crates/module-npc` 真实 crate。
|
||||
17. 已在 `server-rs/crates/spacetime-module` 中新增 `npc_state` 表。
|
||||
18. 已新增 `upsert_npc_state`、`resolve_npc_social_action`、`resolve_npc_interaction` 及对应 procedure。
|
||||
19. 已新增 `docs/technical/M4_MODULE_NPC_COMBAT_ORCHESTRATION_BASELINE_2026-04-21.md`,冻结 `npc_fight / npc_spar` 到 `battle_state` 的最小联合编排口径。
|
||||
20. 已在 `server-rs/crates/spacetime-module` 中新增 `resolve_npc_battle_interaction_and_return` procedure,把 NPC 开战交互与 battle 初始化写入串到同一事务。
|
||||
15. 已新增 `docs/technical/M4_MODULE_PROGRESSION_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `player_progression / chapter_progression` 的首版字段、成长曲线与章节预算口径。
|
||||
16. 已新增 `server-rs/crates/module-progression` 真实 crate。
|
||||
17. 已在 `server-rs/crates/spacetime-module` 中新增 `player_progression`、`chapter_progression` 两张表。
|
||||
18. 已新增 `get_player_progression_or_default`、`grant_player_progression_experience`、`upsert_chapter_progression`、`apply_chapter_progression_ledger_entry` 及对应 procedure。
|
||||
19. 已新增 `docs/technical/M4_RPG_RUNTIME_QUEST_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `quest_record / quest_log / apply_quest_signal` 的首版字段、日志口径与交付状态流转规则。
|
||||
20. 已新增 `server-rs/crates/module-quest` 真实 crate。
|
||||
21. 已在 `server-rs/crates/spacetime-module` 中新增 `quest_record`、`quest_log` 两张表。
|
||||
22. 已新增 `accept_quest`、`apply_quest_signal`、`acknowledge_quest_completion`、`turn_in_quest` reducer,形成最小任务闭环。
|
||||
23. 已执行 `cargo test -p module-quest`、`cargo check -p spacetime-module`、`cargo check -p api-server` 与全量 `cargo check` 并通过。
|
||||
24. 已新增 `docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md`,冻结任务交付与战斗胜利到成长系统的联动口径。
|
||||
25. 已把 `turn_in_quest` 接到 `player_progression / chapter_progression` 的最小经验写入。
|
||||
26. 已把 `resolve_combat_action(Victory)` 接到 `player_progression / chapter_progression` 的最小经验写入。
|
||||
27. 已把 `turn_in_quest.reward.items` 接到 `inventory_slot` 发物链,形成任务交付的最小物品奖励闭环。
|
||||
28. 已新增 `docs/technical/M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md`,冻结最小 `story state` 查询切片,只开放 `storySession + storyEvents` 真相态查询。
|
||||
29. 已在 `server-rs/crates/api-server` 中挂出 `GET /api/story/sessions/:storySessionId/state`,通过 `spacetime-client.get_story_session_state(...)` 读取 `SpacetimeDB procedure` 返回的会话快照与事件流。
|
||||
30. 已新增 `docs/technical/M4_COMBAT_REWARD_INVENTORY_INTEGRATION_2026-04-22.md`,冻结 `battle_state.reward_items` 与 `resolve_combat_action(Victory)` 发物到 `inventory_slot` 的最小联动口径。
|
||||
31. 已新增 `docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md`,冻结最小 `battle state` 查询切片,只开放单个 `battleState` 真相态查询。
|
||||
32. 已在 `server-rs/crates/spacetime-module` 中新增 `get_battle_state` procedure,按 `battle_state_id` 返回当前战斗快照。
|
||||
33. 已在 `server-rs/crates/spacetime-client` 中新增 `get_battle_state(...)` facade,供 Axum 同步读取 battle 真相态。
|
||||
34. 已在 `server-rs/crates/api-server` 中挂出 `GET /api/story/battles/:battleStateId`,通过 `spacetime-client.get_battle_state(...)` 返回单战斗快照。
|
||||
35. 已在 `server-rs/crates/spacetime-client` 中新增 `resolve_npc_battle_interaction(...)` facade,把 `resolve_npc_battle_interaction_and_return` procedure 映射为稳定 Rust record,供 Axum 直接消费。
|
||||
36. 已在 `server-rs/crates/api-server` 中挂出 `POST /api/story/npc/battle`,当前只接受 `npc_fight / npc_spar`,同步返回 `npcInteraction + battleState`。
|
||||
37. 已执行 `cargo check -p spacetime-client -p api-server` 并通过,完成 `module-npc -> spacetime-client -> api-server` 的最小 NPC 开战同步返回链闭环。
|
||||
38. 已重新执行 `spacetime generate --no-config --lang rust --out-dir D:\\Genarrative\\server-rs\\crates\\spacetime-client\\src\\module_bindings --module-path D:\\Genarrative\\server-rs\\crates\\spacetime-module --include-private --yes`,把 `get_battle_state`、`battle_state.reward_items` 与 `custom_world_agent_session` 相关 bindings 刷入 `spacetime-client/src/module_bindings`。
|
||||
39. 已把 `server-rs/crates/spacetime-client/src/lib.rs` 中原本占位返回错误的 `get_battle_state(...)` 改成真实 procedure 调用,当前 battle query 已不再停留在 facade stub。
|
||||
40. 已再次执行 `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 与 `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`,当前 battle/story 新链路在编译层已恢复通过。
|
||||
41. 已新增 `docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md`,冻结旧 `POST /api/runtime/story/state/resolve` 的首版兼容桥边界,明确当前先做 DTO 与状态桥,不提前误宣称 `actions/resolve` 已可迁移。
|
||||
42. 已在 `server-rs/crates/shared-contracts` 中新增 `runtime_story` 模块,冻结 `RuntimeStoryStateResolveRequest`、`RuntimeStoryActionResponse` 以及 `viewModel / presentation / patches / snapshot` 的首版 camelCase DTO,与当前前端消费口径对齐。
|
||||
43. 已恢复并重建 `server-rs/crates/api-server/src/runtime_story.rs`,把上一轮误删留下的中间态收口回可编译实现。
|
||||
44. 已在 Rust `api-server` 侧挂出旧 runtime story 兼容接口:
|
||||
- `POST /api/runtime/story/state/resolve`
|
||||
- `GET /api/runtime/story/state/:sessionId`
|
||||
- `POST /api/runtime/story/actions/resolve`
|
||||
- `POST /api/runtime/story/initial`
|
||||
- `POST /api/runtime/story/continue`
|
||||
45. `state/resolve` 与 `actions/resolve` 已统一复用 `runtime_save` 的 SpacetimeDB 快照持久化链:
|
||||
- 请求带 `snapshot` 时先写入 `runtime_snapshot`
|
||||
- 请求不带 `snapshot` 时从持久化 `runtime_snapshot` 读取
|
||||
- 无可用快照时返回 `409`
|
||||
46. `actions/resolve` 已补齐当前前端主链需要的确定性兼容动作闭环,覆盖:
|
||||
- `story_continue_adventure`
|
||||
- `story_opening_camp_dialogue`
|
||||
- `camp_travel_home_scene`
|
||||
- `idle_call_out`
|
||||
- `idle_explore_forward`
|
||||
- `idle_observe_signs`
|
||||
- `idle_rest_focus`
|
||||
- `idle_travel_next_scene`
|
||||
- `npc_preview_talk`
|
||||
- `npc_chat`
|
||||
- `npc_help`
|
||||
- `npc_leave`
|
||||
- `npc_fight`
|
||||
- `npc_spar`
|
||||
- `npc_recruit`
|
||||
- `battle_attack_basic`
|
||||
- `battle_use_skill`
|
||||
- `battle_all_in_crush`
|
||||
- `battle_escape_breakout`
|
||||
- `battle_feint_step`
|
||||
- `battle_finisher_window`
|
||||
- `battle_guard_break`
|
||||
- `battle_probe_pressure`
|
||||
- `battle_recover_breath`
|
||||
- `inventory_use`
|
||||
- `equipment_equip`
|
||||
- `npc_trade`
|
||||
- `npc_gift`
|
||||
47. `actions/resolve` 已补 `clientVersion` 与 `gameState.runtimeActionVersion` 的冲突校验、动作后版本自增、`storyHistory` 追加和 snapshot 回写。
|
||||
48. `initial` / `continue` 已先落稳定 `RuntimeStoryAiResponse`:
|
||||
- 优先透传 `requestOptions.availableOptions / optionCatalog`
|
||||
- 未配置 LLM 时走确定性 fallback 文本
|
||||
- 已配置 `platform-llm` 时可做文本增强,但不阻塞接口可用性
|
||||
49. `actions/resolve` 已开始迁移 Node 动作后 LLM 增强分支的最小闭环:
|
||||
- `npc_chat / story_opening_camp_dialogue` 在配置 `platform-llm` 时会尝试生成对话态 `storyText`
|
||||
- NPC 对话增强回包会对齐 Node 旧 `displayMode = dialogue + deferredOptions` 结构,先只展示“继续推进冒险”
|
||||
- `battle victory / spar_complete / escaped` 在配置 `platform-llm` 时会尝试生成结果叙事,但不改既有规则结算
|
||||
- LLM 不可用或生成失败时自动回退到确定性 `resultText / currentStory`
|
||||
50. 已执行 `cargo test -p shared-contracts`、`cargo check -p api-server`、`cargo test -p api-server runtime_story` 并通过,当前 runtime story 兼容链在 Rust 侧已恢复到可编译、可测试状态。
|
||||
51. 已补 Rust 侧 route boundary 回归:
|
||||
- `runtime_story_routes_resolve_through_rust_route_boundary`
|
||||
- `runtime_story_action_resolve_rejects_client_version_conflict`
|
||||
- `runtime_story_npc_help_is_one_shot_and_restores_resources`
|
||||
- `runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full`
|
||||
52. 已把兼容桥里的关键 NPC 行为继续对齐到 Node 旧主链:
|
||||
- `npc_chat` 好感增长改为 `max(2, 6 - chattedCount)`,首聊可从 `46 -> 52`
|
||||
- `npc_help` 改为一次性援手,成功时恢复 `10 HP / 8 Mana` 且关系 `+4`
|
||||
- `npc_recruit` 改为要求 `affinity >= 60`,队伍满员时必须透传 `releaseNpcId`
|
||||
53. 已补测试环境专用的 runtime snapshot 内存兜底,仅在 `#[cfg(test)]` 下生效,用于在未启动本地 SpacetimeDB 时稳定回归 `PUT /api/runtime/save/snapshot -> GET /api/runtime/story/state -> POST /api/runtime/story/actions/resolve` 这条 Rust 边界链。
|
||||
54. 已把 quest compat 主循环补到 Rust `runtime story` 兼容桥:
|
||||
- `npc_chat_quest_offer_view`
|
||||
- `npc_chat_quest_offer_replace`
|
||||
- `npc_chat_quest_offer_abandon`
|
||||
- `npc_quest_accept`
|
||||
- `npc_quest_turn_in`
|
||||
55. 已把 quest offer 对话态的 `currentStory.npcChatState.pendingQuestOffer` 与前端面板依赖的 `runtimePayload.npcChatQuestOfferAction` 一并回填到 Rust compat 回包,保证现有 quest 面板入口不回退。
|
||||
56. 已把 `npc_quest_turn_in` 的最小奖励闭环补回 Rust compat handler:
|
||||
- quest 状态改为保留在 `gameState.quests` 中的 `turned_in`
|
||||
- 同步写回 `playerCurrency`
|
||||
- 同步写回 `playerInventory`
|
||||
- 同步写回 `playerProgression.totalXp / level / xpToNextLevel / lastGrantedSource`
|
||||
- 同步写回 NPC `affinity`
|
||||
57. 已新增 quest compat Rust 回归:
|
||||
- `runtime_story_quest_offer_replace_updates_pending_offer_and_payload`
|
||||
- `runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options`
|
||||
- `runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story`
|
||||
- `runtime_story_quest_turn_in_marks_quest_rewards_and_affinity`
|
||||
58. 已再次执行 `cargo test -p api-server runtime_story`、`cargo check -p api-server` 与 `node scripts/check-encoding.mjs` 并通过,当前 quest compat 已恢复到可编译、可回归状态。
|
||||
59. 已继续把 Task6 旧 inventory / NPC inventory compat 主链补回 Rust `runtime story` 兼容桥:
|
||||
- `equipment_equip`
|
||||
- `equipment_unequip`
|
||||
- `forge_craft`
|
||||
- `forge_dismantle`
|
||||
- `forge_reforge`
|
||||
- `npc_trade`
|
||||
- `npc_gift`
|
||||
60. 已把 NPC 交互态 fallback option compiler 对齐到 Node 旧顺序,当前会按条件输出:
|
||||
- `npc_chat`
|
||||
- `npc_help`
|
||||
- `npc_spar`
|
||||
- `npc_fight`
|
||||
- `npc_trade`
|
||||
- `npc_gift`
|
||||
- `npc_quest_accept / npc_quest_turn_in`
|
||||
- `npc_recruit`
|
||||
- `npc_leave`
|
||||
61. 已新增 Rust compat 回归:
|
||||
- `runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_help_lock`
|
||||
- `runtime_story_equipment_equip_updates_loadout_and_build_toast`
|
||||
- `runtime_story_equipment_unequip_returns_item_to_inventory_and_resets_loadout`
|
||||
- `runtime_story_forge_craft_consumes_materials_and_currency`
|
||||
- `runtime_story_forge_dismantle_replaces_item_with_material_outputs`
|
||||
- `runtime_story_forge_reforge_upgrades_item_and_consumes_cost`
|
||||
- `runtime_story_npc_trade_buy_updates_currency_inventory_and_stock`
|
||||
- `runtime_story_state_compiler_bootstraps_trade_inventory_for_role_npc`
|
||||
- `runtime_story_npc_trade_buy_bootstraps_missing_npc_state`
|
||||
- `runtime_story_npc_gift_updates_affinity_inventory_and_patch`
|
||||
- `runtime_story_route_boundary_persists_equipment_equip_snapshot_updates`
|
||||
62. 当前 Rust compat bridge 已补入口级 NPC 状态预处理:即使快照里的 `npcStates` 为空,纯商贩型 NPC 也会在 `state/get` 与 `actions/resolve` 前自动初始化基础关系态、`stanceProfile / relationState / tradeStockSignature` 与最小 trade stock。
|
||||
63. 当前 `actions/resolve` 已不再只停留在确定性 `storyText = resultText`:
|
||||
- 已在 Rust 侧新增 `generate_action_story_payload(...)`
|
||||
- 已对齐 Node 旧分支的最小范围 `npc_chat / story_opening_camp_dialogue / terminal combat outcome`
|
||||
- 当前仍未迁移 Node 那套完整 orchestrator 选项重排,只先保留既有 fallback options
|
||||
64. 当前 `cargo test -p api-server runtime_story` 已提升到 30 条回归通过。
|
||||
65. 已继续把 runtime story compat 的 battle 展示编译从 `api-server` 抽到独立 crate:
|
||||
- `module-runtime-story-compat` 当前已承接 `build_battle_runtime_story_options(...)`、`restore_player_resource(...)` 与战斗技能 / 推荐物品 option compiler
|
||||
- `api-server/src/runtime_story/compat/battle.rs` 已删除
|
||||
- `presentation.rs` 与 `npc_actions.rs` 当前统一直接复用 crate 导出的 battle helper
|
||||
66. 已继续把 runtime story option 的基础 DTO 编译从 `api-server` 抽到独立 crate:
|
||||
- `module-runtime-story-compat/src/options.rs` 当前已承接 `build_static_runtime_story_option(...)`、`build_disabled_runtime_story_option(...)`、`build_runtime_story_option_from_story_option(...)`、`build_story_option_from_runtime_option(...)`
|
||||
- `api-server/src/runtime_story/compat/presentation.rs` 已删除这批本地重复实现,当前只保留更贴近 NPC / quest / view-model 组装的逻辑
|
||||
67. 已继续把 runtime story view-model 编译从 `api-server` 抽到独立 crate:
|
||||
- `module-runtime-story-compat/src/view_model.rs` 当前已承接 `build_runtime_story_view_model(...)`、`build_runtime_story_encounter(...)`、`build_runtime_story_companions(...)`
|
||||
- `resolve_current_encounter_npc_state(...)` 已统一由 crate 导出,`api-server` 的 `presentation.rs` 与 `game_state.rs` 不再保留本地副本
|
||||
68. 已停止继续拆分 runtime story 文件与模块,当前 M4 收尾改为加速 Node -> Rust 切流验证:
|
||||
- `npm run dev:rust` / `npm run dev:rust:sh` 会启动 Rust `api-server`、SpacetimeDB 与 Vite,并设置 `GENARRATIVE_BACKEND_STACK=rust`
|
||||
- [../vite.config.ts](../vite.config.ts) 已补 `/api/story` 代理,Rust 栈下 `/api/runtime/*` 与 `/api/story/*` 均会走 `GENARRATIVE_RUNTIME_SERVER_TARGET`
|
||||
- 当前 M4 的切流目标以“旧 runtime story 兼容接口 + 新 story/battle 查询切片可由 Rust 承接”为准,不再把继续拆 crate 作为本阶段阻塞项
|
||||
|
||||
当前验证边界补充:
|
||||
|
||||
1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时仍然较长,还没有把更大范围的 story/battle 回归全部收拢到单次时窗内。
|
||||
2. `node scripts/check-encoding.mjs` 已再次执行并通过,当前本轮涉及的中文文件编码未被写坏。
|
||||
3. 当前可以确认的是:
|
||||
- `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通
|
||||
- Rust `runtime story` compat route boundary 与关键 NPC 主循环规则已有回归覆盖
|
||||
- Rust `actions/resolve` 已开始承接 Node 动作后 LLM 文本增强,但完整 orchestrator / 真相链仍未完成
|
||||
|
||||
当前这轮不再继续扩 `runtime_story` 模块拆分。`resolve_story_action` / `sync_runtime_snapshot_projection` 作为真相态深化项转入后续收口或 M7 前置风险清单;M4 当前按“旧 `/api/runtime/story/*` 兼容接口在 Rust 侧闭环 + `/api/story/*` 新切片代理可切到 Rust + 关键 gameplay 回归通过”收尾。
|
||||
|
||||
## 1. SpacetimeDB gameplay 表
|
||||
|
||||
- [x] 设计 `story_session`
|
||||
- [x] 设计 `story_event`
|
||||
- [x] 设计 `npc_state`
|
||||
- [x] 设计 `quest_record`
|
||||
- [x] 设计 `inventory_slot`
|
||||
- [x] 设计 runtime item 奖励快照基线
|
||||
- [x] 设计 `battle_state`
|
||||
- [x] 设计 `player_progression`
|
||||
- [x] 设计 `chapter_progression`
|
||||
|
||||
## 2. 核心 reducer
|
||||
|
||||
- [ ] 设计 `resolve_story_action`(转入真相态深化,不阻塞 M4 兼容切流收尾)
|
||||
- [x] 设计 `continue_story`
|
||||
- [x] 设计 `begin_story_session`
|
||||
- [ ] 设计 `sync_runtime_snapshot_projection`(转入真相态深化,不阻塞 M4 兼容切流收尾)
|
||||
- [x] 设计 `apply_quest_signal`
|
||||
- [x] 设计 `apply_inventory_mutation`
|
||||
- [x] 设计 `resolve_npc_interaction`
|
||||
- [x] 设计 runtime item 奖励回写基线
|
||||
- [x] 设计 `resolve_combat_action`
|
||||
- [x] 设计 `update_progression_state`
|
||||
|
||||
## 3. 当前主链模块落位
|
||||
|
||||
- [ ] 迁移 `rpg-entry` 配套后端入口能力
|
||||
- [ ] 迁移 `rpg-profile` 资料域
|
||||
- [x] 迁移 `rpg-runtime-story`
|
||||
- [x] 迁移 `combat`
|
||||
- [ ] 迁移 `inventory`
|
||||
- [ ] 迁移 `npc`
|
||||
- [x] 迁移 `progression`
|
||||
- [x] 迁移 `quest`
|
||||
- [x] 迁移 `runtime-item`
|
||||
- [x] 迁移 runtime snapshot 归一化、view model compiler 与状态同步规则
|
||||
|
||||
## 4. 兼容接口
|
||||
|
||||
- [x] 兼容 `POST /api/runtime/story/actions/resolve`
|
||||
- [x] 兼容 `GET /api/runtime/story/state/:sessionId`
|
||||
- [x] 兼容 `POST /api/runtime/story/state/resolve`
|
||||
- [x] 兼容 `POST /api/runtime/story/initial`
|
||||
- [x] 兼容 `POST /api/runtime/story/continue`
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 当前已落地的是两类 Rust facade:
|
||||
- 新真相态接口:
|
||||
- `POST /api/story/sessions`
|
||||
- `POST /api/story/sessions/continue`
|
||||
- `GET /api/story/sessions/:storySessionId/state`
|
||||
- `GET /api/story/battles/:battleStateId`
|
||||
- `POST /api/story/npc/battle`
|
||||
- 旧 runtime story 兼容接口:
|
||||
- `POST /api/runtime/story/state/resolve`
|
||||
- `GET /api/runtime/story/state/:sessionId`
|
||||
- `POST /api/runtime/story/actions/resolve`
|
||||
- `POST /api/runtime/story/initial`
|
||||
- `POST /api/runtime/story/continue`
|
||||
2. 其中新真相态接口仍是 `story session / battle / NPC 开战` 的底层切片;旧 `runtime/story/*` 则是复用 `runtime_snapshot` 的兼容桥,不等价于最终真相态实现。
|
||||
3. 当前 `runtime/story/*` 已能返回旧前端需要的 `RuntimeStoryActionResponse / AIResponse` 形状,但内部动作仍以确定性兼容编排为主,不代表 `resolve_story_action` 真相 reducer 已完成。
|
||||
4. 当前新增的 `battle state` 查询仍只返回单个 `battleState` 真相切片,不等价于 runtime story 全量视图。
|
||||
5. 后续 `M4` 仍需把兼容桥逐步替换成真正的 story action / snapshot projection 真相链。
|
||||
|
||||
## 5. ViewModel 兼容
|
||||
|
||||
- [x] 兼容当前 `RuntimeStoryActionResponse`
|
||||
- [x] 兼容当前 `RuntimeStoryOptionView`
|
||||
- [x] 兼容当前 `interaction` 元数据
|
||||
- [x] 兼容当前 battle / toast / patch 响应结构
|
||||
- [x] 兼容当前 `currentStory` 回填逻辑
|
||||
|
||||
## 6. 阶段验收
|
||||
|
||||
- [x] 当前前端 story 选项点击后可走新后端闭环
|
||||
- [x] NPC / quest / combat 主循环行为不回退
|
||||
- [x] `story state` 恢复链可用
|
||||
- [x] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致
|
||||
- [x] 旧 Node 版 story route 回归用例完成平移
|
||||
|
||||
阶段验收补充说明:
|
||||
|
||||
1. `当前前端 story 选项点击后可走新后端闭环` 当前按 Rust `api-server` 的真实边界回归判定已满足:
|
||||
- `PUT /api/runtime/save/snapshot`
|
||||
- `GET /api/runtime/story/state/runtime-main`
|
||||
- `POST /api/runtime/story/actions/resolve`
|
||||
但这不等于“生产默认流量已经切到 Rust”。
|
||||
2. `story state 恢复链可用` 当前指:
|
||||
- 请求带 `snapshot` 时可先写后读
|
||||
- 请求不带 `snapshot` 时可从已持久化 `runtime_snapshot` 恢复
|
||||
3. `旧 Node 版 story route 回归用例完成平移` 当前指:
|
||||
- 已平移 Node 的 `rpg runtime story routes resolve through the new route boundary`
|
||||
- 已补 `clientVersion` 冲突回归
|
||||
- 已把 `npc_chat` 的 `46 -> 52` Node 旧语义对齐进 Rust compat handler
|
||||
4. `NPC / quest / combat 主循环行为不回退` 当前按 Rust compat 回归口径已可勾选:
|
||||
- 当前 runtime story compat bridge 已明确移除 `treasure_*` 遭遇动作,不再把 treasure 视作本阶段 runtime story 主循环的一部分。
|
||||
- `npc_chat / npc_help / npc_recruit / npc_chat_quest_offer_* / npc_quest_accept / npc_quest_turn_in / npc_fight / npc_spar / battle_* / inventory_use / equipment_equip / equipment_unequip / forge_craft / forge_dismantle / forge_reforge / npc_trade / npc_gift` 已有确定性兼容闭环。
|
||||
- 当前已补 battle option compiler、`battle_use_skill`、`inventory_use`、`equipment_equip / equipment_unequip`、`forge_*`、`npc_trade`、`npc_gift` 与胜利后的 `hostileNpcsDefeated` / `playerProgression.lastGrantedSource = hostile_npc` 写回。
|
||||
- 当前已补 NPC 交互态入口预处理:纯商贩型 NPC 即使没有预填 `npcStates.*.inventory`,也会在 compat bridge 内自动恢复可交易库存与基础关系态,不再依赖 Node 侧预热。
|
||||
- 更大范围 Node 回归与真相态 reducer 替换不再作为 M4 阻塞项,转入 M7 切流前回归矩阵。
|
||||
5. `后端边界与当前 rpgEntry -> ...` 当前按 Rust 代理与路由覆盖可勾选:
|
||||
- 前端真实调用链已对齐 `/api/runtime/story/*`
|
||||
- Rust 栈已覆盖 `/api/runtime/*` 与 `/api/story/*` 代理目标
|
||||
- `npm run dev:rust` 是本地 Rust 切流入口,M7 再做远端灰度与回退验证
|
||||
@@ -1,117 +0,0 @@
|
||||
# M5:custom world / gallery / agent 任务清单
|
||||
|
||||
## 0. 当前执行基线
|
||||
|
||||
本阶段与当前仓库里的创作链重构直接对应,统一以以下文档为准:
|
||||
|
||||
1. [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)
|
||||
2. [../docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](../docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md)
|
||||
|
||||
当前逻辑层命名和职责边界应优先使用 `rpgCreation / rpgAgent / rpgWorld` 口径;本任务清单继续保留 `custom world` 文件名,只是为了和后端重写阶段文档编号保持一致。
|
||||
|
||||
本轮首批可编码表设计见:
|
||||
|
||||
3. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md)
|
||||
4. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_PUBLISH_WORLD_STAGE4_DESIGN_2026-04-21.md)
|
||||
5. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AXUM_FACADE_STAGE5_DESIGN_2026-04-22.md)
|
||||
6. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md)
|
||||
7. [../docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](../docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md)
|
||||
|
||||
## 1. SpacetimeDB custom world 表
|
||||
|
||||
- [x] 设计 `custom_world_profile`
|
||||
- [x] 设计 `custom_world_session`
|
||||
- [x] 设计 `custom_world_agent_session`
|
||||
- [x] 设计 `custom_world_agent_message`
|
||||
- [x] 设计 `custom_world_agent_operation`
|
||||
- [x] 设计 `custom_world_draft_card`
|
||||
- [x] 设计 `custom_world_asset_link`(已在 Stage 1 文档中明确冻结为 `M6 assets / OSS` 继续落地,不阻塞 `M5` 验收)
|
||||
- [x] 设计 `custom_world_gallery_entry`
|
||||
|
||||
## 2. 当前 RPG 创作主链
|
||||
|
||||
- [x] 迁移 result preview compiler(Stage 9 按冻结口径落最小 preview compiler,不再搬 Node 全量 compiler)
|
||||
- [x] 迁移 published profile compile(Stage 3 已落地)
|
||||
- [x] 迁移 works 聚合读模型(Stage 9 Rust procedure + Axum facade 已接通)
|
||||
- [x] 迁移 library 存储与删除(Stage 2 设计已冻结,待继续接 Axum 兼容)
|
||||
- [x] 迁移 publish / unpublish(Stage 2 设计已冻结,待继续接 Agent publish gate)
|
||||
- [x] 迁移 publish_world 串联主链(Stage 4 设计已冻结,待继续接 Axum action / publish gate)
|
||||
- [x] 迁移 publish gate / enter-world gate(session snapshot / works / action 共用 gate 已接通)
|
||||
- [x] 迁移 gallery 列表与详情(Stage 2 设计已冻结,待继续接 Axum 兼容)
|
||||
|
||||
## 3. RPG 创作 Agent 主链
|
||||
|
||||
- [x] 迁移 session create(Stage 6 首批 Agent session skeleton)
|
||||
- [x] 迁移 session snapshot(Stage 6 首批 Agent session skeleton)
|
||||
- [x] 迁移 message submit(Stage 7 deterministic message / operation 最小闭环)
|
||||
- [x] 迁移 message stream(Stage 8 SSE facade 已落地)
|
||||
- [x] 迁移 operation query(Stage 7 deterministic message / operation 最小闭环)
|
||||
- [x] 迁移 card detail(Stage 9 Rust procedure + Axum facade 已接通)
|
||||
- [x] 迁移 card update(统一走 `/actions` 的 `update_draft_card`)
|
||||
- [x] 迁移 action registry / supportedActions(session 真相态 `supportedActions` 已接通)
|
||||
- [x] 迁移 draft foundation(统一走 `/actions` 的 `draft_foundation`)
|
||||
- [x] 迁移 result preview 生成(session 最小 `resultPreview` 已接通)
|
||||
- [x] 迁移 entity generation(Axum 兼容 `/api/custom-world/entity` 与 `/api/runtime/custom-world/entity` 已接通)
|
||||
- [x] 迁移 role / scene asset sync(最小 action 占位闭环与兼容图片入口已接通)
|
||||
- [x] 迁移 checkpoint / blocker / quality findings 主链(session / works / preview / publish gate 已接通)
|
||||
|
||||
## 4. Axum 编排层
|
||||
|
||||
- [x] 接入 LLM 编排(entity / scene-npc 兼容入口优先接 LLM + fallback)
|
||||
- [x] 接入世界草稿编译(`draft_foundation / update_draft_card / sync_result_profile` 已形成最小草稿编译闭环)
|
||||
- [x] 接入服务端 result preview 编译(最小 preview contract 已接入 session 快照)
|
||||
- [x] 接入角色 / 地点 / 场景 NPC 生成(最小兼容入口已接通)
|
||||
- [x] 接入封面图生成(最小兼容入口已接通)
|
||||
- [x] 接入场景图生成(最小兼容入口已接通)
|
||||
- [x] 接入 OSS 对象写入与绑定(`M5` 兼容图片入口已闭环为本地可消费资产;正式 `asset_object / asset_entity_binding / OSS` 主链顺延 `M6`)
|
||||
- [x] 接入 SSE 事件分发(Stage 8 SSE facade 已接通)
|
||||
|
||||
## 5. 当前正式接口与历史兼容台账
|
||||
|
||||
### 5.1 当前正式接口
|
||||
|
||||
- [x] 兼容 `/api/runtime/custom-world-library`(Stage 5 首批 Axum facade)
|
||||
- [x] 兼容 `/api/runtime/custom-world-library/:profileId`(owner-only detail 查询已补齐)
|
||||
- [x] 兼容 `/api/runtime/custom-world-library/:profileId/publish`(Stage 5 首批 Axum facade)
|
||||
- [x] 兼容 `/api/runtime/custom-world-library/:profileId/unpublish`(Stage 5 首批 Axum facade)
|
||||
- [x] 兼容 `/api/runtime/custom-world-gallery`(Stage 5 首批 Axum facade)
|
||||
- [x] 兼容 `/api/runtime/custom-world-gallery/:ownerUserId/:profileId`(Stage 5 首批 Axum facade)
|
||||
- [x] 兼容 `/api/runtime/custom-world/works`
|
||||
- [x] 兼容 `/api/runtime/custom-world/agent/sessions`(Stage 6 首批 Axum facade)
|
||||
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId`(Stage 6 首批 Axum facade)
|
||||
- [x] 兼容 `DELETE /api/runtime/custom-world/agent/sessions/:sessionId`(草稿物理清理;若作品卡误以已发布来源 session 删除,则回落到关联 profile 软删除并返回 works)
|
||||
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages`(Stage 7 deterministic message submit)
|
||||
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`(Stage 8 SSE facade)
|
||||
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions`(Stage 9 全量 action procedure 已接通)
|
||||
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`(Stage 7 deterministic operation query)
|
||||
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId`
|
||||
- [x] 兼容 `/api/custom-world/entity`
|
||||
- [x] 兼容 `/api/runtime/custom-world/entity`
|
||||
- [x] 兼容 `/api/custom-world/scene-npc`
|
||||
- [x] 兼容 `/api/runtime/custom-world/scene-npc`
|
||||
- [x] 兼容 `/api/custom-world/scene-image`
|
||||
- [x] 兼容 `/api/custom-world/cover-image`
|
||||
- [x] 兼容 `/api/custom-world/cover-upload`
|
||||
|
||||
### 5.2 历史兼容台账(非当前主链)
|
||||
|
||||
- [x] 评估 `/api/runtime/custom-world/sessions` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除)
|
||||
- [x] 评估 `/api/runtime/custom-world/sessions/:sessionId` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除)
|
||||
- [x] 评估 `/api/runtime/custom-world/sessions/:sessionId/answers` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除)
|
||||
- [x] 评估 `/api/runtime/custom-world/sessions/:sessionId/generate/stream` 是否仍需保留历史兼容映射(确认无需保留,旧链已物理删除)
|
||||
|
||||
## 6. 阶段验收
|
||||
|
||||
- [x] RPG 创作主链可用:`agent session -> result preview -> published profile`
|
||||
- [x] works / library / gallery / publish / enter-world 主链可用
|
||||
- [x] RPG 创作 Agent 主链可用
|
||||
- [x] agent 会话、消息、卡片、操作不再依赖单大 JSON 会话体
|
||||
- [x] 旧 `custom-world/sessions` 问答流不再作为当前主链扩展目标
|
||||
|
||||
## 7. 本轮执行结果
|
||||
|
||||
- [x] Stage 9 文档、任务清单、Rust module、spacetime-client、api-server 已对齐
|
||||
- [x] `cargo check -p spacetime-client`
|
||||
- [x] `cargo check -p api-server`
|
||||
- [x] `CARGO_TARGET_DIR=D:\\Genarrative\\server-rs\\target-codex-m5-check cargo check -p api-server`
|
||||
- [x] `node scripts/check-encoding.mjs ...` 编码检查通过
|
||||
@@ -1,153 +0,0 @@
|
||||
# M6:assets / 阿里云 OSS 任务清单
|
||||
|
||||
说明:
|
||||
|
||||
1. `editor` 已于 `2026-04-21` 被确认为遗留无用模块,不再纳入本轮 Rust 后端重写范围。
|
||||
2. 本文件保留原文件名仅用于延续既有任务编号与链接,不再继续安排 editor 迁移项。
|
||||
|
||||
## 1. OSS 基础设施
|
||||
|
||||
- [x] 创建 OSS bucket 方案
|
||||
- [x] 设计对象键前缀
|
||||
- [x] 设计 `object_key -> cdn_url` 解析策略
|
||||
- [x] 设计 public / private 对象访问策略
|
||||
- [x] 设计签名 URL 输出策略
|
||||
- [x] 设计 `x-oss-meta-*` 元数据规范
|
||||
- [x] 设计内容 hash / 版本字段规范(Stage 1 明确为 `asset_object.content_hash: Option<String>` + `version = 1`,后续强 hash 单独阶段再扩)
|
||||
|
||||
## 2. 上传与对象确认
|
||||
|
||||
- [x] 实现浏览器 `PostObject` 直传签名接口
|
||||
- [x] 实现 STS 临时授权接口
|
||||
- [x] 实现服务端上传 helper
|
||||
- [x] 实现上传完成后的对象确认接口
|
||||
- [x] 实现对象绑定业务实体 reducer
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 自 `2026-04-21` 起,当前重写节奏允许在 `M3/M4/M5` 之前先前置落地 `M6` 的 OSS 基础设施。
|
||||
2. 当前已在 `server-rs/crates/platform-oss` 与 `server-rs/crates/api-server` 落下最小可用链路:
|
||||
- `PostObject` 直传签名能力
|
||||
- `/api/assets/direct-upload-tickets`
|
||||
- `/api/assets/objects/confirm`
|
||||
- 兼容旧 `/generated-*` 前缀的对象键规划
|
||||
- `.env/.env.local` 的 OSS 环境变量加载
|
||||
- 服务端 `HEAD Object` 校验
|
||||
- `asset_object` 确认真实 SpacetimeDB 持久化
|
||||
- `/api/assets/objects/bind`
|
||||
- `asset_entity_binding` 业务实体槽位绑定
|
||||
- `/api/assets/sts-upload-credentials` 禁用式 contract
|
||||
- 服务端 `PutObject` 上传 helper
|
||||
3. 当前 bucket 已明确为私有读写;后续正式存储口径改为 `bucket + object_key` 双列,不再把匿名公开 URL 当成真相。
|
||||
4. 当前 STS 接口按“服务器上传、Web 只下载”的需求固定为 `403` 禁用式 contract,不向浏览器下发 OSS 写权限。
|
||||
5. `2026-04-21` 已通过 live test 验证:真实 OSS 上传后,`/api/assets/objects/confirm` 能把 `xushi-dev + object_key` 写入本地 `genarrative-dev.asset_object`,并可继续通过 `/api/assets/objects/bind` 绑定到业务实体槽位。
|
||||
|
||||
## 3. 资产任务系统
|
||||
|
||||
- [x] 设计 `asset_job`(Stage 1 明确不新增重复表,AI 资产任务先复用 `AiTaskService / ai_task` 口径)
|
||||
- [x] 设计 `asset_object`
|
||||
- [x] 设计 `asset_manifest`(Stage 1 使用 OSS JSON manifest + `asset_object` 表达集合对象,不新增结构化表)
|
||||
- [x] 设计 `character_visual_asset`(Stage 1 使用 `asset_entity_binding: character / primary_visual`,强业务表延后)
|
||||
- [x] 设计 `character_animation_asset`(Stage 1 使用 `asset_entity_binding: character / animation_set` 绑定总 manifest,强业务表延后)
|
||||
- [x] 设计 `scene_image_asset`(Stage 1 使用 `asset_entity_binding: custom_world_landmark / scene_image`,强业务表延后)
|
||||
- [x] 设计 `sprite_sheet_asset`(Qwen 独立工具已清理,Stage 1 仅保留历史 `/generated-qwen-sprites/*` 读取兼容)
|
||||
|
||||
补充说明:
|
||||
|
||||
1. `asset_object` 当前已冻结核心存储口径为:
|
||||
- `bucket`
|
||||
- `object_key`
|
||||
2. 详细设计见:
|
||||
- [../docs/technical/SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md)
|
||||
- [../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
|
||||
- [../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
- [../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
|
||||
3. 当前已在 `server-rs/crates/spacetime-module` 落下 `asset_object` 首版表骨架,并完成 `api-server -> SpacetimeDB` 的最小对象确认闭环。
|
||||
4. 元数据、版本、manifest 与强业务资产表边界见:
|
||||
- [../docs/technical/M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md](../docs/technical/M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md)
|
||||
|
||||
## 4. 资产生成链路
|
||||
|
||||
- [x] 迁移角色主形象生成(Stage 1 已接通 Rust `generate / jobs / publish` 最小 OSS 主链,当前仍为 SVG 占位生成,不代表真实 DashScope 图片模型已迁完)
|
||||
- [x] 迁移角色动作生成(Stage 1 已接通 Rust `generate / jobs / publish` 最小 OSS 主链,当前 `image-sequence` 为 SVG 占位帧,视频类策略优先复用参考视频或仓库占位预览,不代表真实视频模型已迁完)
|
||||
- [x] 迁移动作模板查询(Stage 1 已接通 Rust 内置模板列表兼容接口)
|
||||
- [x] 迁移视频导入(Stage 1 已接通 Data URL 视频导入到 OSS 草稿区,不再写本地 `public/`)
|
||||
- [x] 迁移工作流缓存(Stage 1 已接通 Rust `GET/POST character-workflow-cache` 到 OSS JSON 草稿对象,不再写本地 `public/`)
|
||||
- [x] 迁移场景图生成(已完成 Stage 2:custom world `scene-image` 走真实 DashScope 图片生成,并继续写入 `OSS + asset_object + asset_entity_binding`)
|
||||
- [x] 迁移封面图上传(已完成 Stage 2:custom world `cover-image / cover-upload` 已补齐真实 DashScope 生成与 `cropRect + 16:9 + WebP 压缩`)
|
||||
- [x] 首批收口 custom world `scene-image / cover-image / cover-upload` 到正式 `OSS + asset_object + asset_entity_binding` 主链(保持旧 `/generated-*` 返回 contract,不再写仓库 `public/`)
|
||||
|
||||
补充说明:
|
||||
|
||||
1. custom world 兼容图片入口现已完成 Stage 1 + Stage 2:正式资产真相链、真实 DashScope 图片生成,以及封面上传裁剪压缩都已迁完。
|
||||
2. 详细边界见:
|
||||
- [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
3. 角色动作模板与视频导入第一批已新增独立设计文档,当前只迁移:
|
||||
- `GET /api/assets/character-animation/templates`
|
||||
- `POST /api/assets/character-animation/import-video`
|
||||
- [../docs/technical/M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md)
|
||||
4. 角色资产工作流缓存第一批已新增独立设计文档,当前把旧本地 `workflow-cache.json` 改为 OSS JSON 草稿对象:
|
||||
- `GET /api/assets/character-workflow-cache/:characterId`
|
||||
- `POST /api/assets/character-workflow-cache`
|
||||
- [../docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md)
|
||||
5. `2026-04-22` 复核确认:旧独立 `qwen-sprite-tool + qwenSpriteRoutes.ts` 已在 `2026-04-21` 清理,不再作为本轮现役迁移主链;当前仍保留的 `Qwen` 相关内容仅包括:
|
||||
- 角色资产 prompt 层对 `packages/shared/src/prompts/qwenSprite.ts` 的复用
|
||||
- 历史资源前缀 `/generated-qwen-sprites/*` 的读取兼容
|
||||
6. custom world 图片链 Stage 2 已完成:
|
||||
- `scene-image / cover-image` 已替换为真实 DashScope 图片生成
|
||||
- `cover-upload` 已补回 Node 旧链路中的 `cropRect + 16:9 + WebP 压缩`
|
||||
- 详细口径与验证结果见 [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md)
|
||||
|
||||
## 5. 路径兼容
|
||||
|
||||
- [x] 兼容 `/generated-character-drafts/*`
|
||||
- [x] 兼容 `/generated-characters/*`
|
||||
- [x] 兼容 `/generated-animations/*`
|
||||
- [x] 兼容 `/generated-custom-world-scenes/*`
|
||||
- [x] 兼容 `/generated-custom-world-covers/*`
|
||||
- [x] 兼容 `/generated-qwen-sprites/*`
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 第一批路径兼容由 Rust `api-server` 同源代理到私有 OSS 短期读签名,不回退本地 `public/`,详细边界见:
|
||||
- [../docs/technical/M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md](../docs/technical/M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md)
|
||||
2. 当前 Stage 1 先全量代理对象内容,不实现视频 Range 分片;若后续真实视频体积变大,再按播放器需求补 Range。
|
||||
|
||||
## 6. 兼容接口
|
||||
|
||||
- [x] 兼容 `/api/assets/character-visual/generate`
|
||||
- [x] 兼容 `/api/assets/character-visual/jobs/:taskId`
|
||||
- [x] 兼容 `/api/assets/character-visual/publish`
|
||||
- [x] 兼容 `/api/assets/character-animation/generate`
|
||||
- [x] 兼容 `/api/assets/character-animation/jobs/:taskId`
|
||||
- [x] 兼容 `/api/assets/character-animation/publish`
|
||||
- [x] 兼容 `/api/assets/character-animation/import-video`
|
||||
- [x] 兼容 `/api/assets/character-animation/templates`
|
||||
- [x] 兼容 `/api/assets/character-workflow-cache`
|
||||
- [x] 兼容 `/api/assets/character-workflow-cache/:characterId`
|
||||
## 7. 阶段验收
|
||||
|
||||
- [x] OSS 直传对象可被服务端确认并写入 `asset_object`
|
||||
- [x] 所有新生成资产都写入 OSS(Stage 1 覆盖当前现役角色主形象、角色动作、workflow cache、视频导入、custom world 场景图/封面图;历史清理掉的 Qwen 独立工具不再计入现役主链)
|
||||
- [x] 前端仍能通过旧路径习惯访问资源(Stage 1 通过 Rust 同源代理私有 OSS 对象,开发期 Vite 代理已覆盖现役 generated 前缀)
|
||||
- [x] 资产任务状态可查询(角色主形象与角色动作已通过 `jobs/:taskId` 复用 `AiTaskService`;同步上传/确认链路以接口返回结果为状态)
|
||||
- [x] 已确认对象可绑定到业务实体槽位
|
||||
|
||||
补充说明:
|
||||
|
||||
1. custom world 的 `scene-image / cover-image / cover-upload` 已在本轮切到正式 OSS 对象与绑定主链。
|
||||
2. 角色主形象第一批已新增独立设计文档与 Rust 最小闭环:
|
||||
- [../docs/technical/M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
3. 当前角色主形象 `generate` 先用 Rust SVG 占位生成打通 `task + OSS drafts + publish + asset_object + asset_entity_binding` 主链,后续再替换成真实图片模型。
|
||||
4. 角色动作模板与视频导入第一批已接入 Rust:
|
||||
- `templates` 返回旧内置模板 contract。
|
||||
- `import-video` 当前只接受 `data:video/*;base64,...`,并写入 OSS `generated-character-drafts/*` 草稿区。
|
||||
5. 角色资产工作流缓存第一批已接入 Rust:
|
||||
- 保存时写入 OSS `generated-character-drafts/{character}/workflow-cache/workflow-cache.json`。
|
||||
- 读取时未命中返回 `cache: null`,保持旧前端 contract。
|
||||
6. 角色动作第一批已接入 Rust:
|
||||
- `generate` 直接写入 OSS `generated-character-drafts/*`。
|
||||
- `jobs/:taskId` 从 `AiTaskService` 派生旧任务状态 contract。
|
||||
- `publish` 会把动作帧与总 manifest 写入 OSS `generated-animations/*`,并确认 `asset_object + asset_entity_binding`。
|
||||
7. custom world 场景图、封面图、封面上传已在 `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md` + `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md` 范围内完成正式 `OSS + asset_object + asset_entity_binding` 主链、真实 DashScope 图片生成和封面上传裁剪压缩。
|
||||
8. `content_hash/version`、`asset_job`、`asset_manifest` 与强业务资产表当前已冻结 Stage 1 边界,不再作为 M6 第一批工程阻塞项;后续若要做内容去重、manifest 查询、审核/回滚或 sprite sheet 强结构化,再进入独立阶段。
|
||||
@@ -1,66 +0,0 @@
|
||||
# M7:联调、回归、部署与切流任务清单
|
||||
|
||||
## 1. 测试体系
|
||||
|
||||
- [x] 为 Axum handler 补接口测试(现阶段以既有 `api-server` handler 测试编译门禁 + `server-rs/scripts/check.ps1` 固化;新增接口测试继续按主链补齐)
|
||||
- [x] 为 SpacetimeDB reducer 补规则测试(现阶段以 `cargo check -p spacetime-module` 作为 schema/reducer/procedure 最小门禁;真实数据库规则回归继续由本地 publish smoke 承接)
|
||||
- [x] 为 view / projection 补数据一致性测试(现阶段以 `shared-contracts` contract 回归与 SpacetimeDB schema check 固化投影字段门禁)
|
||||
- [x] 为 auth 主链补集成测试(现有 `shared-contracts` 与 `api-server` 鉴权 handler 测试已纳入 Rust 主线检查入口)
|
||||
- [x] 为 runtime snapshot 主链补集成测试(现有 runtime contract 回归已纳入 Rust 主线检查入口)
|
||||
- [x] 为 story action 主链补集成测试(现有 runtime story contract / handler 测试编译已纳入 Rust 主线检查入口)
|
||||
- [x] 为 custom world / agent 主链补集成测试(现阶段纳入 `api-server` 编译与 Rust 主线检查;真实 LLM/OSS 环境联调继续由 smoke 承接)
|
||||
- [x] 为 assets / OSS 主链补集成测试(现有 M6 OSS smoke 与 contract 测试保留,Rust 主线检查固化基础门禁)
|
||||
- [x] 为兼容 contract 补回归测试(`cargo test -p shared-contracts` 已纳入 Rust 主线检查)
|
||||
|
||||
## 2. 部署准备
|
||||
|
||||
- [x] 设计 Axum 部署方式
|
||||
- [x] 设计 SpacetimeDB 发布方式
|
||||
- [x] 设计 OSS bucket / CDN / 域名方案
|
||||
- [x] 设计环境变量清单
|
||||
- [x] 设计灰度环境
|
||||
- [x] 设计数据迁移脚本
|
||||
- [x] 设计回滚策略
|
||||
- [x] 准备本地 Rust 一键联调脚本(`npm run dev:rust` 同时启动前端、Rust `api-server` 与本地 SpacetimeDB)
|
||||
- [x] 准备 Ubuntu 发布包构建脚本(`npm run build:rust:ubuntu` 生成 `build/<timestamp>/`,包含 `web/`、`api-server`、`spacetime_module.wasm`、`start.sh`、`stop.sh`,并默认 scp 上传到目标服务器)
|
||||
|
||||
## 3. 观测能力
|
||||
|
||||
- [x] 接入 tracing / request id / structured logs
|
||||
- [x] 接入慢请求追踪
|
||||
- [x] 接入上游 LLM / OSS / 短信 / 微信失败日志(沿用既有 provider error envelope 与 tracing,M7 固化字段口径)
|
||||
- [x] 接入关键 reducer 执行日志(现阶段固定 reducer 操作日志字段口径,真实 publish 日志回看继续由 SpacetimeDB smoke 承接)
|
||||
- [x] 接入资产任务状态日志(沿用 `AiTaskService / ai_task` 状态链,M7 固化 `task_id / status / asset_kind` 观测口径)
|
||||
|
||||
## 4. 切流准备
|
||||
|
||||
- [x] 准备旧 Node 与新 Rust 双跑窗口
|
||||
- [x] 准备 API 对比脚本
|
||||
- [x] 准备主流程 smoke 清单
|
||||
- [x] 准备前端切换开关
|
||||
- [x] 准备回退开关
|
||||
|
||||
## 5. 主工程结构收口
|
||||
|
||||
- [x] 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 聚合结构整理为 `runtime`、`gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}`、`custom_world`、`asset_metadata`、`ai` 等子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口
|
||||
|
||||
执行约束:
|
||||
|
||||
1. 这是切流前的工程结构收口,不是新功能扩张;拆分过程中不得改变既有 table schema、reducer / procedure 名称、对外 contract 与 publish 行为。
|
||||
2. 拆分后的模块边界必须与 `M0` 已冻结的模块迁移归属一致,避免 `spacetime-module` 再回退成单大包。
|
||||
3. 拆分完成后至少要保持 `cargo check`、SpacetimeDB 本地 build / publish 开发链路与主流程回归脚本可继续通过。
|
||||
|
||||
## 6. 阶段验收
|
||||
|
||||
- [x] 本地切流前预检通过(M7 阶段性预检包装入口已归档,长期入口改为 `server-rs/scripts/check.ps1`)
|
||||
- [x] 主流程基础回归通过(`cargo check -p spacetime-module`、`cargo check -p api-server`、`cargo test -p shared-contracts`、`cargo test -p api-server --no-run`)
|
||||
- [ ] 全链路 smoke 通过
|
||||
- [ ] 主流程真实环境回归通过
|
||||
- [ ] 关键 SSE 接口联调通过
|
||||
- [ ] 可在灰度环境完成切流
|
||||
|
||||
补充说明:
|
||||
|
||||
1. M7 已新增 [../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md),冻结本地预检、部署、灰度、双跑、回滚与结构收口口径。
|
||||
2. 本轮新增 [../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md),并落地 `scripts/dev-rust-stack.ps1`、`scripts/dev-rust-stack.sh`、`scripts/deploy-rust-remote.sh`;其中发布脚本当前语义为生成 Ubuntu release 包。
|
||||
3. 当前 M7 阶段性 preflight 入口已归档;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。
|
||||
@@ -1,62 +0,0 @@
|
||||
# 横向专项、执行顺序与最终验收
|
||||
|
||||
## 1. 横向专项任务
|
||||
|
||||
### Contract 与前端兼容
|
||||
|
||||
- [x] 梳理当前 `packages/shared/src/contracts/*` 到 Rust DTO 的映射
|
||||
- [x] 设计 Rust 侧 contract 生成或手写策略
|
||||
- [x] 保持当前字段名、枚举值、响应结构稳定
|
||||
- [x] 为 breaking change 建立显式变更流程
|
||||
|
||||
### SpacetimeDB schema 演进治理
|
||||
|
||||
- [x] 约定 stable reducer 命名规则
|
||||
- [x] 约定 stable table 命名规则
|
||||
- [x] 约定列追加式演进规则
|
||||
- [x] 约定软删除而不是直接删表删列的场景
|
||||
- [x] 约定事件表与投影表拆分规则
|
||||
|
||||
### 大对象与缓存治理
|
||||
|
||||
- [x] 明确哪些内容入 OSS
|
||||
- [x] 明确哪些内容只存 SpacetimeDB 元数据
|
||||
- [x] 明确哪些内容允许短期本地缓存
|
||||
- [x] 明确 workflow cache 生命周期
|
||||
|
||||
### 文档维护
|
||||
|
||||
- [x] 每个阶段完成后同步更新设计文档
|
||||
- [x] 每个阶段完成后补一份落地记录
|
||||
- [x] 完成接口迁移后更新新的模块与 API 索引文档
|
||||
- [ ] `M4` 结构变更同步对齐 `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
|
||||
- [x] `M5` 结构变更同步对齐 `docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 横向治理规则已冻结在 [../docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](../docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md)。
|
||||
2. Rust 侧 96 条 Axum 路由索引已冻结在 [../docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](../docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md)。
|
||||
3. `M4` 当前仍存在 `runtime_story` 独立 crate 拆分工作区,结构文档对齐需等该拆分收口后再勾选。
|
||||
|
||||
## 2. 第一优先级建议执行顺序
|
||||
|
||||
1. 先做 `M0`,冻结基线,避免迁移过程中口径漂移。
|
||||
2. 再做 `M1 + M2`,先把 Axum 壳与鉴权打稳。
|
||||
3. 再做 `M3`,优先跑通快照、设置、profile。
|
||||
4. 进入 `M4` 和 `M5` 前,先用两份 `2026-04-21` 执行方案冻结当前仓库里的 RPG 运行时链与创作链结构口径。
|
||||
5. 再做 `M4`,把 RPG runtime story 主循环真正迁走。
|
||||
6. 然后做 `M5`,迁 RPG 创作主链、works/library/gallery 与 agent。
|
||||
7. 最后做 `M6 + M7`,收口 assets、editor、部署与切流。
|
||||
|
||||
## 3. 最终验收清单
|
||||
|
||||
- [x] 当前 `96` 条后端接口已全部迁移或有兼容替代
|
||||
- [ ] 当前 `6` 个挂载面已全部迁移
|
||||
- [ ] 当前 `12` 个内部模块已完成新架构落位
|
||||
- [ ] Axum 已成为唯一 HTTP / SSE / 副作用边界
|
||||
- [ ] SpacetimeDB 已成为唯一运行时状态真相源
|
||||
- [ ] 阿里云 OSS 已成为唯一资产对象仓
|
||||
- [ ] `M4` 已与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory / rpgProfile` 主链口径一致
|
||||
- [x] `M5` 已与 `agent session -> result preview -> published profile` 主链口径一致
|
||||
- [ ] 前端主流程在不大改 UI 的前提下可跑通
|
||||
- [ ] 能完成灰度切流,并保留可回退能力
|
||||
@@ -1,183 +0,0 @@
|
||||
# M0:后端挂载面冻结基线
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
依据来源:
|
||||
|
||||
- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md)
|
||||
- [../server-node/manifests/backend-capability-index.json](../server-node/manifests/backend-capability-index.json)
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于完成 `M0` 的第一条任务:
|
||||
|
||||
- 整理当前后端 `6` 个挂载面并锁定为重写验收基线
|
||||
|
||||
这里的“冻结”不是要求新后端永远维持原实现,而是要求:
|
||||
|
||||
1. 当前 Node 后端历史基线仍固定为这 `6` 个挂载面。
|
||||
2. 本轮 Rust 后端的 active rewrite target 固定覆盖其中 `5` 个挂载面:`assets`、`auth`、`health`、`runtime-main`、`runtime-story-action`。
|
||||
3. `editor` 作为历史遗留挂载面继续保留对照记录,但自 `2026-04-21` 起不纳入 `server-rs` 本轮重写验收。
|
||||
4. 允许内部实现从 `Express + PostgreSQL + 本地 public/generated-*` 重写为 `Axum + SpacetimeDB + 阿里云 OSS`,但不允许把挂载面职责打散到无法对照验收。
|
||||
|
||||
## 2. 冻结结论
|
||||
|
||||
当前 Node 后端的正式挂载面固定为以下 `6` 个:
|
||||
|
||||
| 挂载面 ID | 中文名称 | 当前路由数 | 当前入口 | 必须保留的顶层路径 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `assets` | 资产生成工具面 | `14` | `server-node/src/app.ts -> /api/assets` | `/api/assets/*` |
|
||||
| `auth` | 鉴权与会话面 | `17` | `server-node/src/app.ts -> /api/auth` | `/api/auth/*` |
|
||||
| `editor` | 编辑器工具面 | `3` | `server-node/src/app.ts -> /api/editor` | `/api/editor/*` |
|
||||
| `health` | 基础健康检查 | `1` | `server-node/src/app.ts -> /healthz` | `/healthz` |
|
||||
| `runtime-main` | 运行时主能力面 | `59` | `server-node/src/app.ts -> /api` | `/api/runtime/*`、`/api/profile/*`、`/api/custom-world/*`、`/api/llm/*`、`/api/ws/*` |
|
||||
| `runtime-story-action` | 运行时 Story Action 面 | `2` | `server-node/src/app.ts -> /api/runtime/story` | `/api/runtime/story/*` |
|
||||
|
||||
冻结总数:
|
||||
|
||||
1. 历史对外挂载面:`6`
|
||||
2. 本轮 active rewrite target:`5`
|
||||
3. 已登记路由:`96`
|
||||
4. 公开接口:`10`
|
||||
5. JWT 接口:`69`
|
||||
6. 开关控制接口:`17`
|
||||
7. 流式接口:`6`
|
||||
|
||||
## 3. 各挂载面冻结要求
|
||||
|
||||
### 3.1 `assets`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 角色主形象生成
|
||||
2. 角色动作生成
|
||||
3. Qwen 精灵表生成与保存
|
||||
4. 工作流缓存
|
||||
5. 产物发布到 `public/generated-*`
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 仍保留独立的 `/api/assets/*` 命名空间。
|
||||
2. 仍保留“生成任务、任务状态查询、发布/保存”三类操作语义。
|
||||
3. 当前基于本地 `public/generated-*` 的产物落地,可改为 `OSS + 元数据表`,但前端一阶段必须继续通过原有路径习惯访问资源。
|
||||
4. 当前 `ASSETS_API_ENABLED` 门禁能力必须保留。
|
||||
|
||||
### 3.2 `auth`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 本地账号登录
|
||||
2. 手机验证码登录
|
||||
3. 微信登录
|
||||
4. refresh session
|
||||
5. 会话吊销
|
||||
6. 审计与风控
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 仍保留独立的 `/api/auth/*` 命名空间。
|
||||
2. 仍保留当前 `JWT + refresh cookie` 双令牌模型。
|
||||
3. 仍保留 `password / phone / wechat` 三类登录能力面。
|
||||
4. 仍保留审计日志、风控封禁、会话列表与会话吊销能力。
|
||||
|
||||
### 3.3 `editor`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 编辑器 JSON 读取
|
||||
2. 编辑器 JSON 回写
|
||||
3. 图标目录枚举
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. `server-node/src/app.ts -> /api/editor/*` 的历史存在事实继续保留在基线文档中。
|
||||
2. 自 `2026-04-21` 起,该挂载面不纳入 `server-rs` 本轮重写范围,不再作为 `M1 ~ M6` 主线交付目标。
|
||||
3. 若未来仍需清理或替代 editor,需要在遗留链路依赖核对完成后单独立项。
|
||||
|
||||
### 3.4 `health`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 提供后端进程健康探针
|
||||
2. 为代理层与 smoke 提供最小可用确认
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 仍保留 `/healthz`。
|
||||
2. 仍返回简单、无鉴权、无数据库强耦合的健康状态。
|
||||
3. 仍可作为 smoke 与部署探针的第一检查点。
|
||||
|
||||
### 3.5 `runtime-main`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 运行时存档、设置、个人档案
|
||||
2. 聊天、剧情、任务、运行时物品意图
|
||||
3. custom world library / gallery / sessions
|
||||
4. custom world agent 会话、消息、操作
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 仍保留运行时主入口作为最大能力面,不把这些能力拆散到前端无法感知的新命名空间。
|
||||
2. 仍兼容当前:
|
||||
- `/api/runtime/*`
|
||||
- `/api/profile/*`
|
||||
- `/api/custom-world/*`
|
||||
- `/api/llm/*`
|
||||
- `/api/ws/*`
|
||||
3. 除公开画廊与少量公开接口外,仍以登录态为默认访问前提。
|
||||
4. 当前大量业务逻辑虽然会迁到 `SpacetimeDB reducer/view + Axum facade`,但对前端看起来仍应是一个统一运行时能力面。
|
||||
|
||||
### 3.6 `runtime-story-action`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. story choice 动作解析
|
||||
2. story session 状态恢复
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 仍保留 `/api/runtime/story/*` 作为独立挂载面。
|
||||
2. 仍保持“前端动作输入 -> 后端统一结算 -> 返回新状态”的接口职责。
|
||||
3. 当前 `storyActionService` 里跨 `quest / inventory / runtime-item / npc / progression / combat / runtime` 的协作,迁移后必须继续存在,只是实现位置改到 `SpacetimeDB + Axum`。
|
||||
|
||||
## 4. 挂载面与新架构映射
|
||||
|
||||
| 当前挂载面 | 新架构主归属 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `assets` | `Axum + OSS + SpacetimeDB asset metadata` | 外部副作用在 Axum,二进制在 OSS,任务与引用状态在 SpacetimeDB。 |
|
||||
| `auth` | `Axum auth-service + SpacetimeDB auth tables` | 登录副作用与 cookie/JWT 在 Axum,身份与会话状态在 SpacetimeDB。 |
|
||||
| `editor` | `遗留保留于 server-node` | 历史挂载面对照,当前不进入 Rust 重写主链。 |
|
||||
| `health` | `Axum health route` | 维持最小化健康检查面。 |
|
||||
| `runtime-main` | `Axum runtime facade + SpacetimeDB runtime/custom-world tables` | Axum 维持兼容 REST/SSE,SpacetimeDB 负责状态真相。 |
|
||||
| `runtime-story-action` | `Axum story facade + SpacetimeDB gameplay reducers/views` | Story Action 入口继续独立存在,但结算内核迁到新状态层。 |
|
||||
|
||||
## 5. 本轮冻结后的硬约束
|
||||
|
||||
后续迁移中,不允许出现以下情况:
|
||||
|
||||
1. 把历史 `6` 个挂载面减少成更少但无法一一对照的“超级入口”。
|
||||
2. 为了迎合本轮重写范围而把历史存在的 `/api/editor/*` 从基线文档中抹掉。
|
||||
3. 把当前 `/api/auth/*`、`/api/assets/*`、`/api/runtime/story/*` 顶层命名空间直接改掉。
|
||||
4. 在未完成路径兼容前,直接移除 `/healthz` 或 `/generated-*` 的既有访问习惯。
|
||||
5. 在未完成契约回归前,把 `runtime-main` 和 `runtime-story-action` 的职责重新混成一个难以验收的大入口。
|
||||
|
||||
## 6. 本任务完成定义
|
||||
|
||||
当以下条件成立时,这条任务视为完成:
|
||||
|
||||
1. 当前历史 `6` 个挂载面已经有正式书面冻结清单。
|
||||
2. 每个挂载面都有:
|
||||
- 当前入口
|
||||
- 当前路由数
|
||||
- 顶层路径空间
|
||||
- 重写后必须保留的职责边界
|
||||
3. 本轮 active rewrite target 为 `5` 个,且 `editor` 的遗留/不迁移口径已经冻结。
|
||||
4. 后续任务可以直接以这份文档作为验收引用,不再靠聊天记录记忆。
|
||||
|
||||
## 7. 后续直接依赖这份基线的任务
|
||||
|
||||
1. 整理当前后端 `96` 条路由并生成“旧接口 -> 新实现”映射表
|
||||
2. 整理当前 `12` 个内部模块并锁定迁移归属
|
||||
3. 设计 Axum 路由树
|
||||
4. 设计 SpacetimeDB 表 / reducer / view 分层
|
||||
@@ -1,262 +0,0 @@
|
||||
# M0:前端直接依赖的响应头、Envelope 与错误格式冻结基线
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
依据来源:
|
||||
|
||||
- `server-node/src/http.ts`
|
||||
- `server-node/src/middleware/responseEnvelope.ts`
|
||||
- `server-node/src/middleware/errorHandler.ts`
|
||||
- `server-node/src/middleware/requestId.ts`
|
||||
- `packages/shared/src/http.ts`
|
||||
- `src/services/apiClient.ts`
|
||||
- `src/services/authService.ts`
|
||||
- `src/services/aiService.ts`
|
||||
- `src/editor/shared/jsonClient.ts`
|
||||
- `src/services/apiClient.test.ts`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于完成 `M0` 的第六条任务:
|
||||
|
||||
- 整理当前前端直接依赖的响应头、envelope、错误格式
|
||||
|
||||
这里的“直接依赖”指的是:如果 Axum 重写时把这些头或 body 结构改掉,当前前端 `src/services/*`、编辑器请求层和鉴权异常处理就会立刻出问题。
|
||||
|
||||
## 2. 冻结结论
|
||||
|
||||
当前前端直接依赖的响应契约,冻结为以下 4 层:
|
||||
|
||||
1. 请求侧默认会发送 `x-genarrative-response-envelope: v1`。
|
||||
2. 响应侧默认要回 `x-request-id`、`x-api-version`、`x-route-version`。
|
||||
3. 成功响应在请求方要求 envelope 时,必须返回标准 `ok/data/error/meta` 结构。
|
||||
4. 错误响应既要兼容标准 envelope,也要兼容旧式 `{ error, meta }` / `{ message, code }` 解析回退。
|
||||
|
||||
补充结论:
|
||||
|
||||
1. 当前正式前端代码里,没有生产用例主动关闭 envelope 请求头。
|
||||
2. `x-response-time-ms` 当前不是前端代码的直接读取项,但属于现有兼容头集合,重写时仍应保留。
|
||||
3. 鉴权链路额外直接依赖错误码 `CAPTCHA_REQUIRED` 与 `error.details.captchaChallenge`。
|
||||
|
||||
## 3. 当前前端直接依赖矩阵
|
||||
|
||||
| 依赖项 | 当前值/结构 | 当前消费者 | 当前作用 |
|
||||
| --- | --- | --- | --- |
|
||||
| 请求头 | `x-genarrative-response-envelope: v1` | `src/services/apiClient.ts`、`src/editor/shared/jsonClient.ts` | 请求标准 envelope 响应。 |
|
||||
| 响应头 | `x-request-id` | `src/services/apiClient.ts` | 构造 `ApiClientError.meta.requestId` 的回退来源。 |
|
||||
| 响应头 | `x-api-version` | `src/services/apiClient.ts`、`packages/shared/src/http.ts` | 识别标准 envelope / error body。 |
|
||||
| 响应头 | `x-route-version` | `src/services/apiClient.ts` | 构造 `ApiClientError.meta.routeVersion` 的回退来源。 |
|
||||
| 成功 body | `{ ok: true, data, error: null, meta }` | `unwrapApiResponse(...)` | 前端默认解包标准成功 envelope。 |
|
||||
| 错误 body | `{ ok: false, data: null, error, meta }` | `ApiClientError`、`parseApiErrorMessage(...)` | 标准错误解析。 |
|
||||
| 旧错误 body | `{ error, meta }` / `{ message, code }` | `parseApiErrorMessage(...)` | 老接口或非标准错误回退解析。 |
|
||||
| 错误细节 | `error.code === 'CAPTCHA_REQUIRED'` 且 `error.details.captchaChallenge` | `src/services/authService.ts` | 手机验证码发送前的验证码挑战弹出。 |
|
||||
|
||||
## 4. 请求侧冻结要求
|
||||
|
||||
### 4.1 Envelope 请求头
|
||||
|
||||
当前前端默认行为:
|
||||
|
||||
1. `src/services/apiClient.ts` 会自动补:
|
||||
- `x-genarrative-response-envelope: v1`
|
||||
2. `src/editor/shared/jsonClient.ts` 也会自动补:
|
||||
- `x-genarrative-response-envelope: v1`
|
||||
|
||||
当前后端接受的 envelope 触发值:
|
||||
|
||||
1. `1`
|
||||
2. `true`
|
||||
3. `v1`
|
||||
4. `envelope`
|
||||
|
||||
但当前前端真实发送值冻结为:
|
||||
|
||||
1. `v1`
|
||||
|
||||
补充冻结点:
|
||||
|
||||
1. 虽然 `apiClient` 提供了 `omitEnvelopeHeader` 选项,但当前生产代码没有实际依赖它。
|
||||
2. 因此第一阶段 Axum 应默认兼容“前端请求即要 envelope”的模式。
|
||||
|
||||
### 4.2 鉴权与凭证约定
|
||||
|
||||
当前前端请求层默认还会做:
|
||||
|
||||
1. `Authorization: Bearer <token>` 自动注入。
|
||||
2. `credentials: same-origin`。
|
||||
3. 遇到 `401` 时尝试走 `/api/auth/refresh` 自动刷新。
|
||||
|
||||
这不是本文重点,但它解释了为什么 envelope 和错误格式必须在 `/api/auth/refresh` 上也保持兼容。
|
||||
|
||||
## 5. 响应头冻结要求
|
||||
|
||||
### 5.1 必须保留的前端直接依赖头
|
||||
|
||||
| 响应头 | 当前来源 | 当前前端用法 |
|
||||
| --- | --- | --- |
|
||||
| `x-request-id` | `requestIdMiddleware` + `applyApiResponseHeaders` | `ApiClientError.meta.requestId` 的 header 回退来源。 |
|
||||
| `x-api-version` | `applyApiResponseHeaders` | 当前标准 API 契约版本识别。 |
|
||||
| `x-route-version` | `applyApiResponseHeaders` | `ApiClientError.meta.routeVersion` 的 header 回退来源。 |
|
||||
|
||||
### 5.2 兼容头但非直接读取项
|
||||
|
||||
| 响应头 | 当前状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `x-response-time-ms` | 当前统一输出 | 目前前端代码未直接读取,但设计文档与联调约定已锁定,不能随意删除。 |
|
||||
|
||||
补充冻结点:
|
||||
|
||||
1. `requestIdMiddleware` 会优先回显请求方传入的 `x-request-id`,否则服务端自生成。
|
||||
2. `ApiClientError` 读取元信息时优先取 body `meta`,没有再回退到 headers。
|
||||
3. 这意味着即便 envelope body 缺少部分 `meta` 字段,headers 仍必须完整。
|
||||
|
||||
## 6. 成功响应 Envelope 冻结格式
|
||||
|
||||
当前标准成功 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {},
|
||||
"error": null,
|
||||
"meta": {
|
||||
"apiVersion": "2026-04-08",
|
||||
"requestId": "req-xxx",
|
||||
"routeVersion": "2026-04-08",
|
||||
"operation": "runtime.story.initial",
|
||||
"latencyMs": 12,
|
||||
"timestamp": "2026-04-20T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
冻结规则:
|
||||
|
||||
1. `ok` 必须为 `true`。
|
||||
2. `data` 为真实业务负载。
|
||||
3. `error` 必须为 `null`。
|
||||
4. `meta.apiVersion` 必须存在,因为 `unwrapApiResponse(...)` 与 `isApiResponse(...)` 依赖它判断标准 envelope。
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 如果请求未带 envelope 头,当前后端可以直接返回裸 `data`。
|
||||
2. 但由于当前前端默认都会请求 envelope,第一阶段 Axum 基本等价于“所有 JSON 成功响应都要兼容这个结构”。
|
||||
|
||||
## 7. 错误响应 Envelope 与旧格式回退
|
||||
|
||||
### 7.1 当前标准错误 envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "缺少 Authorization Bearer Token",
|
||||
"details": null
|
||||
},
|
||||
"meta": {
|
||||
"apiVersion": "2026-04-08",
|
||||
"requestId": "req-xxx",
|
||||
"routeVersion": "2026-04-08",
|
||||
"operation": "auth.me",
|
||||
"latencyMs": 3,
|
||||
"timestamp": "2026-04-20T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
冻结规则:
|
||||
|
||||
1. `ok` 必须为 `false`。
|
||||
2. `data` 必须为 `null`。
|
||||
3. `error.code`、`error.message` 必须存在。
|
||||
4. `error.details` 可为对象或 `null`。
|
||||
5. `meta.apiVersion` 必须存在。
|
||||
|
||||
### 7.2 当前旧式错误格式回退
|
||||
|
||||
当请求未要求 envelope,或某些链路仍走旧写法时,当前后端与前端仍兼容以下错误结构:
|
||||
|
||||
1. `{ "error": { "code": "...", "message": "...", "details": ... }, "meta": {...} }`
|
||||
2. `{ "message": "...", "code": "..." }`
|
||||
3. `{ "error": { "message": "..." } }`
|
||||
4. 纯文本错误响应
|
||||
|
||||
`parseApiErrorMessage(rawText, fallbackMessage)` 的当前回退顺序固定为:
|
||||
|
||||
1. `parsed.error.message`
|
||||
2. 顶层 `message`
|
||||
3. `error.code` 或顶层 `code`,拼成 `fallback(CODE)`
|
||||
4. 原始文本
|
||||
5. 调用方的 `fallbackMessage`
|
||||
|
||||
这意味着:
|
||||
|
||||
1. Axum 第一阶段不能只兼容标准 envelope,而忽略旧错误解析的回退行为。
|
||||
2. 至少在迁移过渡期,`parseApiErrorMessage(...)` 可识别的信息要继续保留。
|
||||
|
||||
## 8. 前端对错误细节的业务级直接依赖
|
||||
|
||||
### 8.1 验证码挑战
|
||||
|
||||
`src/services/authService.ts` 当前明确依赖:
|
||||
|
||||
1. `error instanceof ApiClientError`
|
||||
2. `error.code === 'CAPTCHA_REQUIRED'`
|
||||
3. `error.details.captchaChallenge`
|
||||
|
||||
冻结要求:
|
||||
|
||||
1. 如果后端要继续触发验证码挑战,必须继续返回:
|
||||
- `code: 'CAPTCHA_REQUIRED'`
|
||||
- `details.captchaChallenge`
|
||||
2. 不能只返回中文文案而不带结构化 `details`。
|
||||
|
||||
### 8.2 元信息透传
|
||||
|
||||
`ApiClientError` 当前会保留:
|
||||
|
||||
1. `status`
|
||||
2. `code`
|
||||
3. `details`
|
||||
4. `meta.apiVersion`
|
||||
5. `meta.requestId`
|
||||
6. `meta.routeVersion`
|
||||
7. `meta.operation`
|
||||
8. `meta.latencyMs`
|
||||
9. `meta.timestamp`
|
||||
|
||||
冻结要求:
|
||||
|
||||
1. Axum 不能把这些字段全都删成单纯 `message` 字符串。
|
||||
2. 即使部分业务 UI 现在没显示这些字段,它们已经进入前端错误对象结构。
|
||||
|
||||
## 9. 当前消费者清单
|
||||
|
||||
以下文件已构成当前前端的直接依赖面:
|
||||
|
||||
1. `src/services/apiClient.ts`
|
||||
2. `src/services/authService.ts`
|
||||
3. `src/services/aiService.ts`
|
||||
4. `src/editor/shared/jsonClient.ts`
|
||||
5. `packages/shared/src/http.ts`
|
||||
|
||||
## 10. 本轮冻结后的硬约束
|
||||
|
||||
后续迁移中,不允许出现以下情况:
|
||||
|
||||
1. 删除 `x-genarrative-response-envelope: v1` 的请求协商能力。
|
||||
2. 删除 `x-request-id`、`x-api-version`、`x-route-version` 这些当前前端直接依赖的响应头。
|
||||
3. 把成功 envelope 从 `{ ok, data, error, meta }` 改成其他字段名。
|
||||
4. 把错误 envelope 从 `{ ok: false, data: null, error, meta }` 改成只有 `message`。
|
||||
5. 删除 `CAPTCHA_REQUIRED + details.captchaChallenge` 这一结构化错误契约。
|
||||
6. 让前端默认请求 envelope,但后端返回裸数据且不再可被 `unwrapApiResponse(...)` 识别。
|
||||
|
||||
## 11. 本任务完成定义
|
||||
|
||||
当以下条件成立时,这条任务视为完成:
|
||||
|
||||
1. 当前前端直接依赖的响应头、envelope、错误格式已有书面冻结清单。
|
||||
2. 已明确哪些是前端直接读取项,哪些是兼容保留项。
|
||||
3. 后续 Axum handler、错误中间件、response envelope 中间件可以直接按本文对齐,而不再靠人工试错。
|
||||
@@ -1,245 +0,0 @@
|
||||
# M0:`/generated-*` 静态资源前缀冻结基线
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
依据来源:
|
||||
|
||||
- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md)
|
||||
- `server-node/src/modules/assets/characterAssetRoutes.ts`
|
||||
- `server-node/src/modules/assets/qwenSpriteRoutes.ts`
|
||||
- `server-node/src/services/sceneImageService.ts`
|
||||
- `server-node/src/services/customWorldCoverAssetService.ts`
|
||||
- `server-node/src/services/customWorldAgentAutoAssetService.ts`
|
||||
- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于完成 `M0` 的第五条任务:
|
||||
|
||||
- 整理当前所有 `/generated-*` 静态资源前缀
|
||||
|
||||
这里的“整理”不是只列出几个目录名,而是要求冻结以下信息:
|
||||
|
||||
1. 当前哪些 `/generated-*` 前缀是正式业务前缀。
|
||||
2. 每个前缀由哪条后端链路产出。
|
||||
3. 每个前缀对应的当前路径模板是什么。
|
||||
4. 哪些前缀只是未来设计名或测试噪音,不能误当成当前正式兼容面。
|
||||
|
||||
## 2. 冻结结论
|
||||
|
||||
当前工程里,正式业务使用的 `/generated-*` 静态资源前缀固定为以下 `6` 个:
|
||||
|
||||
| 前缀 | 当前状态 | 当前主要生产链路 | 当前典型路径模板 | 重写后兼容要求 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `/generated-character-drafts/*` | 正式前缀 | 角色主形象草稿、动作草稿、导入参考素材 | `/generated-character-drafts/{characterId}/{kind}/{jobId}/{file}` | 必须保留 |
|
||||
| `/generated-characters/*` | 正式前缀 | 角色主形象正式发布、Agent 自动角色图回填 | `/generated-characters/{characterId}/visual/{assetId}/{file}` | 必须保留 |
|
||||
| `/generated-animations/*` | 正式前缀 | 角色基础动作正式发布 | `/generated-animations/{characterId}/{animationSetId}/{action}/{file}` | 必须保留 |
|
||||
| `/generated-custom-world-scenes/*` | 正式前缀 | 世界场景图生成、Agent 自动场景图回填 | `/generated-custom-world-scenes/{world}/{landmarkOrAct}/{assetId}/{file}` | 必须保留 |
|
||||
| `/generated-custom-world-covers/*` | 正式前缀 | 世界封面图生成、封面上传 | `/generated-custom-world-covers/{world}/{assetId}/{file}` | 必须保留 |
|
||||
| `/generated-qwen-sprites/*` | 正式前缀 | Qwen 主图草稿、精灵表草稿、修帧草稿、最终保存 | `/generated-qwen-sprites/{assetKeyOrDraftScope}/{actionOrKind}/{assetId}/{file}` | 必须保留 |
|
||||
|
||||
额外结论:
|
||||
|
||||
1. 当前仓库里真实业务前缀是 `6` 个,不是 `4` 个也不是 `5` 个。
|
||||
2. 其中 `generated-animations` 与 `generated-custom-world-covers` 是当前代码已正式使用、但早期重写设计里容易漏掉的两个前缀。
|
||||
3. 当前 `public/` 目录下已存在:
|
||||
- `generated-character-drafts`
|
||||
- `generated-characters`
|
||||
- `generated-qwen-sprites`
|
||||
4. `generated-animations`、`generated-custom-world-scenes`、`generated-custom-world-covers` 当前按需惰性创建,不代表它们不是正式前缀。
|
||||
|
||||
## 3. 正式前缀清单
|
||||
|
||||
### 3.1 `/generated-character-drafts/*`
|
||||
|
||||
当前用途:
|
||||
|
||||
1. 角色主形象候选图草稿。
|
||||
2. 角色动作草稿帧、草稿视频、导入参考素材。
|
||||
3. 角色资产工作流缓存与任务记录。
|
||||
|
||||
当前主要生产链路:
|
||||
|
||||
1. `POST /api/assets/character-visual/generate`
|
||||
2. `POST /api/assets/character-animation/generate`
|
||||
3. `POST /api/assets/character-animation/import-video`
|
||||
4. `POST /api/assets/character-workflow-cache`
|
||||
|
||||
当前典型路径模板:
|
||||
|
||||
1. `/generated-character-drafts/{characterId}/visual/{jobId}/candidate-01.png`
|
||||
2. `/generated-character-drafts/{characterId}/animation/{action}/{jobId}/preview.mp4`
|
||||
3. `/generated-character-drafts/{characterId}/animation/{action}/{jobId}/frame-01.png`
|
||||
4. `/generated-character-drafts/{characterId}-{cacheKey}/workflow-cache.json`
|
||||
|
||||
冻结要求:
|
||||
|
||||
1. 它是“草稿态真相路径”,不是正式发布路径。
|
||||
2. 重写为 OSS 后仍要保留这一层 HTTP 兼容前缀,哪怕底层已不是本地磁盘。
|
||||
|
||||
### 3.2 `/generated-characters/*`
|
||||
|
||||
当前用途:
|
||||
|
||||
1. 角色主形象正式发布路径。
|
||||
2. Custom World Agent 自动回填的角色主图。
|
||||
|
||||
当前主要生产链路:
|
||||
|
||||
1. `POST /api/assets/character-visual/publish`
|
||||
2. `customWorldAgentAutoAssetService`
|
||||
|
||||
当前典型路径模板:
|
||||
|
||||
1. `/generated-characters/{characterId}/visual/{assetId}/master.png`
|
||||
2. `/generated-characters/{characterId}/visual/{assetId}/preview-01.png`
|
||||
|
||||
冻结要求:
|
||||
|
||||
1. 它是角色正式视觉资产前缀,不允许与草稿前缀混用。
|
||||
2. 后续即使内部改成 OSS,也必须继续对前端暴露这一前缀。
|
||||
|
||||
### 3.3 `/generated-animations/*`
|
||||
|
||||
当前用途:
|
||||
|
||||
1. 角色基础动作正式发布路径。
|
||||
2. `CharacterAnimator` 侧消费的核心动画资源前缀。
|
||||
|
||||
当前主要生产链路:
|
||||
|
||||
1. `POST /api/assets/character-animation/publish`
|
||||
|
||||
当前典型路径模板:
|
||||
|
||||
1. `/generated-animations/{characterId}/{animationSetId}/{action}/frame-01.png`
|
||||
2. `/generated-animations/{characterId}/{animationSetId}/{action}/preview.mp4`
|
||||
3. `/generated-animations/{characterId}/{animationSetId}/{action}/manifest.json`
|
||||
|
||||
冻结要求:
|
||||
|
||||
1. 当前正式动画并不挂在 `/generated-characters/.../animation/...` 下,而是独立前缀 `/generated-animations/*`。
|
||||
2. 后续重写若想合并对象键,也必须先做兼容别名,不能直接改掉公开路径。
|
||||
|
||||
### 3.4 `/generated-custom-world-scenes/*`
|
||||
|
||||
当前用途:
|
||||
|
||||
1. 自定义世界场景图生成结果。
|
||||
2. Agent 自动生成的场景图回填结果。
|
||||
|
||||
当前主要生产链路:
|
||||
|
||||
1. `POST /api/custom-world/scene-image`
|
||||
2. `customWorldAgentAutoAssetService`
|
||||
|
||||
当前典型路径模板:
|
||||
|
||||
1. `/generated-custom-world-scenes/{worldSegment}/{landmarkSegment}/{assetId}/scene.png`
|
||||
2. `/generated-custom-world-scenes/{sceneSegment}/{actSegment}/{assetId}/scene.png`
|
||||
|
||||
冻结要求:
|
||||
|
||||
1. 前缀里当前显式带 `custom-world-scenes`,不是通用 `generated-scenes`。
|
||||
2. 后续世界资料库、世界编辑器和 Agent 卡片仍会依赖这一命名习惯。
|
||||
|
||||
### 3.5 `/generated-custom-world-covers/*`
|
||||
|
||||
当前用途:
|
||||
|
||||
1. 自定义世界封面图生成结果。
|
||||
2. 自定义世界封面图上传后规范化结果。
|
||||
|
||||
当前主要生产链路:
|
||||
|
||||
1. `POST /api/custom-world/cover-image`
|
||||
2. `POST /api/custom-world/cover-upload`
|
||||
|
||||
当前典型路径模板:
|
||||
|
||||
1. `/generated-custom-world-covers/{worldSegment}/{assetId}/cover.png`
|
||||
2. `/generated-custom-world-covers/{worldSegment}/{assetId}/manifest.json`
|
||||
|
||||
冻结要求:
|
||||
|
||||
1. 当前正式前缀是 `/generated-custom-world-covers/*`,不是 `/generated-cover-images/*`。
|
||||
2. 后续重写设计和 OSS key 规划必须以这个现状兼容面为准。
|
||||
|
||||
### 3.6 `/generated-qwen-sprites/*`
|
||||
|
||||
当前用途:
|
||||
|
||||
1. Qwen 精灵主图草稿。
|
||||
2. Qwen 精灵表草稿。
|
||||
3. Qwen 修帧草稿。
|
||||
4. Qwen 精灵表最终保存结果。
|
||||
|
||||
当前主要生产链路:
|
||||
|
||||
1. `POST /api/assets/qwen-sprite/master`
|
||||
2. `POST /api/assets/qwen-sprite/sheet`
|
||||
3. `POST /api/assets/qwen-sprite/frame-repair`
|
||||
4. `POST /api/assets/qwen-sprite/save`
|
||||
|
||||
当前典型路径模板:
|
||||
|
||||
1. `/generated-qwen-sprites/_drafts/master/{draftId}/candidate-01.png`
|
||||
2. `/generated-qwen-sprites/_drafts/sheet/{draftId}/candidate-01.png`
|
||||
3. `/generated-qwen-sprites/_drafts/repair/{draftId}/candidate-01.png`
|
||||
4. `/generated-qwen-sprites/{assetKey}/{actionKey}/{assetId}/sheet.png`
|
||||
|
||||
冻结要求:
|
||||
|
||||
1. 这个前缀当前既承载草稿,也承载正式保存结果。
|
||||
2. 如果未来要把草稿与正式对象拆桶,也必须先保留同一公开前缀兼容。
|
||||
|
||||
## 4. 非正式前缀与噪音项
|
||||
|
||||
以下命名当前不能当成“正式兼容前缀”:
|
||||
|
||||
| 名称 | 当前来源 | 处理结论 |
|
||||
| --- | --- | --- |
|
||||
| `/generated-cover-images/*` | 仅出现在重写设计文档旧草案里 | 这是未来提案名,不是当前工程真实前缀。 |
|
||||
| `/generated-role*` | 测试数据或示例 ID | 不是正式静态资源前缀。 |
|
||||
| `/generated-world*` | 测试数据或对象 ID | 不是正式静态资源前缀。 |
|
||||
| `/generated-ruins*` | 测试数据或对象 ID | 不是正式静态资源前缀。 |
|
||||
|
||||
结论:
|
||||
|
||||
1. 后续做 Axum 静态资源兼容层时,只能以第 2 节和第 3 节里的 `6` 个前缀为正式基线。
|
||||
2. 不能把测试里的字符串误判成真实资源入口。
|
||||
|
||||
## 5. 与重写设计的对齐要求
|
||||
|
||||
为避免后续按错路径重写,当前冻结以下对齐规则:
|
||||
|
||||
1. Axum 第一阶段必须兼容这 `6` 个公开资源前缀。
|
||||
2. OSS 内部对象键可以调整,但对前端暴露的 URL 前缀不能少于这 `6` 个。
|
||||
3. 设计文档里的对象键规划如果与这份基线冲突,以这份“当前工程冻结基线”为准,然后再在设计文档中补兼容说明。
|
||||
|
||||
## 6. 本轮冻结后的硬约束
|
||||
|
||||
后续迁移中,不允许出现以下情况:
|
||||
|
||||
1. 把 `/generated-animations/*` 直接并入 `/generated-characters/*` 却不保留兼容别名。
|
||||
2. 把 `/generated-custom-world-covers/*` 擅自改成 `/generated-cover-images/*`。
|
||||
3. 在未做兼容层前,删除 `/generated-character-drafts/*` 或 `/generated-qwen-sprites/*` 的草稿访问习惯。
|
||||
4. 仅因为某个目录在当前仓库里尚未生成,就把它从正式前缀清单里删掉。
|
||||
|
||||
## 7. 本任务完成定义
|
||||
|
||||
当以下条件成立时,这条任务视为完成:
|
||||
|
||||
1. 当前正式 `/generated-*` 前缀已经有书面冻结清单。
|
||||
2. 每个前缀都已明确:
|
||||
- 当前用途
|
||||
- 当前生产链路
|
||||
- 当前路径模板
|
||||
- 后续兼容要求
|
||||
3. 已明确哪些“generated-*”字符串只是噪音,不属于正式前缀。
|
||||
|
||||
## 8. 后续直接依赖这份基线的任务
|
||||
|
||||
1. 设计 Axum 静态资源兼容层
|
||||
2. 设计 OSS 对象键与 CDN 别名
|
||||
3. 做 assets / custom world / editor 的路径回归测试
|
||||
@@ -1,291 +0,0 @@
|
||||
# M0:内部模块迁移归属基线
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
依据来源:
|
||||
|
||||
- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md)
|
||||
- [../server-node/manifests/backend-capability-index.json](../server-node/manifests/backend-capability-index.json)
|
||||
- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于完成 `M0` 的第三条任务:
|
||||
|
||||
- 整理当前 `12` 个内部模块并锁定迁移归属
|
||||
|
||||
这里的“迁移归属”不是简单把旧目录名照搬到 Rust,而是要求后续重写必须先明确:
|
||||
|
||||
1. 每个旧模块在新架构中的主归属 crate。
|
||||
2. 每个旧模块是否需要拆成“SpacetimeDB 状态层 + Axum/application 编排层”。
|
||||
3. 每个旧模块的状态真相应该进入 `SpacetimeDB`、`OSS` 还是开发态本地文件适配。
|
||||
4. 每个旧模块优先落在哪个迁移阶段,避免后续任务拆分时反复改口径。
|
||||
|
||||
命名补充说明:
|
||||
|
||||
1. 本文中仍出现的 `application::...`、`auth-service`、`oss-service`、`llm-service` 等名称,统一表示逻辑职责,不再要求它们必须继续作为顶层独立 crate 存在。
|
||||
2. 在新的多 crate 结构下,这些逻辑职责默认落到对应 `crates/module-*` 的内部子层次,或落到 `crates/platform-*`、`crates/shared-*` 等共享 crate 中。
|
||||
|
||||
补充边界:
|
||||
|
||||
1. 本文只覆盖当前 `server-node/src/modules/*` 下的 `12` 个内部模块。
|
||||
2. `auth`、`health` 虽然属于后端能力面,但不在这 `12` 个内部模块目录里,因此不在本文表内。
|
||||
|
||||
## 2. 冻结结论
|
||||
|
||||
当前 Node 后端的正式内部模块固定为以下 `12` 个:
|
||||
|
||||
补充口径:
|
||||
|
||||
1. 上表 `12` 个模块属于历史基线总量。
|
||||
2. 自 `2026-04-21` 起,本轮 Rust 后端重写的 active rewrite modules 固定为 `11` 个。
|
||||
3. `editor` 作为遗留无用模块,仅保留历史事实对照,不再进入 `server-rs` 主线迁移。
|
||||
|
||||
| 模块 ID | 中文名称 | 当前目录 | 关联路由数 | 当前对外暴露面 | 重写后主归属 | 重写后次归属 | 目标迁移阶段 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `ai` | AI 编排模块 | `server-node/src/modules/ai` | `23` | `runtime-main` | `application + llm-service` | `contracts + api-server SSE facade` | `M4`、`M5`、`M6` |
|
||||
| `assets` | 资产工具模块 | `server-node/src/modules/assets` | `18` | `assets` | `application::assets + oss-service` | `spacetime-module::asset_metadata` | `M6` |
|
||||
| `combat` | 战斗结算模块 | `server-node/src/modules/combat` | `1` | `runtime-story-action` | `spacetime-module::gameplay::combat` | `domain::combat` | `M4` |
|
||||
| `custom-world` | 自定义世界运行时模块 | `server-node/src/modules/custom-world` | `26` | `runtime-main` | `spacetime-module::custom_world + application::custom_world` | `llm-service + oss-service` | `M5` |
|
||||
| `editor` | 编辑器资源模块 | `server-node/src/modules/editor` | `3` | `editor` | `不迁移(遗留模块)` | 保留 `server-node/` 历史链路对照 | `不纳入本轮` |
|
||||
| `inventory` | 背包与物品变更模块 | `server-node/src/modules/inventory` | `1` | `runtime-story-action` | `spacetime-module::gameplay::inventory` | `domain::inventory` | `M4` |
|
||||
| `npc` | NPC 交互模块 | `server-node/src/modules/npc` | `6` | `runtime-story-action`、`runtime-main` | `spacetime-module::gameplay::npc` | `application::npc_dialogue + llm-service` | `M4`、`M5` |
|
||||
| `progression` | 成长与关卡进程模块 | `server-node/src/modules/progression` | `3` | `runtime-story-action`、`runtime-main` | `spacetime-module::gameplay::progression` | `domain::progression` | `M3`、`M4` |
|
||||
| `quest` | 任务运行时模块 | `server-node/src/modules/quest` | `4` | `runtime-main`、`runtime-story-action` | `spacetime-module::gameplay::quest` | `application::quest_drafting + llm-service` | `M3`、`M4` |
|
||||
| `runtime` | 运行时状态基座模块 | `server-node/src/modules/runtime` | `32` | `runtime-main`、`runtime-story-action` | `spacetime-module::runtime` | `application::runtime_facade` | `M3` |
|
||||
| `runtime-item` | 运行时物品模块 | `server-node/src/modules/runtime-item` | `2` | `runtime-main`、`runtime-story-action` | `spacetime-module::gameplay::runtime_item` | `application::item_intent + llm-service` | `M4` |
|
||||
| `story` | 故事会话模块 | `server-node/src/modules/story` | `10` | `runtime-main`、`runtime-story-action` | `spacetime-module::gameplay::story` | `application::story_facade + api-server SSE facade` | `M4` |
|
||||
|
||||
冻结总数:
|
||||
|
||||
1. 历史内部模块目录:`12`
|
||||
2. 本轮 active rewrite modules:`11`
|
||||
3. 关联路由数最多的模块:`runtime`,共 `32` 条
|
||||
4. 本轮纯外部副作用导向模块:`ai`、`assets`
|
||||
5. 已退出本轮重写范围的遗留模块:`editor`
|
||||
6. 纯状态规则导向模块:`combat`、`inventory`
|
||||
7. 需要“状态层 + 编排层”双落位的混合模块:`custom-world`、`npc`、`quest`、`runtime-item`、`story`
|
||||
|
||||
## 3. 锁定迁移归属规则
|
||||
|
||||
后续所有重写实现,必须先遵守以下归属规则:
|
||||
|
||||
1. 纯运行时状态、纯规则计算、纯领域变更,优先进入 `spacetime-module/` 与 `domain/`,不能继续把真相留在 Axum 内存或 Node 风格 service。
|
||||
2. 外部模型调用、OSS 上传、短信、微信、本地文件读写,统一放在 `application/ + api-server/ + infra service`,不能塞进 SpacetimeDB reducer。
|
||||
3. 任何当前“一个模块同时做状态和副作用”的能力,在新架构里都必须拆成:
|
||||
- `SpacetimeDB`:状态真相与读模型
|
||||
- `Axum/application`:外部编排、SSE、对象上传、三方调用
|
||||
4. `public/generated-*` 不再是任何模块的真相源;未来只能作为兼容访问前缀或 CDN 映射。
|
||||
5. 不允许把旧模块简单合并成一个“大 runtime service”;必须保留可对照的领域边界。
|
||||
|
||||
## 4. 模块迁移矩阵
|
||||
|
||||
| 当前模块 | 当前职责摘要 | 新状态真相源 | 新外部副作用归属 | 迁移后必须落位 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `ai` | prompt 编排、聊天/剧情/世界生成组织 | `SpacetimeDB` 只存任务和结果引用,不存编排过程真相 | `llm-service` | 只能留在 Axum/application 侧,禁止直接进 reducer。 |
|
||||
| `assets` | 生成、发布、缓存、Qwen 精灵表 | `asset_job`、`asset_object`、`asset_manifest` 等表 | `oss-service` + 外部媒体模型 | 二进制进 OSS,任务与引用进 SpacetimeDB。 |
|
||||
| `combat` | 战斗结算、数值变化 | `battle_state`、`story_event` | 无 | 作为纯 reducer 规则模块落到 gameplay。 |
|
||||
| `custom-world` | 世界资料、问答流、Agent 草稿与编译 | `custom_world_*` 系列表 | `llm-service`、`oss-service` | 世界状态在 SpacetimeDB,编译/生成在 Axum。 |
|
||||
| `editor` | 编辑器 JSON 读写、图标枚举 | 仍以遗留 Node 链路与开发态本地文件为历史对照 | 不迁移到 `server-rs` | 仅保留历史基线,不纳入本轮 Rust 重写。 |
|
||||
| `inventory` | 背包变更、物品副作用、NPC 背包交互 | `inventory_slot`、`story_event` | 无 | 归入 story action 对应 reducer。 |
|
||||
| `npc` | 互动规则、关系变化、招募/对话语义 | `npc_state`、`story_event` | `application::npc_dialogue + llm-service` | 状态归 SpacetimeDB,台词生成归 Axum。 |
|
||||
| `progression` | 等级、章节、敌对 scaling、benchmark | `player_progression`、`chapter_progression` | 无 | 作为 runtime / story 公共领域模块进入 SpacetimeDB。 |
|
||||
| `quest` | 任务意图、日志、进度变化 | `quest_record`、`story_event` | `application::quest_drafting + llm-service` | 任务状态归 SpacetimeDB,生成型任务草案归 Axum。 |
|
||||
| `runtime` | 快照、设置、资料页、状态归一化 | `runtime_snapshot`、`runtime_setting`、`profile_*` | 无 | 作为新后端最先迁移的状态基座模块。 |
|
||||
| `runtime-item` | 物品意图、奖励解析、宝藏逻辑 | `treasure_record`、`inventory_slot`、`story_event` | `application::item_intent + llm-service` | 奖励结算归 reducer,意图生成归 Axum。 |
|
||||
| `story` | 会话状态、动作分发、主循环 | `story_session`、`story_event` | `application::story_facade + SSE` | 主循环状态归 SpacetimeDB,流式输出由 Axum 兼容。 |
|
||||
|
||||
## 5. 各模块冻结要求
|
||||
|
||||
### 5.1 `ai`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 负责剧情、多轮聊天、自定义世界等 prompt 编排。
|
||||
2. 自身不负责持久化,但会被多条 runtime 路由反复调用。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 主归属固定为 `application + llm-service`,不是 `spacetime-module`。
|
||||
2. 后续如果需要记录 AI 阶段状态,只能把任务状态或结果引用写入 SpacetimeDB,不把供应商 SDK 与 prompt 执行放进 reducer。
|
||||
3. 与 `story`、`custom-world`、`runtime-item`、`quest` 的关系固定为“它们产生命令,`ai` 负责外部生成”。
|
||||
|
||||
### 5.2 `assets`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 负责角色形象、动作、Qwen 精灵表生成。
|
||||
2. 负责发布到 `public/generated-*` 与局部 manifest。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 二进制对象一律进入 `OSS`。
|
||||
2. 主归属固定为 `application::assets + oss-service`。
|
||||
3. 资产任务状态、对象引用关系、发布绑定关系必须进入 `spacetime-module::asset_metadata`。
|
||||
4. 后续不允许继续以本地 `public/generated-*` 是否存在文件作为业务真相。
|
||||
|
||||
### 5.3 `combat`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 提供 story action 里的战斗型结算。
|
||||
2. 本质是纯规则计算。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 主归属固定为 `spacetime-module::gameplay::combat`。
|
||||
2. 不单独拥有 HTTP 路由,也不直接依赖外部 IO。
|
||||
3. 后续实现必须保持纯规则、可测试、可被 `resolve_story_action` reducer 复用。
|
||||
|
||||
### 5.4 `custom-world`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 负责 creator intent、world profile、传统问答流、Agent 运行时类型。
|
||||
2. 同时牵涉世界编译、资产生成和公开画廊。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 这是标准的双落位模块:
|
||||
- `SpacetimeDB` 保存会话、草稿、作品、画廊、Agent 状态。
|
||||
- `Axum/application` 负责编译、SSE、外部 LLM 与资产生成编排。
|
||||
2. 传统问答流和 Agent 流必须拆表,不能继续长期混成一个大 JSON 会话体。
|
||||
3. 对外仍然要兼容当前 `/api/custom-world/*` 与 `/api/runtime/custom-world/*` 访问习惯。
|
||||
|
||||
### 5.5 `editor`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 读写编辑器资源 JSON。
|
||||
2. 枚举工作区 `public/Icons` 图标资源。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 该模块在 `server-node/` 中的存在事实继续保留,用于历史基线与后续清理对照。
|
||||
2. 自 `2026-04-21` 起,不再为 `server-rs/` 创建 `module-editor` crate,也不再把它纳入 `M1 ~ M6` 主线迁移。
|
||||
3. 若未来仍需清理或替代 editor,必须在遗留链路依赖确认后单独立项,不能夹带进当前 Rust 重写主链。
|
||||
4. 不允许为了简化本轮任务而篡改其历史存在事实。
|
||||
|
||||
### 5.6 `inventory`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 负责背包变更、赠礼、NPC 背包交互等副作用。
|
||||
2. 当前主要被 story action 调用。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 主归属固定为 `spacetime-module::gameplay::inventory`。
|
||||
2. 与 `story`、`runtime-item` 的交互必须通过 reducer 协调,不能回到“多个 service 各自改 JSON”。
|
||||
3. 后续如需对外展示背包读模型,优先通过 view 暴露,不新增独立真相副本。
|
||||
|
||||
### 5.7 `npc`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 负责 NPC 关系、招募、交互规则与场景 NPC 语义。
|
||||
2. 同时参与 runtime 聊天流和 story action 结算。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 状态真相固定在 `spacetime-module::gameplay::npc`。
|
||||
2. LLM 对话、招募话术、流式文本输出固定由 `application::npc_dialogue + llm-service` 处理。
|
||||
3. 不允许把 NPC 状态又分散回聊天 session store、本地缓存或前端临时状态。
|
||||
|
||||
### 5.8 `progression`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 负责角色成长、章节推进、敌对强度等规则。
|
||||
2. 同时影响 snapshot hydrate 与 story action 结算。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 主归属固定为 `spacetime-module::gameplay::progression`。
|
||||
2. 仍保持纯规则、纯领域建模,不承接外部 IO。
|
||||
3. 作为 `runtime` 与 `story` 的公共领域组件,不能被重新塞回单一路由 handler。
|
||||
|
||||
### 5.9 `quest`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 负责任务语义、任务日志、任务进度信号。
|
||||
2. 既参与 AI 草案生成,也参与 story action 结算。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 任务主状态固定进入 `spacetime-module::gameplay::quest`。
|
||||
2. AI 生成的任务候选与草案编排固定由 `application::quest_drafting + llm-service` 承担。
|
||||
3. 前端兼容接口仍走 `/api/runtime/quests/*` 或 story action 聚合,不新增前端直连任务状态写入口。
|
||||
|
||||
### 5.10 `runtime`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 是当前运行时快照、设置、资料页、状态归一化的基座模块。
|
||||
2. 路由覆盖最广,是 Node 版后端迁移的第一主战场。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 主归属固定为 `spacetime-module::runtime`。
|
||||
2. `runtime_snapshot`、`runtime_setting`、`profile_*` 等读写模型优先在 `M3` 完成迁移。
|
||||
3. Axum 只保留兼容 facade,不再继续让快照真相停留在 PostgreSQL 风格 repository。
|
||||
|
||||
### 5.11 `runtime-item`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 负责运行时物品意图、奖励、宝藏解析与剧情指纹。
|
||||
2. 同时受到 AI 生成与 story action 结算驱动。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 奖励、掉落、宝藏等状态变化固定进入 `spacetime-module::gameplay::runtime_item`。
|
||||
2. 物品意图生成固定由 `application::item_intent + llm-service` 承接。
|
||||
3. 物品领域不能再拆成“部分在 story、部分在 route、部分在前端”的临时实现。
|
||||
|
||||
### 5.12 `story`
|
||||
|
||||
当前定位:
|
||||
|
||||
1. 负责运行时故事会话、动作分发与 state 恢复。
|
||||
2. 当前既暴露 REST,也暴露与聊天/继续剧情相关的流式体验。
|
||||
|
||||
重写后的冻结要求:
|
||||
|
||||
1. 主归属固定为 `spacetime-module::gameplay::story`。
|
||||
2. SSE 输出与兼容 DTO 拼装固定由 `application::story_facade + api-server SSE facade` 负责。
|
||||
3. `storyAction.resolve` 的跨模块联动必须以 `story` 为编排入口,但不再由单个 Node service 直接改整包 JSON。
|
||||
|
||||
## 6. 本轮冻结后的硬约束
|
||||
|
||||
后续迁移中,不允许出现以下情况:
|
||||
|
||||
1. 把 `ai`、`assets` 直接放进 SpacetimeDB reducer 执行三方网络或文件系统 IO。
|
||||
2. 在未单独立项前,把已退出本轮范围的 `editor` 重新并回 `server-rs` 主链。
|
||||
3. 把 `combat`、`inventory`、`progression` 重新做成只存在于 Axum handler 内部的计算 helper。
|
||||
4. 把 `custom-world`、`story`、`npc` 这类混合模块继续保留为“单大对象 JSON + 单大 service 写回”模式。
|
||||
5. 把 `runtime` 当成一个兜底垃圾桶,把其他领域模块重新并回去。
|
||||
6. 在没有对应 Axum facade 的前提下,让前端第一阶段直接依赖 SpacetimeDB 原生写接口。
|
||||
|
||||
## 7. 本任务完成定义
|
||||
|
||||
当以下条件成立时,这条任务视为完成:
|
||||
|
||||
1. 当前历史 `12` 个内部模块已经有正式书面冻结清单。
|
||||
2. 每个模块都已明确:
|
||||
- 当前目录
|
||||
- 关联路由数
|
||||
- 对外暴露面
|
||||
- 重写后主归属
|
||||
- 重写后次归属
|
||||
- 目标迁移阶段
|
||||
3. 本轮 active rewrite modules 为 `11` 个,且 `editor` 的遗留/不迁移口径已经冻结。
|
||||
4. 后续拆 `server-rs/` 多 crate、建 SpacetimeDB bounded context、排 M3~M6 任务时,可以直接引用本文,不再靠口头记忆。
|
||||
|
||||
## 8. 后续直接依赖这份基线的任务
|
||||
|
||||
1. 设计 `server-rs/` workspace 与 crate 边界
|
||||
2. 设计 SpacetimeDB `runtime / gameplay / custom_world / asset_metadata` 表分层
|
||||
3. 设计 story action reducer 的跨模块协作边界
|
||||
4. 设计 custom world / assets 的 Axum facade
|
||||
@@ -1,106 +0,0 @@
|
||||
# M0:阶段验收矩阵
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
依据来源:
|
||||
|
||||
- [00_MASTER_TASKLIST.md](./00_MASTER_TASKLIST.md)
|
||||
- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md)
|
||||
- [02_M3_RUNTIME_PROFILE.md](./02_M3_RUNTIME_PROFILE.md)
|
||||
- [03_M4_STORY_AND_GAMEPLAY.md](./03_M4_STORY_AND_GAMEPLAY.md)
|
||||
- [04_M5_CUSTOM_WORLD_AND_AGENT.md](./04_M5_CUSTOM_WORLD_AND_AGENT.md)
|
||||
- [05_M6_ASSETS_OSS_EDITOR.md](./05_M6_ASSETS_OSS_EDITOR.md)
|
||||
- [06_M7_TEST_DEPLOY_CUTOVER.md](./06_M7_TEST_DEPLOY_CUTOVER.md)
|
||||
- [07_CROSS_CUTTING_AND_ACCEPTANCE.md](./07_CROSS_CUTTING_AND_ACCEPTANCE.md)
|
||||
- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于把 `M0 ~ M7` 各阶段的入口条件、核心交付、退出条件与回归焦点固定下来,避免后续出现“任务勾完了,但阶段是否真的可进入下一步没有统一标准”的问题。
|
||||
|
||||
从本文件开始,后续每一阶段都需要按“入口满足 -> 交付完成 -> 验收通过 -> 留存证据”的顺序推进。
|
||||
|
||||
## 2. 阶段推进总规则
|
||||
|
||||
1. 未满足上一阶段退出条件前,不进入下一阶段主线编码。
|
||||
2. 每一阶段至少保留一份可复查的证据,证据可以是文档、脚本、测试结果或回归记录。
|
||||
3. 所有阶段都必须持续对齐当前冻结基线:
|
||||
- 历史基线 `6` 个挂载面
|
||||
- 本轮 active rewrite target `5` 个挂载面
|
||||
- `96` 条路由
|
||||
- `12` 个模块
|
||||
- `6` 条 SSE 接口
|
||||
- `6` 个 `/generated-*` 静态资源前缀
|
||||
- 前端直接依赖的响应头、envelope 与鉴权错误格式
|
||||
4. 任一阶段若引入新的协议差异,必须先补 contract 文档或迁移说明,再允许继续编码。
|
||||
|
||||
## 3. 分阶段验收矩阵
|
||||
|
||||
| 阶段 | 入口条件 | 核心交付 | 退出条件 | 回归焦点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `M0` 冻结能力与边界 | 已完成当前 Node 后端摸底;已明确目标架构为 `SpacetimeDB + Axum + 阿里云 OSS` | 冻结能力基线、路由矩阵、模块归属、SSE、静态资源前缀、前端响应契约、仓库边界决议、阶段验收矩阵 | `6` 个挂载面、`96` 条路由、`12` 个模块、`6` 条 SSE、`6` 个静态资源前缀全部形成书面基线;`server-rs/`、`server-node/`、Axum 边界、副作用收口原则全部冻结 | 文档口径一致性;前端 contract 依赖项是否被遗漏;迁移阶段是否还存在多套边界说法 |
|
||||
| `M1` Rust 工作区与 Axum 基础设施 | `M0` 全部退出条件满足 | `server-rs/` workspace、`crates/api-server`、`crates/spacetime-module`、独立模块 crates、统一配置、日志、request id、中间件、response envelope、`/healthz`、开发脚本 | Axum 可独立启动;`/healthz` 与当前工程兼容;`x-request-id`、`x-api-version`、`x-route-version`、`x-response-time-ms` 行为稳定;workspace 完整编译通过;主工程与模块 crate 引用边界稳定 | 基础头部兼容;健康检查兼容;目录结构与 crate 归属是否偏离 `M0` 决议 |
|
||||
| `M2` 鉴权、会话、JWT 与 refresh cookie | `M1` 已可稳定启动;Axum 中间件与配置链可用 | 身份表、会话表、JWT claims、refresh cookie、密码登录、手机验证码登录、微信登录、OIDC 透传、旧鉴权接口兼容 | 密码登录、refresh cookie、手机验证码、微信登录主链可用;旧鉴权接口 contract 回归通过;SpacetimeDB 可识别 Axum 签发身份 | Cookie 与 JWT 兼容;`CAPTCHA_REQUIRED` 与 `details.captchaChallenge` 是否保持;登录态吊销与刷新是否稳定 |
|
||||
| `M3` runtime snapshot / settings / profile | `M2` 鉴权稳定;用户身份可透传到 SpacetimeDB | `runtime_snapshot`、`runtime_setting`、profile 相关主表与 facade;存档、设置、浏览历史、save archive 兼容接口 | 登录用户可正常保存、读取、删除存档;profile dashboard / browse history / save archive 行为一致;前端恢复流程可直接跑通 | 快照恢复准确性;兼容路径与主路径是否返回一致;历史记录排序与去重逻辑 |
|
||||
| `M4` story action 与 gameplay reducer | `M3` 快照与用户状态主链稳定 | story / combat / inventory / npc / quest / progression / runtime-item 表与 reducer;story 兼容接口与 view model | 前端 story 主循环可用;`story state` 恢复链可用;NPC / quest / treasure / combat 主循环行为不回退;旧 Node story route 回归平移完成 | `RuntimeStoryActionResponse` 结构;战斗与奖励联动;状态投影是否与旧前端恢复逻辑一致 |
|
||||
| `M5` custom world / gallery / agent | `M4` story 与 runtime 真相源已稳定;SSE facade 可持续输出 | custom world 主表、agent 会话拆表、传统问答流、library / gallery、agent 消息与操作、LLM/图片生成编排 | 传统 custom world 主链可用;library / gallery 主链可用;agent 主链可用;会话不再依赖单大 JSON 体 | SSE 事件格式;卡片、消息、操作状态一致性;世界草稿编译与发布链是否可回放 |
|
||||
| `M6` assets / OSS | `M5` 世界与角色主链稳定;Axum 应用层可承接外部副作用 | OSS 对象键规范、上传签名、对象确认、资产任务表、角色/场景/Qwen 资产迁移、旧静态路径兼容 | 所有新生成资产写入 OSS;前端仍能通过旧路径习惯访问资源;资产任务状态可查询 | `/generated-*` 路径兼容;OSS 元数据与对象绑定关系;资产任务链状态一致性 |
|
||||
| `M7` 联调、回归、部署与切流 | `M6` 已具备主链闭环;双栈对照条件具备 | 测试体系、部署方案、观测能力、灰度切流方案、回退方案、对比脚本与 smoke 清单 | 全链路 smoke 通过;主流程回归通过;关键 SSE 联调通过;可在灰度环境切流并可回退 | 双跑窗口稳定性;API 对比结果;切流开关、回退开关、观测告警是否齐备 |
|
||||
|
||||
## 4. M0 冻结项专用验收清单
|
||||
|
||||
只有以下项目全部满足,`M0` 才算真正完成:
|
||||
|
||||
1. 已产出以下冻结文档:
|
||||
- [M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md](./M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md)
|
||||
- [M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md](./M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md)
|
||||
- [M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md)
|
||||
- [M0_SSE_INTERFACE_BASELINE_2026-04-20.md](./M0_SSE_INTERFACE_BASELINE_2026-04-20.md)
|
||||
- [M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md](./M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md)
|
||||
- [M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md](./M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md)
|
||||
- [M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md)
|
||||
- [M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](./M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md)
|
||||
2. 已书面冻结以下核心数字:
|
||||
- 挂载面:`6`
|
||||
- 路由:`96`
|
||||
- 模块:`12`
|
||||
- SSE:`6`
|
||||
- 静态资源前缀:`6`
|
||||
3. 已书面冻结以下边界决议:
|
||||
- 新 Rust 后端固定为仓库根目录 `server-rs/`
|
||||
- 迁移期保留 `server-node/`
|
||||
- 前端 `M0 ~ M6` 期间只访问 Axum
|
||||
- 外部副作用统一收口在 Axum
|
||||
- `server-rs/` 内部采用 `crates/*` 多 crate 组织
|
||||
- `editor` 已于 `2026-04-21` 退出本轮 Rust 重写范围
|
||||
4. `M1` 以后任何任务引用路由、模块、SSE、静态资源与响应契约时,都必须能追溯到本阶段产出的冻结文档。
|
||||
|
||||
## 5. 跨阶段回归维度
|
||||
|
||||
无论执行到哪个阶段,都要持续检查以下维度:
|
||||
|
||||
| 维度 | 必查内容 | 最晚必须固化的证据 |
|
||||
| --- | --- | --- |
|
||||
| 路由兼容 | 旧路由是否已有新实现或明确替代路径 | 路由迁移矩阵、API 对比脚本、contract 回归记录 |
|
||||
| SSE 兼容 | 事件名、事件顺序、结束事件、错误事件是否保持兼容 | SSE 基线文档、联调记录、smoke 结果 |
|
||||
| 静态资源兼容 | `/generated-*` 是否可继续访问,是否正确指向 OSS/CDN | 静态资源前缀基线、路径兼容测试记录 |
|
||||
| 鉴权兼容 | JWT、refresh cookie、验证码、微信登录、风控错误是否保持兼容 | 鉴权接口回归记录、claims 设计文档、集成测试 |
|
||||
| 前端 contract | 响应头、envelope、错误结构是否稳定 | response contract 基线、接口测试、前端联调记录 |
|
||||
| 切流回退 | 双栈是否可对照,是否具备回退能力 | `M7` 对比脚本、灰度清单、回退方案 |
|
||||
|
||||
## 6. 阶段证据留存要求
|
||||
|
||||
每个阶段完成时,至少要补齐以下其中两类证据:
|
||||
|
||||
1. 文档:
|
||||
- 更新任务清单勾选状态
|
||||
- 更新设计文档或阶段落地记录
|
||||
2. 测试或脚本:
|
||||
- 新增或更新 smoke / contract / integration 测试
|
||||
- 新增对比脚本、发布脚本或回归脚本
|
||||
3. 结果记录:
|
||||
- 编码检查结果
|
||||
- 关键命令执行结果
|
||||
- 联调、回归、灰度演练结果
|
||||
|
||||
如果阶段只完成了编码、但没有文档和证据留存,则该阶段不能视为完成。
|
||||
@@ -1,281 +0,0 @@
|
||||
# M0:仓库边界决议
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
依据来源:
|
||||
|
||||
- [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
|
||||
- [00_MASTER_TASKLIST.md](./00_MASTER_TASKLIST.md)
|
||||
- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md)
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于持续冻结 `M0` 中与仓库边界直接相关的决策,避免进入 `M1` 后再反复改目录、改职责口径。
|
||||
|
||||
当前已确认的事项会持续在这份文档上追加维护,后续若再有新的边界冻结结论,也统一收口到这里。
|
||||
|
||||
## 2. 边界决议状态
|
||||
|
||||
| 事项 | 当前状态 | 当前结论 |
|
||||
| --- | --- | --- |
|
||||
| Rust 后端新目录名与根目录落位方案 | 已确认 | 新 Rust 后端固定为仓库根目录下的 `server-rs/`,与 `server-node/` 同级。 |
|
||||
| 旧 `server-node/` 在迁移期继续保留,不提前删除 | 已确认 | `server-node/` 在 `M0 ~ M6` 期间持续保留,直到 `M7` 切流与回退验证完成后再评估清理。 |
|
||||
| 前端第一阶段仍然只访问 Axum,不直连 SpacetimeDB | 已确认 | `M0 ~ M6` 前端统一只访问 Axum 暴露的 `/api/*`、`/healthz`、SSE 与静态资源兼容层,不新增直连 SpacetimeDB 原生协议路径。 |
|
||||
| 外部副作用统一收口在 Axum,不放进 SpacetimeDB 模块 | 已确认 | OSS、LLM、短信、微信 OAuth、本地文件系统等外部副作用统一落在 Axum/application/infra,不进入 SpacetimeDB reducer/module。 |
|
||||
| `server-rs/` 内部采用多 crate 组织,由主工程 crate 统一引用模块 crate | 已确认 | `server-rs/` 采用 `crates/*` 工作区结构,`crates/api-server` 与 `crates/spacetime-module` 作为主工程 crate,独立模块以 `crates/module-*` 形式被主工程 crate 引用。 |
|
||||
| `editor` 为遗留无用模块,不纳入 `server-rs` 本轮重写范围 | 已确认 | `server-node/src/modules/editor` 与 `/api/editor/*` 仅作为历史基线保留对照;自 `2026-04-21` 起退出本轮 Rust 后端重写范围。 |
|
||||
|
||||
## 3. 已确认决议一:`server-rs/` 固定落在仓库根目录
|
||||
|
||||
### 3.1 决议内容
|
||||
|
||||
本次重写固定采用以下仓库落位:
|
||||
|
||||
1. 新后端目录名固定为 `server-rs/`
|
||||
2. 目录位置固定在仓库根目录
|
||||
3. 与以下目录保持同级:
|
||||
- `server-node/`
|
||||
- `src/`
|
||||
- `docs/`
|
||||
- `packages/`
|
||||
|
||||
目标形态:
|
||||
|
||||
```text
|
||||
Genarrative/
|
||||
├─ server-node/
|
||||
├─ server-rs/
|
||||
├─ src/
|
||||
├─ packages/
|
||||
├─ docs/
|
||||
└─ backend-rewrite-tasklist/
|
||||
```
|
||||
|
||||
### 3.2 不采用的落位方案
|
||||
|
||||
以下方案当前明确不采用:
|
||||
|
||||
1. 不放进 `server-node/` 子目录中做“Node + Rust 混编后端”。
|
||||
2. 不放进 `packages/`,避免被前端 package/workspace 语义误导。
|
||||
3. 不使用过于泛化的根目录名如 `server/`、`backend/`,避免和当前 `server-node/` 职责混淆。
|
||||
|
||||
### 3.3 这样落位的原因
|
||||
|
||||
1. 与当前重写设计文档、任务清单、后续 `M1` 多 crate 规划保持一致。
|
||||
2. 允许 `server-node/` 与 `server-rs/` 在迁移期并行存在,便于逐阶段切流。
|
||||
3. 让 Rust 工作区边界清晰,不污染现有前端 `src/`、`packages/`、Vite 工具链。
|
||||
4. 后续新增 `server-rs/scripts/*`、`Cargo.toml`、`crates/*` 时路径最直接,不需要额外中间层。
|
||||
|
||||
### 3.4 对后续任务的直接约束
|
||||
|
||||
从这一条决议开始,后续任务必须统一按以下路径落位:
|
||||
|
||||
1. `M1` 的工作区初始化在 `server-rs/`
|
||||
2. Axum 主工程 crate 在 `server-rs/crates/api-server`
|
||||
3. SpacetimeDB 主工程 crate 在 `server-rs/crates/spacetime-module`
|
||||
4. 独立模块 crate 在 `server-rs/crates/module-*`
|
||||
5. 相关脚本在 `server-rs/scripts/`
|
||||
|
||||
## 4. 本条任务完成定义
|
||||
|
||||
当以下条件成立时,这一条边界任务视为完成:
|
||||
|
||||
1. 新 Rust 后端目录名已经书面固定为 `server-rs/`
|
||||
2. 目录位置已经书面固定为仓库根目录
|
||||
3. 后续 `M1` 的工作区初始化不会再出现 `server-rs/`、`backend-rs/`、`server/` 等多套候选名并存
|
||||
|
||||
## 5. 已确认决议二:迁移期保留 `server-node/`
|
||||
|
||||
### 5.1 决议内容
|
||||
|
||||
在本次重写迁移期内,旧 `server-node/` 固定继续保留,不提前删除、不整体挪位、不提前做破坏性收缩。
|
||||
|
||||
保留周期固定为:
|
||||
|
||||
1. `M0` 到 `M6` 全阶段
|
||||
2. 至少持续到 `M7` 的以下条件全部满足之后,才允许评估清理:
|
||||
- 新后端已切流
|
||||
- 旧接口 contract 回归通过
|
||||
- 关键主链 smoke 通过
|
||||
- 已具备明确回退方案
|
||||
|
||||
### 5.2 保留它的原因
|
||||
|
||||
1. 旧 `server-node/` 是当前 `96` 条路由、`6` 个挂载面、`12` 个模块的真实对照实现。
|
||||
2. 前面已经冻结的路由矩阵、模块迁移清单、SSE 协议、静态资源前缀,都需要它作为回归对照源。
|
||||
3. 如果在 `M1 ~ M6` 提前删除旧实现,就会失去最可靠的回退锚点与 diff 基准。
|
||||
|
||||
### 5.3 迁移期允许做什么
|
||||
|
||||
迁移期内允许:
|
||||
|
||||
1. 在 `server-rs/` 中逐步补等价实现。
|
||||
2. 在文档和 manifest 中继续引用 `server-node/` 作为当前系统基线。
|
||||
3. 在必要时从 `server-node/` 补测试样例、补协议对照、补回归夹具。
|
||||
|
||||
迁移期内不允许:
|
||||
|
||||
1. 提前整体删除 `server-node/`
|
||||
2. 把 `server-node/` 改成只剩空壳目录
|
||||
3. 在还没切流前,把旧服务关键实现批量迁走导致无法对照
|
||||
|
||||
### 5.4 对后续任务的直接约束
|
||||
|
||||
从这一条决议开始,后续任务必须遵守:
|
||||
|
||||
1. `M1` 搭建 `server-rs/` 时,不改动 `server-node/` 的存在性。
|
||||
2. `M2 ~ M6` 迁移功能时,旧 `server-node/` 继续作为验收基线与回退锚点。
|
||||
3. 真正评估清理旧 Node 后端的动作,只能放到 `M7` 切流完成之后。
|
||||
|
||||
## 6. 已确认决议三:前端第一阶段只访问 Axum
|
||||
|
||||
### 6.1 决议内容
|
||||
|
||||
在 `M0 ~ M6` 迁移期内,前端访问新后端的唯一入口固定为 Axum。
|
||||
|
||||
第一阶段允许前端继续访问的面固定为:
|
||||
|
||||
1. `/api/*`
|
||||
2. `/healthz`
|
||||
3. 当前已冻结的 SSE 路由
|
||||
4. 当前已冻结的 `/generated-*` 静态资源兼容前缀
|
||||
|
||||
第一阶段明确不做的事:
|
||||
|
||||
1. 不让 Web 前端直接接 SpacetimeDB 原生 HTTP 接口。
|
||||
2. 不让 Web 前端直接接 SpacetimeDB 订阅协议。
|
||||
3. 不要求前端新增一套“Axum + SpacetimeDB 双后端并行直连”调用模式。
|
||||
|
||||
### 6.2 这样决议的原因
|
||||
|
||||
1. 当前前端已经直接依赖现有 `/api/*` 路由、response envelope、SSE、`/generated-*` 路径习惯。
|
||||
2. 如果在第一阶段就让前端同时认识 Axum 与 SpacetimeDB,会把迁移面从“后端平移”扩大成“前后端协议双重重写”。
|
||||
3. Axum 需要承担统一鉴权、cookie、JWT、OSS 签名、错误格式与 contract 兼容职责,这些都不应分散到前端直连多个后端协议。
|
||||
|
||||
### 6.3 对后续任务的直接约束
|
||||
|
||||
从这一条决议开始,后续任务必须遵守:
|
||||
|
||||
1. `M1 ~ M2` 的 Axum 中间件与鉴权必须先跑通,再谈前端联调。
|
||||
2. `M3 ~ M6` 新增的 SpacetimeDB reducer/view 先通过 Axum facade 暴露,不直接要求前端改成原生 SpacetimeDB 客户端。
|
||||
3. 若后续要让前端直连 SpacetimeDB,只能作为第二阶段优化事项,不能混入当前重写主链。
|
||||
|
||||
## 7. 已确认决议四:外部副作用统一收口在 Axum
|
||||
|
||||
### 7.1 决议内容
|
||||
|
||||
本次重写固定采用以下边界:
|
||||
|
||||
1. `SpacetimeDB` 只负责状态、规则、reducer、view、订阅读模型。
|
||||
2. `Axum/application/infra` 统一负责所有外部副作用。
|
||||
|
||||
固定收口到 Axum 的外部副作用包括:
|
||||
|
||||
1. 阿里云 OSS 上传、下载、签名、直传凭证
|
||||
2. DashScope / Ark / 其他 LLM 请求
|
||||
3. 微信 OAuth
|
||||
4. 手机验证码短信发送与校验编排
|
||||
5. 本地文件系统读写
|
||||
|
||||
### 7.2 明确不允许放进 SpacetimeDB 的内容
|
||||
|
||||
以下能力当前明确禁止进入 `spacetime-module/`:
|
||||
|
||||
1. 直接发 HTTP 请求给第三方供应商
|
||||
2. 直接访问 OSS SDK
|
||||
3. 直接读写本地磁盘
|
||||
4. 直接处理 Cookie、回调跳转、multipart 上传
|
||||
5. 直接承担供应商重试、熔断、超时与日志策略
|
||||
|
||||
### 7.3 这样决议的原因
|
||||
|
||||
1. 这些能力都强依赖 HTTP 头、Cookie、SDK、签名、超时与日志,不适合绑进 SpacetimeDB 模块发布周期。
|
||||
2. 当前前端 contract、鉴权、SSE、静态资源兼容都要求一个稳定的 HTTP 边界层,Axum 更适合承担这个角色。
|
||||
3. 把副作用统一收口到 Axum,才能让 SpacetimeDB 保持“状态机真相源”的纯度。
|
||||
|
||||
### 7.4 对后续任务的直接约束
|
||||
|
||||
从这一条决议开始,后续任务必须遵守:
|
||||
|
||||
1. `M1` crate 设计时,`platform-oss`、`platform-llm`、`platform-auth` 固定属于 Axum / 模块应用层一侧。
|
||||
2. `M2 ~ M6` 设计 reducer 时,只写状态变更,不直接发外部请求。
|
||||
3. 若确实需要异步副作用,也必须由 Axum worker 或应用层作业执行,再把结果回写 SpacetimeDB。
|
||||
|
||||
## 8. 已确认决议五:`server-rs/` 内部采用多 crate 组织
|
||||
|
||||
### 8.1 决议内容
|
||||
|
||||
从当前版本开始,`server-rs/` 内部结构固定采用:
|
||||
|
||||
1. `crates/*`:统一收口主工程 crate、独立模块 crate 与共享 crate
|
||||
2. `scripts/*`:开发、发布、回归脚本
|
||||
|
||||
主工程 crate 固定包含:
|
||||
|
||||
1. `crates/api-server`
|
||||
2. `crates/spacetime-module`
|
||||
|
||||
独立模块 crate 固定按“每个独立模块一个 crate”推进,至少覆盖:
|
||||
|
||||
1. `crates/module-auth`
|
||||
2. `crates/module-runtime`
|
||||
3. `crates/module-story`
|
||||
4. `crates/module-combat`
|
||||
5. `crates/module-inventory`
|
||||
6. `crates/module-npc`
|
||||
7. `crates/module-progression`
|
||||
8. `crates/module-quest`
|
||||
9. `crates/module-runtime-item`
|
||||
10. `crates/module-custom-world`
|
||||
11. `crates/module-assets`
|
||||
12. `crates/module-ai`
|
||||
|
||||
跨模块共享 crate 固定包含:
|
||||
|
||||
1. `crates/shared-contracts`
|
||||
2. `crates/shared-kernel`
|
||||
3. `crates/platform-auth`
|
||||
4. `crates/platform-oss`
|
||||
5. `crates/platform-llm`
|
||||
6. `crates/spacetime-client`
|
||||
7. `crates/tests-support`
|
||||
|
||||
### 8.2 这样决议的原因
|
||||
|
||||
1. 用户已经明确要求后端采用 Rust workspace 下的多 crate 模式,独立模块不能继续堆回单个技术层大包。
|
||||
2. 当前后端已有 `12` 个内部模块边界,多 crate 方案更容易保持一一映射与独立演进。
|
||||
3. `crates/api-server` 与 `crates/spacetime-module` 只做组合与发布,更符合“主工程 crate 引用模块 crate”的组织方式。
|
||||
|
||||
### 8.3 对后续任务的直接约束
|
||||
|
||||
从这一条决议开始,后续任务必须遵守:
|
||||
|
||||
1. `M1` 及后续目录任务统一按 `crates/*` 执行,不再保留 `apps/*` 与 `packages/*` 并行规划。
|
||||
2. 每个业务模块默认先有自己的 workspace crate,再由主工程 crate 引用。
|
||||
3. 只有共享 contract、共享领域内核、平台适配、SpacetimeDB client 这类跨模块能力,才允许使用共享 crate,而不是业务模块混装。
|
||||
|
||||
## 9. 已确认决议六:`editor` 退出本轮 Rust 重写范围
|
||||
|
||||
### 9.1 决议内容
|
||||
|
||||
`editor` 在当前 Node 后端中确实存在真实模块与真实挂载面,但已于 `2026-04-21` 被确认为遗留无用模块,不再纳入本轮 `server-rs/` 重写主链。
|
||||
|
||||
当前固定口径为:
|
||||
|
||||
1. 历史基线继续保留 `server-node/src/modules/editor` 与 `/api/editor/*` 的存在事实。
|
||||
2. `server-rs/` 不再保留 `crates/module-editor`。
|
||||
3. `M1 ~ M6` 的主线任务、阶段验收与 crate 规划,不再把 `editor` 计入 active rewrite scope。
|
||||
|
||||
### 9.2 这样决议的原因
|
||||
|
||||
1. 用户已明确确认 `editor` 为遗留无用模块,应从本轮重写目标中剔除。
|
||||
2. 保留历史事实有助于后续对照清理,不会把“旧系统曾存在该模块”的信息抹掉。
|
||||
3. 从当前阶段开始继续为 `editor` 预留 Rust crate,只会增加主线迁移噪音与工程负担。
|
||||
|
||||
### 9.3 对后续任务的直接约束
|
||||
|
||||
从这一条决议开始,后续任务必须遵守:
|
||||
|
||||
1. 不再为 `editor` 创建或维护 `server-rs` 下的新 crate、Axum 路由树与迁移验收项。
|
||||
2. 所有涉及挂载面、模块、路由总量的文档,都要区分“历史基线”与“本轮 active rewrite target”。
|
||||
3. 若未来仍要清理 `editor`,应在 `server-node/` 遗留链路依赖核对完成后单独立项。
|
||||
@@ -1,249 +0,0 @@
|
||||
# M0:旧接口到新实现路由迁移矩阵
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
依据来源:
|
||||
|
||||
- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md)
|
||||
- [../server-node/manifests/backend-capability-index.json](../server-node/manifests/backend-capability-index.json)
|
||||
- [M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md](./M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md)
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于完成 `M0` 的第二条任务:
|
||||
|
||||
- 整理当前后端 `96` 条路由并生成一份“旧接口 -> 新实现”映射表
|
||||
|
||||
这里的“新实现”不是指最终文件路径,而是指第一阶段重写时每条旧接口在新架构中的落点:
|
||||
|
||||
1. 哪条 Axum 路由负责对外兼容
|
||||
2. 哪层 application service 负责编排
|
||||
3. 哪些状态进入 SpacetimeDB
|
||||
4. 哪些二进制对象进入 OSS
|
||||
|
||||
## 2. 映射代码说明
|
||||
|
||||
为避免 `96` 条路由的映射表过长,本表使用以下“新实现归属代码”:
|
||||
|
||||
| 代码 | 新实现归属 |
|
||||
| --- | --- |
|
||||
| `A-HEALTH` | `Axum health route` |
|
||||
| `A-AUTH` | `Axum auth routes + auth-service + SpacetimeDB auth tables` |
|
||||
| `A-EDITOR` | `历史 Node editor 路由(遗留保留,不迁移到 server-rs)` |
|
||||
| `A-OSS` | `Axum assets routes + application::assets + oss-service + SpacetimeDB asset metadata` |
|
||||
| `A-LLM` | `Axum llm proxy/service` |
|
||||
| `A-RUNTIME` | `Axum runtime facade + SpacetimeDB runtime reducers/views` |
|
||||
| `A-STORY` | `Axum story facade + SpacetimeDB gameplay reducers/views` |
|
||||
| `A-CHAT` | `Axum SSE facade + llm-service + SpacetimeDB story/npc state` |
|
||||
| `A-CW` | `Axum custom-world facade + llm-service + SpacetimeDB custom_world reducers/views` |
|
||||
| `A-AGENT` | `Axum custom-world-agent facade + llm-service + oss-service + SpacetimeDB agent tables` |
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 第一阶段默认保留旧路径,不主动改前端请求地址。
|
||||
2. 兼容路径与主路径在新后端中应尽量共用同一 handler。
|
||||
3. 所有 `stream` 接口第一阶段继续用 Axum SSE,不强推改成 WebSocket。
|
||||
4. 自 `2026-04-21` 起,`editor` 路由仅保留历史对照,不纳入本轮 Rust 重写范围。
|
||||
|
||||
## 3. 总量校验
|
||||
|
||||
| 项目 | 数量 |
|
||||
| --- | --- |
|
||||
| 挂载面 | `6` |
|
||||
| 总路由数 | `96` |
|
||||
| `assets` | `14` |
|
||||
| `auth` | `17` |
|
||||
| `editor` | `3` |
|
||||
| `runtime-main` | `59` |
|
||||
| `runtime-story-action` | `2` |
|
||||
| `health` | `1` |
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 上表总量仍然是当前 Node 后端历史基线。
|
||||
2. 其中 `editor` 的 `3` 条路由继续计入历史对照,但不计入本轮 `server-rs` active rewrite target。
|
||||
|
||||
## 4. `assets` 路由映射(14 条)
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `assets.characterAnimationGenerate` | `POST /api/assets/character-animation/generate` | `A-OSS` | 保留原路径;Axum 创建 `asset_job`,外部生成结果写 OSS,任务状态进 SpacetimeDB。 |
|
||||
| `assets.characterAnimationImportVideo` | `POST /api/assets/character-animation/import-video` | `A-OSS` | 保留原路径;视频参考素材由 Axum 上传 OSS,并写任务/对象元数据。 |
|
||||
| `assets.characterAnimationJobGet` | `GET /api/assets/character-animation/jobs/:taskId` | `A-OSS` | 保留原路径;查询改读 SpacetimeDB `asset_job view`。 |
|
||||
| `assets.characterAnimationPublish` | `POST /api/assets/character-animation/publish` | `A-OSS` | 保留原路径;发布动作改为“绑定 OSS 对象到业务实体 + 回写元数据”。 |
|
||||
| `assets.characterAnimationTemplatesList` | `GET /api/assets/character-animation/templates` | `A-OSS` | 保留原路径;模板清单先由 Axum 提供,后续再视情况对象化。 |
|
||||
| `assets.characterVisualGenerate` | `POST /api/assets/character-visual/generate` | `A-OSS` | 保留原路径;角色主形象候选生成改为 Axum 编排 + OSS 入库。 |
|
||||
| `assets.characterVisualJobGet` | `GET /api/assets/character-visual/jobs/:taskId` | `A-OSS` | 保留原路径;任务状态改读 SpacetimeDB。 |
|
||||
| `assets.characterVisualPublish` | `POST /api/assets/character-visual/publish` | `A-OSS` | 保留原路径;发布改为对象绑定,不再依赖本地 `public/generated-*` 真相。 |
|
||||
| `assets.characterWorkflowCacheSave` | `POST /api/assets/character-workflow-cache` | `A-OSS` | 保留原路径;工作流缓存改写 OSS/对象存储,索引进 SpacetimeDB。 |
|
||||
| `assets.characterWorkflowCacheGet` | `GET /api/assets/character-workflow-cache/:characterId` | `A-OSS` | 保留原路径;按角色查缓存索引,再返回对象内容或签名 URL。 |
|
||||
| `assets.qwenSpriteFrameRepairGenerate` | `POST /api/assets/qwen-sprite/frame-repair` | `A-OSS` | 保留原路径;Qwen 修帧结果统一入 OSS,状态进 SpacetimeDB。 |
|
||||
| `assets.qwenSpriteMasterGenerate` | `POST /api/assets/qwen-sprite/master` | `A-OSS` | 保留原路径;主图生成改为 Axum 编排。 |
|
||||
| `assets.qwenSpriteAssetSave` | `POST /api/assets/qwen-sprite/save` | `A-OSS` | 保留原路径;保存动作改为持久化对象元数据与引用关系。 |
|
||||
| `assets.qwenSpriteSheetGenerate` | `POST /api/assets/qwen-sprite/sheet` | `A-OSS` | 保留原路径;整表生成链路保留,底层切换为 OSS + SpacetimeDB。 |
|
||||
|
||||
## 5. `auth` 路由映射(17 条)
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `auth.auditLogs` | `GET /api/auth/audit-logs` | `A-AUTH` | 保留原路径;从 SpacetimeDB `auth_audit_log view` 返回。 |
|
||||
| `auth.entry` | `POST /api/auth/entry` | `A-AUTH` | 保留原路径;密码登录与自动注册由 Axum 完成,再写 auth 表。 |
|
||||
| `auth.loginOptions` | `GET /api/auth/login-options` | `A-AUTH` | 保留原路径;由 Axum 直接返回登录方式配置。 |
|
||||
| `auth.logout` | `POST /api/auth/logout` | `A-AUTH` | 保留原路径;Axum 吊销 refresh session 并清理 cookie。 |
|
||||
| `auth.logoutAll` | `POST /api/auth/logout-all` | `A-AUTH` | 保留原路径;批量吊销用户全部 session。 |
|
||||
| `auth.me` | `GET /api/auth/me` | `A-AUTH` | 保留原路径;由 Axum 校验 JWT 后查询用户读模型。 |
|
||||
| `auth.phoneChange` | `POST /api/auth/phone/change` | `A-AUTH` | 保留原路径;短信校验在 Axum,绑定结果写 SpacetimeDB。 |
|
||||
| `auth.phoneLogin` | `POST /api/auth/phone/login` | `A-AUTH` | 保留原路径;验证码校验成功后创建/恢复账号与 session。 |
|
||||
| `auth.phoneSendCode` | `POST /api/auth/phone/send-code` | `A-AUTH` | 保留原路径;阿里云短信发送适配收口到 Axum。 |
|
||||
| `auth.refresh` | `POST /api/auth/refresh` | `A-AUTH` | 保留原路径;沿用 refresh cookie -> access token 刷新模型。 |
|
||||
| `auth.riskBlocks` | `GET /api/auth/risk-blocks` | `A-AUTH` | 保留原路径;改读风控封禁表/视图。 |
|
||||
| `auth.riskBlocksLift` | `POST /api/auth/risk-blocks/:scopeType/lift` | `A-AUTH` | 保留原路径;解除请求由 Axum 执行校验并写状态。 |
|
||||
| `auth.sessions` | `GET /api/auth/sessions` | `A-AUTH` | 保留原路径;会话列表改读 SpacetimeDB `refresh_session view`。 |
|
||||
| `auth.sessionRevoke` | `POST /api/auth/sessions/:sessionId/revoke` | `A-AUTH` | 保留原路径;会话吊销改写 `refresh_session` 状态。 |
|
||||
| `auth.wechatBindPhone` | `POST /api/auth/wechat/bind-phone` | `A-AUTH` | 保留原路径;微信身份补绑手机号逻辑迁到 Axum。 |
|
||||
| `auth.wechatCallback` | `GET /api/auth/wechat/callback` | `A-AUTH` | 保留原路径与 redirect 语义;微信 code 交换由 Axum 处理。 |
|
||||
| `auth.wechatStart` | `GET /api/auth/wechat/start` | `A-AUTH` | 保留原路径;授权 URL 由 Axum 按设备场景生成。 |
|
||||
|
||||
## 6. `editor` 路由映射(3 条,历史遗留)
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `editor.catalogItems` | `GET /api/editor/catalog/items` | `A-EDITOR` | 保留在 `server-node/` 遗留链路,仅作为历史对照;不迁移到 `server-rs`。 |
|
||||
| `editor.resourceRead` | `GET /api/editor/json/:resourceId` | `A-EDITOR` | 保留在 `server-node/` 遗留链路,仅作为历史对照;不迁移到 `server-rs`。 |
|
||||
| `editor.resourceWrite` | `POST /api/editor/json/:resourceId` | `A-EDITOR` | 保留在 `server-node/` 遗留链路,仅作为历史对照;不迁移到 `server-rs`。 |
|
||||
|
||||
## 7. `runtime-main` 路由映射(59 条)
|
||||
|
||||
### 7.1 custom world 资源与实体生成
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `runtime.customWorldCoverImage` | `POST /api/custom-world/cover-image` | `A-CW` | 保留原路径;封面图生成由 Axum 编排,产物进 OSS,绑定关系进 SpacetimeDB。 |
|
||||
| `runtime.customWorldCoverUpload` | `POST /api/custom-world/cover-upload` | `A-CW` | 保留原路径;上传改为 OSS 直传或 Axum 中转上传。 |
|
||||
| `runtime.customWorldEntity.primary` | `POST /api/custom-world/entity` | `A-CW` | 保留原路径;实体生成由 Axum 调 LLM,再写 custom world 表。 |
|
||||
| `runtime.customWorldSceneImage` | `POST /api/custom-world/scene-image` | `A-CW` | 保留原路径;场景图生成由 Axum + OSS 完成。 |
|
||||
| `runtime.customWorldSceneNpc.primary` | `POST /api/custom-world/scene-npc` | `A-CW` | 保留原路径;场景 NPC 生成结果写 custom world / npc 相关表。 |
|
||||
|
||||
### 7.2 LLM 透传
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `runtime.llmChatCompletionsProxy` | `POST /api/llm/chat/completions` | `A-LLM` | 保留原路径;继续由 Axum 承接代理,不进入 SpacetimeDB。 |
|
||||
|
||||
### 7.3 profile 主路径
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `runtime.profileBrowseHistoryDelete.primary` | `DELETE /api/profile/browse-history` | `A-RUNTIME` | 保留原路径;清理动作改为 reducer 写 `user_browse_history`。 |
|
||||
| `runtime.profileBrowseHistoryGet.primary` | `GET /api/profile/browse-history` | `A-RUNTIME` | 保留原路径;历史记录改读 browse history view。 |
|
||||
| `runtime.profileBrowseHistoryPost.primary` | `POST /api/profile/browse-history` | `A-RUNTIME` | 保留原路径;批量写入改为 Axum -> reducer。 |
|
||||
| `runtime.profileDashboard.primary` | `GET /api/profile/dashboard` | `A-RUNTIME` | 保留原路径;个人主页汇总改读 dashboard view。 |
|
||||
| `runtime.profilePlayStats.primary` | `GET /api/profile/play-stats` | `A-RUNTIME` | 保留原路径;统计数据改读 projection。 |
|
||||
| `runtime.profileSaveArchivesList.primary` | `GET /api/profile/save-archives` | `A-RUNTIME` | 保留原路径;存档摘要改读 save archive view。 |
|
||||
| `runtime.profileSaveArchivesResume.primary` | `POST /api/profile/save-archives/:worldKey` | `A-RUNTIME` | 保留原路径;恢复动作改读 `profile_save_archive` 后重建兼容快照。 |
|
||||
| `runtime.profileWalletLedger.primary` | `GET /api/profile/wallet-ledger` | `A-RUNTIME` | 保留原路径;资产流水改读 ledger view。 |
|
||||
|
||||
### 7.4 runtime 聊天与流式对话
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `runtime.characterReplyStream` | `POST /api/runtime/chat/character/reply/stream` | `A-CHAT` | 保留 SSE contract;Axum 流式产出,状态写 story/session 表。 |
|
||||
| `runtime.characterSuggestions` | `POST /api/runtime/chat/character/suggestions` | `A-CHAT` | 保留原路径;由 Axum 生成建议语并按需写会话状态。 |
|
||||
| `runtime.characterSummary` | `POST /api/runtime/chat/character/summary` | `A-CHAT` | 保留原路径;摘要生成留在 Axum,摘要索引可回写 SpacetimeDB。 |
|
||||
| `runtime.npcDialogueStream` | `POST /api/runtime/chat/npc/dialogue/stream` | `A-CHAT` | 保留 SSE contract;NPC 对话状态迁到 SpacetimeDB。 |
|
||||
| `runtime.npcRecruitStream` | `POST /api/runtime/chat/npc/recruit/stream` | `A-CHAT` | 保留 SSE contract;招募对话与状态变化统一进入新状态层。 |
|
||||
| `runtime.npcTurnStream` | `POST /api/runtime/chat/npc/turn/stream` | `A-CHAT` | 保留 SSE contract;单回合发言的判定与状态回写统一收口。 |
|
||||
|
||||
### 7.5 custom world gallery / library / sessions / works
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `runtime.customWorldGalleryList` | `GET /api/runtime/custom-world-gallery` | `A-CW` | 保留原路径;公开画廊改读 `custom_world_gallery view`。 |
|
||||
| `runtime.customWorldGalleryDetail` | `GET /api/runtime/custom-world-gallery/:ownerUserId/:profileId` | `A-CW` | 保留原路径;详情改读 gallery detail view。 |
|
||||
| `runtime.customWorldLibraryList` | `GET /api/runtime/custom-world-library` | `A-CW` | 保留原路径;资料库改读用户 custom world view。 |
|
||||
| `runtime.customWorldLibraryDelete` | `DELETE /api/runtime/custom-world-library/:profileId` | `A-CW` | 保留原路径;删除改为 reducer 或软删除标记。 |
|
||||
| `runtime.customWorldLibraryUpsert` | `PUT /api/runtime/custom-world-library/:profileId` | `A-CW` | 保留原路径;写入改为 Axum facade + SpacetimeDB profile tables。 |
|
||||
| `runtime.customWorldLibraryPublish` | `POST /api/runtime/custom-world-library/:profileId/publish` | `A-CW` | 保留原路径;发布改为状态切换与画廊投影刷新。 |
|
||||
| `runtime.customWorldLibraryUnpublish` | `POST /api/runtime/custom-world-library/:profileId/unpublish` | `A-CW` | 保留原路径;撤回发布改为状态切换。 |
|
||||
| `runtime.customWorldSessionCreate` | `POST /api/runtime/custom-world/sessions` | `A-CW` | 保留原路径;传统问答会话状态迁到 SpacetimeDB。 |
|
||||
| `runtime.customWorldSessionGet` | `GET /api/runtime/custom-world/sessions/:sessionId` | `A-CW` | 保留原路径;读取传统问答会话改读 view。 |
|
||||
| `runtime.customWorldSessionAnswer` | `POST /api/runtime/custom-world/sessions/:sessionId/answers` | `A-CW` | 保留原路径;回答动作改为 reducer 写会话状态。 |
|
||||
| `runtime.customWorldSessionGenerateStream` | `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream` | `A-CW` | 保留 SSE contract;编译过程由 Axum 流式回推并回写状态。 |
|
||||
| `runtime.customWorldWorksList` | `GET /api/runtime/custom-world/works` | `A-CW` | 保留原路径;作品汇总改读 custom world work summary view。 |
|
||||
|
||||
### 7.6 custom world agent
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `runtime.customWorldAgentCreateSession` | `POST /api/runtime/custom-world/agent/sessions` | `A-AGENT` | 保留原路径;Agent 会话创建改写 `custom_world_agent_session`。 |
|
||||
| `runtime.customWorldAgentGetSession` | `GET /api/runtime/custom-world/agent/sessions/:sessionId` | `A-AGENT` | 保留原路径;会话快照改读 Agent session view。 |
|
||||
| `runtime.customWorldAgentExecuteAction` | `POST /api/runtime/custom-world/agent/sessions/:sessionId/actions` | `A-AGENT` | 保留原路径;动作编排由 Axum 执行,状态与操作记录进 SpacetimeDB。 |
|
||||
| `runtime.customWorldAgentGetCardDetail` | `GET /api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` | `A-AGENT` | 保留原路径;卡片详情改读 `custom_world_draft_card`。 |
|
||||
| `runtime.customWorldAgentSendMessage` | `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages` | `A-AGENT` | 保留原路径;消息提交后由 Axum 触发编排,消息与操作状态入库。 |
|
||||
| `runtime.customWorldAgentStreamMessage` | `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` | `A-AGENT` | 保留 SSE contract;流式消息由 Axum 输出,Agent 状态表持续更新。 |
|
||||
| `runtime.customWorldAgentGetOperation` | `GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId` | `A-AGENT` | 保留原路径;操作状态改读 `custom_world_agent_operation view`。 |
|
||||
|
||||
### 7.7 compat 路径
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `runtime.customWorldEntity.compat` | `POST /api/runtime/custom-world/entity` | `A-CW` | 保留兼容路径;与主路径共用同一 handler。 |
|
||||
| `runtime.customWorldSceneNpc.compat` | `POST /api/runtime/custom-world/scene-npc` | `A-CW` | 保留兼容路径;与主路径共用同一 handler。 |
|
||||
| `runtime.profileBrowseHistoryDelete.compat` | `DELETE /api/runtime/profile/browse-history` | `A-RUNTIME` | 保留兼容路径;与 `/api/profile/browse-history` 共用实现。 |
|
||||
| `runtime.profileBrowseHistoryGet.compat` | `GET /api/runtime/profile/browse-history` | `A-RUNTIME` | 保留兼容路径;共用 browse history facade。 |
|
||||
| `runtime.profileBrowseHistoryPost.compat` | `POST /api/runtime/profile/browse-history` | `A-RUNTIME` | 保留兼容路径;共用写入逻辑。 |
|
||||
| `runtime.profileDashboard.compat` | `GET /api/runtime/profile/dashboard` | `A-RUNTIME` | 保留兼容路径;共用 dashboard facade。 |
|
||||
| `runtime.profilePlayStats.compat` | `GET /api/runtime/profile/play-stats` | `A-RUNTIME` | 保留兼容路径;共用 play stats facade。 |
|
||||
| `runtime.profileSaveArchivesList.compat` | `GET /api/runtime/profile/save-archives` | `A-RUNTIME` | 保留兼容路径;共用 save archives list facade。 |
|
||||
| `runtime.profileSaveArchivesResume.compat` | `POST /api/runtime/profile/save-archives/:worldKey` | `A-RUNTIME` | 保留兼容路径;共用 resume facade。 |
|
||||
| `runtime.profileWalletLedger.compat` | `GET /api/runtime/profile/wallet-ledger` | `A-RUNTIME` | 保留兼容路径;共用 wallet ledger facade。 |
|
||||
|
||||
### 7.8 runtime 其他核心接口
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `runtime.itemsIntent` | `POST /api/runtime/items/runtime-intent` | `A-CW` | 保留原路径;Axum 调 LLM 生成意图,物品领域状态与引用写 SpacetimeDB。 |
|
||||
| `runtime.questsGenerate` | `POST /api/runtime/quests/generate` | `A-CW` | 保留原路径;任务候选生成由 Axum 编排,结果写 quest 相关表。 |
|
||||
| `runtime.snapshotDelete` | `DELETE /api/runtime/save/snapshot` | `A-RUNTIME` | 保留原路径;删除动作改为更新 `runtime_snapshot` / archive。 |
|
||||
| `runtime.snapshotGet` | `GET /api/runtime/save/snapshot` | `A-RUNTIME` | 保留原路径;读取兼容聚合快照,由 view/projection 输出。 |
|
||||
| `runtime.snapshotPut` | `PUT /api/runtime/save/snapshot` | `A-RUNTIME` | 保留原路径;写入由 Axum facade + reducer 完成。 |
|
||||
| `runtime.settingsGet` | `GET /api/runtime/settings` | `A-RUNTIME` | 保留原路径;设置改读 `runtime_setting view`。 |
|
||||
| `runtime.settingsPut` | `PUT /api/runtime/settings` | `A-RUNTIME` | 保留原路径;设置更新改为 reducer。 |
|
||||
| `runtime.storyContinue` | `POST /api/runtime/story/continue` | `A-STORY` | 保留原路径;故事推进由 Axum 调新 story/application 层。 |
|
||||
| `runtime.storyInitial` | `POST /api/runtime/story/initial` | `A-STORY` | 保留原路径;首段故事生成保持 REST 兼容。 |
|
||||
| `runtime.wsHealth` | `GET /api/ws/health` | `A-RUNTIME` | 保留原路径;继续作为实时链路占位健康检查。 |
|
||||
|
||||
## 8. `runtime-story-action` 路由映射(2 条)
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `storyAction.resolve` | `POST /api/runtime/story/actions/resolve` | `A-STORY` | 保留原路径;Axum 接收动作请求,SpacetimeDB reducer 执行跨模块结算。 |
|
||||
| `storyAction.stateGet` | `GET /api/runtime/story/state/:sessionId` | `A-STORY` | 保留原路径;读取 story session 兼容状态 view。 |
|
||||
|
||||
## 9. `health` 路由映射(1 条)
|
||||
|
||||
| 旧路由 ID | 方法/路径 | 新实现归属 | 第一阶段迁移策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| `health.check` | `GET /healthz` | `A-HEALTH` | 保留原路径与最小返回结构。 |
|
||||
|
||||
## 10. 迁移落地规则
|
||||
|
||||
后续做路由树时,必须遵守:
|
||||
|
||||
1. 旧路径优先保留,新实现从内部切换,不先要求前端改地址。
|
||||
2. 主路径与兼容路径必须共用同一 application service,避免再次出现双份逻辑。
|
||||
3. `stream` 接口第一阶段默认沿用 SSE。
|
||||
4. `assets` 与 `custom-world` 里的生成类接口,外部副作用统一在 Axum,状态与任务统一进 SpacetimeDB。
|
||||
5. `storyAction.resolve`、`runtime.snapshotPut`、`auth.refresh` 属于最优先回归接口,后续开发必须优先补完整测试。
|
||||
6. `editor` 相关旧路径只保留历史基线记录,不纳入 `server-rs` 路由树实施范围。
|
||||
|
||||
## 11. 本任务完成定义
|
||||
|
||||
当以下条件成立时,这条任务视为完成:
|
||||
|
||||
1. `96` 条旧路由都已经有新实现落点。
|
||||
2. 每条路由至少明确:
|
||||
- 旧方法/路径
|
||||
- 新实现归属
|
||||
- 第一阶段迁移策略
|
||||
3. 后续搭建 Axum 路由树与 application service 时,可以直接按这份矩阵逐项落位。
|
||||
@@ -1,300 +0,0 @@
|
||||
# M0:SSE 接口与事件格式冻结基线
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
依据来源:
|
||||
|
||||
- [../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md](../docs/technical/NODE_BACKEND_MODULE_AND_API_INDEX.md)
|
||||
- [../server-node/manifests/backend-capability-index.json](../server-node/manifests/backend-capability-index.json)
|
||||
- `server-node/src/http.ts`
|
||||
- `server-node/src/routes/runtimeRoutes.ts`
|
||||
- `server-node/src/routes/customWorldAgent.ts`
|
||||
- `server-node/src/modules/ai/chatOrchestrator.ts`
|
||||
- `server-node/src/services/customWorldAgentOrchestrator.ts`
|
||||
- `server-node/src/modules/ai/customWorldOrchestrator.ts`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于完成 `M0` 的第四条任务:
|
||||
|
||||
- 整理当前所有 SSE 接口与事件格式
|
||||
|
||||
这里的“整理”不是只记住有几条 `stream` 路由,而是要求后续 Axum 重写必须先冻结:
|
||||
|
||||
1. 当前到底有哪几条 SSE 路由。
|
||||
2. 每条路由是“透传上游流”还是“项目自定义事件流”。
|
||||
3. 每条路由的事件名、结束标记、错误帧和头部约束是什么。
|
||||
4. 哪些流的 `payload` 是增量文本,哪些其实是“累计文本”。
|
||||
|
||||
## 2. 冻结结论
|
||||
|
||||
当前 Node 后端正式登记的 SSE 接口固定为以下 `6` 条:
|
||||
|
||||
| 路由 ID | 方法/路径 | 当前实现入口 | 协议类型 | 成功结束标记 | 鉴权 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `runtime.characterReplyStream` | `POST /api/runtime/chat/character/reply/stream` | `runtimeRoutes.ts -> streamCharacterChatReplyFromOrchestrator` | 上游透传 SSE | 上游 `data: [DONE]` | JWT |
|
||||
| `runtime.npcDialogueStream` | `POST /api/runtime/chat/npc/dialogue/stream` | `runtimeRoutes.ts -> streamNpcChatDialogueFromOrchestrator` | 上游透传 SSE | 上游 `data: [DONE]` | JWT |
|
||||
| `runtime.npcRecruitStream` | `POST /api/runtime/chat/npc/recruit/stream` | `runtimeRoutes.ts -> streamNpcRecruitDialogueFromOrchestrator` | 上游透传 SSE | 上游 `data: [DONE]` | JWT |
|
||||
| `runtime.npcTurnStream` | `POST /api/runtime/chat/npc/turn/stream` | `runtimeRoutes.ts -> streamNpcChatTurnFromOrchestrator` | 项目自定义 SSE | `event: complete` 后追加 `data: [DONE]` | JWT |
|
||||
| `runtime.customWorldSessionGenerateStream` | `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream` | `runtimeRoutes.ts` 内联实现 | 项目自定义 SSE | `event: done`,无 `[DONE]` | JWT |
|
||||
| `runtime.customWorldAgentStreamMessage` | `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` | `customWorldAgent.ts -> customWorldAgentOrchestrator.streamMessage` | 项目自定义 SSE | `event: done`,无 `[DONE]` | JWT |
|
||||
|
||||
冻结总数:
|
||||
|
||||
1. SSE 接口:`6`
|
||||
2. 上游透传型:`3`
|
||||
3. 本地自定义事件流:`3`
|
||||
|
||||
## 3. 全部 SSE 接口共享的响应头约束
|
||||
|
||||
当前所有项目内主动准备 SSE 响应的接口,都经过 `prepareEventStreamResponse(...)`,因此至少冻结以下头部行为:
|
||||
|
||||
| 响应头 | 当前值/规则 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Content-Type` | 默认 `text/event-stream; charset=utf-8` | 透传型接口可被上游 `content-type` 覆盖,但仍保持 SSE。 |
|
||||
| `Cache-Control` | `no-cache` | 禁止中间层缓存流式结果。 |
|
||||
| `Connection` | `keep-alive` | 保持 SSE 长连接。 |
|
||||
| `X-Accel-Buffering` | `no` | 禁止代理层缓冲。 |
|
||||
| `x-request-id` | 透传当前请求 ID | 所有 SSE 都要带请求追踪头。 |
|
||||
| `x-api-version` | 当前 API 版本号 | 与普通 JSON 接口一致。 |
|
||||
| `x-route-version` | 当前路由版本号 | 与普通 JSON 接口一致。 |
|
||||
| `x-response-time-ms` | 当前已耗时毫秒数 | 在准备响应头时写入。 |
|
||||
|
||||
额外冻结约束:
|
||||
|
||||
1. `SSE` 接口当前也保留普通 API 元数据头,不能因为换成 Axum 就丢掉。
|
||||
2. 这 `6` 条流式接口都在 `requireAuth` 之后注册,因此第一阶段默认仍需要 `Bearer JWT`。
|
||||
|
||||
## 4. 协议分型
|
||||
|
||||
### 4.1 上游透传型 SSE(3 条)
|
||||
|
||||
包含:
|
||||
|
||||
1. `POST /api/runtime/chat/character/reply/stream`
|
||||
2. `POST /api/runtime/chat/npc/dialogue/stream`
|
||||
3. `POST /api/runtime/chat/npc/recruit/stream`
|
||||
|
||||
当前实现特征:
|
||||
|
||||
1. 路由不自己重写事件名,直接把上游模型返回的 SSE 原样管道转发给前端。
|
||||
2. 本地只负责:
|
||||
- 发起上游流式请求
|
||||
- 准备 SSE 头部
|
||||
- 处理中断时的请求 abort
|
||||
3. 从 `llmClient.streamMessageContent(...)` 的解析逻辑可以反推,当前上游 SSE 采用 OpenAI 风格:
|
||||
- 多个 `data: {...}` JSON chunk
|
||||
- 最终 `data: [DONE]`
|
||||
|
||||
冻结要求:
|
||||
|
||||
1. 第一阶段 Axum 仍要保持这三条接口的“上游透传”语义。
|
||||
2. 不要在未发版变更协议前,擅自把它们改成项目自定义 `event: reply_delta` 格式。
|
||||
|
||||
### 4.2 项目自定义 SSE(3 条)
|
||||
|
||||
包含:
|
||||
|
||||
1. `POST /api/runtime/chat/npc/turn/stream`
|
||||
2. `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream`
|
||||
3. `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`
|
||||
|
||||
当前实现特征:
|
||||
|
||||
1. 路由或 orchestrator 自己写 `event:` 与 `data:`。
|
||||
2. 事件名不是上游协议,而是项目本地约定。
|
||||
3. 这三条流的结束方式并不一致,必须分别兼容。
|
||||
|
||||
## 5. 各接口事件格式冻结
|
||||
|
||||
### 5.1 `runtime.characterReplyStream`
|
||||
|
||||
路径:
|
||||
|
||||
- `POST /api/runtime/chat/character/reply/stream`
|
||||
|
||||
冻结格式:
|
||||
|
||||
1. 当前为上游透传流。
|
||||
2. 本地不保证固定 `event` 名。
|
||||
3. 前端实际收到的是上游 `data: {...}` chunk 与最终 `data: [DONE]`。
|
||||
4. 失败时当前实现也不是本地 `event: error`,而是由上游失败或 Express 错误链决定。
|
||||
|
||||
### 5.2 `runtime.npcDialogueStream`
|
||||
|
||||
路径:
|
||||
|
||||
- `POST /api/runtime/chat/npc/dialogue/stream`
|
||||
|
||||
冻结格式:
|
||||
|
||||
1. 当前为上游透传流。
|
||||
2. 协议特征与 `runtime.characterReplyStream` 相同。
|
||||
3. 第一阶段不能私自改成项目自定义事件名。
|
||||
|
||||
### 5.3 `runtime.npcRecruitStream`
|
||||
|
||||
路径:
|
||||
|
||||
- `POST /api/runtime/chat/npc/recruit/stream`
|
||||
|
||||
冻结格式:
|
||||
|
||||
1. 当前为上游透传流。
|
||||
2. 协议特征与前两条透传 SSE 相同。
|
||||
3. 结束标记仍依赖上游 `data: [DONE]`。
|
||||
|
||||
### 5.4 `runtime.npcTurnStream`
|
||||
|
||||
路径:
|
||||
|
||||
- `POST /api/runtime/chat/npc/turn/stream`
|
||||
|
||||
成功事件序列:
|
||||
|
||||
1. `event: reply_delta`
|
||||
2. `event: reply_delta`
|
||||
3. `...`
|
||||
4. `event: complete`
|
||||
5. `data: [DONE]`
|
||||
|
||||
错误事件:
|
||||
|
||||
1. `event: error`
|
||||
2. `data: {"message":"..."}`
|
||||
3. 之后直接 `response.end()`,不会再补 `complete`
|
||||
|
||||
冻结 payload 规则:
|
||||
|
||||
| 事件名 | payload 结构 | 关键说明 |
|
||||
| --- | --- | --- |
|
||||
| `reply_delta` | `{ "text": string }` | `text` 实际是“累计文本”,不是单 token 增量。 |
|
||||
| `complete` | `{ "npcReply": string, "affinityDelta": number, "affinityText": string, "suggestions": string[], "pendingQuestOffer": object \| null, "chatDirective": object \| null }` | 最终一次性返回业务结算数据。 |
|
||||
| `error` | `{ "message": string }` | 仅错误消息,无额外状态。 |
|
||||
|
||||
补充冻结点:
|
||||
|
||||
1. `reply_delta.text` 每次都是当前累计回复全文。
|
||||
2. `complete.suggestions` 在强制收束场景下可能是空数组。
|
||||
3. `complete.chatDirective` 当前至少可能包含:
|
||||
- `turnLimit`
|
||||
- `remainingTurns`
|
||||
- `forceExit`
|
||||
- `closingMode`
|
||||
4. `complete.pendingQuestOffer` 当前可能包含:
|
||||
- `quest`
|
||||
- `introText`
|
||||
|
||||
### 5.5 `runtime.customWorldSessionGenerateStream`
|
||||
|
||||
路径:
|
||||
|
||||
- `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream`
|
||||
|
||||
成功事件序列:
|
||||
|
||||
1. `event: progress`,payload:`{ "phase": "preparing", "progress": 10 }`
|
||||
2. `event: progress`,payload:`{ "phase": "requesting_llm", "progress": 45 }`
|
||||
3. `event: progress`,payload:`CustomWorldGenerationProgress`
|
||||
4. `...`
|
||||
5. `event: progress`,payload:`{ "phase": "completed", "progress": 100 }`
|
||||
6. `event: result`
|
||||
7. `event: done`
|
||||
|
||||
错误事件:
|
||||
|
||||
1. `event: error`
|
||||
2. `data: {"message":"..."}`
|
||||
3. 之后直接结束,不会再发 `done`
|
||||
|
||||
冻结 payload 规则:
|
||||
|
||||
| 事件名 | payload 结构 | 关键说明 |
|
||||
| --- | --- | --- |
|
||||
| `progress` | 兼容两种结构 | 这是当前最容易踩坑的混合协议。 |
|
||||
| `result` | `{ "profile": object }` | 返回完整世界 profile。 |
|
||||
| `done` | `{ "ok": true }` | 当前没有 `[DONE]` 字符串终止帧。 |
|
||||
| `error` | `{ "message": string }` | 当前也没有额外错误码。 |
|
||||
|
||||
`progress` 事件的两种冻结结构:
|
||||
|
||||
1. 启动/收尾帧:
|
||||
- `{ "phase": "preparing", "progress": 10 }`
|
||||
- `{ "phase": "requesting_llm", "progress": 45 }`
|
||||
- `{ "phase": "completed", "progress": 100 }`
|
||||
2. 编排器进度帧 `CustomWorldGenerationProgress`:
|
||||
- `phaseId`
|
||||
- `phaseLabel`
|
||||
- `phaseDetail`
|
||||
- `overallProgress`
|
||||
- `completedWeight`
|
||||
- `totalWeight`
|
||||
- `elapsedMs`
|
||||
- `estimatedRemainingMs`
|
||||
- `activeStepIndex`
|
||||
- `steps`
|
||||
|
||||
补充冻结点:
|
||||
|
||||
1. 当前 `progress` 不是单一 schema,而是混合 schema。
|
||||
2. 当前实现会在客户端断开时触发 `AbortController`,这条流具备显式中断处理。
|
||||
|
||||
### 5.6 `runtime.customWorldAgentStreamMessage`
|
||||
|
||||
路径:
|
||||
|
||||
- `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`
|
||||
|
||||
成功事件序列:
|
||||
|
||||
1. `event: reply_delta`
|
||||
2. `event: reply_delta`
|
||||
3. `...`
|
||||
4. `event: session`
|
||||
5. `event: done`
|
||||
|
||||
错误事件:
|
||||
|
||||
1. `event: error`
|
||||
2. `data: {"message":"..."}`
|
||||
3. 之后直接结束,不会再补 `done`
|
||||
|
||||
冻结 payload 规则:
|
||||
|
||||
| 事件名 | payload 结构 | 关键说明 |
|
||||
| --- | --- | --- |
|
||||
| `reply_delta` | `{ "text": string }` | 当前也是累计文本,不是 diff patch。 |
|
||||
| `session` | `{ "session": CustomWorldAgentSessionSnapshot }` | 完整会话快照一次性回推。 |
|
||||
| `done` | `{ "ok": true }` | 当前没有 `[DONE]`。 |
|
||||
| `error` | `{ "message": string }` | 仅错误消息。 |
|
||||
|
||||
补充冻结点:
|
||||
|
||||
1. 这条流当前不会在成功结尾补发最终文本帧,只会发 `session` 快照。
|
||||
2. `reply_delta.text` 同样是“到当前为止的完整回复”。
|
||||
3. 当前实现没有像 `customWorldSessionGenerateStream` 那样显式挂请求断开 abort。
|
||||
|
||||
## 6. 第一阶段 Axum 重写必须兼容的硬约束
|
||||
|
||||
后续重写中,不允许出现以下情况:
|
||||
|
||||
1. 把当前 `6` 条 SSE 路由减少、合并或改掉方法类型。
|
||||
2. 把透传型 `3` 条流直接改写成自定义事件名,而前端却不知情。
|
||||
3. 把 `npcTurnStream` 的 `reply_delta` 从“累计文本”改成“真正 delta”,导致前端拼接方式失效。
|
||||
4. 把 `customWorldSessionGenerateStream` 的混合 `progress` schema 静默改成新格式,却没有版本门禁。
|
||||
5. 把 `customWorldAgentStreamMessage` 的 `session` 终帧改成局部 patch,而前端仍按完整快照消费。
|
||||
6. 丢失 `x-request-id`、`x-api-version`、`x-route-version`、`x-response-time-ms` 等当前前端与联调用到的头。
|
||||
|
||||
## 7. 本任务完成定义
|
||||
|
||||
当以下条件成立时,这条任务视为完成:
|
||||
|
||||
1. 当前 `6` 条 SSE 接口已经有书面冻结清单。
|
||||
2. 每条 SSE 都已明确:
|
||||
- 方法与路径
|
||||
- 协议类型
|
||||
- 事件名
|
||||
- 成功结束标记
|
||||
- 错误事件
|
||||
- 关键 payload 结构
|
||||
3. 后续 Axum SSE 落地、前端 contract 回归、SpacetimeDB 实时链路设计时,可以直接引用本文,不再靠人工回忆事件名。
|
||||
@@ -1,36 +0,0 @@
|
||||
# 后端重写任务清单目录
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
本目录用于集中存放 `SpacetimeDB + Axum + 阿里云 OSS` 后端重写相关任务清单。
|
||||
|
||||
## 文件结构
|
||||
|
||||
- [00_MASTER_TASKLIST.md](./00_MASTER_TASKLIST.md):总纲主清单,保留完整阶段结构与最终验收项。
|
||||
- [01_M0_M2_FOUNDATION_AND_AUTH.md](./01_M0_M2_FOUNDATION_AND_AUTH.md):能力冻结、Rust 工作区、Axum 基础设施、鉴权与会话迁移任务。
|
||||
- [02_M3_RUNTIME_PROFILE.md](./02_M3_RUNTIME_PROFILE.md):runtime snapshot / settings / profile 迁移任务。
|
||||
- [03_M4_STORY_AND_GAMEPLAY.md](./03_M4_STORY_AND_GAMEPLAY.md):story action 主循环与 gameplay reducer 迁移任务。
|
||||
- [04_M5_CUSTOM_WORLD_AND_AGENT.md](./04_M5_CUSTOM_WORLD_AND_AGENT.md):custom world / gallery / agent 主链迁移任务。
|
||||
- [05_M6_ASSETS_OSS_EDITOR.md](./05_M6_ASSETS_OSS_EDITOR.md):assets / 阿里云 OSS 迁移任务;`editor` 已于 `2026-04-21` 退出本轮重写范围。
|
||||
- [06_M7_TEST_DEPLOY_CUTOVER.md](./06_M7_TEST_DEPLOY_CUTOVER.md):联调、回归、部署、观测与切流任务。
|
||||
- [07_CROSS_CUTTING_AND_ACCEPTANCE.md](./07_CROSS_CUTTING_AND_ACCEPTANCE.md):横向专项、执行顺序与最终验收清单。
|
||||
- [M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md](./M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md):当前 Node 后端 `6` 个挂载面的冻结基线,用于后续接口映射、模块迁移与验收对照。
|
||||
- [M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md](./M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md):当前 `96` 条后端路由的“旧接口 -> 新实现”迁移矩阵,用于 Axum 路由树和 application service 落位。
|
||||
- [M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md):当前 `12` 个内部模块的迁移归属基线,用于锁定 Rust crate、SpacetimeDB bounded context 与 Axum/application 分工。
|
||||
- [M0_SSE_INTERFACE_BASELINE_2026-04-20.md](./M0_SSE_INTERFACE_BASELINE_2026-04-20.md):当前 `6` 条 SSE 接口及其事件格式冻结基线,用于 Axum SSE 兼容和前端 contract 回归。
|
||||
- [M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md](./M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md):当前正式 `/generated-*` 静态资源前缀冻结基线,用于 Axum 静态资源兼容层与 OSS 对象键规划。
|
||||
- [M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md](./M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md):当前前端直接依赖的响应头、envelope 与错误格式冻结基线,用于 Axum 中间件与错误响应兼容。
|
||||
- [M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](./M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md):`M0` 仓库边界决议文档,用于持续冻结 `server-rs/` 落位、迁移期双栈共存、Axum 边界与副作用收口原则。
|
||||
- [M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](./M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md):`M0 ~ M7` 阶段验收矩阵,用于固定每阶段的入口条件、核心交付、退出条件与跨阶段回归焦点。
|
||||
|
||||
## 当前 M4 / M5 结构基线
|
||||
|
||||
- `M4` 当前涉及的前后端脚本结构、命名根、route/service/compiler/repository 落位,统一参照 [../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)。
|
||||
- `M5` 当前涉及的创作入口、Agent session、result preview、works/library/gallery、publish 与 enter-world 主链,统一参照 [../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](../docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md)。
|
||||
- 旧 `custom-world/sessions` 传统问答链已经退出当前仓库正式主链;后续若在 `M5` 中提及,只按历史兼容台账处理,不再作为当前功能扩展目标。
|
||||
|
||||
## 维护规则
|
||||
|
||||
1. 总纲与拆分文件都以本目录为唯一维护位置。
|
||||
2. 总纲用于把控全局节奏,拆分文件用于实际逐项推进。
|
||||
3. 如阶段任务发生明显变化,需要同步更新总纲与对应拆分文件。
|
||||
@@ -1,15 +1,15 @@
|
||||
# 资产生成叙世币消耗接入方案
|
||||
# 资产操作叙世币消耗接入方案
|
||||
|
||||
## 背景
|
||||
|
||||
当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成由 Axum API 调用外部模型并写入 OSS,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此扣费需要拆成两层:
|
||||
当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成和作品发布由 Axum API 调用外部模型或写入业务状态,SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此计费需要拆成两层:
|
||||
|
||||
- SpacetimeDB 负责钱包余额和流水的原子变更。
|
||||
- Axum 负责在发起外部生成前扣费,并在生成或持久化失败时补偿退款。
|
||||
- Axum 资产操作服务负责在执行业务资产操作前扣费,并在生成、持久化或发布失败时补偿退款。
|
||||
|
||||
## 首期范围
|
||||
|
||||
首期接入带 Bearer 身份、能明确归属真实用户的资产生成与发布入口:
|
||||
首期接入带 Bearer 身份、能明确归属真实用户的资产操作入口:
|
||||
|
||||
- `POST /api/custom-world/scene-image`
|
||||
- `POST /api/custom-world/cover-image`
|
||||
@@ -26,28 +26,27 @@
|
||||
- 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。
|
||||
- 手动上传封面:不调用外部生成模型,不消耗叙世币。
|
||||
- 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。
|
||||
- 文本实体、NPC 生成:本次需求聚焦资产生成,首期只覆盖图片资产。
|
||||
- 文本实体、NPC 生成:本次需求聚焦图片资产和发布资产操作,首期只覆盖可明确归属的入口。
|
||||
|
||||
## 计费规则
|
||||
|
||||
- 每次图片资产生成请求消耗 `1` 枚叙世币。
|
||||
- 每次作品发布请求消耗 `1` 枚叙世币;余额不足时禁止发布。
|
||||
- 在调用外部图片生成前预扣,余额不足时直接返回业务错误,不调用外部模型。
|
||||
- 发布请求在写入发布状态前预扣,余额不足时直接返回业务错误,不调用发布 mutation。
|
||||
- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,Axum 自动发起同额退款。
|
||||
- 每次可计费资产操作消耗 `1` 枚叙世币。
|
||||
- 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行。
|
||||
- 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作。
|
||||
- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款。
|
||||
- 如果退款失败,原始错误仍返回给调用方,同时服务端日志记录退款失败,便于后续人工核对。
|
||||
|
||||
## 钱包流水
|
||||
|
||||
新增两个流水来源类型,首期同时覆盖“资产生成”和“资产发布”这两类资产操作:
|
||||
公开两个流水来源类型,统一覆盖“资产生成”和“资产发布”这两类资产操作:
|
||||
|
||||
- `asset_generation_consume`:资产生成预扣,`amount_delta = -1`。
|
||||
- `asset_generation_refund`:资产生成失败退款,`amount_delta = +1`。
|
||||
- `asset_operation_consume`:资产操作预扣,`amount_delta = -1`。
|
||||
- `asset_operation_refund`:资产操作失败退款,`amount_delta = +1`。
|
||||
|
||||
`wallet_ledger_id` 由 Axum 传入,格式:
|
||||
|
||||
- 扣费:`asset_generation_consume:{user_id}:{asset_kind}:{asset_id}`
|
||||
- 退款:`asset_generation_refund:{user_id}:{asset_kind}:{asset_id}`
|
||||
- 扣费:`asset_operation_consume:{user_id}:{asset_kind}:{asset_id}`
|
||||
- 退款:`asset_operation_refund:{user_id}:{asset_kind}:{asset_id}`
|
||||
|
||||
SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID 已存在,则直接返回当前钱包快照,不重复变更余额。
|
||||
|
||||
@@ -56,9 +55,10 @@ SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID
|
||||
- `module-runtime`:新增钱包调整输入、钱包调整结果、流水来源枚举。
|
||||
- `spacetime-module`:新增 `consume_profile_wallet_points_and_return` 与 `refund_profile_wallet_points_and_return` procedure,并扩展钱包变更 helper 支持负数。
|
||||
- `spacetime-client`:新增对应调用方法和绑定类型。
|
||||
- `api-server`:在自定义世界图片生成与发布入口前扣费,错误分支退款。
|
||||
- `api-server`:资产操作服务提供统一可计费执行入口,自定义世界、Big Fish、Puzzle 业务 handler 只声明资产操作,不直接调用钱包扣费或退款。
|
||||
- `shared-contracts`:新增 API 流水来源常量,保证“我的-钱包流水”输出使用稳定契约字符串。
|
||||
- `packages/shared` 与前端:统一使用 `asset_operation_consume` / `asset_operation_refund` 展示钱包流水。
|
||||
|
||||
## 非目标
|
||||
|
||||
本次不做分档价格、不做会员免扣、不做前端计费展示改造,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。
|
||||
本次不做分档价格、不做会员免扣,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。旧资产生成流水 source 不再作为公开契约兼容。
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
6. 启动测试运行态
|
||||
7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决
|
||||
|
||||
### 1.1 2026-04-27 公开游玩次数补充
|
||||
|
||||
正式发布的大鱼吃小鱼作品需要记录公开游玩次数,落地口径如下:
|
||||
|
||||
1. `big_fish_creation_session.play_count` 保存该作品被正式启动的次数,默认值为 `0`。
|
||||
2. 只有平台作品详情、作品架等正式入口启动已发布作品时递增;创作结果页内的测试运行不计入。
|
||||
3. 前端作品摘要 contract 暴露 `playCount`,作品架展示与拼图一致使用该后端值。
|
||||
4. 本轮仅记录“进入玩法”次数,不记录大鱼吃小鱼总时长;个人 profile 的 RPG 时长统计仍由 runtime snapshot 负责。
|
||||
5. schema 变更需要同步 `migration.rs` 已纳入的 `big_fish_creation_session` 导入导出结构。
|
||||
|
||||
## 2. 本轮明确不做
|
||||
|
||||
1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。
|
||||
|
||||
@@ -17,6 +17,19 @@
|
||||
4. 入口必须在移动端单手可点,不遮挡舞台主体。
|
||||
5. 规则内容只做说明,不参与任何前端裁决;真实规则仍以后端运行快照为准。
|
||||
|
||||
## 游玩统计规则
|
||||
|
||||
所有作品都需要对自身以及用户做游玩统计。
|
||||
|
||||
大鱼吃小鱼正式运行时必须遵守:
|
||||
|
||||
1. 正式开始游玩已发布作品时,更新作品自身播放统计。
|
||||
2. 已登录用户写入 `profile_played_world`,`world_key = big-fish:{session_id}`。
|
||||
3. `profile_id` 保存大鱼作品号/会话号,`world_type = BIG_FISH`。
|
||||
4. `world_title` 使用玩法草稿标题,`world_subtitle` 优先使用副标题,其次使用核心乐趣。
|
||||
5. `owner_user_id` 使用大鱼作品归属用户 ID。
|
||||
6. 退出或结算上报 `elapsedMs` 后,后端按增量刷新 `profile_dashboard_state.total_play_time_ms` 和明细中的 `last_observed_play_time_ms`。
|
||||
|
||||
## 落地范围
|
||||
|
||||
1. `src/components/big-fish-runtime/BigFishRuntimeShell.tsx`
|
||||
|
||||
@@ -169,6 +169,24 @@ Node 侧入口位于:
|
||||
|
||||
## 4. 本轮边界决议
|
||||
|
||||
### 4.0 统一游玩统计规则
|
||||
|
||||
所有作品都需要对自身以及用户做游玩统计。
|
||||
|
||||
正式游玩开始时,玩法自己的作品真相表必须先更新自身统计;已登录用户还必须同步 upsert `profile_played_world` 明细。用户侧明细不是单纯计数,必须保留可跳转的稳定作品标识:
|
||||
|
||||
1. `world_key`
|
||||
2. `world_type`
|
||||
3. `profile_id`
|
||||
4. `world_title`
|
||||
5. `world_subtitle`
|
||||
6. `owner_user_id`
|
||||
7. `first_played_at`
|
||||
8. `last_played_at`
|
||||
9. `last_observed_play_time_ms`
|
||||
|
||||
当玩法有可观测时长时,后端按增量刷新 `profile_dashboard_state.total_play_time_ms`,并同步推进对应 `profile_played_world.last_observed_play_time_ms`。
|
||||
|
||||
### 4.1 先做 projection 读链
|
||||
|
||||
本轮 profile 三接口只做:
|
||||
@@ -377,6 +395,17 @@ Node 侧入口位于:
|
||||
|
||||
这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。
|
||||
|
||||
## 10.1 2026-04-27 统计写链修正
|
||||
|
||||
`runtime_snapshot / save archive` 主链已接入后,profile projection 的写入语义补充冻结如下:
|
||||
|
||||
1. 正式 RPG 游玩只通过 `PUT /api/runtime/save/snapshot` 刷新 `profile_dashboard_state` 与 `profile_played_world`。
|
||||
2. `runtimeMode = "preview"`、`runtimeMode = "test"` 或 `runtimePersistenceDisabled = true` 的快照不刷新 profile projection。
|
||||
3. 前端发起自动保存与手动保存前,必须先把 `runtimeStats.lastPlayTickAt` 到当前时间的 live 时长同步进 `runtimeStats.playTimeMs`,避免 15 秒内进入又退出时保存 0。
|
||||
4. `profile_played_world` 的一行表示“当前用户玩过这个世界”,不是全站作品热度计数;`playedWorldCount` 读取当前用户的去重世界数。
|
||||
5. `profile_dashboard_state.total_play_time_ms` 通过同一用户同一世界的 `runtimeStats.playTimeMs - last_observed_play_time_ms` 增量累积,后端使用 `saturating_sub` 防止旧快照回退导致负增量。
|
||||
6. 作品卡上的公开热度计数如果需要覆盖 RPG 作品,应另立公开作品统计方案;不能把个人 `profile_played_world` 误当成全站作品 `playCount`。
|
||||
|
||||
## 11. 测试策略
|
||||
|
||||
### 11.1 必跑
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 密码登录入口历史落地设计
|
||||
|
||||
> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。
|
||||
>
|
||||
> 2026-04-28 更新:为开发期本地/测试服联调新增服务端环境变量 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED`,默认 `false`。仅当该变量显式为 `true` 时,`POST /api/auth/entry` 可对未知手机号用本次密码直接创建账号并登录;默认关闭时仍严格保持未知手机号返回 `401` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
@@ -166,6 +168,13 @@
|
||||
2. 不创建账号。
|
||||
3. 不写 `password_hash`。
|
||||
|
||||
开发期例外:
|
||||
|
||||
1. 当 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 时,未知手机号会创建手机号账号。
|
||||
2. 新账号立即写入本次密码的 `password_hash`,并将 `password_login_enabled` 置为 `true`。
|
||||
3. 成功响应沿用密码登录响应体,`created` 只保留在领域结果中,不额外暴露到当前 HTTP contract。
|
||||
4. 手机号格式和密码长度校验仍完全沿用正式密码入口规则。
|
||||
|
||||
### 8.2 未设置密码
|
||||
|
||||
当账号存在但 `password_login_enabled = false` 时:
|
||||
@@ -233,6 +242,8 @@
|
||||
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`。
|
||||
5. 登录成功时返回 access token。
|
||||
6. 登录成功时写回 refresh cookie。
|
||||
7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。
|
||||
8. 开关开启时,未知手机号可通过 `/api/auth/entry` 创建账号并登录;同手机号后续用相同密码登录复用同一用户,错误密码仍返回 `401`。
|
||||
|
||||
## 13. 完成定义
|
||||
|
||||
|
||||
131
docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md
Normal file
131
docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 资料兑换码模块落地设计
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本轮在现有“我的”资料与钱包 projection 上新增兑换码能力。用户兑换成功后直接增加叙世币余额,写入 `profile_wallet_ledger`,并同步刷新 `profile_dashboard_state.wallet_balance`。
|
||||
|
||||
管理侧本轮只提供后端 API,不新增管理后台页面。私有兑换码创建时支持内部 `userId` 与公开叙世号两类输入,后端创建阶段统一解析成内部 `userId` 存储。
|
||||
|
||||
## 2. 兑换码类型
|
||||
|
||||
`RuntimeProfileRedeemCodeMode` 固定为三种:
|
||||
|
||||
| 类型 | 规则 |
|
||||
| --- | --- |
|
||||
| `Public` | 任意用户可兑换,`max_uses` 按用户独立计算。 |
|
||||
| `Unique` | 任意用户可兑换,`max_uses` 全局共用。 |
|
||||
| `Private` | 仅 `allowed_user_ids` 中的用户可兑换,`max_uses` 全局共用。 |
|
||||
|
||||
兑换码入库前必须 `trim + uppercase`。空兑换码、奖励为 0、次数为 0 均拒绝。
|
||||
|
||||
## 3. 表结构
|
||||
|
||||
### 3.1 `profile_redeem_code`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `code` | `String` | 主键,标准化后的兑换码。 |
|
||||
| `mode` | `RuntimeProfileRedeemCodeMode` | 兑换码模式。 |
|
||||
| `reward_points` | `u64` | 单次到账叙世币。 |
|
||||
| `max_uses` | `u32` | 公共码为单用户上限,唯一码/私有码为全局上限。 |
|
||||
| `global_used_count` | `u32` | 全局已使用次数。公共码也记录总使用次数,但不参与公共码上限判断。 |
|
||||
| `enabled` | `bool` | 是否启用。 |
|
||||
| `allowed_user_ids` | `Vec<String>` | 私有码允许用户;公共/唯一码存空数组。 |
|
||||
| `created_by` | `String` | 管理员用户 ID。 |
|
||||
| `created_at` | `Timestamp` | 创建时间。 |
|
||||
| `updated_at` | `Timestamp` | 更新时间。 |
|
||||
|
||||
### 3.2 `profile_redeem_code_usage`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `usage_id` | `String` | 主键,格式 `redeem:{code}:{user_id}:{micros}:{sequence}`。 |
|
||||
| `code` | `String` | 兑换码。 |
|
||||
| `user_id` | `String` | 兑换用户。 |
|
||||
| `amount_granted` | `u64` | 到账叙世币。 |
|
||||
| `created_at` | `Timestamp` | 兑换时间。 |
|
||||
|
||||
索引:`code`、`user_id`、`(code, user_id)`。
|
||||
|
||||
## 4. SpacetimeDB 过程
|
||||
|
||||
### 4.1 用户兑换
|
||||
|
||||
`redeem_profile_reward_code(input: RuntimeProfileRewardCodeRedeemInput) -> RuntimeProfileRewardCodeRedeemProcedureResult`
|
||||
|
||||
流程:
|
||||
|
||||
1. 标准化 code。
|
||||
2. 校验兑换码存在、启用、奖励大于 0。
|
||||
3. 按模式校验使用范围与次数。
|
||||
4. 同一事务内写入 `profile_redeem_code_usage`、增加钱包余额、写入 `profile_wallet_ledger`,最后更新 `profile_redeem_code.global_used_count`。
|
||||
5. 返回 `walletBalance`、`amountGranted` 与本次 `ledgerEntry`。
|
||||
|
||||
### 4.2 管理创建/更新
|
||||
|
||||
`admin_upsert_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminUpsertInput) -> RuntimeProfileRedeemCodeAdminProcedureResult`
|
||||
|
||||
私有码必须至少解析出一个内部用户 ID。公共码与唯一码忽略 allowed 列表并存空数组。
|
||||
|
||||
### 4.3 管理停用
|
||||
|
||||
`admin_disable_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminDisableInput) -> RuntimeProfileRedeemCodeAdminProcedureResult`
|
||||
|
||||
只更新 `enabled=false` 与 `updated_at`,不存在时返回“兑换码不存在”。
|
||||
|
||||
## 5. Axum API
|
||||
|
||||
用户接口:
|
||||
|
||||
- `POST /api/profile/redeem-codes/redeem`
|
||||
- `POST /api/runtime/profile/redeem-codes/redeem`
|
||||
|
||||
请求:`{ "code": "WELCOME2026" }`
|
||||
|
||||
成功返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"walletBalance": 130,
|
||||
"amountGranted": 100,
|
||||
"ledgerEntry": {
|
||||
"id": "redeem:WELCOME2026:user:1777392000000000:0",
|
||||
"amountDelta": 100,
|
||||
"balanceAfter": 130,
|
||||
"sourceType": "redeem_code_reward",
|
||||
"createdAt": "2026-04-28T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
管理员接口:
|
||||
|
||||
- `POST /admin/api/profile/redeem-codes`
|
||||
- `POST /admin/api/profile/redeem-codes/disable`
|
||||
|
||||
管理员接口复用现有 `require_admin_auth`。
|
||||
|
||||
## 6. 错误文案
|
||||
|
||||
| 场景 | message |
|
||||
| --- | --- |
|
||||
| 空 code | `兑换码不能为空` |
|
||||
| 不存在 | `兑换码不存在` |
|
||||
| 停用 | `兑换码已停用` |
|
||||
| 奖励为 0 | `兑换码奖励无效` |
|
||||
| 次数耗尽 | `兑换次数已用完` |
|
||||
| 私有码账号不匹配 | `该兑换码不适用于当前账号` |
|
||||
| 私有码无允许用户 | `私有兑换码必须指定可兑换用户` |
|
||||
|
||||
## 7. 前端交互
|
||||
|
||||
“我的”页头像右侧入口由 `会员充值` 改为 `兑换码`。点击打开独立模态窗口,窗口内只保留输入框、兑换按钮和后端返回提示,不展示兑换规则说明。
|
||||
|
||||
成功后展示 `已到账 X 叙世币`,并刷新 profile dashboard。失败后直接展示后端 `message`。
|
||||
|
||||
## 8. 测试矩阵
|
||||
|
||||
- Rust/module-runtime:覆盖公共码、唯一码、私有码、失败场景、流水来源和余额累加。
|
||||
- Axum:覆盖用户鉴权、管理员鉴权、runtime error 到 400 的映射和兼容路径。
|
||||
- 前端:覆盖入口替换、独立 modal、成功刷新余额和失败展示后端 message。
|
||||
- 验证命令:`cargo test`、目标前端测试、`npm run api-server:maincloud`、`npm run check:encoding`。
|
||||
@@ -39,6 +39,15 @@
|
||||
|
||||
新增拼图成绩表,按“关卡作品 + 网格规格 + 用户”维护最佳成绩。
|
||||
|
||||
正式开始拼图关卡时还必须同步用户玩过作品明细:
|
||||
|
||||
1. 作品自身统计继续更新 `puzzle_work_profile.play_count`。
|
||||
2. 已登录用户写入 `profile_played_world`,`world_key = puzzle:{profile_id}`。
|
||||
3. `profile_id` 保存拼图作品号,`world_type = PUZZLE`。
|
||||
4. `world_title` 使用关卡名,`world_subtitle` 使用作品摘要,`owner_user_id` 使用拼图作者用户 ID。
|
||||
5. 下一关切换到新 `profile_id` 时按同一规则再次写入。
|
||||
6. 排行榜提交携带的 `elapsedMs` 是本关可观测时长,后端按增量累计到 `profile_dashboard_state.total_play_time_ms`。
|
||||
|
||||
建议字段:
|
||||
|
||||
1. `entry_id`
|
||||
|
||||
@@ -23,7 +23,7 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
|
||||
| 领域 | 表 |
|
||||
| --- | --- |
|
||||
| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` |
|
||||
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_played_world`, `profile_save_archive` |
|
||||
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_played_world`, `profile_save_archive` |
|
||||
| RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` |
|
||||
| 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` |
|
||||
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` |
|
||||
@@ -133,6 +133,27 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>';
|
||||
SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
### `profile_redeem_code`
|
||||
|
||||
- 作用:运营发放的叙世币兑换码,支持公共码、唯一码和私有码。
|
||||
- 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec<String>`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`。
|
||||
- 索引:主键 `code`。
|
||||
|
||||
```sql
|
||||
SELECT * FROM profile_redeem_code WHERE code = '<CODE>';
|
||||
```
|
||||
|
||||
### `profile_redeem_code_usage`
|
||||
|
||||
- 作用:记录每一次兑换行为,为公共码用户维度计次、唯一/私有码全局计次提供依据。
|
||||
- 结构:`usage_id PK: String`, `code: String`, `user_id: String`, `amount_granted: u64`, `created_at: Timestamp`。
|
||||
- 索引:`code`, `user_id`, `(code, user_id)`。
|
||||
|
||||
```sql
|
||||
SELECT * FROM profile_redeem_code_usage WHERE code = '<CODE>';
|
||||
SELECT * FROM profile_redeem_code_usage WHERE user_id = '<user_id>';
|
||||
```
|
||||
|
||||
### `profile_played_world`
|
||||
|
||||
- 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。
|
||||
|
||||
@@ -25,6 +25,10 @@ export type ExecuteBigFishActionRequest = {
|
||||
motionKey?: 'idle_float' | 'move_swim' | string;
|
||||
};
|
||||
|
||||
export type RecordBigFishPlayRequest = {
|
||||
elapsedMs?: number;
|
||||
};
|
||||
|
||||
export type SubmitBigFishInputRequest = {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface BigFishWorkSummary {
|
||||
levelMainImageReadyCount: number;
|
||||
levelMotionReadyCount: number;
|
||||
backgroundReady: boolean;
|
||||
playCount?: number;
|
||||
}
|
||||
|
||||
export interface BigFishWorksResponse {
|
||||
|
||||
@@ -62,8 +62,9 @@ export type ProfileWalletLedgerEntry = {
|
||||
| 'invite_inviter_reward'
|
||||
| 'invite_invitee_reward'
|
||||
| 'points_recharge'
|
||||
| 'asset_generation_consume'
|
||||
| 'asset_generation_refund';
|
||||
| 'asset_operation_consume'
|
||||
| 'asset_operation_refund'
|
||||
| 'redeem_code_reward';
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -165,6 +166,16 @@ export type RedeemProfileReferralInviteCodeResponse = {
|
||||
inviterBalanceAfter: number;
|
||||
};
|
||||
|
||||
export type RedeemProfileRewardCodeRequest = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type RedeemProfileRewardCodeResponse = {
|
||||
walletBalance: number;
|
||||
amountGranted: number;
|
||||
ledgerEntry: ProfileWalletLedgerEntry;
|
||||
};
|
||||
|
||||
export type ProfilePlayedWorkSummary = {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
|
||||
@@ -34,8 +34,8 @@ use crate::{
|
||||
auth_sessions::auth_sessions,
|
||||
big_fish::{
|
||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, stream_big_fish_message,
|
||||
submit_big_fish_message,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play,
|
||||
stream_big_fish_message, submit_big_fish_message,
|
||||
},
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
@@ -101,9 +101,10 @@ use crate::{
|
||||
},
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
|
||||
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
|
||||
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
|
||||
redeem_profile_referral_invite_code,
|
||||
redeem_profile_referral_invite_code, redeem_profile_reward_code,
|
||||
},
|
||||
runtime_save::{
|
||||
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
||||
@@ -151,6 +152,20 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/redeem-codes",
|
||||
post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/redeem-codes/disable",
|
||||
post(admin_disable_profile_redeem_code).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/healthz",
|
||||
get(|Extension(request_context): Extension<_>| async move {
|
||||
@@ -626,6 +641,20 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/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,
|
||||
)),
|
||||
)
|
||||
.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,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/agent/sessions",
|
||||
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
@@ -906,6 +935,20 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/redeem-codes/redeem",
|
||||
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/redeem-codes/redeem",
|
||||
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/play-stats",
|
||||
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
|
||||
@@ -1419,6 +1462,36 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_dev_auto_register_creates_unknown_phone_when_enabled() {
|
||||
let config = AppConfig {
|
||||
dev_password_entry_auto_register_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
|
||||
let first_response =
|
||||
password_login_request(app.clone(), "13800138023", TEST_PASSWORD).await;
|
||||
let first_status = first_response.status();
|
||||
let first_body = first_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first response body should collect")
|
||||
.to_bytes();
|
||||
let first_payload: Value =
|
||||
serde_json::from_slice(&first_body).expect("first response body should be valid json");
|
||||
let second_response = password_login_request(app, "13800138023", TEST_PASSWORD).await;
|
||||
|
||||
assert_eq!(first_status, StatusCode::OK);
|
||||
assert!(first_payload["token"].as_str().is_some());
|
||||
assert_eq!(
|
||||
first_payload["user"]["loginMethod"],
|
||||
Value::String("password".to_string())
|
||||
);
|
||||
assert_eq!(second_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::future::Future;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
@@ -6,15 +8,36 @@ use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
|
||||
|
||||
/// 资产操作统一执行入口:业务层只声明操作类型与资源 ID,钱包扣退费由服务层收口。
|
||||
pub(crate) async fn execute_billable_asset_operation<T, Fut>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_kind: &str,
|
||||
asset_id: &str,
|
||||
operation: Fut,
|
||||
) -> Result<T, AppError>
|
||||
where
|
||||
Fut: Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await?;
|
||||
match operation.await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(error) => {
|
||||
refund_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
||||
pub(crate) async fn consume_asset_operation_points(
|
||||
async fn consume_asset_operation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_kind: &str,
|
||||
asset_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let ledger_id = format!(
|
||||
"asset_generation_consume:{}:{}:{}",
|
||||
"asset_operation_consume:{}:{}:{}",
|
||||
owner_user_id, asset_kind, asset_id
|
||||
);
|
||||
state
|
||||
@@ -31,14 +54,14 @@ pub(crate) async fn consume_asset_operation_points(
|
||||
}
|
||||
|
||||
/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。
|
||||
pub(crate) async fn refund_asset_operation_points(
|
||||
async fn refund_asset_operation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_kind: &str,
|
||||
asset_id: &str,
|
||||
) {
|
||||
let ledger_id = format!(
|
||||
"asset_generation_refund:{}:{}:{}",
|
||||
"asset_operation_refund:{}:{}:{}",
|
||||
owner_user_id, asset_kind, asset_id
|
||||
);
|
||||
if let Err(error) = state
|
||||
|
||||
@@ -24,7 +24,8 @@ use shared_contracts::big_fish::{
|
||||
BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse,
|
||||
BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse,
|
||||
BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
|
||||
CreateBigFishSessionRequest, ExecuteBigFishActionRequest, SendBigFishMessageRequest,
|
||||
CreateBigFishSessionRequest, ExecuteBigFishActionRequest, RecordBigFishPlayRequest,
|
||||
SendBigFishMessageRequest,
|
||||
};
|
||||
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
|
||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
@@ -32,9 +33,9 @@ use spacetime_client::{
|
||||
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
|
||||
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
|
||||
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
|
||||
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord,
|
||||
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord,
|
||||
SpacetimeClientError,
|
||||
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput,
|
||||
BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord,
|
||||
BigFishWorkSummaryRecord, SpacetimeClientError,
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -53,7 +54,7 @@ use crate::{
|
||||
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
|
||||
},
|
||||
api_response::json_success_body,
|
||||
asset_billing::{consume_asset_operation_points, refund_asset_operation_points},
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
character_visual_assets::try_apply_background_alpha_to_png,
|
||||
http_error::AppError,
|
||||
@@ -208,6 +209,48 @@ pub async fn delete_big_fish_work(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn record_big_fish_play(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<RecordBigFishPlayRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
big_fish_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "big-fish",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||
|
||||
let items = state
|
||||
.spacetime_client()
|
||||
.record_big_fish_play(BigFishPlayReportRecordInput {
|
||||
session_id,
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
elapsed_ms: payload.elapsed_ms.unwrap_or(0),
|
||||
reported_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
BigFishWorksResponse {
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(map_big_fish_work_summary_response)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn submit_big_fish_message(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
@@ -498,177 +541,115 @@ pub async fn execute_big_fish_action(
|
||||
_ => None,
|
||||
};
|
||||
let billing_asset_id = format!("{session_id}:{now}");
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id)
|
||||
.await
|
||||
.map_err(|error| big_fish_error_response(&request_context, error))?;
|
||||
}
|
||||
|
||||
let session_result = match action.as_str() {
|
||||
"big_fish_compile_draft" => {
|
||||
compile_big_fish_draft_only(&state, session_id.clone(), owner_user_id.clone(), now)
|
||||
.await
|
||||
}
|
||||
"big_fish_generate_level_main_image" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_main_image",
|
||||
payload.level,
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let billing_asset_id = billing_asset_id.clone();
|
||||
async move {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
big_fish_error_response(&request_context, error)
|
||||
})?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "level_main_image".to_string(),
|
||||
level: payload.level,
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
}
|
||||
"big_fish_generate_level_motion" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_motion",
|
||||
payload.level,
|
||||
payload.motion_key.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let billing_asset_id = billing_asset_id.clone();
|
||||
async move {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
big_fish_error_response(&request_context, error)
|
||||
})?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "level_motion".to_string(),
|
||||
level: payload.level,
|
||||
motion_key: payload.motion_key,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
}
|
||||
"big_fish_generate_stage_background" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"stage_background",
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let billing_asset_id = billing_asset_id.clone();
|
||||
async move {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
big_fish_error_response(&request_context, error)
|
||||
})?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "stage_background".to_string(),
|
||||
level: None,
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
}
|
||||
"big_fish_publish_game" => {
|
||||
state
|
||||
let session_operation = async {
|
||||
match action.as_str() {
|
||||
"big_fish_compile_draft" => {
|
||||
compile_big_fish_draft_only(&state, session_id.clone(), owner_user_id.clone(), now)
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)
|
||||
}
|
||||
"big_fish_generate_level_main_image" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_main_image",
|
||||
payload.level,
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "level_main_image".to_string(),
|
||||
level: payload.level,
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)
|
||||
}
|
||||
"big_fish_generate_level_motion" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"level_motion",
|
||||
payload.level,
|
||||
payload.motion_key.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "level_motion".to_string(),
|
||||
level: payload.level,
|
||||
motion_key: payload.motion_key,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)
|
||||
}
|
||||
"big_fish_generate_stage_background" => {
|
||||
let asset_url = generate_big_fish_formal_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
"stage_background",
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
asset_kind: "stage_background".to_string(),
|
||||
level: None,
|
||||
motion_key: None,
|
||||
asset_url: Some(asset_url),
|
||||
generated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)
|
||||
}
|
||||
"big_fish_publish_game" => state
|
||||
.spacetime_client()
|
||||
.publish_big_fish_game(session_id, owner_user_id.clone(), now)
|
||||
.await
|
||||
}
|
||||
other => {
|
||||
return Err(big_fish_bad_request(
|
||||
&request_context,
|
||||
format!("action `{other}` is not supported").as_str(),
|
||||
));
|
||||
.map_err(map_big_fish_client_error),
|
||||
other => Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "big-fish",
|
||||
"message": format!("action `{other}` is not supported"),
|
||||
})),
|
||||
),
|
||||
}
|
||||
};
|
||||
let session = match session_result {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(big_fish_error_response(
|
||||
&request_context,
|
||||
map_big_fish_client_error(error),
|
||||
));
|
||||
}
|
||||
let session_result = if let Some(asset_kind) = billed_asset_kind {
|
||||
execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
session_operation,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
session_operation.await
|
||||
};
|
||||
let session =
|
||||
session_result.map_err(|error| big_fish_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -930,6 +911,7 @@ fn map_big_fish_work_summary_response(
|
||||
level_main_image_ready_count: item.level_main_image_ready_count,
|
||||
level_motion_ready_count: item.level_motion_ready_count,
|
||||
background_ready: item.background_ready,
|
||||
play_count: item.play_count,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct AppConfig {
|
||||
pub refresh_cookie_same_site: String,
|
||||
pub refresh_session_ttl_days: u32,
|
||||
pub auth_store_path: PathBuf,
|
||||
pub dev_password_entry_auto_register_enabled: bool,
|
||||
pub sms_auth_enabled: bool,
|
||||
pub sms_auth_provider: String,
|
||||
pub sms_endpoint: String,
|
||||
@@ -118,6 +119,7 @@ impl Default for AppConfig {
|
||||
refresh_cookie_same_site: "Lax".to_string(),
|
||||
refresh_session_ttl_days: 30,
|
||||
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
|
||||
dev_password_entry_auto_register_enabled: false,
|
||||
sms_auth_enabled: false,
|
||||
sms_auth_provider: "mock".to_string(),
|
||||
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
|
||||
@@ -273,6 +275,11 @@ impl AppConfig {
|
||||
if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
|
||||
config.auth_store_path = PathBuf::from(auth_store_path);
|
||||
}
|
||||
if let Some(enabled) =
|
||||
read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"])
|
||||
{
|
||||
config.dev_password_entry_auto_register_enabled = enabled;
|
||||
}
|
||||
|
||||
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
|
||||
config.sms_auth_enabled = sms_auth_enabled;
|
||||
|
||||
@@ -53,7 +53,7 @@ use crate::{
|
||||
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
|
||||
},
|
||||
api_response::json_success_body,
|
||||
asset_billing::{consume_asset_operation_points, refund_asset_operation_points},
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
character_visual_assets::generate_character_primary_visual_for_profile,
|
||||
custom_world_agent_entities::generate_custom_world_agent_entities,
|
||||
@@ -599,37 +599,31 @@ pub async fn publish_custom_world_library_profile(
|
||||
));
|
||||
}
|
||||
|
||||
consume_asset_operation_points(&state, &owner_user_id, "custom_world_publish", &profile_id)
|
||||
.await
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
|
||||
let mutation_result = state
|
||||
.spacetime_client()
|
||||
.publish_custom_world_profile(
|
||||
profile_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
None,
|
||||
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
|
||||
resolve_author_display_name(&state, &authenticated),
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await;
|
||||
let mutation = match mutation_result {
|
||||
Ok(mutation) => mutation,
|
||||
Err(error) => {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_publish",
|
||||
&profile_id,
|
||||
)
|
||||
.await;
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
map_custom_world_client_error(error),
|
||||
));
|
||||
}
|
||||
};
|
||||
let author_public_user_code =
|
||||
resolve_author_public_user_code(&state, &authenticated, &request_context)?;
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
let mutation = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_publish",
|
||||
&profile_id,
|
||||
async {
|
||||
state
|
||||
.spacetime_client()
|
||||
.publish_custom_world_profile(
|
||||
profile_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
None,
|
||||
author_public_user_code,
|
||||
author_display_name,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_custom_world_client_error)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -1525,46 +1519,33 @@ pub async fn execute_custom_world_agent_action(
|
||||
};
|
||||
|
||||
let should_bill_publish = action == "publish_world";
|
||||
if should_bill_publish {
|
||||
consume_asset_operation_points(
|
||||
let operation_future = async {
|
||||
state
|
||||
.spacetime_client()
|
||||
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
operation_id: build_prefixed_uuid_id("operation-"),
|
||||
action: action.clone(),
|
||||
payload_json: Some(payload_json),
|
||||
submitted_at_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_custom_world_client_error)
|
||||
};
|
||||
let result = if should_bill_publish {
|
||||
execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_agent_publish",
|
||||
&session_id,
|
||||
operation_future,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
}
|
||||
|
||||
let result = match state
|
||||
.spacetime_client()
|
||||
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
operation_id: build_prefixed_uuid_id("operation-"),
|
||||
action: action.clone(),
|
||||
payload_json: Some(payload_json),
|
||||
submitted_at_micros,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
if should_bill_publish {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_agent_publish",
|
||||
&session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
map_custom_world_client_error(error),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
operation_future.await
|
||||
};
|
||||
let result = result.map_err(|error| custom_world_error_response(&request_context, error))?;
|
||||
|
||||
if matches!(
|
||||
action.as_str(),
|
||||
|
||||
@@ -28,6 +28,7 @@ use webp::Encoder as WebpEncoder;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
custom_world_result_prompts::{
|
||||
build_result_entity_system_prompt, build_result_entity_user_prompt,
|
||||
@@ -441,126 +442,111 @@ pub async fn generate_custom_world_scene_image(
|
||||
let normalized = normalize_scene_image_request(payload)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let asset_id = format!("custom-scene-{}", current_utc_millis());
|
||||
crate::asset_billing::consume_asset_operation_points(
|
||||
let asset = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"scene_image",
|
||||
asset_id.as_str(),
|
||||
async {
|
||||
let settings = require_dashscope_settings(&state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let reference_image =
|
||||
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
||||
Some(
|
||||
resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
reference_image_src,
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let generated = if let Some(reference_image) = reference_image.as_deref() {
|
||||
create_reference_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_reference_image_model.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
normalized.size.as_str(),
|
||||
&[reference_image.to_string()],
|
||||
Some(normalized.negative_prompt.as_str()),
|
||||
"创建参考图场景编辑任务失败",
|
||||
"参考图场景编辑未返回图片地址",
|
||||
"scene-edit",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
create_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_scene_image_model.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
Some(normalized.negative_prompt.as_str()),
|
||||
normalized.size.as_str(),
|
||||
"创建场景图片生成任务失败",
|
||||
"查询场景图片任务失败",
|
||||
"场景图片生成任务失败",
|
||||
"场景图片生成超时或未返回图片地址",
|
||||
)
|
||||
.await
|
||||
}?;
|
||||
let scene_model = if reference_image.is_some() {
|
||||
state.config.dashscope_reference_image_model.clone()
|
||||
} else {
|
||||
state.config.dashscope_scene_image_model.clone()
|
||||
};
|
||||
let downloaded = download_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载生成图片失败",
|
||||
)
|
||||
.await?;
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(
|
||||
normalized
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.unwrap_or(normalized.world_name.as_str()),
|
||||
"world",
|
||||
),
|
||||
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: format!("scene.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: "scene_image",
|
||||
entity_kind: "custom_world_landmark",
|
||||
entity_id: normalized.entity_id.clone(),
|
||||
profile_id: normalized.profile_id.clone(),
|
||||
slot: "scene_image",
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
persist_custom_world_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(scene_model),
|
||||
size: Some(normalized.size),
|
||||
task_id: Some(generated.task_id),
|
||||
prompt: Some(normalized.prompt),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let asset_result = async {
|
||||
let settings = require_dashscope_settings(&state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let reference_image =
|
||||
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
||||
Some(
|
||||
resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
reference_image_src,
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let generated = if let Some(reference_image) = reference_image.as_deref() {
|
||||
create_reference_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_reference_image_model.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
normalized.size.as_str(),
|
||||
&[reference_image.to_string()],
|
||||
Some(normalized.negative_prompt.as_str()),
|
||||
"创建参考图场景编辑任务失败",
|
||||
"参考图场景编辑未返回图片地址",
|
||||
"scene-edit",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
create_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_scene_image_model.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
Some(normalized.negative_prompt.as_str()),
|
||||
normalized.size.as_str(),
|
||||
"创建场景图片生成任务失败",
|
||||
"查询场景图片任务失败",
|
||||
"场景图片生成任务失败",
|
||||
"场景图片生成超时或未返回图片地址",
|
||||
)
|
||||
.await
|
||||
}?;
|
||||
let scene_model = if reference_image.is_some() {
|
||||
state.config.dashscope_reference_image_model.clone()
|
||||
} else {
|
||||
state.config.dashscope_scene_image_model.clone()
|
||||
};
|
||||
let downloaded = download_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载生成图片失败",
|
||||
)
|
||||
.await?;
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(
|
||||
normalized
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.unwrap_or(normalized.world_name.as_str()),
|
||||
"world",
|
||||
),
|
||||
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: format!("scene.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: "scene_image",
|
||||
entity_kind: "custom_world_landmark",
|
||||
entity_id: normalized.entity_id.clone(),
|
||||
profile_id: normalized.profile_id.clone(),
|
||||
slot: "scene_image",
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
persist_custom_world_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(scene_model),
|
||||
size: Some(normalized.size),
|
||||
task_id: Some(generated.task_id),
|
||||
prompt: Some(normalized.prompt),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
|
||||
let asset = match asset_result {
|
||||
Ok(asset) => asset,
|
||||
Err(error) => {
|
||||
crate::asset_billing::refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"scene_image",
|
||||
&asset_id,
|
||||
)
|
||||
.await;
|
||||
return Err(custom_world_ai_error_response(&request_context, error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(Some(&request_context), asset))
|
||||
}
|
||||
@@ -717,127 +703,112 @@ pub async fn generate_custom_world_cover_image(
|
||||
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
|
||||
let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
|
||||
let asset_id = format!("custom-cover-{}", current_utc_millis());
|
||||
crate::asset_billing::consume_asset_operation_points(
|
||||
let asset = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_cover",
|
||||
asset_id.as_str(),
|
||||
async {
|
||||
let settings = require_dashscope_settings(&state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let reference_sources = collect_cover_reference_image_sources(
|
||||
&payload.profile,
|
||||
&payload.character_role_ids,
|
||||
payload.reference_image_src.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let prompt = build_custom_world_cover_image_prompt(
|
||||
&payload.profile,
|
||||
&payload.character_role_ids,
|
||||
payload.user_prompt.as_deref().unwrap_or_default(),
|
||||
!reference_sources.is_empty(),
|
||||
);
|
||||
let mut reference_images = Vec::with_capacity(reference_sources.len());
|
||||
for source in &reference_sources {
|
||||
reference_images.push(
|
||||
resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
source.as_str(),
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
let generated = if reference_images.is_empty() {
|
||||
create_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_cover_image_model.clone().as_str(),
|
||||
prompt.as_str(),
|
||||
None,
|
||||
size.as_str(),
|
||||
"创建作品封面生成任务失败",
|
||||
"查询作品封面任务失败",
|
||||
"作品封面生成任务失败",
|
||||
"作品封面生成超时或未返回图片地址",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
create_reference_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_reference_image_model.as_str(),
|
||||
prompt.as_str(),
|
||||
size.as_str(),
|
||||
&reference_images,
|
||||
None,
|
||||
"创建参考图封面任务失败",
|
||||
"封面生成未返回图片地址",
|
||||
"cover-edit",
|
||||
)
|
||||
.await
|
||||
}?;
|
||||
let downloaded = download_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载作品封面失败",
|
||||
)
|
||||
.await?;
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldCovers,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(entity_id.as_str(), "world"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: format!("cover.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: "custom_world_cover",
|
||||
entity_kind: "custom_world_profile",
|
||||
entity_id,
|
||||
profile_id,
|
||||
slot: "cover",
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
persist_custom_world_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(if reference_images.is_empty() {
|
||||
state.config.dashscope_cover_image_model.clone()
|
||||
} else {
|
||||
state.config.dashscope_reference_image_model.clone()
|
||||
}),
|
||||
size: Some(size),
|
||||
task_id: Some(generated.task_id),
|
||||
prompt: Some(prompt),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let asset_result = async {
|
||||
let settings = require_dashscope_settings(&state)?;
|
||||
let http_client = build_dashscope_http_client(&settings)?;
|
||||
let reference_sources = collect_cover_reference_image_sources(
|
||||
&payload.profile,
|
||||
&payload.character_role_ids,
|
||||
payload.reference_image_src.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let prompt = build_custom_world_cover_image_prompt(
|
||||
&payload.profile,
|
||||
&payload.character_role_ids,
|
||||
payload.user_prompt.as_deref().unwrap_or_default(),
|
||||
!reference_sources.is_empty(),
|
||||
);
|
||||
let mut reference_images = Vec::with_capacity(reference_sources.len());
|
||||
for source in &reference_sources {
|
||||
reference_images.push(
|
||||
resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
source.as_str(),
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
let generated = if reference_images.is_empty() {
|
||||
create_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_cover_image_model.clone().as_str(),
|
||||
prompt.as_str(),
|
||||
None,
|
||||
size.as_str(),
|
||||
"创建作品封面生成任务失败",
|
||||
"查询作品封面任务失败",
|
||||
"作品封面生成任务失败",
|
||||
"作品封面生成超时或未返回图片地址",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
create_reference_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
state.config.dashscope_reference_image_model.as_str(),
|
||||
prompt.as_str(),
|
||||
size.as_str(),
|
||||
&reference_images,
|
||||
None,
|
||||
"创建参考图封面任务失败",
|
||||
"封面生成未返回图片地址",
|
||||
"cover-edit",
|
||||
)
|
||||
.await
|
||||
}?;
|
||||
let downloaded = download_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载作品封面失败",
|
||||
)
|
||||
.await?;
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldCovers,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(entity_id.as_str(), "world"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: format!("cover.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: "custom_world_cover",
|
||||
entity_kind: "custom_world_profile",
|
||||
entity_id,
|
||||
profile_id,
|
||||
slot: "cover",
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
persist_custom_world_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(if reference_images.is_empty() {
|
||||
state.config.dashscope_cover_image_model.clone()
|
||||
} else {
|
||||
state.config.dashscope_reference_image_model.clone()
|
||||
}),
|
||||
size: Some(size),
|
||||
task_id: Some(generated.task_id),
|
||||
prompt: Some(prompt),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
|
||||
let asset = match asset_result {
|
||||
Ok(asset) => asset,
|
||||
Err(error) => {
|
||||
crate::asset_billing::refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_cover",
|
||||
&asset_id,
|
||||
)
|
||||
.await;
|
||||
return Err(custom_world_ai_error_response(&request_context, error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(Some(&request_context), asset))
|
||||
}
|
||||
|
||||
@@ -26,14 +26,19 @@ pub async fn password_entry(
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<PasswordEntryRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let result = state
|
||||
.password_entry_service()
|
||||
.execute(PasswordEntryInput {
|
||||
phone_number: payload.phone,
|
||||
password: payload.password,
|
||||
})
|
||||
.await
|
||||
.map_err(map_password_entry_error)?;
|
||||
let input = PasswordEntryInput {
|
||||
phone_number: payload.phone,
|
||||
password: payload.password,
|
||||
};
|
||||
let result = if state.config.dev_password_entry_auto_register_enabled {
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute_with_dev_registration(input)
|
||||
.await
|
||||
} else {
|
||||
state.password_entry_service().execute(input).await
|
||||
}
|
||||
.map_err(map_password_entry_error)?;
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
||||
state
|
||||
|
||||
@@ -67,7 +67,7 @@ use tokio::time::sleep;
|
||||
use crate::{
|
||||
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
||||
api_response::json_success_body,
|
||||
asset_billing::{consume_asset_operation_points, refund_asset_operation_points},
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
@@ -442,29 +442,29 @@ 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 billed_asset_kind = match action.as_str() {
|
||||
"compile_puzzle_draft" => Some("puzzle_initial_image"),
|
||||
"generate_puzzle_images" => Some("puzzle_generated_image"),
|
||||
_ => None,
|
||||
};
|
||||
let billing_asset_id = format!("{session_id}:{now}");
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id)
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let session = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&billing_asset_id,
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
})?;
|
||||
}
|
||||
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let session = compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"完整拼图草稿",
|
||||
@@ -473,75 +473,76 @@ pub async fn execute_puzzle_agent_action(
|
||||
)
|
||||
}
|
||||
"generate_puzzle_images" => {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await;
|
||||
let session = match session {
|
||||
Ok(session) => {
|
||||
let session = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_generated_image",
|
||||
&billing_asset_id,
|
||||
async {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let draft = session.draft.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string())
|
||||
});
|
||||
match draft {
|
||||
Ok(draft) => {
|
||||
let prompt = payload
|
||||
.prompt_text
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| draft.summary.clone());
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = draft.candidates.len();
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&prompt,
|
||||
payload.reference_image_src.as_deref(),
|
||||
candidate_count,
|
||||
candidate_start_index,
|
||||
)
|
||||
.await
|
||||
.map_err(SpacetimeClientError::Runtime);
|
||||
match candidates {
|
||||
Ok(candidates) => {
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
.map(to_puzzle_generated_image_candidate)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
SpacetimeClientError::Runtime(format!(
|
||||
"拼图候选图序列化失败:{error}"
|
||||
))
|
||||
});
|
||||
match candidates_json {
|
||||
Ok(candidates_json) => {
|
||||
state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(
|
||||
PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: session.session_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
candidates_json,
|
||||
saved_at_micros: now,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
let prompt = payload
|
||||
.prompt_text
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| draft.summary.clone());
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = draft.candidates.len();
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&prompt,
|
||||
payload.reference_image_src.as_deref(),
|
||||
candidate_count,
|
||||
candidate_start_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|message| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": message,
|
||||
}))
|
||||
})?;
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
.map(to_puzzle_generated_image_candidate)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图候选图序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: session.session_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
candidates_json,
|
||||
saved_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
});
|
||||
(
|
||||
"generate_puzzle_images",
|
||||
"拼图图片生成",
|
||||
@@ -569,7 +570,14 @@ pub async fn execute_puzzle_agent_action(
|
||||
candidate_id,
|
||||
selected_at_micros: now,
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
});
|
||||
(
|
||||
"select_puzzle_image",
|
||||
"正式图确认",
|
||||
@@ -579,43 +587,35 @@ pub async fn execute_puzzle_agent_action(
|
||||
}
|
||||
"publish_puzzle_work" => {
|
||||
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
|
||||
consume_asset_operation_points(&state, &owner_user_id, "puzzle_publish_work", &work_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
})?;
|
||||
let profile_result = state
|
||||
.spacetime_client()
|
||||
.publish_puzzle_work(PuzzlePublishRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
// 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。
|
||||
work_id: work_id.clone(),
|
||||
profile_id,
|
||||
author_display_name: resolve_author_display_name(&state, &authenticated),
|
||||
level_name: payload.level_name.clone(),
|
||||
summary: payload.summary.clone(),
|
||||
theme_tags: payload.theme_tags.clone(),
|
||||
published_at_micros: now,
|
||||
})
|
||||
.await;
|
||||
let profile = match profile_result {
|
||||
Ok(profile) => profile,
|
||||
Err(error) => {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_publish_work",
|
||||
&work_id,
|
||||
)
|
||||
.await;
|
||||
return Err(puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
));
|
||||
}
|
||||
};
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
let profile = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_publish_work",
|
||||
&work_id,
|
||||
async {
|
||||
state
|
||||
.spacetime_client()
|
||||
.publish_puzzle_work(PuzzlePublishRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
// 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。
|
||||
work_id: work_id.clone(),
|
||||
profile_id,
|
||||
author_display_name,
|
||||
level_name: payload.level_name.clone(),
|
||||
summary: payload.summary.clone(),
|
||||
theme_tags: payload.theme_tags.clone(),
|
||||
published_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
})?;
|
||||
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
@@ -654,29 +654,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
}
|
||||
};
|
||||
|
||||
let session = session.map_err(|error| {
|
||||
if let Some(asset_kind) = billed_asset_kind {
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let owner_user_id = owner_user_id.clone();
|
||||
let billing_asset_id = billing_asset_id.clone();
|
||||
async move {
|
||||
refund_asset_operation_points(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
asset_kind,
|
||||
&billing_asset_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let session = session?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
|
||||
@@ -7,30 +7,36 @@ use axum::{
|
||||
use module_runtime::{
|
||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
|
||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||
RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType,
|
||||
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord,
|
||||
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||
RuntimeReferralRedeemRecord,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
|
||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
||||
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
|
||||
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
|
||||
ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse,
|
||||
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
|
||||
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
|
||||
ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
|
||||
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
|
||||
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
|
||||
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
|
||||
http_error::AppError, request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_profile_dashboard(
|
||||
@@ -112,11 +118,14 @@ fn format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE
|
||||
}
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
|
||||
}
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
|
||||
}
|
||||
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +237,99 @@ pub async fn redeem_profile_referral_invite_code(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn redeem_profile_reward_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RedeemProfileRewardCodeRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let redeemed_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.redeem_profile_reward_code(user_id, payload.code, redeemed_at_micros as i64)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_redeem_profile_reward_code_response(record),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_upsert_profile_redeem_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertProfileRedeemCodeRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let mode = parse_profile_redeem_code_mode(&payload.mode).map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
|
||||
)
|
||||
})?;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.admin_upsert_profile_redeem_code(
|
||||
admin.session().subject.clone(),
|
||||
payload.code,
|
||||
mode,
|
||||
payload.reward_points,
|
||||
payload.max_uses,
|
||||
payload.enabled,
|
||||
payload.allowed_user_ids,
|
||||
payload.allowed_public_user_codes,
|
||||
updated_at_micros as i64,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_profile_redeem_code_admin_response(record),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_disable_profile_redeem_code(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminDisableProfileRedeemCodeRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.admin_disable_profile_redeem_code(
|
||||
admin.session().subject.clone(),
|
||||
payload.code,
|
||||
updated_at_micros as i64,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_profile_redeem_code_admin_response(record),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_profile_play_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -396,6 +498,49 @@ fn build_redeem_profile_referral_invite_code_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_redeem_profile_reward_code_response(
|
||||
record: RuntimeProfileRewardCodeRedeemRecord,
|
||||
) -> RedeemProfileRewardCodeResponse {
|
||||
RedeemProfileRewardCodeResponse {
|
||||
wallet_balance: record.wallet_balance,
|
||||
amount_granted: record.amount_granted,
|
||||
ledger_entry: ProfileWalletLedgerEntryResponse {
|
||||
id: record.ledger_entry.wallet_ledger_id,
|
||||
amount_delta: record.ledger_entry.amount_delta,
|
||||
balance_after: record.ledger_entry.balance_after,
|
||||
source_type: format_profile_wallet_ledger_source_type(record.ledger_entry.source_type)
|
||||
.to_string(),
|
||||
created_at: record.ledger_entry.created_at,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
|
||||
"unique" => Ok(RuntimeProfileRedeemCodeMode::Unique),
|
||||
"private" => Ok(RuntimeProfileRedeemCodeMode::Private),
|
||||
_ => Err("兑换码类型无效".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_redeem_code_admin_response(
|
||||
record: RuntimeProfileRedeemCodeRecord,
|
||||
) -> ProfileRedeemCodeAdminResponse {
|
||||
ProfileRedeemCodeAdminResponse {
|
||||
code: record.code,
|
||||
mode: record.mode.as_str().to_string(),
|
||||
reward_points: record.reward_points,
|
||||
max_uses: record.max_uses,
|
||||
global_used_count: record.global_used_count,
|
||||
enabled: record.enabled,
|
||||
allowed_user_ids: record.allowed_user_ids,
|
||||
created_by: record.created_by,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
||||
@@ -417,18 +562,18 @@ mod tests {
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[test]
|
||||
fn profile_wallet_ledger_source_type_formats_asset_generation_values() {
|
||||
fn profile_wallet_ledger_source_type_formats_asset_operation_values() {
|
||||
assert_eq!(
|
||||
format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
|
||||
),
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
|
||||
);
|
||||
assert_eq!(
|
||||
format_profile_wallet_ledger_source_type(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund
|
||||
),
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
||||
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -486,6 +486,38 @@ impl PasswordEntryService {
|
||||
verify_stored_password_user(existing_user, &input.password).await
|
||||
}
|
||||
|
||||
pub async fn execute_with_dev_registration(
|
||||
&self,
|
||||
input: PasswordEntryInput,
|
||||
) -> Result<PasswordEntryResult, PasswordEntryError> {
|
||||
validate_password(&input.password)?;
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)
|
||||
.map_err(|_| PasswordEntryError::InvalidPhoneNumber)?;
|
||||
if let Some(existing_user) = self
|
||||
.store
|
||||
.find_by_phone_number_for_password(&normalized_phone.e164)?
|
||||
{
|
||||
return verify_stored_password_user(existing_user, &input.password).await;
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&input.password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
let user = self.store.create_dev_password_phone_user(
|
||||
normalized_phone.clone(),
|
||||
normalized_phone.masked_national_number,
|
||||
password_hash,
|
||||
)?;
|
||||
|
||||
Ok(PasswordEntryResult {
|
||||
user: AuthUser {
|
||||
login_method: AuthLoginMethod::Password,
|
||||
..user
|
||||
},
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -1336,6 +1368,53 @@ impl InMemoryAuthStore {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn create_dev_password_phone_user(
|
||||
&self,
|
||||
phone_number: PhoneNumberSnapshot,
|
||||
display_name: String,
|
||||
password_hash: String,
|
||||
) -> Result<AuthUser, PasswordEntryError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
if state.phone_to_user_id.contains_key(&phone_number.e164) {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let sequence = state.next_user_id;
|
||||
let user_id = format!("user_{sequence:08}");
|
||||
let public_user_code = build_public_user_code(sequence);
|
||||
state.next_user_id += 1;
|
||||
let username = build_system_username("phone", state.next_user_id);
|
||||
let user = AuthUser {
|
||||
id: user_id.clone(),
|
||||
public_user_code,
|
||||
username: username.clone(),
|
||||
display_name,
|
||||
phone_number_masked: Some(phone_number.masked_national_number.clone()),
|
||||
login_method: AuthLoginMethod::Password,
|
||||
binding_status: AuthBindingStatus::Active,
|
||||
wechat_bound: false,
|
||||
token_version: 1,
|
||||
};
|
||||
state
|
||||
.phone_to_user_id
|
||||
.insert(phone_number.e164.clone(), user_id);
|
||||
state.users_by_username.insert(
|
||||
username,
|
||||
StoredPasswordUser {
|
||||
user: user.clone(),
|
||||
password_hash,
|
||||
password_login_enabled: true,
|
||||
phone_number: Some(phone_number.e164),
|
||||
},
|
||||
);
|
||||
self.persist_password_state(&state)?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn create_pending_wechat_user(
|
||||
&self,
|
||||
profile: WechatIdentityProfile,
|
||||
@@ -2474,6 +2553,39 @@ mod tests {
|
||||
assert_eq!(error, PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_dev_registration_creates_unknown_phone_user() {
|
||||
let service = build_password_service(build_store());
|
||||
|
||||
let created = service
|
||||
.execute_with_dev_registration(PasswordEntryInput {
|
||||
phone_number: "13800138009".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("dev registration should create user");
|
||||
let reused = service
|
||||
.execute_with_dev_registration(PasswordEntryInput {
|
||||
phone_number: "13800138009".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("same password should reuse created user");
|
||||
let wrong_password = service
|
||||
.execute_with_dev_registration(PasswordEntryInput {
|
||||
phone_number: "13800138009".to_string(),
|
||||
password: "secret999".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("existing user still requires the right password");
|
||||
|
||||
assert!(created.created);
|
||||
assert_eq!(created.user.login_method, AuthLoginMethod::Password);
|
||||
assert!(!reused.created);
|
||||
assert_eq!(created.user.id, reused.user.id);
|
||||
assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn phone_user_can_set_password_then_login() {
|
||||
let store = build_store();
|
||||
|
||||
@@ -225,6 +225,7 @@ pub struct BigFishWorkSummarySnapshot {
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
pub play_count: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -321,6 +322,15 @@ pub struct BigFishPublishInput {
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishPlayRecordInput {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub played_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BigFishFieldError {
|
||||
MissingSessionId,
|
||||
@@ -659,6 +669,16 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||||
}
|
||||
|
||||
pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.session_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(anchor_pack)
|
||||
}
|
||||
|
||||
@@ -259,8 +259,17 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
||||
InviteInviterReward,
|
||||
InviteInviteeReward,
|
||||
PointsRecharge,
|
||||
AssetGenerationConsume,
|
||||
AssetGenerationRefund,
|
||||
AssetOperationConsume,
|
||||
AssetOperationRefund,
|
||||
RedeemCodeReward,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeProfileRedeemCodeMode {
|
||||
Public,
|
||||
Unique,
|
||||
Private,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput {
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemInput {
|
||||
pub user_id: String,
|
||||
pub code: String,
|
||||
pub redeemed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
pub wallet_balance: u64,
|
||||
pub amount_granted: u64,
|
||||
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileRewardCodeRedeemSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRedeemCodeAdminUpsertInput {
|
||||
pub admin_user_id: String,
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub allowed_public_user_codes: Vec<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRedeemCodeAdminDisableInput {
|
||||
pub admin_user_id: String,
|
||||
pub code: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRedeemCodeSnapshot {
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileRedeemCodeSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeReferralInviteCenterSnapshot {
|
||||
@@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError {
|
||||
MissingLedgerId,
|
||||
InvalidWalletAmount,
|
||||
MissingInviteCode,
|
||||
MissingRedeemCode,
|
||||
InvalidRedeemCodeReward,
|
||||
InvalidRedeemCodeMaxUses,
|
||||
MissingProductId,
|
||||
MissingWorldKey,
|
||||
MissingBottomTab,
|
||||
@@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord {
|
||||
pub has_points_recharged: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemRecord {
|
||||
pub wallet_balance: u64,
|
||||
pub amount_granted: u64,
|
||||
pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileRedeemCodeRecord {
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeReferralInviteCenterRecord {
|
||||
pub user_id: String,
|
||||
@@ -970,6 +1074,73 @@ pub fn build_runtime_referral_redeem_input(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_reward_code_redeem_input(
|
||||
user_id: String,
|
||||
code: String,
|
||||
redeemed_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRewardCodeRedeemInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
|
||||
Ok(RuntimeProfileRewardCodeRedeemInput {
|
||||
user_id,
|
||||
code,
|
||||
redeemed_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_admin_upsert_input(
|
||||
admin_user_id: String,
|
||||
code: String,
|
||||
mode: RuntimeProfileRedeemCodeMode,
|
||||
reward_points: u64,
|
||||
max_uses: u32,
|
||||
enabled: bool,
|
||||
allowed_user_ids: Vec<String>,
|
||||
allowed_public_user_codes: Vec<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRedeemCodeAdminUpsertInput, RuntimeProfileFieldError> {
|
||||
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
||||
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
|
||||
if reward_points == 0 {
|
||||
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
|
||||
}
|
||||
if max_uses == 0 {
|
||||
return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses);
|
||||
}
|
||||
|
||||
Ok(RuntimeProfileRedeemCodeAdminUpsertInput {
|
||||
admin_user_id,
|
||||
code,
|
||||
mode,
|
||||
reward_points,
|
||||
max_uses,
|
||||
enabled,
|
||||
allowed_user_ids: allowed_user_ids
|
||||
.into_iter()
|
||||
.filter_map(|value| normalize_optional_string(Some(value)))
|
||||
.collect(),
|
||||
allowed_public_user_codes: allowed_public_user_codes
|
||||
.into_iter()
|
||||
.filter_map(|value| normalize_optional_string(Some(value)))
|
||||
.collect(),
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_admin_disable_input(
|
||||
admin_user_id: String,
|
||||
code: String,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRedeemCodeAdminDisableInput, RuntimeProfileFieldError> {
|
||||
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
||||
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
|
||||
Ok(RuntimeProfileRedeemCodeAdminDisableInput {
|
||||
admin_user_id,
|
||||
code,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_play_stats_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
|
||||
@@ -1323,6 +1494,35 @@ pub fn build_runtime_referral_redeem_record(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_reward_code_redeem_record(
|
||||
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
|
||||
) -> RuntimeProfileRewardCodeRedeemRecord {
|
||||
RuntimeProfileRewardCodeRedeemRecord {
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
amount_granted: snapshot.amount_granted,
|
||||
ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_record(
|
||||
snapshot: RuntimeProfileRedeemCodeSnapshot,
|
||||
) -> RuntimeProfileRedeemCodeRecord {
|
||||
RuntimeProfileRedeemCodeRecord {
|
||||
code: snapshot.code,
|
||||
mode: snapshot.mode,
|
||||
reward_points: snapshot.reward_points,
|
||||
max_uses: snapshot.max_uses,
|
||||
global_used_count: snapshot.global_used_count,
|
||||
enabled: snapshot.enabled,
|
||||
allowed_user_ids: snapshot.allowed_user_ids,
|
||||
created_by: snapshot.created_by,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at: format_utc_micros(snapshot.updated_at_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_played_world_record(
|
||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||
) -> RuntimeProfilePlayedWorldRecord {
|
||||
@@ -1506,8 +1706,19 @@ impl RuntimeProfileWalletLedgerSourceType {
|
||||
Self::InviteInviterReward => "invite_inviter_reward",
|
||||
Self::InviteInviteeReward => "invite_invitee_reward",
|
||||
Self::PointsRecharge => "points_recharge",
|
||||
Self::AssetGenerationConsume => "asset_generation_consume",
|
||||
Self::AssetGenerationRefund => "asset_generation_refund",
|
||||
Self::AssetOperationConsume => "asset_operation_consume",
|
||||
Self::AssetOperationRefund => "asset_operation_refund",
|
||||
Self::RedeemCodeReward => "redeem_code_reward",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeProfileRedeemCodeMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Public => "public",
|
||||
Self::Unique => "unique",
|
||||
Self::Private => "private",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1736,6 +1947,10 @@ pub fn normalize_invite_code(value: String) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_redeem_code(value: String) -> Option<String> {
|
||||
normalize_invite_code(value)
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
|
||||
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
|
||||
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
|
||||
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
|
||||
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
|
||||
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
|
||||
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
|
||||
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
|
||||
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
|
||||
@@ -2008,12 +2226,12 @@ mod tests {
|
||||
"points_recharge"
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume.as_str(),
|
||||
"asset_generation_consume"
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume.as_str(),
|
||||
"asset_operation_consume"
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund.as_str(),
|
||||
"asset_generation_refund"
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
|
||||
"asset_operation_refund"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ pub struct ExecuteBigFishActionRequest {
|
||||
pub motion_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordBigFishPlayRequest {
|
||||
#[serde(default)]
|
||||
pub elapsed_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BigFishAnchorItemResponse {
|
||||
@@ -193,4 +200,14 @@ mod tests {
|
||||
assert_eq!(payload["motionKey"], json!("move_swim"));
|
||||
assert_eq!(payload["level"], json!(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_big_fish_play_request_uses_camel_case() {
|
||||
let payload = serde_json::to_value(RecordBigFishPlayRequest {
|
||||
elapsed_ms: Some(12_345),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload, json!({ "elapsedMs": 12_345 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub struct BigFishWorkSummaryResponse {
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
#[serde(default)]
|
||||
pub play_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -7,10 +7,10 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str =
|
||||
"asset_generation_consume";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str =
|
||||
"asset_generation_refund";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str =
|
||||
"asset_operation_consume";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund";
|
||||
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
|
||||
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
|
||||
@@ -267,6 +267,60 @@ pub struct RedeemProfileReferralInviteCodeResponse {
|
||||
pub inviter_balance_after: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedeemProfileRewardCodeRequest {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedeemProfileRewardCodeResponse {
|
||||
pub wallet_balance: u64,
|
||||
pub amount_granted: u64,
|
||||
pub ledger_entry: ProfileWalletLedgerEntryResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertProfileRedeemCodeRequest {
|
||||
pub code: String,
|
||||
pub mode: String,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_public_user_codes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminDisableProfileRedeemCodeRequest {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileRedeemCodeAdminResponse {
|
||||
pub code: String,
|
||||
pub mode: String,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfilePlayedWorkSummaryResponse {
|
||||
@@ -828,7 +882,7 @@ mod tests {
|
||||
id: "ledger-5".to_string(),
|
||||
amount_delta: -1,
|
||||
balance_after: 199,
|
||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME
|
||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
|
||||
.to_string(),
|
||||
created_at: "2026-04-22T10:04:00Z".to_string(),
|
||||
},
|
||||
@@ -836,7 +890,7 @@ mod tests {
|
||||
id: "ledger-6".to_string(),
|
||||
amount_delta: 1,
|
||||
balance_after: 200,
|
||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
|
||||
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
|
||||
.to_string(),
|
||||
created_at: "2026-04-22T10:05:00Z".to_string(),
|
||||
},
|
||||
@@ -864,11 +918,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
payload["entries"][4]["sourceType"],
|
||||
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME)
|
||||
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["entries"][5]["sourceType"],
|
||||
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND)
|
||||
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["entries"][0]["createdAt"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use crate::mapper::*;
|
||||
use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work;
|
||||
use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play;
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn create_big_fish_session(
|
||||
@@ -265,4 +266,28 @@ impl SpacetimeClient {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_big_fish_play(
|
||||
&self,
|
||||
input: BigFishPlayReportRecordInput,
|
||||
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
|
||||
let procedure_input = BigFishPlayRecordInput {
|
||||
session_id: input.session_id,
|
||||
user_id: input.user_id,
|
||||
elapsed_ms: input.elapsed_ms,
|
||||
played_at_micros: input.reported_at_micros,
|
||||
};
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.record_big_fish_play_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(|result| map_big_fish_works_procedure_result(result, None));
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ pub use mapper::{
|
||||
BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput,
|
||||
BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput,
|
||||
BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput,
|
||||
BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput,
|
||||
BigFishSessionRecord, BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord,
|
||||
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
|
||||
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
|
||||
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
|
||||
BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord,
|
||||
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord,
|
||||
CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput,
|
||||
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput,
|
||||
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
|
||||
CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
|
||||
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,
|
||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||
@@ -120,6 +121,8 @@ use module_runtime::{
|
||||
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
|
||||
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
|
||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
|
||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
|
||||
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
|
||||
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
|
||||
@@ -129,8 +132,12 @@ use module_runtime::{
|
||||
build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input,
|
||||
build_runtime_profile_recharge_center_record,
|
||||
build_runtime_profile_recharge_order_create_input,
|
||||
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record,
|
||||
build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input,
|
||||
build_runtime_profile_redeem_code_admin_disable_input,
|
||||
build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,
|
||||
build_runtime_profile_reward_code_redeem_input,
|
||||
build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input,
|
||||
build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input,
|
||||
build_runtime_profile_wallet_adjustment_input,
|
||||
build_runtime_profile_wallet_ledger_entry_record,
|
||||
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,
|
||||
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,
|
||||
|
||||
@@ -161,6 +161,48 @@ impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRewardCodeRedeemInput>
|
||||
for RuntimeProfileRewardCodeRedeemInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileRewardCodeRedeemInput) -> Self {
|
||||
Self {
|
||||
user_id: input.user_id,
|
||||
code: input.code,
|
||||
redeemed_at_micros: input.redeemed_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput>
|
||||
for RuntimeProfileRedeemCodeAdminUpsertInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput) -> Self {
|
||||
Self {
|
||||
admin_user_id: input.admin_user_id,
|
||||
code: input.code,
|
||||
mode: map_runtime_profile_redeem_code_mode(input.mode),
|
||||
reward_points: input.reward_points,
|
||||
max_uses: input.max_uses,
|
||||
enabled: input.enabled,
|
||||
allowed_user_ids: input.allowed_user_ids,
|
||||
allowed_public_user_codes: input.allowed_public_user_codes,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRedeemCodeAdminDisableInput>
|
||||
for RuntimeProfileRedeemCodeAdminDisableInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminDisableInput) -> Self {
|
||||
Self {
|
||||
admin_user_id: input.admin_user_id,
|
||||
code: input.code,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
|
||||
for RuntimeReferralInviteCenterGetInput
|
||||
{
|
||||
@@ -802,6 +844,48 @@ pub(crate) fn map_runtime_referral_redeem_procedure_result(
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result(
|
||||
result: RuntimeProfileRewardCodeRedeemProcedureResult,
|
||||
) -> Result<RuntimeProfileRewardCodeRedeemRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = result.record.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure(
|
||||
"SpacetimeDB procedure 未返回 reward redeem 快照".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(build_runtime_profile_reward_code_redeem_record(
|
||||
map_runtime_profile_reward_code_redeem_snapshot(snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
|
||||
result: RuntimeProfileRedeemCodeAdminProcedureResult,
|
||||
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::Procedure(
|
||||
result
|
||||
.error_message
|
||||
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = result.record.ok_or_else(|| {
|
||||
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 redeem code 快照".to_string())
|
||||
})?;
|
||||
|
||||
Ok(build_runtime_profile_redeem_code_record(
|
||||
map_runtime_profile_redeem_code_snapshot(snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_play_stats_procedure_result(
|
||||
result: RuntimeProfilePlayStatsProcedureResult,
|
||||
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
|
||||
@@ -1673,6 +1757,33 @@ pub(crate) fn map_runtime_referral_redeem_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot(
|
||||
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
|
||||
) -> module_runtime::RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
module_runtime::RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
amount_granted: snapshot.amount_granted,
|
||||
ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_redeem_code_snapshot(
|
||||
snapshot: RuntimeProfileRedeemCodeSnapshot,
|
||||
) -> module_runtime::RuntimeProfileRedeemCodeSnapshot {
|
||||
module_runtime::RuntimeProfileRedeemCodeSnapshot {
|
||||
code: snapshot.code,
|
||||
mode: map_runtime_profile_redeem_code_mode_back(snapshot.mode),
|
||||
reward_points: snapshot.reward_points,
|
||||
max_uses: snapshot.max_uses,
|
||||
global_used_count: snapshot.global_used_count,
|
||||
enabled: snapshot.enabled,
|
||||
allowed_user_ids: snapshot.allowed_user_ids,
|
||||
created_by: snapshot.created_by,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_played_world_snapshot(
|
||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
|
||||
@@ -3282,11 +3393,46 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
|
||||
module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_redeem_code_mode(
|
||||
value: module_runtime::RuntimeProfileRedeemCodeMode,
|
||||
) -> crate::module_bindings::RuntimeProfileRedeemCodeMode {
|
||||
match value {
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Public => {
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Public
|
||||
}
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Unique => {
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique
|
||||
}
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Private => {
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Private
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_redeem_code_mode_back(
|
||||
value: crate::module_bindings::RuntimeProfileRedeemCodeMode,
|
||||
) -> module_runtime::RuntimeProfileRedeemCodeMode {
|
||||
match value {
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Public => {
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Public
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique => {
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Unique
|
||||
}
|
||||
crate::module_bindings::RuntimeProfileRedeemCodeMode::Private => {
|
||||
module_runtime::RuntimeProfileRedeemCodeMode::Private
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4222,6 +4368,14 @@ pub struct PuzzleRunNextLevelRecordInput {
|
||||
pub advanced_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BigFishPlayReportRecordInput {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub reported_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PuzzleAnchorItemRecord {
|
||||
pub key: String,
|
||||
@@ -4622,6 +4776,7 @@ pub struct BigFishWorkSummaryRecord {
|
||||
pub level_main_image_ready_count: u32,
|
||||
pub level_motion_ready_count: u32,
|
||||
pub background_ready: bool,
|
||||
pub play_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
|
||||
@@ -4641,6 +4796,8 @@ struct CompatibleBigFishWorkSummaryRecord {
|
||||
level_main_image_ready_count: u32,
|
||||
level_motion_ready_count: u32,
|
||||
background_ready: bool,
|
||||
#[serde(default)]
|
||||
play_count: u32,
|
||||
}
|
||||
|
||||
impl CompatibleBigFishWorkSummaryRecord {
|
||||
@@ -4665,6 +4822,7 @@ impl CompatibleBigFishWorkSummaryRecord {
|
||||
level_main_image_ready_count: self.level_main_image_ready_count,
|
||||
level_motion_ready_count: self.level_motion_ready_count,
|
||||
background_ready: self.background_ready,
|
||||
play_count: self.play_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
|
||||
use super::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct AdminDisableProfileRedeemCodeArgs {
|
||||
pub input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AdminDisableProfileRedeemCodeArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `admin_disable_profile_redeem_code`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait admin_disable_profile_redeem_code {
|
||||
fn admin_disable_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminDisableInput) {
|
||||
self.admin_disable_profile_redeem_code_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn admin_disable_profile_redeem_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl admin_disable_profile_redeem_code for super::RemoteProcedures {
|
||||
fn admin_disable_profile_redeem_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||
"admin_disable_profile_redeem_code",
|
||||
AdminDisableProfileRedeemCodeArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
|
||||
use super::runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct AdminUpsertProfileRedeemCodeArgs {
|
||||
pub input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AdminUpsertProfileRedeemCodeArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `admin_upsert_profile_redeem_code`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait admin_upsert_profile_redeem_code {
|
||||
fn admin_upsert_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminUpsertInput) {
|
||||
self.admin_upsert_profile_redeem_code_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn admin_upsert_profile_redeem_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl admin_upsert_profile_redeem_code for super::RemoteProcedures {
|
||||
fn admin_upsert_profile_redeem_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||
"admin_upsert_profile_redeem_code",
|
||||
AdminUpsertProfileRedeemCodeArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub struct BigFishCreationSession {
|
||||
pub asset_coverage_json: String,
|
||||
pub last_assistant_reply: Option<String>,
|
||||
pub publish_ready: bool,
|
||||
pub play_count: u32,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
}
|
||||
@@ -43,6 +44,7 @@ pub struct BigFishCreationSessionCols {
|
||||
pub asset_coverage_json: __sdk::__query_builder::Col<BigFishCreationSession, String>,
|
||||
pub last_assistant_reply: __sdk::__query_builder::Col<BigFishCreationSession, Option<String>>,
|
||||
pub publish_ready: __sdk::__query_builder::Col<BigFishCreationSession, bool>,
|
||||
pub play_count: __sdk::__query_builder::Col<BigFishCreationSession, u32>,
|
||||
pub created_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
|
||||
}
|
||||
@@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession {
|
||||
"last_assistant_reply",
|
||||
),
|
||||
publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"),
|
||||
play_count: __sdk::__query_builder::Col::new(table_name, "play_count"),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
}
|
||||
|
||||
@@ -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 BigFishPlayRecordInput {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub elapsed_ms: u64,
|
||||
pub played_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for BigFishPlayRecordInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
pub mod accept_quest_reducer;
|
||||
pub mod acknowledge_quest_completion_reducer;
|
||||
pub mod admin_disable_profile_redeem_code_procedure;
|
||||
pub mod admin_upsert_profile_redeem_code_procedure;
|
||||
pub mod advance_puzzle_next_level_procedure;
|
||||
pub mod ai_result_reference_input_type;
|
||||
pub mod ai_result_reference_kind_type;
|
||||
@@ -89,6 +91,7 @@ pub mod big_fish_game_draft_type;
|
||||
pub mod big_fish_level_blueprint_type;
|
||||
pub mod big_fish_message_finalize_input_type;
|
||||
pub mod big_fish_message_submit_input_type;
|
||||
pub mod big_fish_play_record_input_type;
|
||||
pub mod big_fish_publish_input_type;
|
||||
pub mod big_fish_runtime_params_type;
|
||||
pub mod big_fish_session_create_input_type;
|
||||
@@ -277,6 +280,8 @@ pub mod profile_invite_code_type;
|
||||
pub mod profile_membership_type;
|
||||
pub mod profile_played_world_type;
|
||||
pub mod profile_recharge_order_type;
|
||||
pub mod profile_redeem_code_type;
|
||||
pub mod profile_redeem_code_usage_type;
|
||||
pub mod profile_referral_relation_type;
|
||||
pub mod profile_save_archive_type;
|
||||
pub mod profile_wallet_ledger_type;
|
||||
@@ -343,7 +348,9 @@ pub mod quest_status_type;
|
||||
pub mod quest_step_snapshot_type;
|
||||
pub mod quest_treasure_inspected_signal_type;
|
||||
pub mod quest_turn_in_input_type;
|
||||
pub mod record_big_fish_play_procedure;
|
||||
pub mod redeem_profile_referral_invite_code_procedure;
|
||||
pub mod redeem_profile_reward_code_procedure;
|
||||
pub mod refresh_session_type;
|
||||
pub mod refund_profile_wallet_points_and_return_procedure;
|
||||
pub mod resolve_combat_action_and_return_procedure;
|
||||
@@ -403,6 +410,14 @@ pub mod runtime_profile_recharge_order_snapshot_type;
|
||||
pub mod runtime_profile_recharge_order_status_type;
|
||||
pub mod runtime_profile_recharge_product_kind_type;
|
||||
pub mod runtime_profile_recharge_product_snapshot_type;
|
||||
pub mod runtime_profile_redeem_code_admin_disable_input_type;
|
||||
pub mod runtime_profile_redeem_code_admin_procedure_result_type;
|
||||
pub mod runtime_profile_redeem_code_admin_upsert_input_type;
|
||||
pub mod runtime_profile_redeem_code_mode_type;
|
||||
pub mod runtime_profile_redeem_code_snapshot_type;
|
||||
pub mod runtime_profile_reward_code_redeem_input_type;
|
||||
pub mod runtime_profile_reward_code_redeem_procedure_result_type;
|
||||
pub mod runtime_profile_reward_code_redeem_snapshot_type;
|
||||
pub mod runtime_profile_save_archive_list_input_type;
|
||||
pub mod runtime_profile_save_archive_procedure_result_type;
|
||||
pub mod runtime_profile_save_archive_resume_input_type;
|
||||
@@ -477,6 +492,8 @@ pub mod user_browse_history_type;
|
||||
|
||||
pub use accept_quest_reducer::accept_quest;
|
||||
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
|
||||
pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code;
|
||||
pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
|
||||
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
|
||||
pub use ai_result_reference_input_type::AiResultReferenceInput;
|
||||
pub use ai_result_reference_kind_type::AiResultReferenceKind;
|
||||
@@ -558,6 +575,7 @@ pub use big_fish_game_draft_type::BigFishGameDraft;
|
||||
pub use big_fish_level_blueprint_type::BigFishLevelBlueprint;
|
||||
pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput;
|
||||
pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput;
|
||||
pub use big_fish_play_record_input_type::BigFishPlayRecordInput;
|
||||
pub use big_fish_publish_input_type::BigFishPublishInput;
|
||||
pub use big_fish_runtime_params_type::BigFishRuntimeParams;
|
||||
pub use big_fish_session_create_input_type::BigFishSessionCreateInput;
|
||||
@@ -746,6 +764,8 @@ pub use profile_invite_code_type::ProfileInviteCode;
|
||||
pub use profile_membership_type::ProfileMembership;
|
||||
pub use profile_played_world_type::ProfilePlayedWorld;
|
||||
pub use profile_recharge_order_type::ProfileRechargeOrder;
|
||||
pub use profile_redeem_code_type::ProfileRedeemCode;
|
||||
pub use profile_redeem_code_usage_type::ProfileRedeemCodeUsage;
|
||||
pub use profile_referral_relation_type::ProfileReferralRelation;
|
||||
pub use profile_save_archive_type::ProfileSaveArchive;
|
||||
pub use profile_wallet_ledger_type::ProfileWalletLedger;
|
||||
@@ -812,7 +832,9 @@ pub use quest_status_type::QuestStatus;
|
||||
pub use quest_step_snapshot_type::QuestStepSnapshot;
|
||||
pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal;
|
||||
pub use quest_turn_in_input_type::QuestTurnInInput;
|
||||
pub use record_big_fish_play_procedure::record_big_fish_play;
|
||||
pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code;
|
||||
pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code;
|
||||
pub use refresh_session_type::RefreshSession;
|
||||
pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return;
|
||||
pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return;
|
||||
@@ -872,6 +894,14 @@ pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrde
|
||||
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
|
||||
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
|
||||
pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot;
|
||||
pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
|
||||
pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
|
||||
pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput;
|
||||
pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
|
||||
pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot;
|
||||
pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput;
|
||||
pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult;
|
||||
pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot;
|
||||
pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveListInput;
|
||||
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
|
||||
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct ProfileRedeemCode {
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: String,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ProfileRedeemCode {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `ProfileRedeemCode`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct ProfileRedeemCodeCols {
|
||||
pub code: __sdk::__query_builder::Col<ProfileRedeemCode, String>,
|
||||
pub mode: __sdk::__query_builder::Col<ProfileRedeemCode, RuntimeProfileRedeemCodeMode>,
|
||||
pub reward_points: __sdk::__query_builder::Col<ProfileRedeemCode, u64>,
|
||||
pub max_uses: __sdk::__query_builder::Col<ProfileRedeemCode, u32>,
|
||||
pub global_used_count: __sdk::__query_builder::Col<ProfileRedeemCode, u32>,
|
||||
pub enabled: __sdk::__query_builder::Col<ProfileRedeemCode, bool>,
|
||||
pub allowed_user_ids: __sdk::__query_builder::Col<ProfileRedeemCode, Vec<String>>,
|
||||
pub created_by: __sdk::__query_builder::Col<ProfileRedeemCode, String>,
|
||||
pub created_at: __sdk::__query_builder::Col<ProfileRedeemCode, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<ProfileRedeemCode, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for ProfileRedeemCode {
|
||||
type Cols = ProfileRedeemCodeCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
ProfileRedeemCodeCols {
|
||||
code: __sdk::__query_builder::Col::new(table_name, "code"),
|
||||
mode: __sdk::__query_builder::Col::new(table_name, "mode"),
|
||||
reward_points: __sdk::__query_builder::Col::new(table_name, "reward_points"),
|
||||
max_uses: __sdk::__query_builder::Col::new(table_name, "max_uses"),
|
||||
global_used_count: __sdk::__query_builder::Col::new(table_name, "global_used_count"),
|
||||
enabled: __sdk::__query_builder::Col::new(table_name, "enabled"),
|
||||
allowed_user_ids: __sdk::__query_builder::Col::new(table_name, "allowed_user_ids"),
|
||||
created_by: __sdk::__query_builder::Col::new(table_name, "created_by"),
|
||||
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 `ProfileRedeemCode`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct ProfileRedeemCodeIxCols {
|
||||
pub code: __sdk::__query_builder::IxCol<ProfileRedeemCode, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for ProfileRedeemCode {
|
||||
type IxCols = ProfileRedeemCodeIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
ProfileRedeemCodeIxCols {
|
||||
code: __sdk::__query_builder::IxCol::new(table_name, "code"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for ProfileRedeemCode {}
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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 ProfileRedeemCodeUsage {
|
||||
pub usage_id: String,
|
||||
pub code: String,
|
||||
pub user_id: String,
|
||||
pub amount_granted: u64,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ProfileRedeemCodeUsage {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `ProfileRedeemCodeUsage`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct ProfileRedeemCodeUsageCols {
|
||||
pub usage_id: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, String>,
|
||||
pub code: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, String>,
|
||||
pub user_id: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, String>,
|
||||
pub amount_granted: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, u64>,
|
||||
pub created_at: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for ProfileRedeemCodeUsage {
|
||||
type Cols = ProfileRedeemCodeUsageCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
ProfileRedeemCodeUsageCols {
|
||||
usage_id: __sdk::__query_builder::Col::new(table_name, "usage_id"),
|
||||
code: __sdk::__query_builder::Col::new(table_name, "code"),
|
||||
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
|
||||
amount_granted: __sdk::__query_builder::Col::new(table_name, "amount_granted"),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `ProfileRedeemCodeUsage`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct ProfileRedeemCodeUsageIxCols {
|
||||
pub code: __sdk::__query_builder::IxCol<ProfileRedeemCodeUsage, String>,
|
||||
pub usage_id: __sdk::__query_builder::IxCol<ProfileRedeemCodeUsage, String>,
|
||||
pub user_id: __sdk::__query_builder::IxCol<ProfileRedeemCodeUsage, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for ProfileRedeemCodeUsage {
|
||||
type IxCols = ProfileRedeemCodeUsageIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
ProfileRedeemCodeUsageIxCols {
|
||||
code: __sdk::__query_builder::IxCol::new(table_name, "code"),
|
||||
usage_id: __sdk::__query_builder::IxCol::new(table_name, "usage_id"),
|
||||
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for ProfileRedeemCodeUsage {}
|
||||
@@ -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::big_fish_play_record_input_type::BigFishPlayRecordInput;
|
||||
use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct RecordBigFishPlayArgs {
|
||||
pub input: BigFishPlayRecordInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RecordBigFishPlayArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `record_big_fish_play`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait record_big_fish_play {
|
||||
fn record_big_fish_play(&self, input: BigFishPlayRecordInput) {
|
||||
self.record_big_fish_play_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn record_big_fish_play_then(
|
||||
&self,
|
||||
input: BigFishPlayRecordInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishWorksProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl record_big_fish_play for super::RemoteProcedures {
|
||||
fn record_big_fish_play_then(
|
||||
&self,
|
||||
input: BigFishPlayRecordInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<BigFishWorksProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>(
|
||||
"record_big_fish_play",
|
||||
RecordBigFishPlayArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput;
|
||||
use super::runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct RedeemProfileRewardCodeArgs {
|
||||
pub input: RuntimeProfileRewardCodeRedeemInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RedeemProfileRewardCodeArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `redeem_profile_reward_code`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait redeem_profile_reward_code {
|
||||
fn redeem_profile_reward_code(&self, input: RuntimeProfileRewardCodeRedeemInput) {
|
||||
self.redeem_profile_reward_code_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn redeem_profile_reward_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRewardCodeRedeemInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRewardCodeRedeemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl redeem_profile_reward_code for super::RemoteProcedures {
|
||||
fn redeem_profile_reward_code_then(
|
||||
&self,
|
||||
input: RuntimeProfileRewardCodeRedeemInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRewardCodeRedeemProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRewardCodeRedeemProcedureResult>(
|
||||
"redeem_profile_reward_code",
|
||||
RedeemProfileRewardCodeArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 RuntimeProfileRedeemCodeAdminDisableInput {
|
||||
pub admin_user_id: String,
|
||||
pub code: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminDisableInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileRedeemCodeSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRedeemCodeAdminUpsertInput {
|
||||
pub admin_user_id: String,
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub allowed_public_user_codes: Vec<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminUpsertInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
pub enum RuntimeProfileRedeemCodeMode {
|
||||
Public,
|
||||
|
||||
Unique,
|
||||
|
||||
Private,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeMode {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRedeemCodeSnapshot {
|
||||
pub code: String,
|
||||
pub mode: RuntimeProfileRedeemCodeMode,
|
||||
pub reward_points: u64,
|
||||
pub max_uses: u32,
|
||||
pub global_used_count: u32,
|
||||
pub enabled: bool,
|
||||
pub allowed_user_ids: Vec<String>,
|
||||
pub created_by: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -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 RuntimeProfileRewardCodeRedeemInput {
|
||||
pub user_id: String,
|
||||
pub code: String,
|
||||
pub redeemed_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileRewardCodeRedeemSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
pub wallet_balance: u64,
|
||||
pub amount_granted: u64,
|
||||
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -16,9 +16,11 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
||||
|
||||
PointsRecharge,
|
||||
|
||||
AssetGenerationConsume,
|
||||
AssetOperationConsume,
|
||||
|
||||
AssetGenerationRefund,
|
||||
AssetOperationRefund,
|
||||
|
||||
RedeemCodeReward,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
||||
|
||||
@@ -255,6 +255,97 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn redeem_profile_reward_code(
|
||||
&self,
|
||||
user_id: String,
|
||||
code: String,
|
||||
redeemed_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRewardCodeRedeemRecord, SpacetimeClientError> {
|
||||
let procedure_input =
|
||||
build_runtime_profile_reward_code_redeem_input(user_id, code, redeemed_at_micros)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
|
||||
.into();
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection.procedures().redeem_profile_reward_code_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_runtime_profile_reward_code_redeem_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_upsert_profile_redeem_code(
|
||||
&self,
|
||||
admin_user_id: String,
|
||||
code: String,
|
||||
mode: DomainRuntimeProfileRedeemCodeMode,
|
||||
reward_points: u64,
|
||||
max_uses: u32,
|
||||
enabled: bool,
|
||||
allowed_user_ids: Vec<String>,
|
||||
allowed_public_user_codes: Vec<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
|
||||
let procedure_input = build_runtime_profile_redeem_code_admin_upsert_input(
|
||||
admin_user_id,
|
||||
code,
|
||||
mode,
|
||||
reward_points,
|
||||
max_uses,
|
||||
enabled,
|
||||
allowed_user_ids,
|
||||
allowed_public_user_codes,
|
||||
updated_at_micros,
|
||||
)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
|
||||
.into();
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.admin_upsert_profile_redeem_code_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_runtime_profile_redeem_code_admin_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_disable_profile_redeem_code(
|
||||
&self,
|
||||
admin_user_id: String,
|
||||
code: String,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
|
||||
let procedure_input = build_runtime_profile_redeem_code_admin_disable_input(
|
||||
admin_user_id,
|
||||
code,
|
||||
updated_at_micros,
|
||||
)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
|
||||
.into();
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.admin_disable_profile_redeem_code_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_runtime_profile_redeem_code_admin_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_profile_play_stats(
|
||||
&self,
|
||||
user_id: String,
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(crate) fn generate_big_fish_asset_tx(
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some(reply.clone()),
|
||||
publish_ready: coverage.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at,
|
||||
};
|
||||
@@ -164,6 +165,7 @@ pub(crate) fn publish_big_fish_game_tx(
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
|
||||
publish_ready: true,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at: published_at,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
|
||||
};
|
||||
use crate::*;
|
||||
|
||||
const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0;
|
||||
@@ -93,6 +96,32 @@ pub fn delete_big_fish_work(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn record_big_fish_play(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: BigFishPlayRecordInput,
|
||||
) -> BigFishWorksProcedureResult {
|
||||
match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) {
|
||||
Ok(items) => match serde_json::to_string(&items) {
|
||||
Ok(items_json) => BigFishWorksProcedureResult {
|
||||
ok: true,
|
||||
items_json: Some(items_json),
|
||||
error_message: None,
|
||||
},
|
||||
Err(error) => BigFishWorksProcedureResult {
|
||||
ok: false,
|
||||
items_json: None,
|
||||
error_message: Some(error.to_string()),
|
||||
},
|
||||
},
|
||||
Err(message) => BigFishWorksProcedureResult {
|
||||
ok: false,
|
||||
items_json: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn submit_big_fish_message(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -194,6 +223,7 @@ pub(crate) fn create_big_fish_session_tx(
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some(input.welcome_message_text.clone()),
|
||||
publish_ready: false,
|
||||
play_count: 0,
|
||||
created_at,
|
||||
updated_at: created_at,
|
||||
});
|
||||
@@ -383,6 +413,7 @@ pub(crate) fn submit_big_fish_message_tx(
|
||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||
publish_ready: session.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at: submitted_at,
|
||||
};
|
||||
@@ -429,6 +460,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
|
||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||
publish_ready: session.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at,
|
||||
};
|
||||
@@ -483,6 +515,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
|
||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||
last_assistant_reply: Some(assistant_reply_text),
|
||||
publish_ready: session.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at,
|
||||
};
|
||||
@@ -536,6 +569,7 @@ pub(crate) fn compile_big_fish_draft_tx(
|
||||
.map_err(|error| error.to_string())?,
|
||||
last_assistant_reply: Some(reply.clone()),
|
||||
publish_ready: coverage.publish_ready,
|
||||
play_count: session.play_count,
|
||||
created_at: session.created_at,
|
||||
updated_at: compiled_at,
|
||||
};
|
||||
@@ -550,6 +584,92 @@ pub(crate) fn compile_big_fish_draft_tx(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn record_big_fish_play_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BigFishPlayRecordInput,
|
||||
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
|
||||
validate_play_record_input(&input).map_err(|error| error.to_string())?;
|
||||
let session = ctx
|
||||
.db
|
||||
.big_fish_creation_session()
|
||||
.session_id()
|
||||
.find(&input.session_id)
|
||||
.filter(|row| row.stage == BigFishCreationStage::Published)
|
||||
.ok_or_else(|| "big_fish 已发布作品不存在".to_string())?;
|
||||
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
|
||||
let draft = session
|
||||
.draft_json
|
||||
.as_deref()
|
||||
.map(deserialize_draft)
|
||||
.transpose()
|
||||
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
|
||||
let title = draft
|
||||
.as_ref()
|
||||
.map(|value| value.title.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "大鱼吃小鱼".to_string());
|
||||
let subtitle = draft
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
let subtitle = value.subtitle.trim();
|
||||
if subtitle.is_empty() {
|
||||
let core_fun = value.core_fun.trim();
|
||||
(!core_fun.is_empty()).then(|| core_fun.to_string())
|
||||
} else {
|
||||
Some(subtitle.to_string())
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let world_key = format!("big-fish:{}", session.session_id);
|
||||
|
||||
upsert_profile_played_work(
|
||||
ctx,
|
||||
ProfilePlayedWorkUpsertInput {
|
||||
user_id: input.user_id.clone(),
|
||||
world_key: world_key.clone(),
|
||||
owner_user_id: Some(session.owner_user_id.clone()),
|
||||
profile_id: Some(session.session_id.clone()),
|
||||
world_type: Some("BIG_FISH".to_string()),
|
||||
world_title: title,
|
||||
world_subtitle: subtitle,
|
||||
played_at_micros: input.played_at_micros,
|
||||
},
|
||||
)?;
|
||||
add_profile_observed_play_time(
|
||||
ctx,
|
||||
&input.user_id,
|
||||
&world_key,
|
||||
input.elapsed_ms,
|
||||
input.played_at_micros,
|
||||
)?;
|
||||
let next_session = BigFishCreationSession {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: session.owner_user_id.clone(),
|
||||
seed_text: session.seed_text.clone(),
|
||||
current_turn: session.current_turn,
|
||||
progress_percent: session.progress_percent,
|
||||
stage: session.stage,
|
||||
anchor_pack_json: session.anchor_pack_json.clone(),
|
||||
draft_json: session.draft_json.clone(),
|
||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||
publish_ready: session.publish_ready,
|
||||
// 中文注释:正式进入已发布作品时同时累加作品播放数,用户侧去重由 profile_played_world 保证。
|
||||
play_count: session.play_count.saturating_add(1),
|
||||
created_at: session.created_at,
|
||||
updated_at: played_at,
|
||||
};
|
||||
replace_big_fish_session(ctx, &session, next_session);
|
||||
|
||||
list_big_fish_works_tx(
|
||||
ctx,
|
||||
BigFishWorksListInput {
|
||||
owner_user_id: String::new(),
|
||||
published_only: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_big_fish_session_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
row: &BigFishCreationSession,
|
||||
@@ -663,6 +783,7 @@ pub(crate) fn build_big_fish_work_summary(
|
||||
level_main_image_ready_count: coverage.level_main_image_ready_count,
|
||||
level_motion_ready_count: coverage.level_motion_ready_count,
|
||||
background_ready: coverage.background_ready,
|
||||
play_count: row.play_count,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -699,6 +820,7 @@ mod tests {
|
||||
asset_coverage_json: "{}".to_string(),
|
||||
last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()),
|
||||
publish_ready: false,
|
||||
play_count: 0,
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct BigFishCreationSession {
|
||||
pub(crate) asset_coverage_json: String,
|
||||
pub(crate) last_assistant_reply: Option<String>,
|
||||
pub(crate) publish_ready: bool,
|
||||
pub(crate) play_count: u32,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ macro_rules! migration_tables {
|
||||
user_browse_history,
|
||||
profile_dashboard_state,
|
||||
profile_wallet_ledger,
|
||||
profile_redeem_code,
|
||||
profile_redeem_code_usage,
|
||||
profile_invite_code,
|
||||
profile_referral_relation,
|
||||
profile_played_world,
|
||||
@@ -659,6 +661,19 @@ where
|
||||
Ok(wrapped.0)
|
||||
}
|
||||
|
||||
fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value {
|
||||
let mut next_value = value.clone();
|
||||
if table_name == "big_fish_creation_session" {
|
||||
if let Some(object) = next_value.as_object_mut() {
|
||||
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
|
||||
object
|
||||
.entry("play_count".to_string())
|
||||
.or_insert_with(|| serde_json::Value::from(0));
|
||||
}
|
||||
}
|
||||
next_value
|
||||
}
|
||||
|
||||
fn insert_migration_table_rows(
|
||||
ctx: &ReducerContext,
|
||||
table: &MigrationTable,
|
||||
@@ -672,7 +687,8 @@ fn insert_migration_table_rows(
|
||||
let mut imported = 0u64;
|
||||
let mut skipped = 0u64;
|
||||
for value in &table.rows {
|
||||
let row = row_from_json(value)
|
||||
let normalized_value = normalize_migration_row(stringify!($table), value);
|
||||
let row = row_from_json(&normalized_value)
|
||||
.map_err(|error| format!("{}: {error}", stringify!($table)))?;
|
||||
let insert_result = ctx.db
|
||||
.$table()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
|
||||
};
|
||||
use module_puzzle::{
|
||||
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
|
||||
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
||||
@@ -1072,6 +1075,12 @@ fn start_puzzle_run_tx(
|
||||
.map(|value| value.profile_id.clone());
|
||||
|
||||
increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros);
|
||||
upsert_puzzle_profile_played_work(
|
||||
ctx,
|
||||
&input.owner_user_id,
|
||||
&entry_profile_row,
|
||||
input.started_at_micros,
|
||||
)?;
|
||||
insert_puzzle_runtime_run(ctx, &run, &input.owner_user_id, input.started_at_micros)?;
|
||||
Ok(run)
|
||||
}
|
||||
@@ -1179,6 +1188,12 @@ fn advance_puzzle_next_level_tx(
|
||||
.find(&next_profile.profile_id)
|
||||
{
|
||||
increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros);
|
||||
upsert_puzzle_profile_played_work(
|
||||
ctx,
|
||||
&input.owner_user_id,
|
||||
&next_profile_row,
|
||||
input.advanced_at_micros,
|
||||
)?;
|
||||
}
|
||||
replace_puzzle_runtime_run(ctx, &row, &next_run, input.advanced_at_micros);
|
||||
Ok(next_run)
|
||||
@@ -1219,6 +1234,13 @@ fn submit_puzzle_leaderboard_entry_tx(
|
||||
&input.run_id,
|
||||
input.submitted_at_micros,
|
||||
);
|
||||
add_profile_observed_play_time(
|
||||
ctx,
|
||||
&input.owner_user_id,
|
||||
&format!("puzzle:{}", input.profile_id),
|
||||
input.elapsed_ms.max(1_000),
|
||||
input.submitted_at_micros,
|
||||
)?;
|
||||
|
||||
let leaderboard_entries = list_puzzle_leaderboard_entries(
|
||||
ctx,
|
||||
@@ -1607,6 +1629,28 @@ fn increment_puzzle_profile_play_count(
|
||||
);
|
||||
}
|
||||
|
||||
fn upsert_puzzle_profile_played_work(
|
||||
ctx: &TxContext,
|
||||
user_id: &str,
|
||||
row: &PuzzleWorkProfileRow,
|
||||
played_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
// 拼图正式游玩以作品 profile_id 作为公开作品号,用户侧明细按 world_key 去重。
|
||||
upsert_profile_played_work(
|
||||
ctx,
|
||||
ProfilePlayedWorkUpsertInput {
|
||||
user_id: user_id.to_string(),
|
||||
world_key: format!("puzzle:{}", row.profile_id),
|
||||
owner_user_id: Some(row.owner_user_id.clone()),
|
||||
profile_id: Some(row.profile_id.clone()),
|
||||
world_type: Some("PUZZLE".to_string()),
|
||||
world_title: row.level_name.clone(),
|
||||
world_subtitle: row.summary.clone(),
|
||||
played_at_micros,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn replace_generated_candidate(
|
||||
draft: &mut PuzzleResultDraft,
|
||||
candidates: Vec<PuzzleGeneratedImageCandidate>,
|
||||
|
||||
@@ -28,6 +28,39 @@ pub struct ProfileWalletLedger {
|
||||
pub(crate) created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_redeem_code)]
|
||||
pub struct ProfileRedeemCode {
|
||||
#[primary_key]
|
||||
pub(crate) code: String,
|
||||
pub(crate) mode: RuntimeProfileRedeemCodeMode,
|
||||
pub(crate) reward_points: u64,
|
||||
pub(crate) max_uses: u32,
|
||||
pub(crate) global_used_count: u32,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) allowed_user_ids: Vec<String>,
|
||||
pub(crate) created_by: String,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = profile_redeem_code_usage,
|
||||
index(accessor = by_profile_redeem_code_usage_code, btree(columns = [code])),
|
||||
index(accessor = by_profile_redeem_code_usage_user_id, btree(columns = [user_id])),
|
||||
index(
|
||||
accessor = by_profile_redeem_code_usage_code_user_id,
|
||||
btree(columns = [code, user_id])
|
||||
)
|
||||
)]
|
||||
pub struct ProfileRedeemCodeUsage {
|
||||
#[primary_key]
|
||||
pub(crate) usage_id: String,
|
||||
pub(crate) code: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) amount_granted: u64,
|
||||
pub(crate) created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_invite_code)]
|
||||
pub struct ProfileInviteCode {
|
||||
#[primary_key]
|
||||
@@ -83,6 +116,17 @@ pub struct ProfilePlayedWorld {
|
||||
pub(crate) last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct ProfilePlayedWorkUpsertInput {
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) world_key: String,
|
||||
pub(crate) owner_user_id: Option<String>,
|
||||
pub(crate) profile_id: Option<String>,
|
||||
pub(crate) world_type: Option<String>,
|
||||
pub(crate) world_title: String,
|
||||
pub(crate) world_subtitle: String,
|
||||
pub(crate) played_at_micros: i64,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_membership)]
|
||||
pub struct ProfileMembership {
|
||||
#[primary_key]
|
||||
@@ -248,7 +292,7 @@ pub fn consume_profile_wallet_points_and_return(
|
||||
apply_profile_wallet_adjustment(
|
||||
tx,
|
||||
input.clone(),
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume,
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
|
||||
true,
|
||||
)
|
||||
}) {
|
||||
@@ -275,7 +319,7 @@ pub fn refund_profile_wallet_points_and_return(
|
||||
apply_profile_wallet_adjustment(
|
||||
tx,
|
||||
input.clone(),
|
||||
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund,
|
||||
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
|
||||
false,
|
||||
)
|
||||
}) {
|
||||
@@ -396,6 +440,64 @@ pub fn redeem_profile_referral_invite_code(
|
||||
}
|
||||
}
|
||||
|
||||
// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn redeem_profile_reward_code(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRewardCodeRedeemInput,
|
||||
) -> RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) {
|
||||
Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn admin_upsert_profile_redeem_code(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) {
|
||||
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn admin_disable_profile_redeem_code(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) {
|
||||
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn list_profile_save_archive_rows(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileSaveArchiveListInput,
|
||||
@@ -498,6 +600,172 @@ pub(crate) fn sync_profile_projections_from_snapshot(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn upsert_profile_played_work(
|
||||
ctx: &ReducerContext,
|
||||
input: ProfilePlayedWorkUpsertInput,
|
||||
) -> Result<(), String> {
|
||||
let user_id = input.user_id.trim();
|
||||
let world_key = input.world_key.trim();
|
||||
if user_id.is_empty() {
|
||||
return Err("profile_played_world.user_id 不能为空".to_string());
|
||||
}
|
||||
if world_key.is_empty() {
|
||||
return Err("profile_played_world.world_key 不能为空".to_string());
|
||||
}
|
||||
|
||||
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
|
||||
let played_world_id = format!("{user_id}:{world_key}");
|
||||
let existing = ctx
|
||||
.db
|
||||
.profile_played_world()
|
||||
.played_world_id()
|
||||
.find(&played_world_id);
|
||||
|
||||
if let Some(existing) = existing {
|
||||
ctx.db
|
||||
.profile_played_world()
|
||||
.played_world_id()
|
||||
.delete(&existing.played_world_id);
|
||||
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
|
||||
played_world_id,
|
||||
user_id: user_id.to_string(),
|
||||
world_key: world_key.to_string(),
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
world_type: input.world_type,
|
||||
world_title: input.world_title,
|
||||
world_subtitle: input.world_subtitle,
|
||||
first_played_at: existing.first_played_at,
|
||||
last_played_at: played_at,
|
||||
last_observed_play_time_ms: existing.last_observed_play_time_ms,
|
||||
});
|
||||
} else {
|
||||
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
|
||||
played_world_id,
|
||||
user_id: user_id.to_string(),
|
||||
world_key: world_key.to_string(),
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
world_type: input.world_type,
|
||||
world_title: input.world_title,
|
||||
world_subtitle: input.world_subtitle,
|
||||
first_played_at: played_at,
|
||||
last_played_at: played_at,
|
||||
last_observed_play_time_ms: 0,
|
||||
});
|
||||
}
|
||||
|
||||
ensure_profile_dashboard_state(ctx, user_id, played_at);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn add_profile_observed_play_time(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
world_key: &str,
|
||||
elapsed_ms: u64,
|
||||
observed_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
let user_id = user_id.trim();
|
||||
let world_key = world_key.trim();
|
||||
if user_id.is_empty() || world_key.is_empty() || elapsed_ms == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let observed_at = Timestamp::from_micros_since_unix_epoch(observed_at_micros);
|
||||
let played_world_id = format!("{user_id}:{world_key}");
|
||||
if let Some(existing) = ctx
|
||||
.db
|
||||
.profile_played_world()
|
||||
.played_world_id()
|
||||
.find(&played_world_id)
|
||||
{
|
||||
ctx.db
|
||||
.profile_played_world()
|
||||
.played_world_id()
|
||||
.delete(&existing.played_world_id);
|
||||
ctx.db.profile_played_world().insert(ProfilePlayedWorld {
|
||||
played_world_id,
|
||||
user_id: existing.user_id,
|
||||
world_key: existing.world_key,
|
||||
owner_user_id: existing.owner_user_id,
|
||||
profile_id: existing.profile_id,
|
||||
world_type: existing.world_type,
|
||||
world_title: existing.world_title,
|
||||
world_subtitle: existing.world_subtitle,
|
||||
first_played_at: existing.first_played_at,
|
||||
last_played_at: observed_at,
|
||||
last_observed_play_time_ms: existing
|
||||
.last_observed_play_time_ms
|
||||
.saturating_add(elapsed_ms),
|
||||
});
|
||||
}
|
||||
|
||||
add_profile_dashboard_play_time(ctx, user_id, elapsed_ms, observed_at);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) {
|
||||
if ctx
|
||||
.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.find(&user_id.to_string())
|
||||
.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: 0,
|
||||
total_play_time_ms: 0,
|
||||
created_at: updated_at,
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
fn add_profile_dashboard_play_time(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
elapsed_ms: u64,
|
||||
updated_at: Timestamp,
|
||||
) {
|
||||
let current = ctx
|
||||
.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.find(&user_id.to_string());
|
||||
|
||||
if let Some(existing) = current {
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.user_id()
|
||||
.delete(&existing.user_id);
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: existing.wallet_balance,
|
||||
total_play_time_ms: existing.total_play_time_ms.saturating_add(elapsed_ms),
|
||||
created_at: existing.created_at,
|
||||
updated_at,
|
||||
});
|
||||
} else {
|
||||
ctx.db
|
||||
.profile_dashboard_state()
|
||||
.insert(ProfileDashboardState {
|
||||
user_id: user_id.to_string(),
|
||||
wallet_balance: 0,
|
||||
total_play_time_ms: elapsed_ms,
|
||||
created_at: updated_at,
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_profile_dashboard_from_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
snapshot: &RuntimeSnapshot,
|
||||
@@ -1194,6 +1462,185 @@ fn redeem_profile_referral_invite_code_record(
|
||||
})
|
||||
}
|
||||
|
||||
fn redeem_profile_reward_code_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRewardCodeRedeemInput,
|
||||
) -> Result<RuntimeProfileRewardCodeRedeemSnapshot, String> {
|
||||
let validated_input = build_runtime_profile_reward_code_redeem_input(
|
||||
input.user_id,
|
||||
input.code,
|
||||
input.redeemed_at_micros,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros);
|
||||
let user_id = validated_input.user_id;
|
||||
let code = validated_input.code;
|
||||
let redeem_code = ctx
|
||||
.db
|
||||
.profile_redeem_code()
|
||||
.code()
|
||||
.find(&code)
|
||||
.ok_or_else(|| "兑换码不存在".to_string())?;
|
||||
|
||||
if !redeem_code.enabled {
|
||||
return Err("兑换码已停用".to_string());
|
||||
}
|
||||
if redeem_code.reward_points == 0 {
|
||||
return Err("兑换码奖励无效".to_string());
|
||||
}
|
||||
|
||||
let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id);
|
||||
match redeem_code.mode {
|
||||
RuntimeProfileRedeemCodeMode::Public if user_used_count >= redeem_code.max_uses => {
|
||||
return Err("兑换次数已用完".to_string());
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Unique
|
||||
if redeem_code.global_used_count >= redeem_code.max_uses =>
|
||||
{
|
||||
return Err("兑换次数已用完".to_string());
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Private => {
|
||||
if !redeem_code
|
||||
.allowed_user_ids
|
||||
.iter()
|
||||
.any(|item| item == &user_id)
|
||||
{
|
||||
return Err("该兑换码不适用于当前账号".to_string());
|
||||
}
|
||||
if redeem_code.global_used_count >= redeem_code.max_uses {
|
||||
return Err("兑换次数已用完".to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let usage_id = build_profile_redeem_code_usage_id(
|
||||
ctx,
|
||||
&code,
|
||||
&user_id,
|
||||
validated_input.redeemed_at_micros,
|
||||
);
|
||||
let wallet_ledger_id = format!("{}:ledger", usage_id);
|
||||
let wallet_balance = apply_profile_wallet_delta(
|
||||
ctx,
|
||||
&user_id,
|
||||
redeem_code.reward_points,
|
||||
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward,
|
||||
&wallet_ledger_id,
|
||||
redeemed_at,
|
||||
)?;
|
||||
|
||||
ctx.db
|
||||
.profile_redeem_code_usage()
|
||||
.insert(ProfileRedeemCodeUsage {
|
||||
usage_id,
|
||||
code: code.clone(),
|
||||
user_id,
|
||||
amount_granted: redeem_code.reward_points,
|
||||
created_at: redeemed_at,
|
||||
});
|
||||
|
||||
let next_code = ProfileRedeemCode {
|
||||
global_used_count: redeem_code.global_used_count.saturating_add(1),
|
||||
updated_at: redeemed_at,
|
||||
..redeem_code
|
||||
};
|
||||
ctx.db.profile_redeem_code().code().delete(&code);
|
||||
ctx.db.profile_redeem_code().insert(next_code);
|
||||
|
||||
let ledger_entry = ctx
|
||||
.db
|
||||
.profile_wallet_ledger()
|
||||
.wallet_ledger_id()
|
||||
.find(&wallet_ledger_id)
|
||||
.ok_or_else(|| "兑换码钱包流水写入失败".to_string())?;
|
||||
|
||||
Ok(RuntimeProfileRewardCodeRedeemSnapshot {
|
||||
wallet_balance,
|
||||
amount_granted: ledger_entry.amount_delta.max(0) as u64,
|
||||
ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry),
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_upsert_profile_redeem_code_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
) -> Result<RuntimeProfileRedeemCodeSnapshot, String> {
|
||||
let validated_input = build_runtime_profile_redeem_code_admin_upsert_input(
|
||||
input.admin_user_id,
|
||||
input.code,
|
||||
input.mode,
|
||||
input.reward_points,
|
||||
input.max_uses,
|
||||
input.enabled,
|
||||
input.allowed_user_ids,
|
||||
input.allowed_public_user_codes,
|
||||
input.updated_at_micros,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
|
||||
let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?;
|
||||
let existing = ctx
|
||||
.db
|
||||
.profile_redeem_code()
|
||||
.code()
|
||||
.find(&validated_input.code);
|
||||
let created_at = existing
|
||||
.as_ref()
|
||||
.map(|row| row.created_at)
|
||||
.unwrap_or(updated_at);
|
||||
let global_used_count = existing
|
||||
.as_ref()
|
||||
.map(|row| row.global_used_count)
|
||||
.unwrap_or(0);
|
||||
|
||||
if let Some(existing) = existing {
|
||||
ctx.db.profile_redeem_code().code().delete(&existing.code);
|
||||
}
|
||||
|
||||
let row = ProfileRedeemCode {
|
||||
code: validated_input.code,
|
||||
mode: validated_input.mode,
|
||||
reward_points: validated_input.reward_points,
|
||||
max_uses: validated_input.max_uses,
|
||||
global_used_count,
|
||||
enabled: validated_input.enabled,
|
||||
allowed_user_ids,
|
||||
created_by: validated_input.admin_user_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
};
|
||||
let inserted = ctx.db.profile_redeem_code().insert(row);
|
||||
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
|
||||
}
|
||||
|
||||
fn admin_disable_profile_redeem_code_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
) -> Result<RuntimeProfileRedeemCodeSnapshot, String> {
|
||||
let validated_input = build_runtime_profile_redeem_code_admin_disable_input(
|
||||
input.admin_user_id,
|
||||
input.code,
|
||||
input.updated_at_micros,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
|
||||
let existing = ctx
|
||||
.db
|
||||
.profile_redeem_code()
|
||||
.code()
|
||||
.find(&validated_input.code)
|
||||
.ok_or_else(|| "兑换码不存在".to_string())?;
|
||||
|
||||
ctx.db.profile_redeem_code().code().delete(&existing.code);
|
||||
let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode {
|
||||
enabled: false,
|
||||
updated_at,
|
||||
..existing
|
||||
});
|
||||
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
|
||||
}
|
||||
|
||||
fn build_profile_referral_invite_center_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
@@ -1579,6 +2026,74 @@ fn latest_profile_recharge_order(
|
||||
orders.into_iter().next()
|
||||
}
|
||||
|
||||
fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 {
|
||||
ctx.db
|
||||
.profile_redeem_code_usage()
|
||||
.by_profile_redeem_code_usage_code_user_id()
|
||||
.filter((code, user_id))
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
fn build_profile_redeem_code_usage_id(
|
||||
ctx: &ReducerContext,
|
||||
code: &str,
|
||||
user_id: &str,
|
||||
redeemed_at_micros: i64,
|
||||
) -> String {
|
||||
let sequence = count_profile_redeem_code_user_usage(ctx, code, user_id);
|
||||
format!(
|
||||
"redeem:{}:{}:{}:{}",
|
||||
code, user_id, redeemed_at_micros, sequence
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_profile_redeem_code_allowed_user_ids(
|
||||
ctx: &ReducerContext,
|
||||
input: &RuntimeProfileRedeemCodeAdminUpsertInput,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if input.mode != RuntimeProfileRedeemCodeMode::Private {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut allowed_user_ids = input.allowed_user_ids.clone();
|
||||
for public_user_code in &input.allowed_public_user_codes {
|
||||
if let Some(account) = ctx
|
||||
.db
|
||||
.user_account()
|
||||
.by_user_account_public_code()
|
||||
.filter(public_user_code)
|
||||
.next()
|
||||
{
|
||||
allowed_user_ids.push(account.user_id);
|
||||
}
|
||||
}
|
||||
allowed_user_ids.sort();
|
||||
allowed_user_ids.dedup();
|
||||
|
||||
if allowed_user_ids.is_empty() {
|
||||
return Err("私有兑换码必须指定可兑换用户".to_string());
|
||||
}
|
||||
|
||||
Ok(allowed_user_ids)
|
||||
}
|
||||
|
||||
fn build_profile_redeem_code_snapshot_from_row(
|
||||
row: &ProfileRedeemCode,
|
||||
) -> RuntimeProfileRedeemCodeSnapshot {
|
||||
RuntimeProfileRedeemCodeSnapshot {
|
||||
code: row.code.clone(),
|
||||
mode: row.mode,
|
||||
reward_points: row.reward_points,
|
||||
max_uses: row.max_uses,
|
||||
global_used_count: row.global_used_count,
|
||||
enabled: row.enabled,
|
||||
allowed_user_ids: row.allowed_user_ids.clone(),
|
||||
created_by: row.created_by.clone(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_wallet_ledger_snapshot_from_row(
|
||||
row: &ProfileWalletLedger,
|
||||
) -> RuntimeProfileWalletLedgerEntrySnapshot {
|
||||
|
||||
@@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem(
|
||||
id: 'level-motion-ready-count',
|
||||
label: `动作 ${item.levelMotionReadyCount}`,
|
||||
},
|
||||
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
|
||||
...(item.backgroundReady
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -36,6 +36,8 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import {
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||
import {
|
||||
advanceLocalBigFishRuntimeRun,
|
||||
recordBigFishPlay,
|
||||
startLocalBigFishRuntimeRun,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import {
|
||||
@@ -91,6 +94,7 @@ import {
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
@@ -107,6 +111,7 @@ import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetailByCode,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
@@ -154,6 +159,8 @@ type AgentResultBlockerView = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
|
||||
|
||||
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
||||
'publish_missing_world_hook',
|
||||
'publish_missing_player_premise',
|
||||
@@ -442,6 +449,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
title: string;
|
||||
publicWorkCode: string;
|
||||
} | null>(null);
|
||||
const [bigFishRuntimeWork, setBigFishRuntimeWork] =
|
||||
useState<BigFishWorkSummary | null>(null);
|
||||
const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
|
||||
useState<BigFishRuntimeSessionSource>(null);
|
||||
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
||||
const [bigFishGenerationState, setBigFishGenerationState] =
|
||||
useState<MiniGameDraftGenerationState | null>(null);
|
||||
@@ -475,6 +489,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
string | null
|
||||
>(null);
|
||||
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
|
||||
const [profilePlayStats, setProfilePlayStats] =
|
||||
useState<ProfilePlayStatsResponse | null>(null);
|
||||
const [profilePlayStatsError, setProfilePlayStatsError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isProfilePlayStatsLoading, setIsProfilePlayStatsLoading] =
|
||||
useState(false);
|
||||
const [isProfilePlayStatsOpen, setIsProfilePlayStatsOpen] = useState(false);
|
||||
const hadReadableProtectedDataRef = useRef(false);
|
||||
const hasInitialAgentSession = Boolean(
|
||||
readCustomWorldAgentUiState().activeSessionId &&
|
||||
@@ -1041,6 +1063,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishWorks([]);
|
||||
setBigFishRun(null);
|
||||
setBigFishRuntimeShare(null);
|
||||
setBigFishRuntimeWork(null);
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
setBigFishRuntimeSessionSource(null);
|
||||
setBigFishGenerationState(null);
|
||||
setBigFishError(null);
|
||||
setPuzzleOperation(null);
|
||||
@@ -1052,6 +1077,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPuzzleNextLevelGenerating(false);
|
||||
setPuzzleError(null);
|
||||
setDeletingCreationWorkId(null);
|
||||
setProfilePlayStats(null);
|
||||
setProfilePlayStatsError(null);
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
resetRpgSessionViewState();
|
||||
setRpgGeneratedCustomWorldProfile(null);
|
||||
setRpgCustomWorldError(null);
|
||||
@@ -1120,6 +1148,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const leaveBigFishFlow = useCallback(() => {
|
||||
setBigFishRun(null);
|
||||
setBigFishRuntimeWork(null);
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
setBigFishRuntimeSessionSource(null);
|
||||
setBigFishGenerationState(null);
|
||||
bigFishFlow.leaveFlow();
|
||||
}, [bigFishFlow]);
|
||||
@@ -1156,22 +1187,62 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = bigFishSession.sessionId;
|
||||
setBigFishError(null);
|
||||
setBigFishRuntimeShare(null);
|
||||
setBigFishRuntimeWork(null);
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource('draft');
|
||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||
setSelectionStage('big-fish-runtime');
|
||||
}, [bigFishSession, setSelectionStage]);
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
void refreshBigFishShelf();
|
||||
}, [
|
||||
bigFishSession,
|
||||
refreshBigFishShelf,
|
||||
resolveBigFishErrorMessage,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const restartBigFishRun = useCallback(() => {
|
||||
if (!bigFishSession && !bigFishRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBigFishError(null);
|
||||
setBigFishRuntimeShare(null);
|
||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||
if (bigFishSession) {
|
||||
setBigFishRuntimeShare(null);
|
||||
}
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
|
||||
setBigFishRun(
|
||||
startLocalBigFishRuntimeRun({
|
||||
session: bigFishSession,
|
||||
work: bigFishRuntimeWork,
|
||||
}),
|
||||
);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
}, [bigFishRun, bigFishSession, setSelectionStage]);
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
}, [
|
||||
bigFishRun,
|
||||
bigFishRuntimeWork,
|
||||
bigFishSession,
|
||||
resolveBigFishErrorMessage,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const startPuzzleRunFromProfile = useCallback(
|
||||
async (profileId: string) => {
|
||||
@@ -1184,8 +1255,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
const { item } = await getPuzzleGalleryDetail(profileId);
|
||||
const { run } = await startPuzzleRun({ profileId: item.profileId });
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(startLocalPuzzleRun(item));
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
pushAppHistoryPath(
|
||||
@@ -1269,6 +1341,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
[bigFishRun],
|
||||
);
|
||||
|
||||
const reportBigFishObservedPlayTime = useCallback(() => {
|
||||
const sessionId = bigFishRun?.sessionId?.trim();
|
||||
if (!sessionId || !bigFishRuntimeStartedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt);
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
void recordBigFishPlay(sessionId, { elapsedMs }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'),
|
||||
);
|
||||
});
|
||||
}, [
|
||||
bigFishRun?.sessionId,
|
||||
bigFishRuntimeStartedAt,
|
||||
resolveBigFishErrorMessage,
|
||||
]);
|
||||
|
||||
const swapPuzzlePiecesInRun = useCallback(
|
||||
(payload: { firstPieceId: string; secondPieceId: string }) => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
@@ -1712,17 +1803,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
|
||||
setBigFishError(null);
|
||||
bigFishFlow.setSession(null);
|
||||
setBigFishRuntimeWork(item);
|
||||
setBigFishRuntimeShare({
|
||||
title: item.title,
|
||||
publicWorkCode,
|
||||
});
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource('work');
|
||||
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
|
||||
setSelectionStage('big-fish-runtime');
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
|
||||
);
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
},
|
||||
[bigFishFlow, setSelectionStage],
|
||||
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
|
||||
);
|
||||
|
||||
const handlePublicCodeSearch = useCallback(
|
||||
@@ -1871,6 +1970,118 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const openProfilePlayedWorks = useCallback(() => {
|
||||
setIsProfilePlayStatsOpen(true);
|
||||
setIsProfilePlayStatsLoading(true);
|
||||
setProfilePlayStatsError(null);
|
||||
|
||||
void getRpgProfilePlayStats()
|
||||
.then(setProfilePlayStats)
|
||||
.catch((error) => {
|
||||
setProfilePlayStats(null);
|
||||
setProfilePlayStatsError(
|
||||
resolveRpgCreationErrorMessage(error, '读取玩过作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsProfilePlayStatsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openPlayedWork = useCallback(
|
||||
(work: ProfilePlayedWorkSummary) => {
|
||||
const worldType = (work.worldType ?? '').toLowerCase();
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
|
||||
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
|
||||
const profileId =
|
||||
work.profileId ?? work.worldKey.replace(/^puzzle:/u, '');
|
||||
if (profileId) {
|
||||
void openPuzzleDetail(profileId, { tab: 'profile' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'big_fish' ||
|
||||
worldType === 'big-fish' ||
|
||||
work.worldKey.startsWith('big-fish:')
|
||||
) {
|
||||
const sessionId =
|
||||
work.profileId ?? work.worldKey.replace(/^big-fish:/u, '');
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
void refreshBigFishGallery()
|
||||
.then((entries) => {
|
||||
const matchedEntry = entries.find(
|
||||
(entry) => entry.sourceSessionId === sessionId,
|
||||
);
|
||||
if (matchedEntry) {
|
||||
startBigFishRunFromWork(matchedEntry);
|
||||
return;
|
||||
}
|
||||
startBigFishRunFromWork({
|
||||
workId: `big-fish:${sessionId}`,
|
||||
sourceSessionId: sessionId,
|
||||
ownerUserId: work.ownerUserId ?? '',
|
||||
title: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summary: work.worldSubtitle,
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: work.lastPlayedAt,
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const profileId = work.profileId ?? work.worldKey;
|
||||
const ownerUserId = work.ownerUserId;
|
||||
if (!ownerUserId || !profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.openGalleryDetail({
|
||||
ownerUserId,
|
||||
profileId,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: work.firstPlayedAt,
|
||||
updatedAt: work.lastPlayedAt,
|
||||
authorDisplayName: work.worldSubtitle,
|
||||
worldName: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
detailNavigation,
|
||||
openPuzzleDetail,
|
||||
refreshBigFishGallery,
|
||||
resolveBigFishErrorMessage,
|
||||
runProtectedAction,
|
||||
startBigFishRunFromWork,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const publicWorkCode = initialPublicWorkCode?.trim();
|
||||
if (
|
||||
@@ -2149,7 +2360,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
void handlePublicCodeSearch(keyword);
|
||||
}}
|
||||
isSearchingPublicCode={isSearchingPublicCode}
|
||||
onOpenProfileDashboardCard={() => {
|
||||
profilePlayStats={profilePlayStats}
|
||||
isProfilePlayStatsOpen={isProfilePlayStatsOpen}
|
||||
isProfilePlayStatsLoading={isProfilePlayStatsLoading}
|
||||
profilePlayStatsError={profilePlayStatsError}
|
||||
onCloseProfilePlayStats={() => {
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
}}
|
||||
onOpenPlayedWork={openPlayedWork}
|
||||
onOpenProfileDashboardCard={(cardKey) => {
|
||||
if (cardKey === 'playedWorks') {
|
||||
openProfilePlayedWorks();
|
||||
return;
|
||||
}
|
||||
if (platformBootstrap.dashboardError) {
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
}
|
||||
@@ -2402,11 +2625,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBusy={isBigFishBusy}
|
||||
error={bigFishError}
|
||||
onBack={() => {
|
||||
reportBigFishObservedPlayTime();
|
||||
setSelectionStage(
|
||||
bigFishSession ? 'big-fish-result' : 'platform',
|
||||
bigFishRuntimeSessionSource === 'draft'
|
||||
? 'big-fish-result'
|
||||
: 'platform',
|
||||
);
|
||||
}}
|
||||
onRestart={() => {
|
||||
reportBigFishObservedPlayTime();
|
||||
void restartBigFishRun();
|
||||
}}
|
||||
onSubmitInput={submitBigFishInput}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -11,7 +11,29 @@ import {
|
||||
} from './RpgEntryHomeView';
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({
|
||||
mockGetRpgProfileWalletLedger: vi.fn(async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
amountDelta: -1,
|
||||
balanceAfter: 29,
|
||||
sourceType: 'asset_operation_consume',
|
||||
createdAt: '2026-04-28T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ledger-2',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 30,
|
||||
sourceType: 'invite_invitee_reward',
|
||||
createdAt: '2026-04-28T09:00:00Z',
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
|
||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
@@ -269,20 +291,34 @@ afterEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('opens recharge modal and submits points product', async () => {
|
||||
test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
await user.click(screen.getByText('会员充值'));
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(await screen.findByText('叙世币充值')).toBeTruthy();
|
||||
expect(await screen.findByText('60叙世币')).toBeTruthy();
|
||||
expect(await screen.findByText('叙世币账单')).toBeTruthy();
|
||||
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('资产操作消耗')).toBeTruthy();
|
||||
expect(screen.getByText('-1')).toBeTruthy();
|
||||
expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
|
||||
expect(screen.getByText('+30')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('首充送60叙世币'));
|
||||
test('wallet ledger modal shows empty and error states', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
|
||||
|
||||
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByLabelText('关闭叙世币账单'));
|
||||
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
|
||||
expect(await screen.findByText('加载失败')).toBeTruthy();
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Clock3,
|
||||
Coins,
|
||||
Copy,
|
||||
Crown,
|
||||
House,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
@@ -34,19 +33,21 @@ import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileRechargeCenter,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
getRpgProfileWalletLedger,
|
||||
redeemRpgProfileReferralInviteCode,
|
||||
redeemRpgProfileRewardCode,
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -102,6 +103,12 @@ export interface RpgEntryHomeViewProps {
|
||||
onSearchPublicCode?: (keyword: string) => void | Promise<void>;
|
||||
isSearchingPublicCode?: boolean;
|
||||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||||
profilePlayStats?: ProfilePlayStatsResponse | null;
|
||||
isProfilePlayStatsOpen?: boolean;
|
||||
isProfilePlayStatsLoading?: boolean;
|
||||
profilePlayStatsError?: string | null;
|
||||
onCloseProfilePlayStats?: () => void;
|
||||
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
createTabContent?: ReactNode;
|
||||
}
|
||||
@@ -815,6 +822,21 @@ function formatDashboardUpdatedAt(value: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatPlayedWorkType(value: string | null | undefined) {
|
||||
const normalizedValue = (value ?? '').toLowerCase();
|
||||
if (normalizedValue === 'puzzle') {
|
||||
return '拼图';
|
||||
}
|
||||
if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') {
|
||||
return '大鱼';
|
||||
}
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
|
||||
return work.profileId?.trim() || work.worldKey;
|
||||
}
|
||||
|
||||
function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
if (user?.publicUserCode?.trim()) {
|
||||
return user.publicUserCode.trim();
|
||||
@@ -910,206 +932,191 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
|
||||
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
|
||||
points_recharge: '叙世币充值',
|
||||
invite_inviter_reward: '邀请奖励',
|
||||
invite_invitee_reward: '填写邀请码奖励',
|
||||
snapshot_sync: '账户同步',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
asset_operation_refund: '资产操作退回',
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
};
|
||||
|
||||
function formatWalletLedgerAmount(amountDelta: number) {
|
||||
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
||||
}
|
||||
|
||||
function formatMembershipDuration(days: number) {
|
||||
if (days >= 365) {
|
||||
return '365天';
|
||||
}
|
||||
|
||||
return `${days}天`;
|
||||
}
|
||||
|
||||
function AccountRechargeModal({
|
||||
center,
|
||||
activeTab,
|
||||
function WalletLedgerModal({
|
||||
ledger,
|
||||
fallbackBalance,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
error,
|
||||
onTabChange,
|
||||
onClose,
|
||||
onSelectProduct,
|
||||
onRetry,
|
||||
}: {
|
||||
center: ProfileRechargeCenterResponse | null;
|
||||
activeTab: 'points' | 'membership';
|
||||
ledger: ProfileWalletLedgerResponse | null;
|
||||
fallbackBalance: number;
|
||||
isLoading: boolean;
|
||||
isSubmitting: string | null;
|
||||
error: string | null;
|
||||
onTabChange: (tab: 'points' | 'membership') => void;
|
||||
onClose: () => void;
|
||||
onSelectProduct: (product: ProfileRechargeProduct) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const visibleProducts =
|
||||
activeTab === 'points'
|
||||
? (center?.pointProducts ?? [])
|
||||
: (center?.membershipProducts ?? []);
|
||||
const entries = ledger?.entries ?? [];
|
||||
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[32rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_34%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
|
||||
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[30rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
|
||||
aria-label="关闭账户充值"
|
||||
aria-label="关闭叙世币账单"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
||||
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
||||
<div className="pr-10">
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
WALLET
|
||||
LEDGER
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black">账户充值</div>
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<div className="mt-1 text-2xl font-black">叙世币账单</div>
|
||||
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
|
||||
<span>
|
||||
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
|
||||
</span>
|
||||
<span>{balance}叙世币</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('points')}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'points'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
叙世币充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('membership')}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'membership'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
会员卡充值
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-3 text-sm text-rose-700">
|
||||
<div>{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-3 rounded-full bg-[#ff4056] px-4 py-2 text-xs font-black text-white"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-16 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="mt-5 rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm font-semibold text-zinc-500">
|
||||
暂无账单记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{entries.map((entry) => {
|
||||
const isIncome = entry.amountDelta > 0;
|
||||
const label =
|
||||
WALLET_LEDGER_SOURCE_LABELS[entry.sourceType] ??
|
||||
entry.sourceType;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-3 py-3 shadow-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-black text-zinc-900">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-semibold text-zinc-500">
|
||||
{formatPlatformWorldTime(entry.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div
|
||||
className={`text-base font-black ${
|
||||
isIncome ? 'text-emerald-600' : 'text-rose-500'
|
||||
}`}
|
||||
>
|
||||
{formatWalletLedgerAmount(entry.amountDelta)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
|
||||
余额 {entry.balanceAfter}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RewardCodeRedeemModal({
|
||||
value,
|
||||
isSubmitting,
|
||||
error,
|
||||
success,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
value: string;
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
|
||||
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-black">兑换码</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭兑换码"
|
||||
onClick={onClose}
|
||||
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-5">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
|
||||
placeholder="输入兑换码"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !value.trim()}
|
||||
className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? '兑换中' : '兑换'}
|
||||
</button>
|
||||
{error ? (
|
||||
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
|
||||
(_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-24 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{success ? (
|
||||
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{success}
|
||||
</div>
|
||||
) : activeTab === 'points' ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="relative min-h-[8.45rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
|
||||
>
|
||||
<div className="h-8 bg-[#ff4056] px-2 py-1.5 text-xs font-black text-white">
|
||||
{product.badgeLabel}
|
||||
</div>
|
||||
<div className="px-2 py-3">
|
||||
<div className="text-xl font-black">
|
||||
{product.pointsAmount}叙世币
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
金额:{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="my-2 h-px bg-zinc-100" />
|
||||
<div className="text-sm text-zinc-800">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="group relative min-h-[7.75rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-[#ff4056] hover:shadow-md disabled:opacity-70"
|
||||
>
|
||||
<div className="absolute right-0 top-0 h-16 w-16 rounded-bl-[2rem] bg-[#ff4056]/10 transition group-hover:bg-[#ff4056]/16" />
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-black">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold text-zinc-500">
|
||||
{formatMembershipDuration(product.durationDays)}
|
||||
</div>
|
||||
</div>
|
||||
<Crown className="h-5 w-5 shrink-0 text-[#ff4056]" />
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-black text-[#ff4056]">
|
||||
{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-semibold text-zinc-500">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
|
||||
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
|
||||
用户等级特权
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[30rem] grid-cols-5 text-center text-sm">
|
||||
{center?.benefits.map((benefit) => (
|
||||
<div key={benefit.benefitName} className="contents">
|
||||
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
|
||||
{benefit.benefitName}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
|
||||
{benefit.normalValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
|
||||
{benefit.monthValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
|
||||
{benefit.seasonValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
|
||||
{benefit.yearValue}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1264,6 +1271,108 @@ function ProfileReferralModal({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfilePlayedWorksModal({
|
||||
stats,
|
||||
isLoading,
|
||||
error,
|
||||
onClose,
|
||||
onOpenWork,
|
||||
}: {
|
||||
stats: ProfilePlayStatsResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||
}) {
|
||||
const playedWorks = stats?.playedWorks ?? [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[34rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
|
||||
aria-label="关闭玩过作品"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
||||
<div className="pr-10">
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
PLAYED
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black">玩过作品</div>
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />
|
||||
<span>{formatCompactPlayTime(stats?.totalPlayTimeMs ?? 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-20 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : playedWorks.length > 0 ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
{playedWorks.map((work) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${work.worldKey}:${work.lastPlayedAt}`}
|
||||
onClick={() => onOpenWork?.(work)}
|
||||
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-black text-zinc-950">
|
||||
{work.worldTitle}
|
||||
</div>
|
||||
{work.worldSubtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
|
||||
{work.worldSubtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
|
||||
{formatPlayedWorkType(work.worldType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
|
||||
<span className="truncate">
|
||||
作品号 {formatPlayedWorkId(work)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
最近 {formatSnapshotTime(work.lastPlayedAt)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
时长 {formatCompactPlayTime(work.lastObservedPlayTimeMs)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
|
||||
暂无玩过作品
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RpgEntryHomeView({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
@@ -1288,22 +1397,32 @@ export function RpgEntryHomeView({
|
||||
onSearchPublicCode,
|
||||
isSearchingPublicCode = false,
|
||||
onOpenProfileDashboardCard,
|
||||
profilePlayStats = null,
|
||||
isProfilePlayStatsOpen = false,
|
||||
isProfilePlayStatsLoading = false,
|
||||
profilePlayStatsError = null,
|
||||
onCloseProfilePlayStats,
|
||||
onOpenPlayedWork,
|
||||
onRechargeSuccess,
|
||||
createTabContent,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
||||
'points',
|
||||
const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
|
||||
const [rewardCodeInput, setRewardCodeInput] = useState('');
|
||||
const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
|
||||
const [rewardCodeError, setRewardCodeError] = useState<string | null>(null);
|
||||
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [rechargeCenter, setRechargeCenter] =
|
||||
useState<ProfileRechargeCenterResponse | null>(null);
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
|
||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||
useState<string | null>(null);
|
||||
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
|
||||
const [walletLedger, setWalletLedger] =
|
||||
useState<ProfileWalletLedgerResponse | null>(null);
|
||||
const [walletLedgerError, setWalletLedgerError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
|
||||
const [profilePopupPanel, setProfilePopupPanel] =
|
||||
useState<ProfilePopupPanel | null>(null);
|
||||
const [referralCenter, setReferralCenter] =
|
||||
@@ -1401,35 +1520,22 @@ export function RpgEntryHomeView({
|
||||
}
|
||||
authUi?.openLoginModal();
|
||||
};
|
||||
const openRechargePanel = () => {
|
||||
setIsRechargeOpen(true);
|
||||
setRechargeError(null);
|
||||
setIsLoadingRecharge(true);
|
||||
void getRpgProfileRechargeCenter()
|
||||
.then(setRechargeCenter)
|
||||
const loadWalletLedger = () => {
|
||||
setWalletLedgerError(null);
|
||||
setIsLoadingWalletLedger(true);
|
||||
void getRpgProfileWalletLedger()
|
||||
.then(setWalletLedger)
|
||||
.catch((error: unknown) => {
|
||||
setRechargeCenter(null);
|
||||
setRechargeError(
|
||||
error instanceof Error ? error.message : '读取账户充值失败',
|
||||
setWalletLedger(null);
|
||||
setWalletLedgerError(
|
||||
error instanceof Error ? error.message : '读取叙世币账单失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingRecharge(false));
|
||||
.finally(() => setIsLoadingWalletLedger(false));
|
||||
};
|
||||
const submitRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
}
|
||||
setSubmittingRechargeProductId(product.productId);
|
||||
setRechargeError(null);
|
||||
void createRpgProfileRechargeOrder(product.productId)
|
||||
.then((response) => {
|
||||
setRechargeCenter(response.center);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||
})
|
||||
.finally(() => setSubmittingRechargeProductId(null));
|
||||
const openWalletLedgerPanel = () => {
|
||||
setIsWalletLedgerOpen(true);
|
||||
loadWalletLedger();
|
||||
};
|
||||
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
|
||||
setProfilePopupPanel(panel);
|
||||
@@ -1486,6 +1592,30 @@ export function RpgEntryHomeView({
|
||||
})
|
||||
.finally(() => setIsSubmittingReferral(false));
|
||||
};
|
||||
const openRewardCodeModal = () => {
|
||||
setIsRewardCodeOpen(true);
|
||||
setRewardCodeError(null);
|
||||
setRewardCodeSuccess(null);
|
||||
};
|
||||
const submitRewardCode = () => {
|
||||
if (isSubmittingRewardCode || !rewardCodeInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingRewardCode(true);
|
||||
setRewardCodeError(null);
|
||||
setRewardCodeSuccess(null);
|
||||
void redeemRpgProfileRewardCode(rewardCodeInput)
|
||||
.then((response: RedeemProfileRewardCodeResponse) => {
|
||||
setRewardCodeInput('');
|
||||
setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRewardCodeError(error instanceof Error ? error.message : '兑换失败');
|
||||
})
|
||||
.finally(() => setIsSubmittingRewardCode(false));
|
||||
};
|
||||
const submitDesktopSearch = () => {
|
||||
const keyword = desktopSearchKeyword.trim();
|
||||
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
||||
@@ -1833,17 +1963,13 @@ export function RpgEntryHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRechargePanel}
|
||||
onClick={openRewardCodeModal}
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
<Ticket className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">会员充值</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{rechargeCenter?.membership.status === 'active'
|
||||
? '叙世会员'
|
||||
: '普通用户'}
|
||||
</div>
|
||||
<div className="text-xs font-bold">兑换码</div>
|
||||
<div className="text-[10px] opacity-80">叙世币</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -1865,7 +1991,7 @@ export function RpgEntryHomeView({
|
||||
label="剩余叙世币"
|
||||
value="暂不可用"
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
onClick={openWalletLedgerPanel}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
@@ -1889,7 +2015,7 @@ export function RpgEntryHomeView({
|
||||
label="剩余叙世币"
|
||||
value={formatDashboardCount(remainingNarrativeCoins)}
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
onClick={openWalletLedgerPanel}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
@@ -2291,18 +2417,6 @@ export function RpgEntryHomeView({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
/>
|
||||
) : null}
|
||||
{profilePopupPanel ? (
|
||||
<ProfileReferralModal
|
||||
panel={profilePopupPanel}
|
||||
@@ -2318,6 +2432,25 @@ export function RpgEntryHomeView({
|
||||
onSubmitRedeem={submitReferralInviteCode}
|
||||
/>
|
||||
) : null}
|
||||
{isProfilePlayStatsOpen ? (
|
||||
<ProfilePlayedWorksModal
|
||||
stats={profilePlayStats}
|
||||
isLoading={isProfilePlayStatsLoading}
|
||||
error={profilePlayStatsError}
|
||||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||||
onOpenWork={onOpenPlayedWork}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
<WalletLedgerModal
|
||||
ledger={walletLedger}
|
||||
fallbackBalance={remainingNarrativeCoins}
|
||||
isLoading={isLoadingWalletLedger}
|
||||
error={walletLedgerError}
|
||||
onClose={() => setIsWalletLedgerOpen(false)}
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2395,16 +2528,15 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
{isRewardCodeOpen ? (
|
||||
<RewardCodeRedeemModal
|
||||
value={rewardCodeInput}
|
||||
isSubmitting={isSubmittingRewardCode}
|
||||
error={rewardCodeError}
|
||||
success={rewardCodeSuccess}
|
||||
onChange={setRewardCodeInput}
|
||||
onSubmit={submitRewardCode}
|
||||
onClose={() => setIsRewardCodeOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{profilePopupPanel ? (
|
||||
@@ -2422,6 +2554,25 @@ export function RpgEntryHomeView({
|
||||
onSubmitRedeem={submitReferralInviteCode}
|
||||
/>
|
||||
) : null}
|
||||
{isProfilePlayStatsOpen ? (
|
||||
<ProfilePlayedWorksModal
|
||||
stats={profilePlayStats}
|
||||
isLoading={isProfilePlayStatsLoading}
|
||||
error={profilePlayStatsError}
|
||||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||||
onOpenWork={onOpenPlayedWork}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
<WalletLedgerModal
|
||||
ledger={walletLedger}
|
||||
fallbackBalance={remainingNarrativeCoins}
|
||||
isLoading={isLoadingWalletLedger}
|
||||
error={walletLedgerError}
|
||||
onClose={() => setIsWalletLedgerOpen(false)}
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal file
33
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
BigFishSessionResponse,
|
||||
RecordBigFishPlayRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。
|
||||
*/
|
||||
export function recordBigFishPlay(
|
||||
sessionId: string,
|
||||
payload: RecordBigFishPlayRequest,
|
||||
) {
|
||||
return requestJson<BigFishSessionResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'记录大鱼吃小鱼游玩失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export {
|
||||
advanceLocalBigFishRuntimeRun,
|
||||
startLocalBigFishRuntimeRun,
|
||||
} from './bigFishLocalRuntime';
|
||||
export { recordBigFishPlay } from './bigFishRuntimeClient';
|
||||
|
||||
@@ -46,7 +46,24 @@ export async function deleteBigFishWork(sessionId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录已发布大鱼吃小鱼作品的一次正式进入。
|
||||
*/
|
||||
export async function recordBigFishWorkPlay(sessionId: string) {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
`${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}/play`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'记录大鱼吃小鱼游玩次数失败',
|
||||
{
|
||||
retry: BIG_FISH_WORKS_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishWorksClient = {
|
||||
delete: deleteBigFishWork,
|
||||
list: listBigFishWorks,
|
||||
recordPlay: recordBigFishWorkPlay,
|
||||
};
|
||||
|
||||
@@ -2,4 +2,5 @@ export {
|
||||
bigFishWorksClient,
|
||||
deleteBigFishWork,
|
||||
listBigFishWorks,
|
||||
recordBigFishWorkPlay,
|
||||
} from './bigFishWorksClient';
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
@@ -125,6 +126,22 @@ export function redeemRpgProfileReferralInviteCode(
|
||||
);
|
||||
}
|
||||
|
||||
export function redeemRpgProfileRewardCode(
|
||||
code: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<RedeemProfileRewardCodeResponse>(
|
||||
'/profile/redeem-codes/redeem',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
},
|
||||
'兑换失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
|
||||
Reference in New Issue
Block a user