08 Actor:从零完成一组受保护的 CRUD 接口
本章用 Sakila 中最简单的 actor 表,练习一条完整的后端链路:
Bearer Token
-> authenticateAdmin
-> express-validator
-> Controller
-> Service
-> Sequelize Model
-> MySQL
本章只提供接口契约与代码骨架。where、分页查询、更新和删除等核心 Sequelize 代码由你完成。
1. 业务场景
管理员需要维护演员资料:查询演员列表、查看详情、新增、修改和删除演员。
actor 表的关键字段如下:
| 数据库字段 | 含义 | 提示 |
|---|---|---|
actor_id |
主键 | 自增,无符号整数 |
first_name |
名 | 非空,最长 45 |
last_name |
姓 | 非空,最长 45 |
last_update |
最近更新时间 | 由数据库或 Sequelize 维护 |
演员和电影是多对多关系:
actor 1 --- n film_actor n --- 1 film
删除演员时,film_actor 中可能仍有引用,因此不能假定 destroy() 一定成功。
2. 本章接口
所有接口都挂载在 authenticateAdmin 之后:
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /api/sakila/actors |
分页查询 |
| GET | /api/sakila/actors/:actorId |
查询详情 |
| POST | /api/sakila/actors |
新增演员 |
| PATCH | /api/sakila/actors/:actorId |
部分更新 |
| DELETE | /api/sakila/actors/:actorId |
删除演员 |
即使业务失败,本项目仍返回 HTTP 200,并通过 JSON code 表达结果。
3. TypeScript 类型
export interface ActorIdParams {
actorId: string;
}
export interface ActorListQuery {
page?: string;
pageSize?: string;
name?: string;
sort?: "actorId" | "firstName" | "lastName" | "lastUpdate";
order?: "asc" | "desc";
}
export interface FindActorsOptions {
page: number;
pageSize: number;
name?: string;
sort: "actorId" | "firstName" | "lastName" | "lastUpdate";
order: "asc" | "desc";
}
export interface ActorDto {
actorId: number;
firstName: string;
lastName: string;
fullName: string;
lastUpdate: string;
}
export interface CreateActorBody {
firstName: string;
lastName: string;
}
export interface UpdateActorBody {
firstName?: string;
lastName?: string;
}
注意:HTTP 查询参数和路径参数最初都是字符串。Controller 应把校验后的字符串转换为 Service 使用的数字和枚举值。
4. Router 骨架
import { Router } from "express";
import { authenticateAdmin } from "../../middlewares/authenticate-admin";
const router = Router();
// 这行必须在所有 Sakila 业务路由之前。
router.use(authenticateAdmin);
router.get(
"/actors",
actorListValidation,
validateRequest,
listActorsController,
);
router.get(
"/actors/:actorId",
actorIdValidation,
validateRequest,
getActorController,
);
router.post(
"/actors",
createActorValidation,
validateRequest,
createActorController,
);
router.patch(
"/actors/:actorId",
updateActorValidation,
validateRequest,
updateActorController,
);
router.delete(
"/actors/:actorId",
actorIdValidation,
validateRequest,
deleteActorController,
);
如果把 router.use(authenticateAdmin) 写在这些接口后面,这些接口不会受到保护,因为 Express 中间件按注册顺序向下执行。
5. 列表接口
请求示例:
GET /api/sakila/actors?page=1&pageSize=20&name=pen&sort=lastName&order=asc
Authorization: Bearer <token>
响应数据类型:
type ActorListResponse = ApiResponse<PageResult<ActorDto>>;
Controller 骨架:
export async function listActorsController(
req: Request<
Record<string, never>,
ApiResponse<PageResult<ActorDto>>,
Record<string, never>,
ActorListQuery
>,
res: Response<ApiResponse<PageResult<ActorDto>>>,
): Promise<void> {
// TODO 1:读取经过校验的 query。
// TODO 2:将 page/pageSize 转换为 number,并填入默认值。
// TODO 3:调用 findActors()。
// TODO 4:返回 code = "OK"。
}
Service 骨架:
export async function findActors(
options: FindActorsOptions,
): Promise<PageResult<ActorDto>> {
const offset = (options.page - 1) * options.pageSize;
// TODO 1:根据 name 构造 where。
// TODO 2:将允许的 API 排序名映射为数据库属性名。
// TODO 3:使用 findAndCountAll 查询 count 和 rows。
// TODO 4:把 Model Instance 转换为 ActorDto。
// TODO 5:计算 totalPages。
throw new Error("TODO");
}
必要提示:
name可以匹配firstName或lastName,可考虑Op.or。pageSize建议默认 20,最大 100。- 排序字段必须使用白名单映射,不能直接相信用户输入。
- 为了稳定分页,主排序字段相同时应增加
actorId作为第二排序字段。 findAndCountAll()的count才是符合条件的总数,不能用rows.length代替。
6. 详情接口
export async function getActorById(actorId: number): Promise<ActorDto> {
// TODO:findByPk。
// TODO:不存在时抛出 AppError("ACTOR_NOT_FOUND", "演员不存在")。
// TODO:转换 DTO。
throw new Error("TODO");
}
export async function getActorController(
req: Request<ActorIdParams, ApiResponse<ActorDto>>,
res: Response<ApiResponse<ActorDto>>,
): Promise<void> {
// TODO:把 req.params.actorId 转换为 number。
// TODO:调用 Service 并返回 JSON。
}
不要仅使用 Number(value) 后就相信结果。Number("abc") 是 NaN,Number("0") 是 0;这些都应在 validator 层被拒绝。
7. 新增接口
export async function createActor(
input: CreateActorBody,
): Promise<ActorDto> {
// TODO:只将 firstName、lastName 传入 Actor.create()。
// TODO:转换 DTO。
throw new Error("TODO");
}
验证要求:
firstName、lastName必填。trim()后长度为 1~45。- 统一大小写是否属于业务规则,应明确决定。
- 拒绝请求中的未知字段,或至少不要把整个
req.body传给 Model。
错误示例:
// 不安全:可能包含 actorId、lastUpdate 等不允许写入的字段。
await Actor.create(req.body);
8. PATCH 更新接口
export async function updateActor(
actorId: number,
input: UpdateActorBody,
): Promise<ActorDto> {
// TODO 1:查找实例,不存在时抛出 ACTOR_NOT_FOUND。
// TODO 2:只复制被允许且实际提供的字段。
// TODO 3:保存实例。
// TODO 4:转换 DTO。
throw new Error("TODO");
}
PATCH 的要求:
- Body 至少包含
firstName、lastName中的一个。 - 缺少某字段表示“不修改”,不是将其设置为
null。 - 空字符串不能被当成合法姓名。
- 相同内容再次提交可以成功返回当前资源,这有利于幂等重试。
9. 删除与外键约束
export async function deleteActor(actorId: number): Promise<void> {
// TODO 1:确认演员存在。
// TODO 2:检查或直接尝试删除。
// TODO 3:把 ForeignKeyConstraintError 映射为 ACTOR_IN_USE。
}
可能的失败响应:
{
"code": "ACTOR_IN_USE",
"message": "演员已经关联电影,不能直接删除",
"data": null
}
需要理解两个方案:
- 删除前查询
film_actor:可以给出更友好的消息,但查询与删除之间存在并发窗口。 - 直接删除并捕获外键约束:数据库是最终保护者,但需要正确映射数据库错误。
不要执行 SET FOREIGN_KEY_CHECKS = 0 来让接口“删除成功”。那会制造孤儿数据。
10. 常见错误
- 忘记注册
authenticateAdmin,接口匿名可访问。 - 把
req.query.page当成 number。 findAll()不设置limit,一次加载整个表。- 直接把用户提供的
sort放进order。 - PATCH 把缺少的字段写成
undefined或null。 - 把 Sequelize Model Instance 原样返回,泄漏不需要的字段。
- 删除失败一律返回
INTERNAL_SERVER_ERROR,没有区分外键占用。 - HTTP 虽然为 200,却忘记设置非
OK的业务 code。
11. curl 验收
先登录并保存 Token:
TOKEN="替换为登录返回的 accessToken"
未登录访问应返回业务错误:
curl -i http://127.0.0.1:8080/api/sakila/actors
分页查询:
curl -sS \
-H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8080/api/sakila/actors?page=1&pageSize=10&name=pen"
新增:
curl -sS \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"firstName":"TEST","lastName":"ACTOR"}' \
http://127.0.0.1:8080/api/sakila/actors
修改和删除:
curl -sS \
-X PATCH \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"lastName":"UPDATED"}' \
http://127.0.0.1:8080/api/sakila/actors/201
curl -sS \
-X DELETE \
-H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8080/api/sakila/actors/201
12. 练习题
- 为什么
req.params.actorId的 TypeScript 类型应该先是 string? - 为什么列表必须限制最大
pageSize? - 用户传入排序字段时,为什么需要白名单映射?
findAndCountAll()的rows.length与count分别代表什么?- PATCH 与 PUT 在本接口中有什么区别?
- 删除前查询关联记录为什么不能完全替代外键约束?
- 如何验证未携带 JWT 的请求确实没有执行 Service?
- 如果不允许物理删除演员,还可以设计什么状态机制?