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
关键表:
customer:客户账户与状态。address:地址,不应假定一位客户独占一条地址记录。city、country:地区层级。store:客户所属门店。rental:租赁历史。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");
}
重点提示:
- Sequelize 的
include默认是左连接;在关联表条件真正用于筛选时,常需要required: true。 - 一旦加入一对多关联,例如租赁记录,单个客户会出现多行 SQL 结果。
findAndCountAll()的count可能因此放大,通常要理解并考虑distinct: true。- 不要机械地给所有查询都加
distinct: true;先理解 JOIN 的基表粒度。 attributes应只选择接口真正需要的字段,避免从多个表加载大字段。
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 rental 和 payment 可能形成乘法膨胀:一个客户的每条租赁会与每条支付组合,导致行数和聚合金额错误。详情接口使用少量、职责清楚的查询通常更容易验证。
支付总额使用 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");
}
需要区分:
email缺失:不修改。email: null:明确清空。email: "":建议校验为无效,而不是当成 null。
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. 常见错误
Customer.findAll({ include: [Rental] })后 count 被租赁行数放大。- include 使用了错误的
as,与 Association 定义不一致。 - 详情使用巨大 JOIN,支付金额被重复累计。
- 创建 Address 成功、Customer 失败,却没有事务回滚。
- 将
req.body整体传给update()。 - 把
DECIMAL无条件转为 JavaScript number。 - 把停用客户实现成物理删除。
- 忘记在创建租赁时再次检查客户是否 active。
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. 练习题
include中的required: true会如何改变查询语义?- 为什么一对多 JOIN 会让
findAndCountAll()的 count 变大? distinct: true去重的是哪一个实体?- 为什么客户详情不一定应该用一条 SQL 完成?
email缺失、null 和空字符串分别应该如何处理?- 为什么地址和客户需要在同一个事务中创建?
- 客户停用为什么比物理删除更符合 Sakila 的数据关系?
- 仅在停用接口检查未归还租赁,为什么仍不能保证业务一致性?