This commit is contained in:
2026-04-22 22:01:07 +08:00
parent d8716d70b0
commit b317c2a8ea
37 changed files with 1821 additions and 515 deletions

View File

@@ -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 作品列表区
列表区统一展示作品卡片,但卡片要区分两类:

View File

@@ -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”的竞态。
### 第二批验收

View File

@@ -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. 本次修复的价值在于把问题从“黑盒猜测”变成“有追踪字段、有最终状态、有日志”的可定位链路。

View File

@@ -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`
执行,先补齐回执闭环和送达状态追踪,再继续看供应商配置与号码侧问题。

View File

@@ -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 发布包脚本
入口:

View File

@@ -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."
}

View File

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

View File

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

View File

@@ -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');

View File

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

View File

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

View File

@@ -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 = ''`,
],
},
];

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

@@ -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()
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(&current_run, &input.first_piece_id, &input.second_piece_id).map_err(|error| error.to_string())?;
let mut next_run = swap_pieces(&current_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(&current_profile, &current_run.played_profile_ids, &candidates)
.ok_or_else(|| "没有可用的下一关候选".to_string())?
.clone();
let mut next_run =
module_puzzle::advance_next_level(&current_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(
&current_profile,
&current_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(&current_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(&current.run_id);
ctx.db.puzzle_runtime_run().run_id().delete(&current.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)
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('拼图玩法');
});

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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();

View File

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

View File

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

View File

@@ -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') {