原创

某二手交易平台开发记录:订单超时与支付回调处理

做过一个 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 天未发货的订单已经付过款,取消之后还要退钱。这部分保留人工审核,由运营从管理端发起退款。

后台分别展示订单状态和退款状态。订单变成“超时未发货取消”后进入待退款列表,运营审核后调用支付宝或微信退款接口。退款接口返回受理成功时记录为处理中,确认退款成功后,再更新退款记录及相关账务。

退款请求使用固定的业务退款单号,重新提交或查询时沿用同一个单号,方便核对同一笔退款。处理中和失败的记录继续保留在后台,运营可以查到退款结果,再决定下一步操作。

查看已取消订单时,后台同时展示退款进度和退款单号,方便运营核对。

正文到此结束