🌐 Language: English Version | 中文版
随着业务发展,登录入口逐渐增多:用户名、邮箱、手机号,以及 Google、GitHub、微信等社交账号。传统的”大宽表”用户设计在面对这种多样性时,往往陷入两难:要么不断添加字段导致表结构臃肿,要么限制登录方式失去灵活性。本文将探讨一种通过 AuthIdentity 与 User 分离 的领域模型,来构建可扩展的统一认证体系。
一、从”大宽表”到”分离模型”的演进
1.1 传统设计的困境
早期的用户表通常是这样的:
1 | CREATE TABLE users ( |
这种设计的痛点显而易见:
- 扩展困难:新增登录方式需要修改表结构,线上大表变更风险高
- 逻辑耦合:密码字段对所有用户都是必填的,无法支持”仅短信登录”或”仅社交登录”的用户
- 唯一性混乱:
username、email、mobile各自有唯一约束,但社交账号的 ID 散落在不同字段,难以统一管理 - 数据冗余:不用密码的用户,
password_hash字段为空;不用邮箱的用户,email字段为空
1.2 分离模型的核心思想
将”用户是谁”(User)与”如何证明你是你”(AuthIdentity)彻底解耦:
- User:只关心用户的基础信息,不关心怎么登录
- AuthIdentity:只关心认证凭证,不关心用户其他信息
- UserPassword:密码作为独立实体,支持”无密码用户”场景
这种分离带来的直接好处:新增一种登录方式,只需要插入一条 AuthIdentity 记录,无需修改任何表结构。
二、领域模型设计
2.1 核心实体关系
classDiagram
class User {
+Long id
+String nickname
+String avatar
+String status
+datetime created_at
}
class AuthIdentity {
+Long id
+Long user_id
+String identity_type
+String identifier
+datetime verified_at
}
class UserPassword {
+Long user_id
+String password_hash
}
User "1" --> "n" AuthIdentity : has
User "1" --> "0..1" UserPassword : has
2.2 实体职责边界
User:用户聚合根
User 实体只存储业务无关的基础信息:
1 | public class User { |
设计意图:User 表应该足够稳定。无论后面支持多少种登录方式,这张表都不需要改动。它回答的是”这个用户存在吗”,而不是”这个用户怎么登录”。
AuthIdentity:认证标识
AuthIdentity 记录用户拥有的所有登录凭证,一条记录对应一种登录方式:
1 | public class AuthIdentity { |
关键设计:
- 复合唯一索引:
(identity_type, identifier)确保同一个邮箱不能绑定两个账号 - 灵活的 identifier:
13800138000可以是手机号类型的标识符,也可以是用户名类型的标识符,互不冲突 - 核心字段精简:AuthIdentity 只存储定位用户所需的最小信息,其他扩展数据可单独存储
UserPassword:独立的密码实体
1 | public class UserPassword { |
为什么要单独一张表?
- 支持无密码用户:只用短信验证码或社交登录的用户,没有 UserPassword 记录
- 密码策略独立:可以单独对密码表做安全加固(如更严格的访问控制、审计日志)
- 历史密码管理:如果需要”不能重复使用最近 5 次密码”的策略,可以扩展为一对多关系
三、模型在场景中的体现
3.1 场景一:用户注册
用邮箱注册:
1 | -- 1. 创建 User |
用微信一键注册(无密码用户):
1 | -- 1. 创建 User |
3.2 场景二:新增登录方式
用户原本用邮箱登录,现在想绑定手机号:
1 | -- 只需插入一条记录,无需修改 users 表 |
用户想再绑定 GitHub:
1 | INSERT INTO auth_identities (user_id, identity_type, identifier) |
体会一下这种扩展性:每新增一种登录方式,就是一行 INSERT,系统代码里也只是新增一个 identity_type 的枚举值。
3.3 场景三:登录时的模型协作
用户输入 zhangsan@example.com + 密码登录:
1 | public LoginResult login(String input, String password) { |
注意这里的查询路径:AuthIdentity → User → UserPassword,三个实体各司其职。
3.4 场景四:社交账号绑定冲突
用户 A 已登录,尝试绑定一个已被用户 B 占用的微信:
1 | public void bindSocialAccount(Long currentUserId, String wechatOpenid) { |
关键点:通过 (identity_type, identifier) 的唯一约束,天然防止了重复绑定。冲突处理只需要简单的判断逻辑。
四、模型带来的架构优势
4.1 扩展性:新增登录方式零表结构变更
对比传统设计:
| 方案 | 新增登录方式的成本 |
|---|---|
| 大宽表 | ALTER TABLE 添加字段,线上大表风险高,需要灰度发布 |
| 分离模型 | INSERT 一条记录,代码新增枚举值,纯业务逻辑变更 |
4.2 灵活性:支持”渐进式”认证
用户可以先注册一个”无密码”账号(仅社交登录),后续再设置密码、绑定手机号:
1 | -- 初始状态:只有微信登录 |
4.3 数据一致性:账号解绑不会丢失用户数据
解绑手机号只是删除一条 AuthIdentity 记录:
1 | DELETE FROM auth_identities |
User 实体不受影响,用户的订单、文章、评论等业务数据都还在。只要保留至少一种 AuthIdentity,用户就能继续登录。
五、实现时的注意事项
5.1 索引设计
1 | -- 核心查询:根据登录方式找用户 |
5.2 事务边界
创建用户时需要保证三张表的一致性:
1 |
|
5.3 安全考量(简要)
- 验证码:用于验证手机号/邮箱所有权,验证后应标记
verified_at - 密码策略:使用 Argon2id,参数建议
m=65536, t=3, p=4 - Token 存储:社交账号的 AccessToken 应单独存储并加密,防止泄露
- 防暴力破解:对
identifier+identity_type维度限流,而非仅 IP
六、总结
AuthIdentity 与 User 分离的核心价值在于:将”身份”与”认证方式”解耦。
这种设计让系统可以:
- 灵活扩展:新增登录方式只需添加记录,无需改表结构
- 渐进演进:支持用户从”社交登录”逐步完善到”多因子认证”
- 数据安全:解绑登录方式不会丢失用户业务数据
- 逻辑清晰:每个实体职责单一,代码易于维护
当你的产品从”仅用户名密码”发展到”支持手机号、邮箱、微信、GitHub、企业 SSO”等多种登录方式时,这种分离模型能帮你避免一次次痛苦的表结构迁移,让认证体系真正成为可演进的业务能力。