12 Payment:金额、事务与一致性

本章把租赁与支付组合成一个完整业务动作,并训练 MySQL DECIMAL、事务回滚和重复提交。所有接口必须经过 authenticateAdmin

1. 表关系

customer 1 --- n payment
staff 1 --- n payment
rental 1 --- n payment

Sakila 的 payment.amountDECIMAL(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. 常见错误

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.9991e3、负数、超过上限;Payment 故意失败后的回滚;同一个支付请求重复提交。

14. 练习题

  1. 为什么 mysql2 常把 DECIMAL 返回为 string?
  2. 为什么 Number("0.1") + Number("0.2") 不适合财务累计?
  3. 托管事务为什么要求错误继续抛出?
  4. 怎样验证 Rental 与 Payment 确实一起回滚?
  5. 一条租赁是否允许多次支付,应由哪一层决定?
  6. 如何为支付创建接口增加幂等能力?
  7. SQL 聚合无匹配行时可能返回什么?
  8. 为什么支付列表要使用第二排序字段?