12 Payment:金额、事务与一致性
本章把租赁与支付组合成一个完整业务动作,并训练 MySQL DECIMAL、事务回滚和重复提交。所有接口必须经过 authenticateAdmin。
1. 表关系
customer 1 --- n payment
staff 1 --- n payment
rental 1 --- n payment
Sakila 的 payment.amount 是 DECIMAL(5,2)。它适合保存十进制定点金额,但 mysql2/Sequelize 常把 DECIMAL 作为 string 返回,以避免 JavaScript 浮点数丢失精度。
本章约定:
type MoneyString = string;
API 的金额输入和输出都使用规范的小数字符串,例如 "4.99"。不要在接口内部随意转成 number 后再相加。
2. 接口清单
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/sakila/rentals/checkout |
在一个事务中创建租赁和支付 |
| POST | /api/sakila/payments |
为已有租赁创建支付 |
| GET | /api/sakila/payments |
支付分页列表 |
| GET | /api/sakila/customers/:customerId/payment-summary |
客户消费汇总 |
const router = Router();
router.use(authenticateAdmin);
router.post(
"/rentals/checkout",
checkoutValidation,
validateRequest,
checkoutController,
);
router.post("/payments", createPaymentValidation, validateRequest, createPaymentController);
router.get("/payments", paymentListValidation, validateRequest, listPaymentsController);
router.get(
"/customers/:customerId/payment-summary",
paymentSummaryValidation,
validateRequest,
getPaymentSummaryController,
);
3. 类型契约
export interface CheckoutBody {
customerId: number;
inventoryId: number;
staffId: number;
amount: string;
}
export interface PaymentDto {
paymentId: number;
customerId: number;
staffId: number;
rentalId: number | null;
amount: string;
paymentDate: string;
}
export interface CheckoutResultDto {
rental: RentalDto;
payment: PaymentDto;
}
export interface CreatePaymentBody {
customerId: number;
staffId: number;
rentalId: number;
amount: string;
}
export interface PaymentListQuery {
page?: string;
pageSize?: string;
customerId?: string;
staffId?: string;
storeId?: string;
minAmount?: string;
maxAmount?: string;
startDate?: string;
endDate?: string;
}
export interface CustomerPaymentSummaryDto {
customerId: number;
paymentCount: number;
totalAmount: string;
averageAmount: string;
firstPaymentAt: string | null;
lastPaymentAt: string | null;
}
4. 金额校验边界
Controller 收到的金额应先以 string 校验:
允许:"0.99"、"10.00"、"999.99"(还需符合数据库上限)
拒绝:"1e3"、"NaN"、"Infinity"、"1.999"、负数、空字符串
不要这样校验:
if (Number(amount) > 0) {
// 这会接受指数形式,也提前进入浮点数语义。
}
可以使用 express-validator 的十进制校验并结合自定义规则,但需要明确小数位和最大值。数据库约束仍是最后防线。
5. 租赁并支付事务
业务目标:只有租赁和支付都成功,整个 checkout 才成功。
锁定库存
-> 验证客户、员工和门店
-> 确认库存可用
-> 创建 Rental
-> 创建 Payment
-> 提交事务
Service 骨架:
export async function checkoutRental(
input: CheckoutBody,
operatorAdminId: string,
): Promise<CheckoutResultDto> {
return sequelize.transaction(async (transaction) => {
// TODO 1:锁定 inventory,并验证它当前可出租。
// TODO 2:验证 active customer、staff 和门店关系。
// TODO 3:创建 Rental,传入 transaction。
// TODO 4:创建 Payment,关联刚创建的 rentalId,也传入 transaction。
// TODO 5:组合 DTO。
// TODO 6:考虑审计日志何时写入。
throw new Error("TODO");
});
}
托管事务的特点:回调正常返回时提交,回调抛错时回滚。不要在 catch 中吞掉错误后返回一个失败对象,否则 Sequelize 可能认为回调成功并提交事务。
错误示意:
return sequelize.transaction(async (transaction) => {
try {
// 创建 rental 和 payment
} catch (error) {
// 错误:吞掉异常并正常 return,可能使事务提交。
return {} as CheckoutResultDto;
}
});
6. 如何验证回滚
练习时可以在 Rental 创建后、Payment 创建前显式抛出一个仅供测试的错误,或使用确定会失败的数据库约束/测试替身。不要把“超过 DECIMAL(5,2) 范围”当作可靠故障注入:不同 MySQL sql_mode 下可能报错,也可能截断并产生警告。然后确认:
payment 没有新增
rental 也没有新增
inventory 仍然可出租
仅看到接口返回 INTERNAL_SERVER_ERROR 不代表回滚成功,必须查询数据库验证。
7. 为已有租赁创建支付
export async function createPayment(
input: CreatePaymentBody,
operatorAdminId: string,
): Promise<PaymentDto> {
return sequelize.transaction(async (transaction) => {
// TODO 1:使用同一个 transaction 查询 Rental,并核对 customerId。
// TODO 2:使用同一个 transaction 查询 Staff,并核对门店或其他业务规则。
// TODO 3:创建 Payment,显式传入该 transaction。
// TODO 4:要求原子性的审计信息也传入同一 transaction。
throw new Error("TODO");
});
}
需要先作出业务决定:一条租赁允许多次支付,还是本练习只允许一次?Sakila 结构上允许一对多;如果业务要求一次,就不能只靠应用层 findOne(),还应思考数据库唯一约束或幂等键。
可选进阶:请求携带 Idempotency-Key,服务端保存并识别重复提交。不要简单认为“POST 一定不能幂等”。
8. 支付列表
export interface FindPaymentsOptions {
page: number;
pageSize: number;
customerId?: number;
staffId?: number;
storeId?: number;
minAmount?: string;
maxAmount?: string;
startAt?: Date;
endAtExclusive?: Date;
}
export async function findPayments(
options: FindPaymentsOptions,
): Promise<PageResult<PaymentDto>> {
// TODO 1:动态构造 amount 和 paymentDate 条件。
// TODO 2:按需 include Staff 以筛选 storeId。
// TODO 3:findAndCountAll、稳定排序,并判断关联是否真的需要 distinct。
// TODO 4:保持 amount 为 string。
throw new Error("TODO");
}
这里按需 include Staff 属于 belongsTo,通常不会放大 Payment 主表行数,不必因为出现 include 就固定添加 distinct。只有 JOIN 使一条主记录对应多行时,才需要重点处理 count 重复;即使使用 distinct,也仍应检查分页 SQL 和结果是否符合预期。
日期采用左闭右开:
paymentDate >= startAt
paymentDate < endAtExclusive
默认排序建议:
paymentDate DESC, paymentId DESC
第二排序字段能让相同时间的记录稳定分页。
9. 客户支付汇总
export async function getCustomerPaymentSummary(
customerId: number,
): Promise<CustomerPaymentSummaryDto> {
// TODO 1:先确认 customer 存在。
// TODO 2:使用 count/sum/avg/min/max 聚合。
// TODO 3:无支付记录时,totalAmount 和 averageAmount 返回什么?
// TODO 4:规范化数据库聚合结果类型。
throw new Error("TODO");
}
建议契约:
{
"paymentCount": 0,
"totalAmount": "0.00",
"averageAmount": "0.00",
"firstPaymentAt": null,
"lastPaymentAt": null
}
SQL 的 SUM() 在没有匹配行时可能返回 null,不能直接假定为 "0.00"。
10. Controller 签名
export async function checkoutController(
req: Request<
Record<string, never>,
ApiResponse<CheckoutResultDto>,
CheckoutBody
>,
res: Response<ApiResponse<CheckoutResultDto>>,
): Promise<void> {
if (req.admin === undefined) {
throw new AppError("AUTH_REQUIRED", "请先登录");
}
// TODO:调用 checkoutRental(req.body, req.admin.id)。
// TODO:返回 code="OK"。
}
尽管 Router 已经过认证,TypeScript 中 req.admin 仍可能是可选类型。你可以在中间件类型设计中继续改进,但不要用不安全的 as any 掩盖边界。
11. 错误码建议
INVALID_AMOUNT
CUSTOMER_NOT_FOUND
CUSTOMER_INACTIVE
INVENTORY_NOT_FOUND
INVENTORY_NOT_AVAILABLE
STAFF_NOT_FOUND
STAFF_STORE_MISMATCH
RENTAL_NOT_FOUND
PAYMENT_ALREADY_EXISTS
未知数据库错误不应把 SQL、绝对路径或连接信息返回客户端。
12. 常见错误
parseFloat("4.99")后使用 number 做金额累计。- 把 Payment 放在事务外,导致 Rental 成功但 Payment 失败。
- 托管事务中 catch 后吞掉异常。
- 某一条 Model 查询忘记传
transaction。 - 为减少锁时间而提前提交,结果失去原子性。
- 重复 POST 创建多笔相同支付,没有幂等策略。
- 汇总接口把 null 原样返回,破坏前端契约。
- 所有 HTTP 状态都是 200,日志却只看 status,导致支付错误不可见。
13. curl 验收
TOKEN="替换为 accessToken"
curl -sS \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"customerId":1,"inventoryId":10,"staffId":1,"amount":"4.99"}' \
http://127.0.0.1:8080/api/sakila/rentals/checkout
curl -sS \
-H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8080/api/sakila/payments?page=1&pageSize=20&minAmount=1.00&maxAmount=9.99&startDate=2026-01-01&endDate=2026-01-31"
curl -sS \
-H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8080/api/sakila/customers/1/payment-summary
还应测试:金额 1.999、1e3、负数、超过上限;Payment 故意失败后的回滚;同一个支付请求重复提交。
14. 练习题
- 为什么 mysql2 常把 DECIMAL 返回为 string?
- 为什么
Number("0.1") + Number("0.2")不适合财务累计? - 托管事务为什么要求错误继续抛出?
- 怎样验证 Rental 与 Payment 确实一起回滚?
- 一条租赁是否允许多次支付,应由哪一层决定?
- 如何为支付创建接口增加幂等能力?
- SQL 聚合无匹配行时可能返回什么?
- 为什么支付列表要使用第二排序字段?