Compare commits
5 Commits
60852241c9
...
d234d27cc0
| Author | SHA1 | Date | |
|---|---|---|---|
| d234d27cc0 | |||
| c3c5f1acd7 | |||
| 78dcad1222 | |||
| fcaf7bdb38 | |||
| a83c64133d |
@@ -169,8 +169,12 @@
|
||||
交付物:[../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/config.rs](../server-rs/crates/api-server/src/config.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 refresh token 轮换
|
||||
交付物:[../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 实现会话吊销
|
||||
- [ ] 实现全端登出
|
||||
- [x] 实现多端会话身份建模与会话列表查询
|
||||
交付物:[../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md)、[../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)、[../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/session_client.rs](../server-rs/crates/api-server/src/session_client.rs)、[../server-rs/crates/api-server/src/auth_sessions.rs](../server-rs/crates/api-server/src/auth_sessions.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../packages/shared/src/contracts/auth.ts](../packages/shared/src/contracts/auth.ts)
|
||||
- [x] 实现会话吊销
|
||||
交付物:[../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/logout.rs](../server-rs/crates/api-server/src/logout.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现全端登出
|
||||
交付物:[../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/logout_all.rs](../server-rs/crates/api-server/src/logout_all.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 `me` 查询
|
||||
交付物:[../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
|
||||
@@ -213,16 +217,20 @@
|
||||
|
||||
### 当前接口兼容
|
||||
|
||||
- [ ] 兼容 `/api/auth/login-options`
|
||||
- [x] 兼容 `/api/auth/login-options`
|
||||
交付物:[../docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/login_options.rs](../server-rs/crates/api-server/src/login_options.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 兼容 `/api/auth/entry`
|
||||
交付物:[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 兼容 `/api/auth/me`
|
||||
交付物:[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 兼容 `/api/auth/logout`
|
||||
- [ ] 兼容 `/api/auth/logout-all`
|
||||
- [x] 兼容 `/api/auth/logout`
|
||||
交付物:[../server-rs/crates/api-server/src/logout.rs](../server-rs/crates/api-server/src/logout.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 兼容 `/api/auth/logout-all`
|
||||
交付物:[../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/logout_all.rs](../server-rs/crates/api-server/src/logout_all.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)
|
||||
- [x] 兼容 `/api/auth/refresh`
|
||||
交付物:[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 兼容 `/api/auth/sessions`
|
||||
- [x] 兼容 `/api/auth/sessions`
|
||||
交付物:[../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/auth_sessions.rs](../server-rs/crates/api-server/src/auth_sessions.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)
|
||||
- [ ] 兼容 `/api/auth/sessions/:sessionId/revoke`
|
||||
- [ ] 兼容 `/api/auth/audit-logs`
|
||||
- [ ] 兼容 `/api/auth/risk-blocks`
|
||||
@@ -243,4 +251,4 @@
|
||||
- [ ] 手机验证码主链可用
|
||||
- [ ] 微信登录主链可用
|
||||
说明:当前按“暂缓执行”处理,不作为当前连续阶段的阻塞项。
|
||||
- [ ] 所有旧鉴权接口可通过 contract 回归
|
||||
- [ ] 所有旧鉴权接口可通过 contract 回归
|
||||
102
docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md
Normal file
102
docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# `/api/auth/login-options` 登录方式选项设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 Rust `api-server` 首版 `GET /api/auth/login-options` 的返回 contract、配置来源与当前阶段边界,确保前端在登录页读取“当前可用登录方式”时,不需要依赖硬编码开关。
|
||||
|
||||
## 2. 当前目标
|
||||
|
||||
当前阶段只解决一件事:
|
||||
|
||||
1. 由 `Axum` 根据服务端配置,返回当前环境启用的登录方式列表。
|
||||
|
||||
本阶段明确不包含:
|
||||
|
||||
1. 短信或微信登录链路本身是否已经完整落地
|
||||
2. 对前端返回更细粒度的 provider 配置
|
||||
3. 第三方登录按钮文案、图标或 UI 布局规则
|
||||
|
||||
## 3. 接口 contract
|
||||
|
||||
### 3.1 请求
|
||||
|
||||
1. 方法:`GET`
|
||||
2. 路径:`/api/auth/login-options`
|
||||
3. 鉴权:不需要
|
||||
4. 请求体:空
|
||||
|
||||
### 3.2 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"availableLoginMethods": ["phone", "wechat"]
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
1. `availableLoginMethods` 为字符串数组
|
||||
2. 当前阶段只允许出现:
|
||||
- `phone`
|
||||
- `wechat`
|
||||
|
||||
### 3.3 返回顺序
|
||||
|
||||
返回顺序固定为:
|
||||
|
||||
1. 先 `phone`
|
||||
2. 再 `wechat`
|
||||
|
||||
这样可以保证前端按钮顺序稳定,不因配置解析顺序变化而漂移。
|
||||
|
||||
## 4. 配置来源
|
||||
|
||||
`api-server` 只读取以下布尔配置:
|
||||
|
||||
1. `SMS_AUTH_ENABLED`
|
||||
2. `WECHAT_AUTH_ENABLED`
|
||||
|
||||
映射规则固定为:
|
||||
|
||||
1. `SMS_AUTH_ENABLED=true` 时返回 `phone`
|
||||
2. `WECHAT_AUTH_ENABLED=true` 时返回 `wechat`
|
||||
3. 两者都关闭时返回空数组
|
||||
|
||||
## 5. crate 边界
|
||||
|
||||
### 5.1 `api-server`
|
||||
|
||||
负责:
|
||||
|
||||
1. 读取 `AppState.config`
|
||||
2. 组装 `availableLoginMethods`
|
||||
3. 返回项目兼容的响应 envelope
|
||||
|
||||
### 5.2 `module-auth`
|
||||
|
||||
本接口当前阶段不依赖 `module-auth`。
|
||||
|
||||
### 5.3 前端
|
||||
|
||||
负责:
|
||||
|
||||
1. 根据 `availableLoginMethods` 决定是否展示手机号 / 微信入口
|
||||
2. 不再假设某种登录方式一定存在
|
||||
|
||||
## 6. 测试要求
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. 默认配置下返回空数组
|
||||
2. 同时启用短信与微信时返回 `["phone", "wechat"]`
|
||||
|
||||
## 7. 完成定义
|
||||
|
||||
满足以下条件时,本任务视为完成:
|
||||
|
||||
1. Rust 已提供 `GET /api/auth/login-options`
|
||||
2. 响应字段命名与前端约定一致
|
||||
3. 配置开关可稳定映射到返回数组
|
||||
4. 文档、任务清单与测试已同步更新
|
||||
177
docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md
Normal file
177
docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# `/api/auth/logout-all` 全端登出落地设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于指导 `M2` 中 `实现全端登出` 的首版落地,冻结:
|
||||
|
||||
1. `POST /api/auth/logout-all` 的请求与响应 contract
|
||||
2. 全部 refresh session 吊销与 `token_version` 递增的组合语义
|
||||
3. Rust 首版在进程内鉴权真相中的最小实现边界
|
||||
4. 与 `/logout`、`/sessions/:sessionId/revoke` 的职责切分
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前 Node `/api/auth/logout-all` 已具备以下稳定语义:
|
||||
|
||||
1. 必须先通过 Bearer JWT 校验
|
||||
2. 对当前用户执行 `token_version + 1`
|
||||
3. 吊销该用户全部未吊销 refresh session
|
||||
4. 响应成功时始终清理 refresh cookie
|
||||
|
||||
因此,Node 的“退出全部设备”同样是两层组合动作:
|
||||
|
||||
1. 会话级:吊销同一账号全部 refresh session
|
||||
2. 用户级:递增 `token_version`,让全部旧 access token 立即失效
|
||||
|
||||
Rust 首版必须保留这个语义。
|
||||
|
||||
## 3. 当前阶段范围
|
||||
|
||||
本阶段只落以下内容:
|
||||
|
||||
1. `module-auth` 增加按 `user_id` 吊销全部 refresh session 的能力
|
||||
2. `api-server` 暴露 `POST /api/auth/logout-all`
|
||||
3. 成功场景统一清理 refresh cookie
|
||||
|
||||
本阶段明确不包含:
|
||||
|
||||
1. `/api/auth/sessions/:sessionId/revoke`
|
||||
2. 审计日志正式落表
|
||||
3. SpacetimeDB reducer 真正写表
|
||||
|
||||
## 4. contract
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
1. 方法:`POST`
|
||||
2. 路径:`/api/auth/logout-all`
|
||||
3. 请求体:空
|
||||
4. 鉴权:
|
||||
- Bearer JWT 必填
|
||||
- refresh cookie 选填
|
||||
|
||||
### 4.2 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
同时响应头必须写回清理后的 refresh cookie。
|
||||
|
||||
### 4.3 失败响应
|
||||
|
||||
以下情况返回 `401 UNAUTHORIZED`:
|
||||
|
||||
1. Bearer JWT 缺失或非法
|
||||
2. JWT 对应用户不存在
|
||||
|
||||
## 5. 固定语义
|
||||
|
||||
### 5.1 动作顺序
|
||||
|
||||
`POST /api/auth/logout-all` 固定按以下顺序执行:
|
||||
|
||||
1. 从 Bearer JWT 解析当前用户
|
||||
2. 批量吊销当前用户全部 refresh session
|
||||
3. 对当前用户执行 `token_version + 1`
|
||||
4. 返回 `ok: true`
|
||||
5. 始终清理 refresh cookie
|
||||
|
||||
### 5.2 `token_version` 只递增一次
|
||||
|
||||
无论当前用户存在多少会话:
|
||||
|
||||
1. `logout-all` 只递增一次 `token_version`
|
||||
2. 不为每条 session 单独递增版本号
|
||||
|
||||
### 5.3 缺少 refresh cookie 不影响成功
|
||||
|
||||
`logout-all` 是账号级动作,不依赖当前 refresh cookie 命中:
|
||||
|
||||
1. 即使当前设备没有 refresh cookie,也要允许完成全端登出
|
||||
2. 成功响应仍然统一清理 cookie
|
||||
|
||||
## 6. 与其他接口的职责切分
|
||||
|
||||
### 6.1 `/api/auth/logout`
|
||||
|
||||
负责:
|
||||
|
||||
1. 当前设备退出
|
||||
2. 当前 refresh session 尽力吊销
|
||||
3. `token_version` 递增一次
|
||||
|
||||
### 6.2 `/api/auth/logout-all`
|
||||
|
||||
负责:
|
||||
|
||||
1. 全部设备退出
|
||||
2. 当前用户全部 refresh session 吊销
|
||||
3. `token_version` 递增一次
|
||||
|
||||
### 6.3 `/api/auth/sessions/:sessionId/revoke`
|
||||
|
||||
后续负责:
|
||||
|
||||
1. 只吊销指定远端设备 refresh session
|
||||
2. 不递增 `token_version`
|
||||
|
||||
## 7. crate 边界
|
||||
|
||||
### 7.1 `module-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 按 `user_id` 吊销全部 refresh session
|
||||
2. 递增当前用户 `token_version`
|
||||
3. 返回最新用户快照
|
||||
|
||||
### 7.2 `platform-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 构造清理 cookie 的 `Set-Cookie` 头
|
||||
|
||||
### 7.3 `api-server`
|
||||
|
||||
负责:
|
||||
|
||||
1. Bearer JWT 读取与校验
|
||||
2. 调用 `module-auth` 执行全端登出
|
||||
3. 始终回写清理 cookie
|
||||
|
||||
## 8. 进程内实现策略
|
||||
|
||||
当前阶段 `module-auth` 继续使用进程内真相,新增以下最小能力:
|
||||
|
||||
1. `revoke_all_sessions_by_user_id`
|
||||
2. `logout_all_sessions`
|
||||
|
||||
其中:
|
||||
|
||||
1. 批量吊销只改 `revoked_at`
|
||||
2. 用户版本递增继续直接修改内存用户快照
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. 登录两次后调用 `/api/auth/logout-all` 返回 `ok: true`
|
||||
2. `/logout-all` 成功后清理 refresh cookie
|
||||
3. `/logout-all` 成功后旧 Bearer token 访问 `/api/auth/me` 返回 `401`
|
||||
4. `/logout-all` 成功后旧 refresh cookie 调用 `/api/auth/refresh` 返回 `401`
|
||||
5. 缺少 refresh cookie 时,只要 Bearer token 有效,`/logout-all` 仍返回 `ok: true`
|
||||
|
||||
## 10. 完成定义
|
||||
|
||||
满足以下条件时,本任务视为完成:
|
||||
|
||||
1. Rust 侧已提供 `POST /api/auth/logout-all`
|
||||
2. 同一用户全部 refresh session 可被吊销
|
||||
3. 用户 `token_version` 会在全端登出时递增
|
||||
4. `/logout-all` 总会清理 refresh cookie
|
||||
5. 文档、任务清单与测试已同步更新
|
||||
209
docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md
Normal file
209
docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# `/api/auth/logout` 当前会话吊销落地设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于指导 `M2` 中 `实现会话吊销` 任务的第一段首版落地,冻结:
|
||||
|
||||
1. `POST /api/auth/logout` 的请求与响应 contract。
|
||||
2. 当前设备退出时 refresh session 吊销与 `token_version` 递增的组合语义。
|
||||
3. Rust 首版在进程内鉴权真相中的最小实现边界。
|
||||
4. 与后续 `logout-all`、`sessions/:sessionId/revoke` 的职责切分。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前 Node `/api/auth/logout` 已具备以下稳定语义:
|
||||
|
||||
1. 必须先通过 Bearer JWT 校验。
|
||||
2. 从 cookie 中读取当前 refresh token,并尝试吊销对应 refresh session。
|
||||
3. 无论当前 refresh session 是否已存在,只要用户存在,仍继续执行“退出当前设备”。
|
||||
4. 对当前用户执行 `token_version + 1`,使当前 access token 全局失效。
|
||||
5. 响应成功时始终清理 refresh cookie。
|
||||
|
||||
因此,Node 的“退出当前设备”实际是两层组合动作:
|
||||
|
||||
1. 设备级:吊销当前 refresh session
|
||||
2. 用户级:递增 `token_version`,让当前 access token 立即失效
|
||||
|
||||
Rust 首版必须保留这个语义。
|
||||
|
||||
## 3. 当前阶段范围
|
||||
|
||||
本阶段只落以下内容:
|
||||
|
||||
1. `module-auth` 增加当前 refresh session 吊销能力。
|
||||
2. `module-auth` 增加用户 `token_version` 递增能力。
|
||||
3. `api-server` 暴露 `POST /api/auth/logout`。
|
||||
4. 成功或已失效场景统一清理 refresh cookie。
|
||||
|
||||
本阶段明确不包含:
|
||||
|
||||
1. `/api/auth/logout-all`
|
||||
2. `/api/auth/sessions`
|
||||
3. `/api/auth/sessions/:sessionId/revoke`
|
||||
4. 审计日志与风控日志正式落表
|
||||
5. SpacetimeDB reducer 真正写表
|
||||
|
||||
## 4. contract
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
1. 方法:`POST`
|
||||
2. 路径:`/api/auth/logout`
|
||||
3. 请求体:空
|
||||
4. 鉴权:
|
||||
- Bearer JWT 必填
|
||||
- refresh cookie 选填但应尽量提供
|
||||
|
||||
### 4.2 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
同时响应头必须写回清理后的 refresh cookie。
|
||||
|
||||
### 4.3 失败响应
|
||||
|
||||
以下情况返回 `401 UNAUTHORIZED`:
|
||||
|
||||
1. Bearer JWT 缺失或非法
|
||||
2. JWT 对应用户不存在
|
||||
|
||||
说明:
|
||||
|
||||
1. 当前 refresh cookie 缺失本身不构成 `/logout` 失败。
|
||||
2. 因为当前设备可能已经没有 refresh cookie,但 access token 仍应允许执行显式退出。
|
||||
|
||||
## 5. 固定语义
|
||||
|
||||
### 5.1 当前设备退出的动作顺序
|
||||
|
||||
`POST /api/auth/logout` 固定按以下顺序执行:
|
||||
|
||||
1. 从 Bearer JWT 解析当前用户。
|
||||
2. 尝试按当前 refresh cookie 吊销 refresh session。
|
||||
3. 对当前用户执行 `token_version + 1`。
|
||||
4. 返回 `ok: true`。
|
||||
5. 始终清理 refresh cookie。
|
||||
|
||||
### 5.2 refresh session 吊销是“尽力而为”
|
||||
|
||||
当 refresh cookie 缺失、refresh token 无法命中 session、session 已吊销时:
|
||||
|
||||
1. 不把这些情况视为 `/logout` 失败。
|
||||
2. 继续执行用户级 `token_version` 递增。
|
||||
|
||||
原因:
|
||||
|
||||
1. 当前设备退出的主目标是让“现在这份 access token”立刻失效。
|
||||
2. refresh session 丢失不应该阻断显式退出。
|
||||
|
||||
### 5.3 `token_version` 必须递增
|
||||
|
||||
当前阶段固定规则:
|
||||
|
||||
1. `/logout` 必须递增 `user.token_version`
|
||||
2. 后续 Bearer JWT 校验必须比对当前用户最新 `token_version`
|
||||
|
||||
说明:
|
||||
|
||||
1. 如果不递增,当前 access token 直到自然过期前仍可继续访问。
|
||||
2. 这与 Node 当前行为不一致,也会让“退出登录”在用户感知上失真。
|
||||
|
||||
## 6. 与其他接口的职责切分
|
||||
|
||||
### 6.1 `/api/auth/logout`
|
||||
|
||||
负责:
|
||||
|
||||
1. 当前设备退出
|
||||
2. 当前 access token 立即失效
|
||||
3. 当前 refresh session 尽力吊销
|
||||
|
||||
### 6.2 `/api/auth/logout-all`
|
||||
|
||||
后续负责:
|
||||
|
||||
1. 吊销同一用户全部 refresh session
|
||||
2. 递增一次 `token_version`
|
||||
|
||||
### 6.3 `/api/auth/sessions/:sessionId/revoke`
|
||||
|
||||
后续负责:
|
||||
|
||||
1. 只吊销指定远端设备 refresh session
|
||||
2. 不递增 `token_version`
|
||||
|
||||
## 7. crate 边界
|
||||
|
||||
### 7.1 `module-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 按 refresh token hash 吊销当前 session。
|
||||
2. 递增当前用户 `token_version`。
|
||||
3. 返回退出后最新用户快照,供后续 access token 校验使用。
|
||||
|
||||
### 7.2 `platform-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. refresh token 哈希
|
||||
2. 构造清理 cookie 的 `Set-Cookie` 头
|
||||
|
||||
### 7.3 `api-server`
|
||||
|
||||
负责:
|
||||
|
||||
1. Bearer JWT 与 refresh cookie 的读取
|
||||
2. 调用 `module-auth` 组合执行当前设备退出
|
||||
3. 始终回写清理 cookie
|
||||
|
||||
## 8. 进程内实现策略
|
||||
|
||||
当前阶段 `module-auth` 继续使用进程内真相,新增以下最小能力:
|
||||
|
||||
1. `revoke_session_by_refresh_token_hash`
|
||||
2. `increment_user_token_version`
|
||||
|
||||
其中:
|
||||
|
||||
1. session 吊销要写入 `revoked_at`
|
||||
2. 用户版本递增要直接修改内存中用户快照
|
||||
|
||||
## 9. Bearer JWT 校验补强
|
||||
|
||||
为了让 `/logout` 后“旧 access token 立即失效”真正成立,当前阶段需要补一条约束:
|
||||
|
||||
1. Bearer JWT 校验通过签名后,还必须比对 claims 里的 `ver`
|
||||
2. 若 `claims.ver != 当前用户 token_version`,返回 `401`
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是当前 Rust 鉴权链路必须补上的一致性校验。
|
||||
2. 否则 `logout` 虽然递增了用户版本,但旧 JWT 仍能继续访问。
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. 登录成功后调用 `/api/auth/logout` 返回 `ok: true`
|
||||
2. `/logout` 成功后会清理 refresh cookie
|
||||
3. `/logout` 成功后旧 Bearer token 再访问 `/api/auth/me` 返回 `401`
|
||||
4. refresh cookie 缺失时,只要 Bearer token 有效,`/logout` 仍返回 `ok: true`
|
||||
5. 用户不存在时 `/logout` 返回 `401`
|
||||
|
||||
## 11. 完成定义
|
||||
|
||||
满足以下条件时,本任务视为完成:
|
||||
|
||||
1. Rust 侧已提供 `POST /api/auth/logout`
|
||||
2. 当前 refresh session 可按 cookie 对应关系被吊销
|
||||
3. 用户 `token_version` 会在退出时递增
|
||||
4. Bearer JWT 已补充版本比对
|
||||
5. `/logout` 总会清理 refresh cookie
|
||||
6. 文档、任务清单与测试已同步更新
|
||||
184
docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md
Normal file
184
docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# `/api/auth/sessions` 会话列表与多端标识查询设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于指导 `M2` 中 `兼容 /api/auth/sessions` 的首版落地,冻结:
|
||||
|
||||
1. `GET /api/auth/sessions` 的请求与响应 contract
|
||||
2. 当前设备识别方式与 `isCurrent` 语义
|
||||
3. 多端登录识别字段如何从 `refresh_session` 派生到 DTO
|
||||
4. Rust 首版在 Axum + 进程内 `module-auth` 下的最小实现边界
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前 Node `/api/auth/sessions` 已具备以下稳定行为:
|
||||
|
||||
1. 依赖 Bearer JWT 确认用户身份
|
||||
2. 从 refresh cookie 识别当前设备
|
||||
3. 返回当前账号全部未吊销活跃会话
|
||||
4. 每条记录给出端侧标签、最近活跃时间、到期时间、IP 脱敏信息与是否当前设备
|
||||
|
||||
当前问题是:
|
||||
|
||||
1. 旧实现只能粗略给出“网页端浏览器 / 移动端浏览器”
|
||||
2. 无法稳定区分同设备不同浏览器
|
||||
3. 无法区分微信内 H5 与微信小程序、小程序平台来源
|
||||
|
||||
因此本次 `/api/auth/sessions` 首版落地必须直接承接多端会话身份模型。
|
||||
|
||||
## 3. 设计输入
|
||||
|
||||
本任务直接受以下文档约束:
|
||||
|
||||
1. [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md)
|
||||
2. [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)
|
||||
3. [AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](./AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
|
||||
4. [AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](./AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)
|
||||
|
||||
## 4. 首版落地范围
|
||||
|
||||
本阶段只落以下内容:
|
||||
|
||||
1. `module-auth` 提供按 `user_id` 读取活跃 refresh session 列表的能力
|
||||
2. `api-server` 暴露 `GET /api/auth/sessions`
|
||||
3. 登录创建 session 时落库结构化客户端身份字段
|
||||
4. 会话列表返回多端识别所需字段,并兼容旧 `clientLabel`
|
||||
|
||||
本阶段明确不包含:
|
||||
|
||||
1. `/api/auth/sessions/:sessionId/revoke`
|
||||
2. 前端完整消费全部新增字段
|
||||
3. SpacetimeDB reducer / view 正式读表
|
||||
|
||||
## 5. 请求与响应 contract
|
||||
|
||||
### 5.1 请求
|
||||
|
||||
1. 方法:`GET`
|
||||
2. 路径:`/api/auth/sessions`
|
||||
3. 请求体:空
|
||||
4. 鉴权:
|
||||
- Bearer JWT 必填
|
||||
- refresh cookie 选填但建议携带,用于判断 `isCurrent`
|
||||
|
||||
### 5.2 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "usess_xxx",
|
||||
"clientType": "web_browser",
|
||||
"clientRuntime": "chrome",
|
||||
"clientPlatform": "windows",
|
||||
"clientLabel": "Windows / Chrome",
|
||||
"deviceDisplayName": "Windows / Chrome",
|
||||
"miniProgramAppId": null,
|
||||
"miniProgramEnv": null,
|
||||
"userAgent": "Mozilla/5.0 ...",
|
||||
"ipMasked": "203.0.*.*",
|
||||
"isCurrent": true,
|
||||
"createdAt": "2026-04-21T10:00:00Z",
|
||||
"lastSeenAt": "2026-04-21T10:05:00Z",
|
||||
"expiresAt": "2026-05-21T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
1. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致
|
||||
2. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段
|
||||
3. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv`
|
||||
|
||||
### 5.3 失败响应
|
||||
|
||||
以下情况返回 `401 UNAUTHORIZED`:
|
||||
|
||||
1. Bearer JWT 缺失或非法
|
||||
2. Bearer JWT 对应用户不存在
|
||||
|
||||
仓储读取失败返回 `500 INTERNAL_SERVER_ERROR`。
|
||||
|
||||
## 6. 当前设备识别规则
|
||||
|
||||
`isCurrent` 固定按以下规则判断:
|
||||
|
||||
1. 从 refresh cookie 读取当前原始 refresh token
|
||||
2. 在 Axum 侧计算 `sha256(refresh_token)`
|
||||
3. 与会话列表中的 `refresh_token_hash` 比较
|
||||
4. 命中则 `isCurrent = true`
|
||||
|
||||
说明:
|
||||
|
||||
1. 如果请求没有携带 refresh cookie,本接口仍可返回会话列表
|
||||
2. 此时全部会话的 `isCurrent` 都为 `false`
|
||||
|
||||
## 7. 多端标识派生规则
|
||||
|
||||
### 7.1 后端入库字段
|
||||
|
||||
登录创建会话时,Axum 必须先解析并写入:
|
||||
|
||||
1. `client_type`
|
||||
2. `client_runtime`
|
||||
3. `client_platform`
|
||||
4. `client_instance_id`
|
||||
5. `device_fingerprint`
|
||||
6. `device_display_name`
|
||||
7. `mini_program_app_id`
|
||||
8. `mini_program_env`
|
||||
9. `user_agent`
|
||||
10. `ip`
|
||||
|
||||
### 7.2 DTO 派生规则
|
||||
|
||||
会话列表返回时:
|
||||
|
||||
1. `clientType = client_type`
|
||||
2. `clientRuntime = client_runtime`
|
||||
3. `clientPlatform = client_platform`
|
||||
4. `deviceDisplayName = device_display_name`
|
||||
5. `clientLabel = device_display_name`
|
||||
6. `miniProgramAppId = mini_program_app_id`
|
||||
7. `miniProgramEnv = mini_program_env`
|
||||
|
||||
## 8. crate 边界
|
||||
|
||||
### 8.1 `module-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 保存 refresh session 客户端身份快照
|
||||
2. 按 `user_id` 返回活跃会话列表
|
||||
3. 保持 refresh 轮换后 `session_id` 稳定、客户端身份字段不漂移
|
||||
|
||||
### 8.2 `api-server`
|
||||
|
||||
负责:
|
||||
|
||||
1. 读取 Bearer JWT 与 refresh cookie
|
||||
2. 把活跃会话映射成旧接口兼容 DTO
|
||||
3. 派生 `ipMasked` 与 `isCurrent`
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. 同一账号在同平台不同浏览器登录后,会话列表能返回两条不同运行时记录
|
||||
2. 微信内 H5 登录后,会话列表返回 `wechat_h5 + wechat_embedded_browser`
|
||||
3. 显式小程序头优先于 `User-Agent` 判断
|
||||
4. 请求携带当前 refresh cookie 时,只有当前会话 `isCurrent = true`
|
||||
|
||||
## 10. 完成定义
|
||||
|
||||
满足以下条件时,本任务视为完成:
|
||||
|
||||
1. Rust 侧已提供 `GET /api/auth/sessions`
|
||||
2. 会话列表可区分普通浏览器、微信内 H5、小程序来源
|
||||
3. 同设备不同浏览器可在会话列表中清晰区分
|
||||
4. `clientLabel` 与新增多端字段都已稳定返回
|
||||
5. 文档、任务清单与测试已同步更新
|
||||
@@ -0,0 +1,309 @@
|
||||
# 多端登录会话身份模型设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于补齐当前鉴权体系中“多端登录识别”能力的落地设计,目标是让后端能够稳定标识:
|
||||
|
||||
1. 不同设备上的浏览器登录
|
||||
2. 同一设备上的不同浏览器登录
|
||||
3. 微信小程序、支付宝小程序、抖音小程序等小程序来源
|
||||
4. 微信内 H5 与普通浏览器 H5 的差异
|
||||
|
||||
同时冻结:
|
||||
|
||||
1. `refresh_session` 需要新增的客户端身份字段
|
||||
2. 前端与网关需要补传的上下文
|
||||
3. Axum 派生展示名与“当前设备”标识的规则
|
||||
4. 与后续 `/api/auth/sessions`、`/api/auth/sessions/:sessionId/revoke` 的契约关系
|
||||
|
||||
## 2. 当前问题
|
||||
|
||||
当前项目里“设备类型”只有最小占位语义:
|
||||
|
||||
1. Node `buildAuthRequestContext(...)` 直接写死 `clientType = "browser"`
|
||||
2. `clientLabel` 只是按 `User-Agent` 粗略显示“移动端浏览器 / 网页端浏览器”
|
||||
3. `refresh_session` / `user_sessions` 里没有区分运行时、小程序来源、浏览器实例与设备实例的结构化字段
|
||||
|
||||
这会导致:
|
||||
|
||||
1. 无法区分“同一台电脑上的 Chrome 和 Edge”
|
||||
2. 无法稳定标记“微信小程序登录”和“微信内 H5 登录”
|
||||
3. 无法给会话列表生成稳定、可读的端侧标签
|
||||
|
||||
## 3. 设计目标
|
||||
|
||||
新的会话客户端身份模型必须满足:
|
||||
|
||||
1. 会话列表能区分“设备”与“客户端运行时”
|
||||
2. 同设备不同浏览器被视为不同登录端
|
||||
3. 小程序来源可稳定区分到平台级别
|
||||
4. 前端不需要拿到真实设备名称,也能展示稳定友好标签
|
||||
5. 浏览器拿不到真实设备名时,系统仍能自动生成展示名
|
||||
|
||||
## 4. 基本原则
|
||||
|
||||
### 4.1 不追求真实设备名
|
||||
|
||||
浏览器端通常无法拿到:
|
||||
|
||||
1. 系统设置里的设备名称
|
||||
2. 手机自定义名称
|
||||
3. 电脑主机名
|
||||
|
||||
因此本设计不依赖真实设备名称,而是生成“会话展示名”。
|
||||
|
||||
### 4.2 会话识别拆三层
|
||||
|
||||
当前阶段统一拆为:
|
||||
|
||||
1. `client_type`
|
||||
大类,例如浏览器、小程序、桌面客户端
|
||||
2. `client_runtime`
|
||||
具体运行时,例如 Chrome、Safari、微信小程序
|
||||
3. `client_instance_id`
|
||||
客户端实例 ID,用于区分“同设备不同浏览器”以及“同浏览器不同安装/清缓存”
|
||||
|
||||
### 4.3 展示名由后端派生
|
||||
|
||||
`device_display_name` 不直接信任前端自由文本,而由后端基于结构化字段派生,必要时允许前端提供候选值。
|
||||
|
||||
## 5. 字段模型
|
||||
|
||||
`refresh_session` 建议新增以下字段:
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `client_type` | `String` | 是 | 大类,见 6.1 |
|
||||
| `client_runtime` | `String` | 是 | 运行时,见 6.2 |
|
||||
| `client_platform` | `String` | 否 | 平台,如 `windows`、`macos`、`ios`、`android` |
|
||||
| `client_instance_id` | `String` | 否 | 客户端实例 ID,由前端持久化生成 |
|
||||
| `device_fingerprint` | `String` | 否 | 后端派生指纹,用于聚合同设备近似会话 |
|
||||
| `device_display_name` | `String` | 是 | 给用户展示的设备/端侧名称 |
|
||||
| `mini_program_app_id` | `Option<String>` | 否 | 小程序 appid |
|
||||
| `mini_program_env` | `Option<String>` | 否 | 小程序环境,如 `develop`、`trial`、`release` |
|
||||
| `user_agent` | `Option<String>` | 否 | 原始 UA |
|
||||
| `ip` | `Option<String>` | 否 | 登录 IP |
|
||||
|
||||
说明:
|
||||
|
||||
1. `client_type` 和 `client_runtime` 是强约束字段,必须稳定可枚举。
|
||||
2. `client_instance_id` 用于区分“同一台设备上的不同浏览器/不同安装实例”。
|
||||
3. `device_fingerprint` 不是鉴权凭据,只用于会话展示聚类。
|
||||
4. `device_display_name` 是最终展示字段。
|
||||
|
||||
## 6. 枚举定义
|
||||
|
||||
### 6.1 `client_type`
|
||||
|
||||
固定枚举:
|
||||
|
||||
1. `web_browser`
|
||||
2. `wechat_h5`
|
||||
3. `mini_program`
|
||||
4. `native_app`
|
||||
5. `desktop_app`
|
||||
6. `unknown`
|
||||
|
||||
### 6.2 `client_runtime`
|
||||
|
||||
当前阶段固定允许:
|
||||
|
||||
1. `chrome`
|
||||
2. `edge`
|
||||
3. `safari`
|
||||
4. `firefox`
|
||||
5. `wechat_embedded_browser`
|
||||
6. `wechat_mini_program`
|
||||
7. `alipay_mini_program`
|
||||
8. `douyin_mini_program`
|
||||
9. `unknown`
|
||||
|
||||
后续如扩展,不改已有语义,只追加枚举。
|
||||
|
||||
### 6.3 `client_platform`
|
||||
|
||||
当前阶段固定允许:
|
||||
|
||||
1. `windows`
|
||||
2. `macos`
|
||||
3. `linux`
|
||||
4. `ios`
|
||||
5. `android`
|
||||
6. `unknown`
|
||||
|
||||
## 7. 前端采集输入
|
||||
|
||||
### 7.1 Web 浏览器
|
||||
|
||||
浏览器侧建议补传以下请求头:
|
||||
|
||||
1. `x-client-type`
|
||||
2. `x-client-runtime`
|
||||
3. `x-client-platform`
|
||||
4. `x-client-instance-id`
|
||||
|
||||
其中:
|
||||
|
||||
1. `x-client-instance-id`
|
||||
由前端首次启动时生成并持久化到本地存储
|
||||
2. `x-client-runtime`
|
||||
可由前端粗判,也允许后端根据 UA 覆盖修正
|
||||
|
||||
### 7.2 小程序
|
||||
|
||||
小程序侧额外补传:
|
||||
|
||||
1. `x-client-type=mini_program`
|
||||
2. `x-client-runtime`
|
||||
如 `wechat_mini_program`
|
||||
3. `x-client-platform`
|
||||
`ios` / `android`
|
||||
4. `x-client-instance-id`
|
||||
5. `x-mini-program-app-id`
|
||||
6. `x-mini-program-env`
|
||||
|
||||
## 8. 后端判定规则
|
||||
|
||||
### 8.1 输入优先级
|
||||
|
||||
Axum 侧按以下顺序解析:
|
||||
|
||||
1. 显式请求头
|
||||
2. `User-Agent` 自动识别
|
||||
3. 回退默认值
|
||||
|
||||
### 8.2 默认回退
|
||||
|
||||
如果没有任何显式端侧头:
|
||||
|
||||
1. `client_type = web_browser`
|
||||
2. `client_runtime` 由 UA 粗判,判不出则 `unknown`
|
||||
3. `client_platform` 由 UA 粗判,判不出则 `unknown`
|
||||
|
||||
### 8.3 微信内 H5
|
||||
|
||||
如果 UA 命中 `MicroMessenger` 且不是小程序显式来源:
|
||||
|
||||
1. `client_type = wechat_h5`
|
||||
2. `client_runtime = wechat_embedded_browser`
|
||||
|
||||
### 8.4 小程序来源优先于 UA
|
||||
|
||||
如果 `x-client-type=mini_program`:
|
||||
|
||||
1. 不再按普通 UA 逻辑覆盖成浏览器
|
||||
2. `client_runtime` 必须优先保留小程序来源值
|
||||
|
||||
## 9. `client_instance_id` 规则
|
||||
|
||||
### 9.1 作用
|
||||
|
||||
它用于区分:
|
||||
|
||||
1. 同设备不同浏览器
|
||||
2. 同浏览器不同安装实例
|
||||
3. 小程序容器与普通 H5 容器
|
||||
|
||||
### 9.2 生成策略
|
||||
|
||||
前端首次启动时生成一个随机字符串并持久化:
|
||||
|
||||
1. Web:`localStorage` / `indexedDB`
|
||||
2. 小程序:平台侧本地存储
|
||||
|
||||
说明:
|
||||
|
||||
1. 清浏览器数据后会变化,这是可接受的。
|
||||
2. 它不是登录凭据,只是会话识别辅助字段。
|
||||
|
||||
## 10. `device_fingerprint` 规则
|
||||
|
||||
后端可基于以下字段做稳定哈希:
|
||||
|
||||
1. `client_type`
|
||||
2. `client_runtime`
|
||||
3. `client_platform`
|
||||
4. `client_instance_id`
|
||||
|
||||
如果 `client_instance_id` 缺失,则回退:
|
||||
|
||||
1. `client_type`
|
||||
2. `client_runtime`
|
||||
3. `client_platform`
|
||||
4. `normalized user_agent`
|
||||
|
||||
说明:
|
||||
|
||||
1. 这个指纹只用于显示和聚类,不作为鉴权依据。
|
||||
2. 同一设备上的不同浏览器通常应产生不同指纹。
|
||||
|
||||
## 11. `device_display_name` 派生规则
|
||||
|
||||
后端固定按以下优先级派生:
|
||||
|
||||
1. 小程序:
|
||||
- `微信小程序 / iPhone`
|
||||
- `微信小程序 / Android`
|
||||
- `支付宝小程序 / Android`
|
||||
2. 微信内 H5:
|
||||
- `微信内网页 / iPhone`
|
||||
- `微信内网页 / Android`
|
||||
3. 普通浏览器:
|
||||
- `Windows / Chrome`
|
||||
- `macOS / Safari`
|
||||
- `iPhone / Safari`
|
||||
- `Android / Chrome`
|
||||
4. 无法识别时:
|
||||
- `未知设备`
|
||||
|
||||
## 12. 与会话列表 DTO 的关系
|
||||
|
||||
当前 `AuthSessionSummary` 至少建议新增:
|
||||
|
||||
1. `clientRuntime`
|
||||
2. `clientPlatform`
|
||||
3. `deviceDisplayName`
|
||||
4. `miniProgramAppId`
|
||||
|
||||
当前已有字段中的:
|
||||
|
||||
1. `clientType`
|
||||
保留,但升级为新枚举
|
||||
2. `clientLabel`
|
||||
后续可由 `deviceDisplayName` 替代,或先兼容保留
|
||||
|
||||
## 13. 首版 Rust 落地范围
|
||||
|
||||
当前首版建议先完成:
|
||||
|
||||
1. Axum 从请求头 + UA 解析 `SessionClientContext`
|
||||
2. `module-auth` 的 `refresh_session` 增加结构化客户端字段
|
||||
3. 登录创建 session 时写入这些字段
|
||||
4. 单元测试覆盖浏览器、小程序、微信 H5 的识别规则
|
||||
|
||||
本轮不强制一起完成:
|
||||
|
||||
1. `/api/auth/sessions`
|
||||
2. 前端全面补传 `x-client-*` 头
|
||||
3. 小程序端 SDK 对接
|
||||
|
||||
## 14. 不允许的设计漂移
|
||||
|
||||
后续实现时禁止:
|
||||
|
||||
1. 继续把所有终端都写成 `browser`
|
||||
2. 用 `User-Agent` 文本直接充当最终设备名称
|
||||
3. 用 IP 作为设备唯一标识
|
||||
4. 把 `client_instance_id` 当成安全凭据
|
||||
5. 前端任意上传自由文本设备名并直接持久化
|
||||
|
||||
## 15. 完成定义
|
||||
|
||||
满足以下条件时,这条能力设计视为完成:
|
||||
|
||||
1. 会话身份字段已能区分来源、运行时、平台、实例
|
||||
2. 同设备不同浏览器与小程序来源都有明确建模
|
||||
3. `device_display_name` 已有统一派生规则
|
||||
4. 后续 `/api/auth/sessions` 已有稳定可用的数据基础
|
||||
@@ -4,8 +4,12 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md):`/api/auth/login-options` 首版设计,冻结登录方式列表 contract、配置开关来源与返回顺序。
|
||||
- [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md):`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract,以及用户不存在时的 `401` 语义。
|
||||
- [AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](./AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md):`/api/auth/logout-all` 全端登出设计,冻结全部 refresh session 吊销、`token_version` 递增、清 cookie 语义与 Rust 首版接口边界。
|
||||
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。
|
||||
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。
|
||||
- [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md):多端登录会话身份模型设计,冻结浏览器、小程序、微信内 H5 的客户端身份字段、请求头约定与展示名派生规则。
|
||||
- [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` refresh cookie 适配设计,冻结 cookie 配置结构、读取规则与 `api-server` 最小读取链路。
|
||||
- [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。
|
||||
- [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md):面向 Axum、`platform-auth` 与 `SpacetimeDB` 身份透传的 OIDC 风格 JWT claims 设计,冻结 `iss/sub/sid/provider/roles` 等关键字段。
|
||||
@@ -18,6 +22,7 @@
|
||||
- [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md):`M2` 第二张身份表 `auth_identity` 的 provider 范围、唯一约束、手机号/微信身份写入规则与迁移策略。
|
||||
- [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md):`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。
|
||||
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于当前 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。
|
||||
- [AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md](./AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md):冻结 `POST /api/assets/objects/confirm` 从 Axum 通过 Rust SDK 调用 `SpacetimeDB procedure` 的最小落地方案,明确本地 server、数据库名、procedure/reducer 分工与 `spacetime-client` 边界。
|
||||
- [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。
|
||||
- [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。
|
||||
- [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。
|
||||
@@ -39,4 +44,4 @@
|
||||
## 使用建议
|
||||
|
||||
- 做实现选型时,优先看这一组。
|
||||
- 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。
|
||||
- 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。
|
||||
@@ -115,7 +115,7 @@
|
||||
结论:
|
||||
|
||||
- 所有图片、动画、精灵表、场景图、封面图、视频参考素材都存 OSS。
|
||||
- SpacetimeDB 只存对象键、版本、尺寸、状态、逻辑元数据,不存二进制。
|
||||
- SpacetimeDB 只存 `bucket`、对象键、版本、尺寸、状态、逻辑元数据,不存二进制。
|
||||
- Axum 负责下发直传凭证、校验上传结果、补写元数据。
|
||||
|
||||
## 4. 目标总体架构
|
||||
@@ -429,7 +429,10 @@ server-rs/
|
||||
|
||||
1. 任务状态在 SpacetimeDB。
|
||||
2. 二进制对象在 OSS。
|
||||
3. 所有业务实体只引用 `asset_object_key / cdn_url / version / hash`。
|
||||
3. `asset_object` 的正式真相字段固定为 `bucket + object_key`。
|
||||
4. 所有 URL 都只作为派生读模型,不作为对象主键存储。
|
||||
5. `asset_object` 的首版字段、访问级别与索引设计见:
|
||||
- [SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
|
||||
|
||||
## 8.2 public / private 原则
|
||||
|
||||
@@ -603,20 +606,27 @@ workflow-cache/{workflow_type}/{workflow_id}.json
|
||||
|
||||
1. `server-rs/crates/platform-oss` 已提供 `PostObject` 直传签名能力。
|
||||
2. `server-rs/crates/api-server` 已暴露 `POST /api/assets/direct-upload-tickets`。
|
||||
3. 该接口当前输出:
|
||||
3. `server-rs/crates/platform-oss` 已提供私有对象 `GET` 短期签名 URL 能力。
|
||||
4. `server-rs/crates/api-server` 已暴露 `GET /api/assets/read-url`。
|
||||
5. 上传接口当前输出:
|
||||
- `bucket`
|
||||
- `objectKey`
|
||||
- `legacyPublicPath`
|
||||
- `publicUrl`
|
||||
- `formFields`
|
||||
- `expiresAt`
|
||||
4. 当前签名链路优先兼容旧公开前缀:
|
||||
6. 读取接口当前支持:
|
||||
- `objectKey`
|
||||
- `legacyPublicPath`
|
||||
- `expireSeconds`
|
||||
7. 当前 bucket 已明确为私有读写,因此 `publicUrl` 不再作为正式对象真相输出。
|
||||
8. 当前签名链路优先兼容旧公开前缀:
|
||||
- `/generated-character-drafts/*`
|
||||
- `/generated-characters/*`
|
||||
- `/generated-animations/*`
|
||||
- `/generated-custom-world-scenes/*`
|
||||
- `/generated-custom-world-covers/*`
|
||||
- `/generated-qwen-sprites/*`
|
||||
5. `STS`、服务端上传 helper、对象确认与业务绑定仍在后续阶段补齐。
|
||||
9. `STS`、服务端上传 helper、对象确认与业务绑定仍在后续阶段补齐。
|
||||
|
||||
## 11.3 元数据与标签
|
||||
|
||||
@@ -639,9 +649,9 @@ workflow-cache/{workflow_type}/{workflow_id}.json
|
||||
|
||||
建议:
|
||||
|
||||
1. 业务表里统一存 `object_key`
|
||||
2. 对外输出 `cdn_url`
|
||||
3. 私有对象额外输出短期签名 URL
|
||||
1. 业务表里统一存 `bucket + object_key`
|
||||
2. 对外输出 `cdn_url` 或签名 URL
|
||||
3. 私有对象默认输出短期签名 URL,而不是假设匿名公开读
|
||||
|
||||
为了兼容当前前端相对路径使用习惯,第一阶段可以让 Axum 或 CDN 兼容以下历史前缀:
|
||||
|
||||
@@ -652,6 +662,19 @@ workflow-cache/{workflow_type}/{workflow_id}.json
|
||||
5. `/generated-custom-world-covers/*`
|
||||
6. `/generated-qwen-sprites/*`
|
||||
|
||||
补充约束:
|
||||
|
||||
1. 当前 `xushi-dev` bucket 已明确为私有读写,因此这些旧前缀在第一阶段只代表兼容路径习惯,不代表对象可匿名读取。
|
||||
2. Web 端若拿到的是历史 `/generated-*` 路径,必须先调用 `GET /api/assets/read-url` 换取 `signedUrl`,不能直接把该路径当成正式可读 URL。
|
||||
3. 前端工程内凡是图片、背景图、封面图、角色图、场景图等展示入口,只要可能接收到 `/generated-*`,都必须统一走资源解析层:
|
||||
- 列表/卡片/普通 `<img>` 优先复用 `src/services/assetReadUrlService.ts`
|
||||
- 组件内优先复用 `src/hooks/useResolvedAssetReadUrl.ts`
|
||||
- 通用图片标签优先复用 `src/components/ResolvedAssetImage.tsx`
|
||||
- 当前已完成的高优先级入口包括:`CharacterAnimator`、`CharacterPanel`、`CompanionCampModal`、`CharacterSelectionFlow`、`MapModal`、`GameCanvasSceneLayer`、`GameCanvasShared`、`GameCanvasEntityLayer`、`CustomWorldResultView`、`CustomWorldEntityEditorModal`、`CustomWorldRoleAssetStudioModal`、`CustomWorldAgentDraftDetailPanel`、`PlatformHomeView`、`PlatformWorldDetailView`、`QwenSpriteSheetTool`
|
||||
4. 对私有 OSS 资源,前端在签名地址返回前不能先回退渲染原始 `/generated-*` 路径,否则浏览器会先发起一次无签名请求并触发 `403`。
|
||||
2. 具体对象引用设计见:
|
||||
- [SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md)
|
||||
|
||||
## 12. 关键业务流设计
|
||||
|
||||
## 12.1 Story Action
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
3. `refresh_session` 与 `user_account.token_version` 的职责切分
|
||||
4. 会话列表、当前设备识别、轮换与吊销的数据结构
|
||||
|
||||
补充约束:
|
||||
|
||||
1. 多端登录识别字段以 [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md) 为准。
|
||||
2. 本文负责把这些字段正式并入 `refresh_session` 结构、迁移规则与接口读模型。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前 Node 后端已经存在一张 `user_sessions` 表,并且 refresh cookie 主链已经完整可用:
|
||||
@@ -44,6 +49,7 @@
|
||||
1. refresh session 已经是现有系统的既有真相源。
|
||||
2. Rust 重写时不需要重新发明另一套“session cache + cookie state”双轨模型。
|
||||
3. 只需要把当前语义更明确地迁入 SpacetimeDB,并把与 `user_account` 的职责切开。
|
||||
4. 旧 Node 基线只有最小 `client_type + user_agent + ip` 粒度,本轮需要升级为结构化客户端身份模型。
|
||||
|
||||
## 3. 边界定义
|
||||
|
||||
@@ -129,7 +135,14 @@
|
||||
| `session_id` | `String` | 是 | 主键,建议继续沿用 `usess_*` 前缀。 |
|
||||
| `user_id` | `String` | 是 | 归属账号 ID,外键指向 `user_account.user_id`。 |
|
||||
| `refresh_token_hash` | `String` | 是 | 当前生效 refresh token 的哈希值。 |
|
||||
| `client_type` | `String` | 是 | 当前设备类型,当前阶段默认 `browser`。 |
|
||||
| `client_type` | `String` | 是 | 终端大类,固定枚举见多端会话身份设计。 |
|
||||
| `client_runtime` | `String` | 是 | 具体运行时,例如 `chrome`、`wechat_mini_program`。 |
|
||||
| `client_platform` | `String` | 是 | 平台类型,例如 `windows`、`ios`、`android`。 |
|
||||
| `client_instance_id` | `Option<String>` | 否 | 客户端实例 ID,用于区分同设备不同浏览器或同浏览器不同安装实例。 |
|
||||
| `device_fingerprint` | `Option<String>` | 否 | 服务端派生的设备聚类指纹,不作为安全凭据。 |
|
||||
| `device_display_name` | `String` | 是 | 用于会话列表展示的统一端侧名称。 |
|
||||
| `mini_program_app_id` | `Option<String>` | 否 | 小程序 appid。 |
|
||||
| `mini_program_env` | `Option<String>` | 否 | 小程序环境,例如 `develop`、`trial`、`release`。 |
|
||||
| `user_agent` | `Option<String>` | 否 | 请求头中的 `User-Agent` 原文。 |
|
||||
| `ip` | `Option<String>` | 否 | 会话创建时采集的客户端 IP。 |
|
||||
| `issued_by_provider` | `String` | 是 | 该会话是由哪种登录链路创建,枚举固定为 `password`、`phone`、`wechat`。 |
|
||||
@@ -145,6 +158,8 @@
|
||||
1. 当前阶段时间字段统一继续使用 UTC RFC3339 字符串。
|
||||
2. `session_id` 在 refresh 轮换时保持不变,不创建新会话行。
|
||||
3. `issued_by_provider` 不是为了做 provider 身份表,而是为了后续账号安全页和审计展示保留稳定字段。
|
||||
4. `device_display_name` 由 Axum 应用层基于结构化字段派生,不直接信任前端自由文本。
|
||||
5. `client_type`、`client_runtime`、`client_platform` 的具体枚举口径固定受多端会话身份设计文档约束。
|
||||
|
||||
## 7. 唯一约束与索引
|
||||
|
||||
@@ -285,13 +300,21 @@
|
||||
|
||||
1. `session_id`
|
||||
2. `client_type`
|
||||
3. `user_agent`
|
||||
4. `ip`
|
||||
5. `created_at`
|
||||
6. `last_seen_at`
|
||||
7. `expires_at`
|
||||
3. `client_runtime`
|
||||
4. `client_platform`
|
||||
5. `device_display_name`
|
||||
6. `mini_program_app_id`
|
||||
7. `mini_program_env`
|
||||
8. `user_agent`
|
||||
9. `ip`
|
||||
10. `created_at`
|
||||
11. `last_seen_at`
|
||||
12. `expires_at`
|
||||
|
||||
前端 DTO `clientLabel`、`ipMasked`、`isCurrent` 继续在 Axum 侧派生。
|
||||
前端 DTO 侧:
|
||||
|
||||
1. `clientLabel` 当前阶段继续兼容保留,但固定与 `deviceDisplayName` 对齐。
|
||||
2. `ipMasked`、`isCurrent` 继续在 Axum 侧派生。
|
||||
|
||||
### 10.3 `POST /api/auth/logout`
|
||||
|
||||
@@ -314,7 +337,7 @@
|
||||
| `id` | `session_id` | 原样迁移。 |
|
||||
| `user_id` | `user_id` | 原样迁移。 |
|
||||
| `refresh_token_hash` | `refresh_token_hash` | 原样迁移。 |
|
||||
| `client_type` | `client_type` | 原样迁移。 |
|
||||
| `client_type` | `client_type` | `browser` 在迁移后统一归一为 `web_browser`。 |
|
||||
| `user_agent` | `user_agent` | 原样迁移。 |
|
||||
| `ip` | `ip` | 原样迁移。 |
|
||||
| `expires_at` | `expires_at` | 原样迁移。 |
|
||||
@@ -330,6 +353,20 @@
|
||||
说明:这是保守回填值,后续只影响展示,不影响鉴权正确性
|
||||
2. `revoked_reason_code`
|
||||
初次迁移统一回填为 `null`
|
||||
3. `client_runtime`
|
||||
初次迁移按 `User-Agent` 粗判,无法识别时回填 `unknown`
|
||||
4. `client_platform`
|
||||
初次迁移按 `User-Agent` 粗判,无法识别时回填 `unknown`
|
||||
5. `client_instance_id`
|
||||
初次迁移统一回填为 `null`
|
||||
6. `device_fingerprint`
|
||||
初次迁移按 `client_type + client_runtime + client_platform + normalized_user_agent` 派生
|
||||
7. `device_display_name`
|
||||
初次迁移由后端按多端会话身份规则派生
|
||||
8. `mini_program_app_id`
|
||||
初次迁移统一回填为 `null`
|
||||
9. `mini_program_env`
|
||||
初次迁移统一回填为 `null`
|
||||
|
||||
## 12. reducer / service 落地约束
|
||||
|
||||
|
||||
@@ -84,7 +84,12 @@ export type AuthRefreshResponse = {
|
||||
export type AuthSessionSummary = {
|
||||
sessionId: string;
|
||||
clientType: string;
|
||||
clientRuntime: string;
|
||||
clientPlatform: string;
|
||||
clientLabel: string;
|
||||
deviceDisplayName: string;
|
||||
miniProgramAppId: string | null;
|
||||
miniProgramEnv: string | null;
|
||||
userAgent: string | null;
|
||||
ipMasked: string | null;
|
||||
isCurrent: boolean;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
|
||||
11. 接入 `GET /api/auth/me` 当前用户查询链路
|
||||
12. 接入 `POST /api/auth/refresh` refresh token 轮换链路
|
||||
13. 接入 `POST /api/auth/logout` 当前设备退出链路
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -44,6 +45,7 @@
|
||||
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
||||
8. [x] 接入 `/api/auth/me`
|
||||
9. [x] 接入 `/api/auth/refresh`
|
||||
10. [x] 接入 `/api/auth/logout`
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -105,3 +107,4 @@
|
||||
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
|
||||
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
|
||||
7. 当前 `/api/auth/refresh` 复用 `module-auth` 的 refresh session 轮换能力,`api-server` 负责 refresh cookie 读取、失败清理与 access token 重签。
|
||||
8. 当前 `/api/auth/logout` 复用 `module-auth` 的当前会话吊销与用户版本递增能力,`api-server` 负责 Bearer JWT、refresh cookie 读取与清理 cookie 回写。
|
||||
|
||||
@@ -10,14 +10,18 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
|
||||
use tracing::{Level, info_span};
|
||||
|
||||
use crate::{
|
||||
assets::create_direct_upload_ticket,
|
||||
assets::{create_direct_upload_ticket, get_asset_read_url},
|
||||
auth::{
|
||||
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
|
||||
require_bearer_auth,
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_sessions::auth_sessions,
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
login_options::auth_login_options,
|
||||
logout::logout,
|
||||
logout_all::logout_all,
|
||||
password_entry::password_entry,
|
||||
refresh_session::refresh_session,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
@@ -48,6 +52,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/login-options",
|
||||
get(auth_login_options),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/me",
|
||||
get(auth_me).route_layer(middleware::from_fn_with_state(
|
||||
@@ -55,6 +63,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/sessions",
|
||||
get(auth_sessions)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_refresh_session_token,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/refresh",
|
||||
post(refresh_session).route_layer(middleware::from_fn_with_state(
|
||||
@@ -62,10 +82,30 @@ pub fn build_router(state: AppState) -> Router {
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/logout",
|
||||
post(logout)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_refresh_session_token,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/logout-all",
|
||||
post(logout_all).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/direct-upload-tickets",
|
||||
post(create_direct_upload_ticket),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route("/api/auth/entry", post(password_entry))
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
@@ -226,13 +266,21 @@ 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 claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "usr_auth_debug".to_string(),
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_auth_debug".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 7,
|
||||
token_version: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("测试用户".to_string()),
|
||||
@@ -268,7 +316,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
payload["claims"]["sub"],
|
||||
Value::String("usr_auth_debug".to_string())
|
||||
Value::String("user_00000001".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["claims"]["sid"],
|
||||
@@ -276,7 +324,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
payload["claims"]["ver"],
|
||||
Value::Number(serde_json::Number::from(7))
|
||||
Value::Number(serde_json::Number::from(1))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,6 +443,157 @@ mod tests {
|
||||
assert!(payload["token"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_login_options_returns_enabled_methods_in_stable_order() {
|
||||
let config = AppConfig {
|
||||
sms_auth_enabled: true,
|
||||
wechat_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/login-options")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
payload["availableLoginMethods"],
|
||||
serde_json::json!(["phone", "wechat"])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_sessions_returns_multi_device_session_fields() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let first_login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"user-agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
)
|
||||
.header("x-client-instance-id", "chrome-instance-001")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_sessions_api",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("first login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
let first_cookie = first_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("first cookie should exist")
|
||||
.to_string();
|
||||
let first_body = first_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first login body should collect")
|
||||
.to_bytes();
|
||||
let first_payload: Value =
|
||||
serde_json::from_slice(&first_body).expect("first login payload should be json");
|
||||
let access_token = first_payload["token"]
|
||||
.as_str()
|
||||
.expect("access token should exist")
|
||||
.to_string();
|
||||
|
||||
let _second_login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-client-type", "mini_program")
|
||||
.header("x-client-runtime", "wechat_mini_program")
|
||||
.header("x-client-platform", "android")
|
||||
.header("x-client-instance-id", "mini-instance-001")
|
||||
.header("x-mini-program-app-id", "wx-session-test")
|
||||
.header("x-mini-program-env", "release")
|
||||
.header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_sessions_api",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("second login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second login should succeed");
|
||||
|
||||
let sessions_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/sessions")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("cookie", first_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("sessions request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("sessions request should succeed");
|
||||
|
||||
assert_eq!(sessions_response.status(), StatusCode::OK);
|
||||
let sessions_body = sessions_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("sessions body should collect")
|
||||
.to_bytes();
|
||||
let sessions_payload: Value =
|
||||
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
|
||||
let sessions = sessions_payload["sessions"]
|
||||
.as_array()
|
||||
.expect("sessions should be array");
|
||||
|
||||
assert_eq!(sessions.len(), 2);
|
||||
assert!(sessions.iter().any(|session| {
|
||||
session["clientType"] == Value::String("web_browser".to_string())
|
||||
&& session["clientRuntime"] == Value::String("chrome".to_string())
|
||||
&& session["clientPlatform"] == Value::String("windows".to_string())
|
||||
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
|
||||
&& session["isCurrent"] == Value::Bool(true)
|
||||
}));
|
||||
assert!(sessions.iter().any(|session| {
|
||||
session["clientType"] == Value::String("mini_program".to_string())
|
||||
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
|
||||
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
|
||||
&& session["miniProgramEnv"] == Value::String("release".to_string())
|
||||
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
|
||||
&& session["isCurrent"] == Value::Bool(false)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_reuses_same_user_for_same_credentials() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -732,4 +931,352 @@ mod tests {
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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 refresh_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("refresh cookie should exist")
|
||||
.to_string();
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let login_payload: Value =
|
||||
serde_json::from_slice(&login_body).expect("login payload should be json");
|
||||
let access_token = login_payload["token"]
|
||||
.as_str()
|
||||
.expect("token should exist")
|
||||
.to_string();
|
||||
|
||||
let logout_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/logout")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("cookie", refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("logout request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("logout request should succeed");
|
||||
|
||||
assert_eq!(logout_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
logout_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
|
||||
let logout_body = logout_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("logout body should collect")
|
||||
.to_bytes();
|
||||
let logout_payload: Value =
|
||||
serde_json::from_slice(&logout_body).expect("logout payload should be json");
|
||||
assert_eq!(logout_payload["ok"], Value::Bool(true));
|
||||
|
||||
let me_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("me request should succeed");
|
||||
|
||||
assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[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 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_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let login_payload: Value =
|
||||
serde_json::from_slice(&login_body).expect("login payload should be json");
|
||||
let access_token = login_payload["token"]
|
||||
.as_str()
|
||||
.expect("token should exist")
|
||||
.to_string();
|
||||
|
||||
let logout_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/logout")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("logout request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("logout request should succeed");
|
||||
|
||||
assert_eq!(logout_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
logout_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
|
||||
#[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 first_login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
"user-agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
)
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_logout_all_api",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("first login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
let first_refresh_cookie = first_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("first refresh cookie should exist")
|
||||
.to_string();
|
||||
let first_login_body = first_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first login body should collect")
|
||||
.to_bytes();
|
||||
let first_login_payload: Value =
|
||||
serde_json::from_slice(&first_login_body).expect("first login payload should be json");
|
||||
let first_access_token = first_login_payload["token"]
|
||||
.as_str()
|
||||
.expect("first access token should exist")
|
||||
.to_string();
|
||||
|
||||
let second_login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-client-runtime", "firefox")
|
||||
.header("x-client-instance-id", "logout-all-instance-002")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_logout_all_api",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("second login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second login should succeed");
|
||||
let second_refresh_cookie = second_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("second refresh cookie should exist")
|
||||
.to_string();
|
||||
|
||||
let logout_all_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/logout-all")
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.header("cookie", first_refresh_cookie.clone())
|
||||
.body(Body::empty())
|
||||
.expect("logout-all request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("logout-all request should succeed");
|
||||
|
||||
assert_eq!(logout_all_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
logout_all_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
|
||||
let logout_all_body = logout_all_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("logout-all body should collect")
|
||||
.to_bytes();
|
||||
let logout_all_payload: Value = serde_json::from_slice(&logout_all_body)
|
||||
.expect("logout-all payload should be json");
|
||||
assert_eq!(logout_all_payload["ok"], Value::Bool(true));
|
||||
|
||||
let me_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {first_access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("me request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("me request should succeed");
|
||||
assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let first_refresh_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", first_refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("first refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first refresh request should succeed");
|
||||
assert_eq!(first_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let second_refresh_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", second_refresh_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("second refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second refresh request should succeed");
|
||||
assert_eq!(second_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[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 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_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let login_payload: Value =
|
||||
serde_json::from_slice(&login_body).expect("login payload should be json");
|
||||
let access_token = login_payload["token"]
|
||||
.as_str()
|
||||
.expect("access token should exist")
|
||||
.to_string();
|
||||
|
||||
let logout_all_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/logout-all")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.body(Body::empty())
|
||||
.expect("logout-all request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("logout-all request should succeed");
|
||||
|
||||
assert_eq!(logout_all_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
logout_all_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,36 @@ pub async fn require_bearer_auth(
|
||||
);
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
})?;
|
||||
let current_user = state
|
||||
.auth_user_service()
|
||||
.get_user_by_id(claims.user_id())
|
||||
.map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
error = %error,
|
||||
"Bearer JWT 用户快照读取失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
warn!(
|
||||
%request_id,
|
||||
user_id = %claims.user_id(),
|
||||
"Bearer JWT 对应用户不存在"
|
||||
);
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
})?;
|
||||
if current_user.token_version != claims.token_version() {
|
||||
warn!(
|
||||
%request_id,
|
||||
user_id = %claims.user_id(),
|
||||
token_version = claims.token_version(),
|
||||
current_token_version = current_user.token_version,
|
||||
"Bearer JWT 版本已失效"
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("当前登录态已失效,请重新登录"));
|
||||
}
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use axum::http::{
|
||||
HeaderMap, HeaderValue, StatusCode,
|
||||
header::SET_COOKIE,
|
||||
};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE};
|
||||
use module_auth::{
|
||||
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, RefreshSessionError,
|
||||
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError,
|
||||
};
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
||||
@@ -13,6 +10,7 @@ use platform_auth::{
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
use crate::{session_client::SessionClientContext};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignedAuthSession {
|
||||
@@ -23,6 +21,7 @@ pub struct SignedAuthSession {
|
||||
pub fn create_password_auth_session(
|
||||
state: &AppState,
|
||||
user: &AuthUser,
|
||||
session_client: &SessionClientContext,
|
||||
) -> Result<SignedAuthSession, AppError> {
|
||||
let refresh_token = create_refresh_session_token();
|
||||
let refresh_token_hash = hash_refresh_session_token(&refresh_token);
|
||||
@@ -33,6 +32,7 @@ pub fn create_password_auth_session(
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash,
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: session_client.to_refresh_session_client_info(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
@@ -95,10 +95,7 @@ pub fn build_clear_refresh_session_cookie_header(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn attach_set_cookie_header(
|
||||
headers: &mut HeaderMap,
|
||||
set_cookie: HeaderValue,
|
||||
) {
|
||||
pub fn attach_set_cookie_header(headers: &mut HeaderMap, set_cookie: HeaderValue) {
|
||||
headers.insert(SET_COOKIE, set_cookie);
|
||||
}
|
||||
|
||||
@@ -116,6 +113,16 @@ pub fn map_refresh_session_error(error: RefreshSessionError) -> AppError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_logout_error(error: LogoutError) -> AppError {
|
||||
match error {
|
||||
LogoutError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("当前登录态已失效,请重新登录"),
|
||||
LogoutError::Store(message) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_auth_provider(login_method: &AuthLoginMethod) -> AuthProvider {
|
||||
match login_method {
|
||||
AuthLoginMethod::Password => AuthProvider::Password,
|
||||
|
||||
113
server-rs/crates/api-server/src/auth_sessions.rs
Normal file
113
server-rs/crates/api-server/src/auth_sessions.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::{AuthenticatedAccessToken, RefreshSessionToken},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
session_client::mask_ip,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionsResponse {
|
||||
pub sessions: Vec<AuthSessionSummaryPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionSummaryPayload {
|
||||
pub session_id: String,
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
pub client_label: String,
|
||||
pub device_display_name: String,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_env: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip_masked: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub created_at: String,
|
||||
pub last_seen_at: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
pub async fn auth_sessions(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// 当前设备识别仍然依赖 refresh cookie 命中的原始 token,对旧前端行为保持兼容。
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let current_refresh_token_hash = maybe_refresh_token.and_then(|token| {
|
||||
let token = token.0.token().trim();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(hash_refresh_session_token(token))
|
||||
});
|
||||
|
||||
let sessions = state
|
||||
.refresh_session_service()
|
||||
.list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc())
|
||||
.map_err(map_refresh_session_list_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AuthSessionsResponse {
|
||||
sessions: sessions
|
||||
.sessions
|
||||
.into_iter()
|
||||
.map(|session| {
|
||||
let is_current = current_refresh_token_hash.as_ref().is_some_and(|hash| {
|
||||
session.refresh_token_hash == *hash
|
||||
});
|
||||
let client_label = session.client_info.device_display_name.clone();
|
||||
|
||||
AuthSessionSummaryPayload {
|
||||
session_id: session.session_id,
|
||||
client_type: session.client_info.client_type,
|
||||
client_runtime: session.client_info.client_runtime,
|
||||
client_platform: session.client_info.client_platform,
|
||||
client_label,
|
||||
device_display_name: session.client_info.device_display_name,
|
||||
mini_program_app_id: session.client_info.mini_program_app_id,
|
||||
mini_program_env: session.client_info.mini_program_env,
|
||||
user_agent: session.client_info.user_agent,
|
||||
ip_masked: mask_ip(session.client_info.ip.as_deref()),
|
||||
is_current,
|
||||
created_at: session.created_at,
|
||||
last_seen_at: session.last_seen_at,
|
||||
expires_at: session.expires_at,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError {
|
||||
match error {
|
||||
module_auth::RefreshSessionError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("当前登录态已失效,请重新登录"),
|
||||
module_auth::RefreshSessionError::MissingToken
|
||||
| module_auth::RefreshSessionError::SessionNotFound
|
||||
| module_auth::RefreshSessionError::SessionExpired => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
|
||||
}
|
||||
module_auth::RefreshSessionError::Store(message) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
server-rs/crates/api-server/src/login_options.rs
Normal file
33
server-rs/crates/api-server/src/login_options.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{api_response::json_success_body, request_context::RequestContext, state::AppState};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthLoginOptionsResponse {
|
||||
pub available_login_methods: Vec<&'static str>,
|
||||
}
|
||||
|
||||
pub async fn auth_login_options(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut methods = Vec::new();
|
||||
if state.config.sms_auth_enabled {
|
||||
methods.push("phone");
|
||||
}
|
||||
if state.config.wechat_auth_enabled {
|
||||
methods.push("wechat");
|
||||
}
|
||||
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
AuthLoginOptionsResponse {
|
||||
available_login_methods: methods,
|
||||
},
|
||||
)
|
||||
}
|
||||
63
server-rs/crates/api-server/src/logout.rs
Normal file
63
server-rs/crates/api-server/src/logout.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use axum::{
|
||||
extract::{Extension, State},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::LogoutCurrentSessionInput;
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::{AuthenticatedAccessToken, RefreshSessionToken},
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_clear_refresh_session_cookie_header, map_logout_error,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LogoutResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let refresh_token_hash = maybe_refresh_token.and_then(|token| {
|
||||
let token = token.0.token().trim().to_string();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(hash_refresh_session_token(&token))
|
||||
});
|
||||
|
||||
state
|
||||
.auth_user_service()
|
||||
.logout_current_session(
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
refresh_token_hash,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(map_logout_error)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
&mut headers,
|
||||
build_clear_refresh_session_cookie_header(&state)?,
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
json_success_body(Some(&request_context), LogoutResponse { ok: true }),
|
||||
))
|
||||
}
|
||||
51
server-rs/crates/api-server/src/logout_all.rs
Normal file
51
server-rs/crates/api-server/src/logout_all.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use axum::{
|
||||
extract::{Extension, State},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::LogoutAllSessionsInput;
|
||||
use serde::Serialize;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_clear_refresh_session_cookie_header, map_logout_error,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LogoutAllResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub async fn logout_all(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
state
|
||||
.auth_user_service()
|
||||
.logout_all_sessions(
|
||||
LogoutAllSessionsInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(map_logout_error)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
&mut headers,
|
||||
build_clear_refresh_session_cookie_header(&state)?,
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
json_success_body(Some(&request_context), LogoutAllResponse { ok: true }),
|
||||
))
|
||||
}
|
||||
@@ -2,16 +2,21 @@ mod api_response;
|
||||
mod app;
|
||||
mod assets;
|
||||
mod auth;
|
||||
mod auth_session;
|
||||
mod auth_me;
|
||||
mod auth_sessions;
|
||||
mod auth_session;
|
||||
mod config;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod login_options;
|
||||
mod logout;
|
||||
mod logout_all;
|
||||
mod password_entry;
|
||||
mod refresh_session;
|
||||
mod request_context;
|
||||
mod response_headers;
|
||||
mod session_client;
|
||||
mod state;
|
||||
|
||||
use shared_logging::init_tracing;
|
||||
@@ -40,4 +45,4 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
|
||||
|
||||
axum::serve(listener, router).await
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
session_client::resolve_session_client_context,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -47,6 +48,7 @@ pub struct PasswordEntryUserPayload {
|
||||
pub async fn password_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<PasswordEntryRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let result = state
|
||||
@@ -57,7 +59,8 @@ pub async fn password_entry(
|
||||
})
|
||||
.await
|
||||
.map_err(map_password_entry_error)?;
|
||||
let signed_session = create_password_auth_session(&state, &result.user)?;
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
|
||||
433
server-rs/crates/api-server/src/session_client.rs
Normal file
433
server-rs/crates/api-server/src/session_client.rs
Normal file
@@ -0,0 +1,433 @@
|
||||
use axum::http::HeaderMap;
|
||||
use module_auth::RefreshSessionClientInfo;
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
|
||||
const X_CLIENT_TYPE_HEADER: &str = "x-client-type";
|
||||
const X_CLIENT_RUNTIME_HEADER: &str = "x-client-runtime";
|
||||
const X_CLIENT_PLATFORM_HEADER: &str = "x-client-platform";
|
||||
const X_CLIENT_INSTANCE_ID_HEADER: &str = "x-client-instance-id";
|
||||
const X_MINI_PROGRAM_APP_ID_HEADER: &str = "x-mini-program-app-id";
|
||||
const X_MINI_PROGRAM_ENV_HEADER: &str = "x-mini-program-env";
|
||||
const USER_AGENT_HEADER: &str = "user-agent";
|
||||
const X_FORWARDED_FOR_HEADER: &str = "x-forwarded-for";
|
||||
const X_REAL_IP_HEADER: &str = "x-real-ip";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SessionClientContext {
|
||||
// 统一保存登录时采集到的客户端身份快照,后续直接写入 refresh_session。
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
pub client_instance_id: Option<String>,
|
||||
pub device_fingerprint: Option<String>,
|
||||
pub device_display_name: String,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_env: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionClientContext {
|
||||
pub fn to_refresh_session_client_info(&self) -> RefreshSessionClientInfo {
|
||||
RefreshSessionClientInfo {
|
||||
client_type: self.client_type.clone(),
|
||||
client_runtime: self.client_runtime.clone(),
|
||||
client_platform: self.client_platform.clone(),
|
||||
client_instance_id: self.client_instance_id.clone(),
|
||||
device_fingerprint: self.device_fingerprint.clone(),
|
||||
device_display_name: self.device_display_name.clone(),
|
||||
mini_program_app_id: self.mini_program_app_id.clone(),
|
||||
mini_program_env: self.mini_program_env.clone(),
|
||||
user_agent: self.user_agent.clone(),
|
||||
ip: self.ip.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_session_client_context(headers: &HeaderMap) -> SessionClientContext {
|
||||
// 显式头优先,UA 自动识别兜底,避免前端没有完全补头时整条登录链路不可用。
|
||||
let user_agent = header_value(headers, USER_AGENT_HEADER);
|
||||
let ua_lower = user_agent
|
||||
.as_ref()
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let explicit_client_type = normalize_client_type(header_value(headers, X_CLIENT_TYPE_HEADER));
|
||||
let explicit_client_runtime =
|
||||
normalize_runtime(header_value(headers, X_CLIENT_RUNTIME_HEADER), &ua_lower);
|
||||
let explicit_client_platform =
|
||||
normalize_platform(header_value(headers, X_CLIENT_PLATFORM_HEADER), &ua_lower);
|
||||
let client_instance_id = normalize_optional_string(header_value(
|
||||
headers,
|
||||
X_CLIENT_INSTANCE_ID_HEADER,
|
||||
));
|
||||
let mini_program_app_id =
|
||||
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_APP_ID_HEADER));
|
||||
let mini_program_env =
|
||||
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_ENV_HEADER));
|
||||
|
||||
let inferred_client_type = infer_client_type(explicit_client_type.as_deref(), &ua_lower);
|
||||
let inferred_runtime =
|
||||
infer_client_runtime(explicit_client_runtime.as_deref(), &inferred_client_type, &ua_lower);
|
||||
let inferred_platform = infer_client_platform(explicit_client_platform.as_deref(), &ua_lower);
|
||||
let ip = resolve_ip(headers);
|
||||
let device_display_name =
|
||||
build_device_display_name(&inferred_client_type, &inferred_runtime, &inferred_platform);
|
||||
let device_fingerprint =
|
||||
build_device_fingerprint(&inferred_client_type, &inferred_runtime, &inferred_platform, client_instance_id.as_deref(), user_agent.as_deref());
|
||||
|
||||
SessionClientContext {
|
||||
client_type: inferred_client_type,
|
||||
client_runtime: inferred_runtime,
|
||||
client_platform: inferred_platform,
|
||||
client_instance_id,
|
||||
device_fingerprint,
|
||||
device_display_name,
|
||||
mini_program_app_id,
|
||||
mini_program_env,
|
||||
user_agent: normalize_optional_string(user_agent),
|
||||
ip,
|
||||
}
|
||||
}
|
||||
|
||||
fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(normalized)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_client_type(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"web_browser" | "wechat_h5" | "mini_program" | "native_app" | "desktop_app"
|
||||
| "unknown" => Some(normalized),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_runtime(value: Option<String>, ua_lower: &str) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"chrome"
|
||||
| "edge"
|
||||
| "safari"
|
||||
| "firefox"
|
||||
| "wechat_embedded_browser"
|
||||
| "wechat_mini_program"
|
||||
| "alipay_mini_program"
|
||||
| "douyin_mini_program"
|
||||
| "unknown" => Some(normalized),
|
||||
_ => infer_runtime_from_user_agent(ua_lower),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_platform(value: Option<String>, ua_lower: &str) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"windows" | "macos" | "linux" | "ios" | "android" | "unknown" => Some(normalized),
|
||||
_ => infer_platform_from_user_agent(ua_lower),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn infer_client_type(explicit_type: Option<&str>, ua_lower: &str) -> String {
|
||||
if let Some(client_type) = explicit_type {
|
||||
return client_type.to_string();
|
||||
}
|
||||
|
||||
if ua_lower.contains("micromessenger") {
|
||||
return "wechat_h5".to_string();
|
||||
}
|
||||
|
||||
"web_browser".to_string()
|
||||
}
|
||||
|
||||
fn infer_client_runtime(explicit_runtime: Option<&str>, client_type: &str, ua_lower: &str) -> String {
|
||||
if client_type == "mini_program" {
|
||||
if let Some(runtime) = explicit_runtime {
|
||||
return runtime.to_string();
|
||||
}
|
||||
if ua_lower.contains("alipayclient") {
|
||||
return "alipay_mini_program".to_string();
|
||||
}
|
||||
if ua_lower.contains("toutiaomicroapp") || ua_lower.contains("douyin") {
|
||||
return "douyin_mini_program".to_string();
|
||||
}
|
||||
return "wechat_mini_program".to_string();
|
||||
}
|
||||
|
||||
if client_type == "wechat_h5" {
|
||||
return "wechat_embedded_browser".to_string();
|
||||
}
|
||||
|
||||
explicit_runtime
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| infer_runtime_from_user_agent(ua_lower))
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn infer_client_platform(explicit_platform: Option<&str>, ua_lower: &str) -> String {
|
||||
explicit_platform
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| infer_platform_from_user_agent(ua_lower))
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn infer_runtime_from_user_agent(ua_lower: &str) -> Option<String> {
|
||||
if ua_lower.contains("edg/") {
|
||||
return Some("edge".to_string());
|
||||
}
|
||||
if ua_lower.contains("firefox/") {
|
||||
return Some("firefox".to_string());
|
||||
}
|
||||
if ua_lower.contains("chrome/") || ua_lower.contains("crios/") {
|
||||
return Some("chrome".to_string());
|
||||
}
|
||||
if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/") {
|
||||
return Some("safari".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn infer_platform_from_user_agent(ua_lower: &str) -> Option<String> {
|
||||
if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ios") {
|
||||
return Some("ios".to_string());
|
||||
}
|
||||
if ua_lower.contains("android") {
|
||||
return Some("android".to_string());
|
||||
}
|
||||
if ua_lower.contains("windows") {
|
||||
return Some("windows".to_string());
|
||||
}
|
||||
if ua_lower.contains("mac os") || ua_lower.contains("macintosh") {
|
||||
return Some("macos".to_string());
|
||||
}
|
||||
if ua_lower.contains("linux") {
|
||||
return Some("linux".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn build_device_display_name(client_type: &str, client_runtime: &str, client_platform: &str) -> String {
|
||||
// 展示名固定由后端派生,避免前端上传自由文本导致同类设备标签漂移。
|
||||
if client_type == "mini_program" {
|
||||
return format!(
|
||||
"{} / {}",
|
||||
map_runtime_display(client_runtime),
|
||||
map_platform_display(client_platform)
|
||||
);
|
||||
}
|
||||
if client_type == "wechat_h5" {
|
||||
return format!("微信内网页 / {}", map_platform_display(client_platform));
|
||||
}
|
||||
if client_type == "unknown" {
|
||||
return "未知设备".to_string();
|
||||
}
|
||||
|
||||
format!(
|
||||
"{} / {}",
|
||||
map_platform_display(client_platform),
|
||||
map_runtime_display(client_runtime)
|
||||
)
|
||||
}
|
||||
|
||||
fn map_runtime_display(runtime: &str) -> &'static str {
|
||||
match runtime {
|
||||
"chrome" => "Chrome",
|
||||
"edge" => "Edge",
|
||||
"safari" => "Safari",
|
||||
"firefox" => "Firefox",
|
||||
"wechat_embedded_browser" => "微信内网页",
|
||||
"wechat_mini_program" => "微信小程序",
|
||||
"alipay_mini_program" => "支付宝小程序",
|
||||
"douyin_mini_program" => "抖音小程序",
|
||||
_ => "未知客户端",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_platform_display(platform: &str) -> &'static str {
|
||||
match platform {
|
||||
"windows" => "Windows",
|
||||
"macos" => "macOS",
|
||||
"linux" => "Linux",
|
||||
"ios" => "iPhone",
|
||||
"android" => "Android",
|
||||
_ => "未知设备",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_device_fingerprint(
|
||||
client_type: &str,
|
||||
client_runtime: &str,
|
||||
client_platform: &str,
|
||||
client_instance_id: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Option<String> {
|
||||
// 这里的指纹只用于会话聚类与展示,不参与任何鉴权决策。
|
||||
let seed = if let Some(instance_id) = client_instance_id {
|
||||
format!("{client_type}|{client_runtime}|{client_platform}|{instance_id}")
|
||||
} else if let Some(user_agent) = user_agent {
|
||||
format!(
|
||||
"{client_type}|{client_runtime}|{client_platform}|{}",
|
||||
normalize_user_agent(user_agent)
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(hash_refresh_session_token(&seed))
|
||||
}
|
||||
|
||||
fn normalize_user_agent(user_agent: &str) -> String {
|
||||
user_agent
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn resolve_ip(headers: &HeaderMap) -> Option<String> {
|
||||
if let Some(forwarded) = header_value(headers, X_FORWARDED_FOR_HEADER) {
|
||||
let ip = forwarded
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
if ip.is_some() {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
normalize_optional_string(header_value(headers, X_REAL_IP_HEADER))
|
||||
}
|
||||
|
||||
pub fn mask_ip(ip: Option<&str>) -> Option<String> {
|
||||
// 会话列表只返回脱敏后的 IP,避免把完整地址直接暴露给前端。
|
||||
let ip = ip?.trim();
|
||||
if ip.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if ip.contains(':') {
|
||||
let parts = ip.split(':').filter(|part| !part.is_empty()).collect::<Vec<_>>();
|
||||
if parts.len() <= 2 {
|
||||
return Some(ip.to_string());
|
||||
}
|
||||
return Some(format!("{}:{}::*", parts[0], parts[1]));
|
||||
}
|
||||
|
||||
let parts = ip.split('.').collect::<Vec<_>>();
|
||||
if parts.len() != 4 {
|
||||
return Some(ip.to_string());
|
||||
}
|
||||
|
||||
Some(format!("{}.{}.*.*", parts[0], parts[1]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
use super::{mask_ip, resolve_session_client_context};
|
||||
|
||||
#[test]
|
||||
fn resolve_session_client_context_detects_wechat_h5_from_user_agent() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit Safari MicroMessenger",
|
||||
),
|
||||
);
|
||||
|
||||
let context = resolve_session_client_context(&headers);
|
||||
|
||||
assert_eq!(context.client_type, "wechat_h5");
|
||||
assert_eq!(context.client_runtime, "wechat_embedded_browser");
|
||||
assert_eq!(context.client_platform, "ios");
|
||||
assert_eq!(context.device_display_name, "微信内网页 / iPhone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_session_client_context_prefers_explicit_mini_program_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-client-type", HeaderValue::from_static("mini_program"));
|
||||
headers.insert(
|
||||
"x-client-runtime",
|
||||
HeaderValue::from_static("wechat_mini_program"),
|
||||
);
|
||||
headers.insert("x-client-platform", HeaderValue::from_static("android"));
|
||||
headers.insert(
|
||||
"x-client-instance-id",
|
||||
HeaderValue::from_static("mini-instance-001"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-mini-program-app-id",
|
||||
HeaderValue::from_static("wx1234567890"),
|
||||
);
|
||||
headers.insert("x-mini-program-env", HeaderValue::from_static("release"));
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static("Mozilla/5.0 Chrome/123.0 MicroMessenger"),
|
||||
);
|
||||
|
||||
let context = resolve_session_client_context(&headers);
|
||||
|
||||
assert_eq!(context.client_type, "mini_program");
|
||||
assert_eq!(context.client_runtime, "wechat_mini_program");
|
||||
assert_eq!(context.client_platform, "android");
|
||||
assert_eq!(context.mini_program_app_id.as_deref(), Some("wx1234567890"));
|
||||
assert_eq!(context.mini_program_env.as_deref(), Some("release"));
|
||||
assert_eq!(context.device_display_name, "微信小程序 / Android");
|
||||
assert!(context.device_fingerprint.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_session_client_context_distinguishes_web_browser_runtime() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
),
|
||||
);
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.11"));
|
||||
|
||||
let context = resolve_session_client_context(&headers);
|
||||
|
||||
assert_eq!(context.client_type, "web_browser");
|
||||
assert_eq!(context.client_runtime, "chrome");
|
||||
assert_eq!(context.client_platform, "windows");
|
||||
assert_eq!(context.ip.as_deref(), Some("203.0.113.11"));
|
||||
assert_eq!(context.device_display_name, "Windows / Chrome");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_ip_returns_masked_ipv4_and_ipv6() {
|
||||
assert_eq!(mask_ip(Some("203.0.113.11")).as_deref(), Some("203.0.*.*"));
|
||||
assert_eq!(
|
||||
mask_ip(Some("2408:8000:abcd:1234::1")).as_deref(),
|
||||
Some("2408:8000::*")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use module_auth::{InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
||||
use module_auth::{AuthUserService, InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
@@ -19,6 +19,7 @@ pub struct AppState {
|
||||
oss_client: Option<OssClient>,
|
||||
password_entry_service: PasswordEntryService,
|
||||
refresh_session_service: RefreshSessionService,
|
||||
auth_user_service: AuthUserService,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -49,6 +50,7 @@ impl AppState {
|
||||
let oss_client = build_oss_client(&config)?;
|
||||
let auth_store = InMemoryAuthStore::default();
|
||||
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
||||
let auth_user_service = AuthUserService::new(auth_store.clone());
|
||||
let refresh_session_service =
|
||||
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
|
||||
|
||||
@@ -59,6 +61,7 @@ impl AppState {
|
||||
oss_client,
|
||||
password_entry_service,
|
||||
refresh_session_service,
|
||||
auth_user_service,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,6 +84,10 @@ impl AppState {
|
||||
pub fn refresh_session_service(&self) -> &RefreshSessionService {
|
||||
&self.refresh_session_service
|
||||
}
|
||||
|
||||
pub fn auth_user_service(&self) -> &AuthUserService {
|
||||
&self.auth_user_service
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
10. [../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
|
||||
11. [../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)
|
||||
12. [../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
|
||||
13. [../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)
|
||||
|
||||
## 4. 边界约束
|
||||
|
||||
@@ -52,3 +53,4 @@
|
||||
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
||||
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
|
||||
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
|
||||
8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。
|
||||
|
||||
@@ -61,6 +61,21 @@ pub struct CreateRefreshSessionInput {
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: String,
|
||||
pub issued_by_provider: AuthLoginMethod,
|
||||
pub client_info: RefreshSessionClientInfo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RefreshSessionClientInfo {
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
pub client_instance_id: Option<String>,
|
||||
pub device_fingerprint: Option<String>,
|
||||
pub device_display_name: String,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_env: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -69,6 +84,7 @@ pub struct RefreshSessionRecord {
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: String,
|
||||
pub issued_by_provider: AuthLoginMethod,
|
||||
pub client_info: RefreshSessionClientInfo,
|
||||
pub expires_at: String,
|
||||
pub revoked_at: Option<String>,
|
||||
pub created_at: String,
|
||||
@@ -93,6 +109,31 @@ pub struct RotateRefreshSessionResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ListActiveRefreshSessionsResult {
|
||||
pub sessions: Vec<RefreshSessionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutCurrentSessionInput {
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutCurrentSessionResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutAllSessionsInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutAllSessionsResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PasswordEntryError {
|
||||
InvalidUsername,
|
||||
@@ -111,6 +152,12 @@ pub enum RefreshSessionError {
|
||||
Store(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LogoutError {
|
||||
UserNotFound,
|
||||
Store(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InMemoryAuthStore {
|
||||
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
||||
@@ -146,6 +193,11 @@ pub struct RefreshSessionService {
|
||||
refresh_session_ttl_days: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthUserService {
|
||||
store: InMemoryAuthStore,
|
||||
}
|
||||
|
||||
impl PasswordEntryService {
|
||||
pub fn new(store: InMemoryAuthStore) -> Self {
|
||||
Self { store }
|
||||
@@ -231,10 +283,14 @@ impl RefreshSessionService {
|
||||
let session_id = format!("usess_{}", Uuid::new_v4().simple());
|
||||
let expires_at = now
|
||||
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
|
||||
.ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?;
|
||||
let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err(
|
||||
|error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")),
|
||||
)?;
|
||||
.ok_or_else(|| {
|
||||
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
|
||||
})?;
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}"))
|
||||
})?;
|
||||
let expires_at_iso = expires_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
@@ -245,6 +301,7 @@ impl RefreshSessionService {
|
||||
user_id: input.user_id,
|
||||
refresh_token_hash: input.refresh_token_hash,
|
||||
issued_by_provider: input.issued_by_provider,
|
||||
client_info: input.client_info,
|
||||
expires_at: expires_at_iso,
|
||||
revoked_at: None,
|
||||
created_at: now_iso.clone(),
|
||||
@@ -280,7 +337,9 @@ impl RefreshSessionService {
|
||||
&session.session.expires_at,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.map_err(|error| RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}")))?;
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}"))
|
||||
})?;
|
||||
if expires_at <= now {
|
||||
return Err(RefreshSessionError::SessionExpired);
|
||||
}
|
||||
@@ -293,10 +352,14 @@ impl RefreshSessionService {
|
||||
|
||||
let next_expires_at = now
|
||||
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
|
||||
.ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?;
|
||||
let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err(
|
||||
|error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")),
|
||||
)?;
|
||||
.ok_or_else(|| {
|
||||
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
|
||||
})?;
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}"))
|
||||
})?;
|
||||
let next_expires_at_iso = next_expires_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
@@ -317,6 +380,77 @@ impl RefreshSessionService {
|
||||
user: user.user,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_active_sessions_by_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<ListActiveRefreshSessionsResult, RefreshSessionError> {
|
||||
self.store
|
||||
.find_by_user_id(user_id)
|
||||
.map_err(map_password_store_error)?
|
||||
.ok_or(RefreshSessionError::UserNotFound)?;
|
||||
|
||||
let sessions = self.store.list_active_sessions_by_user(user_id, now)?;
|
||||
Ok(ListActiveRefreshSessionsResult { sessions })
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUserService {
|
||||
pub fn new(store: InMemoryAuthStore) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(&self, user_id: &str) -> Result<Option<AuthUser>, LogoutError> {
|
||||
self.store
|
||||
.find_by_user_id(user_id)
|
||||
.map(|maybe_user| maybe_user.map(|stored| stored.user))
|
||||
.map_err(map_password_error_to_logout_error)
|
||||
}
|
||||
|
||||
pub fn logout_current_session(
|
||||
&self,
|
||||
input: LogoutCurrentSessionInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<LogoutCurrentSessionResult, LogoutError> {
|
||||
if let Some(refresh_token_hash) = input
|
||||
.refresh_token_hash
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
self.store
|
||||
.revoke_session_by_refresh_token_hash(refresh_token_hash, now)
|
||||
.map_err(map_refresh_error_to_logout_error)?;
|
||||
}
|
||||
|
||||
let user = self
|
||||
.store
|
||||
.increment_user_token_version(&input.user_id)
|
||||
.map_err(map_password_error_to_logout_error)?
|
||||
.ok_or(LogoutError::UserNotFound)?;
|
||||
|
||||
Ok(LogoutCurrentSessionResult { user })
|
||||
}
|
||||
// 全端登出需要先吊销该用户全部 refresh session,再统一提升 token_version,
|
||||
// 让所有旧 access token 在下一次鉴权时立即失效。
|
||||
pub fn logout_all_sessions(
|
||||
&self,
|
||||
input: LogoutAllSessionsInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<LogoutAllSessionsResult, LogoutError> {
|
||||
self.store
|
||||
.revoke_all_sessions_by_user_id(&input.user_id, now)
|
||||
.map_err(map_refresh_error_to_logout_error)?;
|
||||
|
||||
let user = self
|
||||
.store
|
||||
.increment_user_token_version(&input.user_id)
|
||||
.map_err(map_password_error_to_logout_error)?
|
||||
.ok_or(LogoutError::UserNotFound)?;
|
||||
|
||||
Ok(LogoutAllSessionsResult { user })
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryAuthStore {
|
||||
@@ -398,10 +532,7 @@ impl InMemoryAuthStore {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn insert_session(
|
||||
&self,
|
||||
session: RefreshSessionRecord,
|
||||
) -> Result<(), RefreshSessionError> {
|
||||
fn insert_session(&self, session: RefreshSessionRecord) -> Result<(), RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -420,10 +551,9 @@ impl InMemoryAuthStore {
|
||||
session.refresh_token_hash.clone(),
|
||||
session.session_id.clone(),
|
||||
);
|
||||
state.sessions_by_id.insert(
|
||||
session.session_id.clone(),
|
||||
StoredRefreshSession { session },
|
||||
);
|
||||
state
|
||||
.sessions_by_id
|
||||
.insert(session.session_id.clone(), StoredRefreshSession { session });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -436,13 +566,60 @@ impl InMemoryAuthStore {
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let Some(session_id) = state.session_id_by_refresh_token_hash.get(refresh_token_hash) else {
|
||||
let Some(session_id) = state
|
||||
.session_id_by_refresh_token_hash
|
||||
.get(refresh_token_hash)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(state.sessions_by_id.get(session_id).cloned())
|
||||
}
|
||||
|
||||
fn list_active_sessions_by_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<Vec<RefreshSessionRecord>, RefreshSessionError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let now_unix = now.unix_timestamp();
|
||||
|
||||
let mut sessions = state
|
||||
.sessions_by_id
|
||||
.values()
|
||||
.filter_map(|stored| {
|
||||
if stored.session.user_id != user_id {
|
||||
return None;
|
||||
}
|
||||
if stored.session.revoked_at.is_some() {
|
||||
return None;
|
||||
}
|
||||
let expires_at = OffsetDateTime::parse(
|
||||
&stored.session.expires_at,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.ok()?;
|
||||
if expires_at.unix_timestamp() <= now_unix {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(stored.session.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
sessions.sort_by(|left, right| {
|
||||
right
|
||||
.last_seen_at
|
||||
.cmp(&left.last_seen_at)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
});
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
fn rotate_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@@ -489,12 +666,96 @@ impl InMemoryAuthStore {
|
||||
stored.session.updated_at = updated_at;
|
||||
stored.session.last_seen_at = last_seen_at;
|
||||
let updated_session = stored.clone();
|
||||
state
|
||||
.session_id_by_refresh_token_hash
|
||||
.insert(next_refresh_token_hash, updated_session.session.session_id.clone());
|
||||
state.session_id_by_refresh_token_hash.insert(
|
||||
next_refresh_token_hash,
|
||||
updated_session.session.session_id.clone(),
|
||||
);
|
||||
|
||||
Ok(updated_session)
|
||||
}
|
||||
|
||||
fn revoke_session_by_refresh_token_hash(
|
||||
&self,
|
||||
refresh_token_hash: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<(), RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let Some(session_id) = state
|
||||
.session_id_by_refresh_token_hash
|
||||
.get(refresh_token_hash)
|
||||
.cloned()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(stored) = state.sessions_by_id.get_mut(&session_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
if stored.session.revoked_at.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||
})?;
|
||||
stored.session.revoked_at = Some(now_iso.clone());
|
||||
stored.session.updated_at = now_iso;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
fn revoke_all_sessions_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<(), RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||
})?;
|
||||
|
||||
for stored in state.sessions_by_id.values_mut() {
|
||||
if stored.session.user_id != user_id {
|
||||
continue;
|
||||
}
|
||||
if stored.session.revoked_at.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
stored.session.revoked_at = Some(now_iso.clone());
|
||||
stored.session.updated_at = now_iso.clone();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn increment_user_token_version(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<AuthUser>, PasswordEntryError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
|
||||
for stored_user in state.users_by_username.values_mut() {
|
||||
if stored_user.user.id != user_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
stored_user.user.token_version += 1;
|
||||
return Ok(Some(stored_user.user.clone()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -549,6 +810,17 @@ impl fmt::Display for RefreshSessionError {
|
||||
|
||||
impl Error for RefreshSessionError {}
|
||||
|
||||
impl fmt::Display for LogoutError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UserNotFound => f.write_str("当前登录态已失效,请重新登录"),
|
||||
Self::Store(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for LogoutError {}
|
||||
|
||||
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
|
||||
match error {
|
||||
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
|
||||
@@ -561,6 +833,26 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
|
||||
match error {
|
||||
PasswordEntryError::Store(message) => LogoutError::Store(message),
|
||||
PasswordEntryError::InvalidUsername
|
||||
| PasswordEntryError::InvalidPasswordLength
|
||||
| PasswordEntryError::InvalidCredentials
|
||||
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
|
||||
match error {
|
||||
RefreshSessionError::Store(message) => LogoutError::Store(message),
|
||||
RefreshSessionError::MissingToken
|
||||
| RefreshSessionError::SessionNotFound
|
||||
| RefreshSessionError::SessionExpired
|
||||
| RefreshSessionError::UserNotFound => LogoutError::Store("会话吊销失败".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
|
||||
let username = raw_username.trim().to_string();
|
||||
let valid_length =
|
||||
@@ -603,6 +895,25 @@ mod tests {
|
||||
RefreshSessionService::new(store, 30)
|
||||
}
|
||||
|
||||
fn build_user_service(store: InMemoryAuthStore) -> AuthUserService {
|
||||
AuthUserService::new(store)
|
||||
}
|
||||
|
||||
fn build_client_info() -> RefreshSessionClientInfo {
|
||||
RefreshSessionClientInfo {
|
||||
client_type: "web_browser".to_string(),
|
||||
client_runtime: "chrome".to_string(),
|
||||
client_platform: "windows".to_string(),
|
||||
client_instance_id: Some("client-instance-001".to_string()),
|
||||
device_fingerprint: Some("device-fingerprint-001".to_string()),
|
||||
device_display_name: "Windows / Chrome".to_string(),
|
||||
mini_program_app_id: None,
|
||||
mini_program_env: None,
|
||||
user_agent: Some("Mozilla/5.0".to_string()),
|
||||
ip: Some("203.0.113.10".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_password_entry_creates_user() {
|
||||
let service = build_password_service(build_store());
|
||||
@@ -706,6 +1017,7 @@ mod tests {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: first_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
@@ -746,4 +1058,219 @@ mod tests {
|
||||
|
||||
assert_eq!(error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_current_session_revokes_session_and_increments_token_version() {
|
||||
let store = build_store();
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let user_service = build_user_service(store);
|
||||
let user = password_service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_logout".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed")
|
||||
.user;
|
||||
let refresh_token_hash = hash_refresh_session_token("logout-token");
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: refresh_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("session should create");
|
||||
|
||||
let result = user_service
|
||||
.logout_current_session(
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: Some(refresh_token_hash.clone()),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("logout should succeed");
|
||||
|
||||
assert_eq!(result.user.token_version, 2);
|
||||
|
||||
let refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("logout-token-next"),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect_err("revoked session should fail");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() {
|
||||
let store = build_store();
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let user_service = build_user_service(store);
|
||||
let user = password_service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_logout_all".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed")
|
||||
.user;
|
||||
let first_refresh_token_hash = hash_refresh_session_token("logout-all-token-01");
|
||||
let second_refresh_token_hash = hash_refresh_session_token("logout-all-token-02");
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: first_refresh_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("first session should create");
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: second_refresh_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: RefreshSessionClientInfo {
|
||||
client_runtime: "firefox".to_string(),
|
||||
device_display_name: "Windows / Firefox".to_string(),
|
||||
..build_client_info()
|
||||
},
|
||||
},
|
||||
now + Duration::seconds(1),
|
||||
)
|
||||
.expect("second session should create");
|
||||
|
||||
let result = user_service
|
||||
.logout_all_sessions(
|
||||
LogoutAllSessionsInput {
|
||||
user_id: user.id.clone(),
|
||||
},
|
||||
now + Duration::minutes(1),
|
||||
)
|
||||
.expect("logout all should succeed");
|
||||
|
||||
assert_eq!(result.user.token_version, 2);
|
||||
assert_eq!(
|
||||
refresh_service
|
||||
.list_active_sessions_by_user(&user.id, now + Duration::minutes(2))
|
||||
.expect("sessions should list")
|
||||
.sessions
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
|
||||
let first_refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: first_refresh_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("logout-all-token-03"),
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("first revoked session should fail");
|
||||
assert_eq!(first_refresh_error, RefreshSessionError::SessionNotFound);
|
||||
|
||||
let second_refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: second_refresh_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("logout-all-token-04"),
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("second revoked session should fail");
|
||||
assert_eq!(second_refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_active_sessions_by_user_filters_revoked_and_expired_sessions() {
|
||||
let store = build_store();
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let user = password_service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_sessions".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed")
|
||||
.user;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
let active_session = refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: hash_refresh_session_token("sessions-active"),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("active session should create");
|
||||
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: hash_refresh_session_token("sessions-revoked"),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: RefreshSessionClientInfo {
|
||||
client_runtime: "edge".to_string(),
|
||||
device_display_name: "Windows / Edge".to_string(),
|
||||
..build_client_info()
|
||||
},
|
||||
},
|
||||
now - Duration::minutes(5),
|
||||
)
|
||||
.expect("revoked session should create");
|
||||
store
|
||||
.revoke_session_by_refresh_token_hash(
|
||||
&hash_refresh_session_token("sessions-revoked"),
|
||||
now - Duration::minutes(1),
|
||||
)
|
||||
.expect("revoked session should revoke");
|
||||
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: hash_refresh_session_token("sessions-expired"),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: RefreshSessionClientInfo {
|
||||
client_runtime: "firefox".to_string(),
|
||||
device_display_name: "Windows / Firefox".to_string(),
|
||||
..build_client_info()
|
||||
},
|
||||
},
|
||||
now - Duration::days(40),
|
||||
)
|
||||
.expect("expired session should create");
|
||||
|
||||
let listed = refresh_service
|
||||
.list_active_sessions_by_user(&user.id, now)
|
||||
.expect("sessions should list");
|
||||
|
||||
assert_eq!(listed.sessions.len(), 1);
|
||||
assert_eq!(listed.sessions[0].session_id, active_session.session.session_id);
|
||||
assert_eq!(listed.sessions[0].client_info.client_runtime, "chrome");
|
||||
assert_eq!(
|
||||
listed.sessions[0].client_info.device_display_name,
|
||||
"Windows / Chrome"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AnimationState, type Character } from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
|
||||
vi.mock('../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
|
||||
resolvedUrl: source?.trim() ?? '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
function buildCharacter(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'generated-role',
|
||||
@@ -26,6 +34,10 @@ function buildCharacter(overrides: Partial<Character> = {}): Character {
|
||||
}
|
||||
|
||||
describe('CharacterAnimator portrait fallbacks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps idle fallback static on the portrait when idle animation is missing', () => {
|
||||
render(
|
||||
<CharacterAnimator
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
import { AnimationState, Character, CharacterAnimationConfig } from '../types';
|
||||
|
||||
interface CharacterAnimatorProps {
|
||||
@@ -208,6 +209,13 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
|
||||
const imagePath = fallbackToPortrait
|
||||
? character.portrait
|
||||
: generatedImagePath;
|
||||
const {
|
||||
resolvedUrl: resolvedImagePath,
|
||||
shouldResolve: shouldResolveImagePath,
|
||||
} = useResolvedAssetReadUrl(imagePath);
|
||||
// 私有 OSS 资源必须等签名地址返回后再渲染,不能先落回原始 generated-* 路径。
|
||||
const displayImagePath =
|
||||
resolvedImagePath || (!shouldResolveImagePath ? imagePath : '');
|
||||
const resolvedImageClassName =
|
||||
`h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim();
|
||||
const imageStyle =
|
||||
@@ -215,10 +223,14 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
|
||||
? FALLEN_PORTRAIT_STYLE
|
||||
: DEFAULT_IMAGE_STYLE;
|
||||
|
||||
if (!displayImagePath) {
|
||||
return <div className={`relative ${className ?? ''}`} style={style} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className ?? ''}`} style={style}>
|
||||
<img
|
||||
src={imagePath}
|
||||
src={displayImagePath}
|
||||
alt={`${character.name} ${state} animation`}
|
||||
className={resolvedImageClassName}
|
||||
style={imageStyle}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
interface CharacterPanelProps {
|
||||
worldType: WorldType | null;
|
||||
@@ -407,7 +408,7 @@ export function CharacterPanel({
|
||||
>
|
||||
<div className="flex items-start gap-3 rounded-xl border border-white/6 bg-black/18 px-3 py-3">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent sm:h-16 sm:w-16">
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={member.character.portrait}
|
||||
alt={member.character.name}
|
||||
className="h-full w-full scale-125 object-contain object-bottom"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MAX_COMPANIONS } from '../data/npcInteractions';
|
||||
import { Character, CompanionState } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
interface CompanionCampModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -180,7 +181,7 @@ export function CompanionCampModal({
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
className="h-full w-full scale-125 object-contain"
|
||||
@@ -243,7 +244,7 @@ export function CompanionCampModal({
|
||||
<div key={companion.npcId} className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={character.portrait}
|
||||
alt={character.name}
|
||||
className="h-full w-full scale-125 object-contain"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
|
||||
|
||||
const COVER_PORTRAIT_CLASS_NAMES = [
|
||||
@@ -34,7 +35,7 @@ export function CustomWorldCoverArtwork({
|
||||
className={`relative overflow-hidden bg-[radial-gradient(circle_at_top,rgba(255,244,214,0.3),transparent_38%),linear-gradient(180deg,rgba(34,40,55,0.92),rgba(10,12,18,0.96))] ${className}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={imageSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
@@ -56,7 +57,7 @@ export function CustomWorldCoverArtwork({
|
||||
key={`${title}-cover-character-${index}-${characterImageSrc}`}
|
||||
className={`overflow-hidden rounded-[1rem] border border-white/16 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.04))] shadow-[0_12px_28px_rgba(0,0,0,0.4)] ${COVER_PORTRAIT_CLASS_NAMES[index] ?? COVER_PORTRAIT_CLASS_NAMES[1]}`}
|
||||
>
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={characterImageSrc}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
|
||||
@@ -31,6 +31,7 @@ import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
|
||||
@@ -171,7 +172,11 @@ function ImageFrame({
|
||||
className={`overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(255,96,147,0.92),rgba(255,146,109,0.84))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={alt} className="h-full w-full object-cover" />
|
||||
<ResolvedAssetImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
|
||||
{fallbackLabel}
|
||||
@@ -1427,7 +1432,7 @@ export function CustomWorldEntityCatalog({
|
||||
}
|
||||
media={
|
||||
role.imageSrc?.trim() ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={role.imageSrc}
|
||||
alt={role.name}
|
||||
className="h-full w-full object-cover object-top"
|
||||
@@ -1440,7 +1445,7 @@ export function CustomWorldEntityCatalog({
|
||||
imageClassName="object-bottom"
|
||||
/>
|
||||
) : previewImageSrc ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={previewImageSrc}
|
||||
alt={role.name}
|
||||
className="h-full w-full object-cover object-top"
|
||||
|
||||
@@ -24,6 +24,7 @@ import { fetchJson } from '../editor/shared/jsonClient';
|
||||
import { useCombatFlow } from '../hooks/useCombatFlow';
|
||||
import { useGameFlow } from '../hooks/useGameFlow';
|
||||
import { useNpcInteractionFlow } from '../hooks/useNpcInteractionFlow';
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
import { useStoryGeneration } from '../hooks/useStoryGeneration';
|
||||
import { buildSkillActionPrompt } from '../prompts/customWorldEntityActionPrompts';
|
||||
import {
|
||||
@@ -85,6 +86,7 @@ import {
|
||||
} from './game-canvas/GameCanvasShared';
|
||||
import { GameShellRuntime } from './game-shell/GameShellRuntime';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
export type CustomWorldEditorTarget =
|
||||
| { kind: 'world' }
|
||||
@@ -1153,13 +1155,19 @@ function ImagePreview({
|
||||
children?: ReactNode;
|
||||
overlayInteractive?: boolean;
|
||||
}) {
|
||||
const {
|
||||
resolvedUrl: resolvedSrc,
|
||||
shouldResolve,
|
||||
} = useResolvedAssetReadUrl(src);
|
||||
const displaySrc = resolvedSrc || (!shouldResolve ? src : '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.95),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
>
|
||||
{src ? (
|
||||
{displaySrc ? (
|
||||
<img
|
||||
src={src}
|
||||
src={displaySrc}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
@@ -3881,7 +3889,7 @@ function RoleSkillEditorModal({
|
||||
/>
|
||||
</div>
|
||||
) : role.imageSrc ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={role.imageSrc}
|
||||
alt={role.name}
|
||||
className="max-h-40 w-full object-contain"
|
||||
@@ -4013,7 +4021,7 @@ function SkillListEditor({
|
||||
/>
|
||||
</div>
|
||||
) : role.imageSrc ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={role.imageSrc}
|
||||
alt={skill.name}
|
||||
className="max-h-20 w-full object-contain"
|
||||
@@ -4589,7 +4597,7 @@ function PlayableNpcEditor({
|
||||
</div>
|
||||
<div className="mt-3 grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={draft.imageSrc || selectedTemplate.portrait}
|
||||
alt={selectedTemplate.name}
|
||||
className="h-28 w-full object-cover object-top"
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import { HostileNpcAnimator } from './HostileNpcAnimator';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
type EditableNpcSource = Pick<
|
||||
CustomWorldNpc,
|
||||
@@ -333,7 +334,7 @@ export function CustomWorldNpcPortrait({
|
||||
className={`relative flex h-full items-center justify-center ${contentClassName}`}
|
||||
>
|
||||
{preferredImageSrc ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={preferredImageSrc}
|
||||
alt={npc.name}
|
||||
className="h-full w-full object-contain drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
|
||||
@@ -29,6 +29,17 @@ vi.mock('./CustomWorldEntityEditorModal', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../services/assetReadUrlService', () => ({
|
||||
resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => {
|
||||
const value = source?.trim() ?? '';
|
||||
return value ? `https://signed.example${value}` : '';
|
||||
}),
|
||||
isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => {
|
||||
const value = source?.trim() ?? '';
|
||||
return /^\/generated-[^/?#]+\/.+/u.test(value);
|
||||
}),
|
||||
}));
|
||||
|
||||
async function loadAiService() {
|
||||
return import('../services/aiService');
|
||||
}
|
||||
@@ -315,9 +326,13 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
|
||||
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => {
|
||||
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', async () => {
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
expect(screen.getByText('玩家幻想')).toBeTruthy();
|
||||
expect(screen.getByText('主题边界')).toBeTruthy();
|
||||
@@ -378,7 +393,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder',
|
||||
|
||||
const portrait = screen.getByRole('img', { name: '云止' });
|
||||
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
|
||||
'/generated-characters/playable-portrait/master.png',
|
||||
'https://signed.example/generated-characters/playable-portrait/master.png',
|
||||
);
|
||||
expect(screen.getByText('已生成主图')).toBeTruthy();
|
||||
});
|
||||
@@ -395,6 +410,59 @@ test('landmark tab uses first act image as scene card preview and keeps chapter
|
||||
|
||||
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
|
||||
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/scene-act-1.png',
|
||||
'https://signed.example/generated-custom-world-scenes/scene-act-1.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('asset debug panel opens signed image link in dev mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const originalImportMetaEnv = import.meta.env;
|
||||
const originalUrl = window.location.href;
|
||||
const originalImage = globalThis.Image;
|
||||
|
||||
Object.defineProperty(import.meta, 'env', {
|
||||
value: {
|
||||
...originalImportMetaEnv,
|
||||
DEV: true,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
window.history.pushState({}, '', '/?debugCustomWorldAssets=1');
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => {
|
||||
this.onload?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperty(globalThis, 'Image', {
|
||||
value: MockImage,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景\s*2/u }));
|
||||
|
||||
const signedLink = await screen.findByRole('link', {
|
||||
name: /打开 沉钟栈桥章节 \/ 潮声逼近幕图签名图/u,
|
||||
});
|
||||
expect((signedLink as HTMLAnchorElement).href).toBe(
|
||||
'https://signed.example/generated-custom-world-scenes/scene-act-1.png',
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(import.meta, 'env', {
|
||||
value: originalImportMetaEnv,
|
||||
configurable: true,
|
||||
});
|
||||
window.history.pushState({}, '', originalUrl);
|
||||
Object.defineProperty(globalThis, 'Image', {
|
||||
value: originalImage,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
generateCustomWorldPlayableNpc,
|
||||
generateCustomWorldStoryNpc,
|
||||
} from '../services/aiService';
|
||||
import { resolveAssetReadUrl } from '../services/assetReadUrlService';
|
||||
import {
|
||||
Character,
|
||||
CustomWorldLandmark,
|
||||
@@ -405,6 +406,29 @@ export function CustomWorldResultView({
|
||||
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
|
||||
Record<string, AssetDebugLoadStatus>
|
||||
>({});
|
||||
const [assetDebugResolvedImageMap, setAssetDebugResolvedImageMap] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const assetDebugResolvedEntries = useMemo(
|
||||
() =>
|
||||
assetDebugEntries.map((entry) => ({
|
||||
...entry,
|
||||
hasResolvedImageSrc: Object.prototype.hasOwnProperty.call(
|
||||
assetDebugResolvedImageMap,
|
||||
entry.id,
|
||||
),
|
||||
resolvedImageSrc: assetDebugResolvedImageMap[entry.id] || entry.imageSrc,
|
||||
})),
|
||||
[assetDebugEntries, assetDebugResolvedImageMap],
|
||||
);
|
||||
const assetDebugDetectableEntries = useMemo(
|
||||
() =>
|
||||
assetDebugResolvedEntries.filter(
|
||||
(entry) =>
|
||||
entry.hasResolvedImageSrc && Boolean(entry.resolvedImageSrc.trim()),
|
||||
),
|
||||
[assetDebugResolvedEntries],
|
||||
);
|
||||
|
||||
const createTarget = useMemo(
|
||||
() => getCreateTargetByTab(activeTab),
|
||||
@@ -425,11 +449,42 @@ export function CustomWorldResultView({
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetDebugEnabled) {
|
||||
setAssetDebugStatusMap({});
|
||||
setAssetDebugResolvedImageMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetDebugEntries.length === 0) {
|
||||
setAssetDebugResolvedImageMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void Promise.all(
|
||||
assetDebugEntries.map(async (entry) => [
|
||||
entry.id,
|
||||
await resolveAssetReadUrl(entry.imageSrc),
|
||||
] as const),
|
||||
).then((resolvedEntries) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetDebugResolvedImageMap(Object.fromEntries(resolvedEntries));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [assetDebugEnabled, assetDebugEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetDebugEnabled) {
|
||||
setAssetDebugStatusMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetDebugDetectableEntries.length === 0) {
|
||||
setAssetDebugStatusMap({});
|
||||
return;
|
||||
}
|
||||
@@ -437,13 +492,14 @@ export function CustomWorldResultView({
|
||||
let cancelled = false;
|
||||
const cleanupList: Array<() => void> = [];
|
||||
|
||||
// 诊断面板只根据已解析地址做探测,避免状态流里反复访问原始 generated-* 路径。
|
||||
setAssetDebugStatusMap(
|
||||
Object.fromEntries(
|
||||
assetDebugEntries.map((entry) => [entry.id, 'loading' as const]),
|
||||
assetDebugDetectableEntries.map((entry) => [entry.id, 'loading' as const]),
|
||||
),
|
||||
);
|
||||
|
||||
assetDebugEntries.forEach((entry) => {
|
||||
assetDebugDetectableEntries.forEach((entry) => {
|
||||
const image = new Image();
|
||||
const updateStatus = (status: AssetDebugLoadStatus) => {
|
||||
if (cancelled) {
|
||||
@@ -463,7 +519,7 @@ export function CustomWorldResultView({
|
||||
|
||||
image.onload = () => updateStatus('loaded');
|
||||
image.onerror = () => updateStatus('error');
|
||||
image.src = entry.imageSrc;
|
||||
image.src = entry.resolvedImageSrc;
|
||||
cleanupList.push(() => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
@@ -474,7 +530,7 @@ export function CustomWorldResultView({
|
||||
cancelled = true;
|
||||
cleanupList.forEach((cleanup) => cleanup());
|
||||
};
|
||||
}, [assetDebugEnabled, assetDebugEntries]);
|
||||
}, [assetDebugDetectableEntries, assetDebugEnabled]);
|
||||
|
||||
const startPendingProgress = (kind: EntityGenerationKind) => {
|
||||
stopPendingProgressTimer();
|
||||
@@ -679,7 +735,7 @@ export function CustomWorldResultView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{assetDebugEntries.length}项
|
||||
{assetDebugResolvedEntries.length}项
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
|
||||
@@ -696,8 +752,8 @@ export function CustomWorldResultView({
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{assetDebugEntries.length > 0 ? (
|
||||
assetDebugEntries.map((entry) => (
|
||||
{assetDebugResolvedEntries.length > 0 ? (
|
||||
assetDebugResolvedEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="platform-subpanel rounded-2xl px-3 py-2"
|
||||
@@ -718,15 +774,21 @@ export function CustomWorldResultView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={entry.imageSrc}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`打开 ${entry.label}`}
|
||||
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
|
||||
>
|
||||
打开原图
|
||||
</a>
|
||||
{entry.hasResolvedImageSrc ? (
|
||||
<a
|
||||
href={entry.resolvedImageSrc}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`打开 ${entry.label}签名图`}
|
||||
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
|
||||
>
|
||||
打开签名图
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-xs text-zinc-500">
|
||||
正在解析签名地址...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -34,6 +34,7 @@ import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRoleProm
|
||||
import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference';
|
||||
import { useAuthUi } from './auth/AuthUiContext';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
type EditableCustomWorldRole = {
|
||||
id: string;
|
||||
@@ -1173,13 +1174,13 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
|
||||
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
|
||||
{previewImageSrc ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={previewImageSrc}
|
||||
alt={workingRole.name}
|
||||
className="max-h-[28rem] w-full object-contain"
|
||||
/>
|
||||
) : selectedTemplate ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={selectedTemplate.portrait}
|
||||
alt={selectedTemplate.name}
|
||||
className="max-h-[20rem] w-full object-contain"
|
||||
@@ -1289,7 +1290,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
</div>
|
||||
</div>
|
||||
) : previewImageSrc ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={previewImageSrc}
|
||||
alt={workingRole.name}
|
||||
className="max-h-[28rem] w-full object-contain pixelated"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type CSSProperties, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||
import { getConnectedScenePresets } from '../data/scenePresets';
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
import { ScenePresetInfo, WorldType } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
@@ -153,6 +154,10 @@ export function MapModal({
|
||||
canTravel = true,
|
||||
}: MapModalProps) {
|
||||
const [pendingScene, setPendingScene] = useState<MapConnectionEntry | null>(null);
|
||||
const {
|
||||
resolvedUrl: resolvedBackdropImageSrc,
|
||||
shouldResolve: shouldResolveBackdropImage,
|
||||
} = useResolvedAssetReadUrl(currentScenePreset?.imageSrc);
|
||||
|
||||
const connectedScenes = useMemo(
|
||||
() =>
|
||||
@@ -186,7 +191,10 @@ export function MapModal({
|
||||
|
||||
return buildFallbackConnectionEntries(currentScenePreset, connectedScenes);
|
||||
}, [connectedScenes, currentScenePreset]);
|
||||
const sceneBackdropStyle = buildSceneBackdropStyle(currentScenePreset?.imageSrc);
|
||||
const sceneBackdropStyle = buildSceneBackdropStyle(
|
||||
resolvedBackdropImageSrc
|
||||
|| (!shouldResolveBackdropImage ? currentScenePreset?.imageSrc : ''),
|
||||
);
|
||||
const destinationStackHeightPx = getMapDestinationStackHeight(connectionEntries.length);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
27
src/components/ResolvedAssetImage.tsx
Normal file
27
src/components/ResolvedAssetImage.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
|
||||
type ResolvedAssetImageProps = Omit<
|
||||
ImgHTMLAttributes<HTMLImageElement>,
|
||||
'src'
|
||||
> & {
|
||||
src?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetImage({
|
||||
src,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
...rest
|
||||
}: ResolvedAssetImageProps) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src);
|
||||
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
|
||||
|
||||
if (!finalSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <img {...rest} src={finalSrc} alt={alt} />;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel';
|
||||
|
||||
type CustomWorldAgentDraftDetailPanelProps = {
|
||||
@@ -179,7 +180,7 @@ export function CustomWorldAgentDraftDetailPanel({
|
||||
{section.label}
|
||||
</div>
|
||||
{shouldRenderImagePreview(detail.kind, section.id, section.value) ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={section.value}
|
||||
alt={section.label}
|
||||
className="mt-3 h-40 w-full rounded-[1rem] border border-white/10 object-cover"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {HostileNpcAnimator} from '../HostileNpcAnimator';
|
||||
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
|
||||
import {getRenderableNpcFacing} from '../npcRenderUtils';
|
||||
import {ResolvedAssetImage} from '../ResolvedAssetImage';
|
||||
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
|
||||
import {
|
||||
DialogueBubbleIcon,
|
||||
@@ -408,7 +409,7 @@ export function GameCanvasEntityLayer({
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
{encounter.kind === 'treasure' ? (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/15 shadow-[0_0_20px_rgba(255,255,255,0.12)]">
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={encounter.npcAvatar || '/Icons/47_treasure.png'}
|
||||
alt={encounter.npcName}
|
||||
className="h-12 w-12 object-contain"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {AnimatePresence, motion} from 'motion/react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import {type ScenePresetInfo, WorldType} from '../../types';
|
||||
import {CHROME_ICONS, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
|
||||
import {PixelIcon} from '../PixelIcon';
|
||||
@@ -24,11 +25,19 @@ export function GameCanvasSceneLayer({
|
||||
onSceneNameClick = null,
|
||||
onBackgroundLoadError,
|
||||
}: GameCanvasSceneLayerProps) {
|
||||
const {
|
||||
resolvedUrl: resolvedBackgroundSrc,
|
||||
shouldResolve: shouldResolveBackground,
|
||||
} = useResolvedAssetReadUrl(backgroundSrc);
|
||||
// 签名地址未返回前先显示渐变底色,避免浏览器直接访问私有原图触发 403。
|
||||
const displayBackgroundSrc =
|
||||
resolvedBackgroundSrc || (!shouldResolveBackground ? backgroundSrc : '');
|
||||
|
||||
return (
|
||||
<>
|
||||
{!backgroundLoadFailed ? (
|
||||
{!backgroundLoadFailed && displayBackgroundSrc ? (
|
||||
<img
|
||||
src={backgroundSrc}
|
||||
src={displayBackgroundSrc}
|
||||
alt={currentScenePreset?.name || 'Scene background'}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
style={{imageRendering: 'pixelated'}}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {useEffect, useState} from 'react';
|
||||
|
||||
import {getCharacterById} from '../../data/characterPresets';
|
||||
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import {
|
||||
buildMedievalNpcVisual,
|
||||
buildMedievalNpcVisualFromCustomWorldVisual,
|
||||
@@ -266,6 +267,15 @@ export function SceneEncounterNpcSprite({
|
||||
facing: 'left' | 'right';
|
||||
className?: string;
|
||||
}) {
|
||||
const rawEncounterImageSrc = encounter.imageSrc?.trim() ?? '';
|
||||
const {
|
||||
resolvedUrl: resolvedEncounterImageSrc,
|
||||
shouldResolve: shouldResolveEncounterImage,
|
||||
} = useResolvedAssetReadUrl(rawEncounterImageSrc);
|
||||
const displayEncounterImageSrc =
|
||||
resolvedEncounterImageSrc
|
||||
|| (!shouldResolveEncounterImage ? rawEncounterImageSrc : '');
|
||||
|
||||
if (encounter.visual) {
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
@@ -277,10 +287,14 @@ export function SceneEncounterNpcSprite({
|
||||
);
|
||||
}
|
||||
|
||||
if (encounter.imageSrc?.trim()) {
|
||||
if (rawEncounterImageSrc && shouldResolveEncounterImage && !displayEncounterImageSrc) {
|
||||
return <div className={`h-full w-full ${className ?? ''}`.trim()} />;
|
||||
}
|
||||
|
||||
if (displayEncounterImageSrc) {
|
||||
return (
|
||||
<img
|
||||
src={encounter.imageSrc.trim()}
|
||||
src={displayEncounterImageSrc}
|
||||
alt={encounter.npcName}
|
||||
className={`h-full w-full object-contain ${className ?? ''}`.trim()}
|
||||
style={{
|
||||
|
||||
@@ -15,6 +15,7 @@ import {AnimationState, type Character, type CustomWorldProfile, WorldType} from
|
||||
import {getNineSliceStyle, UI_CHROME} from '../../uiAssets';
|
||||
import {CharacterAnimator} from '../CharacterAnimator';
|
||||
import {CharacterDetailModal} from '../CharacterDetailModal';
|
||||
import {ResolvedAssetImage} from '../ResolvedAssetImage';
|
||||
import {CharacterDraftModal} from '../SelectionCustomizationModals';
|
||||
|
||||
type CharacterSelectionDraft = {
|
||||
@@ -346,7 +347,12 @@ export function CharacterSelectionFlow({
|
||||
className="character-carousel__portrait character-carousel__portrait--animated"
|
||||
/>
|
||||
) : (
|
||||
<img src={character.portrait} alt={meta.name} className="character-carousel__portrait" style={{imageRendering: 'pixelated'}} />
|
||||
<ResolvedAssetImage
|
||||
src={character.portrait}
|
||||
alt={meta.name}
|
||||
className="character-carousel__portrait"
|
||||
style={{imageRendering: 'pixelated'}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{selected ? (
|
||||
|
||||
@@ -32,7 +32,9 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapsho
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { PlatformBrandLogo } from './PlatformBrandLogo';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
@@ -53,6 +55,35 @@ const MOBILE_PAGE_STAGE_CLASS =
|
||||
const DESKTOP_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-5 pb-4';
|
||||
|
||||
function ResolvedAssetBackdrop({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
ariaHidden = false,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt: string;
|
||||
className: string;
|
||||
ariaHidden?: boolean;
|
||||
}) {
|
||||
const { resolvedUrl, shouldResolve } = useResolvedAssetReadUrl(src);
|
||||
// 私有 OSS 封面在签名地址返回前保持现有底色层,避免浏览器直接访问旧 generated-* 路径。
|
||||
const displaySrc = resolvedUrl || (!shouldResolve ? src?.trim() ?? '' : '');
|
||||
|
||||
if (!displaySrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={displaySrc}
|
||||
alt={alt}
|
||||
aria-hidden={ariaHidden}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
@@ -91,7 +122,7 @@ function SaveArchivePreview({
|
||||
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
|
||||
>
|
||||
{entry.coverImageSrc ? (
|
||||
<img
|
||||
<ResolvedAssetBackdrop
|
||||
src={entry.coverImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
@@ -139,14 +170,14 @@ function WorldCard({
|
||||
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[15.25rem] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
|
||||
>
|
||||
{coverImage ? (
|
||||
<img
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-40"
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
@@ -223,14 +254,14 @@ function CreationLibraryCard({
|
||||
className="platform-surface platform-interactive-card relative flex min-h-[13rem] w-full min-w-0 flex-col overflow-hidden px-3 py-3 text-left sm:min-h-[14rem] sm:px-3.5 sm:py-3.5"
|
||||
>
|
||||
{coverImage ? (
|
||||
<img
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
@@ -413,7 +444,7 @@ function DesktopTrendingItem({
|
||||
>
|
||||
<div className="relative h-[5.5rem] w-[4.3rem] shrink-0 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-[rgba(255,255,255,0.66)]">
|
||||
{coverImage ? (
|
||||
<img
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="h-full w-full object-cover"
|
||||
@@ -1140,7 +1171,7 @@ export function PlatformHomeView({
|
||||
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
|
||||
>
|
||||
{desktopHeroCover ? (
|
||||
<img
|
||||
<ResolvedAssetBackdrop
|
||||
src={desktopHeroCover}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
@@ -1188,7 +1219,7 @@ export function PlatformHomeView({
|
||||
>
|
||||
<div className="relative aspect-[1.35/1] overflow-hidden">
|
||||
{coverImage ? (
|
||||
<img
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowLeft } from 'lucide-react';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
@@ -96,14 +97,14 @@ export function PlatformWorldDetailView({
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-[18px] py-4">
|
||||
{coverImage ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -38,6 +38,17 @@ import {
|
||||
type SelectionStage,
|
||||
} from './PreGameSelectionFlow';
|
||||
|
||||
vi.mock('../../services/assetReadUrlService', () => ({
|
||||
resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => {
|
||||
const value = source?.trim() ?? '';
|
||||
return value ? `https://signed.example${value}` : '';
|
||||
}),
|
||||
isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => {
|
||||
const value = source?.trim() ?? '';
|
||||
return /^\/generated-[^/?#]+\/.+/u.test(value);
|
||||
}),
|
||||
}));
|
||||
|
||||
async function clickFirstButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
@@ -434,6 +445,39 @@ test('clicking a public work while logged out routes through requireAuth', async
|
||||
expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('platform home cards resolve generated cover images through signed read urls', async () => {
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([
|
||||
{
|
||||
ownerUserId: 'author-1',
|
||||
profileId: 'world-public-1',
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '最近公开发布的世界。',
|
||||
coverImageSrc: '/generated-custom-world-covers/world-public-1/cover.webp',
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
const coverImages = screen.getAllByAltText('潮雾列岛');
|
||||
expect(
|
||||
coverImages.some(
|
||||
(image) =>
|
||||
image.getAttribute('src') ===
|
||||
'https://signed.example/generated-custom-world-covers/world-public-1/cover.webp',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('selecting RPG creation while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -626,14 +670,11 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||||
expect(screen.getByText('已自动保存')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /进入世界/u })).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
expect(
|
||||
await screen.findByText('世界档案', undefined, { timeout: 10000 }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('已自动保存')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /进入世界/u })).toBeTruthy();
|
||||
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull();
|
||||
|
||||
64
src/hooks/useResolvedAssetReadUrl.ts
Normal file
64
src/hooks/useResolvedAssetReadUrl.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
isGeneratedLegacyPath,
|
||||
resolveAssetReadUrl,
|
||||
} from '../services/assetReadUrlService';
|
||||
|
||||
type UseResolvedAssetReadUrlOptions = {
|
||||
enabled?: boolean;
|
||||
expireSeconds?: number;
|
||||
};
|
||||
|
||||
export function useResolvedAssetReadUrl(
|
||||
source: string | null | undefined,
|
||||
options: UseResolvedAssetReadUrlOptions = {},
|
||||
) {
|
||||
const enabled = options.enabled !== false;
|
||||
const normalizedSource = source?.trim() ?? '';
|
||||
const shouldResolve =
|
||||
enabled && Boolean(normalizedSource) && isGeneratedLegacyPath(normalizedSource);
|
||||
const [resolvedUrl, setResolvedUrl] = useState(
|
||||
shouldResolve ? '' : normalizedSource,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedSource) {
|
||||
setResolvedUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldResolve) {
|
||||
setResolvedUrl(normalizedSource);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setResolvedUrl('');
|
||||
|
||||
void resolveAssetReadUrl(normalizedSource, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
})
|
||||
.then((nextUrl) => {
|
||||
if (!cancelled) {
|
||||
setResolvedUrl(nextUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// 读取签名失败时回退原始路径,至少保持现有 UI 可见错误表象。
|
||||
setResolvedUrl(normalizedSource);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedSource, options.expireSeconds, shouldResolve]);
|
||||
|
||||
return {
|
||||
resolvedUrl,
|
||||
isResolving: shouldResolve && !resolvedUrl,
|
||||
shouldResolve,
|
||||
};
|
||||
}
|
||||
113
src/services/assetReadUrlService.test.ts
Normal file
113
src/services/assetReadUrlService.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
clearSignedAssetReadUrlCache,
|
||||
getSignedAssetReadUrl,
|
||||
resolveAssetReadUrl,
|
||||
} from './assetReadUrlService';
|
||||
|
||||
describe('assetReadUrlService', () => {
|
||||
beforeEach(() => {
|
||||
clearSignedAssetReadUrlCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('resolveAssetReadUrl returns passthrough for absolute url', async () => {
|
||||
await expect(resolveAssetReadUrl('https://example.com/demo.png')).resolves.toBe(
|
||||
'https://example.com/demo.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAssetReadUrl returns passthrough for data url', async () => {
|
||||
await expect(resolveAssetReadUrl('data:image/png;base64,abc')).resolves.toBe(
|
||||
'data:image/png;base64,abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAssetReadUrl exchanges legacy generated path for signed url', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-characters/hero/visual/asset-01/master.png',
|
||||
signedUrl: 'https://signed.example.com/master.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveAssetReadUrl('/generated-characters/hero/visual/asset-01/master.png'),
|
||||
).resolves.toBe('https://signed.example.com/master.png');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
});
|
||||
|
||||
test('getSignedAssetReadUrl reuses cached signed url before expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-custom-world-scenes/profile-1/landmark-1/scene.png',
|
||||
signedUrl: 'https://signed.example.com/scene.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const first = await getSignedAssetReadUrl({
|
||||
legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png',
|
||||
});
|
||||
const second = await getSignedAssetReadUrl({
|
||||
legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png',
|
||||
});
|
||||
|
||||
expect(first).toBe('https://signed.example.com/scene.png');
|
||||
expect(second).toBe(first);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
191
src/services/assetReadUrlService.ts
Normal file
191
src/services/assetReadUrlService.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { requestJson } from './apiClient';
|
||||
|
||||
export type AssetReadUrlRequest = {
|
||||
objectKey?: string;
|
||||
legacyPublicPath?: string;
|
||||
expireSeconds?: number;
|
||||
};
|
||||
|
||||
export type AssetReadUrlResponse = {
|
||||
read?: {
|
||||
objectKey?: string;
|
||||
signedUrl?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
signedUrl?: string;
|
||||
objectKey?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
type CachedReadUrlEntry = {
|
||||
signedUrl: string;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
|
||||
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
|
||||
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
|
||||
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
|
||||
|
||||
export function isGeneratedLegacyPath(value: string) {
|
||||
return /^\/generated-[^/?#]+\/.+/u.test(value.trim());
|
||||
}
|
||||
|
||||
function normalizeLegacyPublicPath(value: string) {
|
||||
return `/${value.trim().replace(/^\/+/u, '')}`;
|
||||
}
|
||||
|
||||
function buildCacheKey(request: AssetReadUrlRequest) {
|
||||
if (request.objectKey?.trim()) {
|
||||
return `object:${request.objectKey.trim().replace(/^\/+/u, '')}`;
|
||||
}
|
||||
|
||||
if (request.legacyPublicPath?.trim()) {
|
||||
return `legacy:${normalizeLegacyPublicPath(request.legacyPublicPath)}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveSignedReadPayload(response: AssetReadUrlResponse) {
|
||||
const read = response.read ?? response;
|
||||
const signedUrl = typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
|
||||
const expiresAt = typeof read.expiresAt === 'string' ? read.expiresAt.trim() : '';
|
||||
const objectKey = typeof read.objectKey === 'string' ? read.objectKey.trim() : '';
|
||||
|
||||
if (!signedUrl) {
|
||||
throw new Error('资源访问地址缺失');
|
||||
}
|
||||
|
||||
return {
|
||||
signedUrl,
|
||||
expiresAt,
|
||||
objectKey,
|
||||
};
|
||||
}
|
||||
|
||||
function parseExpiresAtMs(expiresAt: string) {
|
||||
if (!expiresAt) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(expiresAt);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function shouldReuseCachedReadUrl(entry: CachedReadUrlEntry | undefined) {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entry.expiresAtMs - DEFAULT_CACHE_SAFETY_WINDOW_MS > Date.now();
|
||||
}
|
||||
|
||||
export async function getSignedAssetReadUrl(
|
||||
request: AssetReadUrlRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const cacheKey = buildCacheKey(request);
|
||||
const cached = cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
|
||||
if (cached && shouldReuseCachedReadUrl(cached)) {
|
||||
return cached.signedUrl;
|
||||
}
|
||||
|
||||
if (cacheKey) {
|
||||
const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (request.objectKey?.trim()) {
|
||||
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
|
||||
}
|
||||
if (request.legacyPublicPath?.trim()) {
|
||||
searchParams.set(
|
||||
'legacyPublicPath',
|
||||
normalizeLegacyPublicPath(request.legacyPublicPath),
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof request.expireSeconds === 'number' &&
|
||||
Number.isFinite(request.expireSeconds) &&
|
||||
request.expireSeconds > 0
|
||||
) {
|
||||
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
|
||||
}
|
||||
|
||||
const response = await requestJson<AssetReadUrlResponse>(
|
||||
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal,
|
||||
},
|
||||
'获取资源访问地址失败',
|
||||
);
|
||||
const payload = resolveSignedReadPayload(response);
|
||||
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
|
||||
|
||||
if (cacheKey && expiresAtMs > 0) {
|
||||
signedReadUrlCache.set(cacheKey, {
|
||||
signedUrl: payload.signedUrl,
|
||||
expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
return payload.signedUrl;
|
||||
})();
|
||||
|
||||
if (cacheKey) {
|
||||
pendingSignedReadUrlRequests.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestPromise;
|
||||
} finally {
|
||||
if (cacheKey) {
|
||||
pendingSignedReadUrlRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。
|
||||
export async function resolveAssetReadUrl(
|
||||
source: string | null | undefined,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
} = {},
|
||||
) {
|
||||
const value = source?.trim() ?? '';
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (
|
||||
/^(?:https?:)?\/\//u.test(value) ||
|
||||
value.startsWith('data:') ||
|
||||
value.startsWith('blob:')
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isGeneratedLegacyPath(value)) {
|
||||
return getSignedAssetReadUrl(
|
||||
{
|
||||
legacyPublicPath: value,
|
||||
expireSeconds: options.expireSeconds,
|
||||
},
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function clearSignedAssetReadUrlCache() {
|
||||
signedReadUrlCache.clear();
|
||||
pendingSignedReadUrlRequests.clear();
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
GENERATED_FRAME_WIDTH,
|
||||
} from '../components/asset-studio/characterAssetWorkflowModel';
|
||||
import { generateCharacterAnimationDraft } from '../components/asset-studio/characterAssetWorkflowPersistence';
|
||||
import { ResolvedAssetImage } from '../components/ResolvedAssetImage';
|
||||
import {
|
||||
NumberField,
|
||||
SelectField,
|
||||
@@ -124,7 +125,7 @@ function DraftStrip({
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-[220px] items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[linear-gradient(180deg,#171b23,#0d1117)] p-2">
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={draft.imageSrc}
|
||||
alt={draft.label}
|
||||
className="h-full w-full object-contain"
|
||||
@@ -714,7 +715,7 @@ export default function QwenSpriteSheetTool() {
|
||||
当前主图
|
||||
</div>
|
||||
<div className="flex h-[260px] items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[linear-gradient(180deg,#171b23,#0d1117)] p-2">
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={selectedMasterSource}
|
||||
alt="当前主图"
|
||||
className="h-full w-full object-contain"
|
||||
|
||||
Reference in New Issue
Block a user