diff --git a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md index 551bf1c2..6c2cea76 100644 --- a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md +++ b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md @@ -168,7 +168,7 @@ UI 主标题建议: ```text 创作页面 -> 点击“新建作品” --> 打开轻量 launcher +-> 在首屏新建作品卡片中直接选择游戏创作模板 -> 创建新的 Agent session -> 进入 custom-world-agent ``` @@ -177,6 +177,7 @@ UI 主标题建议: - 新建作品依然走 Agent session 主链 - 创作页面不自己生成世界 +- 可创建玩法模板直接露出在首屏卡片中,不再额外弹一次类型 launcher ## 5.3 在创作页面继续草稿 @@ -215,32 +216,26 @@ UI 主标题建议: 创作页面必须包含 4 个区域: -1. 顶部导航区 +1. 首屏新建作品区 2. 新建作品区 3. 历史作品筛选区 4. 作品列表区 -## 6.2 顶部导航区 +## 6.2 首屏新建作品区 -必须展示: - -1. 页面标题:`自定义世界创作` -2. 返回按钮:`返回世界选择` - -可选展示: - -1. 用户作品统计 - - 草稿数 - - 已发布数 - -## 6.3 新建作品区 - -这是页面首屏最高优先级区域。 +这是页面首屏最高优先级区域,默认不再单独展示返回按钮和“创作中心 / 自定义世界创作”标题,直接把注意力留给可创建模板与作品列表。 必须包含: -1. `新建作品` 主按钮 +1. `新建作品` 主标题 2. 一段极短说明文案 +3. 游戏创作模板快捷卡片 + +模板卡片要求: + +1. 直接展示当前可创建玩法,如 `角色扮演 RPG`、`大鱼吃小鱼`、`拼图玩法` +2. 锁定中的玩法保留占位,但必须显式禁用 +3. 点击可创建玩法后直接进入对应创作工作台,不再额外弹二次选择面板 说明文案要求: @@ -250,9 +245,9 @@ UI 主标题建议: 推荐文案方向: -- `输入一点灵感,开始共创一个新世界。` +- `直接选择游戏创作模板,立刻进入对应的共创工作台。` -## 6.4 历史作品筛选区 +## 6.3 历史作品筛选区 建议用 3 个 tab: @@ -266,7 +261,7 @@ UI 主标题建议: 第一版不强制上搜索框,但如果作品数超过 `8` 个,建议补搜索。 -## 6.5 作品列表区 +## 6.4 作品列表区 列表区统一展示作品卡片,但卡片要区分两类: diff --git a/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md b/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md index 28ad44b6..8813f208 100644 --- a/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md +++ b/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md @@ -67,6 +67,7 @@ 1. 后端继续通过 JWT 承载 access token,并只从 `Authorization: Bearer ...` 读取当前访问身份。 2. 前端请求层继续负责保存、刷新和携带 access token;公开请求与静默探测不得误清正式 token。 3. access cookie 会话方案不进入 `codex/backend-rewrite-spacetimedb`,避免和目标分支已有 JWT 方案并存。 +4. `AuthGate` 通过 refresh cookie / `/api/auth/me` 恢复出用户后,必须先确保本地 access token 可用,再把 `readyUser` 暴露给运行时、设置、作品列表等受保护业务 hook;业务 hook 不能只凭 `user.id` 在 token 尚未补齐时启动远端请求。 本批涉及: @@ -168,6 +169,7 @@ 2. 401 刷新链只在已发送 Bearer token 时触发,并且刷新响应必须返回新的 JWT。 3. 浏览历史仅通过远端接口读写。 4. `src/services/platformBrowseHistory.ts` 不再是正式链路依赖。 +5. 手机验证码登录、微信回调或 refresh cookie 会话恢复完成后,首屏并发读取设置、存档、个人看板、浏览历史、作品列表前,必须已经完成 access token 写入,避免出现“用户已 ready 但请求缺少 Authorization Bearer Token”的竞态。 ### 第二批验收 diff --git a/docs/technical/PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md b/docs/technical/PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md new file mode 100644 index 00000000..b3b81b63 --- /dev/null +++ b/docs/technical/PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md @@ -0,0 +1,220 @@ +# 手机验证码短信送达可观测性与回执闭环设计 + +日期:`2026-04-22` + +## 1. 文档目的 + +这份文档用于冻结当前 Node 短信验证码登录链路的一个关键修复方向: + +1. 明确区分“阿里云接口受理成功”和“用户手机实际收到短信”不是同一件事。 +2. 为仓库补齐短信发送追踪字段与送达回执入口,避免前端继续把接口 `200` 误判成短信已送达。 +3. 为后续继续排查“发送验证码收不到短信”提供稳定的数据抓手,而不是只靠人工复现。 + +## 2. 当前问题 + +截至 `2026-04-22`,仓库里的短信链路存在以下状态: + +1. 前端点击“获取验证码”后,只要 `/api/auth/phone/send-code` 返回 `200`,界面就提示“验证码已发送”。 +2. Node 后端当前只知道: + - 是否成功调用了阿里云 `SendSmsVerifyCode` + - 当前请求的冷却时间与过期时间 +3. Node 后端当前不知道: + - 阿里云是否拿到了运营商侧最终送达状态 + - 某次发送最终是“送达成功”“送达失败”还是“状态未知” +4. 现有 `sms_auth_events` 表只记录了: + - `phone_number` + - `scene` + - `action` + - `success` + - `ip` + - `user_agent` + - `created_at` + +这意味着当前系统最多只能证明: + +1. 我们自己的后端接口没有立即报错。 +2. 阿里云发送接口返回了受理成功。 + +但它不能证明: + +1. 短信已经真正下发到运营商。 +2. 运营商已经真正投递到用户手机。 + +## 3. 本次修复目标 + +本次只做短信可观测性与送达回执闭环,不改动现有登录主链产品流程。 + +本次必须达成: + +1. 发送短信成功后,把阿里云返回的追踪字段持久化。 +2. 提供一个接收阿里云短信送达回执的 HTTP 接口。 +3. 把单次短信事件从“发送受理成功”升级为“可查询最终送达状态”。 +4. 前端发送成功提示改成更准确的“短信请求已提交”,避免误导用户。 +5. 文档中明确当前 `200` 只代表“已提交到短信平台”,不是“手机必然已收到”。 + +## 4. 阿里云能力边界 + +按当前仓库引用的阿里云 `@alicloud/dypnsapi20170525` SDK 与官方文档口径,本次冻结以下认知: + +1. `SendSmsVerifyCode` 返回 `OK` + - 只代表短信验证码发送请求已被阿里云受理 + - 不代表用户手机一定已经收到短信 +2. `SendSmsVerifyCode` 响应里可用于追踪的关键字段包括: + - `BizId` + - `OutId` + - `RequestId` +3. 短信最终送达结果需要依赖阿里云 `DypnsSmsVerifyReport` 回执通知 +4. 如果项目没有接回执通知,就无法从仓库内部判断某条短信最终是否真正送达 + +## 5. 范围边界 + +### 5.1 本次必须做 + +1. 扩展 `sms_auth_events` 表结构,补充短信平台追踪字段与最终送达状态字段。 +2. `send-code` 成功后写入: + - `provider` + - `provider_request_id` + - `provider_biz_id` + - `provider_out_id` + - `delivery_status` + - `delivery_report_raw_json` +3. 新增阿里云短信回执接口: + - `POST /api/auth/phone/delivery-report/aliyun` +4. 按回执内容更新对应短信事件的最终状态。 +5. 前端成功提示文本收敛为“短信请求已提交,请留意手机短信”。 +6. 文档同步说明当前定位方式。 + +### 5.2 本次明确不做 + +1. 更换短信供应商 +2. 重构验证码登录交互流程 +3. 增加短信消息中心 UI +4. 新增阿里云控制台自动配置脚本 +5. Rust / Axum 侧同步实现同一回执能力 + +## 6. 数据设计 + +### 6.1 `sms_auth_events` 新字段 + +在现有表上追加以下字段: + +1. `provider TEXT` + - 当前值为 `aliyun` 或 `mock` +2. `provider_request_id TEXT` + - 对应阿里云 `RequestId` +3. `provider_biz_id TEXT` + - 对应阿里云 `BizId` +4. `provider_out_id TEXT` + - 对应请求发出时的 `OutId` +5. `delivery_status TEXT NOT NULL DEFAULT 'pending'` + - 允许值: + - `pending` + - `delivered` + - `failed` + - `unknown` +6. `delivery_report_raw_json JSONB` + - 原样保留最近一次回执内容,方便排查 +7. `delivery_reported_at TEXT` + - 最近一次接收到回执的时间 + +### 6.2 记录策略 + +1. `mock` provider + - `delivery_status` 直接写 `delivered` + - 因为本地 mock 不存在真实运营商投递链路 +2. `aliyun` provider + - 发送成功时先写 `pending` + - 收到回执后再更新为 `delivered` / `failed` / `unknown` + +## 7. 后端设计 + +### 7.1 发送验证码链路 + +`POST /api/auth/phone/send-code` 成功后: + +1. 仍然返回现有 contract: + - `ok` + - `cooldownSeconds` + - `expiresInSeconds` + - `providerRequestId` +2. 但内部补充写库: + - provider 追踪字段 + - 当前回执状态 +3. 日志中输出: + - 手机号后四位 + - provider + - `provider_request_id` + - `provider_biz_id` + - `provider_out_id` + - `delivery_status` + +### 7.2 阿里云回执接口 + +新增: + +1. `POST /api/auth/phone/delivery-report/aliyun` + +当前阶段采用最小策略: + +1. 接收阿里云回执原始表单字段 +2. 优先按 `BizId` 匹配短信事件 +3. 若 `BizId` 缺失,则降级按 `OutId` 匹配 +4. 命中记录后更新: + - `delivery_status` + - `delivery_report_raw_json` + - `delivery_reported_at` +5. 不命中时只记录日志,不报 500,避免供应商反复重试把接口打挂 + +### 7.3 状态映射 + +当前只冻结最小映射: + +1. 回执明确表示成功送达 + - 写成 `delivered` +2. 回执明确表示失败 + - 写成 `failed` +3. 其余无法稳定归类的值 + - 写成 `unknown` + +如果后续阿里云回执字段口径需要更细化,再单独补文档和状态枚举。 + +## 8. 前端提示规则 + +### 8.1 成功提示 + +发送成功提示统一调整为: + +1. “短信请求已提交,请留意手机短信。验证码有效期约 X 分钟。” + +不再使用: + +1. “验证码已发送” +2. “短信已发送到手机” + +因为这类文案会把“平台受理成功”误导成“用户已收到”。 + +### 8.2 错误提示 + +维持当前错误透传策略: + +1. 如果阿里云接口直接报错,继续展示服务端返回的明确错误 +2. 如果服务端只拿到上游失败,也继续显示“发送验证码失败”这类通用错误 + +## 9. 验收标准 + +满足以下条件时,本次修复视为完成: + +1. `send-code` 成功后,数据库中能看到短信平台追踪字段。 +2. `mock` provider 的短信事件默认标记为 `delivered`。 +3. `aliyun` provider 的短信事件默认标记为 `pending`。 +4. 新增回执接口后,能把一条 `pending` 事件更新成最终状态。 +5. 前端提示不再把 `200` 直接描述成“手机已收到验证码”。 +6. 相关测试与编码检查通过。 + +## 10. 风险提示 + +本次修复完成后,仍需明确以下现实边界: + +1. 如果阿里云控制台没有正确配置回执地址,仓库仍然拿不到最终送达结果。 +2. 如果短信签名、模板、通道资质或号码策略有外部问题,本次修复不会替代供应商侧配置排查。 +3. 本次修复的价值在于把问题从“黑盒猜测”变成“有追踪字段、有最终状态、有日志”的可定位链路。 diff --git a/docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md b/docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md index 5cb440db..ec9217a5 100644 --- a/docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md +++ b/docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md @@ -125,3 +125,19 @@ 3. 点击“获取验证码”后,弹窗出现发送成功提示与倒计时。 4. 公开广场请求不会因为 `401` 顺手把已登录态清空。 5. smoke 脚本已经包含手机号验证码登录主链。 + +## 8. 补充说明 + +截至 `2026-04-22`,Node 侧真实阿里云短信链路已经可以返回 `send-code 200`,但这只代表: + +1. 当前服务成功调用了阿里云发送接口 +2. 阿里云成功受理了发送请求 + +它不代表: + +1. 运营商已经成功下发 +2. 用户手机一定已经收到短信 + +因此后续“发送验证码收不到短信”的排查统一按 +`docs/technical/PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md` +执行,先补齐回执闭环和送达状态追踪,再继续看供应商配置与号码侧问题。 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index c09b7507..1a14ca70 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -32,15 +32,15 @@ Windows 下 `dev:rust:sh`、`deploy:rust:remote` 与 `build:rust:ubuntu` 会通 1. Web 前端:`http://127.0.0.1:3000` 2. Rust `api-server`:`http://127.0.0.1:8082` 3. SpacetimeDB standalone:`http://127.0.0.1:3101` -4. SpacetimeDB database:`genarrative-dev` +4. SpacetimeDB database:优先读取仓库根目录 `spacetime.local.json` 的 `database` 字段;没有该字段时才回退到 `genarrative-dev` 默认流程: 1. 检查 `cargo`、`node` 与 `spacetime` CLI。 2. 启动 `spacetime --root-dir server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101`。 3. 等待 `spacetime server ping http://127.0.0.1:3101` 可用。 -4. 执行 `spacetime publish genarrative-dev --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module --yes`。 -5. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`。 +4. 执行 `spacetime publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module --yes`。 +5. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。 6. 注入 `GENARRATIVE_BACKEND_STACK=rust`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 7. 任一子进程退出时,脚本回收其余子进程。 @@ -60,17 +60,25 @@ Vite 代理覆盖范围: 常用示例: ```powershell +.\scripts\dev-rust-stack.ps1 .\scripts\dev-rust-stack.ps1 -ApiPort 8090 -SpacetimePort 3110 -Database genarrative-dev .\scripts\dev-rust-stack.ps1 -SkipSpacetime -SkipPublish .\scripts\dev-rust-stack.ps1 -ClearDatabase ``` ```bash +./scripts/dev-rust-stack.sh ./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110 --database genarrative-dev ./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish ./scripts/dev-rust-stack.sh --clear-database ``` +联调排错补充: + +1. 如果首页公开广场出现 `上游服务请求失败`,优先检查 `api-server` 错误详情里的 `ws://.../v1/database//subscribe` 是否指向了未发布的库。 +2. `spacetime list --server http://127.0.0.1:3101` 应能看到 `spacetime.local.json` 中的库名;若没有,执行 `spacetime publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module --yes`。 +3. 发布库名与 `GENARRATIVE_SPACETIME_DATABASE` 不一致时,`/api/runtime/custom-world-gallery` 会从 Rust `api-server` 返回 `502`,前端首页只能展示空态或错误提示,无法自行修复。 + ## 3. Ubuntu 发布包脚本 入口: diff --git a/scripts/dev-rust-stack.ps1 b/scripts/dev-rust-stack.ps1 index fe10b6f3..1a17db4c 100644 --- a/scripts/dev-rust-stack.ps1 +++ b/scripts/dev-rust-stack.ps1 @@ -9,7 +9,7 @@ param( [string]$SpacetimeHost = "127.0.0.1", [int]$SpacetimePort = 3101, [string]$SpacetimeRootDir = "", - [string]$Database = "genarrative-dev", + [string]$Database = "", [string]$Log = "info,tower_http=info", [int]$SpacetimeStartupTimeoutSeconds = 60, [switch]$SkipSpacetime, @@ -65,6 +65,28 @@ function Resolve-ClientHost { return $HostName } +function Read-LocalSpacetimeDatabase { + param([string]$RepoRoot) + + $localConfigPath = Join-Path $RepoRoot "spacetime.local.json" + if (-not (Test-Path $localConfigPath)) { + return "" + } + + try { + $localConfig = Get-Content -Path $localConfigPath -Encoding UTF8 -Raw | ConvertFrom-Json + $database = [string]$localConfig.database + if (-not [string]::IsNullOrWhiteSpace($database)) { + return $database.Trim() + } + } + catch { + Write-Host "[dev:rust] ignore invalid spacetime.local.json: $($_.Exception.Message)" + } + + return "" +} + function Start-StackProcess { param( [string]$Name, @@ -165,6 +187,14 @@ if ([string]::IsNullOrWhiteSpace($SpacetimeRootDir)) { $SpacetimeRootDir = Join-Path $serverRsDir ".spacetimedb\local" } +if ([string]::IsNullOrWhiteSpace($Database)) { + $Database = Read-LocalSpacetimeDatabase -RepoRoot $repoRoot +} + +if ([string]::IsNullOrWhiteSpace($Database)) { + $Database = "genarrative-dev" +} + if (-not (Test-Path $manifestPath)) { throw "Missing server-rs/Cargo.toml, cannot start Rust local stack." } diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index 2cee6274..185c1931 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -99,7 +99,7 @@ WEB_PORT="3000" SPACETIME_HOST="127.0.0.1" SPACETIME_PORT="3101" SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local" -DATABASE="genarrative-dev" +DATABASE="" API_LOG="info,tower_http=info" SPACETIME_TIMEOUT_SECONDS="60" SKIP_SPACETIME=0 @@ -108,6 +108,27 @@ CLEAR_DATABASE=0 PIDS=() NAMES=() +read_local_spacetime_database() { + local config_path="${REPO_ROOT}/spacetime.local.json" + + if [[ ! -f "${config_path}" ]]; then + return + fi + + node -e ' +const fs = require("fs"); +const path = process.argv[1]; +try { + const value = JSON.parse(fs.readFileSync(path, "utf8")).database; + if (typeof value === "string" && value.trim()) { + process.stdout.write(value.trim()); + } +} catch (error) { + process.stderr.write(`[dev:rust] ignore invalid spacetime.local.json: ${error.message}\n`); +} +' "${config_path}" +} + while [[ $# -gt 0 ]]; do case "$1" in -h|--help) @@ -174,6 +195,14 @@ while [[ $# -gt 0 ]]; do esac done +if [[ -z "${DATABASE//[[:space:]]/}" ]]; then + DATABASE="$(read_local_spacetime_database)" +fi + +if [[ -z "${DATABASE//[[:space:]]/}" ]]; then + DATABASE="genarrative-dev" +fi + if [[ ! -f "${MANIFEST_PATH}" ]]; then echo "[dev:rust] 未找到 ${MANIFEST_PATH},无法启动 Rust 本地栈。" >&2 exit 1 diff --git a/server-node/sql/schema/08_sms_auth_events.sql b/server-node/sql/schema/08_sms_auth_events.sql index adaf19a6..c98af72f 100644 --- a/server-node/sql/schema/08_sms_auth_events.sql +++ b/server-node/sql/schema/08_sms_auth_events.sql @@ -6,6 +6,13 @@ CREATE TABLE IF NOT EXISTS sms_auth_events ( success BOOLEAN NOT NULL, ip TEXT, user_agent TEXT, + provider TEXT, + provider_request_id TEXT, + provider_biz_id TEXT, + provider_out_id TEXT, + delivery_status TEXT NOT NULL DEFAULT 'pending', + delivery_report_raw_json JSONB, + delivery_reported_at TEXT, created_at TEXT NOT NULL ); @@ -14,3 +21,9 @@ CREATE INDEX IF NOT EXISTS sms_auth_events_phone_created_idx CREATE INDEX IF NOT EXISTS sms_auth_events_ip_created_idx ON sms_auth_events (ip, created_at DESC); + +CREATE INDEX IF NOT EXISTS sms_auth_events_provider_biz_id_idx + ON sms_auth_events (provider_biz_id); + +CREATE INDEX IF NOT EXISTS sms_auth_events_provider_out_id_idx + ON sms_auth_events (provider_out_id); diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index de5479c0..4ffff19b 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -238,6 +238,7 @@ async function sendPhoneCode( ok: true; cooldownSeconds: number; expiresInSeconds: number; + providerRequestId: string | null; }; assert.equal(response.status, 200); @@ -1012,6 +1013,166 @@ test('phone login reuses the same account for repeated verification', async () = }); }); +test('mock sms send code records delivered tracking metadata', async () => { + await withTestServer('phone-send-code-mock-tracking', async ({ baseUrl, context }) => { + const sendResult = await sendPhoneCode(baseUrl, '13800138009'); + + assert.equal(sendResult.providerRequestId, 'mock-request-id'); + + const rows = await context.db.query<{ + provider: string | null; + provider_request_id: string | null; + provider_biz_id: string | null; + provider_out_id: string | null; + delivery_status: string; + }>( + `SELECT + provider, + provider_request_id, + provider_biz_id, + provider_out_id, + delivery_status + FROM sms_auth_events + WHERE phone_number = $1 + AND action = 'send_code' + ORDER BY created_at DESC + LIMIT 1`, + ['+8613800138009'], + ); + + assert.equal(rows.rows.length, 1); + assert.equal(rows.rows[0]?.provider, 'mock'); + assert.equal(rows.rows[0]?.provider_request_id, 'mock-request-id'); + assert.equal(rows.rows[0]?.provider_biz_id, null); + assert.equal(rows.rows[0]?.provider_out_id, 'mock-out-id'); + assert.equal(rows.rows[0]?.delivery_status, 'delivered'); + }); +}); + +test('aliyun delivery report updates sms event delivery status', async () => { + await withTestServer( + 'phone-delivery-report-aliyun', + async ({ baseUrl, context }) => { + await context.smsAuthEventRepository.create({ + phoneNumber: '+8613800138007', + scene: 'login', + action: 'send_code', + success: true, + ip: '127.0.0.1', + userAgent: 'test-agent', + provider: 'aliyun', + providerRequestId: 'aliyun-request-id', + providerBizId: 'aliyun-biz-id-report', + providerOutId: 'login_out_id_report', + deliveryStatus: 'pending', + }); + + const response = await httpRequest( + `${baseUrl}/api/auth/phone/delivery-report/aliyun`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + BizId: 'aliyun-biz-id-report', + OutId: 'login_out_id_report', + ReceiveStatus: 'SUCCESS', + }).toString(), + }, + ); + const payload = (await response.json()) as { + ok: true; + matched: boolean; + }; + + assert.equal(response.status, 200); + assert.equal(payload.ok, true); + assert.equal(payload.matched, true); + + const rows = await context.db.query<{ + delivery_status: string; + delivery_report_raw_json: { + BizId?: string; + ReceiveStatus?: string; + } | null; + delivery_reported_at: string | null; + }>( + `SELECT + delivery_status, + delivery_report_raw_json, + delivery_reported_at + FROM sms_auth_events + WHERE provider_biz_id = $1 + ORDER BY created_at DESC + LIMIT 1`, + ['aliyun-biz-id-report'], + ); + + assert.equal(rows.rows.length, 1); + assert.equal(rows.rows[0]?.delivery_status, 'delivered'); + assert.equal( + rows.rows[0]?.delivery_report_raw_json?.BizId, + 'aliyun-biz-id-report', + ); + assert.equal( + rows.rows[0]?.delivery_report_raw_json?.ReceiveStatus, + 'SUCCESS', + ); + assert.ok(rows.rows[0]?.delivery_reported_at); + }, + ); +}); + +test('aliyun-tracked send events can persist pending delivery metadata', async () => { + await withTestServer( + 'phone-send-code-aliyun-tracking', + async ({ context }) => { + await context.smsAuthEventRepository.create({ + phoneNumber: '+8613800138008', + scene: 'login', + action: 'send_code', + success: true, + ip: '127.0.0.1', + userAgent: 'test-agent', + provider: 'aliyun', + providerRequestId: 'aliyun-request-id', + providerBizId: 'aliyun-biz-id', + providerOutId: 'login_out_id_001', + deliveryStatus: 'pending', + }); + + const rows = await context.db.query<{ + provider: string | null; + provider_request_id: string | null; + provider_biz_id: string | null; + provider_out_id: string | null; + delivery_status: string; + }>( + `SELECT + provider, + provider_request_id, + provider_biz_id, + provider_out_id, + delivery_status + FROM sms_auth_events + WHERE phone_number = $1 + AND action = 'send_code' + ORDER BY created_at DESC + LIMIT 1`, + ['+8613800138008'], + ); + + assert.equal(rows.rows.length, 1); + assert.equal(rows.rows[0]?.provider, 'aliyun'); + assert.equal(rows.rows[0]?.provider_request_id, 'aliyun-request-id'); + assert.equal(rows.rows[0]?.provider_biz_id, 'aliyun-biz-id'); + assert.equal(rows.rows[0]?.provider_out_id, 'login_out_id_001'); + assert.equal(rows.rows[0]?.delivery_status, 'pending'); + }, + ); +}); + test('phone login rejects incorrect verification codes', async () => { await withTestServer('phone-login-invalid-code', async ({ baseUrl }) => { await sendPhoneCode(baseUrl, '13700137000'); diff --git a/server-node/src/auth/authService.ts b/server-node/src/auth/authService.ts index a1637088..2fd14b73 100644 --- a/server-node/src/auth/authService.ts +++ b/server-node/src/auth/authService.ts @@ -314,15 +314,29 @@ async function recordSmsAuthEvent( action: 'send_code' | 'verify_code'; success: boolean; requestContext: RefreshSessionRequestContext | null; + provider?: string | null; + providerRequestId?: string | null; + providerBizId?: string | null; + providerOutId?: string | null; + deliveryStatus?: 'pending' | 'delivered' | 'failed' | 'unknown'; + deliveryReportRawJson?: Record | null; + deliveryReportedAt?: string | null; }, ) { - await context.smsAuthEventRepository.create({ + return context.smsAuthEventRepository.create({ phoneNumber: input.phoneNumber, scene: input.scene, action: input.action, success: input.success, ip: input.requestContext?.ip ?? null, userAgent: input.requestContext?.userAgent ?? null, + provider: input.provider ?? null, + providerRequestId: input.providerRequestId ?? null, + providerBizId: input.providerBizId ?? null, + providerOutId: input.providerOutId ?? null, + deliveryStatus: input.deliveryStatus, + deliveryReportRawJson: input.deliveryReportRawJson ?? null, + deliveryReportedAt: input.deliveryReportedAt ?? null, }); } @@ -1014,13 +1028,30 @@ export async function sendPhoneLoginCode( const result = await context.smsVerificationService.sendLoginCode( normalizedPhone, ); - await recordSmsAuthEvent(context, { + const smsEvent = await recordSmsAuthEvent(context, { phoneNumber: normalizedPhone.e164, scene, action: 'send_code', success: true, requestContext, + provider: result.provider, + providerRequestId: result.providerRequestId, + providerBizId: result.providerBizId, + providerOutId: result.providerOutId, + deliveryStatus: result.deliveryStatus, }); + context.logger.info( + { + phone_suffix: normalizedPhone.nationalNumber.slice(-4), + sms_event_id: smsEvent?.id ?? null, + provider: result.provider, + provider_request_id: result.providerRequestId, + provider_biz_id: result.providerBizId, + provider_out_id: result.providerOutId, + delivery_status: result.deliveryStatus, + }, + 'sms verify code accepted by provider', + ); return { ok: true, @@ -1030,6 +1061,98 @@ export async function sendPhoneLoginCode( }; } +function normalizeAliyunDeliveryStatus(rawValue: string) { + const normalizedValue = rawValue.trim().toLowerCase(); + if (!normalizedValue) { + return 'unknown' as const; + } + + if ( + normalizedValue === 'success' || + normalizedValue === 'delivered' || + normalizedValue === 'pass' || + normalizedValue === 'ok' + ) { + return 'delivered' as const; + } + + if ( + normalizedValue === 'fail' || + normalizedValue === 'failed' || + normalizedValue === 'failure' || + normalizedValue === 'undelivered' + ) { + return 'failed' as const; + } + + return 'unknown' as const; +} + +export async function handleAliyunSmsDeliveryReport( + context: AppContext, + reportPayload: Record, +) { + const bizId = + typeof reportPayload.BizId === 'string' ? reportPayload.BizId.trim() : ''; + const outId = + typeof reportPayload.OutId === 'string' ? reportPayload.OutId.trim() : ''; + const deliveryStatus = normalizeAliyunDeliveryStatus( + typeof reportPayload.ReceiveStatus === 'string' + ? reportPayload.ReceiveStatus + : typeof reportPayload.Status === 'string' + ? reportPayload.Status + : '', + ); + + const matchedEvent = bizId + ? await context.smsAuthEventRepository.findLatestByProviderBizId(bizId) + : outId + ? await context.smsAuthEventRepository.findLatestByProviderOutId(outId) + : null; + + if (!matchedEvent) { + context.logger.warn( + { + provider: 'aliyun', + provider_biz_id: bizId || null, + provider_out_id: outId || null, + delivery_status: deliveryStatus, + report_payload: reportPayload, + }, + 'aliyun sms delivery report did not match any local event', + ); + + return { + ok: true as const, + matched: false as const, + }; + } + + const reportedAt = new Date().toISOString(); + const updatedEvent = await context.smsAuthEventRepository.updateDeliveryStatus({ + id: matchedEvent.id, + deliveryStatus, + deliveryReportRawJson: reportPayload, + deliveryReportedAt: reportedAt, + }); + + context.logger.info( + { + sms_event_id: matchedEvent.id, + provider: matchedEvent.provider, + provider_biz_id: matchedEvent.providerBizId, + provider_out_id: matchedEvent.providerOutId, + delivery_status: updatedEvent?.deliveryStatus ?? deliveryStatus, + }, + 'aliyun sms delivery report applied', + ); + + return { + ok: true as const, + matched: true as const, + }; +} + export async function entryWithPhoneCode( context: AppContext, phoneInput: string, diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts index 1611dda5..65aa7109 100644 --- a/server-node/src/db.test.ts +++ b/server-node/src/db.test.ts @@ -120,6 +120,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () = '20260417_013_custom_world_profile_soft_delete', '20260419_014_profile_save_archives', '20260419_015_runtime_settings_platform_theme', + '20260422_016_sms_auth_delivery_tracking', ], ); diff --git a/server-node/src/db/migrations.ts b/server-node/src/db/migrations.ts index db8d9da5..47717cca 100644 --- a/server-node/src/db/migrations.ts +++ b/server-node/src/db/migrations.ts @@ -351,4 +351,38 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ ADD COLUMN IF NOT EXISTS platform_theme TEXT NOT NULL DEFAULT 'light'`, ], }, + { + id: '20260422_016_sms_auth_delivery_tracking', + name: 'sms auth delivery tracking', + statements: [ + `ALTER TABLE sms_auth_events + ADD COLUMN IF NOT EXISTS provider TEXT`, + `ALTER TABLE sms_auth_events + ADD COLUMN IF NOT EXISTS provider_request_id TEXT`, + `ALTER TABLE sms_auth_events + ADD COLUMN IF NOT EXISTS provider_biz_id TEXT`, + `ALTER TABLE sms_auth_events + ADD COLUMN IF NOT EXISTS provider_out_id TEXT`, + `ALTER TABLE sms_auth_events + ADD COLUMN IF NOT EXISTS delivery_status TEXT NOT NULL DEFAULT 'pending'`, + `ALTER TABLE sms_auth_events + ADD COLUMN IF NOT EXISTS delivery_report_raw_json JSONB`, + `ALTER TABLE sms_auth_events + ADD COLUMN IF NOT EXISTS delivery_reported_at TEXT`, + `CREATE INDEX IF NOT EXISTS sms_auth_events_provider_biz_id_idx + ON sms_auth_events (provider_biz_id)`, + `CREATE INDEX IF NOT EXISTS sms_auth_events_provider_out_id_idx + ON sms_auth_events (provider_out_id)`, + `UPDATE sms_auth_events + SET provider = COALESCE(provider, 'mock'), + delivery_status = CASE + WHEN delivery_status IS NOT NULL AND delivery_status <> '' THEN delivery_status + WHEN success THEN 'delivered' + ELSE 'unknown' + END + WHERE provider IS NULL + OR delivery_status IS NULL + OR delivery_status = ''`, + ], + }, ]; diff --git a/server-node/src/repositories/smsAuthEventRepository.ts b/server-node/src/repositories/smsAuthEventRepository.ts index beb4587c..70f90699 100644 --- a/server-node/src/repositories/smsAuthEventRepository.ts +++ b/server-node/src/repositories/smsAuthEventRepository.ts @@ -6,11 +6,71 @@ import type { AppDatabase } from '../db.js'; export type SmsAuthScene = 'login' | 'bind_phone' | 'change_phone'; export type SmsAuthAction = 'send_code' | 'verify_code'; +export type SmsDeliveryStatus = 'pending' | 'delivered' | 'failed' | 'unknown'; + +export type SmsAuthEventRecord = { + id: string; + phoneNumber: string; + scene: SmsAuthScene; + action: SmsAuthAction; + success: boolean; + ip: string | null; + userAgent: string | null; + provider: string | null; + providerRequestId: string | null; + providerBizId: string | null; + providerOutId: string | null; + deliveryStatus: SmsDeliveryStatus; + deliveryReportRawJson: Record | null; + deliveryReportedAt: string | null; + createdAt: string; +}; type SmsAuthEventRow = QueryResultRow & { + id: string; + phone_number: string; + scene: SmsAuthScene; + action: SmsAuthAction; + success: boolean; + ip: string | null; + user_agent: string | null; + provider: string | null; + provider_request_id: string | null; + provider_biz_id: string | null; + provider_out_id: string | null; + delivery_status: SmsDeliveryStatus; + delivery_report_raw_json: Record | null; + delivery_reported_at: string | null; + created_at: string; total: number; }; +function toSmsAuthEventRecord( + row: SmsAuthEventRow | undefined, +): SmsAuthEventRecord | null { + if (!row) { + return null; + } + + return { + id: row.id, + phoneNumber: row.phone_number, + scene: row.scene, + action: row.action, + success: row.success, + ip: row.ip, + userAgent: row.user_agent, + provider: row.provider, + providerRequestId: row.provider_request_id, + providerBizId: row.provider_biz_id, + providerOutId: row.provider_out_id, + deliveryStatus: row.delivery_status, + deliveryReportRawJson: row.delivery_report_raw_json, + deliveryReportedAt: row.delivery_reported_at, + createdAt: row.created_at, + }; +} + export class SmsAuthEventRepository { constructor(private readonly db: AppDatabase) {} @@ -21,9 +81,17 @@ export class SmsAuthEventRepository { success: boolean; ip: string | null; userAgent: string | null; + provider?: string | null; + providerRequestId?: string | null; + providerBizId?: string | null; + providerOutId?: string | null; + deliveryStatus?: SmsDeliveryStatus; + deliveryReportRawJson?: Record | null; + deliveryReportedAt?: string | null; }) { const id = `smsev_${crypto.randomBytes(16).toString('hex')}`; - await this.db.query( + const createdAt = new Date().toISOString(); + const result = await this.db.query( `INSERT INTO sms_auth_events ( id, phone_number, @@ -32,9 +100,34 @@ export class SmsAuthEventRepository { success, ip, user_agent, + provider, + provider_request_id, + provider_biz_id, + provider_out_id, + delivery_status, + delivery_report_raw_json, + delivery_reported_at, created_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + ) + RETURNING + id, + phone_number, + scene, + action, + success, + ip, + user_agent, + provider, + provider_request_id, + provider_biz_id, + provider_out_id, + delivery_status, + delivery_report_raw_json, + delivery_reported_at, + created_at`, [ id, input.phoneNumber, @@ -43,9 +136,18 @@ export class SmsAuthEventRepository { input.success, input.ip, input.userAgent, - new Date().toISOString(), + input.provider ?? null, + input.providerRequestId ?? null, + input.providerBizId ?? null, + input.providerOutId ?? null, + input.deliveryStatus ?? 'pending', + input.deliveryReportRawJson ?? null, + input.deliveryReportedAt ?? null, + createdAt, ], ); + + return toSmsAuthEventRecord(result.rows[0]); } async countSinceByPhone(params: { @@ -99,4 +201,102 @@ export class SmsAuthEventRepository { return result.rows[0]?.total ?? 0; } + + async findLatestByProviderBizId(providerBizId: string) { + const result = await this.db.query( + `SELECT + id, + phone_number, + scene, + action, + success, + ip, + user_agent, + provider, + provider_request_id, + provider_biz_id, + provider_out_id, + delivery_status, + delivery_report_raw_json, + delivery_reported_at, + created_at, + 0::int AS total + FROM sms_auth_events + WHERE provider_biz_id = $1 + ORDER BY created_at DESC + LIMIT 1`, + [providerBizId], + ); + + return toSmsAuthEventRecord(result.rows[0]); + } + + async findLatestByProviderOutId(providerOutId: string) { + const result = await this.db.query( + `SELECT + id, + phone_number, + scene, + action, + success, + ip, + user_agent, + provider, + provider_request_id, + provider_biz_id, + provider_out_id, + delivery_status, + delivery_report_raw_json, + delivery_reported_at, + created_at, + 0::int AS total + FROM sms_auth_events + WHERE provider_out_id = $1 + ORDER BY created_at DESC + LIMIT 1`, + [providerOutId], + ); + + return toSmsAuthEventRecord(result.rows[0]); + } + + async updateDeliveryStatus(params: { + id: string; + deliveryStatus: SmsDeliveryStatus; + deliveryReportRawJson: Record | null; + deliveryReportedAt: string; + }) { + const result = await this.db.query( + `UPDATE sms_auth_events + SET delivery_status = $1, + delivery_report_raw_json = $2, + delivery_reported_at = $3 + WHERE id = $4 + RETURNING + id, + phone_number, + scene, + action, + success, + ip, + user_agent, + provider, + provider_request_id, + provider_biz_id, + provider_out_id, + delivery_status, + delivery_report_raw_json, + delivery_reported_at, + created_at, + 0::int AS total`, + [ + params.deliveryStatus, + params.deliveryReportRawJson, + params.deliveryReportedAt, + params.id, + ], + ); + + return toSmsAuthEventRecord(result.rows[0]); + } } diff --git a/server-node/src/routes/authRoutes.ts b/server-node/src/routes/authRoutes.ts index 025d6ca8..8e2f6cd3 100644 --- a/server-node/src/routes/authRoutes.ts +++ b/server-node/src/routes/authRoutes.ts @@ -1,4 +1,4 @@ -import { type Request, type Response, Router } from 'express'; +import express, { type Request, type Response, Router } from 'express'; import { z } from 'zod'; import type { @@ -28,6 +28,7 @@ import { revokeRefreshSession, revokeUserSession, sendPhoneLoginCode, + handleAliyunSmsDeliveryReport, startWechatLogin, } from '../auth/authService.js'; import { @@ -116,6 +117,8 @@ export function createAuthRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); + router.use('/phone/delivery-report/aliyun', express.urlencoded({ extended: false })); + router.get( '/login-options', routeMeta({ operation: 'auth.login_options' }), @@ -178,6 +181,22 @@ export function createAuthRoutes(context: AppContext) { }), ); + router.post( + '/phone/delivery-report/aliyun', + routeMeta({ operation: 'auth.phone.delivery_report.aliyun' }), + asyncHandler(async (request, response) => { + const payload = + request.body && typeof request.body === 'object' + ? (request.body as Record) + : {}; + + sendApiResponse( + response, + await handleAliyunSmsDeliveryReport(context, payload), + ); + }), + ); + router.post( '/phone/change', routeMeta({ operation: 'auth.phone.change' }), diff --git a/server-node/src/services/smsVerificationService.test.ts b/server-node/src/services/smsVerificationService.test.ts index 6d9280da..84b0d39e 100644 --- a/server-node/src/services/smsVerificationService.test.ts +++ b/server-node/src/services/smsVerificationService.test.ts @@ -51,3 +51,26 @@ test('createSmsVerificationService initializes aliyun sdk client under tsx esm r assert.equal(typeof service.sendLoginCode, 'function'); assert.equal(typeof service.verifyLoginCode, 'function'); }); + +test('mock sms service reports delivered tracking metadata', async () => { + const config = createAliyunSmsConfig(); + config.smsAuth.provider = 'mock'; + config.smsAuth.accessKeyId = ''; + config.smsAuth.accessKeySecret = ''; + + const service = createSmsVerificationService( + config, + pino({ enabled: false }), + ); + + const result = await service.sendLoginCode({ + e164: '+8613800138000', + nationalNumber: '13800138000', + maskedNationalNumber: '138****8000', + }); + + assert.equal(result.provider, 'mock'); + assert.equal(result.deliveryStatus, 'delivered'); + assert.equal(result.providerRequestId, 'mock-request-id'); + assert.equal(result.providerOutId, 'mock-out-id'); +}); diff --git a/server-node/src/services/smsVerificationService.ts b/server-node/src/services/smsVerificationService.ts index c1b01e9c..8bca9393 100644 --- a/server-node/src/services/smsVerificationService.ts +++ b/server-node/src/services/smsVerificationService.ts @@ -19,6 +19,10 @@ export type SendLoginCodeResult = { cooldownSeconds: number; expiresInSeconds: number; providerRequestId: string | null; + providerBizId: string | null; + providerOutId: string | null; + provider: 'aliyun' | 'mock'; + deliveryStatus: 'pending' | 'delivered'; }; export type SmsVerificationService = { @@ -131,6 +135,10 @@ class AliyunSmsVerificationService implements SmsVerificationService { cooldownSeconds: this.config.intervalSeconds, expiresInSeconds: this.config.validTimeSeconds, providerRequestId: body.requestId ?? body.model?.requestId ?? null, + providerBizId: body.model?.bizId ?? null, + providerOutId: body.model?.outId ?? null, + provider: 'aliyun', + deliveryStatus: 'pending', } satisfies SendLoginCodeResult; } catch (error) { if (error instanceof Error && error.name === 'HttpError') { @@ -240,6 +248,10 @@ class MockSmsVerificationService implements SmsVerificationService { cooldownSeconds: this.config.intervalSeconds, expiresInSeconds: this.config.validTimeSeconds, providerRequestId: 'mock-request-id', + providerBizId: null, + providerOutId: 'mock-out-id', + provider: 'mock', + deliveryStatus: 'delivered', } satisfies SendLoginCodeResult; } diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index d15d63f3..fa44113e 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -9,8 +9,7 @@ use axum::{ response::Response, }; use platform_auth::{ - AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, - verify_access_token, + AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, verify_access_token, }; use serde_json::{Value, json}; use time::OffsetDateTime; @@ -237,11 +236,11 @@ mod tests { INTERNAL_API_SECRET_HEADER, INTERNAL_AUTH_USER_ID_HEADER, RefreshSessionToken, extract_bearer_token, try_build_internal_forwarded_claims, }; + use crate::{config::AppConfig, state::AppState}; use axum::{ http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION}, response::IntoResponse, }; - use crate::{config::AppConfig, state::AppState}; #[test] fn extract_bearer_token_accepts_standard_header() { diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 5688162b..cdab1d9f 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -9,8 +9,8 @@ use shared_contracts::big_fish::{ BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse, BigFishAnchorPackResponse, BigFishAssetCoverageResponse, BigFishAssetSlotResponse, BigFishBackgroundBlueprintResponse, BigFishGameDraftResponse, BigFishLevelBlueprintResponse, - BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse, BigFishRuntimeSnapshotResponse, - BigFishRunResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse, + BigFishRunResponse, BigFishRuntimeEntityResponse, BigFishRuntimeParamsResponse, + BigFishRuntimeSnapshotResponse, BigFishSessionResponse, BigFishSessionSnapshotResponse, BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest, SendBigFishMessageRequest, SubmitBigFishInputRequest, }; @@ -58,7 +58,9 @@ pub async fn create_big_fish_session( created_at_micros: current_utc_micros(), }) .await - .map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?; + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -80,7 +82,9 @@ pub async fn get_big_fish_session( .spacetime_client() .get_big_fish_session(session_id, authenticated.claims().user_id().to_string()) .await - .map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?; + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -128,7 +132,9 @@ pub async fn submit_big_fish_message( submitted_at_micros: current_utc_micros(), }) .await - .map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?; + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -168,7 +174,9 @@ pub async fn stream_big_fish_message( submitted_at_micros: current_utc_micros(), }) .await - .map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?; + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; let session_response = map_big_fish_session_response(session); let reply_text = session_response @@ -176,9 +184,24 @@ pub async fn stream_big_fish_message( .clone() .unwrap_or_else(|| "锚点已更新。".to_string()); let mut sse_body = String::new(); - append_sse_event(&request_context, &mut sse_body, "reply_delta", &json!({ "text": reply_text }))?; - append_sse_event(&request_context, &mut sse_body, "session", &json!({ "session": session_response }))?; - append_sse_event(&request_context, &mut sse_body, "done", &json!({ "ok": true }))?; + append_sse_event( + &request_context, + &mut sse_body, + "reply_delta", + &json!({ "text": reply_text }), + )?; + append_sse_event( + &request_context, + &mut sse_body, + "session", + &json!({ "session": session_response }), + )?; + append_sse_event( + &request_context, + &mut sse_body, + "done", + &json!({ "ok": true }), + )?; Ok(build_event_stream_response(sse_body)) } @@ -288,7 +311,9 @@ pub async fn start_big_fish_run( started_at_micros: current_utc_micros(), }) .await - .map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?; + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -310,7 +335,9 @@ pub async fn get_big_fish_run( .spacetime_client() .get_big_fish_run(run_id, authenticated.claims().user_id().to_string()) .await - .map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?; + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -348,7 +375,9 @@ pub async fn submit_big_fish_input( submitted_at_micros: current_utc_micros(), }) .await - .map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?; + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; Ok(json_success_body( Some(&request_context), @@ -383,7 +412,9 @@ fn map_big_fish_session_response(session: BigFishSessionRecord) -> BigFishSessio } } -fn map_big_fish_anchor_pack_response(anchor_pack: BigFishAnchorPackRecord) -> BigFishAnchorPackResponse { +fn map_big_fish_anchor_pack_response( + anchor_pack: BigFishAnchorPackRecord, +) -> BigFishAnchorPackResponse { BigFishAnchorPackResponse { gameplay_promise: map_big_fish_anchor_item_response(anchor_pack.gameplay_promise), ecology_visual_theme: map_big_fish_anchor_item_response(anchor_pack.ecology_visual_theme), @@ -417,7 +448,9 @@ fn map_big_fish_draft_response(draft: BigFishGameDraftRecord) -> BigFishGameDraf } } -fn map_big_fish_level_response(level: BigFishLevelBlueprintRecord) -> BigFishLevelBlueprintResponse { +fn map_big_fish_level_response( + level: BigFishLevelBlueprintRecord, +) -> BigFishLevelBlueprintResponse { BigFishLevelBlueprintResponse { level: level.level, name: level.name, @@ -528,7 +561,9 @@ fn map_big_fish_runtime_response(run: BigFishRuntimeRecord) -> BigFishRuntimeSna } } -fn map_big_fish_entity_response(entity: BigFishRuntimeEntityRecord) -> BigFishRuntimeEntityResponse { +fn map_big_fish_entity_response( + entity: BigFishRuntimeEntityRecord, +) -> BigFishRuntimeEntityResponse { BigFishRuntimeEntityResponse { entity_id: entity.entity_id, level: entity.level, @@ -547,7 +582,8 @@ fn map_big_fish_vector_response(vector: BigFishVector2Record) -> BigFishVector2R fn build_big_fish_welcome_text(seed_text: &str) -> String { if seed_text.trim().is_empty() { - return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。".to_string(); + return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。" + .to_string(); } "我已经收到你的玩法起点,会先把它整理成锚点并准备结果页草稿。".to_string() } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index d0020ee1..b908f590 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1,4 +1,4 @@ -use std::{env, net::SocketAddr}; +use std::{env, fs, net::SocketAddr, path::PathBuf}; use platform_llm::{ DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, @@ -7,6 +7,7 @@ use platform_llm::{ const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715"; const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge"; +const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json"; // 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。 #[derive(Clone, Debug)] @@ -123,6 +124,10 @@ impl AppConfig { pub fn from_env() -> Self { let mut config = Self::default(); + if let Some(local_spacetime_database) = read_local_spacetime_database() { + config.spacetime_database = local_spacetime_database; + } + if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST") && !bind_host.trim().is_empty() { @@ -141,8 +146,7 @@ impl AppConfig { config.log_filter = log_filter; } - config.internal_api_secret = - read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]); + config.internal_api_secret = read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]); if let Some(jwt_issuer) = read_first_non_empty_env(&["GENARRATIVE_JWT_ISSUER", "JWT_ISSUER"]) @@ -361,6 +365,33 @@ fn read_first_non_empty_env(keys: &[&str]) -> Option { }) } +fn read_local_spacetime_database() -> Option { + let config_path = find_upward_file(SPACETIME_LOCAL_CONFIG_FILE)?; + let raw_text = fs::read_to_string(config_path).ok()?; + let parsed = serde_json::from_str::(&raw_text).ok()?; + parsed + .get("database") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn find_upward_file(file_name: &str) -> Option { + let mut current_dir = env::current_dir().ok()?; + + loop { + let candidate = current_dir.join(file_name); + if candidate.is_file() { + return Some(candidate); + } + + if !current_dir.pop() { + return None; + } + } +} + fn read_first_duration_seconds_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 4b252d6d..18cd97cd 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -30,15 +30,16 @@ use shared_contracts::{ SwapPuzzlePiecesRequest, }, puzzle_works::{ - PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, PuzzleWorkProfileResponse, - PuzzleWorkSummaryResponse, PuzzleWorksResponse, PutPuzzleWorkRequest, + PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, + PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse, }, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ - PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, - PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, - PuzzleAnchorPackRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, + PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, + PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, + PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, + PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, @@ -47,11 +48,8 @@ use spacetime_client::{ }; use crate::{ - api_response::json_success_body, - auth::AuthenticatedAccessToken, - http_error::AppError, - request_context::RequestContext, - state::AppState, + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, }; const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent"; @@ -110,14 +108,16 @@ pub async fn get_puzzle_agent_session( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { - ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; let session = state .spacetime_client() - .get_puzzle_agent_session( - session_id, - authenticated.claims().user_id().to_string(), - ) + .get_puzzle_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( @@ -152,7 +152,12 @@ pub async fn submit_puzzle_agent_message( })), ) })?; - ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; let client_message_id = payload.client_message_id.trim().to_string(); let message_text = payload.text.trim().to_string(); @@ -207,7 +212,12 @@ pub async fn stream_puzzle_agent_message( })), ) })?; - ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; let session = state .spacetime_client() @@ -245,7 +255,12 @@ pub async fn stream_puzzle_agent_message( "session", &json!({ "session": session_response }), )?; - append_sse_event(&request_context, &mut sse_body, "done", &json!({ "ok": true }))?; + append_sse_event( + &request_context, + &mut sse_body, + "done", + &json!({ "ok": true }), + )?; Ok(build_event_stream_response(sse_body)) } @@ -266,7 +281,12 @@ pub async fn execute_puzzle_agent_action( })), ) })?; - ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; let owner_user_id = authenticated.claims().user_id().to_string(); let now = current_utc_micros(); @@ -327,11 +347,11 @@ pub async fn execute_puzzle_agent_action( }) .collect::>(), ) - .map_err(|error| { - SpacetimeClientError::Runtime(format!( - "拼图候选图序列化失败:{error}" - )) - }); + .map_err(|error| { + SpacetimeClientError::Runtime(format!( + "拼图候选图序列化失败:{error}" + )) + }); match candidates_json { Ok(candidates_json) => { state @@ -497,7 +517,12 @@ pub async fn get_puzzle_work_detail( Extension(request_context): Extension, Extension(_authenticated): Extension, ) -> Result, Response> { - ensure_non_empty(&request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId")?; + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; let item = state .spacetime_client() @@ -536,7 +561,12 @@ pub async fn put_puzzle_work( })), ) })?; - ensure_non_empty(&request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId")?; + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; let item = state .spacetime_client() @@ -599,7 +629,12 @@ pub async fn get_puzzle_gallery_detail( AxumPath(profile_id): AxumPath, Extension(request_context): Extension, ) -> Result, Response> { - ensure_non_empty(&request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId")?; + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; let item = state .spacetime_client() @@ -637,7 +672,12 @@ pub async fn start_puzzle_run( })), ) })?; - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &payload.profile_id, "profileId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.profile_id, + "profileId", + )?; let run = state .spacetime_client() @@ -767,7 +807,12 @@ pub async fn drag_puzzle_piece_or_group( ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &payload.piece_id, "pieceId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.piece_id, + "pieceId", + )?; let run = state .spacetime_client() @@ -850,12 +895,16 @@ fn map_puzzle_agent_session_response( .into_iter() .map(map_puzzle_suggested_action_response) .collect(), - result_preview: session.result_preview.map(map_puzzle_result_preview_response), + result_preview: session + .result_preview + .map(map_puzzle_result_preview_response), updated_at: session.updated_at, } } -fn map_puzzle_anchor_pack_response(anchor_pack: PuzzleAnchorPackRecord) -> PuzzleAnchorPackResponse { +fn map_puzzle_anchor_pack_response( + anchor_pack: PuzzleAnchorPackRecord, +) -> PuzzleAnchorPackResponse { PuzzleAnchorPackResponse { theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise), visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject), @@ -1098,8 +1147,7 @@ fn resolve_author_display_name( fn build_puzzle_welcome_text(seed_text: &str) -> String { if seed_text.trim().is_empty() { - return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。" - .to_string(); + return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。".to_string(); } "我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string() @@ -1124,11 +1172,7 @@ fn ensure_non_empty( Ok(()) } -fn puzzle_bad_request( - request_context: &RequestContext, - provider: &str, - message: &str, -) -> Response { +fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: &str) -> Response { puzzle_error_response( request_context, provider, @@ -1279,7 +1323,11 @@ fn save_placeholder_puzzle_asset( fs::write(output_dir.join(&file_name), svg).map_err(io_error)?; Ok(GeneratedPuzzleAssetResponse { - image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name), + image_src: format!( + "/{}/{}", + relative_dir.to_string_lossy().replace('\\', "/"), + file_name + ), asset_id, }) } diff --git a/server-rs/crates/api-server/src/runtime_story/compat.rs b/server-rs/crates/api-server/src/runtime_story/compat.rs index ed573aed..ccd72d41 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat.rs @@ -11,35 +11,27 @@ use module_npc::{ use module_runtime::RuntimeSnapshotRecord; use module_runtime_story_compat::{ CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload, - PendingQuestOfferContext, RuntimeStoryActionResponseParts, - StoryResolution, add_player_currency, add_player_inventory_items, - append_story_history, apply_equipment_loadout_to_state, - battle_mode_text, build_battle_runtime_story_options, build_current_build_toast, - build_status_patch, - build_npc_gift_result_text, - build_runtime_story_view_model, + PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution, + add_player_currency, add_player_inventory_items, append_story_history, + apply_equipment_loadout_to_state, battle_mode_text, build_battle_runtime_story_options, + build_current_build_toast, build_disabled_runtime_story_option, build_npc_gift_result_text, + build_runtime_story_option_from_story_option, build_runtime_story_view_model, + build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option, clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity, current_encounter_id, current_encounter_name, current_world_type, ensure_inventory_action_available, ensure_json_object, equipment_slot_label, - find_player_inventory_entry, - format_now_rfc3339, grant_player_progression_experience, has_giftable_player_inventory, - format_currency_text, - increment_runtime_stat, normalize_equipped_item, - normalize_equipment_slot_id, normalize_required_string, npc_buyback_price, - npc_purchase_price, read_array_field, read_bool_field, read_field, read_i32_field, - read_inventory_item_name, read_object_field, read_optional_string_field, + find_player_inventory_entry, format_currency_text, format_now_rfc3339, + grant_player_progression_experience, has_giftable_player_inventory, increment_runtime_stat, + normalize_equipment_slot_id, normalize_equipped_item, normalize_required_string, + npc_buyback_price, npc_purchase_price, read_array_field, read_bool_field, read_field, + read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field, read_player_equipment_item, read_required_string_field, read_runtime_session_id, - read_u32_field, recruit_companion_to_party, remove_player_inventory_item, - restore_player_resource, - resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item, - resolve_forge_craft_action, - resolve_forge_dismantle_action, resolve_forge_reforge_action, - resolve_npc_gift_affinity_gain, simple_story_resolution, trade_quantity_suffix, - resolve_current_encounter_npc_state, - build_disabled_runtime_story_option, build_runtime_story_option_from_story_option, - build_static_runtime_story_option, build_story_option_from_runtime_option, - write_bool_field, write_i32_field, write_null_field, write_player_equipment_item, - write_string_field, write_u32_field, + read_u32_field, recruit_companion_to_party, remove_player_inventory_item, resolve_action_text, + resolve_battle_action, resolve_current_encounter_npc_state, resolve_equipment_slot_for_item, + resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action, + resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution, + trade_quantity_suffix, write_bool_field, write_i32_field, write_null_field, + write_player_equipment_item, write_string_field, write_u32_field, }; use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde_json::{Map, Value, json}; diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 52719ffd..9e864ba5 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -19,9 +19,9 @@ use module_big_fish::{ BigFishAgentMessageKind, BigFishAgentMessageRole, BigFishAgentMessageSnapshot, BigFishAssetGenerateInput, BigFishAssetKind, BigFishAssetSlotSnapshot, BigFishAssetStatus, BigFishCreationStage, BigFishDraftCompileInput, BigFishMessageSubmitInput, BigFishPublishInput, - BigFishRunGetInput, BigFishRunInputSubmitInput, BigFishRunProcedureResult, BigFishRunStartInput, - BigFishRunStatus, BigFishRuntimeSnapshot, BigFishSessionCreateInput, BigFishSessionGetInput, - BigFishSessionProcedureResult, BigFishSessionSnapshot, + BigFishRunGetInput, BigFishRunInputSubmitInput, BigFishRunProcedureResult, + BigFishRunStartInput, BigFishRunStatus, BigFishRuntimeSnapshot, BigFishSessionCreateInput, + BigFishSessionGetInput, BigFishSessionProcedureResult, BigFishSessionSnapshot, advance_runtime_snapshot, build_asset_coverage, build_generated_asset_slot, build_initial_runtime_snapshot, compile_default_draft, deserialize_anchor_pack, deserialize_draft, deserialize_runtime_snapshot, empty_anchor_pack, infer_anchor_pack, @@ -41,29 +41,29 @@ use module_combat::{ use module_custom_world::{ CustomWorldAgentActionExecuteInput, CustomWorldAgentActionExecuteResult, CustomWorldAgentCardDetailGetInput, CustomWorldAgentMessageSnapshot, - CustomWorldAgentMessageSubmitInput, - CustomWorldAgentOperationGetInput, CustomWorldAgentOperationProcedureResult, - CustomWorldAgentOperationSnapshot, CustomWorldAgentSessionCreateInput, - CustomWorldAgentSessionGetInput, CustomWorldAgentSessionProcedureResult, - CustomWorldAgentSessionSnapshot, CustomWorldDraftCardDetailResult, - CustomWorldDraftCardDetailSectionSnapshot, CustomWorldDraftCardDetailSnapshot, - CustomWorldDraftCardSnapshot, CustomWorldPublishBlockerSnapshot, - CustomWorldPublishGateSnapshot, CustomWorldWorkSummarySnapshot, CustomWorldWorksListInput, - CustomWorldWorksListResult, - CustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot, - CustomWorldGalleryListResult, CustomWorldGenerationMode, CustomWorldLibraryDetailInput, - CustomWorldLibraryMutationResult, CustomWorldProfileListInput, - CustomWorldProfileListResult, CustomWorldProfilePublishInput, CustomWorldProfileSnapshot, - CustomWorldProfileUnpublishInput, CustomWorldProfileUpsertInput, - CustomWorldPublicationStatus, CustomWorldPublishWorldInput, CustomWorldPublishWorldResult, + CustomWorldAgentMessageSubmitInput, CustomWorldAgentOperationGetInput, + CustomWorldAgentOperationProcedureResult, CustomWorldAgentOperationSnapshot, + CustomWorldAgentSessionCreateInput, CustomWorldAgentSessionGetInput, + CustomWorldAgentSessionProcedureResult, CustomWorldAgentSessionSnapshot, + CustomWorldDraftCardDetailResult, CustomWorldDraftCardDetailSectionSnapshot, + CustomWorldDraftCardDetailSnapshot, CustomWorldDraftCardSnapshot, + CustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot, CustomWorldGalleryListResult, + CustomWorldGenerationMode, CustomWorldLibraryDetailInput, CustomWorldLibraryMutationResult, + CustomWorldProfileListInput, CustomWorldProfileListResult, CustomWorldProfilePublishInput, + CustomWorldProfileSnapshot, CustomWorldProfileUnpublishInput, CustomWorldProfileUpsertInput, + CustomWorldPublicationStatus, CustomWorldPublishBlockerSnapshot, + CustomWorldPublishGateSnapshot, CustomWorldPublishWorldInput, CustomWorldPublishWorldResult, CustomWorldPublishedProfileCompileInput, CustomWorldPublishedProfileCompileResult, CustomWorldRoleAssetStatus, CustomWorldSessionStatus, CustomWorldThemeMode, + CustomWorldWorkSummarySnapshot, CustomWorldWorksListInput, CustomWorldWorksListResult, RpgAgentDraftCardKind, RpgAgentDraftCardStatus, RpgAgentMessageKind, RpgAgentMessageRole, RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage, - build_custom_world_published_profile_compile_snapshot, validate_custom_world_agent_message_submit_input, + build_custom_world_published_profile_compile_snapshot, validate_custom_world_agent_action_execute_input, validate_custom_world_agent_card_detail_get_input, - validate_custom_world_agent_operation_get_input, validate_custom_world_agent_session_create_input, + validate_custom_world_agent_message_submit_input, + validate_custom_world_agent_operation_get_input, + validate_custom_world_agent_session_create_input, validate_custom_world_agent_session_get_input, validate_custom_world_gallery_detail_input, validate_custom_world_library_detail_input, validate_custom_world_profile_list_input, validate_custom_world_profile_publish_input, validate_custom_world_profile_unpublish_input, @@ -105,26 +105,24 @@ use module_quest::{ }; use module_runtime::{ DEFAULT_MUSIC_VOLUME, DEFAULT_PLATFORM_THEME, DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT, - PROFILE_WALLET_LEDGER_LIST_LIMIT, SAVE_SNAPSHOT_VERSION, - RuntimeBrowseHistoryClearInput, RuntimeBrowseHistoryListInput, - RuntimeBrowseHistoryProcedureResult, RuntimeBrowseHistorySnapshot, - RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryThemeMode, RuntimePlatformTheme, - RuntimeProfileSaveArchiveListInput, RuntimeProfileSaveArchiveProcedureResult, - RuntimeProfileSaveArchiveResumeInput, RuntimeProfileSaveArchiveSnapshot, - RuntimeProfileDashboardGetInput, RuntimeProfileDashboardProcedureResult, + PROFILE_WALLET_LEDGER_LIST_LIMIT, RuntimeBrowseHistoryClearInput, + RuntimeBrowseHistoryListInput, RuntimeBrowseHistoryProcedureResult, + RuntimeBrowseHistorySnapshot, RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryThemeMode, + RuntimePlatformTheme, RuntimeProfileDashboardGetInput, RuntimeProfileDashboardProcedureResult, RuntimeProfileDashboardSnapshot, RuntimeProfilePlayStatsGetInput, RuntimeProfilePlayStatsProcedureResult, RuntimeProfilePlayStatsSnapshot, - RuntimeProfilePlayedWorldSnapshot, RuntimeProfileWalletLedgerEntrySnapshot, + RuntimeProfilePlayedWorldSnapshot, RuntimeProfileSaveArchiveListInput, + RuntimeProfileSaveArchiveProcedureResult, RuntimeProfileSaveArchiveResumeInput, + RuntimeProfileSaveArchiveSnapshot, RuntimeProfileWalletLedgerEntrySnapshot, RuntimeProfileWalletLedgerListInput, RuntimeProfileWalletLedgerProcedureResult, - RuntimeProfileWalletLedgerSourceType, RuntimeSettingGetInput, - RuntimeSettingProcedureResult, RuntimeSettingSnapshot, RuntimeSettingUpsertInput, - RuntimeSnapshot, RuntimeSnapshotDeleteInput, RuntimeSnapshotGetInput, - RuntimeSnapshotProcedureResult, RuntimeSnapshotUpsertInput, - build_runtime_browse_history_clear_input, + RuntimeProfileWalletLedgerSourceType, RuntimeSettingGetInput, RuntimeSettingProcedureResult, + RuntimeSettingSnapshot, RuntimeSettingUpsertInput, RuntimeSnapshot, RuntimeSnapshotDeleteInput, + RuntimeSnapshotGetInput, RuntimeSnapshotProcedureResult, RuntimeSnapshotUpsertInput, + SAVE_SNAPSHOT_VERSION, build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, build_runtime_profile_dashboard_get_input, - build_runtime_profile_play_stats_get_input, build_runtime_profile_wallet_ledger_list_input, - build_runtime_profile_save_archive_list_input, - build_runtime_profile_save_archive_resume_input, build_runtime_setting_get_input, + build_runtime_profile_play_stats_get_input, build_runtime_profile_save_archive_list_input, + build_runtime_profile_save_archive_resume_input, + build_runtime_profile_wallet_ledger_list_input, build_runtime_setting_get_input, build_runtime_setting_upsert_input, build_runtime_snapshot_delete_input, build_runtime_snapshot_get_input, build_runtime_snapshot_upsert_input, prepare_runtime_browse_history_entries, @@ -142,13 +140,29 @@ use module_story::{ build_story_session_snapshot, build_story_started_event, validate_story_continue_input, validate_story_session_input, validate_story_session_state_input, }; -use shared_kernel::format_timestamp_micros; use serde_json::{Map as JsonMap, Value as JsonValue, json}; +use shared_kernel::format_timestamp_micros; use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; mod puzzle; // 这层输入只服务 NPC 开战编排;普通聊天、援手、招募继续走已有 resolve_npc_interaction 接口。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ResolveNpcBattleInteractionInput { + pub npc_interaction: ResolveNpcInteractionInput, + pub story_session_id: String, + pub actor_user_id: String, + pub battle_state_id: Option, + pub player_hp: i32, + pub player_max_hp: i32, + pub player_mana: i32, + pub player_max_mana: i32, + pub target_hp: i32, + pub target_max_hp: i32, + pub experience_reward: u32, + pub reward_items: Vec, +} + // 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct NpcBattleInteractionResult { @@ -2023,8 +2037,9 @@ fn submit_big_fish_message_tx( }); let anchor_pack = infer_anchor_pack(&session.seed_text, Some(&input.user_message_text)); - let assistant_text = "我已经把这版方向收束成 4 个高杠杆锚点,可以继续细化,也可以直接编译第一版玩法草稿。" - .to_string(); + let assistant_text = + "我已经把这版方向收束成 4 个高杠杆锚点,可以继续细化,也可以直接编译第一版玩法草稿。" + .to_string(); ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { message_id: input.assistant_message_id, session_id: input.session_id.clone(), @@ -2208,9 +2223,15 @@ fn publish_big_fish_game_tx( .as_deref() .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; - let coverage = build_asset_coverage(Some(&draft), &list_big_fish_asset_slots(ctx, &session.session_id)); + let coverage = build_asset_coverage( + Some(&draft), + &list_big_fish_asset_slots(ctx, &session.session_id), + ); if !coverage.publish_ready { - return Err(format!("big_fish 发布校验未通过:{}", coverage.blockers.join(";"))); + return Err(format!( + "big_fish 发布校验未通过:{}", + coverage.blockers.join(";") + )); } let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); @@ -4217,18 +4238,16 @@ fn list_custom_world_work_snapshots( let mut items = Vec::new(); - for session in ctx - .db - .custom_world_agent_session() - .iter() - .filter(|row| row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published) - { + for session in ctx.db.custom_world_agent_session().iter().filter(|row| { + row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published + }) { let gate = build_custom_world_publish_gate_from_session(&session); let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); let title = resolve_session_work_title(&session, draft_profile.as_ref()); let summary = resolve_session_work_summary(&session, draft_profile.as_ref()); let stage_label = Some(resolve_rpg_agent_stage_label(session.stage).to_string()); - let subtitle = resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref()); + let subtitle = + resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref()); let (playable_npc_count, landmark_count) = resolve_session_work_counts(ctx, &session, draft_profile.as_ref()); @@ -4301,8 +4320,16 @@ fn list_custom_world_work_snapshots( .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| { - let left_rank = if left.source_type == "agent_session" { 0 } else { 1 }; - let right_rank = if right.source_type == "agent_session" { 0 } else { 1 }; + let left_rank = if left.source_type == "agent_session" { + 0 + } else { + 1 + }; + let right_rank = if right.source_type == "agent_session" { + 0 + } else { + 1 + }; left_rank.cmp(&right_rank) }) .then(left.work_id.cmp(&right.work_id)) @@ -4363,7 +4390,9 @@ fn execute_custom_world_agent_action_tx( match input.action.trim() { "draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload), "update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload), - "sync_result_profile" => execute_sync_result_profile_action(ctx, &session, &input, &payload), + "sync_result_profile" => { + execute_sync_result_profile_action(ctx, &session, &input, &payload) + } "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), "generate_characters" @@ -4388,18 +4417,19 @@ fn execute_draft_foundation_action( } let updated_at = input.submitted_at_micros; - let draft_profile = if let Some(profile) = payload.get("draftProfile").and_then(JsonValue::as_object) { - profile.clone() - } else if let Some(existing) = parse_optional_session_object(session.draft_profile_json.as_deref()) { - ensure_minimal_draft_profile(existing, &session.seed_text) - } else { - build_minimal_draft_profile_from_seed(&session.seed_text) - }; + let draft_profile = + if let Some(profile) = payload.get("draftProfile").and_then(JsonValue::as_object) { + profile.clone() + } else if let Some(existing) = + parse_optional_session_object(session.draft_profile_json.as_deref()) + { + ensure_minimal_draft_profile(existing, &session.seed_text) + } else { + build_minimal_draft_profile_from_seed(&session.seed_text) + }; - let draft_profile_json = - serde_json::to_string(&JsonValue::Object(draft_profile.clone())).map_err(|error| { - format!("draft_foundation 无法序列化 draft_profile_json: {error}") - })?; + let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone())) + .map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?; let gate = summarize_publish_gate_from_json( &input.session_id, RpgAgentStage::ObjectRefining, @@ -4412,8 +4442,12 @@ fn execute_draft_foundation_action( progress_percent: Some(100), stage: Some(RpgAgentStage::ObjectRefining), draft_profile_json: Some(Some(draft_profile_json.clone())), - last_assistant_reply: Some(Some("世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string())), - publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), + last_assistant_reply: Some(Some( + "世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string(), + )), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( + &gate, + ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, @@ -4460,7 +4494,8 @@ fn execute_update_draft_card_action( ) -> Result { ensure_refining_stage(session.stage, "update_draft_card")?; - let card_id = read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; + let card_id = + read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; let card = ctx .db .custom_world_draft_card() @@ -4476,7 +4511,8 @@ fn execute_update_draft_card_action( return Err("update_draft_card requires sections".to_string()); } - let mut detail_object = parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); + let mut detail_object = + parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); let mut detail_sections = detail_object .get("sections") .and_then(JsonValue::as_array) @@ -4520,27 +4556,36 @@ fn execute_update_draft_card_action( } detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone())); - detail_object.insert("kind".to_string(), JsonValue::String(card.kind.as_str().to_string())); + detail_object.insert( + "kind".to_string(), + JsonValue::String(card.kind.as_str().to_string()), + ); detail_object.insert("title".to_string(), JsonValue::String(card.title.clone())); - detail_object.insert("sections".to_string(), JsonValue::Array(detail_sections.clone())); + detail_object.insert( + "sections".to_string(), + JsonValue::Array(detail_sections.clone()), + ); detail_object.insert( "linkedIds".to_string(), - serde_json::from_str::(&card.linked_ids_json).unwrap_or_else(|_| JsonValue::Array(Vec::new())), + serde_json::from_str::(&card.linked_ids_json) + .unwrap_or_else(|_| JsonValue::Array(Vec::new())), ); detail_object.insert("locked".to_string(), JsonValue::Bool(false)); detail_object.insert("editable".to_string(), JsonValue::Bool(false)); - detail_object.insert("editableSectionIds".to_string(), JsonValue::Array(Vec::new())); + detail_object.insert( + "editableSectionIds".to_string(), + JsonValue::Array(Vec::new()), + ); detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new())); - let updated_title = extract_detail_section_value(&detail_sections, "title").unwrap_or_else(|| card.title.clone()); - let updated_subtitle = - extract_detail_section_value(&detail_sections, "subtitle").unwrap_or_else(|| card.subtitle.clone()); - let updated_summary = - extract_detail_section_value(&detail_sections, "summary").unwrap_or_else(|| card.summary.clone()); - let detail_payload_json = - serde_json::to_string(&JsonValue::Object(detail_object)).map_err(|error| { - format!("update_draft_card 无法序列化 detail_payload_json: {error}") - })?; + let updated_title = extract_detail_section_value(&detail_sections, "title") + .unwrap_or_else(|| card.title.clone()); + let updated_subtitle = extract_detail_section_value(&detail_sections, "subtitle") + .unwrap_or_else(|| card.subtitle.clone()); + let updated_summary = extract_detail_section_value(&detail_sections, "summary") + .unwrap_or_else(|| card.summary.clone()); + let detail_payload_json = serde_json::to_string(&JsonValue::Object(detail_object)) + .map_err(|error| format!("update_draft_card 无法序列化 detail_payload_json: {error}"))?; replace_custom_world_draft_card( ctx, @@ -4563,7 +4608,14 @@ fn execute_update_draft_card_action( }, ); - let next_session = sync_session_draft_profile_from_card_update(session, &card, &updated_title, &updated_subtitle, &updated_summary, input.submitted_at_micros)?; + let next_session = sync_session_draft_profile_from_card_update( + session, + &card, + &updated_title, + &updated_subtitle, + &updated_summary, + input.submitted_at_micros, + )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( @@ -4610,9 +4662,13 @@ fn execute_sync_result_profile_action( let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { - draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), + draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( + draft_profile.clone(), + ))?)), last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())), - publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( + &gate, + ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, @@ -4658,12 +4714,13 @@ fn execute_publish_world_action( ) -> Result { ensure_publishable_stage(session.stage, "publish_world")?; - let draft_profile = if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { - explicit.clone() - } else { - parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? - }; + let draft_profile = + if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { + explicit.clone() + } else { + parse_optional_session_object(session.draft_profile_json.as_deref()) + .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? + }; let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, @@ -4723,7 +4780,10 @@ fn execute_publish_world_action( &session.session_id, RpgAgentOperationType::PublishWorld, "世界已发布", - &format!("正式世界档案已写入作品库:{}。", publish_result.1.profile_id), + &format!( + "正式世界档案已写入作品库:{}。", + publish_result.1.profile_id + ), input.submitted_at_micros, ); @@ -4797,9 +4857,15 @@ fn execute_revert_checkpoint_action( .map(|value| serialize_json_value(&JsonValue::Object(value.clone()))) .transpose()?, ), - last_assistant_reply: Some(Some("已恢复到所选 checkpoint 的世界草稿状态。".to_string())), - quality_findings_json: Some(serialize_json_value(&JsonValue::Array(restored_quality_findings))?), - publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), + last_assistant_reply: Some(Some( + "已恢复到所选 checkpoint 的世界草稿状态。".to_string(), + )), + quality_findings_json: Some(serialize_json_value(&JsonValue::Array( + restored_quality_findings, + ))?), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( + &gate, + ))?)), result_preview_json: Some(build_result_preview_json( restored_draft_profile.as_ref(), &gate, @@ -4850,7 +4916,10 @@ fn execute_placeholder_custom_world_action( ctx, &session.session_id, &input.operation_id, - &format!("动作 {} 已接入最小兼容占位,后续会继续补真实编排。", input.action), + &format!( + "动作 {} 已接入最小兼容占位,后续会继续补真实编排。", + input.action + ), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( @@ -4931,7 +5000,8 @@ fn summarize_publish_gate_from_json( blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_player_premise".to_string(), code: "publish_missing_player_premise".to_string(), - message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。".to_string(), + message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。" + .to_string(), }); } if !json_array_has_non_empty_text(profile.get("coreConflicts")) { @@ -5061,8 +5131,10 @@ fn build_supported_actions_json( let has_checkpoint = checkpoints .iter() .any(|entry| entry.get("snapshot").is_some()); - let draft_refining_enabled = - matches!(stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining); + let draft_refining_enabled = matches!( + stage, + RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining + ); let long_tail_enabled = matches!( stage, RpgAgentStage::ObjectRefining @@ -5181,8 +5253,10 @@ fn build_custom_world_draft_card_detail_snapshot( card: &CustomWorldDraftCard, ) -> Result { if let Some(detail_payload_json) = card.detail_payload_json.as_deref() { - let detail_value = serde_json::from_str::(detail_payload_json) - .map_err(|error| format!("custom_world_draft_card.detail_payload_json 非法: {error}"))?; + let detail_value = + serde_json::from_str::(detail_payload_json).map_err(|error| { + format!("custom_world_draft_card.detail_payload_json 非法: {error}") + })?; if let Some(object) = detail_value.as_object() { let sections = object .get("sections") @@ -5220,8 +5294,14 @@ fn build_custom_world_draft_card_detail_snapshot( .to_string(), sections, linked_ids_json: card.linked_ids_json.clone(), - locked: object.get("locked").and_then(JsonValue::as_bool).unwrap_or(false), - editable: object.get("editable").and_then(JsonValue::as_bool).unwrap_or(false), + locked: object + .get("locked") + .and_then(JsonValue::as_bool) + .unwrap_or(false), + editable: object + .get("editable") + .and_then(JsonValue::as_bool) + .unwrap_or(false), editable_section_ids_json: serialize_json_value( object .get("editableSectionIds") @@ -5253,7 +5333,9 @@ fn build_custom_world_draft_card_detail_snapshot( }) } -fn build_fallback_card_sections(card: &CustomWorldDraftCard) -> Vec { +fn build_fallback_card_sections( + card: &CustomWorldDraftCard, +) -> Vec { vec![ CustomWorldDraftCardDetailSectionSnapshot { section_id: "title".to_string(), @@ -5297,7 +5379,9 @@ fn rebuild_custom_world_agent_session_row( current_turn: current.current_turn, progress_percent: patch.progress_percent.unwrap_or(current.progress_percent), stage: patch.stage.unwrap_or(current.stage), - focus_card_id: patch.focus_card_id.unwrap_or_else(|| current.focus_card_id.clone()), + focus_card_id: patch + .focus_card_id + .unwrap_or_else(|| current.focus_card_id.clone()), anchor_content_json: patch .anchor_content_json .unwrap_or_else(|| current.anchor_content_json.clone()), @@ -5307,8 +5391,12 @@ fn rebuild_custom_world_agent_session_row( creator_intent_readiness_json: patch .creator_intent_readiness_json .unwrap_or_else(|| current.creator_intent_readiness_json.clone()), - anchor_pack_json: patch.anchor_pack_json.unwrap_or_else(|| current.anchor_pack_json.clone()), - lock_state_json: patch.lock_state_json.unwrap_or_else(|| current.lock_state_json.clone()), + anchor_pack_json: patch + .anchor_pack_json + .unwrap_or_else(|| current.anchor_pack_json.clone()), + lock_state_json: patch + .lock_state_json + .unwrap_or_else(|| current.lock_state_json.clone()), draft_profile_json: patch .draft_profile_json .unwrap_or_else(|| current.draft_profile_json.clone()), @@ -5460,7 +5548,8 @@ fn upsert_world_foundation_card( status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), - subtitle: read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(), + subtitle: read_optional_text_field(draft_profile, &["subtitle"]) + .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), @@ -5473,24 +5562,27 @@ fn upsert_world_foundation_card( }, ); } else { - ctx.db.custom_world_draft_card().insert(CustomWorldDraftCard { - card_id, - session_id: session_id.to_string(), - kind: RpgAgentDraftCardKind::World, - status: RpgAgentDraftCardStatus::Confirmed, - title: read_optional_text_field(draft_profile, &["name", "title"]) - .unwrap_or_else(|| "世界底稿".to_string()), - subtitle: read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(), - summary: read_optional_text_field(draft_profile, &["summary"]) - .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), - linked_ids_json: "[]".to_string(), - warning_count: 0, - asset_status: None, - asset_status_label: None, - detail_payload_json: Some(detail_payload_json), - created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), - }); + ctx.db + .custom_world_draft_card() + .insert(CustomWorldDraftCard { + card_id, + session_id: session_id.to_string(), + kind: RpgAgentDraftCardKind::World, + status: RpgAgentDraftCardStatus::Confirmed, + title: read_optional_text_field(draft_profile, &["name", "title"]) + .unwrap_or_else(|| "世界底稿".to_string()), + subtitle: read_optional_text_field(draft_profile, &["subtitle"]) + .unwrap_or_default(), + summary: read_optional_text_field(draft_profile, &["summary"]) + .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), + linked_ids_json: "[]".to_string(), + warning_count: 0, + asset_status: None, + asset_status_label: None, + detail_payload_json: Some(detail_payload_json), + created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + }); } Ok(()) @@ -5507,7 +5599,10 @@ fn sync_session_draft_profile_from_card_update( let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .unwrap_or_else(|| build_minimal_draft_profile_from_seed(&session.seed_text)); if card.kind == RpgAgentDraftCardKind::World { - draft_profile.insert("name".to_string(), JsonValue::String(updated_title.to_string())); + draft_profile.insert( + "name".to_string(), + JsonValue::String(updated_title.to_string()), + ); draft_profile.insert( "subtitle".to_string(), JsonValue::String(updated_subtitle.to_string()), @@ -5527,8 +5622,12 @@ fn sync_session_draft_profile_from_card_update( rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { - draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), - publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), + draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( + draft_profile.clone(), + ))?)), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( + &gate, + ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, @@ -5543,7 +5642,10 @@ fn sync_session_draft_profile_from_card_update( } fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { - if matches!(stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining) { + if matches!( + stage, + RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining + ) { Ok(()) } else { Err(format!( @@ -5652,10 +5754,7 @@ fn read_required_payload_text( .ok_or_else(|| error_message.to_string()) } -fn read_optional_text_field( - object: &JsonMap, - keys: &[&str], -) -> Option { +fn read_optional_text_field(object: &JsonMap, keys: &[&str]) -> Option { for key in keys { let mut current = JsonValue::Object(object.clone()); let mut found = true; @@ -5668,7 +5767,11 @@ fn read_optional_text_field( } } if found { - if let Some(value) = current.as_str().map(str::trim).filter(|value| !value.is_empty()) { + if let Some(value) = current + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { return Some(value.to_string()); } } @@ -5860,21 +5963,28 @@ fn append_checkpoint_json(current: &str, checkpoint: &JsonValue) -> Result Option { sections.iter().find_map(|entry| { let object = entry.as_object()?; - (object.get("id").and_then(JsonValue::as_str) == Some(target_id)) - .then(|| { - object - .get("value") - .and_then(JsonValue::as_str) - .unwrap_or_default() - .to_string() - }) + (object.get("id").and_then(JsonValue::as_str) == Some(target_id)).then(|| { + object + .get("value") + .and_then(JsonValue::as_str) + .unwrap_or_default() + .to_string() + }) }) } fn json_array_has_non_empty_text(value: Option<&JsonValue>) -> bool { value .and_then(JsonValue::as_array) - .map(|entries| entries.iter().any(|entry| entry.as_str().map(str::trim).filter(|text| !text.is_empty()).is_some())) + .map(|entries| { + entries.iter().any(|entry| { + entry + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .is_some() + }) + }) .unwrap_or(false) } @@ -6054,12 +6164,14 @@ fn build_custom_world_agent_session_snapshot( recommended_replies_json: row.recommended_replies_json.clone(), asset_coverage_json: row.asset_coverage_json.clone(), checkpoints_json: row.checkpoints_json.clone(), - supported_actions_json: serialize_json_value(&JsonValue::Array(build_supported_actions_json( - row.stage, - row.progress_percent, - &build_custom_world_publish_gate_from_session(row), - &parse_json_array_or_empty(&row.checkpoints_json), - ))) + supported_actions_json: serialize_json_value(&JsonValue::Array( + build_supported_actions_json( + row.stage, + row.progress_percent, + &build_custom_world_publish_gate_from_session(row), + &parse_json_array_or_empty(&row.checkpoints_json), + ), + )) .unwrap_or_else(|_| "[]".to_string()), messages, draft_cards, @@ -7524,7 +7636,10 @@ fn upsert_runtime_snapshot_record( let snapshot = match ctx.db.runtime_snapshot().user_id().find(&prepared.user_id) { Some(existing) => { - ctx.db.runtime_snapshot().user_id().delete(&existing.user_id); + ctx.db + .runtime_snapshot() + .user_id() + .delete(&existing.user_id); ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { user_id: existing.user_id.clone(), version: SAVE_SNAPSHOT_VERSION, @@ -7591,7 +7706,10 @@ fn delete_runtime_snapshot_record( .find(&validated_input.user_id); if let Some(existing) = existing { let snapshot = build_runtime_snapshot_from_row(&existing); - ctx.db.runtime_snapshot().user_id().delete(&existing.user_id); + ctx.db + .runtime_snapshot() + .user_id() + .delete(&existing.user_id); return Ok(Some(snapshot)); } @@ -7602,8 +7720,8 @@ fn list_profile_save_archive_rows( ctx: &ReducerContext, input: RuntimeProfileSaveArchiveListInput, ) -> Result, String> { - let validated_input = - build_runtime_profile_save_archive_list_input(input.user_id).map_err(|error| error.to_string())?; + let validated_input = build_runtime_profile_save_archive_list_input(input.user_id) + .map_err(|error| error.to_string())?; let mut entries = ctx .db @@ -7627,13 +7745,16 @@ fn resume_profile_save_archive_record( ctx: &ReducerContext, input: RuntimeProfileSaveArchiveResumeInput, ) -> Result<(RuntimeProfileSaveArchiveSnapshot, RuntimeSnapshot), String> { - let validated_input = build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key) - .map_err(|error| error.to_string())?; + let validated_input = + build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key) + .map_err(|error| error.to_string())?; let archive = ctx .db .profile_save_archive() .iter() - .find(|row| row.user_id == validated_input.user_id && row.world_key == validated_input.world_key) + .find(|row| { + row.user_id == validated_input.user_id && row.world_key == validated_input.world_key + }) .ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?; let existing_snapshot = ctx @@ -7647,7 +7768,10 @@ fn resume_profile_save_archive_record( .unwrap_or(archive.saved_at); if let Some(existing) = existing_snapshot { - ctx.db.runtime_snapshot().user_id().delete(&existing.user_id); + ctx.db + .runtime_snapshot() + .user_id() + .delete(&existing.user_id); } ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { @@ -7701,21 +7825,23 @@ fn sync_profile_dashboard_from_snapshot( .profile_dashboard_state() .user_id() .find(&snapshot.user_id); - let previous_wallet_balance = current_state.as_ref().map(|row| row.wallet_balance).unwrap_or(0); + let previous_wallet_balance = current_state + .as_ref() + .map(|row| row.wallet_balance) + .unwrap_or(0); let previous_total_play_time_ms = current_state .as_ref() .map(|row| row.total_play_time_ms) .unwrap_or(0); - let next_wallet_balance = read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency"))); + let next_wallet_balance = + read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency"))); let mut next_total_play_time_ms = previous_total_play_time_ms; if next_wallet_balance != previous_wallet_balance { ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { wallet_ledger_id: format!( "{}:{}:{}", - snapshot.user_id, - snapshot.saved_at_micros, - next_wallet_balance + snapshot.user_id, snapshot.saved_at_micros, next_wallet_balance ), user_id: snapshot.user_id.clone(), amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64, @@ -7733,12 +7859,17 @@ fn sync_profile_dashboard_from_snapshot( .and_then(|stats| stats.get("playTimeMs")), ); let played_world_id = format!("{}:{}", snapshot.user_id, world_meta.world_key); - let existing = ctx.db.profile_played_world().played_world_id().find(&played_world_id); + let existing = ctx + .db + .profile_played_world() + .played_world_id() + .find(&played_world_id); let previous_observed_play_time_ms = existing .as_ref() .map(|row| row.last_observed_play_time_ms) .unwrap_or(0); - let incremental_play_time_ms = current_play_time_ms.saturating_sub(previous_observed_play_time_ms); + let incremental_play_time_ms = + current_play_time_ms.saturating_sub(previous_observed_play_time_ms); next_total_play_time_ms = next_total_play_time_ms.saturating_add(incremental_play_time_ms); if let Some(existing) = existing { @@ -7757,7 +7888,8 @@ fn sync_profile_dashboard_from_snapshot( world_subtitle: world_meta.world_subtitle, first_played_at: existing.first_played_at, last_played_at: saved_at, - last_observed_play_time_ms: current_play_time_ms.max(existing.last_observed_play_time_ms), + last_observed_play_time_ms: current_play_time_ms + .max(existing.last_observed_play_time_ms), }); } else { ctx.db.profile_played_world().insert(ProfilePlayedWorld { @@ -7781,21 +7913,25 @@ fn sync_profile_dashboard_from_snapshot( .profile_dashboard_state() .user_id() .delete(&existing.user_id); - ctx.db.profile_dashboard_state().insert(ProfileDashboardState { - user_id: snapshot.user_id.clone(), - wallet_balance: next_wallet_balance, - total_play_time_ms: next_total_play_time_ms, - created_at: existing.created_at, - updated_at: saved_at, - }); + ctx.db + .profile_dashboard_state() + .insert(ProfileDashboardState { + user_id: snapshot.user_id.clone(), + wallet_balance: next_wallet_balance, + total_play_time_ms: next_total_play_time_ms, + created_at: existing.created_at, + updated_at: saved_at, + }); } else { - ctx.db.profile_dashboard_state().insert(ProfileDashboardState { - user_id: snapshot.user_id.clone(), - wallet_balance: next_wallet_balance, - total_play_time_ms: next_total_play_time_ms, - created_at: saved_at, - updated_at: saved_at, - }); + ctx.db + .profile_dashboard_state() + .insert(ProfileDashboardState { + user_id: snapshot.user_id.clone(), + wallet_balance: next_wallet_balance, + total_play_time_ms: next_total_play_time_ms, + created_at: saved_at, + updated_at: saved_at, + }); } } @@ -7805,17 +7941,18 @@ fn sync_profile_save_archive_from_snapshot( game_state: &JsonValue, saved_at: Timestamp, ) -> Result<(), String> { - let Some(archive_meta) = resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref()) else { + let Some(archive_meta) = + resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref()) + else { return Ok(()); }; let archive_id = format!("{}:{}", snapshot.user_id, archive_meta.world_key); - let existing = ctx - .db - .profile_save_archive() - .archive_id() - .find(&archive_id); - let created_at = existing.as_ref().map(|row| row.created_at).unwrap_or(saved_at); + let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id); + let created_at = existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or(saved_at); if let Some(existing) = existing { ctx.db @@ -7905,7 +8042,8 @@ fn build_profile_save_archive_snapshot_from_row( } fn parse_json_str(raw: &str) -> Result { - serde_json::from_str::(raw).map_err(|error| format!("game_state_json 解析失败: {error}")) + serde_json::from_str::(raw) + .map_err(|error| format!("game_state_json 解析失败: {error}")) } fn parse_optional_json_str(raw: Option<&str>) -> Result, String> { @@ -7951,7 +8089,9 @@ fn resolve_profile_world_snapshot_meta( game_state: Option<&serde_json::Map>, ) -> Option { let game_state = game_state?; - let custom_world_profile = game_state.get("customWorldProfile").and_then(JsonValue::as_object); + let custom_world_profile = game_state + .get("customWorldProfile") + .and_then(JsonValue::as_object); if let Some(custom_world_profile) = custom_world_profile { let profile_id = read_string_from_json(custom_world_profile.get("id")); @@ -7976,7 +8116,9 @@ fn resolve_profile_world_snapshot_meta( } let world_type = read_string_from_json(game_state.get("worldType"))?; - let current_scene_preset = game_state.get("currentScenePreset").and_then(JsonValue::as_object); + let current_scene_preset = game_state + .get("currentScenePreset") + .and_then(JsonValue::as_object); Some(ProfileWorldSnapshotMeta { world_key: format!("builtin:{world_type}"), @@ -8004,8 +8146,8 @@ fn resolve_profile_save_archive_meta( let story_engine_memory = game_state_object .and_then(|state| state.get("storyEngineMemory")) .and_then(JsonValue::as_object); - let continue_game_digest = - story_engine_memory.and_then(|memory| read_string_from_json(memory.get("continueGameDigest"))); + let continue_game_digest = story_engine_memory + .and_then(|memory| read_string_from_json(memory.get("continueGameDigest"))); let current_story_text = parse_optional_json_str(current_story_json) .ok() .flatten() @@ -8704,7 +8846,11 @@ fn replace_big_fish_session( ctx.db.big_fish_creation_session().insert(next); } -fn replace_big_fish_run(ctx: &ReducerContext, current: &BigFishRuntimeRun, next: BigFishRuntimeRun) { +fn replace_big_fish_run( + ctx: &ReducerContext, + current: &BigFishRuntimeRun, + next: BigFishRuntimeRun, +) { ctx.db .big_fish_runtime_run() .run_id() @@ -8713,12 +8859,7 @@ fn replace_big_fish_run(ctx: &ReducerContext, current: &BigFishRuntimeRun, next: } fn upsert_big_fish_asset_slot(ctx: &ReducerContext, slot: BigFishAssetSlotSnapshot) { - if let Some(existing) = ctx - .db - .big_fish_asset_slot() - .slot_id() - .find(&slot.slot_id) - { + if let Some(existing) = ctx.db.big_fish_asset_slot().slot_id().find(&slot.slot_id) { ctx.db .big_fish_asset_slot() .slot_id() diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index c39443cd..dceb2046 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,18 +1,16 @@ -use module_puzzle::{ +use module_puzzle::{ PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, - PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, - PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, - PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, - PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkGetInput, - PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, - PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, - apply_selected_candidate, - build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack, - normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size, - select_next_profile, start_run, swap_pieces, + PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, + PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, + PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, + PuzzleSelectCoverImageInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, + PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, + apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, + compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags, + publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces, }; use serde_json::from_str as json_from_str; use serde_json::to_string as json_to_string; @@ -488,7 +486,10 @@ fn submit_puzzle_agent_message_tx( text: input.user_message_text.clone(), created_at: submitted_at, }); - let assistant_message_id = format!("{}assistant-{}", input.session_id, input.submitted_at_micros); + let assistant_message_id = format!( + "{}assistant-{}", + input.session_id, input.submitted_at_micros + ); ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { message_id: assistant_message_id, session_id: input.session_id.clone(), @@ -547,7 +548,9 @@ fn compile_puzzle_agent_draft_tx( stage: PuzzleAgentStage::DraftReady, anchor_pack_json: serialize_json(&anchor_pack), draft_json: Some(serialize_json(&draft)), - last_assistant_reply: Some("拼图结果页草稿已经生成,可以开始出图并确认标签。".to_string()), + last_assistant_reply: Some( + "拼图结果页草稿已经生成,可以开始出图并确认标签。".to_string(), + ), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: compiled_at, @@ -574,14 +577,19 @@ fn save_puzzle_generated_images_tx( ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let mut draft = deserialize_draft_required(&row.draft_json)?; - let candidates: Vec = - json_from_str(&input.candidates_json).map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?; + let candidates: Vec = json_from_str(&input.candidates_json) + .map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?; if candidates.is_empty() { return Err("拼图候选图不能为空".to_string()); } draft.candidates = candidates; draft.generation_status = "ready".to_string(); - if let Some(selected) = draft.candidates.iter().find(|entry| entry.selected).cloned() { + if let Some(selected) = draft + .candidates + .iter() + .find(|entry| entry.selected) + .cloned() + { draft.selected_candidate_id = Some(selected.candidate_id); draft.cover_image_src = Some(selected.image_src); draft.cover_asset_id = Some(selected.asset_id); @@ -626,7 +634,8 @@ fn select_puzzle_cover_image_tx( ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let draft = deserialize_draft_required(&row.draft_json)?; - let draft = apply_selected_candidate(draft, &input.candidate_id).map_err(|error| error.to_string())?; + let draft = + apply_selected_candidate(draft, &input.candidate_id).map_err(|error| error.to_string())?; let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros); let next_stage = if build_result_preview(&draft, Some("创作者")).publish_ready { PuzzleAgentStage::ReadyToPublish @@ -814,7 +823,13 @@ fn start_puzzle_run_tx( ctx: &TxContext, input: PuzzleRunStartInput, ) -> Result { - if ctx.db.puzzle_runtime_run().run_id().find(&input.run_id).is_some() { + if ctx + .db + .puzzle_runtime_run() + .run_id() + .find(&input.run_id) + .is_some() + { return Err("拼图 run 已存在".to_string()); } let entry_profile_row = ctx @@ -827,7 +842,8 @@ fn start_puzzle_run_tx( return Err("入口拼图作品未发布".to_string()); } let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; - let mut run = start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?; + let mut run = + start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?; run.recommended_next_profile_id = select_next_profile( &entry_profile, &run.played_profile_ids, @@ -854,8 +870,8 @@ fn swap_puzzle_pieces_tx( ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; - let mut next_run = - swap_pieces(¤t_run, &input.first_piece_id, &input.second_piece_id).map_err(|error| error.to_string())?; + let mut next_run = swap_pieces(¤t_run, &input.first_piece_id, &input.second_piece_id) + .map_err(|error| error.to_string())?; refresh_next_profile_recommendation(ctx, &mut next_run)?; replace_puzzle_runtime_run(ctx, &row, &next_run, input.swapped_at_micros); Ok(next_run) @@ -900,17 +916,18 @@ fn advance_puzzle_next_level_tx( .ok_or_else(|| "当前拼图作品不存在".to_string())?, )?; let candidates = list_published_puzzle_profiles(ctx)?; - let next_profile = select_next_profile(¤t_profile, ¤t_run.played_profile_ids, &candidates) - .ok_or_else(|| "没有可用的下一关候选".to_string())? - .clone(); - let mut next_run = - module_puzzle::advance_next_level(¤t_run, &next_profile).map_err(|error| error.to_string())?; - next_run.recommended_next_profile_id = select_next_profile( - &next_profile, - &next_run.played_profile_ids, + let next_profile = select_next_profile( + ¤t_profile, + ¤t_run.played_profile_ids, &candidates, ) - .map(|value| value.profile_id.clone()); + .ok_or_else(|| "没有可用的下一关候选".to_string())? + .clone(); + let mut next_run = module_puzzle::advance_next_level(¤t_run, &next_profile) + .map_err(|error| error.to_string())?; + next_run.recommended_next_profile_id = + select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates) + .map(|value| value.profile_id.clone()); if let Some(next_profile_row) = ctx .db @@ -954,7 +971,9 @@ fn build_puzzle_agent_session_snapshot( }) } -fn build_puzzle_work_profile_from_row(row: &PuzzleWorkProfileRow) -> Result { +fn build_puzzle_work_profile_from_row( + row: &PuzzleWorkProfileRow, +) -> Result { Ok(PuzzleWorkProfile { work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), @@ -968,7 +987,9 @@ fn build_puzzle_work_profile_from_row(row: &PuzzleWorkProfileRow) -> Result Vec Vec { +fn build_puzzle_suggested_actions( + stage: PuzzleAgentStage, +) -> Vec { match stage { PuzzleAgentStage::CollectingAnchors => vec![module_puzzle::PuzzleAgentSuggestedAction { id: "compile-draft".to_string(), @@ -1051,14 +1074,26 @@ fn append_system_message( } fn ensure_session_missing(ctx: &TxContext, session_id: &str) -> Result<(), String> { - if ctx.db.puzzle_agent_session().session_id().find(&session_id.to_string()).is_some() { + if ctx + .db + .puzzle_agent_session() + .session_id() + .find(&session_id.to_string()) + .is_some() + { return Err("拼图 session 已存在".to_string()); } Ok(()) } fn ensure_message_missing(ctx: &TxContext, message_id: &str) -> Result<(), String> { - if ctx.db.puzzle_agent_message().message_id().find(&message_id.to_string()).is_some() { + if ctx + .db + .puzzle_agent_message() + .message_id() + .find(&message_id.to_string()) + .is_some() + { return Err("拼图消息已存在".to_string()); } Ok(()) @@ -1122,10 +1157,7 @@ fn replace_puzzle_work_profile( ctx.db.puzzle_work_profile().insert(next); } -fn upsert_puzzle_work_profile( - ctx: &TxContext, - profile: PuzzleWorkProfile, -) -> Result<(), String> { +fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> { if let Some(existing) = ctx .db .puzzle_work_profile() @@ -1219,10 +1251,7 @@ fn replace_puzzle_runtime_run( run: &PuzzleRunSnapshot, updated_at_micros: i64, ) { - ctx.db - .puzzle_runtime_run() - .run_id() - .delete(¤t.run_id); + ctx.db.puzzle_runtime_run().run_id().delete(¤t.run_id); ctx.db.puzzle_runtime_run().insert(PuzzleRuntimeRunRow { run_id: run.run_id.clone(), owner_user_id: current.owner_user_id.clone(), @@ -1414,6 +1443,9 @@ mod tests { author_display_name: "作者".to_string(), summary: String::new(), }; - assert!(recommendation_score(&left, &right) > tag_similarity_score(&left.theme_tags, &right.theme_tags)); + assert!( + recommendation_score(&left, &right) + > tag_similarity_score(&left.theme_tags, &right.theme_tags) + ); } } diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 3ffc82e4..32c6f48e 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -9,6 +9,7 @@ import { AuthGate } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ + ensureStoredAccessToken: vi.fn(), ensureAutoAuthUser: vi.fn(), getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), @@ -20,6 +21,7 @@ const authMocks = vi.hoisted(() => ({ vi.mock('../../services/apiClient', () => ({ AUTH_STATE_EVENT: 'genarrative-auth-state-changed', + ensureStoredAccessToken: authMocks.ensureStoredAccessToken, })); vi.mock('../../services/authService', () => ({ @@ -76,6 +78,7 @@ const mockUser: AuthUser = { beforeEach(() => { vi.clearAllMocks(); authMocks.consumeAuthCallbackResult.mockReturnValue(null); + authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: null, availableLoginMethods: ['phone'], @@ -127,6 +130,33 @@ test('auth gate keeps platform content visible when phone login is available', a expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled(); }); +test('auth gate waits for access token refresh before exposing restored user content', async () => { + let resolveToken!: (token: string) => void; + const tokenPromise = new Promise((resolve) => { + resolveToken = resolve; + }); + authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + + render( + +
应用内容
+
, + ); + + expect(screen.getByText('正在校验登录状态...')).toBeTruthy(); + expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled(); + + resolveToken('jwt-restored-token'); + + expect(await screen.findByText('应用内容')).toBeTruthy(); + expect(authMocks.ensureStoredAccessToken).toHaveBeenCalledTimes(1); + expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); +}); + test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: [], @@ -171,6 +201,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi '13800000000', '123456', ); + expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); expect(onAuthenticated).toHaveBeenCalledTimes(1); }); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 0ff917c1..297c57e8 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -10,6 +10,7 @@ import { import { useGameSettings } from '../../hooks/useGameSettings'; import { AUTH_STATE_EVENT, + ensureStoredAccessToken, } from '../../services/apiClient'; import { type AuthAuditLogEntry, @@ -89,10 +90,15 @@ export function AuthGate({ children }: AuthGateProps) { const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] = useState(null); const pendingProtectedActionRef = useRef<(() => void) | null>(null); - const settings = useGameSettings(user?.id ?? null); + const readyUser = status === 'ready' ? user : null; + const settings = useGameSettings(readyUser?.id ?? null); const platformThemeClass = `platform-theme--${settings.platformTheme}`; - const readyUser = status === 'ready' ? user : null; + const activateReadyUser = useCallback((nextUser: AuthUser) => { + // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 + setUser(nextUser); + setStatus('ready'); + }, []); const closeLoginModal = useCallback(() => { pendingProtectedActionRef.current = null; @@ -154,8 +160,8 @@ export function AuthGate({ children }: AuthGateProps) { return; } - setUser(nextUser); - setStatus('ready'); + await ensureStoredAccessToken(); + activateReadyUser(nextUser); setError(''); } catch (autoAuthError) { if (!isActive) { @@ -229,6 +235,7 @@ export function AuthGate({ children }: AuthGateProps) { } try { + await ensureStoredAccessToken(); const nextSession = await getCurrentAuthUser(); if (!isActive) { return; @@ -270,7 +277,7 @@ export function AuthGate({ children }: AuthGateProps) { isActive = false; window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange); }; - }, []); + }, [activateReadyUser]); useEffect(() => { if (!readyUser) { @@ -458,8 +465,7 @@ export function AuthGate({ children }: AuthGateProps) { try { const nextUser = await bindWechatPhone(phone, code); setBindCaptchaChallenge(null); - setUser(nextUser); - setStatus('ready'); + activateReadyUser(nextUser); } catch (bindError) { setError( bindError instanceof Error @@ -665,8 +671,7 @@ export function AuthGate({ children }: AuthGateProps) { try { const nextUser = await loginWithPhoneCode(phone, code); setLoginCaptchaChallenge(null); - setUser(nextUser); - setStatus('ready'); + activateReadyUser(nextUser); } catch (loginError) { setError( loginError instanceof Error diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 4634e73c..feda8aaf 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -151,7 +151,7 @@ export function LoginScreen({ }); setCooldownSeconds(result.cooldownSeconds); setHint( - `验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`, + `短信请求已提交,请留意手机短信。验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`, ); setCaptchaAnswer(''); } catch { diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index f73338dd..3657bf3b 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -6,6 +6,8 @@ import { expect, test } from 'vitest'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import { CustomWorldCreationHub } from './CustomWorldCreationHub'; +const noopCreateType = () => {}; + const baseDraftItem: CustomWorldWorkSummary = { workId: 'draft:session-1', sourceType: 'agent_session', @@ -32,9 +34,8 @@ test('creation hub reflects updated draft title summary and counts after rerende items={[baseDraftItem]} loading={false} error={null} - onBack={() => {}} onRetry={() => {}} - onCreateNew={() => {}} + onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} />, @@ -44,6 +45,9 @@ test('creation hub reflects updated draft title summary and counts after rerende expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy(); expect(screen.getByText('角色 3')).toBeTruthy(); expect(screen.getByText('地点 4')).toBeTruthy(); + expect(screen.getByRole('button', { name: /角色扮演 RPG/u })).toBeTruthy(); + expect(screen.getByRole('button', { name: /大鱼吃小鱼/u })).toBeTruthy(); + expect(screen.getByRole('button', { name: /拼图玩法/u })).toBeTruthy(); rerender( {}} onRetry={() => {}} - onCreateNew={() => {}} + onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} />, @@ -96,9 +99,8 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to ]} loading={false} error={null} - onBack={() => {}} onRetry={() => {}} - onCreateNew={() => {}} + onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onOpenPuzzleDetail={() => {}} @@ -109,5 +111,4 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to expect(screen.getByText('沉钟拼图')).toBeTruthy(); expect(screen.getAllByText('拼图').length).toBeGreaterThan(0); expect(screen.queryByText('我的拼图作品')).toBeNull(); - expect(screen.queryByText('拼图玩法')).toBeNull(); }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 31780816..1cedd873 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -3,6 +3,8 @@ import { expect, test } from 'vitest'; import { CustomWorldCreationHub } from './CustomWorldCreationHub'; +const noopCreateType = () => {}; + test('creation hub draft card renders compiled work summary fields', () => { const html = renderToStaticMarkup( { ]} loading={false} error={null} - onBack={() => {}} onRetry={() => {}} - onCreateNew={() => {}} + onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} />, @@ -41,6 +42,9 @@ test('creation hub draft card renders compiled work summary fields', () => { expect(html).toContain('一个被潮雾切开的列岛世界'); expect(html).toContain('玩家是失职返乡的守灯人'); expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); + expect(html).toContain('角色扮演 RPG'); + expect(html).toContain('大鱼吃小鱼'); + expect(html).toContain('拼图玩法'); }); test('creation hub renders puzzle works in the same unified list with puzzle tag', () => { @@ -66,9 +70,8 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag ]} loading={false} error={null} - onBack={() => {}} onRetry={() => {}} - onCreateNew={() => {}} + onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onOpenPuzzleDetail={() => {}} @@ -78,5 +81,4 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag expect(html).toContain('潮雾拼图'); expect(html).toContain('拼图'); expect(html).not.toContain('我的拼图作品'); - expect(html).not.toContain('拼图玩法'); }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 6aafaec9..f605304c 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -11,14 +11,16 @@ import { type CustomWorldWorkFilter, CustomWorldWorkTabs, } from './CustomWorldWorkTabs'; +import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes'; type CustomWorldCreationHubProps = { items: CustomWorldWorkSummary[]; loading: boolean; error: string | null; - onBack: () => void; onRetry: () => void; - onCreateNew: () => void; + createError?: string | null; + createBusy?: boolean; + onCreateType: (type: PlatformCreationTypeId) => void; onOpenDraft: (item: CustomWorldWorkSummary) => void; onEnterPublished: (profileId: string) => void; puzzleItems?: PuzzleWorkSummary[]; @@ -39,9 +41,10 @@ export function CustomWorldCreationHub({ items, loading, error, - onBack, onRetry, - onCreateNew, + createError = null, + createBusy = false, + onCreateType, onOpenDraft, onEnterPublished, puzzleItems = [], @@ -80,33 +83,12 @@ export function CustomWorldCreationHub({ return (
-
-
-
- -
- 创作中心 -
-
-
- - 草稿 {draftCount} - - - 已发布 {publishedCount} - -
-
-
-
- + void; + busy?: boolean; + error?: string | null; + onCreateType: (type: PlatformCreationTypeId) => void; }; export function CustomWorldCreationStartCard({ - onCreateNew, + busy = false, + error = null, + onCreateType, }: CustomWorldCreationStartCardProps) { return (
-
+
新建作品
+
+ 直接选择游戏创作模板,立刻进入对应的共创工作台。 +
- + +
+ {PLATFORM_CREATION_TYPES.map((item) => { + const disabled = item.locked || busy; + + return ( + + ); + })} +
+ + {error ? ( +
+ {error} +
+ ) : null}
); diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index f74f2d7c..5dc2fddb 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -1,5 +1,7 @@ import { ArrowRight, X } from 'lucide-react'; +import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes'; + export interface PlatformEntryCreationTypeModalProps { isOpen: boolean; isBusy: boolean; @@ -10,54 +12,8 @@ export interface PlatformEntryCreationTypeModalProps { onSelectPuzzle: () => void; } -type CreationGameTypeCard = { - id: 'rpg' | 'big-fish' | 'puzzle' | 'airp' | 'visual-novel'; - title: string; - subtitle: string; - badge: string; - locked: boolean; -}; - -const CREATION_GAME_TYPES: CreationGameTypeCard[] = [ - { - id: 'rpg', - title: '角色扮演 RPG', - subtitle: 'Agent 共创', - badge: '可创建', - locked: false, - }, - { - id: 'big-fish', - title: '大鱼吃小鱼', - subtitle: '实时成长玩法', - badge: '可创建', - locked: false, - }, - { - id: 'puzzle', - title: '拼图玩法', - subtitle: '图像锚点共创', - badge: '可创建', - locked: false, - }, - { - id: 'airp', - title: 'AIRP', - subtitle: '敬请期待', - badge: '锁定', - locked: true, - }, - { - id: 'visual-novel', - title: '视觉小说', - subtitle: '敬请期待', - badge: '锁定', - locked: true, - }, -]; - function CreationTypeCard(props: { - item: CreationGameTypeCard; + item: (typeof PLATFORM_CREATION_TYPES)[number]; busy: boolean; onSelect: () => void; }) { @@ -147,7 +103,7 @@ export function PlatformEntryCreationTypeModal({
- {CREATION_GAME_TYPES.map((item) => ( + {PLATFORM_CREATION_TYPES.map((item) => ( { + const prepareCreationLaunch = useCallback(() => { if (sessionController.isCreatingAgentSession) { - return; + return false; } if (!hasSavedGame) { @@ -373,13 +374,21 @@ export function PlatformEntryFlowShellImpl({ } sessionController.setCreationTypeError(null); - setShowCreationTypeModal(true); + return true; }, [ handleStartNewGame, hasSavedGame, sessionController, ]); + const openCreationTypePicker = useCallback(() => { + if (!prepareCreationLaunch()) { + return; + } + + setShowCreationTypeModal(true); + }, [prepareCreationLaunch]); + const resolveBigFishErrorMessage = useCallback( (error: unknown, fallback: string) => resolveRpgCreationErrorMessage(error, fallback), @@ -466,6 +475,45 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage, ]); + const handleCreationHubCreateType = useCallback( + (type: PlatformCreationTypeId) => { + if (type === 'airp' || type === 'visual-novel') { + return; + } + + if (!prepareCreationLaunch()) { + return; + } + + if (type === 'rpg') { + runProtectedAction(() => { + void sessionController.openRpgAgentWorkspace(); + }); + return; + } + + if (type === 'big-fish') { + runProtectedAction(() => { + void openBigFishAgentWorkspace(); + }); + return; + } + + if (type === 'puzzle') { + runProtectedAction(() => { + void openPuzzleAgentWorkspace(); + }); + } + }, + [ + openBigFishAgentWorkspace, + openPuzzleAgentWorkspace, + prepareCreationLaunch, + runProtectedAction, + sessionController, + ], + ); + const leaveBigFishFlow = useCallback(() => { setBigFishError(null); setBigFishRun(null); @@ -925,9 +973,6 @@ export function PlatformEntryFlowShellImpl({ sessionController.creationTypeError ?? puzzleError } - onBack={() => { - platformBootstrap.setPlatformTab('home'); - }} onRetry={() => { platformBootstrap.setPlatformError(null); void platformBootstrap.refreshCustomWorldWorks().catch((error) => { @@ -936,7 +981,11 @@ export function PlatformEntryFlowShellImpl({ ); }); }} - onCreateNew={openCreationTypePicker} + createError={sessionController.creationTypeError ?? bigFishError ?? puzzleError} + createBusy={ + sessionController.isCreatingAgentSession || isBigFishBusy || isPuzzleBusy + } + onCreateType={handleCreationHubCreateType} onOpenDraft={(item) => { runProtectedAction(() => { void detailNavigation.handleOpenCreationWork(item); diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts new file mode 100644 index 00000000..4da77c8a --- /dev/null +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -0,0 +1,55 @@ +export type PlatformCreationTypeId = + | 'rpg' + | 'big-fish' + | 'puzzle' + | 'airp' + | 'visual-novel'; + +export type PlatformCreationTypeCard = { + id: PlatformCreationTypeId; + title: string; + subtitle: string; + badge: string; + locked: boolean; +}; + +/** + * 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。 + */ +export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [ + { + id: 'rpg', + title: '角色扮演 RPG', + subtitle: 'Agent 共创', + badge: '可创建', + locked: false, + }, + { + id: 'big-fish', + title: '大鱼吃小鱼', + subtitle: '实时成长玩法', + badge: '可创建', + locked: false, + }, + { + id: 'puzzle', + title: '拼图玩法', + subtitle: '图像锚点共创', + badge: '可创建', + locked: false, + }, + { + id: 'airp', + title: 'AIRP', + subtitle: '敬请期待', + badge: '锁定', + locked: true, + }, + { + id: 'visual-novel', + title: '视觉小说', + subtitle: '敬请期待', + badge: '锁定', + locked: true, + }, +]; diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index ae7e3bc1..a8d0aa28 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -60,18 +60,13 @@ async function clickFirstAsyncButtonByName( async function openCreationHub(user: ReturnType) { await clickFirstButtonByName(user, '创作'); - expect(await screen.findByText('创作中心')).toBeTruthy(); + expect(await screen.findByText('角色扮演 RPG')).toBeTruthy(); } async function openNewRpgCreation( user: ReturnType, ) { await openCreationHub(user); - const createButtons = await screen.findAllByRole('button', { - name: /新建作品/u, - }); - await user.click(createButtons.at(-1)!); - expect(screen.getByText('选择创作类型')).toBeTruthy(); await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); } @@ -544,18 +539,12 @@ beforeEach(() => { vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession); }); -test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => { +test('create hub exposes direct template entry, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => { const user = userEvent.setup(); render(); await openCreationHub(user); - const createButtons = await screen.findAllByRole('button', { - name: /新建作品/u, - }); - await user.click(createButtons.at(-1)!); - - expect(screen.getByText('选择创作类型')).toBeTruthy(); const airpButton = screen.getByRole('button', { name: /AIRP/u }); const visualNovelButton = screen.getByRole('button', { @@ -613,18 +602,17 @@ test('create tab opens compiled agent draft in result refinement page', async () await openCreationHub(user); - expect(screen.getByRole('button', { name: /继续完善/u })).toBeTruthy(); + expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy(); - await user.click(screen.getByRole('button', { name: /继续完善/u })); + await user.click(await screen.findByRole('button', { name: /继续完善/u })); - await waitFor( - async () => { - expect(await screen.findByText('世界档案')).toBeTruthy(); - expect(screen.queryByText('Agent工作区:custom-world-agent-session-1')).toBeNull(); - expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy(); - }, - { timeout: 2500 }, - ); + await waitFor(() => { + expect(screen.queryByText('正在加载世界编辑器...')).toBeNull(); + }, { timeout: 5000 }); + + expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy(); + expect(screen.queryByText('Agent工作区:custom-world-agent-session-1')).toBeNull(); + expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy(); }); test('create tab resumes agent workspace when draft has no compiled result yet', async () => { @@ -661,9 +649,9 @@ test('create tab resumes agent workspace when draft has no compiled result yet', await openCreationHub(user); - expect(screen.getByRole('button', { name: /继续创作/u })).toBeTruthy(); + expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy(); - await user.click(screen.getByRole('button', { name: /继续创作/u })); + await user.click(await screen.findByRole('button', { name: /继续创作/u })); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), @@ -714,7 +702,7 @@ test('opening a compiled draft with a missing agent session falls back to create render(); await openCreationHub(user); - await user.click(screen.getByRole('button', { name: /继续完善/u })); + await user.click(await screen.findByRole('button', { name: /继续完善/u })); await waitFor(() => { expect( @@ -1227,7 +1215,7 @@ test('agent draft result back button returns to creation hub without redundant s await user.click(screen.getByRole('button', { name: /返回创作/u })); await waitFor(() => { - expect(screen.getByText('创作中心')).toBeTruthy(); + expect(screen.getByText('角色扮演 RPG')).toBeTruthy(); }); expect( @@ -1584,7 +1572,7 @@ test('owned world detail can delete a work and return to the create tab list', a render(); await openCreationHub(user); - await user.click(screen.getByRole('button', { name: /进入世界/u })); + await user.click(await screen.findByRole('button', { name: /进入世界/u })); await user.click(await screen.findByRole('button', { name: '删除作品' })); await waitFor(() => { @@ -1662,7 +1650,7 @@ test('creation hub published work enters existing detail view', async () => { render(); await openCreationHub(user); - await user.click(screen.getByRole('button', { name: /进入世界/u })); + await user.click(await screen.findByRole('button', { name: /进入世界/u })); expect(await screen.findByText('世界信息')).toBeTruthy(); expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy(); diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index fb985e97..4f045264 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -1,3 +1,4 @@ +import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth'; import { API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION, @@ -7,7 +8,6 @@ import { parseApiErrorMessage, unwrapApiResponse, } from '../../packages/shared/src/http'; -import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth'; const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1'; const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1'; @@ -487,6 +487,16 @@ async function refreshAccessToken() { } } +export async function ensureStoredAccessToken() { + const currentToken = getStoredAccessToken(); + if (currentToken) { + return currentToken; + } + + // AuthGate 恢复会话时可能只有 HttpOnly refresh cookie,本地尚无 access token。 + return refreshAccessToken(); +} + export async function fetchWithApiAuth( input: string, init: RequestInit = {}, diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 24559a0a..74c5a07f 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -118,7 +118,7 @@ describe('authService', () => { '登录失败', ); expect(getStoredAccessToken()).toBe('jwt-entry-token'); - expect(window.dispatchEvent).toHaveBeenCalledTimes(1); + expect(window.dispatchEvent).not.toHaveBeenCalled(); }); it('creates a fresh guest credential pair for auto auth when a session is missing', async () => { @@ -251,7 +251,7 @@ describe('authService', () => { '登录失败', ); expect(getStoredAccessToken()).toBe('jwt-phone-token'); - expect(window.dispatchEvent).toHaveBeenCalledTimes(1); + expect(window.dispatchEvent).not.toHaveBeenCalled(); }); it('stores renewed access token after wechat bind activation', async () => { @@ -272,7 +272,7 @@ describe('authService', () => { expect(user.wechatBound).toBe(true); expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token'); - expect(window.dispatchEvent).toHaveBeenCalledTimes(1); + expect(window.dispatchEvent).not.toHaveBeenCalled(); }); it('changes phone number without emitting a global auth state refresh', async () => { @@ -367,7 +367,7 @@ describe('authService', () => { error: null, }); expect(getStoredAccessToken()).toBe('jwt-callback-token'); - expect(window.dispatchEvent).toHaveBeenCalledTimes(1); + expect(window.dispatchEvent).not.toHaveBeenCalled(); expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/'); }); diff --git a/src/services/authService.ts b/src/services/authService.ts index 623209ff..d28934aa 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -3,9 +3,9 @@ import type { AuthAuditLogsResponse, AuthCaptchaChallenge, AuthEntryResponse, + AuthLiftRiskBlockResponse, AuthLoginMethod, AuthLoginOptionsResponse, - AuthLiftRiskBlockResponse, AuthLogoutAllResponse, AuthMeResponse, AuthPhoneChangeResponse, @@ -166,7 +166,7 @@ export async function loginWithPhoneCode(phone: string, code: string) { '登录失败', ); - setStoredAccessToken(response.token); + setStoredAccessToken(response.token, { emit: false }); return response.user; } @@ -184,7 +184,7 @@ export async function bindWechatPhone(phone: string, code: string) { '绑定手机号失败', ); - setStoredAccessToken(response.token); + setStoredAccessToken(response.token, { emit: false }); return response.user; } @@ -239,7 +239,7 @@ export async function authEntry(username: string, password: string) { '登录失败', ); - setStoredAccessToken(response.token); + setStoredAccessToken(response.token, { emit: false }); return response.user; } @@ -297,7 +297,7 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null { } if (authToken) { - setStoredAccessToken(authToken); + setStoredAccessToken(authToken, { emit: false }); } if (typeof window.history?.replaceState === 'function') {