# `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` | `Option` | 否 | 用户显式设置或重置密码后才写入;手机号/微信新建账号默认不可用密码登录。 | | `password_login_enabled` | `bool` | 是 | 是否允许密码登录;只有用户设置或重置密码后才为 `true`。 | | `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` 作用:系统账号唯一约束与内部排查,不作为前台密码登录入口 2. `primary_phone_e164` 作用:支撑 `POST /api/auth/entry`、`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. 只读取请求中的 `phone` 和 `password`。 2. 先把 `phone` 归一化为 `primary_phone_e164` 后查询账号。 3. 若手机号不存在,返回 `401`,不创建账号。 4. 若账号存在但 `password_login_enabled = false` 或 `password_hash = null`,返回 `401`。 5. 若账号存在且已设置密码,校验 `password_hash`。 6. 校验成功后只更新登录会话与 `last_login_at`,不改变账号主归属。 ### 8.2 `POST /api/auth/phone/login` 写入规则: 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`