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");
}

必要提示:

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")NaNNumber("0") 是 0;这些都应在 validator 层被拒绝。

7. 新增接口

export async function createActor(
  input: CreateActorBody,
): Promise<ActorDto> {
  // TODO:只将 firstName、lastName 传入 Actor.create()。
  // TODO:转换 DTO。
  throw new Error("TODO");
}

验证要求:

错误示例:

// 不安全:可能包含 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 的要求:

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
}

需要理解两个方案:

  1. 删除前查询 film_actor:可以给出更友好的消息,但查询与删除之间存在并发窗口。
  2. 直接删除并捕获外键约束:数据库是最终保护者,但需要正确映射数据库错误。

不要执行 SET FOREIGN_KEY_CHECKS = 0 来让接口“删除成功”。那会制造孤儿数据。

10. 常见错误

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. 练习题

  1. 为什么 req.params.actorId 的 TypeScript 类型应该先是 string?
  2. 为什么列表必须限制最大 pageSize
  3. 用户传入排序字段时,为什么需要白名单映射?
  4. findAndCountAll()rows.lengthcount 分别代表什么?
  5. PATCH 与 PUT 在本接口中有什么区别?
  6. 删除前查询关联记录为什么不能完全替代外键约束?
  7. 如何验证未携带 JWT 的请求确实没有执行 Service?
  8. 如果不允许物理删除演员,还可以设计什么状态机制?