4 Commits

48 changed files with 1334 additions and 886 deletions

View File

@@ -96,7 +96,9 @@
- 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。
- 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。
- `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。
- `密码登录` 页签包含手机号/邮箱、密码、主按钮和忘记密码入口。
- `密码登录` 页签包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或叙世号
- 密码登录只是手机号验证码登录的补充方式:只有已登录并设置过密码的手机号账号才能使用,不能在密码页签创建账号。
- `密码登录` 主按钮固定为 `登录`,不得使用 `注册/登录`
- 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。
- 移动端页签保持等分点击区域,输入框与按钮宽度仍随弹窗收缩。

View File

@@ -11,6 +11,7 @@
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
- [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。
- [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。

View File

@@ -0,0 +1,93 @@
# 统一模态窗口设计 2026-04-25
## 背景
当前前端已有两套稳定视觉资产:
- 平台侧:`platform-overlay``platform-modal-shell``platform-auth-card` 等主题变量。
- RPG 运行时:`pixel-nine-slice``pixel-modal-shell``UI_CHROME.modalPanel` 九宫格边框。
但弹窗结构仍分散在业务组件内,常见重复包括遮罩层、点击遮罩关闭、`role="dialog"``aria-modal`、移动端底部贴边、桌面居中、最大高度、滚动区域和关闭按钮。新增弹窗时容易出现 z-index、无障碍属性、移动端高度和视觉边界不一致。
## 目标
新增统一组件 `UnifiedModal`,只负责弹窗外壳和交互边界,不接管业务内容:
- 统一遮罩、面板、标题区、内容区、底部区结构。
- 支持平台风与像素风两种外观,不混用两套视觉资产。
- 默认移动端优先,平台风移动端底部弹出、桌面居中;像素风保持游戏内居中弹窗。
- 默认提供 `role="dialog"``aria-modal`、标题关联、Escape 关闭和遮罩点击关闭。
- 支持禁用关闭,用于生成中、保存中等不可打断流程。
- 支持 Portal 渲染到 `document.body`,避免被父层 `overflow` 裁剪。
## 非目标
- 不一次性迁移所有旧弹窗,避免运行时大面积回归。
- 不把业务按钮、表单、状态文案放进通用组件。
- 不改变现有主题变量、九宫格素材、平台和 RPG 的视觉风格。
- 不新增第三方弹窗库。
## 组件接口
`UnifiedModal` 核心参数:
| 参数 | 说明 |
| --- | --- |
| `open` | 是否显示。为 `false` 时返回 `null`。 |
| `variant` | `platform``pixel`。默认 `platform`。 |
| `title` | 标题,同时作为默认 `aria-label` 来源。 |
| `description` | 可选副标题,显示在标题下方。 |
| `children` | 主内容区。 |
| `footer` | 可选底部操作区。 |
| `onClose` | 关闭回调。 |
| `closeDisabled` | 禁止遮罩、Escape 和关闭按钮触发关闭。 |
| `closeOnBackdrop` | 是否允许点击遮罩关闭,默认允许。 |
| `showCloseButton` | 是否显示右上关闭按钮,默认显示。 |
| `size` | `sm``md``lg``xl``fullscreen`。 |
| `zIndexClassName` | z-index class默认 `z-[90]`。 |
| `panelClassName` / `bodyClassName` / `footerClassName` | 局部样式扩展。 |
| `portal` | 是否渲染到 `document.body`,默认开启。 |
## 使用边界
### 平台风弹窗
用于平台首页、登录注册、作品结果、创作工作台等非 RPG 运行时界面。
要求:
- 使用 `variant="platform"`
- 面板使用 `platform-modal-shell` 主题变量。
- 移动端优先底部贴边,大屏居中。
- 不在弹窗内放功能说明式长文案,只放任务所需信息。
### 像素风弹窗
用于 RPG 运行时、地图、背包、角色详情、NPC 交易等游戏内面板。
要求:
- 使用 `variant="pixel"`
- 面板使用 `pixel-nine-slice pixel-modal-shell`
- 默认使用 `getNineSliceStyle(UI_CHROME.modalPanel)`
- 标题、内容和底部仍由业务方控制,避免通用组件内写入玩法解释。
## 首批迁移
首批只迁移平台入口创作类型弹窗:
- 文件:`src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
- 目的:验证平台风布局、关闭禁用、标题区、内容区与错误区都可由统一组件承载。
后续可按风险由低到高迁移:
1. 结果页小弹窗:`PuzzleResultView``BigFishResultView`
2. 平台创作页编辑器弹窗:`RpgCreationEntityEditorShared` 内局部 `ModalShell`
3. RPG 运行时像素风弹窗:`RpgAdventurePanelOverlays``AdventureEntityModal``NpcModals`
## 验收标准
- 新增弹窗优先使用 `UnifiedModal`,不再手写完整 overlay + panel 结构。
- 迁移后的弹窗保留原有移动端和桌面布局。
- 关闭按钮、遮罩关闭、Escape 行为一致,`closeDisabled` 时都不会关闭。
- 类型检查、编码检查通过。

View File

@@ -188,14 +188,14 @@ MVP 阶段建议采用最稳妥规则:
1. 用户名密码注册
2. 游客正式入口
3. 账号密码找回
3. 邮箱登录
4. 实名认证
5. 社交好友体系
6. 多微信绑定同一账号
说明:
当前用户名密码模式可仅保留为开发环境兜底能力,不作为正式前台入口
密码登录不是注册入口,也不是邮箱入口;它只作为手机号验证码登录的补充方式。用户必须先通过手机号验证码登录形成正式账号,并在已登录账号中心设置过密码后,后续才能用“手机号 + 密码”登录
---
@@ -388,6 +388,24 @@ MVP 阶段建议采用最稳妥规则:
MVP 阶段不需要单独设置密码。
## 6.1.1 密码登录补充方式
密码登录只补充手机号验证码登录,不建立新的账号体系。
落地规则:
- 入参只允许 `phone``password`,不支持邮箱、用户名或叙世号。
- 手机号不存在时,不创建账号,返回统一的登录失败。
- 手机号存在但账号未设置过密码时,不允许密码登录。
- 首次设置密码只能在已登录账号中心内完成;用户必须先通过手机号验证码或已绑定手机号的微信账号进入已登录态。
- 忘记密码 / 重置密码必须先完成该手机号的短信验证码校验;手机号不存在时不创建账号。
前台约束:
- 密码页签的账号输入框文案固定为 `手机号`
- 密码页签主按钮固定为 `登录`,不能出现 `注册/登录`
- 短信验证码页签可继续承担“手机号不存在时创建正式账号并登录”的能力,但按钮文案不应暗示密码注册。
## 6.2 微信登录
微信登录按终端拆分:
@@ -611,7 +629,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
因此本期不是推翻重做,而是:
1. 保留 `users` 作为账号主表
2. 废弃“用户名密码自动注册”作为正式入口
2. 废弃“用户名密码自动注册”作为任何正式入口
3. 增加手机号与微信身份层
4. 增加验证码表与会话表
@@ -619,7 +637,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
## 8. 接口设计
所有接口均由 Express 后端承接。
所有接口均由 `server-rs` 后端承接。
## 8.1 手机号登录相关
@@ -698,6 +716,28 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
## 8.3 会话与账号信息
### `POST /api/auth/entry`
用途:
- 使用已设置密码的手机号账号登录
入参:
- `phone`
- `password`
出参:
- `token`
- `user`
约束:
- 不支持邮箱、用户名或叙世号。
- 不承担注册能力。
- 只有已存在、已验证手机号、且 `passwordLoginEnabled=true` 的账号可以登录。
### `GET /api/auth/me`
返回建议扩展为:

View File

@@ -0,0 +1,26 @@
# 创作中心退出登录私有缓存清理修复 2026-04-25
## 问题
点击退出登录后,页面未刷新时仍能切到创作中心,并看到上一位登录用户的作品。刷新页面后才恢复正常。
## 根因
1. `AuthGate` 的退出动作先等待 `/api/auth/logout` 完成,再通过全局鉴权事件重新 hydrate期间前端 context 仍可能暴露旧用户。
2. 平台创作入口里的 RPG works 会在 `canReadProtectedData=false` 时清空,但大鱼吃小鱼与拼图 works 是 `PlatformEntryFlowShellImpl` 内部 state没有在退出登录时同步清空。
3. 创作 Tab 会保持挂载以降低闪烁,因此私有作品数组只要留在内存里,就会继续被货架组件渲染。
## 修复口径
1. 用户触发退出当前设备或退出全部设备时,前端必须先本地收回 `user / canAccessProtectedData`,再等待后端吊销会话。
2. `canReadProtectedData``true` 变为未登录态 `false` 时,创作中心必须清空所有私有作品缓存:
- RPG works / library 由 `useRpgEntryBootstrap` 清空。
- Big Fish works、Puzzle works 由 `PlatformEntryFlowShellImpl` 清空。
- 当前创作工作区、结果页、删除忙碌态与生成态一并复位。
3. 公开广场与分类数据不受影响,仍按匿名公开接口读取。
## 验收
1. 点击退出登录后,不刷新页面进入创作 Tab只能看到空作品货架不再出现上一账号作品。
2. 退出登录瞬间 `AuthUiContext.user``null``canAccessProtectedData=false`
3. 重新登录后按新账号重新拉取作品列表,不复用旧账号内存缓存。

View File

@@ -0,0 +1,31 @@
# Jenkins 部署环境文件 BOM 修复
日期:`2026-04-25`
## 1. 问题
Jenkins 部署阶段执行固定目录内的 `start.sh` 时失败:
```text
/var/lib/jenkins/deploy/Genarrative/.env.local: line 1: VITE_LLM_BASE_URL=...: No such file or directory
```
根因是 `.env.local` 第一行包含 UTF-8 BOM。旧版 `start.sh` 直接 `source .env.local`BOM 会成为变量名前缀Bash 无法按赋值语句解析,进而把整行当作命令执行。日志末尾的 sudo 提示只是 hook 执行失败后的兜底提示,不是本次失败的真实根因。
## 2. 修复口径
1. 发布包构建脚本复制 `.env``.env.local` 到发布目录和 `web/` 目录后,统一移除 UTF-8 BOM 与 CRLF。
2. Jenkins 部署脚本在移动发布产物前后,再次净化发布目录和固定部署目录中的 `.env``.env.local`,兼容已经构建出来但尚未部署成功的旧发布包。
3. 新生成的 `start.sh` 不再直接 `source` 环境文件,而是按 `KEY=value` 子集解析、导出合法变量,并跳过空行、注释和不合法行。
4. `start.sh` 仍保留 `.env` 先于 `.env.local` 的加载顺序,后加载的 `.env.local` 可以覆盖默认配置。
## 3. 运行边界
1. 环境文件应保持 UTF-8 文本,允许 UTF-8 BOM 和 CRLF但部署脚本会在发布目录中消除它们。
2. 环境变量名必须符合 `[A-Za-z_][A-Za-z0-9_]*`
3. 值支持不加引号、双引号和单引号;复杂 shell 表达式不会执行,避免把环境文件变成脚本入口。
4. 业务密钥仍通过目标服务器环境变量或发布目录 `.env.local` 管理,不写入 Jenkinsfile。
## 4. 失败现场恢复
如果 Jenkins 已经生成了失败版本,可以在拉取本次脚本修复后直接重跑部署流水线。`scripts/jenkins-deploy-release.sh` 会在执行新版本 `start.sh` 前净化已有发布目录,因此不要求手工编辑服务器上的 `.env.local`

View File

@@ -99,7 +99,7 @@ scripts/jenkins-deploy-release.sh \
如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo否则部署会直接失败不会进入交互式密码提示。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 仍会以构建产物中的文件为准。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 仍会以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF避免 `start.sh` 在 Bash 下把首行变量名误解析成命令
### 4.3 构建并部署

View File

@@ -1,6 +1,6 @@
# 密码登录历史落地设计
# 密码登录入口历史落地设计
> 2026-04-24 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经设置密码的账号。密码修改与重置以 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准;本文中“密码自动建号”仅保留为历史基线说明,不再作为当前落地依据
> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。
日期:`2026-04-21`
@@ -8,17 +8,25 @@
这份文档用于指导 `M2` 中以下两条任务的首版落地:
1. `实现密码登录`
2. `实现账号自动创建 / 幂等登录兼容策略`
1. `实现手机号密码登录`
2. `移除密码登录自动注册 / 自动建号语义`
目标是先把当前 Node 已经稳定运行的 `/api/auth/entry` 语义迁到 Rust 工作区,并冻结:
目标是 `/api/auth/entry` Rust 工作区冻结为手机号验证码账号的补充登录方式
1. `api-server` 对外暴露的最小兼容接口。
2. `module-auth` 负责的密码登录用例边界
3. 自动建号与并发幂等兼容规则
1. `api-server` 对外暴露 `phone + password` 的最小接口。
2. `module-auth` 负责已存在手机号账号的密码校验
3. 密码入口不创建账号,不接收邮箱、用户名或叙世号
4. 登录成功后与 JWT、refresh cookie 的衔接方式。
## 2. 当前基线
## 1.1 当前冻结结论
1. 密码登录不是注册入口。
2. 密码登录是手机号验证码登录的补充方式。
3. 只有已存在、已绑定手机号、并已设置密码的账号可以通过密码登录。
4. 未知手机号、未设置密码、密码错误统一返回 `401 UNAUTHORIZED`,避免通过密码入口探测账号状态。
5. 手机号验证码登录仍是新用户注册/首次登录的唯一入口。
## 2. 历史基线
当前 Node `/api/auth/entry` 主链已经具备如下语义:
@@ -29,7 +37,7 @@
5. 同时创建 refresh session并把原始 refresh token 写入 HttpOnly cookie。
6. 并发创建同一用户名时,后到的请求会回退为“查已存在账号并校验密码”,不因唯一键冲突直接失败。
这条链路既是当前前端匿名/游客恢复的基础,也是真实 `/api/auth/entry` contract 的既有事实,因此 Rust 首版必须兼容
这条链路曾经是前端匿名/游客恢复的基础。2026-04-25 起该历史语义已废弃Rust 当前实现必须以“手机号账号已设置密码后登录”为准,不再兼容密码自动建号
## 3. 设计输入
@@ -41,12 +49,12 @@
4. [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
5. [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
关键冻结点:
当前冻结点:
1. `password_hash` 当前继续由 `user_account` 承担,不进入 `auth_identity`
2. `sub` 必须是稳定 `user_id`
3. 登录成功后必须继续同时生成 access token 和 refresh session。
4. 自动建号兼容必须保留,不能因为迁到 Rust 就删除
4. 密码登录不再保留自动建号兼容,旧开发游客自动建号链路必须迁出 `/api/auth/entry`
## 4. 首版落地范围
@@ -54,14 +62,14 @@
1. `module-auth` 中的密码登录用例。
2. `api-server` 中的 `POST /api/auth/entry`
3. 用户名校验、密码哈希校验与自动建号
3. 手机号归一化、密码哈希校验与未设置密码拒绝
4. 登录成功后的 access token 与 refresh cookie 主链打通。
本阶段明确不包含:
1. SpacetimeDB 真正的 `user_account` / `refresh_session` reducer 写入。
2. `/api/auth/me``/api/auth/logout``/api/auth/refresh` 的正式业务闭环。
3. 手机验证码与微信登录链路。
3. 新增邮箱登录或独立密码注册链路。
## 5. crate 边界
@@ -69,9 +77,9 @@
负责:
1. 用户名与密码的领域校验。
1. 手机号与密码的领域校验。
2. 密码登录主用例。
3. 自动建号与并发幂等兼容策略
3. 已存在手机号账号与已设置密码约束
4. 输出登录成功所需的最小用户快照。
不负责:
@@ -106,11 +114,11 @@
### 6.1 请求体
固定沿用当前 contract
当前 contract
```json
{
"username": "guest_001",
"phone": "13800138000",
"password": "secret123"
}
```
@@ -124,9 +132,9 @@
"token": "<access-token>",
"user": {
"id": "user_xxx",
"username": "guest_001",
"displayName": "guest_001",
"phoneNumberMasked": null,
"username": "phone_xxx",
"displayName": "138****8000",
"phoneNumberMasked": "138****8000",
"loginMethod": "password",
"bindingStatus": "active",
"wechatBound": false
@@ -136,11 +144,11 @@
同时响应头必须写回 refresh cookie。
## 7. 用户名与密码规则
## 7. 手机号与密码规则
当前阶段继续对齐 Node 基线
当前阶段固定
1. `username` 只允许 `3``24` 位字母、数字、下划线
1. `phone` 只接受中国大陆手机号,服务端统一归一化为 `E.164` 后查询
2. `password` 长度必须在 `6``128` 位之间。
任一校验失败时:
@@ -148,37 +156,30 @@
1. 返回 `400 BAD_REQUEST`
2. 错误文案继续保持中文
## 8. 自动建号与幂等兼容
## 8. 登录校验规则
### 8.1 自动建
### 8.1 未知手机
`username` 不存在时:
`phone` 归一化后找不到账号时:
1. 用当前请求里的 `password` 生成密码哈希
2. 创建一条本地账号。
3. `display_name = username`
4. `login_provider = password`
5. `account_status = active`
6. `token_version = 1`
1. 返回 `401 UNAUTHORIZED`
2. 创建账号。
3. 不写 `password_hash`
### 8.2 已存在账号
### 8.2 未设置密码
`username` 已存在时:
账号存在但 `password_login_enabled = false` 时:
1. 返回 `401 UNAUTHORIZED`
2. 不区分“未设置密码”和“密码错误”的外部文案。
### 8.3 已设置密码
当账号存在且已设置密码时:
1. 校验密码哈希。
2. 校验失败返回 `401 UNAUTHORIZED`
3. 校验成功继续登录
### 8.3 并发幂等兼容
若两个请求并发创建同一用户名:
1. 允许其中一个请求先创建成功。
2. 后一个请求若命中唯一键冲突,不直接失败。
3. 后一个请求必须重新查询该用户名。
4. 若查到账号,则按“已存在账号”路径继续校验密码。
这保证了当前前端重复调用 `/api/auth/entry` 时可以恢复同一账号,而不是随机失败。
3. 校验成功签发 access token 与 refresh cookie
## 9. 首版存储策略
@@ -226,10 +227,10 @@
当前阶段至少覆盖:
1. 首次密码登录自动建号成功
2. 同用户名同密码可重复登录同一账号
3.用户名不同密码返回 `401`
4. 非法用户名返回 `400`
1. 未知手机号密码登录返回 `401`,且不创建账号
2. 已登录手机号账号设置密码后可用 `phone + password` 登录
3.手机号错误密码返回 `401`
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`
5. 登录成功时返回 access token。
6. 登录成功时写回 refresh cookie。
@@ -239,7 +240,7 @@
1. `module-auth` 不再只是 README占位被真实 crate 实现替换。
2. `POST /api/auth/entry` 可在 Rust 侧独立跑通。
3. 自动建号与幂等兼容行为可验证。
3. 密码入口不注册、不接收邮箱/用户名的行为可验证。
4. JWT 与 refresh cookie 登录成功主链打通。
5. 文档、任务清单与测试同步完成。

View File

@@ -18,8 +18,8 @@
沿用现有 `POST /api/auth/entry`
1. 请求字段沿用 `username``password`前端固定把手机号填入 `username`
2. 后端优先按标准手机号归一化后查找账号,兼容历史用户名只作为开发游客兜底能力
1. 请求字段固定为 `phone``password`,前端只提交手机号
2. 后端按标准手机号归一化后查找账号,兼容邮箱、用户名、叙世号或历史开发游客标识
3. 手机号不存在时返回 `401`,不创建账号。
4. 手机号存在但未设置密码时返回 `401`
5. 校验成功后签发 access token并写入 refresh cookie。
@@ -41,7 +41,7 @@
1. 不需要 Bearer 登录态。
2. 请求字段:`phone``code``newPassword`
3. 使用 `reset_password` 短信场景校验验证码。
4. 手机号不存在时返回 `404`,避免用密码重置隐式注册账号。
4. 手机号不存在时返回 `401`,避免用密码重置隐式注册账号,并避免泄露手机号注册状态
5. 重置成功后签发新的 access token并写入 refresh cookie便于用户直接进入登录态。
### 2.4 发送重置验证码
@@ -62,7 +62,7 @@
登录弹窗不再拆独立注册页签:
1. 面板直接展示手机号和密码输入,用于已设置密码账号登录。
2. 登录按钮文本固定为 `注册/登录`避免用户在登录和首次进入之间做页面切换
2. 密码登录按钮文本固定为 `登录`不允许暗示密码入口具备注册能力
3. 忘记密码入口显示在登录按钮右下侧,点击后仍进入独立重置面板,不在当前表单下方展开。
4. 同一面板保留手机号验证码注册/登录能力,用于新用户自动注册和已注册用户免密码登录。
5. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。

View File

@@ -14,6 +14,7 @@
- [CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md](./CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md):记录角色主形象遇到 DashScope `IPInfringementSuspect` 时自动改用原创安全 prompt 兜底重试的修复口径,并保留供应商原始错误便于排查。
- [CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md](./CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md):记录创作 Agent 用户发送消息后立刻展示三点等待动画的前端展示条件,避免首个 SSE token 到达前聊天区无反馈。
- [CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md](./CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md):冻结 Agent 创作页上传文本类文档并解析为输入框内容的前后端边界、接口、支持范围和验收标准。
- [CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md](./CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md):记录退出登录后创作中心仍显示上一账号作品的前端缓存根因,并冻结退出时立即收回鉴权上下文、清空三类私有作品货架缓存的修复口径。
- [CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md](./CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md):冻结三类作品创作 Agent client 通用工厂与平台轻量流程 controller 的复用边界,明确本轮只收口 HTTP/SSE 骨架和大鱼/拼图会话流程,不合并 RPG 自动保存主链。
- [BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md](./BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md):冻结后端创作 Agent LLM turn 公共化边界,收口模型可用性检查、流式 JSON 回复抽取、最终 JSON 解析与中文错误映射,玩法 schema 和写回逻辑继续留在各自模块。
- [CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md](./CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md):冻结创作中心作品货架统一视图模型,先在前端归一 RPG、大鱼、拼图 works 的展示字段、筛选状态和卡片动作语义,不新增后端聚合接口。
@@ -42,6 +43,7 @@
- [CREATION_HUB_CARD_ACTIONS_2026-04-22.md](./CREATION_HUB_CARD_ACTIONS_2026-04-22.md):冻结创作中心作品卡“体验 / 删除”入口的最小落地语义,明确 RPG 已发布作品软删除、卡片直达运行时,以及暂不扩草稿 / 拼图删除契约。
- [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。
- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本与 `/home/ubuntu/Genarrative-deploy/` 覆盖策略。
- [JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md](./JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md):记录 Jenkins 部署时 `.env.local` 首行 UTF-8 BOM 导致 `start.sh` 加载失败的根因,并冻结发布包构建、部署脚本和启动脚本的环境文件净化规则。
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust``npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传和安全清库开关。
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 101 条 Axum 路由,并补充管理后台入口与管理接口索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。

View File

@@ -116,9 +116,9 @@ npm run deploy:rust:remote
3. 使用 Vite 构建前端 release 到目标目录的 `web/`
4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
6. 把仓库根目录的 `.env``.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下。
6. 把仓库根目录的 `.env``.env.local` 分别复制到目标目录根部和目标目录的 `web/`;复制后统一移除 UTF-8 BOM 与 CRLF避免目标服务器 Bash 加载环境文件失败
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*``/generated-*``/healthz` 反代到本包内的 `api-server`
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先加载发布目录根部的 `.env``.env.local`,再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先`KEY=value` 子集加载发布目录根部的 `.env``.env.local`兼容 UTF-8 BOM 与 CRLF再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
发布包结构:
@@ -160,10 +160,11 @@ cd build/<timestamp>
1. 构建脚本会把仓库根目录已有的 `.env``.env.local` 一并复制进发布包,因此运行前必须确认这些文件内容适合被带入目标环境。
2. 如果仓库根目录不存在 `.env``.env.local`,脚本会打印跳过日志,但不会因此失败;此时 `start.sh` 仅使用构建时写入的默认值与运行时显式传入的环境变量。
3. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database` 才追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发
4. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm清库模式下会追加 `-c=on-conflict`在 schema 冲突时删除旧模块数据。
5. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置
6. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传
3. `start.sh` 只解析合法 `KEY=value` 环境行,支持不加引号、双引号和单引号;不执行复杂 shell 表达式,避免把环境文件变成脚本入口
4. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database`追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发
5. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm清库模式下会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据
6. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置
7. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传。
目标服务器最小要求:

View File

@@ -206,6 +206,8 @@
1. 密码登录仍由 `user_account.password_hash` 承担
2. 本轮不引入 `password` provider identity
3. 密码登录只接受已绑定手机号的账号,不支持邮箱、用户名或叙世号作为登录身份
4. 密码登录不创建账号,新账号只由手机号验证码登录创建
### 9.2 `POST /api/auth/phone/login`

View File

@@ -18,7 +18,7 @@
当前 Node 鉴权主链已经依赖 `users` 主表完成以下能力:
1. `POST /api/auth/entry`用户名密码登录,不存在则自动创建账号
1. `POST /api/auth/entry`手机号密码登录,仅允许已存在且已设置密码的手机号账号登录
2. `POST /api/auth/phone/login`:手机号验证码登录,不存在则自动创建账号
3. `GET /api/auth/me`:读取当前账号基础信息
4. `POST /api/auth/logout`:提升 `token_version`,让当前 access token 失效
@@ -99,8 +99,9 @@
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `user_id` | `String` | 是 | 主键,继续沿用当前 `user_*` 前缀格式。 |
| `username` | `String` | 是 | 当前密码登录用户名;手机号/微信创建的系统账号同样要写入唯一用户名。 |
| `password_hash` | `String` | | 密码登录校验字段;手机号/微信建账号时继续写随机密码哈希,保持兼容。 |
| `username` | `String` | 是 | 系统账号名;不再作为前台密码登录标识,手机号/微信创建账号时仍写入唯一系统用户名。 |
| `password_hash` | `Option<String>` | | 用户显式设置或重置密码后才写入;手机号/微信建账号默认不可用密码登录。 |
| `password_login_enabled` | `bool` | 是 | 是否允许密码登录;只有用户设置或重置密码后才为 `true`。 |
| `token_version` | `u32` | 是 | access token 统一失效计数,默认 `1`。 |
| `display_name` | `String` | 是 | 账号展示名;密码账号默认用户名,手机号账号默认脱敏手机号,微信待绑定账号默认微信昵称或“微信旅人”。 |
| `login_provider` | `String` | 是 | 当前账号的主登录归属,枚举固定为 `password``phone``wechat`。 |
@@ -131,9 +132,9 @@
### 6.2 必须具备的查询索引
1. `username`
作用:支撑 `POST /api/auth/entry`
作用:系统账号唯一约束与内部排查,不作为前台密码登录入口
2. `primary_phone_e164`
作用:支撑 `POST /api/auth/phone/login``POST /api/auth/phone/change`
作用:支撑 `POST /api/auth/entry``POST /api/auth/phone/login``POST /api/auth/phone/change`
3. `account_status + updated_at`
作用:后续管理端、审计排查与禁用账号扫描
4. `merged_to_user_id`
@@ -188,13 +189,12 @@
写入规则:
1. 先按 `username` 查询
2. 若不存在,则创建一条 `active` 账号
3. `login_provider = password`
4. `display_name = username`
5. `primary_phone_e164 = null`
6. `phone_verified_at = null`
7. `last_login_at = 当前时间`
1. 只读取请求中的 `phone``password`
2. 先把 `phone` 归一化为 `primary_phone_e164` 后查询账号
3. 若手机号不存在,返回 `401`,不创建账号。
4. 若账号存在但 `password_login_enabled = false``password_hash = null`,返回 `401`
5. 若账号存在且已设置密码,校验 `password_hash`
6. 校验成功后只更新登录会话与 `last_login_at`,不改变账号主归属。
### 8.2 `POST /api/auth/phone/login`

View File

@@ -47,7 +47,7 @@ SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default';
### `user_account`
- 作用:用户账号主表,保存用户名、公开叙世号、手机号掩码、登录方式、密码登录开关和 token 版本。
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`
- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `phone_number_masked: Option<String>`, `phone_number_e164: Option<String>`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: Option<String>`, `password_login_enabled: bool`, `token_version: u64`
- 索引:`username`, `public_user_code`
```sql

View File

@@ -23,7 +23,7 @@ export type PublicUserSearchResponse = {
};
export type AuthEntryRequest = {
username: string;
phone: string;
password: string;
};

View File

@@ -57,6 +57,19 @@ copy_required_file() {
cp "${source_path}" "${target_path}"
}
normalize_env_file() {
local env_file="$1"
local temp_file="${env_file}.tmp.$$"
if [[ ! -f "${env_file}" ]]; then
return
fi
# 发布环境文件可能由 Windows 编辑器保存,启动脚本只接受无 BOM、无 CRLF 的 KEY=value 文本。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
mv "${temp_file}" "${env_file}"
}
copy_optional_file() {
local source_path="$1"
local target_path_a="$2"
@@ -70,6 +83,8 @@ copy_optional_file() {
cp "${source_path}" "${target_path_a}"
cp "${source_path}" "${target_path_b}"
normalize_env_file "${target_path_a}"
normalize_env_file "${target_path_b}"
echo "[deploy:rust] 已复制 ${label} -> ${target_path_a}${target_path_b}"
}
@@ -426,17 +441,47 @@ cd "${SCRIPT_DIR}"
load_env_file() {
local env_file="$1"
local line=""
local line_number=0
local key=""
local value=""
local utf8_bom=$'\xef\xbb\xbf'
if [[ ! -f "${env_file}" ]]; then
return
fi
echo "[start] 加载环境文件: ${env_file}"
set -a
# 发布包内环境文件由当前构建脚本生成,允许在启动时作为默认环境源加载
# shellcheck disable=SC1090
source "${env_file}"
set +a
# 环境文件按 dotenv 的 KEY=value 子集解析,避免 BOM 被 shell 当成命令名执行
while IFS= read -r line || [[ -n "${line}" ]]; do
line_number=$((line_number + 1))
if [[ "${line_number}" -eq 1 ]]; then
line="${line#"${utf8_bom}"}"
fi
line="${line%$'\r'}"
if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then
continue
fi
if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
echo "[start] 跳过不符合 KEY=value 的环境行: ${env_file}:${line_number}" >&2
continue
fi
key="${BASH_REMATCH[2]}"
value="${BASH_REMATCH[3]}"
if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
value="${value:1:${#value}-2}"
value="${value//\\\"/\"}"
elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
value="${value:1:${#value}-2}"
fi
printf -v "${key}" '%s' "${value}"
export "${key}"
done <"${env_file}"
}
usage() {
@@ -655,6 +700,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
## 环境变量
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
- 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数。
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`

View File

@@ -32,6 +32,28 @@ require_argument() {
fi
}
normalize_env_file() {
local env_file="$1"
local temp_file="${env_file}.tmp.$$"
if [[ ! -f "${env_file}" ]]; then
return
fi
# 兼容由 Windows 编辑器或 Jenkins 参数落盘产生的 BOM/CRLF避免 start.sh 加载时报命令不存在。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
mv "${temp_file}" "${env_file}"
}
normalize_release_env_files() {
local release_dir="$1"
normalize_env_file "${release_dir}/.env"
normalize_env_file "${release_dir}/.env.local"
normalize_env_file "${release_dir}/web/.env"
normalize_env_file "${release_dir}/web/.env.local"
}
SOURCE_DIR=""
DEPLOY_DIR=""
CLEAR_DATABASE="0"
@@ -125,6 +147,8 @@ if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then
exit 1
fi
normalize_release_env_files "${SOURCE_DIR}"
if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then
echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}"
run_hook "${DEPLOY_DIR}" "stop.sh"
@@ -154,6 +178,8 @@ if [[ -f "${DEPLOY_DIR}/stop.sh" ]]; then
chmod +x "${DEPLOY_DIR}/stop.sh"
fi
normalize_release_env_files "${DEPLOY_DIR}"
echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}"
if [[ "${CLEAR_DATABASE}" == "1" ]]; then
echo "[jenkins-deploy] 以清库模式启动新版本"

View File

@@ -642,13 +642,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "ai_tasks_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138100", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -659,7 +655,7 @@ mod tests {
session_id: "sess_ai_tasks".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("AI 任务用户".to_string()),

View File

@@ -1031,6 +1031,7 @@ pub fn build_router(state: AppState) -> Router {
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
@@ -1048,6 +1049,40 @@ mod tests {
use super::build_router;
const TEST_PASSWORD: &str = "secret123";
async fn seed_phone_user_with_password(
state: &AppState,
phone_number: &str,
password: &str,
) -> module_auth::AuthUser {
state
.seed_test_phone_user_with_password(phone_number, password)
.await
}
async fn password_login_request(
app: Router,
phone_number: &str,
password: &str,
) -> axum::response::Response {
app.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": phone_number,
"password": password
})
.to_string(),
))
.expect("password login request should build"),
)
.await
.expect("password login request should succeed")
}
#[tokio::test]
async fn healthz_returns_legacy_compatible_payload_and_headers() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -1162,24 +1197,17 @@ mod tests {
async fn internal_auth_claims_returns_verified_claims() {
let config = AppConfig::default();
let state = AppState::new(config.clone()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "guest_auth_debug".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
user_id: seed_user.id.clone(),
session_id: "sess_auth_debug".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: seed_user.token_version,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("测试用户".to_string()),
display_name: Some(seed_user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
@@ -1210,17 +1238,14 @@ mod tests {
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["claims"]["sub"],
Value::String("user_00000001".to_string())
);
assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id));
assert_eq!(
payload["claims"]["sid"],
Value::String("sess_auth_debug".to_string())
);
assert_eq!(
payload["claims"]["ver"],
Value::Number(serde_json::Number::from(1))
Value::Number(serde_json::Number::from(seed_user.token_version))
);
}
@@ -1293,26 +1318,21 @@ mod tests {
}
#[tokio::test]
async fn password_entry_creates_user_and_sets_refresh_cookie() {
async fn password_entry_rejects_unknown_phone_without_registration() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
let response = password_login_request(app, "13800138011", TEST_PASSWORD).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138012", TEST_PASSWORD).await;
let app = build_router(state);
let response = password_login_request(app, "13800138012", TEST_PASSWORD).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(
@@ -1332,9 +1352,10 @@ mod tests {
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
assert_eq!(
payload["user"]["username"],
Value::String("guest_001".to_string())
payload["user"]["loginMethod"],
Value::String("password".to_string())
);
assert!(payload["token"].as_str().is_some());
}
@@ -1371,7 +1392,7 @@ mod tests {
assert_eq!(
payload["availableLoginMethods"],
serde_json::json!(["phone", "wechat"])
serde_json::json!(["phone", "password", "wechat"])
);
}
@@ -2232,7 +2253,9 @@ mod tests {
#[tokio::test]
async fn auth_sessions_returns_multi_device_session_fields() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await;
let app = build_router(state);
let first_login_response = app
.clone()
@@ -2248,8 +2271,8 @@ mod tests {
.header("x-client-instance-id", "chrome-instance-001")
.body(Body::from(
serde_json::json!({
"username": "guest_sessions_api",
"password": "secret123"
"phone": "13800138013",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -2292,8 +2315,8 @@ mod tests {
.header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger")
.body(Body::from(
serde_json::json!({
"username": "guest_sessions_api",
"password": "secret123"
"phone": "13800138013",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -2346,27 +2369,13 @@ mod tests {
}
#[tokio::test]
async fn password_entry_reuses_same_user_for_same_credentials() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
async fn password_entry_reuses_same_user_for_same_phone() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138014", TEST_PASSWORD).await;
let app = build_router(state);
let first_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("first request should succeed");
let first_response =
password_login_request(app.clone(), "13800138014", TEST_PASSWORD).await;
let first_body = first_response
.into_body()
.collect()
@@ -2376,23 +2385,7 @@ mod tests {
let first_payload: Value =
serde_json::from_slice(&first_body).expect("first payload should be json");
let second_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("second request should succeed");
let second_response = password_login_request(app, "13800138014", TEST_PASSWORD).await;
let second_body = second_response
.into_body()
.collect()
@@ -2402,54 +2395,23 @@ mod tests {
let second_payload: Value =
serde_json::from_slice(&second_body).expect("second payload should be json");
assert_eq!(first_payload["user"]["id"], Value::String(seed_user.id));
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
}
#[tokio::test]
async fn password_entry_rejects_wrong_password() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138015", TEST_PASSWORD).await;
let app = build_router(state);
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("seed request should succeed");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_001",
"password": "secret999"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
let response = password_login_request(app, "13800138015", "secret999").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn password_entry_rejects_invalid_username() {
async fn password_entry_rejects_email_or_username_identifier() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
@@ -2460,8 +2422,8 @@ mod tests {
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "无效用户",
"password": "secret123"
"phone": "user@example.com",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -2481,24 +2443,17 @@ mod tests {
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
user_id: seed_user.id.clone(),
session_id: "sess_me_query".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: seed_user.token_version,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some("guest_001".to_string()),
display_name: Some(seed_user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
@@ -2529,13 +2484,10 @@ mod tests {
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["user"]["id"],
Value::String("user_00000001".to_string())
);
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
assert_eq!(
payload["availableLoginMethods"],
serde_json::json!(["phone", "wechat"])
serde_json::json!(["phone", "password", "wechat"])
);
}
@@ -2577,26 +2529,12 @@ mod tests {
#[tokio::test]
async fn refresh_session_rotates_cookie_and_returns_new_access_token() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138017", TEST_PASSWORD).await;
let app = build_router(state);
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_refresh",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
let login_response =
password_login_request(app.clone(), "13800138017", TEST_PASSWORD).await;
let first_cookie = login_response
.headers()
.get("set-cookie")
@@ -2685,26 +2623,12 @@ mod tests {
#[tokio::test]
async fn logout_clears_cookie_and_invalidates_current_access_token() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138018", TEST_PASSWORD).await;
let app = build_router(state);
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_logout_api",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
let login_response =
password_login_request(app.clone(), "13800138018", TEST_PASSWORD).await;
let refresh_cookie = login_response
.headers()
.get("set-cookie")
@@ -2773,26 +2697,12 @@ mod tests {
#[tokio::test]
async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138019", TEST_PASSWORD).await;
let app = build_router(state);
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_logout_no_cookie",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
let login_response =
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
let login_body = login_response
.into_body()
.collect()
@@ -2830,7 +2740,9 @@ mod tests {
#[tokio::test]
async fn logout_all_clears_cookie_and_invalidates_all_sessions() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138020", TEST_PASSWORD).await;
let app = build_router(state);
let first_login_response = app
.clone()
@@ -2845,8 +2757,8 @@ mod tests {
)
.body(Body::from(
serde_json::json!({
"username": "guest_logout_all_api",
"password": "secret123"
"phone": "13800138020",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -2884,8 +2796,8 @@ mod tests {
.header("x-client-instance-id", "logout-all-instance-002")
.body(Body::from(
serde_json::json!({
"username": "guest_logout_all_api",
"password": "secret123"
"phone": "13800138020",
"password": TEST_PASSWORD
})
.to_string(),
))
@@ -2976,26 +2888,12 @@ mod tests {
#[tokio::test]
async fn logout_all_succeeds_without_refresh_cookie_when_bearer_token_is_valid() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138021", TEST_PASSWORD).await;
let app = build_router(state);
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_logout_all_nc",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
let login_response =
password_login_request(app.clone(), "13800138021", TEST_PASSWORD).await;
let login_body = login_response
.into_body()
.collect()
@@ -3079,26 +2977,11 @@ mod tests {
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let state = AppState::new(config).expect("state should build");
seed_phone_user_with_password(&state, "13800138022", TEST_PASSWORD).await;
let app = build_router(state.clone());
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_admin_forbidden",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login should succeed");
let login_response =
password_login_request(app.clone(), "13800138022", TEST_PASSWORD).await;
let login_body = login_response
.into_body()
.collect()

View File

@@ -34,6 +34,7 @@ pub async fn auth_me(
user: map_auth_user_payload(user.user),
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
true,
state.config.wechat_auth_enabled,
),
},

View File

@@ -64,7 +64,7 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr
}
module_auth::PasswordEntryError::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidUsername
| module_auth::PasswordEntryError::InvalidPhoneNumber
| module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidCredentials
| module_auth::PasswordEntryError::UserNotFound => {

View File

@@ -257,13 +257,9 @@ mod tests {
async fn seed_authenticated_state(config: AppConfig) -> AppState {
let state = AppState::new(config).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "llm_proxy_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138101", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -274,7 +270,7 @@ mod tests {
session_id: "sess_llm_proxy".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("LLM 代理用户".to_string()),

View File

@@ -15,6 +15,7 @@ pub async fn auth_login_options(
AuthLoginOptionsResponse {
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
true,
state.config.wechat_auth_enabled,
),
},

View File

@@ -29,7 +29,7 @@ pub async fn password_entry(
let result = state
.password_entry_service()
.execute(PasswordEntryInput {
username: payload.username,
phone_number: payload.phone,
password: payload.password,
})
.await
@@ -64,10 +64,10 @@ pub async fn password_entry(
fn map_password_entry_error(error: PasswordEntryError) -> AppError {
match error {
PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST)
PasswordEntryError::InvalidPhoneNumber => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("手机号格式不正确")
.with_details(json!({
"field": "username",
"field": "phone",
})),
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("密码长度需要在 6 到 128 位之间")
@@ -77,7 +77,7 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("叙世号格式不正确")
.with_details(json!({
"field": "username",
"field": "phone",
})),
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")

View File

@@ -100,7 +100,7 @@ pub async fn reset_password(
fn map_password_management_error(error: PasswordEntryError) -> AppError {
match error {
PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPublicUserCode => {
PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)

View File

@@ -422,13 +422,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "browse_history_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138102", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -439,7 +435,7 @@ mod tests {
session_id: "sess_runtime_browse_history".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("浏览历史用户".to_string()),

View File

@@ -164,13 +164,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_inventory_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138103", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -181,7 +177,7 @@ mod tests {
session_id: "sess_runtime_inventory".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("背包查询用户".to_string()),

View File

@@ -481,13 +481,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_profile_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138104", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -498,7 +494,7 @@ mod tests {
session_id: "sess_runtime_profile".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("资料页用户".to_string()),

View File

@@ -380,13 +380,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_save_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138105", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -397,7 +393,7 @@ mod tests {
session_id: "sess_runtime_save".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("存档用户".to_string()),

View File

@@ -340,13 +340,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_settings_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138106", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -357,7 +353,7 @@ mod tests {
session_id: "sess_runtime_settings".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("设置用户".to_string()),

View File

@@ -1986,13 +1986,9 @@ fn runtime_story_dialogue_current_story_keeps_continue_and_deferred_options() {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_story_state_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138109", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -2003,7 +1999,7 @@ fn issue_access_token(state: &AppState) -> String {
session_id: "sess_runtime_story_state".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("运行时剧情状态用户".to_string()),

View File

@@ -38,6 +38,7 @@ pub struct AppState {
admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig,
oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore,
password_entry_service: PasswordEntryService,
refresh_session_service: RefreshSessionService,
@@ -96,6 +97,9 @@ pub enum AppStateInitError {
impl AppState {
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
#[cfg(test)]
let auth_store = InMemoryAuthStore::default();
#[cfg(not(test))]
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
.map_err(AppStateInitError::AuthStore)?;
Self::new_with_auth_store(config, auth_store)
@@ -206,19 +210,27 @@ impl AppState {
}
pub async fn sync_auth_store_snapshot_to_spacetime(&self) -> Result<(), SpacetimeClientError> {
#[cfg(test)]
return Ok(());
#[cfg(not(test))]
let snapshot_json = self
.auth_store
.export_snapshot_json()
.map_err(SpacetimeClientError::Runtime)?;
#[cfg(not(test))]
let updated_at_micros = i64::try_from(
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
#[cfg(not(test))]
self.spacetime_client
.upsert_auth_store_snapshot(snapshot_json, updated_at_micros)
.await?;
// ?????????????????????????????????
#[cfg(not(test))]
self.spacetime_client.import_auth_store_snapshot().await?;
#[cfg(not(test))]
Ok(())
}
@@ -401,6 +413,47 @@ impl AppState {
#[cfg(test)]
impl AppState {
pub(crate) async fn seed_test_phone_user_with_password(
&self,
phone_number: &str,
password: &str,
) -> module_auth::AuthUser {
let now = OffsetDateTime::now_utc();
self.phone_auth_service()
.send_code(
module_auth::SendPhoneCodeInput {
phone_number: phone_number.to_string(),
scene: module_auth::PhoneAuthScene::Login,
},
now,
)
.await
.expect("test phone code should send");
let user = self
.phone_auth_service()
.login(
module_auth::PhoneLoginInput {
phone_number: phone_number.to_string(),
verify_code: "123456".to_string(),
},
now + time::Duration::seconds(1),
)
.await
.expect("test phone login should create user")
.user;
let changed = self
.password_entry_service()
.change_password(module_auth::ChangePasswordInput {
user_id: user.id.clone(),
current_password: None,
new_password: password.to_string(),
})
.await
.expect("test password should set");
changed.user
}
fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) {
self.test_runtime_snapshot_store
.lock()

View File

@@ -797,13 +797,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "story_battles_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138107", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -814,7 +810,7 @@ mod tests {
session_id: "sess_story_battles".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("战斗接口用户".to_string()),

View File

@@ -384,13 +384,9 @@ mod tests {
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "story_sessions_user".to_string(),
password: "secret123".to_string(),
})
.seed_test_phone_user_with_password("13800138108", "secret123")
.await
.expect("seed login should succeed");
.id;
state
}
@@ -401,7 +397,7 @@ mod tests {
session_id: "sess_story_sessions".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("故事会话用户".to_string()),

View File

@@ -18,8 +18,6 @@ use shared_kernel::{
use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
const USERNAME_MIN_LENGTH: usize = 3;
const USERNAME_MAX_LENGTH: usize = 24;
const PASSWORD_MIN_LENGTH: usize = 6;
const PASSWORD_MAX_LENGTH: usize = 128;
const SMS_CODE_LENGTH: usize = 6;
@@ -65,7 +63,7 @@ pub struct PublicUserSearchResult {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub username: String,
pub phone_number: String,
pub password: String,
}
@@ -315,7 +313,7 @@ pub struct AuthStoreSnapshotProcedureResult {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidUsername,
InvalidPhoneNumber,
InvalidPasswordLength,
InvalidPublicUserCode,
InvalidCredentials,
@@ -476,27 +474,16 @@ impl PasswordEntryService {
input: PasswordEntryInput,
) -> Result<PasswordEntryResult, PasswordEntryError> {
validate_password(&input.password)?;
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)
.map_err(|_| PasswordEntryError::InvalidPhoneNumber)?;
let Some(existing_user) = self
.store
.find_by_phone_number_for_password(&normalized_phone.e164)?
else {
return Err(PasswordEntryError::InvalidCredentials);
};
// 登录面板现在固定使用手机号作为密码登录标识;先走手机号索引,
// 再保留历史用户名路径给开发游客和旧测试数据使用。
if let Ok(normalized_phone) = normalize_mainland_china_phone_number(&input.username) {
let Some(existing_user) = self
.store
.find_by_phone_number_for_password(&normalized_phone.e164)?
else {
return Err(PasswordEntryError::InvalidCredentials);
};
return verify_stored_password_user(existing_user, &input.password).await;
}
let username = normalize_username(&input.username)?;
if let Some(existing_user) = self.store.find_by_username(&username)? {
return verify_stored_password_user(existing_user, &input.password).await;
}
Err(PasswordEntryError::InvalidCredentials)
verify_stored_password_user(existing_user, &input.password).await
}
pub fn get_user_by_id(
@@ -1232,17 +1219,6 @@ impl InMemoryAuthStore {
.map_err(RefreshSessionError::Store)
}
fn find_by_username(
&self,
username: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state.users_by_username.get(username).cloned())
}
fn find_by_user_id(
&self,
user_id: &str,
@@ -2087,10 +2063,10 @@ impl AuthBindingStatus {
impl fmt::Display for PasswordEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"),
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("用户名或密码错误"),
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
@@ -2161,7 +2137,7 @@ impl Error for LogoutError {}
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
match error {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidUsername
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
@@ -2176,7 +2152,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
match error {
PasswordEntryError::Store(message) => PhoneAuthError::Store(message),
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidUsername
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
@@ -2187,7 +2163,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
match error {
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidUsername
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
@@ -2215,21 +2191,6 @@ fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError
}
}
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
let username = normalize_required_string(raw_username).unwrap_or_default();
let valid_length =
(USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count());
let valid_chars = username
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_');
if !valid_length || !valid_chars {
return Err(PasswordEntryError::InvalidUsername);
}
Ok(username)
}
fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
let length = password.chars().count();
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
@@ -2255,7 +2216,10 @@ async fn verify_stored_password_user(
}
Ok(PasswordEntryResult {
user: existing_user.user,
user: AuthUser {
login_method: AuthLoginMethod::Password,
..existing_user.user
},
created: false,
})
}
@@ -2501,7 +2465,7 @@ mod tests {
let error = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
phone_number: "13800138000".to_string(),
password: "secret123".to_string(),
})
.await
@@ -2516,7 +2480,7 @@ mod tests {
let user = create_phone_login_user(store.clone(), "13800138000").await;
let service = build_password_service(store);
let changed = service
service
.change_password(ChangePasswordInput {
user_id: user.id.clone(),
current_password: None,
@@ -2526,7 +2490,7 @@ mod tests {
.expect("phone user should set first password");
let result = service
.execute(PasswordEntryInput {
username: changed.user.username.clone(),
phone_number: "13800138000".to_string(),
password: "secret123".to_string(),
})
.await
@@ -2534,7 +2498,7 @@ mod tests {
assert!(!result.created);
assert_eq!(result.user.id, user.id);
assert_eq!(result.user.login_method, AuthLoginMethod::Phone);
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
}
#[tokio::test]
@@ -2553,7 +2517,7 @@ mod tests {
let error = service
.execute(PasswordEntryInput {
username: user.username,
phone_number: "13800138001".to_string(),
password: "secret999".to_string(),
})
.await
@@ -2651,18 +2615,18 @@ mod tests {
}
#[tokio::test]
async fn invalid_username_returns_bad_request_error() {
async fn password_entry_rejects_email_or_username_identifier() {
let service = build_password_service(build_store());
let error = service
.execute(PasswordEntryInput {
username: "坏用户名".to_string(),
phone_number: "user@example.com".to_string(),
password: "secret123".to_string(),
})
.await
.expect_err("invalid username should fail");
.expect_err("email should fail");
assert_eq!(error, PasswordEntryError::InvalidUsername);
assert_eq!(error, PasswordEntryError::InvalidPhoneNumber);
}
#[tokio::test]

View File

@@ -42,7 +42,7 @@ pub struct PublicUserSearchResponse {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PasswordEntryRequest {
pub username: String,
pub phone: String,
pub password: String,
}
@@ -193,12 +193,16 @@ pub struct WechatBindPhoneResponse {
pub fn build_available_login_methods(
sms_auth_enabled: bool,
password_auth_enabled: bool,
wechat_auth_enabled: bool,
) -> Vec<String> {
let mut methods = vec![AUTH_LOGIN_METHOD_PASSWORD.to_string()];
let mut methods = Vec::new();
if sms_auth_enabled {
methods.push(AUTH_LOGIN_METHOD_PHONE.to_string());
}
if password_auth_enabled {
methods.push(AUTH_LOGIN_METHOD_PASSWORD.to_string());
}
if wechat_auth_enabled {
methods.push(AUTH_LOGIN_METHOD_WECHAT.to_string());
}
@@ -212,13 +216,13 @@ mod tests {
#[test]
fn available_login_methods_keep_phone_then_wechat_order() {
let methods = build_available_login_methods(true, true);
let methods = build_available_login_methods(true, true, true);
assert_eq!(
methods,
vec![
AUTH_LOGIN_METHOD_PASSWORD.to_string(),
AUTH_LOGIN_METHOD_PHONE.to_string(),
AUTH_LOGIN_METHOD_PASSWORD.to_string(),
AUTH_LOGIN_METHOD_WECHAT.to_string()
]
);
@@ -227,7 +231,7 @@ mod tests {
#[test]
fn password_entry_request_uses_camel_case_fields() {
let payload = serde_json::to_value(PasswordEntryRequest {
username: "guest_001".to_string(),
phone: "13800138000".to_string(),
password: "secret123".to_string(),
})
.expect("payload should serialize");
@@ -235,7 +239,7 @@ mod tests {
assert_eq!(
payload,
json!({
"username": "guest_001",
"phone": "13800138000",
"password": "secret123"
})
);

View File

@@ -13,10 +13,11 @@ const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
loginWithPhoneCode: vi.fn(),
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
resetPassword: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
@@ -34,7 +35,6 @@ vi.mock('../../services/authService', () => ({
changePassword: authMocks.changePassword,
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: vi.fn(),
getAuthLoginOptions: authMocks.getAuthLoginOptions,
@@ -44,8 +44,8 @@ vi.mock('../../services/authService', () => ({
getCaptchaChallengeFromError: vi.fn(() => null),
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
@@ -96,22 +96,21 @@ beforeEach(() => {
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
authMocks.logoutAuthUser.mockResolvedValue(undefined);
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
});
authMocks.startWechatLogin.mockResolvedValue(undefined);
authMocks.ensureAutoAuthUser.mockResolvedValue({
user: mockUser,
credentials: {
username: 'guest_tester',
password: 'auto_password',
},
});
});
function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => void }) {
function ProtectedActionButton({
onAuthenticated,
}: {
onAuthenticated: () => void;
}) {
const authUi = useAuthUi();
return (
@@ -139,6 +138,27 @@ function PlatformTabStateProbe() {
);
}
function LogoutStateProbe() {
const authUi = useAuthUi();
return (
<div>
<div>{authUi?.user?.displayName ?? '未登录'}</div>
<div>
{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
</div>
<button
type="button"
onClick={() => {
void authUi?.logout();
}}
>
退
</button>
</div>
);
}
test('auth gate keeps platform content visible when phone login is available', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
@@ -153,7 +173,6 @@ test('auth gate keeps platform content visible when phone login is available', a
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
});
test('auth gate waits for access token refresh before exposing restored user content', async () => {
@@ -195,7 +214,6 @@ test('auth gate does not auto-create a guest account when dev guest switch is no
);
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
});
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
@@ -220,7 +238,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
@@ -276,6 +294,40 @@ test('auth state refresh keeps mounted platform content and local tab state', as
expect(screen.getByText('当前Tab创作')).toBeTruthy();
});
test('logout withdraws user context before backend request finishes', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
let resolveLogout!: () => void;
const logoutPromise = new Promise<void>((resolve) => {
resolveLogout = resolve;
});
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
render(
<AuthGate>
<LogoutStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '退出登录' }));
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
await act(async () => {
resolveLogout();
await logoutPromise;
});
});
test('auth gate shows sms send feedback in the login modal', async () => {
const user = userEvent.setup();
@@ -329,24 +381,26 @@ test('login modal resets draft state every time it is reopened', async () => {
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
await user.click(within(firstDialog).getByRole('button', { name: '获取验证码' }));
await user.click(
within(firstDialog).getByRole('button', { name: '获取验证码' }),
);
expect(
await within(firstDialog).findByText('短信请求已提交,验证码有效期约 5 分钟。'),
await within(firstDialog).findByText(
'短信请求已提交,验证码有效期约 5 分钟。',
),
).toBeTruthy();
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
await user.click(within(firstDialog).getByRole('button', { name: '忘记密码' }));
expect(
screen.getByRole('dialog', { name: '重置密码' }),
).toBeTruthy();
await user.click(
screen.getByRole('button', { name: '关闭登录弹窗' }),
within(firstDialog).getByRole('button', { name: '忘记密码' }),
);
expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '关闭登录弹窗' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
@@ -367,7 +421,9 @@ test('login modal resets draft state every time it is reopened', async () => {
).toBe('');
expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull();
expect(
within(reopenedDialog).queryByText('短信请求已提交,验证码有效期约 5 分钟。'),
within(reopenedDialog).queryByText(
'短信请求已提交,验证码有效期约 5 分钟。',
),
).toBeNull();
expect(
within(reopenedDialog).getByRole('button', { name: '获取验证码' }),
@@ -406,9 +462,9 @@ test('auth gate separates sms and password login by tabs', async () => {
).toBe('true');
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号/邮箱'), '13800000000');
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');

View File

@@ -24,7 +24,6 @@ import {
changePassword,
changePhoneNumber,
consumeAuthCallbackResult,
ensureAutoAuthUser,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
@@ -42,10 +41,7 @@ import {
startWechatLogin,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
import {
AuthUiContext,
type PlatformSettingsSection,
} from './AuthUiContext';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
@@ -61,11 +57,6 @@ type AuthStatus =
| 'ready'
| 'error';
const allowDevGuestAutoAuth =
import.meta.env.DEV &&
// 开发游客兜底必须显式开启,避免抢占正式手机号验证码登录入口。
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
@@ -113,6 +104,50 @@ export function AuthGate({ children }: AuthGateProps) {
setStatus('ready');
}, []);
const clearLocalAuthenticatedState = useCallback(() => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowSettingsModal(false);
setInitialSettingsSection(null);
setSessions([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setError('');
}, []);
const logoutCurrentSession = useCallback(async () => {
clearLocalAuthenticatedState();
try {
await logoutAuthUser();
} catch (logoutError) {
setError(
logoutError instanceof Error
? logoutError.message
: '退出登录失败,请刷新页面确认状态。',
);
}
}, [clearLocalAuthenticatedState]);
const logoutAllSessions = useCallback(async () => {
clearLocalAuthenticatedState();
try {
await logoutAllAuthSessions();
} catch (logoutError) {
setError(
logoutError instanceof Error
? logoutError.message
: '退出全部设备失败,请刷新页面确认状态。',
);
}
}, [clearLocalAuthenticatedState]);
const closeLoginModal = useCallback(() => {
pendingProtectedActionRef.current = null;
setShowLoginModal(false);
@@ -160,37 +195,6 @@ export function AuthGate({ children }: AuthGateProps) {
useEffect(() => {
let isActive = true;
const ensureAutoUser = async () => {
if (!isActive) {
return;
}
setStatus('recovering');
try {
const { user: nextUser } = await ensureAutoAuthUser();
if (!isActive) {
return;
}
await ensureStoredAccessToken();
activateReadyUser(nextUser);
setError('');
} catch (autoAuthError) {
if (!isActive) {
return;
}
setUser(null);
setStatus('error');
setError(
autoAuthError instanceof Error
? autoAuthError.message
: '自动登录失败,请稍后再试。',
);
}
};
const hydrate = async () => {
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
@@ -209,15 +213,6 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
if (
allowDevGuestAutoAuth &&
options &&
options.availableLoginMethods.length === 0
) {
await ensureAutoUser();
return;
}
setUser(null);
setStatus('unauthenticated');
} catch (optionsError) {
@@ -225,11 +220,6 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
if (allowDevGuestAutoAuth) {
await ensureAutoUser();
return;
}
setAvailableLoginMethods([]);
setUser(null);
setError(
@@ -400,10 +390,7 @@ export function AuthGate({ children }: AuthGateProps) {
requireAuth,
openSettingsModal,
openAccountModal,
logout: async () => {
await logoutAuthUser();
setShowSettingsModal(false);
},
logout: logoutCurrentSession,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,
platformTheme: settings.platformTheme,
@@ -418,6 +405,7 @@ export function AuthGate({ children }: AuthGateProps) {
openSettingsModal,
readyUser,
requireAuth,
logoutCurrentSession,
status,
settings.isHydratingSettings,
settings.isPersistingSettings,
@@ -431,7 +419,9 @@ export function AuthGate({ children }: AuthGateProps) {
if (status === 'checking' && !canKeepPlatformContentMounted) {
return (
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
<div
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
>
...
</div>
);
@@ -439,8 +429,10 @@ export function AuthGate({ children }: AuthGateProps) {
if (status === 'recovering' && !canKeepPlatformContentMounted) {
return (
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
...
<div
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
>
...
</div>
);
}
@@ -458,7 +450,11 @@ export function AuthGate({ children }: AuthGateProps) {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
const result = await sendPhoneLoginCode(
phone,
'bind_phone',
captcha,
);
setBindCaptchaChallenge(null);
return result;
} catch (sendError) {
@@ -494,9 +490,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
}}
onLogout={async () => {
await logoutAuthUser();
setUser(null);
setStatus('unauthenticated');
await logoutCurrentSession();
}}
/>
);
@@ -508,7 +502,9 @@ export function AuthGate({ children }: AuthGateProps) {
!canKeepPlatformContentMounted
) {
return (
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}>
<div
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}
>
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
<div className="text-base font-medium text-[var(--platform-text-strong)]">
@@ -551,10 +547,7 @@ export function AuthGate({ children }: AuthGateProps) {
settingsError={settings.settingsError}
onClose={() => setShowSettingsModal(false)}
onPlatformThemeChange={settings.setPlatformTheme}
onLogout={async () => {
await logoutAuthUser();
setShowSettingsModal(false);
}}
onLogout={logoutCurrentSession}
onRefreshRiskBlocks={async () => {
setLoadingRiskBlocks(true);
try {
@@ -614,7 +607,9 @@ export function AuthGate({ children }: AuthGateProps) {
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
current.filter(
(session) => session.sessionId !== sessionId,
),
);
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
@@ -625,10 +620,7 @@ export function AuthGate({ children }: AuthGateProps) {
);
}
}}
onLogoutAll={async () => {
await logoutAllAuthSessions();
setShowSettingsModal(false);
}}
onLogoutAll={logoutAllSessions}
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
onSendChangePhoneCode={async (phone, captcha) => {
try {
@@ -640,7 +632,8 @@ export function AuthGate({ children }: AuthGateProps) {
setChangePhoneCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
const captchaChallenge =
getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setChangePhoneCaptchaChallenge(captchaChallenge);
}
@@ -653,7 +646,10 @@ export function AuthGate({ children }: AuthGateProps) {
setUser(nextUser);
}}
onChangePassword={async (currentPassword, newPassword) => {
const nextUser = await changePassword(currentPassword, newPassword);
const nextUser = await changePassword(
currentPassword,
newPassword,
);
setUser(nextUser);
}}
/>
@@ -676,7 +672,8 @@ export function AuthGate({ children }: AuthGateProps) {
setLoginCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
const captchaChallenge =
getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setLoginCaptchaChallenge(captchaChallenge);
}
@@ -708,12 +705,12 @@ export function AuthGate({ children }: AuthGateProps) {
setLoggingIn(false);
}
}}
onPasswordSubmit={async (username, password) => {
onPasswordSubmit={async (phone, password) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await authEntry(username, password);
setStoredLastLoginPhone(username);
const nextUser = await authEntry(phone, password);
setStoredLastLoginPhone(phone);
activateReadyUser(nextUser);
} catch (loginError) {
setError(

View File

@@ -34,7 +34,7 @@ type LoginScreenProps = {
expiresInSeconds: number;
}>;
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
onPasswordSubmit: (username: string, password: string) => Promise<void>;
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
onResetPassword: (
phone: string,
code: string,
@@ -96,12 +96,20 @@ export function LoginScreen({
}, [isOpen, phoneLoginEnabled]);
useEffect(() => {
if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) {
if (
activeLoginTab === 'phone' &&
!phoneLoginEnabled &&
passwordLoginEnabled
) {
setActiveLoginTab('password');
return;
}
if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) {
if (
activeLoginTab === 'password' &&
!passwordLoginEnabled &&
phoneLoginEnabled
) {
setActiveLoginTab('phone');
}
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
@@ -182,7 +190,9 @@ export function LoginScreen({
const result = await onSendCode(resetPhone, 'reset_password');
setResetCooldownSeconds(result.cooldownSeconds);
}}
onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)}
onSubmit={() =>
onResetPassword(resetPhone, resetCode, resetPasswordValue)
}
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
@@ -216,13 +226,14 @@ export function LoginScreen({
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span>/</span>
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="手机号或邮箱"
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
@@ -242,10 +253,12 @@ export function LoginScreen({
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={submitDisabled || !phone.trim() || !password.trim()}
disabled={
submitDisabled || !phone.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '登录中' : '注册/登录'}
{loggingIn ? '登录中' : '登录'}
</button>
<button
type="button"
@@ -277,7 +290,7 @@ export function LoginScreen({
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="注册/登录"
submitLabel="登录"
enabled={phoneLoginEnabled}
showPhoneField
onPhoneChange={setPhone}
@@ -299,7 +312,9 @@ export function LoginScreen({
/>
) : null}
{!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? (
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
</div>
@@ -544,7 +559,9 @@ function PasswordResetPanel({
</button>
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim() || !password.trim()}
disabled={
loggingIn || !phone.trim() || !code.trim() || !password.trim()
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '处理中' : '重置密码'}
@@ -576,9 +593,17 @@ function WechatButton({
}
function ErrorBanner({ message }: { message: string }) {
return <div className="platform-banner platform-banner--danger text-sm">{message}</div>;
return (
<div className="platform-banner platform-banner--danger text-sm">
{message}
</div>
);
}
function SuccessBanner({ message }: { message: string }) {
return <div className="platform-banner platform-banner--success text-sm">{message}</div>;
return (
<div className="platform-banner platform-banner--success text-sm">
{message}
</div>
);
}

View File

@@ -16,6 +16,7 @@ import type {
BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { UnifiedModal } from '../common/UnifiedModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BigFishAssetStudioTarget =
@@ -537,38 +538,37 @@ function BigFishResultErrorModal({
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/58 px-4 py-6 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="big-fish-result-error-title"
className="w-full max-w-sm rounded-[1.6rem] border border-red-100/80 bg-white p-5 text-slate-950 shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Waves className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div
id="big-fish-result-error-title"
className="text-base font-black text-slate-950"
>
</div>
<div className="mt-2 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</div>
<UnifiedModal
open
title="发布失败"
onClose={onClose}
closeOnBackdrop={false}
showCloseButton={false}
size="sm"
zIndexClassName="z-[160]"
overlayClassName="bg-slate-950/58"
panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl"
bodyClassName="p-5"
footer={(
<button
type="button"
onClick={onClose}
className="mt-5 inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
className="inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
>
</button>
)}
footerClassName="border-t-0 px-5 pb-5 pt-0"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Waves className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</div>
</UnifiedModal>
);
}

View File

@@ -0,0 +1,58 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { UnifiedModal } from './UnifiedModal';
test('renders an accessible platform modal', () => {
render(
<UnifiedModal open title="统一弹窗" onClose={() => {}} portal={false}>
<div></div>
</UnifiedModal>,
);
expect(screen.getByRole('dialog', { name: '统一弹窗' })).toBeTruthy();
expect(screen.getByText('窗口内容')).toBeTruthy();
});
test('closes through backdrop and escape', () => {
const onClose = vi.fn();
const { rerender } = render(
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
<div></div>
</UnifiedModal>,
);
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
rerender(
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
<div></div>
</UnifiedModal>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(2);
});
test('respects closeDisabled for every default close path', () => {
const onClose = vi.fn();
render(
<UnifiedModal
open
title="生成中"
onClose={onClose}
closeDisabled
portal={false}
>
<div></div>
</UnifiedModal>,
);
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
fireEvent.keyDown(window, { key: 'Escape' });
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(onClose).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,220 @@
import { X } from 'lucide-react';
import {
type CSSProperties,
type ReactNode,
useEffect,
useId,
} from 'react';
import { createPortal } from 'react-dom';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
type UnifiedModalVariant = 'platform' | 'pixel';
type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
type UnifiedModalProps = {
open: boolean;
title: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
variant?: UnifiedModalVariant;
size?: UnifiedModalSize;
closeDisabled?: boolean;
closeOnBackdrop?: boolean;
showCloseButton?: boolean;
closeLabel?: string;
portal?: boolean;
zIndexClassName?: string;
overlayClassName?: string;
panelClassName?: string;
headerClassName?: string;
bodyClassName?: string;
footerClassName?: string;
panelStyle?: CSSProperties;
};
const PLATFORM_SIZE_CLASS: Record<UnifiedModalSize, string> = {
sm: 'max-w-md',
md: 'max-w-xl',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
fullscreen: 'max-w-[min(100vw,76rem)] sm:h-[min(92vh,60rem)]',
};
const PIXEL_SIZE_CLASS: Record<UnifiedModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
fullscreen: 'max-w-[min(96vw,64rem)]',
};
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
function getPanelStyle(
variant: UnifiedModalVariant,
panelStyle: CSSProperties | undefined,
) {
if (variant !== 'pixel') {
return panelStyle;
}
return {
...getNineSliceStyle(UI_CHROME.modalPanel),
...panelStyle,
};
}
function UnifiedModalContent({
open,
title,
description,
children,
footer,
onClose,
variant = 'platform',
size = 'md',
closeDisabled = false,
closeOnBackdrop = true,
showCloseButton = true,
closeLabel = '关闭',
zIndexClassName = 'z-[90]',
overlayClassName,
panelClassName,
headerClassName,
bodyClassName,
footerClassName,
panelStyle,
}: Omit<UnifiedModalProps, 'portal'>) {
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
if (!open || closeDisabled) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeDisabled, onClose, open]);
if (!open) {
return null;
}
const isPixel = variant === 'pixel';
const sizeClassName = isPixel
? PIXEL_SIZE_CLASS[size]
: PLATFORM_SIZE_CLASS[size];
const overlayClasses = isPixel
? 'fixed inset-0 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4'
: 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4';
const panelClasses = isPixel
? 'pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]'
: 'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]';
const headerClasses = isPixel
? 'flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4'
: 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
const titleClasses = isPixel
? 'truncate text-sm font-semibold text-white'
: 'text-base font-semibold text-[var(--platform-text-strong)]';
const descriptionClasses = isPixel
? 'mt-1 text-xs leading-5 text-zinc-400'
: 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]';
const bodyClasses = isPixel
? 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5'
: 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5';
const footerClasses = isPixel
? 'flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4'
: 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
const closeButtonClasses = isPixel
? 'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45'
: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45';
return (
<div
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
onClick={(event) => {
if (
closeOnBackdrop &&
!closeDisabled &&
event.target === event.currentTarget
) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
style={getPanelStyle(variant, panelStyle)}
onClick={(event) => event.stopPropagation()}
>
<div className={joinClassNames(headerClasses, headerClassName)}>
<div className="min-w-0">
<div id={titleId} className={titleClasses}>
{title}
</div>
{description ? (
<div id={descriptionId} className={descriptionClasses}>
{description}
</div>
) : null}
</div>
{showCloseButton ? (
<button
type="button"
aria-label={closeLabel}
onClick={onClose}
disabled={closeDisabled}
className={closeButtonClasses}
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<div className={joinClassNames(bodyClasses, bodyClassName)}>
{children}
</div>
{footer ? (
<div className={joinClassNames(footerClasses, footerClassName)}>
{footer}
</div>
) : null}
</div>
</div>
);
}
/**
* 统一模态窗口外壳。
* 业务组件只传入标题、内容和操作区遮罩、无障碍属性、Escape 与移动端布局在这里收口。
*/
export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) {
if (!portal || typeof document === 'undefined') {
return <UnifiedModalContent {...props} />;
}
return createPortal(<UnifiedModalContent {...props} />, document.body);
}

View File

@@ -1,5 +1,6 @@
import { ArrowRight, X } from 'lucide-react';
import { ArrowRight } from 'lucide-react';
import { UnifiedModal } from '../common/UnifiedModal';
import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes';
export interface PlatformEntryCreationTypeModalProps {
@@ -79,58 +80,40 @@ export function PlatformEntryCreationTypeModal({
}
return (
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
<div className="bg-transparent">
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-4 sm:px-5 sm:py-5">
<div className="grid gap-3 sm:grid-cols-5">
{PLATFORM_CREATION_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
if (item.id === 'big-fish') {
onSelectBigFish();
}
if (item.id === 'puzzle') {
onSelectPuzzle();
}
}}
/>
))}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</div>
</div>
<UnifiedModal
open={isOpen}
title="选择创作类型"
description="先选玩法类型,再进入对应创作工作台。"
onClose={onClose}
closeDisabled={isBusy}
size="lg"
>
<div className="grid gap-3 sm:grid-cols-5">
{PLATFORM_CREATION_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
if (item.id === 'big-fish') {
onSelectBigFish();
}
if (item.id === 'puzzle') {
onSelectPuzzle();
}
}}
/>
))}
</div>
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</UnifiedModal>
);
}

View File

@@ -373,6 +373,7 @@ export function PlatformEntryFlowShellImpl({
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
string | null
>(null);
const hadReadableProtectedDataRef = useRef(false);
const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId,
);
@@ -934,7 +935,13 @@ export function PlatformEntryFlowShellImpl({
const setIsPuzzleBusy = puzzleFlow.setIsBusy;
const streamingPuzzleReplyText = puzzleFlow.streamingReplyText;
const isStreamingPuzzleReply = puzzleFlow.isStreamingReply;
const resetRpgSessionViewState = sessionController.resetSessionViewState;
const setRpgGeneratedCustomWorldProfile =
sessionController.setGeneratedCustomWorldProfile;
const setRpgCustomWorldError = sessionController.setCustomWorldError;
const persistRpgAgentUiState = sessionController.persistAgentUiState;
const resetAutoSaveTrackingToIdle =
autosaveCoordinator.resetAutoSaveTrackingToIdle;
const openBigFishAgentWorkspace = useCallback(async () => {
setBigFishRun(null);
await bigFishFlow.openWorkspace();
@@ -946,6 +953,61 @@ export function PlatformEntryFlowShellImpl({
await puzzleFlow.openWorkspace();
}, [puzzleFlow]);
useEffect(() => {
if (platformBootstrap.canReadProtectedData) {
hadReadableProtectedDataRef.current = true;
return;
}
if (authUi?.user || !hadReadableProtectedDataRef.current) {
return;
}
hadReadableProtectedDataRef.current = false;
// 创作中心只展示当前登录用户的私有作品。
// 一旦退出登录或鉴权上下文被收回,三类作品缓存必须同步清空,不能等刷新页面。
setShowCreationTypeModal(false);
setSelectedDetailEntry(null);
setBigFishWorks([]);
setBigFishRun(null);
setBigFishGenerationState(null);
setBigFishError(null);
setPuzzleOperation(null);
setPuzzleWorks([]);
setSelectedPuzzleDetail(null);
setPuzzleRun(null);
setPuzzleGenerationState(null);
setIsPuzzleNextLevelGenerating(false);
setPuzzleError(null);
setDeletingCreationWorkId(null);
resetRpgSessionViewState();
setRpgGeneratedCustomWorldProfile(null);
setRpgCustomWorldError(null);
persistRpgAgentUiState(null, null);
resetAutoSaveTrackingToIdle();
if (
selectionStage !== 'platform' &&
selectionStage !== 'detail' &&
selectionStage !== 'puzzle-gallery-detail'
) {
setSelectionStage('platform');
}
}, [
authUi?.user,
platformBootstrap.canReadProtectedData,
persistRpgAgentUiState,
resetAutoSaveTrackingToIdle,
resetRpgSessionViewState,
selectionStage,
setBigFishError,
setPuzzleError,
setRpgCustomWorldError,
setRpgGeneratedCustomWorldProfile,
setSelectionStage,
]);
const handleCreationHubCreateType = useCallback(
(type: PlatformCreationTypeId) => {
if (type === 'airp' || type === 'visual-novel') {

View File

@@ -1301,6 +1301,101 @@ test('clicking a public work while logged out routes through requireAuth', async
expect(getRpgEntryWorldGalleryDetail).not.toHaveBeenCalled();
});
test('creation hub clears all private work shelves immediately after logout state', async () => {
const user = userEvent.setup();
const loggedInAuth = createAuthValue();
const loggedOutAuth = createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: () => {},
});
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:rpg-logout-cache-1',
sourceType: 'agent_session',
status: 'draft',
title: 'RPG 退出缓存作品',
subtitle: '登出后不应继续可见',
summary: '这条 RPG 私有作品只能在登录态展示。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'rpg-logout-cache-session',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
vi.mocked(listBigFishWorks).mockResolvedValue({
items: [
{
workId: 'big-fish-logout-cache-1',
sourceSessionId: 'big-fish-logout-cache-session',
title: '大鱼退出缓存作品',
subtitle: '登出后不应继续可见',
summary: '这条大鱼私有作品只能在登录态展示。',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-04-25T10:05:00.000Z',
publishReady: false,
levelCount: 8,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
},
],
});
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-logout-cache-1',
profileId: 'puzzle-logout-cache-profile',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-logout-cache-session',
authorDisplayName: '测试玩家',
levelName: '拼图退出缓存作品',
summary: '这条拼图私有作品只能在登录态展示。',
themeTags: ['退出态'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-04-25T10:10:00.000Z',
publishedAt: null,
playCount: 0,
publishReady: false,
},
],
});
const { rerender } = render(<TestWrapper authValue={loggedInAuth} />);
await openCreationHub(user);
const createPanel = getPlatformTabPanel('create');
expect(await within(createPanel).findByText('RPG 退出缓存作品')).toBeTruthy();
expect(await within(createPanel).findByText('大鱼退出缓存作品')).toBeTruthy();
expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy();
rerender(<TestWrapper authValue={loggedOutAuth} />);
await waitFor(() => {
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull();
});
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
});
test('published puzzle works appear on home and category public shelves', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {

View File

@@ -10,8 +10,6 @@ import {
} from '../../packages/shared/src/http';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
@@ -184,7 +182,9 @@ function composeAbortSignal(
timeoutMs: number | undefined,
) {
const shouldUseTimeout =
typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0;
typeof timeoutMs === 'number' &&
Number.isFinite(timeoutMs) &&
timeoutMs > 0;
if (!shouldUseTimeout) {
return {
@@ -324,7 +324,11 @@ export function isTimeoutError(error: unknown) {
return error instanceof Error && error.name === 'TimeoutError';
}
function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) {
function shouldRetryError(
error: unknown,
attempt: number,
retry: ResolvedRetryOptions,
) {
if (attempt >= retry.maxRetries || isAbortError(error)) {
return false;
}
@@ -369,7 +373,9 @@ export class ApiClientError extends Error {
}
function canUseLocalStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
export function emitAuthStateChange() {
@@ -437,52 +443,6 @@ export function clearStoredAccessToken(
}
}
export function getStoredAutoAuthCredentials() {
if (!canUseLocalStorage()) {
return null;
}
const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || '';
const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || '';
if (!username || !password) {
return null;
}
return {
username,
password,
};
}
export function setStoredAutoAuthCredentials(credentials: {
username: string;
password: string;
}) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim());
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
}
export function clearStoredAutoAuthCredentials(
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
if (options.emit !== false) {
emitAuthStateChange();
}
}
function withAuthorizationHeaders(
headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
@@ -588,10 +548,7 @@ export async function fetchWithApiAuth(
}
}
const timedRequest = composeAbortSignal(
requestSignal,
options.timeoutMs,
);
const timedRequest = composeAbortSignal(requestSignal, options.timeoutMs);
let response: Response;
try {
response = await fetch(input, {
@@ -648,10 +605,7 @@ export async function fetchWithApiAuth(
}
attempt += 1;
await waitForRetry(
buildRetryDelayMs(attempt, retry),
requestSignal,
);
await waitForRetry(buildRetryDelayMs(attempt, retry), requestSignal);
}
}

View File

@@ -6,7 +6,8 @@ const apiClientMocks = vi.hoisted(() => ({
}));
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
const actual =
await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
@@ -17,12 +18,10 @@ vi.mock('./apiClient', async () => {
import { ApiClientError } from './apiClient';
import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
import {
authEntryWithStoredCredentials,
authEntry,
bindWechatPhone,
changePhoneNumber,
consumeAuthCallbackResult,
createAutoAuthCredentials,
ensureAutoAuthUser,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
@@ -80,41 +79,30 @@ describe('authService', () => {
clearStoredAccessToken({ emit: false });
});
it('creates credentials that match current username/password constraints', () => {
const credentials = createAutoAuthCredentials();
expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u);
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
});
it('auth entry trims guest credentials and写入 access token', async () => {
it('auth entry posts phone password credentials and 写入 access token', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-entry-token',
user: {
id: 'user_1',
publicUserCode: 'SY-00000001',
username: 'guest_abc123abc123',
displayName: 'guest_abc123abc123',
phoneNumberMasked: null,
username: 'phone_00000001',
displayName: '138****8000',
phoneNumberMasked: '138****8000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const user = await authEntryWithStoredCredentials({
username: ' guest_abc123abc123 ',
password: ' auto_secret_password ',
});
const user = await authEntry(' 138 0013 8000 ', ' secret123 ');
expect(user.username).toBe('guest_abc123abc123');
expect(user.phoneNumberMasked).toBe('138****8000');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
body: JSON.stringify({
username: 'guest_abc123abc123',
password: 'auto_secret_password',
phone: '13800138000',
password: 'secret123',
}),
}),
'登录失败',
@@ -127,62 +115,6 @@ describe('authService', () => {
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-auto-token',
user: {
id: 'user_saved',
publicUserCode: 'SY-00000002',
username: 'guest_saveduser01',
displayName: 'guest_saveduser01',
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const result = await ensureAutoAuthUser();
const authEntryBody = JSON.parse(
apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
};
expect(result.user.username).toBe('guest_saveduser01');
expect(result.credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
expect(result.credentials.password).toMatch(
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
);
expect(authEntryBody).toEqual(result.credentials);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
});
it('deduplicates concurrent auto auth requests', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-auto-shared-token',
user: {
id: 'user_auto',
publicUserCode: 'SY-00000003',
username: 'guest_auto',
displayName: 'guest_auto',
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const [firstResult, secondResult] = await Promise.all([
ensureAutoAuthUser(),
ensureAutoAuthUser(),
]);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
expect(firstResult).toEqual(secondResult);
});
it('sends phone login code through the auth endpoint', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
@@ -327,7 +259,8 @@ describe('authService', () => {
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
authorizationUrl:
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
});
await startWechatLogin();

View File

@@ -28,22 +28,14 @@ import {
ApiClientError,
type ApiRequestOptions,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
emitAuthStateChange,
getStoredAutoAuthCredentials,
requestJson,
setStoredAccessToken,
setStoredAutoAuthCredentials,
} from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
export type AutoAuthCredentials = {
username: string;
password: string;
};
export type AuthSessionSnapshot = {
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
availableLoginMethods: AuthLoginMethod[];
@@ -59,11 +51,6 @@ export type ConsumedAuthCallback = {
error: string | null;
};
let pendingAutoAuthUser: Promise<{
user: AuthUser;
credentials: AutoAuthCredentials;
}> | null = null;
// 登录前公开认证入口不能误带旧 token也不能先触发 refresh 探测,
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
const PUBLIC_AUTH_REQUEST_OPTIONS = {
@@ -108,7 +95,8 @@ export function getCaptchaChallengeFromError(
typeof error.details === 'object' &&
'captchaChallenge' in error.details
) {
const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge;
const challenge = (error.details as { captchaChallenge?: unknown })
.captchaChallenge;
if (
challenge &&
typeof challenge === 'object' &&
@@ -124,39 +112,8 @@ export function getCaptchaChallengeFromError(
return null;
}
function normalizeCredentials(credentials: AutoAuthCredentials): AutoAuthCredentials {
return {
username: credentials.username.trim(),
password: credentials.password.trim(),
};
}
function buildRandomSegment(length: number) {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
const cryptoApi = globalThis.crypto;
if (!cryptoApi?.getRandomValues) {
return Array.from(
{length},
() => alphabet[Math.floor(Math.random() * alphabet.length)],
).join('');
}
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
}
export function createAutoAuthCredentials(): AutoAuthCredentials {
return {
username: `guest_${buildRandomSegment(12)}`,
password: `auto_${buildRandomSegment(24)}_${buildRandomSegment(8)}`,
};
}
export function clearAuthSession() {
clearStoredAccessToken({ emit: false });
clearStoredAutoAuthCredentials({ emit: false });
emitAuthStateChange();
}
@@ -265,14 +222,16 @@ export async function getAuthLoginOptions() {
);
}
export async function authEntry(username: string, password: string) {
const credentials = normalizeCredentials({ username, password });
export async function authEntry(phone: string, password: string) {
const response = await requestJson<AuthEntryResponse>(
'/api/auth/entry',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
body: JSON.stringify({
phone: normalizePhoneInput(phone),
password: password.trim(),
}),
},
'登录失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
@@ -326,37 +285,6 @@ export async function resetPassword(
return response.user;
}
export async function authEntryWithStoredCredentials(
credentials: AutoAuthCredentials,
) {
const normalizedCredentials = normalizeCredentials(credentials);
const user = await authEntry(
normalizedCredentials.username,
normalizedCredentials.password,
);
setStoredAutoAuthCredentials(normalizedCredentials);
return user;
}
export async function ensureAutoAuthUser() {
pendingAutoAuthUser ??= (async () => {
const credentials =
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
const user = await authEntryWithStoredCredentials(credentials);
return {
user,
credentials,
};
})();
try {
return await pendingAutoAuthUser;
} finally {
pendingAutoAuthUser = null;
}
}
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
if (typeof window === 'undefined') {
return null;