GitHub仓库 :github.com/2530622506/...
01|(前端转全栈)前端人第一次打开 Spring Boot 项目,应该先看哪里?
02|(前端转全栈)从 pnpm dev 到 Spring Boot 启动:后端服务到底怎么跑起来?
03|(前端转全栈)Axios 请求进了后端之后:Controller、Request、Response 是怎么接住它的?
04|(前端转全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
05|(前端转全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
06|(前端转全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路
07|(前端转全栈)为什么后端也要缓存?从前端缓存思维理解 Redis
08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON
09|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门
10|(前端转全栈)库存扣减为什么最容易出事故?SKU、并发与原子更新
11|(前端转全栈)购物车不能只存在前端:用户维度数据如何在后端落库
12|(前端转全栈)点击提交订单后,后端如何用事务守住价格、库存和订单?
面向读者:你已经理解第 12 篇中的订单创建、事务和库存锁定。现在订单已经是
PENDING_PAYMENT,库存已经从available_stock转入locked_stock。本篇继续回答:用户支付成功后,后端如何把订单变成已支付?支付平台为什么可能重复回调?如果用户一直不支付,后端如何超时关单并释放库存?取消、支付回调、超时关单同时发生时,系统如何保证最终只有一个赢家?重要说明:本文中的 Vue / TypeScript 片段都只是"前端侧示意代码",用于帮助前端同学类比理解后端接口,不代表当前仓库中存在真实前端源码。
1. 这篇解决什么问题
上一章讲到,点击"提交订单"后,后端会创建待支付订单,保存订单项快照,并把 SKU 的可售库存转为锁定库存。此时交易还没有结束,只是进入了一个中间状态:用户还没真正支付,库存也还没真正卖出。这个阶段很像前端页面里"正在支付中"的 loading 状态,但后端语义更复杂:订单不能永久停在待支付,支付结果可能来自外部平台回调,回调可能重复到达,用户也可能主动取消订单,定时任务还可能把超时订单关闭。
本项目把这一阶段拆成几个接口和后台任务:用户登录后可以调用 POST /api/orders/{orderId}/payments 创建支付流水,可以调用 GET /api/orders/{orderId}/payment 查询支付流水;模拟外部支付平台调用 POST /api/mock-payments/callback 通知支付成功;系统定时任务 OrderCloseScheduler 周期扫描超过 expires_at 的待支付订单,并调用关单事务释放库存。你会看到,这些入口虽然不同,但最终都围绕同一组核心资源:mall_order、mall_payment、mall_order_item、mall_product_sku。
本篇要解决 8 个问题:
- 为什么创建支付流水和创建订单是两个接口,而不是订单创建时直接支付;
mall_payment表保存什么,为什么一张订单只允许一条支付流水;- 支付回调为什么不使用用户 JWT,而使用
X-Mock-Payment-Secret; - 支付回调为什么必须幂等,重复回调为什么不能重复确认库存;
- 支付成功时
locked_stock如何真正变成已售库存; - 取消订单和超时关单如何把
locked_stock释放回available_stock; - 支付回调、取消订单、超时关单之间为什么会竞争,代码如何用锁和条件更新控制状态;
- 如何用 curl、数据库字段和测试验证支付链路是否正确。
你是前端转后端,所以本篇不会假设你已经熟悉第三方支付平台、分布式事务或消息队列。我们只基于当前工程已有代码,从最小闭环讲起:本地模拟支付成功回调、支付状态机、订单状态机、库存确认、超时关单。理解这个版本之后,以后你接入支付宝、微信支付、Stripe、PayPal 或公司内部支付平台,本质思路都是类似的。
2. 用前端知识类比
2.1 支付页不是交易终点,只是等待外部结果
前端同学做支付页时,通常会写出这样的交互:
ts
// 前端侧示意代码:只用于解释支付页交互,不代表仓库中存在真实前端源码。
async function pay(orderId: number) {
const payment = await request.post(`/api/orders/${orderId}/payments`)
openMockPayPage(payment.data.paymentNo)
startPollingPayment(orderId)
}
async function startPollingPayment(orderId: number) {
const timer = setInterval(async () => {
const res = await request.get(`/api/orders/${orderId}/payment`)
if (res.data.status === 'SUCCESS') {
clearInterval(timer)
router.push(`/orders/${orderId}?paid=1`)
}
if (res.data.status === 'CLOSED') {
clearInterval(timer)
showToast('支付已关闭,请重新下单')
}
}, 2000)
}
这段前端代码里,页面只是在等待状态变化。真正把支付状态从 PENDING 改为 SUCCESS 的,不是前端轮询,而是后端收到支付平台回调后的事务处理。前端轮询只是观察结果。这个区别很重要:前端不能自己说"我支付成功了",也不能直接把订单改成已支付。支付成功必须来自可信的服务端回调,且后端要校验回调来源、支付流水、订单金额和当前状态。
2.2 回调像前端事件,但不能只处理一次就算了
前端里有事件回调,例如按钮点击、WebSocket 消息、SDK 回调。你可能会写:
ts
// 前端侧示意代码:UI 事件回调通常只影响当前页面状态。
thirdPartySdk.on('paid', () => {
paymentStatus.value = 'SUCCESS'
})
后端支付回调看起来也像事件:支付平台告诉你某个支付单成功了。但后端必须考虑比前端更复杂的情况:同一个回调事件可能因为网络超时重复发送;第一次回调已经处理成功,但响应丢了,支付平台会再发一次;两个回调请求可能同时到达;回调到达时订单可能刚好被取消或被超时关单处理。后端不能用一个普通布尔变量 handled = true 解决,因为请求是跨进程、跨线程、跨时间的,真正的幂等状态必须落在数据库里。
本项目通过三个手段处理回调幂等:第一,PaymentCallbackTransactionService.handleSuccess 先用 selectByPaymentNoForUpdate 锁定支付流水行,让同一支付单的重复回调排队;第二,如果支付流水已经是 SUCCESS,直接返回原支付流水,不再次确认库存;第三,mall_payment.callback_id 有唯一索引 uk_mall_payment_callback_id,防止同一个回调事件 ID 被记录到不同支付流水上。
2.3 超时关单像前端定时器,但执行在服务端
前端页面也会写倒计时:
ts
// 前端侧示意代码:倒计时只是展示,不代表订单真的被关闭。
const remainSeconds = computed(() => expiresAt - Date.now())
if (remainSeconds.value <= 0) {
showToast('订单已超时')
}
但前端倒计时没有业务权威。用户关掉浏览器、换设备、断网,倒计时都可能不执行。真正的超时关单必须由后端定时任务完成。本项目的 OrderCloseScheduler 使用 @Scheduled 定时触发 orderCloseBatchService.closeExpiredOrders(),后台扫描已经超过 expires_at 且仍为 PENDING_PAYMENT 的订单,然后逐张订单用独立事务关闭并释放库存。
你可以把它类比成服务端版 cron job。前端负责显示剩余时间,后端负责在时间到了之后真正改变订单状态和库存状态。不要把"页面倒计时结束"误认为"订单已经关闭"。
3. 后端核心概念讲解
3.1 支付流水:订单和支付平台之间的桥
订单表示用户买了什么、多少钱、状态是什么;支付流水表示某次支付动作。为什么不直接在订单表里放支付平台交易号?因为支付本身也有状态、金额、创建时间、支付成功时间、关闭时间、回调事件 ID。把这些字段拆到 mall_payment,可以让订单状态机和支付状态机各自清晰。
本项目 mall_payment 表字段包括:payment_no、order_id、user_id、amount、status_code、provider_transaction_no、callback_id、created_at、updated_at、paid_at、closed_at。其中 payment_no 是本系统生成的支付流水号,用来给模拟支付平台定位;provider_transaction_no 是支付平台回传的交易号;callback_id 是支付平台本次回调事件的唯一 ID;amount 来自订单金额快照,不能由前端提交。
一张订单一条支付流水由唯一索引 uk_mall_payment_order_id (order_id) 保证。这样前端重复点击"去支付",后端也只会返回同一条支付流水,不会为同一个订单创建多条互相竞争的支付记录。
3.2 订单状态机和支付状态机
本项目订单状态在 OrderStatus 中,支付状态在 PaymentStatus 中。创建订单后,订单是 PENDING_PAYMENT。创建支付流水后,支付是 PENDING。之后有三种核心结局:
支付流水状态更简单:
两个状态机必须保持一致:订单 PAID 时支付流水应该是 SUCCESS;订单 CANCELLED 或 CLOSED 时,如果支付流水仍是 PENDING,应该关闭为 CLOSED;支付流水已经 SUCCESS 后,超时关单不能再把订单改成 CLOSED。本篇的大部分代码都在保护这个一致性。
3.3 回调鉴权:为什么不用用户 JWT
PaymentController 是用户接口,需要 JWT,因为用户创建和查询的是自己的支付流水。MockPaymentCallbackController 是模拟支付平台回调入口,不使用用户 JWT,而使用请求头 X-Mock-Payment-Secret。原因是回调请求不是用户浏览器发来的,而是支付平台服务器发来的。真实业务中通常会校验签名、公钥证书、时间戳、回调报文摘要等;本项目为了学习简化为共享密钥。
PaymentFacade.requireValidCallbackSecret 会把配置里的 mall.payment.mock-callback-secret 和请求头里的 secret 转成字节数组,并用 MessageDigest.isEqual 做常量时间比较。普通字符串比较可能因为提前结束而暴露匹配长度信息,虽然本地学习项目风险不大,但这种写法体现了安全习惯。密钥错误时返回 PAYMENT_CALLBACK_SECRET_INVALID,HTTP 状态是 401 Unauthorized。
3.4 悲观锁和条件更新:让竞争串行化
支付回调、用户取消、超时关单都可能修改同一张订单、同一条支付流水、同一批 SKU 库存。如果两个事务同时改,就会产生竞争。本项目使用两类机制:
SELECT ... FOR UPDATE悲观锁:先锁定关键行,让并发请求排队;- 条件
UPDATE:只有旧状态符合预期时才更新,影响行数为 1 才算成功。
例如 PaymentMapper.selectByPaymentNoForUpdate 会按支付单号锁定支付流水;OrderMapper.markPaid 只允许 PENDING_PAYMENT 更新为 PAID;PaymentMapper.markSuccess 只允许 PENDING 更新为 SUCCESS;OrderMapper.closeExpiredOrder 只允许已过期的 PENDING_PAYMENT 更新为 CLOSED;PaymentMapper.closePendingByOrderId 只关闭仍为 PENDING 的支付流水。
前端类比是:你会在异步请求回来后检查当前页面状态是否还是"可更新",避免旧请求覆盖新状态;后端则用数据库行锁和条件更新保证状态不会被并发请求覆盖。
3.5 锁定库存的三个终点
第 12 篇讲过,创建订单时库存从 available_stock 转到 locked_stock。之后有三条路径:

支付成功时,锁定库存真正被消费掉,所以只减少 locked_stock,不增加 available_stock。取消和超时关单时,交易没有完成,所以锁定库存释放回可售库存。这个三段式库存模型比"下单直接扣库存"更适合待支付场景。
4. 在本项目中对应哪些文件
| 层次 | 文件 | 作用 |
|---|---|---|
| 用户支付入口 | backend/service/src/main/java/com/example/fullstackmall/service/payment/PaymentController.java |
定义 POST /api/orders/{orderId}/payments 和 GET /api/orders/{orderId}/payment |
| 模拟回调入口 | MockPaymentCallbackController.java |
定义 POST /api/mock-payments/callback,使用 X-Mock-Payment-Secret |
| 支付契约 | backend/contract/src/main/java/com/example/fullstackmall/contract/payment/IPaymentFacade.java |
声明创建支付、查询支付、处理回调 |
| 回调 Request | MockPaymentCallbackRequest.java |
包含 paymentNo、callbackId、providerTransactionNo、result |
| 支付响应 | PaymentResponse.java |
返回支付流水号、订单 ID、金额、状态、交易号和时间字段 |
| 支付门面 | PaymentFacade.java |
处理当前用户、回调密钥、并发重复请求、Response 组装 |
| 创建支付事务 | PaymentCreateTransactionService.java |
锁定订单,校验订单待支付且未过期,创建唯一支付流水 |
| 回调事务 | PaymentCallbackTransactionService.java |
锁定支付流水,校验金额和状态,标记订单已支付,确认锁定库存 |
| 支付 DB | PaymentDbService.java、PaymentMapper.java |
封装支付流水查询、行锁、状态更新、关闭支付流水 |
| 取消事务 | OrderCancelTransactionService.java |
取消待支付订单,释放库存,关闭待支付流水 |
| 关单任务 | OrderCloseScheduler.java、OrderCloseBatchService.java、OrderCloseTransactionService.java |
定时扫描过期订单,逐单关闭并释放库存 |
| 库存 SQL | SkuMapper.java |
confirmLockedStock 和 releaseLockedStock |
| 表结构 | sql/01_schema.sql |
定义 mall_payment 及唯一索引 |
| 配置 | backend/service/src/main/resources/application.yml |
定义回调密钥、订单支付超时时间、关单扫描间隔和批大小 |
| 测试 | PaymentControllerTest.java、PaymentMySqlContainerTest.java、PaymentTransactionRollbackTest.java |
验证权限、幂等、回调、超时关单、并发竞争和回滚 |
读代码时可以按这张表走:先看用户接口,再看模拟回调接口,再看 Facade,再看事务服务,再看 Mapper 的 SQL,最后看测试如何证明这些规则。
5. Mermaid 图:支付与关单完整链路
5.1 创建支付流水
5.2 支付成功回调
5.3 超时关单
5.4 回调和关单竞争

5.5 支付流水表和订单表关系
6. 逐段读源码
6.1 PaymentController:用户创建和查询支付流水
PaymentController 位于 backend/service/src/main/java/com/example/fullstackmall/service/payment/PaymentController.java。类级路径是 @RequestMapping("/api/orders/{orderId}"),创建支付路径是 POST /api/orders/{orderId}/payments,查询支付路径是 GET /api/orders/{orderId}/payment。
创建方法如下:
java
@PostMapping("/payments")
@Operation(summary = "为我的待支付订单创建支付流水")
public ResponseEntity<ApiResponse<PaymentResponse>> createPayment(
@PathVariable Long orderId,
HttpServletRequest request
) {
PaymentResponse response = paymentFacade.createMyPayment(orderId);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(request)));
}
这里没有 @RequestBody,因为创建支付流水不需要前端提交金额。金额来自订单快照。orderId 来自路径,但后端仍会校验该订单是否属于当前登录用户。成功返回 201 Created,说明这也是一次资源创建。
查询方法返回 ApiResponse<PaymentResponse>。如果当前用户没有这张订单的支付流水,会返回 PAYMENT_NOT_FOUND 或在创建阶段返回 ORDER_NOT_FOUND。前端不能靠隐藏按钮保护权限,后端必须用 orderId + userId 约束。
6.2 MockPaymentCallbackController:模拟外部平台回调
模拟回调入口位于 MockPaymentCallbackController:
java
@PostMapping("/callback")
@Operation(summary = "接收本地模拟支付成功回调")
public ApiResponse<PaymentResponse> callback(
@RequestHeader(value = "X-Mock-Payment-Secret", required = false) String callbackSecret,
@Valid @RequestBody MockPaymentCallbackRequest callbackRequest,
HttpServletRequest request
) {
return ApiResponse.success(
paymentFacade.handleMockCallback(callbackSecret, callbackRequest),
TraceIdContext.get(request)
);
}
它不读取用户 JWT,因为这不是用户浏览器发起的请求。它读取 X-Mock-Payment-Secret,并接收 MockPaymentCallbackRequest。请求体里有 paymentNo、callbackId、providerTransactionNo、result。阶段 8 只支持 PaymentCallbackResult.SUCCESS,也就是只模拟支付成功,不模拟支付失败、退款等复杂场景。
6.3 PaymentFacade.createMyPayment:重复创建支付返回同一条
PaymentFacade.createMyPayment 的第一步是拿当前用户:
java
Long userId = currentUserService.requireCurrentUserId();
PaymentEntity existing = paymentDbService.getByOrderIdAndUserId(orderId, userId);
if (existing != null) {
return toResponse(existing);
}
如果同一个用户对同一个订单重复调用创建支付接口,直接返回已有支付流水。这对前端非常友好:用户刷新支付页、重复点击"去支付"、网络超时重试,都不会产生多条支付流水。
如果还不存在,就调用事务服务:
java
try {
Long paymentId = paymentCreateTransactionService.createPayment(orderId, userId);
return toResponse(paymentDbService.getById(paymentId));
} catch (DataIntegrityViolationException exception) {
PaymentEntity concurrent = paymentDbService.getByOrderIdAndUserId(orderId, userId);
if (concurrent != null) {
return toResponse(concurrent);
}
throw exception;
}
这里和订单幂等很像:业务层先查一次,数据库唯一索引 uk_mall_payment_order_id 做并发兜底。两个请求同时创建支付流水时,最多一个插入成功,另一个捕获唯一约束异常后读取已有流水返回。
6.4 PaymentCreateTransactionService:锁订单后创建支付
创建支付流水的事务方法是:
java
@Transactional(rollbackFor = Exception.class)
public Long createPayment(Long orderId, Long userId) {
OrderEntity order = orderDbService.selectByIdForUpdate(orderId);
if (order == null || !userId.equals(order.getUserId())) {
throw new BusinessException(ApiCode.ORDER_NOT_FOUND,
ApiCode.ORDER_NOT_FOUND.defaultMessage(), HttpStatus.NOT_FOUND);
}
if (!OrderStatus.PENDING_PAYMENT.name().equals(order.getStatusCode())) {
throw conflict(ApiCode.PAYMENT_ORDER_NOT_PAYABLE);
}
if (order.getExpiresAt() != null && !order.getExpiresAt().isAfter(LocalDateTime.now())) {
throw conflict(ApiCode.PAYMENT_ORDER_NOT_PAYABLE);
}
...
}
为什么要 selectByIdForUpdate 锁订单?因为创建支付、取消订单、超时关单都可能围绕同一张订单并发执行。锁住订单后,当前事务可以稳定判断订单是否还待支付、是否已过期。只有订单属于当前用户、状态为 PENDING_PAYMENT、且未超过 expires_at,才能创建支付流水。
然后代码再次检查是否已有支付流水:
java
PaymentEntity existing = paymentDbService.getByOrderId(orderId);
if (existing != null) {
return existing.getId();
}
这是事务内的顺序幂等。最后创建 PaymentEntity,设置 paymentNo、orderId、userId、amount、statusCode=PENDING、创建时间和更新时间。amount 来自 order.getTotalAmount(),不是前端提交。
6.5 PaymentFacade.handleMockCallback:回调入口外层控制
回调处理先校验密钥:
java
requireValidCallbackSecret(callbackSecret);
密钥通过后,调用事务服务:
java
try {
Long paymentId = paymentCallbackTransactionService.handleSuccess(request);
return toResponse(paymentDbService.getById(paymentId));
} catch (DataIntegrityViolationException exception) {
PaymentEntity callbackPayment = paymentDbService.getByCallbackId(request.getCallbackId());
if (callbackPayment != null && request.getPaymentNo().equals(callbackPayment.getPaymentNo())) {
return toResponse(callbackPayment);
}
throw new BusinessException(ApiCode.PAYMENT_CALLBACK_DUPLICATE,
ApiCode.PAYMENT_CALLBACK_DUPLICATE.defaultMessage(), HttpStatus.CONFLICT);
}
这里处理的是 callback_id 唯一约束冲突。同一个回调事件重复到达同一个支付流水,可以返回已有结果;但如果同一个 callbackId 被拿去绑定另一个 paymentNo,就是高风险异常,返回 PAYMENT_CALLBACK_DUPLICATE。
6.6 PaymentCallbackTransactionService:支付成功的事务边界
回调事务是本篇最重要的代码。第一步按 paymentNo 锁定支付流水:
java
PaymentEntity payment = paymentDbService.selectByPaymentNoForUpdate(request.getPaymentNo());
if (payment == null) {
throw new BusinessException(ApiCode.PAYMENT_NOT_FOUND,
ApiCode.PAYMENT_NOT_FOUND.defaultMessage(), HttpStatus.NOT_FOUND);
}
如果已经成功,直接返回:
java
if (PaymentStatus.SUCCESS.name().equals(payment.getStatusCode())) {
return payment.getId();
}
if (!PaymentStatus.PENDING.name().equals(payment.getStatusCode())) {
throw conflict(ApiCode.PAYMENT_STATUS_CONFLICT);
}
这就是回调幂等的核心:重复成功回调不再重复确认库存;已经关闭的支付流水不能再改成功。然后读取订单,校验金额和订单状态:
java
OrderEntity order = orderDbService.getById(payment.getOrderId());
if (payment.getAmount().compareTo(order.getTotalAmount()) != 0) {
throw conflict(ApiCode.PAYMENT_AMOUNT_MISMATCH);
}
if (!OrderStatus.PENDING_PAYMENT.name().equals(order.getStatusCode())) {
throw conflict(ApiCode.PAYMENT_STATUS_CONFLICT);
}
金额校验很关键。支付流水金额来自订单金额快照,订单金额也是后端在创建订单时计算的。如果两者不一致,说明数据异常,不能继续把订单标记为已支付。
6.7 标记订单已支付和支付流水成功
回调事务随后做两个条件更新:
java
if (orderDbService.markPaid(order.getId()) != 1) {
throw conflict(ApiCode.PAYMENT_STATUS_CONFLICT);
}
if (paymentDbService.markSuccess(payment.getId(),
request.getProviderTransactionNo(), request.getCallbackId()) != 1) {
throw conflict(ApiCode.PAYMENT_STATUS_CONFLICT);
}
markPaid 只允许订单从 PENDING_PAYMENT 变为 PAID。markSuccess 只允许支付流水从 PENDING 变为 SUCCESS,并写入支付平台交易号、回调 ID、支付成功时间。影响行数不是 1,就表示状态已经被其他事务抢先修改,例如超时关单或取消订单已经完成。此时必须抛异常,让当前事务回滚。
6.8 支付成功后确认锁定库存
最后一步确认锁定库存:
java
List<OrderItemEntity> items = orderItemDbService.listByOrderId(order.getId())
.stream().sorted(Comparator.comparing(OrderItemEntity::getSkuId)).toList();
if (items.isEmpty()) {
throw conflict(ApiCode.PAYMENT_STOCK_CONFIRM_FAILED);
}
for (OrderItemEntity item : items) {
if (skuDbService.confirmLockedStock(item.getSkuId(), item.getQuantity()) != 1) {
throw conflict(ApiCode.PAYMENT_STOCK_CONFIRM_FAILED);
}
}
confirmLockedStock 底层 SQL 是:
sql
UPDATE mall_product_sku
SET locked_stock = locked_stock - #{quantity},
version = version + 1,
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{skuId}
AND locked_stock >= #{quantity}
支付成功后,锁定库存被真正消费,所以 locked_stock 减少,但 available_stock 不增加。若确认库存失败,抛出 PAYMENT_STOCK_CONFIRM_FAILED。因为整个方法有 @Transactional,订单从 PENDING_PAYMENT 改 PAID、支付从 PENDING 改 SUCCESS、库存确认这些动作会一起回滚,不能留下"订单已支付但库存没确认"的半成功状态。
6.9 OrderCancelTransactionService:用户主动取消
用户取消待支付订单时,会进入 OrderCancelTransactionService.cancelOrder。它先确认订单属于当前用户,然后锁定支付行,再把订单从 PENDING_PAYMENT 更新为 CANCELLED。随后遍历订单项调用 releaseLockedStock 释放库存,最后关闭仍为 PENDING 的支付流水。
这里释放库存的 SQL 和支付成功不同:取消没有卖出商品,所以要 available_stock + quantity、locked_stock - quantity。如果任何一条 SKU 释放失败,抛 ORDER_STOCK_RELEASE_FAILED,整个取消事务回滚。
6.10 OrderCloseScheduler:后台超时关单
OrderCloseScheduler 使用 @Scheduled:
java
@Scheduled(
fixedDelayString = "${mall.order.close-scan-fixed-delay-ms:60000}",
initialDelayString = "${mall.order.close-scan-fixed-delay-ms:60000}"
)
public void closeExpiredOrders() {
orderCloseBatchService.closeExpiredOrders();
}
fixedDelay 表示上一次扫描结束后再等待指定时间,避免扫描任务无间隔重叠。默认扫描间隔来自配置 mall.order.close-scan-fixed-delay-ms,默认 60000 毫秒。扫描批大小来自 mall.order.close-scan-batch-size,默认 100。支付超时时间来自 mall.order.payment-timeout-minutes,默认 15 分钟。
6.11 OrderCloseBatchService:批量扫描但不包大事务
批量服务先查询一批过期订单 ID:
java
List<Long> orderIds = orderDbService.listExpiredPendingOrderIds(now, batchSize);
然后逐个调用 OrderCloseTransactionService.closeExpiredOrder(orderId, now)。注意注释:批次本身不包大事务,每个订单调用独立 Bean 的事务方法,单笔失败不会回滚整批。这是后端批处理非常重要的设计。假设一次扫描 100 张订单,如果放在一个大事务里,一张订单释放库存失败,其他 99 张也全部回滚,锁持有时间还很长。逐单事务能缩短锁时间,也能让失败隔离。
6.12 OrderCloseTransactionService:关闭过期订单并释放库存
关单事务逻辑如下:
java
paymentDbService.selectByOrderIdForUpdate(orderId);
if (orderDbService.closeExpiredOrder(orderId, now) != 1) {
return false;
}
List<OrderItemEntity> items = orderItemDbService.listByOrderId(orderId)
.stream().sorted(Comparator.comparing(OrderItemEntity::getSkuId)).toList();
...
paymentDbService.closePendingByOrderId(orderId);
return true;
先锁支付行,是为了和回调、取消保持统一锁顺序,降低死锁风险。closeExpiredOrder 的 SQL 只允许 status_code='PENDING_PAYMENT' 且 expires_at <= now 的订单变成 CLOSED。如果影响行数是 0,说明订单可能已经支付、取消、关闭,或者还没过期。这不是系统异常,返回 false 即可。
如果关闭成功,就释放库存,并关闭仍为 PENDING 的支付流水。若库存释放失败,抛 ORDER_CLOSE_STOCK_RELEASE_FAILED,事务回滚,订单和支付流水都不应该留下关闭状态。
7. 本地运行 / curl 验证
7.1 启动项目
先启动 MySQL:
bash
docker compose -f docker-compose.dev.yml up -d mysql
进入 backend/ 目录启动服务:
bash
cd backend
mvn -pl service -am spring-boot:run
如果你使用本地 Maven 仓库缓存,可以按自己的环境增加 -Dmaven.repo.local=../.m2-repository。
7.2 登录、创建订单、创建支付流水
登录小明:
bash
curl -sS -X POST 'http://localhost:8080/api/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"xiaoming","password":"Mall123456"}'
保存 token:
bash
TOKEN='把登录响应里的 token 放这里'
创建订单:
bash
curl -sS -X POST 'http://localhost:8080/api/orders' \
-H "Authorization: Bearer $TOKEN" \
-H 'Idempotency-Key: blog-payment-order-001'
保存订单 ID:
bash
ORDER_ID='把订单 id 放这里'
创建支付流水:
bash
curl -sS -X POST "http://localhost:8080/api/orders/$ORDER_ID/payments" \
-H "Authorization: Bearer $TOKEN"
保存响应里的 paymentNo:
bash
PAYMENT_NO='把 paymentNo 放这里'
7.3 查询支付流水
bash
curl -sS "http://localhost:8080/api/orders/$ORDER_ID/payment" \
-H "Authorization: Bearer $TOKEN"
期望看到 status 为 PENDING,amount 等于订单金额 9198.00。
7.4 模拟支付成功回调
bash
curl -sS -X POST 'http://localhost:8080/api/mock-payments/callback' \
-H 'Content-Type: application/json' \
-H 'X-Mock-Payment-Secret: local-mock-payment-secret' \
-d "{
\"paymentNo\": \"$PAYMENT_NO\",
\"callbackId\": \"callback-blog-001\",
\"providerTransactionNo\": \"provider-blog-001\",
\"result\": \"SUCCESS\"
}"
期望返回支付流水 status=SUCCESS,并包含 providerTransactionNo、paidAt。随后查询订单,应看到订单状态为 PAID。数据库中 SKU 1 的 locked_stock 应减少到 0,available_stock 保持订单创建后的数值,因为商品已经售出,不应释放回可售库存。
7.5 重复回调验证幂等
再次发送同一个回调:
bash
curl -sS -X POST 'http://localhost:8080/api/mock-payments/callback' \
-H 'Content-Type: application/json' \
-H 'X-Mock-Payment-Secret: local-mock-payment-secret' \
-d "{
\"paymentNo\": \"$PAYMENT_NO\",
\"callbackId\": \"callback-blog-001\",
\"providerTransactionNo\": \"provider-blog-001\",
\"result\": \"SUCCESS\"
}"
期望仍返回同一条成功支付流水,不应该重复确认库存。这个实验能帮助你理解"回调幂等"不是理论,而是可以通过重复请求直接验证的行为。
7.6 密钥错误验证
bash
curl -i -X POST 'http://localhost:8080/api/mock-payments/callback' \
-H 'Content-Type: application/json' \
-H 'X-Mock-Payment-Secret: wrong-secret' \
-d "{
\"paymentNo\": \"$PAYMENT_NO\",
\"callbackId\": \"callback-blog-bad\",
\"providerTransactionNo\": \"provider-blog-bad\",
\"result\": \"SUCCESS\"
}"
期望返回 PAYMENT_CALLBACK_SECRET_INVALID,HTTP 状态为 401。真实支付平台会使用更强的签名校验,本项目用共享密钥模拟这个安全入口。
8. 常见错误
8.1 让前端直接把订单改成已支付
前端可以展示"支付成功"页面,但不能决定订单是否已支付。订单状态必须由后端根据支付平台可信回调修改。否则用户可以绕过支付直接调用接口把订单改成已支付。
8.2 支付回调用用户 JWT
支付平台回调不是用户浏览器请求,通常不会携带用户 JWT。真实系统要校验平台签名,本项目使用 X-Mock-Payment-Secret。如果你把回调做成必须用户登录,支付平台就无法调用;如果完全不鉴权,任何人都能伪造支付成功。
8.3 重复回调重复扣库存
支付平台重复通知是常态,不是异常。后端必须让重复成功回调返回已有结果,而不是再次执行 confirmLockedStock。本项目通过支付行锁、状态判断和 callback_id 唯一索引共同保证。
8.4 支付成功后释放库存
支付成功不是释放库存,而是确认售出。释放库存发生在取消订单或超时关单。支付成功应该减少 locked_stock,不增加 available_stock。如果把支付成功写成释放库存,会导致已售商品重新变成可卖。
8.5 超时关单覆盖已支付订单
关单 SQL 必须带旧状态条件:只有 PENDING_PAYMENT 且已过期才能变成 CLOSED。如果不检查旧状态,支付成功后的订单可能被定时任务错误关闭。本项目的 OrderMapper.closeExpiredOrder 用条件更新防止这种覆盖。
8.6 批量关单放进一个大事务
一次扫描 100 张过期订单,如果放在一个大事务里,锁持有时间长,一张失败会影响整批。正确做法是批量扫描 ID,然后每张订单独立事务关闭。本项目的 OrderCloseBatchService 就是这样设计的。
8.7 忽略取消、回调、关单的竞争
真实用户行为不会按你想象的顺序发生。用户可能刚点击取消,支付平台回调也到了;定时任务可能刚要关单,支付回调也到了。后端必须用行锁、统一锁顺序和条件更新让最终状态一致,而不是相信"这种情况应该很少"。
8.8 查询支付流水不带 userId
支付流水也是用户私有资源。查询接口必须通过 orderId + userId 限制,否则用户可能猜订单 ID 查询别人的支付信息。本项目 PaymentDbService.getByOrderIdAndUserId 就是在保护这个边界。
8.9 用户支付接口和平台回调接口不是同一种权限模型
前端同学最容易把"创建支付"和"支付回调"想成同一个东西:反正都是 HTTP POST,带一个 token 不就行了吗?后端一定要把这两个入口分开看。POST /api/orders/{orderId}/payments 和 GET /api/orders/{orderId}/payment 是用户接口,调用者是登录用户本人,所以它们必须经过 JWT。JWT 解决的问题是"这个用户是谁",再配合 orderId + userId 查询,保证用户只能创建或查看自己的支付流水。换成前端类比,这相当于一个订单详情页只能读取当前登录账号的订单状态,不能只凭 URL 上的 id 就信任请求。
POST /api/mock-payments/callback 的调用者不是用户浏览器,而是支付平台。支付平台不会拿某个用户的 JWT,因为它代表的是外部系统,不代表某个登录用户。真实项目里通常会使用平台证书、签名串、时间戳、防重放参数和回调 IP 白名单。本项目为了学习,把它简化成 X-Mock-Payment-Secret 共享密钥,并且在 PaymentFacade.requireValidCallbackSecret 中用 MessageDigest.isEqual 做恒定时间比较。这个设计要表达的思想是:用户入口验证用户身份,平台回调验证平台身份,二者不能混用。
如果让前端直接调用回调接口,就等于把"宣布支付成功"的权力交给浏览器,这是严重安全漏洞。前端只能发起创建支付、展示支付页、轮询查询支付状态;订单最终是否支付成功,必须由后端根据可信回调改变状态。
8.10 前端轮询只是观察,后端回调才是事实来源
支付页通常会有一个倒计时和一个"支付中"的状态。前端可以每隔几秒请求 GET /api/orders/{orderId}/payment,根据后端返回的支付流水状态更新页面。但请记住:轮询只是观察后端状态,不负责改变后端状态。用户关闭浏览器、切换网络、刷新页面,都不应该影响支付平台回调和超时关单。后端状态机必须在没有前端页面在线的情况下仍然能完成最终流转。
前端侧示意代码如下,注意它只读状态,不写状态:
ts
// 前端侧示意代码:轮询只是观察后端状态,不负责把订单改成已支付或已关闭。
async function pollPayment(orderId: number) {
const res = await request.get(`/api/orders/${orderId}/payment`)
const status = res.data.data.status
if (status === 'SUCCESS') {
stopPolling()
router.push(`/orders/${orderId}/success`)
return
}
if (status === 'CLOSED') {
stopPolling()
showToast('支付已关闭,请重新下单')
}
}
这个分工很像前端的 server state 管理:页面状态可以缓存、刷新、重试,但真正的事实来源仍然是服务端 API。区别在于支付场景更严格,后端还有外部平台回调和定时任务两个"页面看不见的写入口"。所以前端倒计时到 0 只能提示"可能已超时",不能代替 OrderCloseScheduler 真正关闭订单;前端看到成功页也只是展示结果,不能代替 PaymentCallbackTransactionService 标记订单已支付。
8.11 为什么创建支付流水也要幂等
很多人只知道支付回调要幂等,却忽略创建支付流水也要幂等。真实页面里,用户可能连续点击"去支付",浏览器可能因为网络慢重试请求,前端路由刷新也可能重新初始化支付页。如果每次 POST /api/orders/{orderId}/payments 都插入一条新流水,就会出现一张订单对应多个支付单号,后续回调、查询和关单都会变复杂。
本项目用两层保护解决这个问题。第一层是在业务代码中先按 orderId + userId 查已有流水,存在就直接返回。第二层是在数据库里给 mall_payment.order_id 建唯一索引 uk_mall_payment_order_id,即使两个请求并发通过了"先查不存在"的窗口,也只有一个插入能成功。PaymentFacade.createMyPayment 捕获 DataIntegrityViolationException 后会再查询已有流水并返回。前端可以把它理解成"按钮防重复点击"的后端版本,但后端不能只相信按钮禁用,必须在数据库层拥有最后防线。
9. 本章小练习
练习 1:画出支付成功链路
从 POST /api/mock-payments/callback 开始,写出经过的类和方法:MockPaymentCallbackController.callback、PaymentFacade.handleMockCallback、PaymentCallbackTransactionService.handleSuccess、PaymentDbService.selectByPaymentNoForUpdate、OrderDbService.markPaid、PaymentDbService.markSuccess、SkuDbService.confirmLockedStock。
练习 2:解释为什么回调不用 JWT
用自己的话说明:用户创建支付流水为什么需要 JWT?支付平台回调为什么不使用 JWT?本项目用哪个请求头模拟支付平台鉴权?
练习 3:读懂 mall_payment 唯一索引
找到 sql/01_schema.sql 中 uk_mall_payment_order_id 和 uk_mall_payment_callback_id。解释它们分别防止什么问题。再思考:为什么 callback_id 可以为空,但一旦有值就应该唯一?
练习 4:解释支付成功时库存变化
假设订单创建后 SKU 1 的 available_stock=8、locked_stock=2。支付成功后应该变成多少?取消订单后又应该变成多少?请分别写出原因。
练习 5:阅读测试当业务文档
把 PaymentControllerTest 中这些测试名翻译成中文业务规则:createsQueriesAndRetriesSamePayment、callbackRequiresSecretAndIsIdempotent、cancelledOrderRejectsLateCallbackAndKeepsReleasedStock、closesExpiredOrderAndReleasesStock、paidOrderIsNotClosedByBatch。
练习 6:分析回滚测试
阅读 PaymentTransactionRollbackTest.callbackRollsBackOrderAndPaymentWhenStockConfirmationFails。为什么它把 locked_stock 改成 0 后,回调应该失败?失败后订单和支付流水为什么仍然保持待支付 / 待处理?
练习 7:设计前端轮询停止条件
写一段前端侧示意代码,轮询 GET /api/orders/{orderId}/payment,当状态为 SUCCESS 时跳转成功页,当状态为 CLOSED 时停止轮询并提示订单已关闭。注意标注"前端侧示意代码"。
练习 8:解释三个最终状态
用三句话解释:PAID、CANCELLED、CLOSED 分别由什么触发?它们对支付流水和库存分别有什么影响?
10. 再深入一点:一致性不是靠一个 if 判断实现的
很多前端转后端同学刚开始写业务状态流转时,会习惯在代码里写几个 if:如果订单是待支付,就改成已支付;如果支付是待处理,就改成成功。单线程演示时这看起来没问题,但后端服务是多线程处理请求的。两个请求可能同时读到同一个旧状态,然后分别写入不同新状态。只靠 Java 里的 if,不能保证数据库最终一致。
本项目的做法是把判断推进到数据库层。比如 markPaid 不是先查订单再无条件更新,而是:
sql
UPDATE mall_order
SET status_code = 'PAID',
paid_at = CURRENT_TIMESTAMP(3),
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{orderId}
AND status_code = 'PENDING_PAYMENT'
status_code = 'PENDING_PAYMENT' 是关键。它让"检查旧状态"和"修改新状态"合并成一个原子数据库动作。更新后影响行数是 1,说明你赢得了状态流转;影响行数是 0,说明状态已经被其他事务改变,你不能继续执行后续库存操作。
这就是后端并发业务的基本思路:Java 判断用于可读性和提前失败,数据库条件更新用于最终保证。行锁用于让高冲突资源串行化,唯一索引用于让重复请求有唯一胜者,事务用于让多张表一起成功或回滚。支付链路把这些手段都串起来了。
11. 排查支付和关单问题的顺序
支付问题常见现象包括:创建支付失败、重复创建多条支付、回调 401、回调后订单没变已支付、库存没有变化、订单超时后没关闭、已支付订单被错误关闭。建议按下面顺序排查:
数据库字段优先看这些:mall_order.status_code、mall_order.expires_at、mall_payment.status_code、mall_payment.callback_id、mall_payment.provider_transaction_no、mall_product_sku.available_stock、mall_product_sku.locked_stock。如果错误码是 PAYMENT_CALLBACK_SECRET_INVALID,先看请求头;如果是 PAYMENT_STATUS_CONFLICT,看订单和支付流水是否已经被其他流程处理;如果是 PAYMENT_STOCK_CONFIRM_FAILED 或 ORDER_CLOSE_STOCK_RELEASE_FAILED,看锁定库存是否足够。
测试也是排查入口。PaymentMySqlContainerTest.concurrentSuccessCallbacksConfirmStockOnlyOnce 验证重复成功回调只确认一次库存;callbackAndTimeoutCloseHaveOneConsistentWinner 验证支付回调和超时关单并发时最终只能得到一致状态;PaymentTransactionRollbackTest 验证库存确认或释放失败时订单和支付流水一起回滚。这些测试覆盖的是生产中最容易出事故的边界。
12. 下一章预告:Redis Cache Aside 商品详情实战
到这里,商品、SKU、购物车、订单、支付的主交易链路已经基本串起来了。下一篇我们会回到读多写少的商品详情场景,深入讲 Redis Cache Aside 商品详情实战。你会看到商品详情缓存 key 如何设计,缓存命中和未命中如何处理,为什么要缓存空值,Redis 异常时为什么要降级查 MySQL,管理端修改商品后为什么要删除缓存,以及如何用 curl 和日志观察缓存命中。
这对前端同学也很重要。前端常见缓存有 memory cache、HTTP cache、localStorage、SWR;后端 Redis 缓存和它们思想相似,但多了跨用户共享、TTL、缓存穿透、缓存一致性和服务降级等问题。下一篇会用当前工程的 ProductDetailCacheService 和 ProductFacade 继续讲。
13. 本篇总结
本篇你需要带走 13 个结论:
- 订单创建后只是待支付,库存处于锁定状态,交易还没最终完成;
- 支付流水是订单和支付平台之间的桥,一张订单在本阶段只有一条支付流水;
- 创建支付和查询支付是用户接口,需要 JWT,并且必须按
orderId + userId隔离; - 支付回调不是用户请求,不使用用户 JWT,本项目用
X-Mock-Payment-Secret模拟平台鉴权; - 支付回调必须幂等,因为支付平台可能重复通知;
- 重复成功回调应该返回已有成功结果,不能重复确认库存;
callback_id唯一索引用于防止同一个回调事件被串用到不同支付流水;- 支付成功时订单从
PENDING_PAYMENT变为PAID,支付流水从PENDING变为SUCCESS; - 支付成功会调用
confirmLockedStock,只减少locked_stock,不增加available_stock; - 用户取消和超时关单会调用
releaseLockedStock,把锁定库存释放回可售库存; - 超时关单由后端定时任务执行,前端倒计时只负责展示;
- 批量关单不应该放在一个大事务里,每张订单独立事务更安全;
- 支付回调、取消、关单之间会竞争,后端用行锁、条件更新、唯一索引和事务保证最终一致。
如果你能解释"支付成功为什么不是释放库存,而是确认锁定库存",并能说清楚"重复回调为什么不会重复扣库存",说明你已经理解了支付链路最核心的后端思维。下一篇我们会从交易链路切回性能优化,学习 Redis Cache Aside 在商品详情中的完整实战。