diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index bda3bbb0..5468ba09 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -140,7 +140,8 @@ ### SpacetimeDB 身份表 -- [ ] 设计 `user_account` +- [x] 设计 `user_account` + 交付物:[../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md) - [ ] 设计 `auth_identity` - [ ] 设计 `refresh_session` - [ ] 设计 `auth_audit_log` diff --git a/docs/technical/README.md b/docs/technical/README.md index ff21fca7..69b7d62d 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [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` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。 - [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/` 的目录方案、兼容策略与后续新增规则。 diff --git a/docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md new file mode 100644 index 00000000..89fa903d --- /dev/null +++ b/docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md @@ -0,0 +1,378 @@ +# `user_account` 表设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于完成 `M2` 的第一条任务:`设计 user_account`。 + +目标不是只列一组字段名,而是把以下内容一次钉死到可编码级别: + +1. `user_account` 在新鉴权体系中的唯一职责 +2. 它与当前 Node `users` 表的一一映射关系 +3. 它与后续 `auth_identity`、`refresh_session` 的边界 +4. 它需要支撑的 `/api/auth/*` 兼容链路 +5. 它在 SpacetimeDB 中的字段、唯一约束、状态迁移与写入规则 + +## 2. 现有基线 + +当前 Node 鉴权主链已经依赖 `users` 主表完成以下能力: + +1. `POST /api/auth/entry`:用户名密码登录,不存在则自动创建账号 +2. `POST /api/auth/phone/login`:手机号验证码登录,不存在则自动创建账号 +3. `GET /api/auth/me`:读取当前账号基础信息 +4. `POST /api/auth/logout`:提升 `token_version`,让当前 access token 失效 +5. `POST /api/auth/logout-all`:提升 `token_version` 并吊销全部 refresh session +6. `POST /api/auth/wechat/bind-phone`:待绑定微信账号激活,或把微信身份归并到已有手机号账号 + +当前 Node `users` 表已有字段基线: + +1. `id` +2. `username` +3. `password_hash` +4. `token_version` +5. `display_name` +6. `login_provider` +7. `account_status` +8. `phone_number` +9. `phone_verified_at` +10. `created_at` +11. `updated_at` + +当前真实业务结论: + +1. 用户主实体已经存在,不能在 Rust 重写时把账号主表重新拆散成多个等价主表。 +2. `password_hash` 当前仍然是账号主链的一部分,不能因为正式前台主入口转向手机号/微信就直接删除。 +3. `token_version` 当前承担 access token 批量失效语义,必须保留。 +4. 微信待绑定账号壳当前通过 `account_status = pending_bind_phone` 表达,这个状态必须继续保留。 + +## 3. 表职责边界 + +`user_account` 只负责“账号主实体”本身,具体边界固定如下: + +### 3.1 它负责的内容 + +1. 稳定账号 ID +2. 账号显示名 +3. 主手机号归属 +4. 账号当前状态 +5. 主登录方式归属 +6. 密码登录所需的 `password_hash` +7. access token 统一失效计数 `token_version` +8. 账号级时间戳与合并痕迹 + +### 3.2 它不负责的内容 + +1. 微信 `openid / unionid / avatar` 这类 provider 明细 +2. refresh token hash 与设备会话 +3. 短信验证码发送与校验流水 +4. 风控封禁 +5. 审计日志 + +### 3.3 与其他表的边界 + +1. `auth_identity` 负责一个账号挂了哪些外部登录身份。 +2. `refresh_session` 负责一个账号有哪些活跃设备会话。 +3. `user_account` 只保留“主手机号”和“主登录方式”,不重复承担 provider 明细存储。 + +## 4. 表访问级别 + +`user_account` 固定为 `private table`。 + +原因: + +1. 表内包含 `password_hash` +2. 表内包含手机号主归属 +3. 表内包含账号状态和 token 失效控制字段 +4. 前端不应直接查询该表 + +读取方式固定为: + +1. Axum 通过服务层查询账号信息 +2. SpacetimeDB 内部 reducer / view 通过 owner 身份读取 +3. `/api/auth/me` 等对外接口通过 view / Axum 聚合返回脱敏 DTO + +## 5. 字段设计 + +建议字段如下。 + +| 字段名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `user_id` | `String` | 是 | 主键,继续沿用当前 `user_*` 前缀格式。 | +| `username` | `String` | 是 | 当前密码登录用户名;手机号/微信创建的系统账号同样要写入唯一用户名。 | +| `password_hash` | `String` | 是 | 密码登录校验字段;手机号/微信创建账号时继续写随机密码哈希,保持兼容。 | +| `token_version` | `u32` | 是 | access token 统一失效计数,默认 `1`。 | +| `display_name` | `String` | 是 | 账号展示名;密码账号默认用户名,手机号账号默认脱敏手机号,微信待绑定账号默认微信昵称或“微信旅人”。 | +| `login_provider` | `String` | 是 | 当前账号的主登录归属,枚举固定为 `password`、`phone`、`wechat`。 | +| `account_status` | `String` | 是 | 账号状态,枚举固定为 `active`、`pending_bind_phone`、`disabled`。 | +| `primary_phone_e164` | `Option` | 否 | 当前账号的主手机号,统一存 `E.164`。 | +| `phone_verified_at` | `Option` | 否 | 主手机号最近一次完成校验的 UTC RFC3339 时间。 | +| `last_login_at` | `Option` | 否 | 最近一次完成交互式登录成功的时间,不在 refresh 时更新。 | +| `merged_to_user_id` | `Option` | 否 | 待绑定微信壳账号并入已有手机号账号时写入目标账号 ID。 | +| `merged_at` | `Option` | 否 | 写入并入发生时间。 | +| `status_reason_code` | `Option` | 否 | 非 `active` 状态的原因码,例如 `manual_disabled`、`merged_into_existing_account`。 | +| `created_at` | `String` | 是 | UTC RFC3339 创建时间。 | +| `updated_at` | `String` | 是 | UTC RFC3339 最近更新时间。 | + +补充约束: + +1. 当前阶段时间字段统一继续使用 UTC RFC3339 字符串,优先对齐现有 Node 数据与调试方式。 +2. `username` 在账号创建后默认不可修改,本轮不设计用户名改名链路。 +3. `password_hash` 当前视为账号主字段,而不是 `auth_identity` 子字段。 + +## 6. 唯一约束与索引 + +### 6.1 必须具备的唯一约束 + +1. `user_id` 主键唯一 +2. `username` 全局唯一 +3. `primary_phone_e164` 在非空时全局唯一 + +### 6.2 必须具备的查询索引 + +1. `username` + 作用:支撑 `POST /api/auth/entry` +2. `primary_phone_e164` + 作用:支撑 `POST /api/auth/phone/login`、`POST /api/auth/phone/change` +3. `account_status + updated_at` + 作用:后续管理端、审计排查与禁用账号扫描 +4. `merged_to_user_id` + 作用:微信待绑定壳账号归并后排查与数据修复 + +### 6.3 当前阶段不放进 `user_account` 的查询需求 + +1. 微信身份查找 +2. refresh token hash 查找 +3. 会话列表查找 + +这些查询固定由后续 `auth_identity` 与 `refresh_session` 承担。 + +## 7. 状态机设计 + +`account_status` 当前阶段只允许以下三种值: + +### 7.1 `active` + +表示: + +1. 账号已完成正式激活 +2. 可以进入游戏 +3. 可以创建与读取正式存档 +4. 可以使用 `/api/auth/me`、`/api/auth/sessions` 等正式能力 + +### 7.2 `pending_bind_phone` + +表示: + +1. 账号由微信首次登录创建 +2. 已经拿到微信身份,但还没有正式手机号归属 +3. 不允许进入游戏主链 +4. 只允许继续完成绑定手机号或退出 + +### 7.3 `disabled` + +表示: + +1. 账号已被人工禁用 +2. 或账号已作为临时壳账号并入其他正式账号 +3. 所有 access token 与 refresh session 后续都应视为不可继续使用 + +附加约束: + +1. 若 `account_status = disabled` 且 `merged_to_user_id` 非空,则该记录视为“并入后保留痕迹”,不能再恢复为活跃账号。 +2. 不新增 `merged` 枚举,统一使用 `disabled + status_reason_code = merged_into_existing_account` 表达。 + +## 8. 字段写入规则 + +### 8.1 `POST /api/auth/entry` + +写入规则: + +1. 先按 `username` 查询 +2. 若不存在,则创建一条 `active` 账号 +3. `login_provider = password` +4. `display_name = username` +5. `primary_phone_e164 = null` +6. `phone_verified_at = null` +7. `last_login_at = 当前时间` + +### 8.2 `POST /api/auth/phone/login` + +写入规则: + +1. 先按 `primary_phone_e164` 查询 +2. 若不存在,则创建一条 `active` 账号 +3. `login_provider = phone` +4. `display_name = 脱敏手机号` +5. `primary_phone_e164 = 验证成功的 E.164 手机号` +6. `phone_verified_at = 当前时间` +7. `last_login_at = 当前时间` + +### 8.3 `GET /api/auth/wechat/callback` + +写入规则: + +1. 若该微信身份未绑定任何账号,则创建一条 `pending_bind_phone` 账号壳 +2. `login_provider = wechat` +3. `display_name = 微信昵称或“微信旅人”` +4. `primary_phone_e164 = null` +5. `phone_verified_at = null` +6. `last_login_at = 当前时间` + +### 8.4 `POST /api/auth/wechat/bind-phone` + +分两种情况: + +1. 手机号未被使用 + 结果:更新当前 `pending_bind_phone` 账号为 `active` +2. 手机号已绑定正式账号 + 结果:当前壳账号不再物理删除,而是: + - `account_status = disabled` + - `status_reason_code = merged_into_existing_account` + - `merged_to_user_id = 目标正式账号` + - `merged_at = 当前时间` + +同时: + +1. 微信身份迁移到目标正式账号 +2. 当前壳账号不再允许继续登录 + +### 8.5 `POST /api/auth/phone/change` + +写入规则: + +1. 校验当前账号必须是 `active` +2. 新手机号不能和旧手机号相同 +3. 新手机号必须在 `user_account` 中唯一 +4. 写入新的 `primary_phone_e164` +5. 写入新的 `phone_verified_at` +6. 若当前账号展示名本质上仍是旧手机号派生值,则同步更新 `display_name` + +### 8.6 `POST /api/auth/logout` 与 `POST /api/auth/logout-all` + +写入规则: + +1. `token_version = token_version + 1` +2. `updated_at = 当前时间` + +说明: + +1. 继续保留当前 Node 语义,让 access token 可统一失效。 +2. 指定设备吊销只改 `refresh_session`,不修改 `user_account.token_version`。 + +## 9. 读模型约束 + +`user_account` 本身不直接面向前端暴露,但必须能稳定支撑以下读需求: + +### 9.1 `/api/auth/me` + +直接提供的字段: + +1. `user_id` +2. `display_name` +3. `login_provider` +4. `account_status` +5. `primary_phone_e164` + +经 Axum 或 view 转换后的字段: + +1. `phoneNumberMasked` +2. `bindingStatus` +3. `loginMethod` + +### 9.2 `/api/auth/login-options` + +不直接读取 `user_account`,但需要与 `user_account` 的状态语义兼容: + +1. 未登录无账号上下文时,只返回可用登录方式 +2. 已登录但 `pending_bind_phone` 时,前端要据 `account_status` 限制继续进入游戏 + +### 9.3 `/api/auth/sessions` + +只依赖 `user_id` 作为 join 键,不在 `user_account` 上重复放会话信息。 + +## 10. 与当前 Node `users` 表的映射关系 + +| Node `users` 列 | 新 `user_account` 字段 | 迁移规则 | +| --- | --- | --- | +| `id` | `user_id` | 原样迁移,继续保留 `user_*` 前缀。 | +| `username` | `username` | 原样迁移。 | +| `password_hash` | `password_hash` | 原样迁移。 | +| `token_version` | `token_version` | 原样迁移。 | +| `display_name` | `display_name` | 原样迁移。 | +| `login_provider` | `login_provider` | 原样迁移。 | +| `account_status` | `account_status` | 原样迁移。 | +| `phone_number` | `primary_phone_e164` | 原样迁移,但命名改为明确表示“主手机号”。 | +| `phone_verified_at` | `phone_verified_at` | 原样迁移。 | +| `created_at` | `created_at` | 原样迁移。 | +| `updated_at` | `updated_at` | 原样迁移。 | + +新增字段回填规则: + +1. `last_login_at` + 初次迁移时先回填为 `updated_at` +2. `merged_to_user_id` + 初次迁移统一为 `null` +3. `merged_at` + 初次迁移统一为 `null` +4. `status_reason_code` + 初次迁移统一为 `null` + +## 11. reducer / service 落地约束 + +为避免后续实现漂移,`user_account` 相关能力固定拆成以下职责: + +### 11.1 `module-auth` reducer 层 + +必须至少具备这些命令入口: + +1. `create_password_user_account` +2. `create_phone_user_account` +3. `create_pending_wechat_user_account` +4. `activate_pending_wechat_user_account` +5. `update_user_account_phone` +6. `bump_user_account_token_version` +7. `mark_user_account_merged` +8. `disable_user_account` +9. `touch_user_account_last_login` + +### 11.2 Axum 应用层 + +固定负责: + +1. 密码校验 +2. 短信验证码校验 +3. 微信 code 换身份 +4. 决定调用哪条 reducer +5. 再读取后续 view / join 结果返回旧接口 contract + +## 12. 不允许的设计漂移 + +后续实现时禁止出现以下情况: + +1. 因为要做 `auth_identity`,就把 `password_hash` 从 `user_account` 里提前移走。 +2. 因为要做 `refresh_session`,就把 `token_version` 也移到会话表中。 +3. 因为微信待绑定账号壳当前无正式游戏数据,就继续走“物理删除账号”策略。 +4. 把手机号明细既放 `auth_identity` 又放多份等价主字段,导致唯一约束漂移。 +5. 为了图省事把 `pending_bind_phone` 和 `disabled` 混成一个状态。 + +## 13. 本任务完成定义 + +当以下条件满足时,`设计 user_account` 视为完成: + +1. `user_account` 的职责边界已经和 `auth_identity`、`refresh_session` 明确切开。 +2. 字段、唯一约束、状态枚举、写入规则已具体到可以直接编码。 +3. 已明确与当前 Node `users` 表的迁移关系。 +4. 后续 `auth_identity` 与 `refresh_session` 的设计可以直接以这份文档为前置约束继续展开。 + +## 14. 依据文件 + +1. `server-node/src/routes/authRoutes.ts` +2. `server-node/src/auth/authService.ts` +3. `server-node/src/repositories/userRepository.ts` +4. `server-node/src/repositories/authIdentityRepository.ts` +5. `server-node/src/repositories/userSessionRepository.ts` +6. `server-node/src/db/migrations.ts` +7. `docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md` +8. `backend-rewrite-tasklist/M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md` +9. `docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md` diff --git a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md index bd599d8f..e550c26d 100644 --- a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md +++ b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md @@ -306,6 +306,10 @@ server-rs/ 2. 密码哈希、短信验证码校验、微信 code 换 token 的动作在 Axum 完成。 3. Axum 再调用 reducer 写入最终结果。 +`user_account` 的详细字段、状态迁移与旧 `users` 映射规则,见: + +- [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md) + ### B. 运行时主状态表 - `runtime_snapshot` diff --git a/server-rs/packages/module-auth/README.md b/server-rs/packages/module-auth/README.md index 8ba82c89..326c8618 100644 --- a/server-rs/packages/module-auth/README.md +++ b/server-rs/packages/module-auth/README.md @@ -13,7 +13,7 @@ ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入接口、表结构与 token 细节实现。 +当前阶段已先冻结第一张账号主表 `user_account` 的设计,其余身份表、会话表与 token 细节仍按顺序继续展开。 后续与本 package 直接相关的任务包括: @@ -22,6 +22,10 @@ 3. 设计 `sms_auth_event`、`wechat_auth_state` 4. 落地 JWT claims、refresh cookie 与旧接口兼容 +当前已冻结文档: + +1. [../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md) + ## 3. 边界约束 1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 逻辑写进主工程。