1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-28 20:52:08 +08:00
82 changed files with 3844 additions and 4125 deletions

View File

@@ -48,6 +48,8 @@ AUTH_REFRESH_COOKIE_SAME_SITE="Lax"
AUTH_REFRESH_COOKIE_SECURE="false" AUTH_REFRESH_COOKIE_SECURE="false"
# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash只能放服务端私有目录。 # Rust 鉴权快照路径;包含 password_hash 与 refresh token hash只能放服务端私有目录。
GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json" GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json"
# 开发期便捷开关true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false"
# 手机号验证码登录配置(阿里云 PNVS # 手机号验证码登录配置(阿里云 PNVS
# 正式环境请改成你自己的 AccessKey 和短信签名/模板。 # 正式环境请改成你自己的 AccessKey 和短信签名/模板。

View File

@@ -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)
后续如继续细化任务,请优先在该目录内维护,避免根目录散落多份版本。

View File

@@ -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 的前提下可跑通
- [ ] 能完成灰度切流,并保留可回退能力

View File

@@ -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)
## M1Rust 工作区与 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 回归

View File

@@ -1,69 +0,0 @@
# M3runtime 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)

View File

@@ -1,318 +0,0 @@
# M4story 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 再做远端灰度与回退验证

View File

@@ -1,117 +0,0 @@
# M5custom 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 compilerStage 9 按冻结口径落最小 preview compiler不再搬 Node 全量 compiler
- [x] 迁移 published profile compileStage 3 已落地)
- [x] 迁移 works 聚合读模型Stage 9 Rust procedure + Axum facade 已接通)
- [x] 迁移 library 存储与删除Stage 2 设计已冻结,待继续接 Axum 兼容)
- [x] 迁移 publish / unpublishStage 2 设计已冻结,待继续接 Agent publish gate
- [x] 迁移 publish_world 串联主链Stage 4 设计已冻结,待继续接 Axum action / publish gate
- [x] 迁移 publish gate / enter-world gatesession snapshot / works / action 共用 gate 已接通)
- [x] 迁移 gallery 列表与详情Stage 2 设计已冻结,待继续接 Axum 兼容)
## 3. RPG 创作 Agent 主链
- [x] 迁移 session createStage 6 首批 Agent session skeleton
- [x] 迁移 session snapshotStage 6 首批 Agent session skeleton
- [x] 迁移 message submitStage 7 deterministic message / operation 最小闭环)
- [x] 迁移 message streamStage 8 SSE facade 已落地)
- [x] 迁移 operation queryStage 7 deterministic message / operation 最小闭环)
- [x] 迁移 card detailStage 9 Rust procedure + Axum facade 已接通)
- [x] 迁移 card update统一走 `/actions``update_draft_card`
- [x] 迁移 action registry / supportedActionssession 真相态 `supportedActions` 已接通)
- [x] 迁移 draft foundation统一走 `/actions``draft_foundation`
- [x] 迁移 result preview 生成session 最小 `resultPreview` 已接通)
- [x] 迁移 entity generationAxum 兼容 `/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 ...` 编码检查通过

View File

@@ -1,153 +0,0 @@
# M6assets / 阿里云 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 2custom world `scene-image` 走真实 DashScope 图片生成,并继续写入 `OSS + asset_object + asset_entity_binding`
- [x] 迁移封面图上传(已完成 Stage 2custom 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] 所有新生成资产都写入 OSSStage 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 强结构化,再进入独立阶段。

View File

@@ -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 与 tracingM7 固化字段口径)
- [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 的完整运行环境,不在无外部服务的本地预检中虚假勾选。

View File

@@ -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 的前提下可跑通
- [ ] 能完成灰度切流,并保留可回退能力

View File

@@ -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/SSESpacetimeDB 负责状态真相。 |
| `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 分层

View File

@@ -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`,拼成 `fallbackCODE`
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 中间件可以直接按本文对齐,而不再靠人工试错。

View File

@@ -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 的路径回归测试

View File

@@ -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

View File

@@ -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 表与 reducerstory 兼容接口与 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. 结果记录:
- 编码检查结果
- 关键命令执行结果
- 联调、回归、灰度演练结果
如果阶段只完成了编码、但没有文档和证据留存,则该阶段不能视为完成。

View File

@@ -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/` 遗留链路依赖核对完成后单独立项。

View File

@@ -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 contractAxum 流式产出,状态写 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 contractNPC 对话状态迁到 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 时,可以直接按这份矩阵逐项落位。

View File

@@ -1,300 +0,0 @@
# M0SSE 接口与事件格式冻结基线
日期:`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 上游透传型 SSE3 条)
包含:
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 项目自定义 SSE3 条)
包含:
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 实时链路设计时,可以直接引用本文,不再靠人工回忆事件名。

View File

@@ -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. 如阶段任务发生明显变化,需要同步更新总纲与对应拆分文件。

View File

@@ -1,15 +1,15 @@
# 资产生成叙世币消耗接入方案 # 资产操作叙世币消耗接入方案
## 背景 ## 背景
当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成由 Axum API 调用外部模型写入 OSSSpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此费需要拆成两层: 当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成和作品发布由 Axum API 调用外部模型写入业务状态SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此费需要拆成两层:
- SpacetimeDB 负责钱包余额和流水的原子变更。 - SpacetimeDB 负责钱包余额和流水的原子变更。
- Axum 负责在发起外部生成前扣费,并在生成持久化失败时补偿退款。 - Axum 资产操作服务负责在执行业务资产操作前扣费,并在生成持久化或发布失败时补偿退款。
## 首期范围 ## 首期范围
首期接入带 Bearer 身份、能明确归属真实用户的资产生成与发布入口: 首期接入带 Bearer 身份、能明确归属真实用户的资产操作入口:
- `POST /api/custom-world/scene-image` - `POST /api/custom-world/scene-image`
- `POST /api/custom-world/cover-image` - `POST /api/custom-world/cover-image`
@@ -26,28 +26,27 @@
- 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。 - 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。
- 手动上传封面:不调用外部生成模型,不消耗叙世币。 - 手动上传封面:不调用外部生成模型,不消耗叙世币。
- 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。 - 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。
- 文本实体、NPC 生成:本次需求聚焦资产生成,首期只覆盖图片资产。 - 文本实体、NPC 生成:本次需求聚焦图片资产和发布资产操作,首期只覆盖可明确归属的入口
## 计费规则 ## 计费规则
- 每次图片资产生成请求消耗 `1` 枚叙世币。 - 每次可计费资产操作消耗 `1` 枚叙世币。
- 每次作品发布请求消耗 `1` 枚叙世币;余额不足时禁止发布 - 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行
- 在调用外部图片生成前预扣,余额不足时直接返回业务错误,不调用外部模型 - 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作
- 发布请求在写入发布状态前预扣,余额不足时直接返回业务错误,不调用发布 mutation - 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款
- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败Axum 自动发起同额退款。
- 如果退款失败,原始错误仍返回给调用方,同时服务端日志记录退款失败,便于后续人工核对。 - 如果退款失败,原始错误仍返回给调用方,同时服务端日志记录退款失败,便于后续人工核对。
## 钱包流水 ## 钱包流水
新增两个流水来源类型,首期同时覆盖“资产生成”和“资产发布”这两类资产操作: 公开两个流水来源类型,统一覆盖“资产生成”和“资产发布”这两类资产操作:
- `asset_generation_consume`:资产生成预扣,`amount_delta = -1` - `asset_operation_consume`:资产操作预扣,`amount_delta = -1`
- `asset_generation_refund`:资产生成失败退款,`amount_delta = +1` - `asset_operation_refund`:资产操作失败退款,`amount_delta = +1`
`wallet_ledger_id` 由 Axum 传入,格式: `wallet_ledger_id` 由 Axum 传入,格式:
- 扣费:`asset_generation_consume:{user_id}:{asset_kind}:{asset_id}` - 扣费:`asset_operation_consume:{user_id}:{asset_kind}:{asset_id}`
- 退款:`asset_generation_refund:{user_id}:{asset_kind}:{asset_id}` - 退款:`asset_operation_refund:{user_id}:{asset_kind}:{asset_id}`
SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID 已存在,则直接返回当前钱包快照,不重复变更余额。 SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID 已存在,则直接返回当前钱包快照,不重复变更余额。
@@ -56,9 +55,10 @@ SpacetimeDB procedure 对 `ledger_id` 做幂等保护:如果同一个流水 ID
- `module-runtime`:新增钱包调整输入、钱包调整结果、流水来源枚举。 - `module-runtime`:新增钱包调整输入、钱包调整结果、流水来源枚举。
- `spacetime-module`:新增 `consume_profile_wallet_points_and_return``refund_profile_wallet_points_and_return` procedure并扩展钱包变更 helper 支持负数。 - `spacetime-module`:新增 `consume_profile_wallet_points_and_return``refund_profile_wallet_points_and_return` procedure并扩展钱包变更 helper 支持负数。
- `spacetime-client`:新增对应调用方法和绑定类型。 - `spacetime-client`:新增对应调用方法和绑定类型。
- `api-server`在自定义世界图片生成与发布入口前扣费,错误分支退款。 - `api-server`资产操作服务提供统一可计费执行入口自定义世界、Big Fish、Puzzle 业务 handler 只声明资产操作,不直接调用钱包扣费或退款。
- `shared-contracts`:新增 API 流水来源常量,保证“我的-钱包流水”输出使用稳定契约字符串。 - `shared-contracts`:新增 API 流水来源常量,保证“我的-钱包流水”输出使用稳定契约字符串。
- `packages/shared` 与前端:统一使用 `asset_operation_consume` / `asset_operation_refund` 展示钱包流水。
## 非目标 ## 非目标
本次不做分档价格、不做会员免扣、不做前端计费展示改造,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。 本次不做分档价格、不做会员免扣,也不迁移旧 `server-node` 逻辑。旧资产工坊角色主形象/动作生成与发布接口仍需要先补齐 Bearer 身份归属后再纳入扣费范围。旧资产生成流水 source 不再作为公开契约兼容。

View File

@@ -16,6 +16,16 @@
6. 启动测试运行态 6. 启动测试运行态
7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决 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. 本轮明确不做 ## 2. 本轮明确不做
1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。 1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。

View File

@@ -17,6 +17,19 @@
4. 入口必须在移动端单手可点,不遮挡舞台主体。 4. 入口必须在移动端单手可点,不遮挡舞台主体。
5. 规则内容只做说明,不参与任何前端裁决;真实规则仍以后端运行快照为准。 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` 1. `src/components/big-fish-runtime/BigFishRuntimeShell.tsx`

View File

@@ -169,6 +169,24 @@ Node 侧入口位于:
## 4. 本轮边界决议 ## 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 读链 ### 4.1 先做 projection 读链
本轮 profile 三接口只做: 本轮 profile 三接口只做:
@@ -377,6 +395,17 @@ Node 侧入口位于:
这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。 这些都等 `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. 测试策略
### 11.1 必跑 ### 11.1 必跑

View File

@@ -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-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` 日期:`2026-04-21`
@@ -166,6 +168,13 @@
2. 不创建账号。 2. 不创建账号。
3. 不写 `password_hash` 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 未设置密码 ### 8.2 未设置密码
当账号存在但 `password_login_enabled = false` 时: 当账号存在但 `password_login_enabled = false` 时:
@@ -233,6 +242,8 @@
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400` 4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`
5. 登录成功时返回 access token。 5. 登录成功时返回 access token。
6. 登录成功时写回 refresh cookie。 6. 登录成功时写回 refresh cookie。
7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。
8. 开关开启时,未知手机号可通过 `/api/auth/entry` 创建账号并登录;同手机号后续用相同密码登录复用同一用户,错误密码仍返回 `401`
## 13. 完成定义 ## 13. 完成定义

View 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`

View File

@@ -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` 1. `entry_id`

View File

@@ -23,7 +23,7 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
| 领域 | 表 | | 领域 | 表 |
| --- | --- | | --- | --- |
| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | | 认证 | `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` | | 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` | | 世界创作 | `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` | | 拼图 | `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; 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` ### `profile_played_world`
- 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。 - 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。

View File

@@ -25,6 +25,10 @@ export type ExecuteBigFishActionRequest = {
motionKey?: 'idle_float' | 'move_swim' | string; motionKey?: 'idle_float' | 'move_swim' | string;
}; };
export type RecordBigFishPlayRequest = {
elapsedMs?: number;
};
export type SubmitBigFishInputRequest = { export type SubmitBigFishInputRequest = {
x: number; x: number;
y: number; y: number;

View File

@@ -15,6 +15,7 @@ export interface BigFishWorkSummary {
levelMainImageReadyCount: number; levelMainImageReadyCount: number;
levelMotionReadyCount: number; levelMotionReadyCount: number;
backgroundReady: boolean; backgroundReady: boolean;
playCount?: number;
} }
export interface BigFishWorksResponse { export interface BigFishWorksResponse {

View File

@@ -62,8 +62,9 @@ export type ProfileWalletLedgerEntry = {
| 'invite_inviter_reward' | 'invite_inviter_reward'
| 'invite_invitee_reward' | 'invite_invitee_reward'
| 'points_recharge' | 'points_recharge'
| 'asset_generation_consume' | 'asset_operation_consume'
| 'asset_generation_refund'; | 'asset_operation_refund'
| 'redeem_code_reward';
createdAt: string; createdAt: string;
}; };
@@ -165,6 +166,16 @@ export type RedeemProfileReferralInviteCodeResponse = {
inviterBalanceAfter: number; inviterBalanceAfter: number;
}; };
export type RedeemProfileRewardCodeRequest = {
code: string;
};
export type RedeemProfileRewardCodeResponse = {
walletBalance: number;
amountGranted: number;
ledgerEntry: ProfileWalletLedgerEntry;
};
export type ProfilePlayedWorkSummary = { export type ProfilePlayedWorkSummary = {
worldKey: string; worldKey: string;
ownerUserId: string | null; ownerUserId: string | null;

View File

@@ -34,8 +34,8 @@ use crate::{
auth_sessions::auth_sessions, auth_sessions::auth_sessions,
big_fish::{ big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, 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, get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play,
submit_big_fish_message, stream_big_fish_message, submit_big_fish_message,
}, },
character_animation_assets::{ character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache, generate_character_animation, get_character_animation_job, get_character_workflow_cache,
@@ -101,9 +101,10 @@ use crate::{
}, },
runtime_inventory::get_runtime_inventory_state, runtime_inventory::get_runtime_inventory_state,
runtime_profile::{ runtime_profile::{
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats, create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger, 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::{ runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -151,6 +152,20 @@ pub fn build_router(state: AppState) -> Router {
require_admin_auth, 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( .route(
"/healthz", "/healthz",
get(|Extension(request_context): Extension<_>| async move { get(|Extension(request_context): Extension<_>| async move {
@@ -626,6 +641,20 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, 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( .route(
"/api/runtime/puzzle/agent/sessions", "/api/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state( 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, 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( .route(
"/api/runtime/profile/play-stats", "/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
@@ -1419,6 +1462,36 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 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] #[tokio::test]
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() { async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
let state = AppState::new(AppConfig::default()).expect("state should build"); let state = AppState::new(AppConfig::default()).expect("state should build");

View File

@@ -1,3 +1,5 @@
use std::future::Future;
use axum::http::StatusCode; use axum::http::StatusCode;
use serde_json::json; use serde_json::json;
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
@@ -6,15 +8,36 @@ use crate::{http_error::AppError, state::AppState};
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1; 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 参与构造,保证重试幂等。 /// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
pub(crate) async fn consume_asset_operation_points( async fn consume_asset_operation_points(
state: &AppState, state: &AppState,
owner_user_id: &str, owner_user_id: &str,
asset_kind: &str, asset_kind: &str,
asset_id: &str, asset_id: &str,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let ledger_id = format!( let ledger_id = format!(
"asset_generation_consume:{}:{}:{}", "asset_operation_consume:{}:{}:{}",
owner_user_id, asset_kind, asset_id owner_user_id, asset_kind, asset_id
); );
state state
@@ -31,14 +54,14 @@ pub(crate) async fn consume_asset_operation_points(
} }
/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。 /// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。
pub(crate) async fn refund_asset_operation_points( async fn refund_asset_operation_points(
state: &AppState, state: &AppState,
owner_user_id: &str, owner_user_id: &str,
asset_kind: &str, asset_kind: &str,
asset_id: &str, asset_id: &str,
) { ) {
let ledger_id = format!( let ledger_id = format!(
"asset_generation_refund:{}:{}:{}", "asset_operation_refund:{}:{}:{}",
owner_user_id, asset_kind, asset_id owner_user_id, asset_kind, asset_id
); );
if let Err(error) = state if let Err(error) = state

View File

@@ -24,7 +24,8 @@ use shared_contracts::big_fish::{
BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse, BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse,
BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse, BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse,
BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse, BigFishRuntimeParamsResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse,
CreateBigFishSessionRequest, ExecuteBigFishActionRequest, SendBigFishMessageRequest, CreateBigFishSessionRequest, ExecuteBigFishActionRequest, RecordBigFishPlayRequest,
SendBigFishMessageRequest,
}; };
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse}; use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
@@ -32,9 +33,9 @@ use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord,
SpacetimeClientError, BigFishWorkSummaryRecord, SpacetimeClientError,
}; };
use tokio::time::sleep; use tokio::time::sleep;
@@ -53,7 +54,7 @@ use crate::{
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
}, },
api_response::json_success_body, api_response::json_success_body,
asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken, auth::AuthenticatedAccessToken,
character_visual_assets::try_apply_background_alpha_to_png, character_visual_assets::try_apply_background_alpha_to_png,
http_error::AppError, 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( pub async fn submit_big_fish_message(
State(state): State<AppState>, State(state): State<AppState>,
Path(session_id): Path<String>, Path(session_id): Path<String>,
@@ -498,16 +541,12 @@ pub async fn execute_big_fish_action(
_ => None, _ => None,
}; };
let billing_asset_id = format!("{session_id}:{now}"); let billing_asset_id = format!("{session_id}:{now}");
if let Some(asset_kind) = billed_asset_kind { let session_operation = async {
consume_asset_operation_points(&state, &owner_user_id, asset_kind, &billing_asset_id) match action.as_str() {
.await
.map_err(|error| big_fish_error_response(&request_context, error))?;
}
let session_result = match action.as_str() {
"big_fish_compile_draft" => { "big_fish_compile_draft" => {
compile_big_fish_draft_only(&state, session_id.clone(), owner_user_id.clone(), now) compile_big_fish_draft_only(&state, session_id.clone(), owner_user_id.clone(), now)
.await .await
.map_err(map_big_fish_client_error)
} }
"big_fish_generate_level_main_image" => { "big_fish_generate_level_main_image" => {
let asset_url = generate_big_fish_formal_asset( let asset_url = generate_big_fish_formal_asset(
@@ -519,26 +558,7 @@ pub async fn execute_big_fish_action(
None, None,
now, now,
) )
.await .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 state
.spacetime_client() .spacetime_client()
.generate_big_fish_asset(BigFishAssetGenerateRecordInput { .generate_big_fish_asset(BigFishAssetGenerateRecordInput {
@@ -551,6 +571,7 @@ pub async fn execute_big_fish_action(
generated_at_micros: now, generated_at_micros: now,
}) })
.await .await
.map_err(map_big_fish_client_error)
} }
"big_fish_generate_level_motion" => { "big_fish_generate_level_motion" => {
let asset_url = generate_big_fish_formal_asset( let asset_url = generate_big_fish_formal_asset(
@@ -562,26 +583,7 @@ pub async fn execute_big_fish_action(
payload.motion_key.as_deref(), payload.motion_key.as_deref(),
now, now,
) )
.await .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 state
.spacetime_client() .spacetime_client()
.generate_big_fish_asset(BigFishAssetGenerateRecordInput { .generate_big_fish_asset(BigFishAssetGenerateRecordInput {
@@ -594,6 +596,7 @@ pub async fn execute_big_fish_action(
generated_at_micros: now, generated_at_micros: now,
}) })
.await .await
.map_err(map_big_fish_client_error)
} }
"big_fish_generate_stage_background" => { "big_fish_generate_stage_background" => {
let asset_url = generate_big_fish_formal_asset( let asset_url = generate_big_fish_formal_asset(
@@ -605,26 +608,7 @@ pub async fn execute_big_fish_action(
None, None,
now, now,
) )
.await .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 state
.spacetime_client() .spacetime_client()
.generate_big_fish_asset(BigFishAssetGenerateRecordInput { .generate_big_fish_asset(BigFishAssetGenerateRecordInput {
@@ -637,38 +621,35 @@ pub async fn execute_big_fish_action(
generated_at_micros: now, generated_at_micros: now,
}) })
.await .await
.map_err(map_big_fish_client_error)
} }
"big_fish_publish_game" => { "big_fish_publish_game" => state
state
.spacetime_client() .spacetime_client()
.publish_big_fish_game(session_id, owner_user_id.clone(), now) .publish_big_fish_game(session_id, owner_user_id.clone(), now)
.await .await
} .map_err(map_big_fish_client_error),
other => { other => Err(
return Err(big_fish_bad_request( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
&request_context, "provider": "big-fish",
format!("action `{other}` is not supported").as_str(), "message": format!("action `{other}` is not supported"),
)); })),
),
} }
}; };
let session = match session_result { let session_result = if let Some(asset_kind) = billed_asset_kind {
Ok(session) => session, execute_billable_asset_operation(
Err(error) => {
if let Some(asset_kind) = billed_asset_kind {
refund_asset_operation_points(
&state, &state,
&owner_user_id, &owner_user_id,
asset_kind, asset_kind,
&billing_asset_id, &billing_asset_id,
session_operation,
) )
.await; .await
} } else {
return Err(big_fish_error_response( session_operation.await
&request_context,
map_big_fish_client_error(error),
));
}
}; };
let session =
session_result.map_err(|error| big_fish_error_response(&request_context, error))?;
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), 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_main_image_ready_count: item.level_main_image_ready_count,
level_motion_ready_count: item.level_motion_ready_count, level_motion_ready_count: item.level_motion_ready_count,
background_ready: item.background_ready, background_ready: item.background_ready,
play_count: item.play_count,
} }
} }

View File

@@ -29,6 +29,7 @@ pub struct AppConfig {
pub refresh_cookie_same_site: String, pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32, pub refresh_session_ttl_days: u32,
pub auth_store_path: PathBuf, pub auth_store_path: PathBuf,
pub dev_password_entry_auto_register_enabled: bool,
pub sms_auth_enabled: bool, pub sms_auth_enabled: bool,
pub sms_auth_provider: String, pub sms_auth_provider: String,
pub sms_endpoint: String, pub sms_endpoint: String,
@@ -118,6 +119,7 @@ impl Default for AppConfig {
refresh_cookie_same_site: "Lax".to_string(), refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30, refresh_session_ttl_days: 30,
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH), auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false, sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(), sms_auth_provider: "mock".to_string(),
sms_endpoint: "dypnsapi.aliyuncs.com".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"]) { if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
config.auth_store_path = PathBuf::from(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"]) { if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
config.sms_auth_enabled = sms_auth_enabled; config.sms_auth_enabled = sms_auth_enabled;

View File

@@ -53,7 +53,7 @@ use crate::{
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
}, },
api_response::json_success_body, api_response::json_success_body,
asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken, auth::AuthenticatedAccessToken,
character_visual_assets::generate_character_primary_visual_for_profile, character_visual_assets::generate_character_primary_visual_for_profile,
custom_world_agent_entities::generate_custom_world_agent_entities, 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) let author_public_user_code =
.await resolve_author_public_user_code(&state, &authenticated, &request_context)?;
.map_err(|error| custom_world_error_response(&request_context, error))?; let author_display_name = resolve_author_display_name(&state, &authenticated);
let mutation = execute_billable_asset_operation(
let mutation_result = state &state,
&owner_user_id,
"custom_world_publish",
&profile_id,
async {
state
.spacetime_client() .spacetime_client()
.publish_custom_world_profile( .publish_custom_world_profile(
profile_id.clone(), profile_id.clone(),
owner_user_id.clone(), owner_user_id.clone(),
None, None,
resolve_author_public_user_code(&state, &authenticated, &request_context)?, author_public_user_code,
resolve_author_display_name(&state, &authenticated), author_display_name,
current_utc_micros(), current_utc_micros(),
) )
.await; .await
let mutation = match mutation_result { .map_err(map_custom_world_client_error)
Ok(mutation) => mutation, },
Err(error) => {
refund_asset_operation_points(
&state,
&owner_user_id,
"custom_world_publish",
&profile_id,
) )
.await; .await
return Err(custom_world_error_response( .map_err(|error| custom_world_error_response(&request_context, error))?;
&request_context,
map_custom_world_client_error(error),
));
}
};
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),
@@ -1525,18 +1519,8 @@ pub async fn execute_custom_world_agent_action(
}; };
let should_bill_publish = action == "publish_world"; let should_bill_publish = action == "publish_world";
if should_bill_publish { let operation_future = async {
consume_asset_operation_points( state
&state,
&owner_user_id,
"custom_world_agent_publish",
&session_id,
)
.await
.map_err(|error| custom_world_error_response(&request_context, error))?;
}
let result = match state
.spacetime_client() .spacetime_client()
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id: session_id.clone(), session_id: session_id.clone(),
@@ -1547,24 +1531,21 @@ pub async fn execute_custom_world_agent_action(
submitted_at_micros, submitted_at_micros,
}) })
.await .await
{ .map_err(map_custom_world_client_error)
Ok(result) => result, };
Err(error) => { let result = if should_bill_publish {
if should_bill_publish { execute_billable_asset_operation(
refund_asset_operation_points(
&state, &state,
&owner_user_id, &owner_user_id,
"custom_world_agent_publish", "custom_world_agent_publish",
&session_id, &session_id,
operation_future,
) )
.await; .await
} } else {
return Err(custom_world_error_response( operation_future.await
&request_context,
map_custom_world_client_error(error),
));
}
}; };
let result = result.map_err(|error| custom_world_error_response(&request_context, error))?;
if matches!( if matches!(
action.as_str(), action.as_str(),

View File

@@ -28,6 +28,7 @@ use webp::Encoder as WebpEncoder;
use crate::{ use crate::{
api_response::json_success_body, api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken, auth::AuthenticatedAccessToken,
custom_world_result_prompts::{ custom_world_result_prompts::{
build_result_entity_system_prompt, build_result_entity_user_prompt, build_result_entity_system_prompt, build_result_entity_user_prompt,
@@ -441,15 +442,12 @@ pub async fn generate_custom_world_scene_image(
let normalized = normalize_scene_image_request(payload) let normalized = normalize_scene_image_request(payload)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?; .map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-scene-{}", current_utc_millis()); let asset_id = format!("custom-scene-{}", current_utc_millis());
crate::asset_billing::consume_asset_operation_points( let asset = execute_billable_asset_operation(
&state, &state,
&owner_user_id, &owner_user_id,
"scene_image", "scene_image",
asset_id.as_str(), asset_id.as_str(),
) async {
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_result = async {
let settings = require_dashscope_settings(&state)?; let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?; let http_client = build_dashscope_http_client(&settings)?;
let reference_image = let reference_image =
@@ -545,22 +543,10 @@ pub async fn generate_custom_world_scene_image(
}, },
) )
.await .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; .await
return Err(custom_world_ai_error_response(&request_context, error)); .map_err(|error| custom_world_ai_error_response(&request_context, error))?;
}
};
Ok(json_success_body(Some(&request_context), asset)) Ok(json_success_body(Some(&request_context), asset))
} }
@@ -717,15 +703,12 @@ pub async fn generate_custom_world_cover_image(
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone()); 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 size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
let asset_id = format!("custom-cover-{}", current_utc_millis()); let asset_id = format!("custom-cover-{}", current_utc_millis());
crate::asset_billing::consume_asset_operation_points( let asset = execute_billable_asset_operation(
&state, &state,
&owner_user_id, &owner_user_id,
"custom_world_cover", "custom_world_cover",
asset_id.as_str(), asset_id.as_str(),
) async {
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_result = async {
let settings = require_dashscope_settings(&state)?; let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?; let http_client = build_dashscope_http_client(&settings)?;
let reference_sources = collect_cover_reference_image_sources( let reference_sources = collect_cover_reference_image_sources(
@@ -822,22 +805,10 @@ pub async fn generate_custom_world_cover_image(
}, },
) )
.await .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; .await
return Err(custom_world_ai_error_response(&request_context, error)); .map_err(|error| custom_world_ai_error_response(&request_context, error))?;
}
};
Ok(json_success_body(Some(&request_context), asset)) Ok(json_success_body(Some(&request_context), asset))
} }

View File

@@ -26,13 +26,18 @@ pub async fn password_entry(
headers: HeaderMap, headers: HeaderMap,
Json(payload): Json<PasswordEntryRequest>, Json(payload): Json<PasswordEntryRequest>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let result = state let input = PasswordEntryInput {
.password_entry_service()
.execute(PasswordEntryInput {
phone_number: payload.phone, phone_number: payload.phone,
password: payload.password, password: payload.password,
}) };
let result = if state.config.dev_password_entry_auto_register_enabled {
state
.password_entry_service()
.execute_with_dev_registration(input)
.await .await
} else {
state.password_entry_service().execute(input).await
}
.map_err(map_password_entry_error)?; .map_err(map_password_entry_error)?;
let session_client = resolve_session_client_context(&headers); let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;

View File

@@ -67,7 +67,7 @@ use tokio::time::sleep;
use crate::{ use crate::{
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
api_response::json_success_body, api_response::json_success_body,
asset_billing::{consume_asset_operation_points, refund_asset_operation_points}, asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken, auth::AuthenticatedAccessToken,
http_error::AppError, http_error::AppError,
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, 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 owner_user_id = authenticated.claims().user_id().to_string();
let now = current_utc_micros(); let now = current_utc_micros();
let action = payload.action.trim().to_string(); 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}"); 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| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
})?;
}
let (operation_type, phase_label, phase_detail, session) = match action.as_str() { let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => { "compile_puzzle_draft" => {
let session = compile_puzzle_draft_with_initial_cover( let session = execute_billable_asset_operation(
&state,
&owner_user_id,
"puzzle_initial_image",
&billing_asset_id,
async {
compile_puzzle_draft_with_initial_cover(
&state, &state,
session_id.clone(), session_id.clone(),
owner_user_id.clone(), owner_user_id.clone(),
now, now,
) )
.await; .await
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
( (
"compile_puzzle_draft", "compile_puzzle_draft",
"完整拼图草稿", "完整拼图草稿",
@@ -473,17 +473,23 @@ pub async fn execute_puzzle_agent_action(
) )
} }
"generate_puzzle_images" => { "generate_puzzle_images" => {
let session = execute_billable_asset_operation(
&state,
&owner_user_id,
"puzzle_generated_image",
&billing_asset_id,
async {
let session = state let session = state
.spacetime_client() .spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await; .await
let session = match session { .map_err(map_puzzle_client_error)?;
Ok(session) => {
let draft = session.draft.clone().ok_or_else(|| { let draft = session.draft.clone().ok_or_else(|| {
SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()) AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
}); "provider": PUZZLE_AGENT_API_BASE_PROVIDER,
match draft { "message": "拼图结果页草稿尚未生成",
Ok(draft) => { }))
})?;
let prompt = payload let prompt = payload
.prompt_text .prompt_text
.clone() .clone()
@@ -503,9 +509,12 @@ pub async fn execute_puzzle_agent_action(
candidate_start_index, candidate_start_index,
) )
.await .await
.map_err(SpacetimeClientError::Runtime); .map_err(|message| {
match candidates { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
Ok(candidates) => { "provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
})?;
let candidates_json = serde_json::to_string( let candidates_json = serde_json::to_string(
&candidates &candidates
.iter() .iter()
@@ -513,35 +522,27 @@ pub async fn execute_puzzle_agent_action(
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.map_err(|error| { .map_err(|error| {
SpacetimeClientError::Runtime(format!( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"拼图候选图序列化失败:{error}" "provider": PUZZLE_AGENT_API_BASE_PROVIDER,
)) "message": format!("拼图候选图序列化失败:{error}"),
}); }))
match candidates_json { })?;
Ok(candidates_json) => {
state state
.spacetime_client() .spacetime_client()
.save_puzzle_generated_images( .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id, session_id: session.session_id,
owner_user_id: owner_user_id.clone(), owner_user_id: owner_user_id.clone(),
candidates_json, candidates_json,
saved_at_micros: now, saved_at_micros: now,
})
.await
.map_err(map_puzzle_client_error)
}, },
) )
.await .await
} .map_err(|error| {
Err(error) => Err(error), puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
} });
}
Err(error) => Err(error),
}
}
Err(error) => Err(error),
}
}
Err(error) => Err(error),
};
( (
"generate_puzzle_images", "generate_puzzle_images",
"拼图图片生成", "拼图图片生成",
@@ -569,7 +570,14 @@ pub async fn execute_puzzle_agent_action(
candidate_id, candidate_id,
selected_at_micros: now, 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", "select_puzzle_image",
"正式图确认", "正式图确认",
@@ -579,12 +587,14 @@ pub async fn execute_puzzle_agent_action(
} }
"publish_puzzle_work" => { "publish_puzzle_work" => {
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); 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) let author_display_name = resolve_author_display_name(&state, &authenticated);
.await let profile = execute_billable_asset_operation(
.map_err(|error| { &state,
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) &owner_user_id,
})?; "puzzle_publish_work",
let profile_result = state &work_id,
async {
state
.spacetime_client() .spacetime_client()
.publish_puzzle_work(PuzzlePublishRecordInput { .publish_puzzle_work(PuzzlePublishRecordInput {
session_id: session_id.clone(), session_id: session_id.clone(),
@@ -592,30 +602,20 @@ pub async fn execute_puzzle_agent_action(
// 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。 // 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。
work_id: work_id.clone(), work_id: work_id.clone(),
profile_id, profile_id,
author_display_name: resolve_author_display_name(&state, &authenticated), author_display_name,
level_name: payload.level_name.clone(), level_name: payload.level_name.clone(),
summary: payload.summary.clone(), summary: payload.summary.clone(),
theme_tags: payload.theme_tags.clone(), theme_tags: payload.theme_tags.clone(),
published_at_micros: now, published_at_micros: now,
}) })
.await; .await
let profile = match profile_result { .map_err(map_puzzle_client_error)
Ok(profile) => profile, },
Err(error) => {
refund_asset_operation_points(
&state,
&owner_user_id,
"puzzle_publish_work",
&work_id,
) )
.await; .await
return Err(puzzle_error_response( .map_err(|error| {
&request_context, puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
PUZZLE_AGENT_API_BASE_PROVIDER, })?;
map_puzzle_client_error(error),
));
}
};
let session = state let session = state
.spacetime_client() .spacetime_client()
@@ -654,29 +654,7 @@ pub async fn execute_puzzle_agent_action(
} }
}; };
let session = session.map_err(|error| { let session = session?;
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),
)
})?;
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),

View File

@@ -7,30 +7,36 @@ use axum::{
use module_runtime::{ use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord,
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::runtime::{ use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
RedeemProfileRewardCodeResponse,
}; };
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::{ use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
request_context::RequestContext, state::AppState, http_error::AppError, request_context::RequestContext, state::AppState,
}; };
pub async fn get_profile_dashboard( pub async fn get_profile_dashboard(
@@ -112,11 +118,14 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::PointsRecharge => { RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE
} }
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => { RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
} }
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND 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( pub async fn get_profile_play_stats(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, 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)] #[cfg(test)]
mod tests { mod tests {
use module_runtime::RuntimeProfileWalletLedgerSourceType; use module_runtime::RuntimeProfileWalletLedgerSourceType;
@@ -417,18 +562,18 @@ mod tests {
use crate::{app::build_router, config::AppConfig, state::AppState}; use crate::{app::build_router, config::AppConfig, state::AppState};
#[test] #[test]
fn profile_wallet_ledger_source_type_formats_asset_generation_values() { fn profile_wallet_ledger_source_type_formats_asset_operation_values() {
assert_eq!( assert_eq!(
format_profile_wallet_ledger_source_type( 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!( assert_eq!(
format_profile_wallet_ledger_source_type( 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
); );
} }

View File

@@ -486,6 +486,38 @@ impl PasswordEntryService {
verify_stored_password_user(existing_user, &input.password).await 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( pub fn get_user_by_id(
&self, &self,
user_id: &str, user_id: &str,
@@ -1336,6 +1368,53 @@ impl InMemoryAuthStore {
Ok(user) 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( fn create_pending_wechat_user(
&self, &self,
profile: WechatIdentityProfile, profile: WechatIdentityProfile,
@@ -2474,6 +2553,39 @@ mod tests {
assert_eq!(error, PasswordEntryError::InvalidCredentials); 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] #[tokio::test]
async fn phone_user_can_set_password_then_login() { async fn phone_user_can_set_password_then_login() {
let store = build_store(); let store = build_store();

View File

@@ -225,6 +225,7 @@ pub struct BigFishWorkSummarySnapshot {
pub level_main_image_ready_count: u32, pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32, pub level_motion_ready_count: u32,
pub background_ready: bool, pub background_ready: bool,
pub play_count: u32,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -321,6 +322,15 @@ pub struct BigFishPublishInput {
pub published_at_micros: i64, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum BigFishFieldError { pub enum BigFishFieldError {
MissingSessionId, MissingSessionId,
@@ -659,6 +669,16 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish
validate_session_owner(&input.session_id, &input.owner_user_id) 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> { pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
serde_json::to_string(anchor_pack) serde_json::to_string(anchor_pack)
} }

View File

@@ -259,8 +259,17 @@ pub enum RuntimeProfileWalletLedgerSourceType {
InviteInviterReward, InviteInviterReward,
InviteInviteeReward, InviteInviteeReward,
PointsRecharge, PointsRecharge,
AssetGenerationConsume, AssetOperationConsume,
AssetGenerationRefund, 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))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput {
pub created_at_micros: i64, 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))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterSnapshot { pub struct RuntimeReferralInviteCenterSnapshot {
@@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError {
MissingLedgerId, MissingLedgerId,
InvalidWalletAmount, InvalidWalletAmount,
MissingInviteCode, MissingInviteCode,
MissingRedeemCode,
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
MissingProductId, MissingProductId,
MissingWorldKey, MissingWorldKey,
MissingBottomTab, MissingBottomTab,
@@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord {
pub has_points_recharged: bool, 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)] #[derive(Clone, Debug, PartialEq)]
pub struct RuntimeReferralInviteCenterRecord { pub struct RuntimeReferralInviteCenterRecord {
pub user_id: String, 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( pub fn build_runtime_profile_play_stats_get_input(
user_id: String, user_id: String,
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> { ) -> 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( pub fn build_runtime_profile_played_world_record(
snapshot: RuntimeProfilePlayedWorldSnapshot, snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> RuntimeProfilePlayedWorldRecord { ) -> RuntimeProfilePlayedWorldRecord {
@@ -1506,8 +1706,19 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::InviteInviterReward => "invite_inviter_reward", Self::InviteInviterReward => "invite_inviter_reward",
Self::InviteInviteeReward => "invite_invitee_reward", Self::InviteInviteeReward => "invite_invitee_reward",
Self::PointsRecharge => "points_recharge", Self::PointsRecharge => "points_recharge",
Self::AssetGenerationConsume => "asset_generation_consume", Self::AssetOperationConsume => "asset_operation_consume",
Self::AssetGenerationRefund => "asset_generation_refund", 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 { impl std::fmt::Display for RuntimeProfileFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
@@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"), Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"), Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"), 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::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
@@ -2008,12 +2226,12 @@ mod tests {
"points_recharge" "points_recharge"
); );
assert_eq!( assert_eq!(
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume.as_str(), RuntimeProfileWalletLedgerSourceType::AssetOperationConsume.as_str(),
"asset_generation_consume" "asset_operation_consume"
); );
assert_eq!( assert_eq!(
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund.as_str(), RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
"asset_generation_refund" "asset_operation_refund"
); );
} }

View File

@@ -26,6 +26,13 @@ pub struct ExecuteBigFishActionRequest {
pub motion_key: Option<String>, 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)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BigFishAnchorItemResponse { pub struct BigFishAnchorItemResponse {
@@ -193,4 +200,14 @@ mod tests {
assert_eq!(payload["motionKey"], json!("move_swim")); assert_eq!(payload["motionKey"], json!("move_swim"));
assert_eq!(payload["level"], json!(3)); 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 }));
}
} }

View File

@@ -18,6 +18,8 @@ pub struct BigFishWorkSummaryResponse {
pub level_main_image_ready_count: u32, pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32, pub level_motion_ready_count: u32,
pub background_ready: bool, pub background_ready: bool,
#[serde(default)]
pub play_count: u32,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -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_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_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_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str = pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str =
"asset_generation_consume"; "asset_operation_consume";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str = pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund";
"asset_generation_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_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -267,6 +267,60 @@ pub struct RedeemProfileReferralInviteCodeResponse {
pub inviter_balance_after: u64, 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)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProfilePlayedWorkSummaryResponse { pub struct ProfilePlayedWorkSummaryResponse {
@@ -828,7 +882,7 @@ mod tests {
id: "ledger-5".to_string(), id: "ledger-5".to_string(),
amount_delta: -1, amount_delta: -1,
balance_after: 199, 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(), .to_string(),
created_at: "2026-04-22T10:04:00Z".to_string(), created_at: "2026-04-22T10:04:00Z".to_string(),
}, },
@@ -836,7 +890,7 @@ mod tests {
id: "ledger-6".to_string(), id: "ledger-6".to_string(),
amount_delta: 1, amount_delta: 1,
balance_after: 200, 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(), .to_string(),
created_at: "2026-04-22T10:05:00Z".to_string(), created_at: "2026-04-22T10:05:00Z".to_string(),
}, },
@@ -864,11 +918,11 @@ mod tests {
); );
assert_eq!( assert_eq!(
payload["entries"][4]["sourceType"], payload["entries"][4]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME) json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME)
); );
assert_eq!( assert_eq!(
payload["entries"][5]["sourceType"], payload["entries"][5]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND) json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)
); );
assert_eq!( assert_eq!(
payload["entries"][0]["createdAt"], payload["entries"][0]["createdAt"],

View File

@@ -1,6 +1,7 @@
use super::*; use super::*;
use crate::mapper::*; use crate::mapper::*;
use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; 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 { impl SpacetimeClient {
pub async fn create_big_fish_session( pub async fn create_big_fish_session(
@@ -265,4 +266,28 @@ impl SpacetimeClient {
}) })
.await .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
}
} }

View File

@@ -10,13 +10,14 @@ pub use mapper::{
BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput,
BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput,
BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput, BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput,
BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord,
BigFishSessionRecord, BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord,
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput,
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
@@ -120,6 +121,8 @@ use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, 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_play_stats_record, build_runtime_profile_recharge_center_get_input,
build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input, build_runtime_profile_recharge_order_create_input,
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record, build_runtime_profile_redeem_code_admin_disable_input,
build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_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_entry_record,
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, 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, build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,

View File

@@ -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> impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
for 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( pub(crate) fn map_runtime_profile_play_stats_procedure_result(
result: RuntimeProfilePlayStatsProcedureResult, result: RuntimeProfilePlayStatsProcedureResult,
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> { ) -> 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( pub(crate) fn map_runtime_profile_played_world_snapshot(
snapshot: RuntimeProfilePlayedWorldSnapshot, snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { ) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
@@ -3282,11 +3393,46 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => { crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge
} }
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume => { crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
} }
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund 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, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleAnchorItemRecord { pub struct PuzzleAnchorItemRecord {
pub key: String, pub key: String,
@@ -4622,6 +4776,7 @@ pub struct BigFishWorkSummaryRecord {
pub level_main_image_ready_count: u32, pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32, pub level_motion_ready_count: u32,
pub background_ready: bool, pub background_ready: bool,
pub play_count: u32,
} }
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
@@ -4641,6 +4796,8 @@ struct CompatibleBigFishWorkSummaryRecord {
level_main_image_ready_count: u32, level_main_image_ready_count: u32,
level_motion_ready_count: u32, level_motion_ready_count: u32,
background_ready: bool, background_ready: bool,
#[serde(default)]
play_count: u32,
} }
impl CompatibleBigFishWorkSummaryRecord { impl CompatibleBigFishWorkSummaryRecord {
@@ -4665,6 +4822,7 @@ impl CompatibleBigFishWorkSummaryRecord {
level_main_image_ready_count: self.level_main_image_ready_count, level_main_image_ready_count: self.level_main_image_ready_count,
level_motion_ready_count: self.level_motion_ready_count, level_motion_ready_count: self.level_motion_ready_count,
background_ready: self.background_ready, background_ready: self.background_ready,
play_count: self.play_count,
} }
} }
} }

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -20,6 +20,7 @@ pub struct BigFishCreationSession {
pub asset_coverage_json: String, pub asset_coverage_json: String,
pub last_assistant_reply: Option<String>, pub last_assistant_reply: Option<String>,
pub publish_ready: bool, pub publish_ready: bool,
pub play_count: u32,
pub created_at: __sdk::Timestamp, pub created_at: __sdk::Timestamp,
pub updated_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 asset_coverage_json: __sdk::__query_builder::Col<BigFishCreationSession, String>,
pub last_assistant_reply: __sdk::__query_builder::Col<BigFishCreationSession, Option<String>>, pub last_assistant_reply: __sdk::__query_builder::Col<BigFishCreationSession, Option<String>>,
pub publish_ready: __sdk::__query_builder::Col<BigFishCreationSession, bool>, 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 created_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
pub updated_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", "last_assistant_reply",
), ),
publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), 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"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
} }

View File

@@ -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;
}

View File

@@ -8,6 +8,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
pub mod accept_quest_reducer; pub mod accept_quest_reducer;
pub mod acknowledge_quest_completion_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 advance_puzzle_next_level_procedure;
pub mod ai_result_reference_input_type; pub mod ai_result_reference_input_type;
pub mod ai_result_reference_kind_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_level_blueprint_type;
pub mod big_fish_message_finalize_input_type; pub mod big_fish_message_finalize_input_type;
pub mod big_fish_message_submit_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_publish_input_type;
pub mod big_fish_runtime_params_type; pub mod big_fish_runtime_params_type;
pub mod big_fish_session_create_input_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_membership_type;
pub mod profile_played_world_type; pub mod profile_played_world_type;
pub mod profile_recharge_order_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_referral_relation_type;
pub mod profile_save_archive_type; pub mod profile_save_archive_type;
pub mod profile_wallet_ledger_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_step_snapshot_type;
pub mod quest_treasure_inspected_signal_type; pub mod quest_treasure_inspected_signal_type;
pub mod quest_turn_in_input_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_referral_invite_code_procedure;
pub mod redeem_profile_reward_code_procedure;
pub mod refresh_session_type; pub mod refresh_session_type;
pub mod refund_profile_wallet_points_and_return_procedure; pub mod refund_profile_wallet_points_and_return_procedure;
pub mod resolve_combat_action_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_order_status_type;
pub mod runtime_profile_recharge_product_kind_type; pub mod runtime_profile_recharge_product_kind_type;
pub mod runtime_profile_recharge_product_snapshot_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_list_input_type;
pub mod runtime_profile_save_archive_procedure_result_type; pub mod runtime_profile_save_archive_procedure_result_type;
pub mod runtime_profile_save_archive_resume_input_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 accept_quest_reducer::accept_quest;
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; 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 advance_puzzle_next_level_procedure::advance_puzzle_next_level;
pub use ai_result_reference_input_type::AiResultReferenceInput; pub use ai_result_reference_input_type::AiResultReferenceInput;
pub use ai_result_reference_kind_type::AiResultReferenceKind; 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_level_blueprint_type::BigFishLevelBlueprint;
pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput; pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput;
pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput; 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_publish_input_type::BigFishPublishInput;
pub use big_fish_runtime_params_type::BigFishRuntimeParams; pub use big_fish_runtime_params_type::BigFishRuntimeParams;
pub use big_fish_session_create_input_type::BigFishSessionCreateInput; 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_membership_type::ProfileMembership;
pub use profile_played_world_type::ProfilePlayedWorld; pub use profile_played_world_type::ProfilePlayedWorld;
pub use profile_recharge_order_type::ProfileRechargeOrder; 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_referral_relation_type::ProfileReferralRelation;
pub use profile_save_archive_type::ProfileSaveArchive; pub use profile_save_archive_type::ProfileSaveArchive;
pub use profile_wallet_ledger_type::ProfileWalletLedger; 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_step_snapshot_type::QuestStepSnapshot;
pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal; pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal;
pub use quest_turn_in_input_type::QuestTurnInInput; 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_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 refresh_session_type::RefreshSession;
pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; 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; 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_order_status_type::RuntimeProfileRechargeOrderStatus;
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot; 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_list_input_type::RuntimeProfileSaveArchiveListInput;
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult; pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput; pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -16,9 +16,11 @@ pub enum RuntimeProfileWalletLedgerSourceType {
PointsRecharge, PointsRecharge,
AssetGenerationConsume, AssetOperationConsume,
AssetGenerationRefund, AssetOperationRefund,
RedeemCodeReward,
} }
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {

View File

@@ -255,6 +255,97 @@ impl SpacetimeClient {
.await .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( pub async fn get_profile_play_stats(
&self, &self,
user_id: String, user_id: String,

View File

@@ -108,6 +108,7 @@ pub(crate) fn generate_big_fish_asset_tx(
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()), last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready, publish_ready: coverage.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at, updated_at,
}; };
@@ -164,6 +165,7 @@ pub(crate) fn publish_big_fish_game_tx(
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
publish_ready: true, publish_ready: true,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at: published_at, updated_at: published_at,
}; };

View File

@@ -1,4 +1,7 @@
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session}; 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::*; use crate::*;
const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0; 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] #[spacetimedb::procedure]
pub fn submit_big_fish_message( pub fn submit_big_fish_message(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
@@ -194,6 +223,7 @@ pub(crate) fn create_big_fish_session_tx(
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
last_assistant_reply: Some(input.welcome_message_text.clone()), last_assistant_reply: Some(input.welcome_message_text.clone()),
publish_ready: false, publish_ready: false,
play_count: 0,
created_at, created_at,
updated_at: 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(), asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: session.last_assistant_reply.clone(), last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready, publish_ready: session.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at: submitted_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(), asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: session.last_assistant_reply.clone(), last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready, publish_ready: session.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_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(), asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: Some(assistant_reply_text), last_assistant_reply: Some(assistant_reply_text),
publish_ready: session.publish_ready, publish_ready: session.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at, updated_at,
}; };
@@ -536,6 +569,7 @@ pub(crate) fn compile_big_fish_draft_tx(
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()), last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready, publish_ready: coverage.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at: compiled_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( pub(crate) fn build_big_fish_session_snapshot(
ctx: &ReducerContext, ctx: &ReducerContext,
row: &BigFishCreationSession, 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_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count, level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready, background_ready: coverage.background_ready,
play_count: row.play_count,
}) })
} }
@@ -699,6 +820,7 @@ mod tests {
asset_coverage_json: "{}".to_string(), asset_coverage_json: "{}".to_string(),
last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()), last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()),
publish_ready: false, publish_ready: false,
play_count: 0,
created_at: Timestamp::from_micros_since_unix_epoch(1), created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1),
} }

View File

@@ -17,6 +17,7 @@ pub struct BigFishCreationSession {
pub(crate) asset_coverage_json: String, pub(crate) asset_coverage_json: String,
pub(crate) last_assistant_reply: Option<String>, pub(crate) last_assistant_reply: Option<String>,
pub(crate) publish_ready: bool, pub(crate) publish_ready: bool,
pub(crate) play_count: u32,
pub(crate) created_at: Timestamp, pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp, pub(crate) updated_at: Timestamp,
} }

View File

@@ -109,6 +109,8 @@ macro_rules! migration_tables {
user_browse_history, user_browse_history,
profile_dashboard_state, profile_dashboard_state,
profile_wallet_ledger, profile_wallet_ledger,
profile_redeem_code,
profile_redeem_code_usage,
profile_invite_code, profile_invite_code,
profile_referral_relation, profile_referral_relation,
profile_played_world, profile_played_world,
@@ -659,6 +661,19 @@ where
Ok(wrapped.0) 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( fn insert_migration_table_rows(
ctx: &ReducerContext, ctx: &ReducerContext,
table: &MigrationTable, table: &MigrationTable,
@@ -672,7 +687,8 @@ fn insert_migration_table_rows(
let mut imported = 0u64; let mut imported = 0u64;
let mut skipped = 0u64; let mut skipped = 0u64;
for value in &table.rows { 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)))?; .map_err(|error| format!("{}: {error}", stringify!($table)))?;
let insert_result = ctx.db let insert_result = ctx.db
.$table() .$table()

View File

@@ -1,3 +1,6 @@
use crate::runtime::{
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
};
use module_puzzle::{ use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
@@ -1072,6 +1075,12 @@ fn start_puzzle_run_tx(
.map(|value| value.profile_id.clone()); .map(|value| value.profile_id.clone());
increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros); 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)?; insert_puzzle_runtime_run(ctx, &run, &input.owner_user_id, input.started_at_micros)?;
Ok(run) Ok(run)
} }
@@ -1179,6 +1188,12 @@ fn advance_puzzle_next_level_tx(
.find(&next_profile.profile_id) .find(&next_profile.profile_id)
{ {
increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros); 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); replace_puzzle_runtime_run(ctx, &row, &next_run, input.advanced_at_micros);
Ok(next_run) Ok(next_run)
@@ -1219,6 +1234,13 @@ fn submit_puzzle_leaderboard_entry_tx(
&input.run_id, &input.run_id,
input.submitted_at_micros, 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( let leaderboard_entries = list_puzzle_leaderboard_entries(
ctx, 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( fn replace_generated_candidate(
draft: &mut PuzzleResultDraft, draft: &mut PuzzleResultDraft,
candidates: Vec<PuzzleGeneratedImageCandidate>, candidates: Vec<PuzzleGeneratedImageCandidate>,

View File

@@ -28,6 +28,39 @@ pub struct ProfileWalletLedger {
pub(crate) created_at: Timestamp, 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)] #[spacetimedb::table(accessor = profile_invite_code)]
pub struct ProfileInviteCode { pub struct ProfileInviteCode {
#[primary_key] #[primary_key]
@@ -83,6 +116,17 @@ pub struct ProfilePlayedWorld {
pub(crate) last_observed_play_time_ms: u64, 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)] #[spacetimedb::table(accessor = profile_membership)]
pub struct ProfileMembership { pub struct ProfileMembership {
#[primary_key] #[primary_key]
@@ -248,7 +292,7 @@ pub fn consume_profile_wallet_points_and_return(
apply_profile_wallet_adjustment( apply_profile_wallet_adjustment(
tx, tx,
input.clone(), input.clone(),
RuntimeProfileWalletLedgerSourceType::AssetGenerationConsume, RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
true, true,
) )
}) { }) {
@@ -275,7 +319,7 @@ pub fn refund_profile_wallet_points_and_return(
apply_profile_wallet_adjustment( apply_profile_wallet_adjustment(
tx, tx,
input.clone(), input.clone(),
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund, RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
false, 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( pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext, ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput, input: RuntimeProfileSaveArchiveListInput,
@@ -498,6 +600,172 @@ pub(crate) fn sync_profile_projections_from_snapshot(
Ok(()) 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( fn sync_profile_dashboard_from_snapshot(
ctx: &ReducerContext, ctx: &ReducerContext,
snapshot: &RuntimeSnapshot, 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( fn build_profile_referral_invite_center_snapshot(
ctx: &ReducerContext, ctx: &ReducerContext,
user_id: &str, user_id: &str,
@@ -1579,6 +2026,74 @@ fn latest_profile_recharge_order(
orders.into_iter().next() 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( fn build_profile_wallet_ledger_snapshot_from_row(
row: &ProfileWalletLedger, row: &ProfileWalletLedger,
) -> RuntimeProfileWalletLedgerEntrySnapshot { ) -> RuntimeProfileWalletLedgerEntrySnapshot {

View File

@@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem(
id: 'level-motion-ready-count', id: 'level-motion-ready-count',
label: `动作 ${item.levelMotionReadyCount}`, label: `动作 ${item.levelMotionReadyCount}`,
}, },
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
...(item.backgroundReady ...(item.backgroundReady
? [ ? [
{ {

View File

@@ -36,6 +36,8 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
import type { import type {
CustomWorldGalleryCard, CustomWorldGalleryCard,
CustomWorldLibraryEntry, CustomWorldLibraryEntry,
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { import {
@@ -55,6 +57,7 @@ import {
import { listBigFishGallery } from '../../services/big-fish-gallery'; import { listBigFishGallery } from '../../services/big-fish-gallery';
import { import {
advanceLocalBigFishRuntimeRun, advanceLocalBigFishRuntimeRun,
recordBigFishPlay,
startLocalBigFishRuntimeRun, startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime'; } from '../../services/big-fish-runtime';
import { import {
@@ -91,6 +94,7 @@ import {
} from '../../services/puzzle-gallery'; } from '../../services/puzzle-gallery';
import { import {
advanceLocalPuzzleNextLevel, advanceLocalPuzzleNextLevel,
startPuzzleRun,
submitPuzzleLeaderboard, submitPuzzleLeaderboard,
} from '../../services/puzzle-runtime'; } from '../../services/puzzle-runtime';
import { import {
@@ -107,6 +111,7 @@ import {
deleteRpgEntryWorldProfile, deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetailByCode, getRpgEntryWorldGalleryDetailByCode,
} from '../../services/rpg-entry/rpgEntryLibraryClient'; } from '../../services/rpg-entry/rpgEntryLibraryClient';
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
import { import {
@@ -154,6 +159,8 @@ type AgentResultBlockerView = {
message: string; message: string;
}; };
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
'publish_missing_world_hook', 'publish_missing_world_hook',
'publish_missing_player_premise', 'publish_missing_player_premise',
@@ -442,6 +449,13 @@ export function PlatformEntryFlowShellImpl({
title: string; title: string;
publicWorkCode: string; publicWorkCode: string;
} | null>(null); } | 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 [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] = const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null); useState<MiniGameDraftGenerationState | null>(null);
@@ -475,6 +489,14 @@ export function PlatformEntryFlowShellImpl({
string | null string | null
>(null); >(null);
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish'); 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 hadReadableProtectedDataRef = useRef(false);
const hasInitialAgentSession = Boolean( const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId && readCustomWorldAgentUiState().activeSessionId &&
@@ -1041,6 +1063,9 @@ export function PlatformEntryFlowShellImpl({
setBigFishWorks([]); setBigFishWorks([]);
setBigFishRun(null); setBigFishRun(null);
setBigFishRuntimeShare(null); setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishGenerationState(null); setBigFishGenerationState(null);
setBigFishError(null); setBigFishError(null);
setPuzzleOperation(null); setPuzzleOperation(null);
@@ -1052,6 +1077,9 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleNextLevelGenerating(false); setIsPuzzleNextLevelGenerating(false);
setPuzzleError(null); setPuzzleError(null);
setDeletingCreationWorkId(null); setDeletingCreationWorkId(null);
setProfilePlayStats(null);
setProfilePlayStatsError(null);
setIsProfilePlayStatsOpen(false);
resetRpgSessionViewState(); resetRpgSessionViewState();
setRpgGeneratedCustomWorldProfile(null); setRpgGeneratedCustomWorldProfile(null);
setRpgCustomWorldError(null); setRpgCustomWorldError(null);
@@ -1120,6 +1148,9 @@ export function PlatformEntryFlowShellImpl({
const leaveBigFishFlow = useCallback(() => { const leaveBigFishFlow = useCallback(() => {
setBigFishRun(null); setBigFishRun(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishGenerationState(null); setBigFishGenerationState(null);
bigFishFlow.leaveFlow(); bigFishFlow.leaveFlow();
}, [bigFishFlow]); }, [bigFishFlow]);
@@ -1156,22 +1187,62 @@ export function PlatformEntryFlowShellImpl({
return; return;
} }
const sessionId = bigFishSession.sessionId;
setBigFishError(null); setBigFishError(null);
setBigFishRuntimeShare(null); setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('draft');
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime'); 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(() => { const restartBigFishRun = useCallback(() => {
if (!bigFishSession && !bigFishRun) { if (!bigFishSession && !bigFishRun) {
return; return;
} }
const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId;
if (!sessionId) {
return;
}
setBigFishError(null); setBigFishError(null);
if (bigFishSession) {
setBigFishRuntimeShare(null); setBigFishRuntimeShare(null);
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); }
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
setBigFishRun(
startLocalBigFishRuntimeRun({
session: bigFishSession,
work: bigFishRuntimeWork,
}),
);
setSelectionStage('big-fish-runtime'); 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( const startPuzzleRunFromProfile = useCallback(
async (profileId: string) => { async (profileId: string) => {
@@ -1184,8 +1255,9 @@ export function PlatformEntryFlowShellImpl({
try { try {
const { item } = await getPuzzleGalleryDetail(profileId); const { item } = await getPuzzleGalleryDetail(profileId);
const { run } = await startPuzzleRun({ profileId: item.profileId });
setSelectedPuzzleDetail(item); setSelectedPuzzleDetail(item);
setPuzzleRun(startLocalPuzzleRun(item)); setPuzzleRun(run);
setPuzzleRuntimeReturnStage('puzzle-gallery-detail'); setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setSelectionStage('puzzle-runtime'); setSelectionStage('puzzle-runtime');
pushAppHistoryPath( pushAppHistoryPath(
@@ -1269,6 +1341,25 @@ export function PlatformEntryFlowShellImpl({
[bigFishRun], [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( const swapPuzzlePiecesInRun = useCallback(
(payload: { firstPieceId: string; secondPieceId: string }) => { (payload: { firstPieceId: string; secondPieceId: string }) => {
if (!puzzleRun || isPuzzleBusy) { if (!puzzleRun || isPuzzleBusy) {
@@ -1712,17 +1803,25 @@ export function PlatformEntryFlowShellImpl({
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId); const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
setBigFishError(null); setBigFishError(null);
bigFishFlow.setSession(null); bigFishFlow.setSession(null);
setBigFishRuntimeWork(item);
setBigFishRuntimeShare({ setBigFishRuntimeShare({
title: item.title, title: item.title,
publicWorkCode, publicWorkCode,
}); });
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('work');
setBigFishRun(startLocalBigFishRuntimeRun({ work: item })); setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime'); setSelectionStage('big-fish-runtime');
pushAppHistoryPath( pushAppHistoryPath(
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode), buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
); );
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
);
});
}, },
[bigFishFlow, setSelectionStage], [bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
); );
const handlePublicCodeSearch = useCallback( 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(() => { useEffect(() => {
const publicWorkCode = initialPublicWorkCode?.trim(); const publicWorkCode = initialPublicWorkCode?.trim();
if ( if (
@@ -2149,7 +2360,19 @@ export function PlatformEntryFlowShellImpl({
void handlePublicCodeSearch(keyword); void handlePublicCodeSearch(keyword);
}} }}
isSearchingPublicCode={isSearchingPublicCode} 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) { if (platformBootstrap.dashboardError) {
void platformBootstrap.refreshProfileDashboard(); void platformBootstrap.refreshProfileDashboard();
} }
@@ -2402,11 +2625,15 @@ export function PlatformEntryFlowShellImpl({
isBusy={isBigFishBusy} isBusy={isBigFishBusy}
error={bigFishError} error={bigFishError}
onBack={() => { onBack={() => {
reportBigFishObservedPlayTime();
setSelectionStage( setSelectionStage(
bigFishSession ? 'big-fish-result' : 'platform', bigFishRuntimeSessionSource === 'draft'
? 'big-fish-result'
: 'platform',
); );
}} }}
onRestart={() => { onRestart={() => {
reportBigFishObservedPlayTime();
void restartBigFishRun(); void restartBigFishRun();
}} }}
onSubmitInput={submitBigFishInput} onSubmitInput={submitBigFishInput}

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */ /* @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 userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
@@ -11,7 +11,29 @@ import {
} from './RpgEntryHomeView'; } from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; 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', () => ({ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
getRpgProfileRechargeCenter: vi.fn(async () => ({ getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0, walletBalance: 0,
membership: { 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 user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess); renderProfileView();
await user.click(screen.getByText('会员充值')); await user.click(screen.getByText('剩余叙世币'));
expect(await screen.findByText('账户充值')).toBeTruthy(); expect(await screen.findByText('叙世币账单')).toBeTruthy();
expect(await screen.findByText('叙世币充值')).toBeTruthy(); expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
expect(await screen.findByText('60叙世币')).toBeTruthy(); 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 () => { test('shows a reachable login entry in logged out mobile shell', async () => {

View File

@@ -8,7 +8,6 @@ import {
Clock3, Clock3,
Coins, Coins,
Copy, Copy,
Crown,
House, House,
LogIn, LogIn,
MessageCircle, MessageCircle,
@@ -34,19 +33,21 @@ import type {
PlatformBrowseHistoryEntry, PlatformBrowseHistoryEntry,
ProfileDashboardCardKey, ProfileDashboardCardKey,
ProfileDashboardSummary, ProfileDashboardSummary,
ProfileRechargeCenterResponse, ProfilePlayedWorkSummary,
ProfileRechargeProduct, ProfilePlayStatsResponse,
ProfileReferralInviteCenterResponse, ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary, ProfileSaveArchiveSummary,
ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService'; import type { AuthUser } from '../../services/authService';
import { import {
createRpgProfileRechargeOrder,
getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter, getRpgProfileReferralInviteCenter,
getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode, redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient'; } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
@@ -102,6 +103,12 @@ export interface RpgEntryHomeViewProps {
onSearchPublicCode?: (keyword: string) => void | Promise<void>; onSearchPublicCode?: (keyword: string) => void | Promise<void>;
isSearchingPublicCode?: boolean; isSearchingPublicCode?: boolean;
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void; onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
profilePlayStats?: ProfilePlayStatsResponse | null;
isProfilePlayStatsOpen?: boolean;
isProfilePlayStatsLoading?: boolean;
profilePlayStatsError?: string | null;
onCloseProfilePlayStats?: () => void;
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
onRechargeSuccess?: () => void | Promise<void>; onRechargeSuccess?: () => void | Promise<void>;
createTabContent?: ReactNode; 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) { function buildPublicUserCode(user: AuthUser | null | undefined) {
if (user?.publicUserCode?.trim()) { if (user?.publicUserCode?.trim()) {
return user.publicUserCode.trim(); return user.publicUserCode.trim();
@@ -910,206 +932,191 @@ function ProfileShortcutButton({
); );
} }
function formatRechargePrice(priceCents: number) { const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
const yuan = priceCents / 100; points_recharge: '叙世币充值',
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`; 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) { function WalletLedgerModal({
if (days >= 365) { ledger,
return '365天'; fallbackBalance,
}
return `${days}`;
}
function AccountRechargeModal({
center,
activeTab,
isLoading, isLoading,
isSubmitting,
error, error,
onTabChange,
onClose, onClose,
onSelectProduct, onRetry,
}: { }: {
center: ProfileRechargeCenterResponse | null; ledger: ProfileWalletLedgerResponse | null;
activeTab: 'points' | 'membership'; fallbackBalance: number;
isLoading: boolean; isLoading: boolean;
isSubmitting: string | null;
error: string | null; error: string | null;
onTabChange: (tab: 'points' | 'membership') => void;
onClose: () => void; onClose: () => void;
onSelectProduct: (product: ProfileRechargeProduct) => void; onRetry: () => void;
}) { }) {
const visibleProducts = const entries = ledger?.entries ?? [];
activeTab === 'points' const balance = entries[0]?.balanceAfter ?? fallbackBalance;
? (center?.pointProducts ?? [])
: (center?.membershipProducts ?? []);
return ( return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5"> <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 <button
type="button" type="button"
onClick={onClose} 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" 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> </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="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]"> <div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
WALLET LEDGER
</div> </div>
<div className="mt-1 text-2xl font-black"></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-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]" /> <Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span> <span>{balance}</span>
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
</span>
</div> </div>
</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 ? ( {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} {error}
</div> </div>
) : null} ) : null}
{success ? (
{isLoading ? ( <div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3"> {success}
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
(_, index) => (
<div
key={index}
className="h-24 animate-pulse rounded-xl bg-zinc-100"
/>
),
)}
</div> </div>
) : activeTab === 'points' ? ( ) : null}
<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>
</>
)}
</div> </div>
</div> </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({ export function RpgEntryHomeView({
activeTab, activeTab,
onTabChange, onTabChange,
@@ -1288,22 +1397,32 @@ export function RpgEntryHomeView({
onSearchPublicCode, onSearchPublicCode,
isSearchingPublicCode = false, isSearchingPublicCode = false,
onOpenProfileDashboardCard, onOpenProfileDashboardCard,
profilePlayStats = null,
isProfilePlayStatsOpen = false,
isProfilePlayStatsLoading = false,
profilePlayStatsError = null,
onCloseProfilePlayStats,
onOpenPlayedWork,
onRechargeSuccess, onRechargeSuccess,
createTabContent, createTabContent,
}: RpgEntryHomeViewProps) { }: RpgEntryHomeViewProps) {
const authUi = useAuthUi(); const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState(''); const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState(''); const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [isRechargeOpen, setIsRechargeOpen] = useState(false); const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>( const [rewardCodeInput, setRewardCodeInput] = useState('');
'points', const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
const [rewardCodeError, setRewardCodeError] = useState<string | null>(null);
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
null,
); );
const [rechargeCenter, setRechargeCenter] = const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
useState<ProfileRechargeCenterResponse | null>(null); const [walletLedger, setWalletLedger] =
const [rechargeError, setRechargeError] = useState<string | null>(null); useState<ProfileWalletLedgerResponse | null>(null);
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false); const [walletLedgerError, setWalletLedgerError] = useState<string | null>(
const [submittingRechargeProductId, setSubmittingRechargeProductId] = null,
useState<string | null>(null); );
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
const [profilePopupPanel, setProfilePopupPanel] = const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null); useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] = const [referralCenter, setReferralCenter] =
@@ -1401,35 +1520,22 @@ export function RpgEntryHomeView({
} }
authUi?.openLoginModal(); authUi?.openLoginModal();
}; };
const openRechargePanel = () => { const loadWalletLedger = () => {
setIsRechargeOpen(true); setWalletLedgerError(null);
setRechargeError(null); setIsLoadingWalletLedger(true);
setIsLoadingRecharge(true); void getRpgProfileWalletLedger()
void getRpgProfileRechargeCenter() .then(setWalletLedger)
.then(setRechargeCenter)
.catch((error: unknown) => { .catch((error: unknown) => {
setRechargeCenter(null); setWalletLedger(null);
setRechargeError( setWalletLedgerError(
error instanceof Error ? error.message : '读取账户充值失败', error instanceof Error ? error.message : '读取叙世币账单失败',
); );
}) })
.finally(() => setIsLoadingRecharge(false)); .finally(() => setIsLoadingWalletLedger(false));
}; };
const submitRechargeProduct = (product: ProfileRechargeProduct) => { const openWalletLedgerPanel = () => {
if (submittingRechargeProductId) { setIsWalletLedgerOpen(true);
return; loadWalletLedger();
}
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 openProfilePopupPanel = (panel: ProfilePopupPanel) => { const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
setProfilePopupPanel(panel); setProfilePopupPanel(panel);
@@ -1486,6 +1592,30 @@ export function RpgEntryHomeView({
}) })
.finally(() => setIsSubmittingReferral(false)); .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 submitDesktopSearch = () => {
const keyword = desktopSearchKeyword.trim(); const keyword = desktopSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) { if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
@@ -1833,17 +1963,13 @@ export function RpgEntryHomeView({
<button <button
type="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" 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>
<div className="text-xs font-bold"></div> <div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80"> <div className="text-[10px] opacity-80"></div>
{rechargeCenter?.membership.status === 'active'
? '叙世会员'
: '普通用户'}
</div>
</div> </div>
<ChevronRight className="h-4 w-4 opacity-80" /> <ChevronRight className="h-4 w-4 opacity-80" />
</button> </button>
@@ -1865,7 +1991,7 @@ export function RpgEntryHomeView({
label="剩余叙世币" label="剩余叙世币"
value="暂不可用" value="暂不可用"
icon={Coins} icon={Coins}
onClick={onOpenProfileDashboardCard} onClick={openWalletLedgerPanel}
/> />
<ProfileStatCard <ProfileStatCard
cardKey="playTime" cardKey="playTime"
@@ -1889,7 +2015,7 @@ export function RpgEntryHomeView({
label="剩余叙世币" label="剩余叙世币"
value={formatDashboardCount(remainingNarrativeCoins)} value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins} icon={Coins}
onClick={onOpenProfileDashboardCard} onClick={openWalletLedgerPanel}
/> />
<ProfileStatCard <ProfileStatCard
cardKey="playTime" cardKey="playTime"
@@ -2291,18 +2417,6 @@ export function RpgEntryHomeView({
))} ))}
</div> </div>
</div> </div>
{isRechargeOpen ? (
<AccountRechargeModal
center={rechargeCenter}
activeTab={rechargeTab}
isLoading={isLoadingRecharge}
isSubmitting={submittingRechargeProductId}
error={rechargeError}
onTabChange={setRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onSelectProduct={submitRechargeProduct}
/>
) : null}
{profilePopupPanel ? ( {profilePopupPanel ? (
<ProfileReferralModal <ProfileReferralModal
panel={profilePopupPanel} panel={profilePopupPanel}
@@ -2318,6 +2432,25 @@ export function RpgEntryHomeView({
onSubmitRedeem={submitReferralInviteCode} onSubmitRedeem={submitReferralInviteCode}
/> />
) : null} ) : 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> </div>
); );
} }
@@ -2395,16 +2528,15 @@ export function RpgEntryHomeView({
</div> </div>
</div> </div>
</div> </div>
{isRechargeOpen ? ( {isRewardCodeOpen ? (
<AccountRechargeModal <RewardCodeRedeemModal
center={rechargeCenter} value={rewardCodeInput}
activeTab={rechargeTab} isSubmitting={isSubmittingRewardCode}
isLoading={isLoadingRecharge} error={rewardCodeError}
isSubmitting={submittingRechargeProductId} success={rewardCodeSuccess}
error={rechargeError} onChange={setRewardCodeInput}
onTabChange={setRechargeTab} onSubmit={submitRewardCode}
onClose={() => setIsRechargeOpen(false)} onClose={() => setIsRewardCodeOpen(false)}
onSelectProduct={submitRechargeProduct}
/> />
) : null} ) : null}
{profilePopupPanel ? ( {profilePopupPanel ? (
@@ -2422,6 +2554,25 @@ export function RpgEntryHomeView({
onSubmitRedeem={submitReferralInviteCode} onSubmitRedeem={submitReferralInviteCode}
/> />
) : null} ) : 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> </div>
); );
} }

View 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,
},
);
}

View File

@@ -2,3 +2,4 @@ export {
advanceLocalBigFishRuntimeRun, advanceLocalBigFishRuntimeRun,
startLocalBigFishRuntimeRun, startLocalBigFishRuntimeRun,
} from './bigFishLocalRuntime'; } from './bigFishLocalRuntime';
export { recordBigFishPlay } from './bigFishRuntimeClient';

View File

@@ -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 = { export const bigFishWorksClient = {
delete: deleteBigFishWork, delete: deleteBigFishWork,
list: listBigFishWorks, list: listBigFishWorks,
recordPlay: recordBigFishWorkPlay,
}; };

View File

@@ -2,4 +2,5 @@ export {
bigFishWorksClient, bigFishWorksClient,
deleteBigFishWork, deleteBigFishWork,
listBigFishWorks, listBigFishWorks,
recordBigFishWorkPlay,
} from './bigFishWorksClient'; } from './bigFishWorksClient';

View File

@@ -11,6 +11,7 @@ import type {
ProfileSaveArchiveResumeResponse, ProfileSaveArchiveResumeResponse,
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
RuntimeSettings, RuntimeSettings,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; 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 = {}) { export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfilePlayStatsResponse>( return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
'/profile/play-stats', '/profile/play-stats',