# `auth_identity` 表设计 日期:`2026-04-21` ## 1. 文档目的 这份文档用于完成 `M2` 的第二条任务:`设计 auth_identity`。 目标是把“账号主实体”和“外部登录身份”彻底拆清楚,让后续编码时不会再出现: 1. 微信身份字段继续堆进 `user_account` 2. 手机号既是账号字段又没有对应身份行 3. 微信待绑定壳账号并入正式手机号账号时,不知道到底迁哪些记录 ## 2. 当前基线 当前 Node 后端已经有一张 `auth_identities` 表,但范围还不完整: 1. 当前只真实存了 `wechat` 身份 2. 手机号登录仍主要依赖 `users.phone_number` 3. `/api/auth/me` 的 `wechatBound` 依赖查询 `auth_identities` 4. 微信登录后的“已有正式账号 / 首次待绑定账号 / 绑定后归并”三种分支,都已经在 `authService.ts` 中真实发生 当前 Node `auth_identities` 字段基线: 1. `id` 2. `user_id` 3. `provider` 4. `provider_uid` 5. `provider_unionid` 6. `display_name` 7. `avatar_url` 8. `is_verified` 9. `meta_json` 10. `created_at` 11. `updated_at` 这说明: 1. `auth_identity` 在现有系统里已经不是新概念,而是微信登录链路的既有事实。 2. Rust 重写时不能再把它降级成“可有可无的附属表”。 3. 但它也还没有扩展到手机号身份层,因此本轮需要把“手机号是否入表、如何入表”钉死。 ## 3. 与 `user_account` 的边界 `auth_identity` 与 `user_account` 的固定边界如下。 ### 3.1 `user_account` 负责 1. 账号主键与主状态 2. `password_hash` 3. `token_version` 4. `display_name` 5. `primary_phone_e164` 6. `login_provider` ### 3.2 `auth_identity` 负责 1. 一个账号绑定了哪些外部身份 2. 每个 provider 的唯一主体键 3. provider 级验证状态 4. provider 资料快照 5. provider 绑定时间与最近一次使用时间 6. 身份迁移时的归属变更 ### 3.3 不允许的漂移 1. 不把 `password_hash` 放进 `auth_identity` 2. 不把 refresh token hash 放进 `auth_identity` 3. 不让 `auth_identity` 取代 `user_account` 成为账号主表 4. 不再只给微信建 identity 行,而让手机号继续做“无 identity 的特殊分支” ## 4. provider 范围 当前阶段 `auth_identity.provider` 固定只支持: 1. `phone` 2. `wechat` 说明: 1. `password` 当前仍留在 `user_account.password_hash`,不进入 `auth_identity`。 2. 后续若补 OIDC 或更多社交登录,再在新任务里追加 provider,不在本轮预支。 ## 5. 字段设计 | 字段名 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `identity_id` | `String` | 是 | 主键,建议沿用 `authi_*` 前缀。 | | `user_id` | `String` | 是 | 归属账号 ID,外键指向 `user_account.user_id`。 | | `provider` | `String` | 是 | 枚举固定为 `phone`、`wechat`。 | | `provider_uid` | `String` | 是 | provider 主主体键;`phone` 时固定等于 `phone_e164`,`wechat` 时固定存 provider 返回的稳定 UID。 | | `provider_unionid` | `Option` | 否 | 仅 `wechat` 使用;有值时作为更高优先级唯一键。 | | `phone_e164` | `Option` | 否 | 仅 `phone` provider 使用,统一存 `E.164`。 | | `display_name` | `Option` | 否 | provider 侧显示名快照;`wechat` 可用昵称,`phone` 可为空。 | | `avatar_url` | `Option` | 否 | provider 侧头像;当前主要给 `wechat` 用。 | | `is_verified` | `bool` | 是 | 当前 identity 是否已完成可信校验。 | | `is_primary` | `bool` | 是 | 当前 identity 是否是该 provider 在本账号下的主身份。 | | `meta_json` | `Option` | 否 | provider 扩展元信息 JSON 字符串。 | | `bound_at` | `String` | 是 | 首次绑定到该账号的 UTC RFC3339 时间。 | | `last_login_at` | `Option` | 否 | 最近一次由该 identity 驱动完成交互式登录成功的时间。 | | `created_at` | `String` | 是 | UTC RFC3339 创建时间。 | | `updated_at` | `String` | 是 | UTC RFC3339 最近更新时间。 | 补充说明: 1. 当前阶段统一继续使用 UTC RFC3339 字符串时间,先与现有 Node 表与调试习惯对齐。 2. `meta_json` 当前建议直接存 JSON 字符串,避免后续第一版 schema 先引入过宽的嵌套类型。 3. `is_primary` 当前虽然大多是冗余字段,但必须先保留,为后续多 identity/同 provider 扩展留稳定位置。 ## 6. provider 专属约束 ### 6.1 `phone` provider 固定规则: 1. `provider_uid = phone_e164` 2. `provider_unionid = null` 3. `phone_e164 != null` 4. `is_verified = true` 5. `is_primary = true` 当前阶段约束: 1. 一个账号最多只允许一条活跃 `phone` identity 2. 它必须与 `user_account.primary_phone_e164` 一致 ### 6.2 `wechat` provider 固定规则: 1. `provider_uid` 必填 2. `provider_unionid` 可空,但若存在则优先作为稳定唯一键 3. `phone_e164 = null` 4. `display_name`、`avatar_url`、`meta_json` 可按登录回调刷新 5. `is_verified = true` 当前阶段约束: 1. 一个账号最多只允许一条活跃 `wechat` identity 2. 微信首次登录但未绑定手机号时,也必须先创建这条 identity 行 ## 7. 唯一约束与索引 ### 7.1 必须具备的唯一约束 1. `identity_id` 主键唯一 2. `(provider, provider_uid)` 全局唯一 3. `(provider, provider_unionid)` 在 `provider_unionid != null` 时全局唯一 4. `(provider, phone_e164)` 在 `provider = phone` 且 `phone_e164 != null` 时全局唯一 ### 7.2 必须具备的查询索引 1. `(user_id, provider)` 作用:支撑 `/api/auth/me`、账号中心与 provider 检查 2. `provider_uid` 作用:支撑微信回调身份查找 3. `provider_unionid` 作用:支撑微信跨应用稳定身份查找 4. `phone_e164` 作用:支撑手机号登录、手机号换绑与手机号冲突检查 ## 8. 与 `user_account` 的一致性规则 ### 8.1 手机号 identity 与账号主手机号必须一致 只要 `user_account.primary_phone_e164` 非空,就必须满足: 1. 同一 `user_id` 下存在一条 `provider = phone` 的 identity 2. 该行 `phone_e164 = user_account.primary_phone_e164` 3. 该行 `provider_uid = user_account.primary_phone_e164` 4. 该行 `is_primary = true` 5. 该行 `is_verified = true` ### 8.2 `login_provider` 不等于 `identity.provider` 必须明确: 1. `user_account.login_provider` 表示账号主归属方式 2. `auth_identity.provider` 表示账号下挂了哪一种外部身份 因此一个手机号正式账号在绑定微信后,完全允许出现: 1. `user_account.login_provider = phone` 2. 同时存在 `provider = phone` 与 `provider = wechat` 两条 identity ### 8.3 待绑定微信账号允许没有手机号 identity 若: 1. `user_account.account_status = pending_bind_phone` 2. `user_account.primary_phone_e164 = null` 则允许: 1. 当前账号只有一条 `wechat` identity 2. 不存在 `phone` identity ## 9. 写入场景设计 ### 9.1 `POST /api/auth/entry` 当前阶段不写 `auth_identity`。 原因: 1. 密码登录仍由 `user_account.password_hash` 承担 2. 本轮不引入 `password` provider identity 3. 密码登录只接受已绑定手机号的账号,不支持邮箱、用户名或百梦号作为登录身份 4. 密码登录不创建账号,新账号只由手机号验证码登录创建 ### 9.2 `POST /api/auth/phone/login` 写入规则: 1. 若账号不存在,则先创建 `user_account` 2. 同时创建一条 `provider = phone` identity 3. 若账号已存在,则校验手机号一致并更新 `last_login_at` 4. `bound_at` 只在首次绑定时写入 ### 9.3 `GET /api/auth/wechat/callback` 写入规则: 1. 先按 `provider_unionid` 查;没有再按 `provider_uid` 查 2. 若 identity 已存在,则更新资料快照并写 `last_login_at` 3. 若本次是按 `provider_unionid` 命中,但 `provider_uid` 已变化,则必须把新的 `provider_uid` 一并回写为最新主映射 4. 若 identity 不存在,则创建一条新的 `wechat` identity 5. 若是首次微信登录,还要同步创建 `pending_bind_phone` 的 `user_account` ### 9.4 `POST /api/auth/wechat/bind-phone` 分两种情况: 1. 手机号未被其他账号使用 - 当前账号写入 `phone` identity - 当前 `user_account` 转 `active` 2. 手机号已绑定已有正式账号 - 不新建第二条 `phone` identity - 把当前壳账号下的 `wechat` identity 迁移到目标正式账号 - 当前壳账号标记为并入 ### 9.5 `POST /api/auth/phone/change` 写入规则: 1. 更新当前账号唯一一条 `phone` identity 的 `phone_e164` 2. 同步更新 `provider_uid` 3. 更新 `bound_at` 不变 4. 更新 `last_login_at` 不变 5. `updated_at = 当前时间` 说明: 1. 当前阶段手机号换绑不保留“旧手机号 identity 历史行”。 2. 历史审计交给 `auth_audit_log`,不让 identity 表承担日志表职责。 ## 10. 迁移场景设计 ### 10.1 从当前 Node `auth_identities` 迁移 `wechat` identity 直接一比一迁移: | Node 列 | 新字段 | | --- | --- | | `id` | `identity_id` | | `user_id` | `user_id` | | `provider` | `provider` | | `provider_uid` | `provider_uid` | | `provider_unionid` | `provider_unionid` | | `display_name` | `display_name` | | `avatar_url` | `avatar_url` | | `is_verified` | `is_verified` | | `meta_json` | `meta_json` | | `created_at` | `bound_at`、`created_at` | | `updated_at` | `updated_at` | `last_login_at` 初次迁移规则: 1. 先回填为 `updated_at` ### 10.2 从当前 Node `users.phone_number` 反向补 phone identity 凡是当前 `users.phone_number` 非空的账号,都要补一条 `phone` identity: 1. `provider = phone` 2. `provider_uid = phone_number` 3. `phone_e164 = phone_number` 4. `display_name = null` 5. `avatar_url = null` 6. `is_verified = phone_verified_at != null` 7. `is_primary = true` 8. `bound_at = phone_verified_at ?? created_at` 9. `last_login_at = updated_at` ### 10.3 初次迁移的兼容结论 迁移完成后,必须满足: 1. 每个带手机号的账号都有一条 `phone` identity 2. 每个微信已绑定账号都有一条 `wechat` identity 3. `/api/auth/me` 的 `wechatBound` 不再依赖旧 Node 仓储逻辑,而能直接从 `auth_identity` 派生 ## 11. 读模型约束 `auth_identity` 至少要支撑以下读需求: ### 11.1 `/api/auth/me` 需要派生: 1. `wechatBound` 2. `bindingStatus` 3. `phoneNumberMasked` 其中: 1. `phoneNumberMasked` 最终以 `user_account.primary_phone_e164` 为准 2. `wechatBound` 以是否存在 `provider = wechat` identity 为准 ### 11.2 `/api/auth/wechat/start` 与 `/api/auth/wechat/callback` 需要支撑: 1. 微信 callback 身份查找 2. 首次登录 identity 创建 3. 绑定后 identity 迁移 ### 11.3 账号安全页 未来至少会依赖: 1. 当前账号绑定了哪些 provider 2. 是否完成手机号正式归属 3. 微信头像与昵称展示 ## 12. reducer / service 落地约束 ### 12.1 `module-auth` reducer 层 必须至少具备这些命令入口: 1. `create_phone_identity` 2. `upsert_phone_identity_after_phone_change` 3. `create_wechat_identity` 4. `touch_identity_last_login` 5. `refresh_wechat_identity_profile` 6. `move_identity_to_user` ### 12.2 Axum 应用层 固定负责: 1. 从微信 provider 拿到 `uid / unionid / 昵称 / 头像` 2. 决定微信 callback 应该命中已有 identity 还是创建新 identity 3. 在手机号绑定或换绑成功后,同步调用 `user_account` 与 `auth_identity` 相关 reducer ## 13. 不允许的设计漂移 后续实现时禁止出现以下情况: 1. 继续让手机号登录只写 `user_account.primary_phone_e164`,不写 `auth_identity` 2. 把微信 `unionid` 丢到 `meta_json` 里,而不是单独做唯一约束字段 3. 微信壳账号并入正式账号时直接删除 `wechat` identity 再重建,导致 `bound_at` 与历史 identity ID 丢失 4. 为图省事,把不同 provider 的唯一键都塞成一个模糊 `subject` 字段而不保留微信/手机号的显式结构 ## 14. 本任务完成定义 当以下条件满足时,`设计 auth_identity` 视为完成: 1. provider 范围、字段与唯一约束已明确到可以直接编码。 2. 手机号 identity 与微信 identity 的写入规则都已固定。 3. 已和 `user_account` 明确好一致性关系。 4. 微信 identity 迁移与手机号换绑的更新策略已经明确。 ## 15. 依据文件 1. `server-node/src/routes/authRoutes.ts` 2. `server-node/src/auth/authService.ts` 3. `server-node/src/repositories/authIdentityRepository.ts` 4. `server-node/src/repositories/userRepository.ts` 5. `server-node/src/db/migrations.ts` 6. `docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md` 7. `docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md` 8. `docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md`