01 管理员表设计

本章设计 admin_user 表。数据库是现成的 Sakila,但认证账号是新业务,因此需要单独建表。

本章提供可执行 DDL 骨架供你补全和审核,但不要在不理解字段与约束的情况下直接在重要数据库执行。

1. 业务假设

这是电影租赁公司的内部管理后台:

不要直接用 staff.username/password 实现本练习认证。将业务员工与认证身份分开,可以避免:

进阶时可以增加可空的 staff_id,把某个管理员关联到门店员工,但这不是第一阶段必做项。

2. 字段设计

字段 推荐类型 NULL 默认值 说明
id BIGINT UNSIGNED 自增 主键
username VARCHAR(50) 登录名,唯一
password_hash VARCHAR(255) Argon2id 摘要
display_name VARCHAR(100) 页面显示名称
email VARCHAR(254) NULL 联系邮箱,非登录必需
account_status TINYINT UNSIGNED 1 0 禁用,1 启用,2 手工锁定
token_version INT UNSIGNED 0 撤销历史 Token
failed_login_count INT UNSIGNED 0 连续失败次数
locked_until DATETIME(3) NULL 自动锁定截止时间
last_login_at DATETIME(3) NULL 最近成功登录时间
password_changed_at DATETIME(3) 当前时间 密码最后修改时间
created_at DATETIME(3) 当前时间 创建时间
updated_at DATETIME(3) 当前时间并自动更新 修改时间

3. 关键字段为什么这样设计

3.1 password_hash

数据库只保存:

$argon2id$v=19$...

它是 Argon2id 密码摘要,不是明文,也不是可解密密文。项目已经安装 argon2,后续应使用:

const passwordHash = await argon2.hash(password, {
  type: argon2.argon2id,
});

校验时使用:

await argon2.verify(passwordHash, candidatePassword);

不要自行调用 MD5、SHA-1 或普通 SHA-256 保存密码。密码 Hash 需要专门的慢算法、随机盐和可调成本。

VARCHAR(255) 给 Argon2 编码串和未来算法升级留出余量。不要把 password_hash 放进 JWT、DTO、普通日志或错误响应。

3.2 token_version

JWT 签发时放入当前版本:

{
  "sub": "12",
  "role": "manager",
  "tokenVersion": 3
}

鉴权时查询管理员并比较:

JWT.tokenVersion === admin_user.token_version

修改密码或退出所有设备时执行原子递增:

UPDATE admin_user
SET token_version = token_version + 1
WHERE id = ?;

旧 JWT 中的版本不再匹配,因而失效。注意:这会让鉴权不再完全无状态,因为每次请求需要查询数据库或可靠缓存。

3.3 锁定字段

字段各自表达不同事实:

account_status       管理员主动配置的长期状态
failed_login_count   连续失败次数
locked_until         自动临时锁定截止时间

不要只用一个 Boolean locked 混合“被管理员禁用”和“失败太多临时锁定”。成功登录后通常清零 failed_login_count;临时锁定过期后如何复位,应在 Service 中制定明确规则。

登录失败计数存在并发问题。两个失败请求同时执行“读出 2,再写入 3”可能丢失一次更新。后续应练习原子递增:

failed_login_count = failed_login_count + 1

3.4 为什么使用 DATETIME(3)

(3) 保存毫秒,适合登录、锁定和审计时间。MySQL DATETIME 不携带时区;应用必须约定数据库连接时区和 API 输出格式。

建议:

3.5 BIGINT 与 JavaScript string 策略

MySQL BIGINT UNSIGNED 最大值可能超过 JavaScript 安全整数:

Number.MAX_SAFE_INTEGER;

因此本练习约定:

数据库:BIGINT UNSIGNED
Sequelize Model:string
DTO:adminId 为 string
JWT sub:string
URL 参数:校验后仍转换为规范的十进制 string

不要使用 Number(adminId) 后再查询,否则大 ID 可能丢失精度。

4. 索引对应的真实查询

不要因为“字段很多”就给每列建索引。索引应服务于查询。

登录查询

SELECT ...
FROM admin_user
WHERE username = ?
LIMIT 1;

对应唯一索引:

UNIQUE KEY uk_admin_user_username (username)

按邮箱查重

SELECT id
FROM admin_user
WHERE email = ?
LIMIT 1;

对应:

UNIQUE KEY uk_admin_user_email (email)

MySQL 唯一索引通常允许多行 NULL,符合“邮箱可空,但有值时唯一”的假设。空邮箱应保存 NULL,不要保存 ''

管理员分页列表

SELECT ...
FROM admin_user
WHERE account_status = ?
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?;

候选联合索引:

KEY idx_admin_user_status_created_id
  (account_status, created_at, id)

是否真正需要它,要根据数据量和 EXPLAIN 决定。管理员通常数量很少,不要把“看起来合理的索引”误当成必须项。

不建议仅为 failed_login_count 建索引,因为常规业务并不按失败次数批量检索管理员。

5. 可执行 DDL 骨架

请先在练习库执行 USE sakila_training;,逐项补全 TODO,并自行审核字符集、约束支持版本和字段默认值。

CREATE TABLE admin_user (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  username VARCHAR(50) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  display_name VARCHAR(100) NOT NULL,
  email VARCHAR(254) NULL,

  account_status TINYINT UNSIGNED NOT NULL DEFAULT 1,
  token_version INT UNSIGNED NOT NULL DEFAULT 0,
  failed_login_count INT UNSIGNED NOT NULL DEFAULT 0,
  locked_until DATETIME(3) NULL,
  last_login_at DATETIME(3) NULL,
  password_changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),

  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
    ON UPDATE CURRENT_TIMESTAMP(3),

  PRIMARY KEY (id),
  UNIQUE KEY uk_admin_user_username (username),
  UNIQUE KEY uk_admin_user_email (email),

  /* TODO:是否加入管理员列表的联合索引?先写出对应查询和 EXPLAIN */

  CONSTRAINT chk_admin_user_account_status
    CHECK (account_status IN (0, 1, 2)),
  CONSTRAINT chk_admin_user_failed_login_count
    CHECK (failed_login_count >= 0)
) ENGINE = InnoDB
  DEFAULT CHARACTER SET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci;

注意:

6. Sequelize 字段映射预览

Model 使用 camelCase,数据库使用 snake_case:

declare passwordHash: string;
declare tokenVersion: CreationOptional<number>;
declare lockedUntil: Date | null;

字段配置通过 field 映射:

passwordHash: {
  type: DataTypes.STRING(255),
  allowNull: false,
  field: "password_hash",
},

本练习的 DDL 已由 MySQL 负责生成和更新两个时间戳。为了让 Model 属性仍保持 camelCase,建议把它们作为普通字段显式映射,并关闭 Sequelize 的自动时间戳:

createdAt: {
  type: DataTypes.DATE(3),
  allowNull: false,
  field: "created_at",
},
updatedAt: {
  type: DataTypes.DATE(3),
  allowNull: false,
  field: "updated_at",
},

// Model options
timestamps: false,

这里的 timestamps: false 不是说表里没有时间字段,而是“不让 Sequelize 自动添加和维护时间戳属性”。不要再同时配置 createdAt: "created_at":该 option 会重命名 Model 属性,并不是单纯设置数据库列名,容易与类上的 createdAt 冲突。

7. 数据库约束与应用校验的边界

应用层可先检查用户名格式和是否已存在,以返回友好消息;数据库唯一约束仍然必须存在,因为并发请求可能同时通过“查重”。最终 Service 还要捕获 UniqueConstraintError

同理:

express-validator  校验 username 长度和字符
Service            判断业务状态
Sequelize Model    描述字段规则
MySQL constraint   提供最终一致性保护

这些校验有重合,但各自处于不同边界,不能只保留最外层校验。

8. 本章验收清单

9. 本章练习

  1. 补全并审核 DDL,在你自己的练习库中执行前先说明每个约束的作用。
  2. 如果用户名需要严格区分大小写,应怎样调整字段或索引?
  3. 写出“临时锁定 15 分钟”的状态判断伪代码。
  4. 两次并发登录失败怎样避免丢失计数?
  5. 为什么管理员表数据很少时,联合索引可能没有明显收益?
  6. 设计“不能禁用最后一个启用管理员”的查询与事务边界。
  7. 如果以后支持 Refresh Token,还需要保存哪些可撤销信息?
  8. 分析 DATETIME(3) 与 Node.js Date 交换时的时区风险。