1
This commit is contained in:
@@ -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 作品列表区
|
||||
|
||||
列表区统一展示作品卡片,但卡片要区分两类:
|
||||
|
||||
|
||||
@@ -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”的竞态。
|
||||
|
||||
### 第二批验收
|
||||
|
||||
|
||||
@@ -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. 本次修复的价值在于把问题从“黑盒猜测”变成“有追踪字段、有最终状态、有日志”的可定位链路。
|
||||
@@ -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`
|
||||
执行,先补齐回执闭环和送达状态追踪,再继续看供应商配置与号码侧问题。
|
||||
|
||||
@@ -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/<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 发布包脚本
|
||||
|
||||
入口:
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = ''`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<SmsAuthEventRow>(
|
||||
`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<SmsAuthEventRow>(
|
||||
`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<SmsAuthEventRow>(
|
||||
`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<string, unknown> | null;
|
||||
deliveryReportedAt: string;
|
||||
}) {
|
||||
const result = await this.db.query<SmsAuthEventRow>(
|
||||
`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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
: {};
|
||||
|
||||
sendApiResponse(
|
||||
response,
|
||||
await handleAliyunSmsDeliveryReport(context, payload),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/phone/change',
|
||||
routeMeta({ operation: 'auth.phone.change' }),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_local_spacetime_database() -> Option<String> {
|
||||
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::<serde_json::Value>(&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<PathBuf> {
|
||||
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<u64> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
|
||||
@@ -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<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, 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::<Vec<_>>(),
|
||||
)
|
||||
.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<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, 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<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
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<RuntimeItemRewardItemSnapshot>,
|
||||
}
|
||||
|
||||
// 输出同时返回 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<CustomWorldAgentOperationSnapshot, String> {
|
||||
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::<JsonValue>(&card.linked_ids_json).unwrap_or_else(|_| JsonValue::Array(Vec::new())),
|
||||
serde_json::from_str::<JsonValue>(&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<CustomWorldAgentOperationSnapshot, String> {
|
||||
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<CustomWorldDraftCardDetailSnapshot, String> {
|
||||
if let Some(detail_payload_json) = card.detail_payload_json.as_deref() {
|
||||
let detail_value = serde_json::from_str::<JsonValue>(detail_payload_json)
|
||||
.map_err(|error| format!("custom_world_draft_card.detail_payload_json 非法: {error}"))?;
|
||||
let detail_value =
|
||||
serde_json::from_str::<JsonValue>(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<CustomWorldDraftCardDetailSectionSnapshot> {
|
||||
fn build_fallback_card_sections(
|
||||
card: &CustomWorldDraftCard,
|
||||
) -> Vec<CustomWorldDraftCardDetailSectionSnapshot> {
|
||||
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<String, JsonValue>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
fn read_optional_text_field(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
|
||||
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<Strin
|
||||
fn extract_detail_section_value(sections: &[JsonValue], target_id: &str) -> Option<String> {
|
||||
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<Vec<RuntimeProfileSaveArchiveSnapshot>, 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<JsonValue, String> {
|
||||
serde_json::from_str::<JsonValue>(raw).map_err(|error| format!("game_state_json 解析失败: {error}"))
|
||||
serde_json::from_str::<JsonValue>(raw)
|
||||
.map_err(|error| format!("game_state_json 解析失败: {error}"))
|
||||
}
|
||||
|
||||
fn parse_optional_json_str(raw: Option<&str>) -> Result<Option<JsonValue>, String> {
|
||||
@@ -7951,7 +8089,9 @@ fn resolve_profile_world_snapshot_meta(
|
||||
game_state: Option<&serde_json::Map<String, JsonValue>>,
|
||||
) -> Option<ProfileWorldSnapshotMeta> {
|
||||
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()
|
||||
|
||||
@@ -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<PuzzleAgentSessionSnapshot, String> {
|
||||
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<PuzzleGeneratedImageCandidate> =
|
||||
json_from_str(&input.candidates_json).map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
|
||||
let candidates: Vec<PuzzleGeneratedImageCandidate> = 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<PuzzleAgentSessionSnapshot, String> {
|
||||
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<PuzzleRunSnapshot, String> {
|
||||
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<PuzzleRunSnapshot, String> {
|
||||
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<PuzzleWorkProfile, String> {
|
||||
fn build_puzzle_work_profile_from_row(
|
||||
row: &PuzzleWorkProfileRow,
|
||||
) -> Result<PuzzleWorkProfile, String> {
|
||||
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<Puzz
|
||||
cover_asset_id: row.cover_asset_id.clone(),
|
||||
publication_status: row.publication_status,
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
|
||||
published_at_micros: row
|
||||
.published_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
play_count: row.play_count,
|
||||
publish_ready: row.publish_ready,
|
||||
anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?,
|
||||
@@ -994,7 +1015,9 @@ fn list_session_messages(ctx: &TxContext, session_id: &str) -> Vec<PuzzleAgentMe
|
||||
items
|
||||
}
|
||||
|
||||
fn build_puzzle_suggested_actions(stage: PuzzleAgentStage) -> Vec<module_puzzle::PuzzleAgentSuggestedAction> {
|
||||
fn build_puzzle_suggested_actions(
|
||||
stage: PuzzleAgentStage,
|
||||
) -> Vec<module_puzzle::PuzzleAgentSuggestedAction> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>((resolve) => {
|
||||
resolveToken = resolve;
|
||||
});
|
||||
authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<AuthCaptchaChallenge | null>(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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
<CustomWorldCreationHub
|
||||
@@ -59,9 +63,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<CustomWorldCreationHub
|
||||
@@ -30,9 +32,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
]}
|
||||
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('拼图玩法');
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="pb-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="mt-4 text-[1.8rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2.3rem]">
|
||||
创作中心
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 gap-2 sm:flex">
|
||||
<span className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
草稿 {draftCount}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已发布 {publishedCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<CustomWorldCreationStartCard onCreateNew={onCreateNew} />
|
||||
<CustomWorldCreationStartCard
|
||||
busy={createBusy}
|
||||
error={createError}
|
||||
onCreateType={onCreateType}
|
||||
/>
|
||||
|
||||
<CustomWorldWorkTabs
|
||||
activeFilter={activeFilter}
|
||||
|
||||
@@ -1,27 +1,89 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import {
|
||||
PLATFORM_CREATION_TYPES,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
onCreateNew: () => void;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
};
|
||||
|
||||
export function CustomWorldCreationStartCard({
|
||||
onCreateNew,
|
||||
busy = false,
|
||||
error = null,
|
||||
onCreateType,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
return (
|
||||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-5 py-5">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="relative z-10 space-y-4">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
新建作品
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
|
||||
直接选择游戏创作模板,立刻进入对应的共创工作台。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateNew}
|
||||
className="platform-button platform-button--primary w-full justify-between rounded-[1.1rem] text-left sm:w-auto"
|
||||
>
|
||||
<span className="text-sm font-semibold">新建作品</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`platform-interactive-card relative overflow-hidden rounded-[1.5rem] border px-4 py-4 text-left transition ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
|
||||
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={`platform-pill px-3 ${
|
||||
item.locked
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
}`}
|
||||
>
|
||||
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
|
||||
</span>
|
||||
{item.locked ? (
|
||||
<span className="text-base leading-none text-white/40">·</span>
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 text-lg font-black leading-tight text-inherit">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-2 text-sm ${
|
||||
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
<div className="px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="grid gap-3 sm:grid-cols-5">
|
||||
{CREATION_GAME_TYPES.map((item) => (
|
||||
{PLATFORM_CREATION_TYPES.map((item) => (
|
||||
<CreationTypeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
normalizeAgentBackedProfile,
|
||||
resolveRpgCreationErrorMessage,
|
||||
} from './platformEntryShared';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
|
||||
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
|
||||
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||
@@ -363,9 +364,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
[authUi],
|
||||
);
|
||||
|
||||
const openCreationTypePicker = useCallback(() => {
|
||||
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);
|
||||
|
||||
55
src/components/platform-entry/platformEntryCreationTypes.ts
Normal file
55
src/components/platform-entry/platformEntryCreationTypes.ts
Normal file
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -60,18 +60,13 @@ async function clickFirstAsyncButtonByName(
|
||||
|
||||
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('创作中心')).toBeTruthy();
|
||||
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openNewRpgCreation(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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();
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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, '', '/');
|
||||
});
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user