01|建立 Sequelize 的正确心智模型

这一章先不追求记住 API。目标是回答一个更重要的问题:你写下一行 Sequelize 代码时,究竟是哪一层在保证什么?

1. 业务场景:管理员登录到底经过了哪些层

当前工程的登录链路可以简化为:

HTTP 请求
  → Controller:读取请求并组织响应
  → Service:执行登录规则
  → Sequelize Model:表达查询和更新
  → Sequelize:生成 SQL、管理连接和事务
  → mysql2:使用 MySQL 协议通信
  → MySQL:真正保存数据、执行约束和锁

例如 findAdminForLogin():

const admin = await AdminUser.scope("withPassword").findOne({
  where: { username },
});

它大致对应:

SELECT id, username, password_hash, display_name, email,
       account_status, token_version, failed_login_count,
       locked_until, last_login_at, password_changed_at,
       created_at, updated_at
FROM admin_user
WHERE username = ?
LIMIT 1;

其中 ? 的值由驱动绑定,而不是拼接进 SQL。数据库完成查询后,Sequelize 再把一行结果构造成 AdminUser 实例。

2. 五个容易混淆的对象

2.1 数据库表

admin_user 是 MySQL 中真实存在的对象。它负责持久化数据,并执行:

即使 Node.js 服务停止,表和数据仍然存在。

2.2 Model 类

export class AdminUser extends Model<...> {}

AdminUser 是类,也是表的程序入口。静态方法通常表示“对表操作”:

await AdminUser.findByPk("1");
await AdminUser.findAll();
await AdminUser.create({ /* ... */ });
await AdminUser.update({ account_status: 0 }, { where: { id: "1" } });

Model 定义只是映射规则。修改 TypeScript 文件不会自动修改已存在的表。

2.3 Model 实例

const admin = await AdminUser.findByPk("1");

当结果不为 null 时,admin 是一行数据对应的实例。实例既有字段,也有方法:

admin.display_name = "New name";
await admin.save();
await admin.reload();
await admin.update({ failed_login_count: 0 });

它不是普通 JSON。JSON.stringify() 会触发 Sequelize 的序列化逻辑,而且实例可能含有不应暴露的字段。

2.4 TypeScript 类型

declare email: string | null;

这条声明只帮助编辑器和编译器。它不会在运行时检查 MySQL 是否真的返回了字符串或 null,也不会创建数据库列。

下面代码即使类型写对了,也无法修复一个结构错误的数据库:

declare token_version: number;

数据库若缺少该列,查询仍会在运行时报错。

2.5 DTO

DTO 是对外传输的数据结构。当前工程通过 toAdminUserDto() 只返回白名单字段:

return {
  id: admin.id,
  username: admin.username,
  display_name: admin.display_name,
  email: admin.email,
  account_status: admin.account_status,
  last_login_at: admin.last_login_at?.toISOString() ?? null,
  created_at: admin.created_at.toISOString(),
};

Model 描述完整数据库记录,DTO 描述允许穿过 API 边界的内容。两者不应该合并。

3. 四层必须一致,但职责不同

以 email 为例:

层 表达 作用
MySQL VARCHAR(254) DEFAULT NULL 真正存储并允许 NULL
Sequelize DataTypes.STRING(254), allowNull: true 生成查询、运行部分校验
TypeScript string | null 编译时提醒处理空值
DTO email: string | null 定义 API 是否公开该字段

如果只改其中一层,会产生不同问题:

4. 谁负责校验

一个规则可能需要出现在多个位置,但目的不同。

请求边界

例如用户名不能为空、长度最多 50。这里适合尽早给用户清晰反馈。

Service 业务规则

例如账号被禁用时不能登录。这依赖业务语义,不属于数据库字段格式。

Model validate

当前 account_status 使用 isIn。它可尽早阻止通过该 Model 写入非法状态,但不是数据库最终防线。

数据库约束

唯一用户名、NOT NULL、外键和 CHECK 必须由数据库最终保证,因为多个程序和并发请求都可能写同一张表。

关键原则:应用校验改善体验,数据库约束维护事实。

5. Scope 是便利措施,不是安全边界

当前 Model 默认排除 password_hash:

defaultScope: {
  attributes: { exclude: ["password_hash"] },
},

登录时显式启用:

AdminUser.scope("withPassword").findOne(...)

这样能降低普通查询误取密码摘要的概率,但不能代替 DTO:

所以响应仍应使用 DTO 白名单。

6. ORM 不会代替 SQL 思考

看到以下代码:

await AdminUser.findAll({
  where: { account_status: 1 },
  order: [["created_at", "DESC"]],
  limit: 20,
});

应能想到:

SELECT ...
FROM admin_user
WHERE account_status = 1
ORDER BY created_at DESC
LIMIT 20;

然后继续问:

Sequelize 帮你表达 SQL,不替你设计 SQL。

7. 当前登录代码中的完整例子

recordFailedLogin() 不是简单的“保存对象”。它表达了一个并发规则:五个并发失败请求不能互相覆盖。

await db.transaction(async (transaction) => {
  const current_admin = await AdminUser.findByPk(admin.id, {
    transaction,
    lock: transaction.LOCK.UPDATE,
  });

  // 依据事务内最新值计算,再更新
});

大致 SQL:

START TRANSACTION;
SELECT ... FROM admin_user WHERE id = ? FOR UPDATE;
UPDATE admin_user
SET failed_login_count = ?, locked_until = ?
WHERE id = ?;
COMMIT;

这里分别由:

8. 初学者常见错误

修改 Model 后以为表已改变

Model.init() 不是 Migration。除非主动运行 sync(),它只是映射。正式结构变更应留在版本化 Migration 中。

把 Model 实例直接返回

res.json(admin); // 不推荐

问题不仅是密码摘要,还包括内部状态、未来新增字段以及时间格式不稳定。

相信 TypeScript 能校验数据库

TypeScript 编译后类型会消失。外部数据和数据库结构仍需运行时约束与集成测试。

把 allowNull: false 当作业务规则全集

它只能表达“不允许 null”,不能完整表达用户名 trim、账号登录资格等规则。

不看生成 SQL

include、Scope、分页和聚合很容易生成与你想象不同的 SQL。开发期应观察日志并用 MySQL CLI 或 DBeaver验证。

9. 动手练习

  1. 在不改代码的前提下,画出 loginAdmin() 从 Controller 到 MySQL 的调用链。
  2. 写出 findAdminForLogin("admin") 可能生成的 SQL,并标出为什么需要 LIMIT 1。
  3. 列出 AdminUser 中绝不能直接返回客户端的字段。
  4. 假设数据库把 email 改为 NOT NULL,列出需要同步检查的代码、Model、DTO 和测试。
  5. 开启 Sequelize SQL 日志,完成一次登录,逐条标注 SQL 来自哪个 Service 函数。

10. 复盘问题

  1. Model 类和 Model 实例分别代表什么?
  2. 为什么 TypeScript 类型正确不代表数据库结构正确?
  3. 为什么默认 Scope 不能代替 DTO?
  4. 请求校验、Service 规则、Model validate、数据库约束各自负责什么?
  5. 为什么学习 ORM 时仍要阅读最终 SQL?

下一章会进入 db.ts:一个 Sequelize 实例如何管理配置、连接池、BIGINT 和日志。