用户模型设计:从字段到状态,一个都不能少

好,咱们进入实战的第二块核心内容——用户模型设计。说白了,就是数据库里那张用户表该怎么建。我见过太多项目,前期图省事,用户表就三五个字段,结果后面权限一复杂,改得想哭。今天咱们就把这事一次性说透。

用户表字段设计:别漏了,也别多

先说说字段。我个人习惯,用户表至少要有这几类字段:身份标识、认证信息、状态控制、时间戳。缺了哪个,后面都会补坑。

下面这张表是我在多个项目中沉淀下来的标准结构,你可以直接拿去用:

字段名 类型 说明
id BIGINT UNSIGNED 主键,自增。别用UUID做主键,性能扛不住
username VARCHAR(50) 唯一索引。我建议限制长度,防止注入
email VARCHAR(100) 唯一索引。用于找回密码和通知
password_hash VARCHAR(255) 存bcrypt加密后的哈希值,不是明文!
status TINYINT 0=禁用, 1=正常, 2=未激活, 3=锁定
role_id INT UNSIGNED 外键,关联角色表。后面章节会细讲
last_login_at DATETIME 最后登录时间,排查问题很有用
created_at DATETIME 创建时间,默认当前时间
updated_at DATETIME 更新时间,每次更新自动修改
我的小建议: 别把手机号、昵称、头像这些非核心字段塞进用户主表。拆成 profile 表,用 user_id 关联。这样用户表查询更快,也方便扩展。

密码加密策略:为什么必须用 bcrypt?

说到密码存储,我得先讲个真实案例。我曾经接手过一个老项目,密码字段存的是 MD5 哈希。你猜怎么着?用户表被拖库后,攻击者用彩虹表半小时就还原了 80% 的密码。嗯,那场面,真是惨不忍睹。

所以,千万别用 MD5、SHA-1、SHA-256 这些快速哈希算法存密码。它们设计出来是为了快,而密码存储恰恰需要慢。bcrypt 就是专门干这个的——它内置了 salt(盐值),还能调整计算成本,让暴力破解变得极其昂贵。

来看一段 Python 代码,用 bcrypt 加密和验证密码:

import bcrypt

# 注册时:加密密码
def hash_password(plain_password: str) -> str:
    # 自动生成盐值,cost factor 默认12,我建议至少12
    salt = bcrypt.gensalt(rounds=12)
    hashed = bcrypt.hashpw(plain_password.encode('utf-8'), salt)
    return hashed.decode('utf-8')

# 登录时:验证密码
def verify_password(plain_password: str, hashed_password: str) -> bool:
    return bcrypt.checkpw(
        plain_password.encode('utf-8'),
        hashed_password.encode('utf-8')
    )

# 使用示例
if __name__ == '__main__':
    pwd = "MySecureP@ss123"
    hashed = hash_password(pwd)
    print(f"哈希值: {hashed}")
    print(f"验证结果: {verify_password(pwd, hashed)}")
关键点: bcrypt 每次生成的哈希值都不一样,因为盐值是随机的。所以别拿两个哈希值直接比较,要用 checkpw 方法。

你可能会问:为什么不选 argon2?它比 bcrypt 更安全啊。没错,argon2 是 2015 年密码哈希竞赛的冠军。但说实话,bcrypt 的生态更成熟,几乎所有语言都有稳定库,而且对于大多数业务系统来说,bcrypt 的安全性已经足够了。我个人习惯,新项目用 argon2,老项目升级到 bcrypt 就行。

用户状态管理:别只用一个字段

用户状态这事,看着简单,坑却不少。我见过有人用一个字段存「正常/禁用/删除」,结果后面要加个「未激活」状态,改代码改到崩溃。

我的做法是:状态字段用位掩码(bitmask)设计。每个 bit 代表一种状态,互不干扰。比如:

  • bit 0 (值1):是否激活
  • bit 1 (值2):是否禁用
  • bit 2 (值4):是否锁定(多次登录失败)
  • bit 3 (值8):是否删除(软删除)

这样,一个字段就能组合多种状态。比如 status=3 表示「已激活 + 已禁用」,status=5 表示「已激活 + 已锁定」。判断起来也很方便:

# 状态常量定义
STATUS_ACTIVE    = 1  # 0001
STATUS_DISABLED  = 2  # 0010
STATUS_LOCKED    = 4  # 0100
STATUS_DELETED   = 8  # 1000

def is_active(status: int) -> bool:
    return bool(status & STATUS_ACTIVE)

def is_disabled(status: int) -> bool:
    return bool(status & STATUS_DISABLED)

def is_locked(status: int) -> bool:
    return bool(status & STATUS_LOCKED)

# 登录时检查
def can_login(status: int) -> bool:
    # 必须激活,且未被禁用或锁定
    return (status & STATUS_ACTIVE) and not (status & (STATUS_DISABLED | STATUS_LOCKED))
避坑指南: 我曾经在项目里直接用「0=正常, 1=禁用, 2=删除」这种枚举值。后来产品说要加个「冻结」状态,我不得不改所有查询语句。用位掩码,加状态只需要加一个常量,不用改数据库结构。

另外,状态变更一定要记录日志。谁改的、什么时候改的、从什么状态改到什么状态、原因是什么。这些信息在排查问题时能救命。我习惯单独建一张 user_status_log 表,每次状态变化都写一条记录。

总结一下

用户模型设计,说白了就是三件事:字段要全但不冗余、密码要用 bcrypt 或 argon2、状态要灵活可扩展。别小看这些基础设计,它们决定了你的系统能走多远。下一章咱们聊角色和权限的关联,那才是真正的重头戏。

对了,如果你现在去翻翻自己项目的用户表,发现密码字段还是 MD5,嗯……赶紧改吧,趁还没出事。