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 中真实存在的对象。它负责持久化数据,并执行:
- 主键、唯一索引和 CHECK 约束;
NOT NULL和默认值;- 事务、行锁和并发控制;
- 字符集、排序规则和索引访问。
即使 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 是否公开该字段 |
如果只改其中一层,会产生不同问题:
- 只改 SQL:Model 可能仍查询旧列或给出错误类型;
- 只改 Model:数据库并不会凭空增加字段;
- 只改 TypeScript:仅仅“骗过”或“吓到”编译器;
- 直接返回 Model:数据库内部字段可能泄露。
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:
- 开发者仍可以启用 Scope;
- 实例仍可能因别的查询含有敏感字段;
- Scope 不是授权系统;
- 日志也可能意外打印实例。
所以响应仍应使用 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;
然后继续问:
- 是否只需若干字段而不是
SELECT *? - 排序在同一时间值下是否稳定?
account_status是否值得建索引,取决于数据分布与查询频率?- 是否可能一次查出过多数据?
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;
这里分别由:
- Service 定义“第 5 次失败锁定”的规则;
- Sequelize 组织事务和参数;
- MySQL 的事务与行锁真正阻止并发覆盖;
- TypeScript 检查
Date | null等使用方式。
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. 动手练习
- 在不改代码的前提下,画出
loginAdmin()从 Controller 到 MySQL 的调用链。 - 写出
findAdminForLogin("admin")可能生成的 SQL,并标出为什么需要LIMIT 1。 - 列出
AdminUser中绝不能直接返回客户端的字段。 - 假设数据库把
email改为NOT NULL,列出需要同步检查的代码、Model、DTO 和测试。 - 开启 Sequelize SQL 日志,完成一次登录,逐条标注 SQL 来自哪个 Service 函数。
10. 复盘问题
- Model 类和 Model 实例分别代表什么?
- 为什么 TypeScript 类型正确不代表数据库结构正确?
- 为什么默认 Scope 不能代替 DTO?
- 请求校验、Service 规则、Model validate、数据库约束各自负责什么?
- 为什么学习 ORM 时仍要阅读最终 SQL?
下一章会进入 db.ts:一个 Sequelize 实例如何管理配置、连接池、BIGINT 和日志。