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 5468ba09..84e9fba2 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -142,7 +142,8 @@ - [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` +- [x] 设计 `auth_identity` + 交付物:[../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md) - [ ] 设计 `refresh_session` - [ ] 设计 `auth_audit_log` - [ ] 设计 `auth_risk_block` diff --git a/docs/technical/README.md b/docs/technical/README.md index 69b7d62d..05b5b80d 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,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` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。 - [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。 diff --git a/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md new file mode 100644 index 00000000..e2afa884 --- /dev/null +++ b/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md @@ -0,0 +1,382 @@ +# `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 + +### 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. 若 identity 不存在,则创建一条新的 `wechat` identity +4. 若是首次微信登录,还要同步创建 `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` 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 e550c26d..0f7d19a9 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 @@ -310,6 +310,10 @@ server-rs/ - [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md) +`auth_identity` 的 provider 约束、唯一键与手机号/微信身份写入规则,见: + +- [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_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 326c8618..eb8bb31d 100644 --- a/server-rs/packages/module-auth/README.md +++ b/server-rs/packages/module-auth/README.md @@ -25,6 +25,7 @@ 当前已冻结文档: 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) +2. [../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md) ## 3. 边界约束