09 Customer:多表查询、创建与停用

本章从单表 CRUD 进入真实业务常见的多表管理。所有接口必须先经过 authenticateAdmin

1. 表关系与业务背景

客户属于一个门店,并通过地址关联城市和国家:

store 1 --- n customer n --- 1 address n --- 1 city n --- 1 country
customer 1 --- n rental
customer 1 --- n payment

关键表:

本章不要求物理删除客户。Sakila 的客户被租赁和支付引用,日常业务更适合通过 active 停用。

2. 接口清单

方法 路径 作用
GET /api/sakila/customers 分页与多条件查询
GET /api/sakila/customers/:customerId 客户详情
POST /api/sakila/customers 使用已有地址创建客户
POST /api/sakila/customers/with-address 事务创建地址和客户
PATCH /api/sakila/customers/:customerId 修改资料
PATCH /api/sakila/customers/:customerId/status 启用或停用

Router 的共同前置条件:

const router = Router();
router.use(authenticateAdmin);

// TODO:在此后注册 customer 路由。

3. TypeScript 契约

export interface CustomerIdParams {
  customerId: string;
}

export interface CustomerListQuery {
  page?: string;
  pageSize?: string;
  active?: "true" | "false";
  name?: string;
  email?: string;
  storeId?: string;
  countryId?: string;
  sort?: "customerId" | "lastName" | "createDate";
  order?: "asc" | "desc";
}

export interface CustomerAddressDto {
  addressId: number;
  address: string;
  address2: string | null;
  district: string;
  cityId: number;
  city: string;
  countryId: number;
  country: string;
  postalCode: string | null;
  phone: string;
}

export interface CustomerListItemDto {
  customerId: number;
  storeId: number;
  firstName: string;
  lastName: string;
  email: string | null;
  active: boolean;
  createDate: string;
  address: CustomerAddressDto;
}

export interface CustomerDetailDto extends CustomerListItemDto {
  recentRentals: Array<{
    rentalId: number;
    filmId: number;
    filmTitle: string;
    rentalDate: string;
    returnDate: string | null;
  }>;
  paymentTotal: string;
  openRentalCount: number;
}

export interface CreateCustomerBody {
  storeId: number;
  firstName: string;
  lastName: string;
  email?: string | null;
  addressId: number;
  active?: boolean;
}

export interface CreateAddressBody {
  address: string;
  address2?: string | null;
  district: string;
  cityId: number;
  postalCode?: string | null;
  phone: string;
}

export interface CreateCustomerWithAddressBody {
  customer: Omit<CreateCustomerBody, "addressId">;
  address: CreateAddressBody;
}

export interface UpdateCustomerBody {
  firstName?: string;
  lastName?: string;
  email?: string | null;
  addressId?: number;
}

export interface UpdateCustomerStatusBody {
  active: boolean;
}

4. 客户列表:include 与 distinct

Service 接口:

export interface FindCustomersOptions {
  page: number;
  pageSize: number;
  active?: boolean;
  name?: string;
  email?: string;
  storeId?: number;
  countryId?: number;
  sort: "customerId" | "lastName" | "createDate";
  order: "asc" | "desc";
}

export async function findCustomers(
  options: FindCustomersOptions,
): Promise<PageResult<CustomerListItemDto>> {
  // TODO 1:构造 customer where。
  // TODO 2:include address -> city -> country。
  // TODO 3:countryId 存在时,决定关联是否 required。
  // TODO 4:findAndCountAll + limit + offset + 稳定排序。
  // TODO 5:处理 JOIN 造成 count 重复的问题。
  // TODO 6:转换嵌套 DTO。
  throw new Error("TODO");
}

重点提示:

Controller 骨架:

export async function listCustomersController(
  req: Request<
    Record<string, never>,
    ApiResponse<PageResult<CustomerListItemDto>>,
    Record<string, never>,
    CustomerListQuery
  >,
  res: Response<ApiResponse<PageResult<CustomerListItemDto>>>,
): Promise<void> {
  // TODO:将字符串 query 转成经过约束的 FindCustomersOptions。
  // TODO:调用 Service 并返回 OK。
}

5. 客户详情:不要强求一条巨大 SQL

详情同时包含基本资料、最近租赁、支付总额和未归还数量。

export async function getCustomerDetail(
  customerId: number,
): Promise<CustomerDetailDto> {
  // TODO 1:查询客户、地址、城市和国家。
  // TODO 2:查询最近 10 条租赁及电影。
  // TODO 3:聚合支付金额。
  // TODO 4:统计 returnDate 为 null 的租赁。
  // TODO 5:组合 DTO。
  throw new Error("TODO");
}

一条 SQL 同时 JOIN rentalpayment 可能形成乘法膨胀:一个客户的每条租赁会与每条支付组合,导致行数和聚合金额错误。详情接口使用少量、职责清楚的查询通常更容易验证。

支付总额使用 string:

paymentTotal: string;

这是因为 MySQL DECIMAL 为保持精度,mysql2/Sequelize 常将其作为字符串返回。不要未经设计就使用 Number() 做财务计算。

6. 使用已有地址创建客户

export async function createCustomer(
  input: CreateCustomerBody,
): Promise<CustomerListItemDto> {
  // TODO 1:确认 store 存在。
  // TODO 2:确认 address 存在。
  // TODO 3:只构造允许写入的 customer 字段。
  // TODO 4:创建并重新查询完整 DTO。
  throw new Error("TODO");
}

不要把所有“外键是否存在”的检查都寄托在 express-validator 的异步自定义校验上。输入格式属于 validator,复杂业务一致性更适合放在 Service,数据库外键负责最终兜底。

7. 事务创建地址和客户

export async function createCustomerWithAddress(
  input: CreateCustomerWithAddressBody,
): Promise<CustomerListItemDto> {
  return sequelize.transaction(async (transaction) => {
    // TODO 1:使用同一个 transaction 确认 store 和 city 存在。
    // TODO 2:创建 Address,并传入 transaction。
    // TODO 3:用新 addressId 创建 Customer,也传入同一 transaction。
    // TODO 4:返回 DTO;需要的关联查询是否放在事务内,要解释原因。
    throw new Error("TODO");
  });
}

训练目标:如果客户创建失败,不能留下无人使用的新地址。最常见错误是只给第一条查询传递 transaction,后面的查询却跑在事务外。

8. 更新客户资料

export async function updateCustomer(
  customerId: number,
  input: UpdateCustomerBody,
): Promise<CustomerListItemDto> {
  // TODO:查询客户。
  // TODO:若提供 addressId,确认地址存在。
  // TODO:按白名单构建更新对象。
  // TODO:保存并返回 DTO。
  throw new Error("TODO");
}

需要区分:

9. 停用客户

export async function setCustomerStatus(
  customerId: number,
  active: boolean,
): Promise<CustomerListItemDto> {
  // TODO 1:查找客户。
  // TODO 2:如果 active=false,检查是否存在 returnDate=null 的租赁。
  // TODO 3:相同状态重复提交时保持幂等。
  // TODO 4:更新状态并返回 DTO。
  throw new Error("TODO");
}

可能的业务错误:

CUSTOMER_NOT_FOUND
CUSTOMER_HAS_OPEN_RENTALS
STORE_NOT_FOUND
ADDRESS_NOT_FOUND
CITY_NOT_FOUND

注意“先检查未归还,再停用”仍可能遇到并发:另一个请求可能同时创建租赁。最终项目需要在租赁创建流程中再次检查客户 active,不能只依赖停用接口。

10. Router 与函数签名

router.get("/customers", customerListValidation, validateRequest, listCustomersController);
router.get("/customers/:customerId", customerIdValidation, validateRequest, getCustomerController);
router.post("/customers", createCustomerValidation, validateRequest, createCustomerController);
router.post(
  "/customers/with-address",
  createCustomerWithAddressValidation,
  validateRequest,
  createCustomerWithAddressController,
);
router.patch(
  "/customers/:customerId",
  updateCustomerValidation,
  validateRequest,
  updateCustomerController,
);
router.patch(
  "/customers/:customerId/status",
  updateCustomerStatusValidation,
  validateRequest,
  updateCustomerStatusController,
);

注意固定路径 /customers/with-address 与参数路径 /customers/:customerId 的注册顺序。否则 with-address 可能被当成 customerId;即使 validator 最终拒绝,也会造成难懂的行为。

11. 常见错误

12. curl 验收

TOKEN="替换为 accessToken"

curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8080/api/sakila/customers?page=1&pageSize=10&active=true&countryId=44"

curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:8080/api/sakila/customers/1

curl -sS \
  -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"storeId":1,"firstName":"TEST","lastName":"CUSTOMER","email":null,"addressId":1}' \
  http://127.0.0.1:8080/api/sakila/customers

curl -sS \
  -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"active":false}' \
  http://127.0.0.1:8080/api/sakila/customers/1/status

验收时还要主动测试:非法 countryId、超过最大 pageSize、不存在的地址、存在未归还租赁的客户,以及没有 Token 的访问。

13. 练习题

  1. include 中的 required: true 会如何改变查询语义?
  2. 为什么一对多 JOIN 会让 findAndCountAll() 的 count 变大?
  3. distinct: true 去重的是哪一个实体?
  4. 为什么客户详情不一定应该用一条 SQL 完成?
  5. email 缺失、null 和空字符串分别应该如何处理?
  6. 为什么地址和客户需要在同一个事务中创建?
  7. 客户停用为什么比物理删除更符合 Sakila 的数据关系?
  8. 仅在停用接口检查未归还租赁,为什么仍不能保证业务一致性?