00 项目目标与练习规则
本练习把 MySQL 官方示例数据库 Sakila 改造成一个小型的“电影租赁管理后台 API”。你将以管理员身份登录,然后通过 Express 接口管理演员、客户、库存、租赁和支付,并使用 Sequelize 与 Raw SQL 访问 MySQL。
本章只确定边界与约定,不要求编写业务查询。
1. 最终请求链路
管理后台前端
-> HTTP 请求
-> Express Router
-> express-validator
-> JWT 鉴权中间件
-> Controller
-> Service
-> Sequelize Model 或 Raw SQL
-> MySQL sakila_training
PM2 只负责运行 Node.js 进程,Nginx 只负责接收和转发外部请求;它们都不代替 Express 的认证和业务校验。
2. 数据库使用规则
建议从原始 Sakila 复制一份练习库:
sakila 只读参考库
sakila_training 本练习读写库
本练习会新增 admin_user,并会修改 Actor、Customer、Rental、Payment 等业务数据,因此不要把唯一一份 Sakila 当作练习库。
禁止在现有数据库上使用:
sequelize.sync({ force: true });
sequelize.sync({ alter: true });
它们可能删除表、重建表或作出你没有审核过的 DDL。练习新增表应使用手工审核的 SQL;Sequelize Model 用来映射数据库,不负责猜测数据库结构。
3. 环境变量
建议的 .env 配置:
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=sakila_training
DB_USER=sakila_app
DB_PASSWORD=请使用真实密码
JWT_ACCESS_SECRET=请使用足够长的随机值
JWT_ACCESS_EXPIRES_IN_SECONDS=1800
JWT_ISSUER=nloop-sakila-api
JWT_AUDIENCE=nloop-sakila-admin
要求:
.env不提交到 Git;仓库只提供不含秘密的.env.example。- 不在代码、日志、错误响应中输出数据库密码或 JWT Secret。
- 开发库账号只授予练习所需权限,不使用 MySQL
root运行应用。 - 程序启动时检查必要配置;不要等到第一次请求才发现配置缺失。
- 将
JWT_ACCESS_EXPIRES_IN_SECONDS校验并转换为正整数,不把未经校验的环境变量字符串直接传给jsonwebtoken。
4. HTTP 接口范围
公开接口只有:
GET /health
POST /api/auth/login
需要管理员 Token 的接口:
GET /api/auth/me
PATCH /api/auth/password
POST /api/auth/logout-all
GET /api/admin/users
POST /api/admin/users
PATCH /api/admin/users/:adminId/status
GET /api/sakila/actors
POST /api/sakila/actors
...
第一阶段只有 manager 一种角色。不要一开始实现菜单权限、动态角色或复杂 RBAC;先把“未登录不能访问业务接口”做正确。
5. 认证约定
客户端通过请求头发送访问令牌:
Authorization: Bearer <access-token>
JWT Payload 只放认证所需的最小信息,例如管理员 ID、角色和 Token 版本。JWT 是签名数据,不是加密数据;任何拿到 Token 的人都能读取 Payload,所以禁止放入:
- 密码或密码摘要;
- 邮箱等非必要个人信息;
- 数据库连接信息;
- 完整管理员对象。
6. 本项目的响应约定
当前工程约定:Express 已捕获的业务成功与失败都返回 HTTP 200,通过 JSON code 判断结果。
成功:
{
"code": "OK",
"message": "success",
"data": {}
}
失败:
{
"code": "INVALID_CREDENTIALS",
"message": "用户名或密码错误",
"data": null
}
这是一项项目约定,不代表 HTTP 的通用最佳实践。你必须理解它的代价:
- Axios 不能只根据 HTTP 状态判断业务成功;
curl --fail-with-body无法识别业务失败;- Nginx access log 中业务失败仍是 200;
- 日志和监控必须记录
businessCode; - Nginx 自己产生的 404、502、504 仍不会自动变成该 JSON。
7. 分页、筛选与排序规则
所有列表接口必须:
- 提供
page和pageSize默认值; - 限制
pageSize上限,例如 100; - 使用稳定排序,例如
last_update DESC, actor_id DESC; - 只允许白名单中的排序字段;
- 返回
list、total、page、pageSize、totalPages。
HTTP Query 最初都是字符串:
req.query.page === "2";
Controller 应把经过校验的输入转换成内部类型,Service 接收 number,而不是重复处理 string | undefined。
8. 时间与金额规则
日期区间统一使用左闭右开:
startAt <= value < endAt
例如查询 2026-01-01 到 2026-01-02 两天的数据,内部结束时间应是 2026-01-03 00:00:00。前后端必须明确时区。
MySQL DECIMAL 可能由 Sequelize/mysql2 返回字符串。金额 DTO 默认保留字符串,不要未经论证就转为 JavaScript number 并累加。
9. 各层职责
Router
- 声明 method 与 path;
- 编排校验、鉴权和 Controller;
- 不编写 SQL。
Validator
- 判断输入格式、长度和基础类型;
- 规范化输入;
- 不承担复杂事务规则。
Controller
- 把 HTTP 输入转换为 Service 参数;
- 调用 Service;
- 组织 JSON 响应;
- 不堆积复杂 Sequelize 查询。
Service
- 执行业务规则;
- 使用 Model 或 Raw SQL;
- 管理事务;
- 返回 DTO 或明确的内部结果。
Model
- 映射真实表、字段和关联;
- 提供数据库层验证;
- 不直接等同于 API DTO。
10. 安全底线
- 密码只保存 Argon2id hash,绝不保存明文或可逆密文。
- 登录错误对外统一为“用户名或密码错误”,避免用户名枚举。
- Raw SQL 必须使用 replacements/bind;排序列只能从白名单映射。
- 禁止把整个
req.body直接交给Model.update()。 - 未知错误写入服务端日志,对外只返回通用消息。
- 日志不得记录密码、密码摘要、JWT 或数据库 Secret。
- 所有 Sakila 业务 Router 必须在正确位置注册鉴权中间件。
11. 每章的完成方法
每个练习会区分:
已提供:HTTP 契约、DTO、函数签名、代码骨架
需完成:TODO 中的校验、查询、业务判断和错误映射
进阶项:事务、并发、EXPLAIN、集成测试
建议每完成一个接口都执行:
npx tsc --noEmit
然后用 curl 测试正常、缺少参数、非法参数、未登录、数据不存在等分支。
12. 本章练习
- 画出“不带 Token 请求演员列表”时的中间件执行顺序。
- 解释为什么 PM2 显示 online 不等于数据库接口一定可用。
- 写出三个不能放入 JWT Payload 的字段并说明原因。
- 如果所有业务错误都是 HTTP 200,前端 Axios 响应拦截器至少应检查什么?
- 为什么不能对现成 Sakila 直接执行
sync({ force: true })? - 说明 Validator 和 Service 中数据库存在性检查的边界。
- 设计一个不会被用户任意控制 SQL 列名的排序白名单。
- 说明为什么分页结果必须有稳定排序。