某二手交易平台开发记录:订单超时与支付回调处理
做过一个 C2C 二手闲置交易平台,买家下单付款,卖家发货,买家确认收货,最后双方评价。商品大多只有一件,一笔订单没有处理完,其他人就不能再买。
开发时需要处理几种情况:买家下单后一直不付款,卖家收款后不发货,或者买家收到货后没有确认收货。不能一直等用户操作,所以项目里加了超时取消、自动收货和自动评价。
这篇记录这部分的处理方式。代码按业务流程做了整理,省略实体、Mapper 声明和支付 SDK 的接入代码,主要看任务怎么安排,以及订单状态怎么更新。
一、订单状态和超时时间
项目中订单状态使用数字表示,相关状态如下:
| 状态值 | 含义 |
|---|---|
| 1 | 待付款 |
| 2 | 已付款,待发货 |
| 3 | 已发货,待收货 |
| 4 | 交易完成 |
| 6 | 超时未付款,系统取消 |
| 7 | 买家取消 |
| 8 | 卖家取消 |
| 9 | 超时未发货,系统取消 |
超时任务分为五种:
| 任务 | 从什么时候开始计算 | 生产环境时长 | 到期处理 |
|---|---|---|---|
| 未支付提醒 | 下单成功 | 5 分钟 | 给买卖双方发送提醒 |
| 未支付取消 | 下单成功 | 30 分钟 | 订单状态改为 6 |
| 未发货取消 | 支付成功 | 3 天 | 订单状态改为 9,退款由运营处理 |
| 自动确认收货 | 卖家发货 | 7 天 | 订单状态改为 4 |
| 自动好评 | 确认收货 | 7 天 | 补充默认好评 |
未发货取消从付款成功开始算,自动收货从发货开始算,不能全部使用下单时间。否则卖家发货晚一些,买家的收货时间就被提前扣掉了。
到期时间跟订单一起保存。例如下单时保存 payExpireTime,支付成功时保存 sendGoodsExpireTime。延迟队列按这些时间安排任务,定时补查也使用同一份时间。
测试环境使用较短的时间。比如未支付取消在生产环境是 30 分钟,开发环境改成 60 秒;未发货取消在生产环境是 3 天,开发环境改成 2 分钟,方便把整个流程走一遍。
二、为什么选择 Redis 延迟队列
项目后端主要由我一个人维护,Redis 已经在使用,团队也没有专门负责中间件运维的人。对于该项目的目标群体来说,订单量不会特别巨大,超时处理稍晚一些可以接受,所以选了 Redisson 延迟队列,没有另外引入 MQ。
订单状态和到期时间保存在数据库,Redis 用来安排到期任务。即使某次入队没有成功,后面的定时补查也能按数据库里的到期时间找到订单。
三、队列初始化、入队和消费
这里用到两个队列对象:
RDelayedQueue:存放还没到时间的任务。RQueue:接收到期任务,供定时任务取出处理。
1. 应用启动时初始化队列
五种任务各用一组队列。队列对象在应用启动时创建,业务方法直接复用,应用停止时再释放。
public enum OrderTimeoutType {
PAY_REMINDER,
CANCEL_UNPAID,
CANCEL_UNSHIPPED,
CONFIRM_RECEIPT,
AUTO_REVIEW
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrderTimeoutMessage {
private Long orderId;
private int retryCount;
}
消息只保存订单 ID 和重试次数。订单状态、商品 ID、支付单号都从数据库读取,消息到达时以数据库中的记录为准。
@Component
public class OrderTimeoutQueue {
@Resource
private RedissonClient redissonClient;
private final Map<OrderTimeoutType, RQueue<OrderTimeoutMessage>> readyQueues =
new EnumMap<>(OrderTimeoutType.class);
private final Map<OrderTimeoutType, RDelayedQueue<OrderTimeoutMessage>> delayedQueues =
new EnumMap<>(OrderTimeoutType.class);
@PostConstruct
public void init() {
for (OrderTimeoutType type : OrderTimeoutType.values()) {
String name = "order:timeout:" + type.name();
RQueue<OrderTimeoutMessage> ready = redissonClient.getQueue(name);
readyQueues.put(type, ready);
delayedQueues.put(type, redissonClient.getDelayedQueue(ready));
}
}
public void offerAt(OrderTimeoutType type, Long orderId, LocalDateTime expiresAt) {
long delay = Math.max(0L,
Duration.between(LocalDateTime.now(), expiresAt).toMillis());
delayedQueues.get(type).offer(
new OrderTimeoutMessage(orderId, 0), delay, TimeUnit.MILLISECONDS);
}
public List<OrderTimeoutMessage> poll(OrderTimeoutType type, int count) {
return readyQueues.get(type).poll(count);
}
public void retry(OrderTimeoutType type, OrderTimeoutMessage message) {
delayedQueues.get(type).offer(
new OrderTimeoutMessage(message.getOrderId(), message.getRetryCount() + 1),
30, TimeUnit.SECONDS);
}
@PreDestroy
public void destroy() {
delayedQueues.values().forEach(RDelayedQueue::destroy);
}
}
destroy() 放在组件停止时调用,不放在每次 offer() 或 poll() 后面。延迟消息到期后,由客户端的转移任务放进目标队列,因此队列对象随应用保持运行。Redisson 实现参考
以上使用 LocalDateTime 计算时间,应用和数据库按同一时区配置。这里只做相对延迟计算,真正能否取消进行二次检查数据库里的状态和到期时间。
2. 每次批量取出,逐条处理
消费任务每轮最多取 10 条,逐条调用订单服务。一条订单处理失败,不影响这一批里的其他订单。
下面以未支付取消为例:
@Slf4j
@Component
public class OrderTimeoutTask {
@Resource
private OrderTimeoutQueue timeoutQueue;
@Resource
private OrderTimeoutService timeoutService;
@Scheduled(fixedDelay = 2000)
public void cancelUnpaidOrders() {
List<OrderTimeoutMessage> messages;
try {
messages = timeoutQueue.poll(OrderTimeoutType.CANCEL_UNPAID, 10);
} catch (Exception e) {
log.error("读取未支付超时队列失败", e);
return;
}
for (OrderTimeoutMessage message : messages) {
try {
timeoutService.cancelUnpaid(message.getOrderId());
} catch (Exception e) {
retryOrLog(message, e);
}
}
}
private void retryOrLog(OrderTimeoutMessage message, Exception cause) {
if (message.getRetryCount() >= 5) {
log.error("订单超时任务重试达到上限,等待数据库补查,orderId={}",
message.getOrderId(), cause);
return;
}
try {
timeoutQueue.retry(OrderTimeoutType.CANCEL_UNPAID, message);
log.warn("订单超时任务将在 30 秒后重试,orderId={}, retryCount={}",
message.getOrderId(), message.getRetryCount() + 1, cause);
} catch (Exception e) {
log.error("订单超时任务重新入队失败,等待数据库补查,orderId={}",
message.getOrderId(), e);
}
}
}
调度配置中启用 @EnableScheduling。这里的 fixedDelay = 2000 表示上一轮结束后等待 2 秒,再开始下一轮;30 分钟未付款是入队时计算的延迟时间,两者分别控制轮询和业务到期时间。
同一条消息最多额外重试 5 次。达到次数后停止这一轮消息重试,但订单仍会被数据库补查找到。重试上限不是放弃处理订单,而是避免一条失败消息持续占用消费线程。
四、事务提交后,再安排下一个阶段的任务
创建订单时,先保存订单和到期时间,再发布任务事件:
// 位于创建订单的 @Transactional 方法内
LocalDateTime createdAt = LocalDateTime.now();
LocalDateTime payExpireAt = "dev".equals(environment)
? createdAt.plusSeconds(60)
: createdAt.plusMinutes(30);
LocalDateTime reminderAt = "dev".equals(environment)
? createdAt.plusSeconds(30)
: createdAt.plusMinutes(5);
order.setPayExpireTime(payExpireAt);
orderMapper.insert(order);
eventPublisher.publishEvent(new OrderTimeoutEvent(
OrderTimeoutType.CANCEL_UNPAID, order.getOrderId(), payExpireAt));
eventPublisher.publishEvent(new OrderTimeoutEvent(
OrderTimeoutType.PAY_REMINDER, order.getOrderId(), reminderAt));
事件携带已经计算好的到期时间,不在监听器里重新从当前时间开始计时:
@Value
public class OrderTimeoutEvent {
OrderTimeoutType type;
Long orderId;
LocalDateTime expiresAt;
}
@Slf4j
@Component
public class OrderTimeoutListener {
@Resource
private OrderTimeoutQueue timeoutQueue;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTimeoutScheduled(OrderTimeoutEvent event) {
try {
timeoutQueue.offerAt(
event.getType(), event.getOrderId(), event.getExpiresAt());
} catch (Exception e) {
log.error("订单超时任务入队失败,type={}, orderId={}",
event.getType(), event.getOrderId(), e);
}
}
}
监听器只在事务成功提交后入队。这样创建订单失败时,不会留下对应的超时任务;Redis 入队失败时,已经提交的订单仍保留到期时间,由数据库补查继续处理。Spring 事务事件说明
支付成功后,在保存付款结果的事务中计算并保存发货期限,再发布未发货取消事件。完整调用放在下一节的 confirmPaid() 方法中。
发货和确认收货也按这个顺序处理:保存新状态及对应到期时间,事务提交后安排下一阶段任务。
付款后不再依赖删除旧队列消息。旧的取消任务到期时,查到订单已经不是待付款便退出;未付款提醒也只发送给仍未付款的订单。这样即使任务已经被其他线程取走,也能根据当前状态结束处理。
五、支付回调和超时取消都带上原状态
1. 支付成功只从待付款状态更新
支付回调核验通过后,订单从待付款改为待发货。状态条件直接写在 UPDATE 中,并检查影响行数。
下面统一使用 trade_order 和 pay_record 表名说明,#{...} 是 MyBatis 参数:
-- orderMapper.markPaid
UPDATE trade_order
SET order_status = 2,
paid_time = #{paidAt},
send_goods_expire_time = #{sendExpireAt}
WHERE order_id = #{orderId}
AND order_status = 1;
支付单使用 back_status 记录回调是否已经处理:1 为待处理,2 为已处理。进入事务后锁定这条支付记录,同一支付单的并发通知依次处理。
-- payRecordMapper.selectForUpdate
SELECT id, order_id, pay_no, amount, back_status
FROM pay_record
WHERE pay_no = #{payNo}
FOR UPDATE;
-- payRecordMapper.markHandled
UPDATE pay_record
SET back_status = 2,
transaction_id = #{transactionId}
WHERE id = #{id}
AND back_status = 1;
pay_no 建立唯一索引,保证一个商户支付单号只对应一条记录。金额、商户和应用信息在进入业务方法前完成核验,业务方法接收核验后的付款结果:
@Service
public class PaymentTransactionService {
// 省略 Mapper、ApplicationEventPublisher 和 environment 的注入声明
@Transactional(rollbackFor = Exception.class)
public void confirmPaid(VerifiedPayment payment) {
PayRecord record = payRecordMapper.selectForUpdate(payment.getPayNo());
if (record == null) {
throw new IllegalStateException("支付单不存在");
}
if (record.getBackStatus() == 2) {
return;
}
Long orderId = record.getOrderId();
LocalDateTime paidAt = payment.getPaidAt();
LocalDateTime sendExpireAt = "dev".equals(environment)
? paidAt.plusMinutes(2)
: paidAt.plusDays(3);
int changed = orderMapper.markPaid(orderId, paidAt, sendExpireAt);
if (changed != 1) {
throw new IllegalStateException("付款结果与订单状态不一致,orderId=" + orderId);
}
int handled = payRecordMapper.markHandled(record.getId(), payment.getTransactionId());
if (handled != 1) {
throw new IllegalStateException("支付记录更新失败,orderId=" + orderId);
}
eventPublisher.publishEvent(new OrderTimeoutEvent(
OrderTimeoutType.CANCEL_UNSHIPPED, orderId, sendExpireAt));
}
}
第一次通知完成订单和支付单更新,后面的通知查到 backStatus = 2 就结束,不再重复安排任务。订单和支付单在同一个事务里修改,任意一步失败都回滚。
回调接口在这段事务提交成功后才返回支付平台要求的成功应答。验签失败、金额不符、订单状态冲突分别记录原因,处理失败时不返回成功。重复通知只在确认支付单已经处理完成后返回成功。
2. 未支付取消先确认支付平台的结果
买家可能刚完成付款,回调还没到,超时任务就开始执行了。因此取消未付款订单时,先通过支付平台关单或查单,确认它是否还能按未付款处理。
paymentGateway.closeOrQuery() 封装支付宝、微信的查单和关单结果,返回三种状态:
| 返回状态 | 处理方式 |
|---|---|
CLOSED_UNPAID |
已确认支付单关闭且未支付,可以取消本地订单 |
PAID |
已支付,将查到的付款结果交给付款处理方法 |
UNKNOWN |
网络异常或结果暂不确定,稍后重试 |
这里只把支付平台确认的终态映射为 CLOSED_UNPAID。没有查到支付单、请求超时,都不直接等同于关单成功。
@Service
public class OrderTimeoutService {
@Resource
private OrderLockExecutor orderLockExecutor;
@Resource
private OrderTransactionService orderTransactionService;
@Resource
private PaymentTransactionService paymentTransactionService;
@Resource
private PaymentGateway paymentGateway;
@Resource
private OrderMapper orderMapper;
public void cancelUnpaid(Long orderId) {
orderLockExecutor.execute(orderId, () -> {
OrderEntity order = orderMapper.selectById(orderId);
if (order == null || order.getOrderStatus() != 1) {
return;
}
if (order.getPayExpireTime().isAfter(LocalDateTime.now())) {
return;
}
PaymentCloseResult result = paymentGateway.closeOrQuery(order.getOrderPayNo());
switch (result.getStatus()) {
case CLOSED_UNPAID:
orderTransactionService.cancelUnpaid(orderId, LocalDateTime.now());
return;
case PAID:
paymentTransactionService.confirmPaid(result.getVerifiedPayment());
return;
default:
throw new IllegalStateException("支付结果暂不确定,orderId=" + orderId);
}
});
}
}
getVerifiedPayment() 返回的查单结果同样经过订单号、商户、金额等核对。收到成功回调和主动查到付款成功,都调用同一个 confirmPaid()。
本地取消仍然带上待付款状态和到期时间:
-- orderMapper.cancelUnpaid
UPDATE trade_order
SET order_status = 6
WHERE order_id = #{orderId}
AND order_status = 1
AND pay_expire_time <= #{now};
订单取消和商品解除占用放在一个事务里。释放商品时同时匹配占用它的订单 ID,避免旧任务解除其他订单的占用。
@Service
public class OrderTransactionService {
@Resource
private OrderMapper orderMapper;
@Resource
private GoodsMapper goodsMapper;
@Transactional(rollbackFor = Exception.class)
public void cancelUnpaid(Long orderId, LocalDateTime now) {
int changed = orderMapper.cancelUnpaid(orderId, now);
if (changed == 0) {
return;
}
OrderEntity order = orderMapper.selectById(orderId);
goodsMapper.releaseReservation(order.getGoodsId(), orderId);
}
}
对应的商品更新条件如下。这里用 reserved_order_id 表示商品当前由哪笔订单占用:
-- goodsMapper.releaseReservation
UPDATE goods
SET reserved_order_id = NULL
WHERE goods_id = #{goodsId}
AND reserved_order_id = #{orderId};
未发货取消和自动收货也按原状态、到期时间更新。只有更新成功才执行该次状态变化附带的操作;重复任务影响行数为 0,直接结束。
六、下单锁商品,订单操作使用同一个锁
1. 创建订单使用商品锁
一件商品可能同时被多个买家点击,下单时按商品 ID 加锁。抢不到锁提示“商品正在处理中,请稍后重试”,商品是否已售则在拿到锁后查询。
public Long createOrder(CreateOrderCommand command) {
RLock lock = redissonClient.getLock("goods:order:" + command.getGoodsId());
if (!lock.tryLock()) {
throw new IllegalStateException("商品正在处理中,请稍后重试");
}
try {
// 独立 Spring Bean 的事务方法,返回时数据库事务已经提交
return orderCreateTransactionService.create(command);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
事务方法中生成订单,再用条件更新占用商品。订单号由订单插入时生成:
@Transactional(rollbackFor = Exception.class)
public Long create(CreateOrderCommand command) {
GoodsEntity goods = goodsMapper.selectById(command.getGoodsId());
if (goods == null) {
throw new IllegalStateException("商品不存在");
}
OrderEntity order = buildOrder(command, goods);
// buildOrder 设置待付款状态、服务端计算的金额和 payExpireTime
orderMapper.insert(order);
int changed = goodsMapper.reserve(goods.getGoodsId(), order.getOrderId());
if (changed != 1) {
throw new IllegalStateException("商品已售出或已被其他订单占用");
}
eventPublisher.publishEvent(new OrderTimeoutEvent(
OrderTimeoutType.CANCEL_UNPAID, order.getOrderId(), order.getPayExpireTime()));
return order.getOrderId();
}
上面省略了第四节的提醒事件,商品占用 SQL 如下。on_sale = 1 表示商品仍处于可售状态,reserved_order_id IS NULL 表示当前没有订单占用:
-- goodsMapper.reserve
UPDATE goods
SET reserved_order_id = #{orderId}
WHERE goods_id = #{goodsId}
AND on_sale = 1
AND reserved_order_id IS NULL;
如果商品没有占用成功,抛异常回滚订单插入;事务没有提交,任务事件也不会入队。
2. 支付、取消、发货统一按订单 ID 加锁
同一笔订单的操作统一使用 order:operate:{orderId},不再按订单阶段拆分锁 key。
@Component
public class OrderLockExecutor {
@Resource
private RedissonClient redissonClient;
public void execute(Long orderId, Runnable action) {
RLock lock = redissonClient.getLock("order:operate:" + orderId);
if (!lock.tryLock()) {
throw new IllegalStateException("订单正在处理中,orderId=" + orderId);
}
try {
action.run();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
回调处理在核验通知、确定订单 ID 后调用:
orderLockExecutor.execute(verifiedPayment.getOrderId(),
() -> paymentTransactionService.confirmPaid(verifiedPayment));
取消任务使用的也是这个执行器。涉及数据库事务的方法放在独立的 Spring Bean 中,由外层拿锁后调用,事务方法返回后再释放锁,避免事务尚未提交就让下一个请求进入。
用户请求抢不到锁,可以提示稍后重试;超时任务抢不到锁会抛出异常,由第三节的消费代码重新入队。
3. 每分钟补查已经到期的订单
队列负责及时处理,数据库补查负责找到仍然停留在待付款状态的超时订单。补查直接调用同一个订单处理方法,不再绕回 Redis。
@Slf4j
@Component
public class OrderTimeoutScanTask {
@Resource
private OrderMapper orderMapper;
@Resource
private OrderTimeoutService timeoutService;
@Scheduled(fixedDelay = 60000)
public void scanExpiredUnpaidOrders() {
LocalDateTime cutoff = LocalDateTime.now();
long maxOrderId = orderMapper.selectMaxOrderId();
long afterId = 0L;
while (true) {
List<Long> ids = orderMapper.selectExpiredUnpaidIds(
afterId, maxOrderId, cutoff, 100);
if (ids.isEmpty()) {
return;
}
for (Long orderId : ids) {
try {
timeoutService.cancelUnpaid(orderId);
} catch (Exception e) {
log.error("补查订单处理失败,下轮继续,orderId={}", orderId, e);
}
}
afterId = ids.get(ids.size() - 1);
}
}
}
这里按订单 ID 向后查,不使用会随状态变化发生偏移的页码。每轮固定扫描时间和最大订单 ID,新增订单留到下一轮处理。
-- orderMapper.selectMaxOrderId
SELECT COALESCE(MAX(order_id), 0)
FROM trade_order;
-- orderMapper.selectExpiredUnpaidIds
SELECT order_id
FROM trade_order
WHERE order_status = 1
AND pay_expire_time <= #{cutoff}
AND order_id > #{afterId}
AND order_id <= #{maxOrderId}
ORDER BY order_id
LIMIT #{limit};
这段扫描使用单调递增的正数订单 ID。处理一条订单失败后仍继续向后扫描,下一轮再从头检查,不会一直停在同一条订单上。
未发货取消、自动收货按各自的状态和到期时间使用相同的处理方式。提醒通过发送记录判重,自动评价通过评价记录判重,记录与订单关联。一次订单状态可以对应多种操作,因此这些任务分别记录执行结果。
七、支付回调检查哪些内容
支付宝回调先验签,再核对本地支付单:
| 检查项 | 对应内容 |
|---|---|
| 验签 | 通知内容是否通过签名校验 |
out_trade_no |
是否对应本地支付单 |
trade_status |
是否为当前支付产品接受的成功状态 |
total_amount |
是否与创建支付单时保存的金额一致 |
| 收款方信息 | 商户信息是否与本地配置一致 |
app_id |
是否为当前应用 |
项目片段中的支付宝成功状态使用 TRADE_SUCCESS,其他状态按所接入支付产品的通知规则处理。
金额比较使用 BigDecimal.compareTo(),避免因为 10.0 和 10.00 的小数位数不同而判断不一致:
if (record.getAmount().compareTo(notifyAmount) != 0) {
throw new IllegalArgumentException("支付金额与订单金额不一致");
}
金额以创建支付单时服务端保存的值为准,不使用回调后由客户端重新传入的金额。验签确认通知没有被篡改,业务字段校验则确认通知对应的是本地这笔交易。
微信支付 API v3 先验签、解密,再核对订单号、商户号、应用信息、交易状态和金额。如果本地按元保存,而回调金额单位为分,使用十进制数精确换算:
long expectedFen = record.getAmount().movePointRight(2).longValueExact();
if (expectedFen != notifyAmountFen) {
throw new IllegalArgumentException("支付金额与订单金额不一致");
}
核验通过后生成 VerifiedPayment,交给前面的付款事务方法。通知重复到达时,也先核验通知,再根据支付记录判断是否已经处理。
八、已付款订单取消后,退款由运营处理
未付款订单超时取消,确认支付单关闭后,修改订单状态并解除商品占用。
超过 3 天未发货的订单已经付过款,取消之后还要退钱。这部分保留人工审核,由运营从管理端发起退款。
后台分别展示订单状态和退款状态。订单变成“超时未发货取消”后进入待退款列表,运营审核后调用支付宝或微信退款接口。退款接口返回受理成功时记录为处理中,确认退款成功后,再更新退款记录及相关账务。
退款请求使用固定的业务退款单号,重新提交或查询时沿用同一个单号,方便核对同一笔退款。处理中和失败的记录继续保留在后台,运营可以查到退款结果,再决定下一步操作。
查看已取消订单时,后台同时展示退款进度和退款单号,方便运营核对。
- 本文标签: Java Spring Boot Mysql
- 本文链接: https://www.tianyajuanke.top/article/100
- 版权声明: 本文由吴沛芙原创发布,转载请遵循《署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)》许可协议授权