支付对接的"最后一公里":回调丢失、关单冲突与幂等性的工程化解决方案

在趣玩搭社交平台集成微信/支付宝支付的过程中,遇到了比预想中复杂得多的边界情况。支付本身不难,难的是"回调一直没来怎么办""回调来了但订单已关闭怎么办""同一个回调来了两次怎么办"。本文记录这些边界问题的排查与解决全过程。

一、支付流程全景

先画一下趣玩搭的完整支付链路,后续讨论的所有边界问题都发生在这条链路上。

用户点击支付
    │
    ▼
趣玩搭服务端 → 调用微信/支付宝统一下单API → 获取预付单信息
    │
    ▼
返回给客户端(小程序/APP)→ 拉起微信/支付宝收银台
    │
    ▼
用户输入密码完成支付
    │
    ├─────────────────────────────────┐
    ▼                                 ▼
微信/支付宝异步回调                用户端同步跳转
(POST到我们的notify_url)          (页面跳转到结果页)
    │                                 │
    ▼                                 ▼
验签 → 处理业务逻辑 → 返回SUCCESS   前端轮询订单状态

关键认知:用户端同步跳转不代表支付成功。 用户看到"支付成功"页面只是支付渠道的前端行为,真正的支付确认必须以异步回调为准。所以前端收到跳转后会轮询我们的订单状态接口,等异步回调处理完毕后才展示最终结果。

二、场景一:扣了库存但支付回调一直没来

2.1 问题分析

在趣玩搭的抢票流程中,用户抢票成功时就已经通过 Redis Lua 脚本扣减了库存并创建了待支付订单。如果用户付了钱但支付渠道的回调因为网络问题、服务器故障等原因一直没到达,系统就会出现一个尴尬的状态:

用户视角:钱已经扣了
系统视角:订单还是 PENDING 状态
15分钟后:超时关闭任务把订单关了,库存回补了
实际情况:用户确实付了钱,但名额被释放给了别人

这是一个非常严重的问题——用户钱扣了但名额丢了。

2.2 解决方案:主动查单机制

不能被动等待回调。我设计了一个主动查单机制,在关键时间点主动向微信/支付宝查询支付结果。

触发查单的三个时机:

时机一:延时查单。 用户发起支付后,发送一条 5 分钟延时的 MQ 消息。5 分钟后如果订单还是 PENDING 状态,就主动调用微信/支付宝的查单 API 确认支付结果。

// 创建预付单后,发送延时查单消息
public UnifiedOrderResult createPayment(Long orderId, Integer amount) {
    // 1. 调用微信统一下单API
    UnifiedOrderResult result = wechatPayClient.unifiedOrder(buildRequest(orderId, amount));
​
    // 2. 发送延时查单消息(5分钟后触发)
    PaymentQueryMessage queryMsg = new PaymentQueryMessage(orderId, result.getTransactionId());
    rocketMQTemplate.syncSend("payment-query-topic",
        MessageBuilder.withPayload(queryMsg).build(), 3000, 13); // 等级13 = 5分钟
​
    return result;
}

时机二:关单前查单。 前面博客提到的超时关单任务(15 分钟)在执行关闭前,必须先调用支付渠道查单 API。如果查到已支付,就走支付成功逻辑而不是关闭订单。

@Transactional
public void closeTimeoutOrder(Long orderId, Long activityId, Long userId) {
    // ⚠️ 关闭前必须先查一次支付状态
    PaymentStatus paymentStatus = paymentQueryService.queryPaymentStatus(orderId);
​
    if (paymentStatus == PaymentStatus.SUCCESS) {
        // 支付已成功,回调丢了,主动补偿走支付成功流程
        log.warn("订单 {} 支付已成功但回调未到达,主动触发支付成功处理", orderId);
        paymentCallbackService.handlePaymentSuccess(buildCallbackFromQuery(orderId));
        return; // 不关闭订单
    }
​
    if (paymentStatus == PaymentStatus.PAYING) {
        // 用户还在支付中,延后5分钟再处理
        log.info("订单 {} 用户正在支付中,延后关闭", orderId);
        resendCloseMessage(orderId, activityId, userId, 5);
        return;
    }
​
    // 确认未支付,安全关闭
    doCloseOrder(orderId, activityId, userId);
}

时机三:用户主动触发。 前端的订单详情页有一个"我已支付"按钮(处理用户看到支付成功但系统未更新的情况)。点击后前端调用后端接口,后端主动查单并更新状态。

2.3 支付查询服务的实现

@Service
public class PaymentQueryService {
​
    @Autowired
    private WechatPayClient wechatPayClient;
    @Autowired
    private AlipayClient alipayClient;
​
    /**
     * 查询支付状态,支持微信和支付宝
     */
    public PaymentStatus queryPaymentStatus(Long orderId) {
        Order order = orderMapper.selectById(orderId);
​
        try {
            if (order.getPayChannel() == PayChannel.WECHAT) {
                WechatQueryResult result = wechatPayClient.orderQuery(order.getOutTradeNo());
                return mapWechatStatus(result.getTradeState());
            } else if (order.getPayChannel() == PayChannel.ALIPAY) {
                AlipayQueryResult result = alipayClient.tradeQuery(order.getOutTradeNo());
                return mapAlipayStatus(result.getTradeStatus());
            }
        } catch (Exception e) {
            log.error("查询订单 {} 支付状态失败", orderId, e);
            return PaymentStatus.UNKNOWN; // 查询失败返回未知状态,不做任何操作
        }
​
        return PaymentStatus.UNKNOWN;
    }
​
    private PaymentStatus mapWechatStatus(String tradeState) {
        return switch (tradeState) {
            case "SUCCESS" -> PaymentStatus.SUCCESS;
            case "USERPAYING" -> PaymentStatus.PAYING;
            case "NOTPAY", "CLOSED", "REVOKED", "PAYERROR" -> PaymentStatus.NOT_PAID;
            default -> PaymentStatus.UNKNOWN;
        };
    }
}

当查询返回 UNKNOWN 时绝不做任何操作。 这是一个重要的防御性原则——在无法确认支付状态时,宁可让订单多等一会儿(等下一次查询或等回调到达),也不能贸然关闭一个可能已经支付的订单。

2.4 查单的时间线全景

T+0min    用户发起支付
T+5min    延时查单消息到达 → 查询支付状态
              ├── 已支付 → 主动触发支付成功处理
              ├── 支付中 → 等待,不处理
              └── 未支付 → 等待,不处理
T+10min   二阶段提醒消息 → 给用户发"即将关闭"通知
T+15min   超时关单任务触发 → 先查单再决定
              ├── 已支付 → 主动触发支付成功处理(补偿)
              ├── 支付中 → 延后5分钟再关闭
              └── 未支付 → 安全关闭订单,回补库存
T+20min   如果延后了,再次查单并关闭

通过这套机制,即使支付回调永远不来,系统也能通过主动查单自我修复,不会出现"用户付了钱但名额丢失"的情况。

三、场景二:回调来了但订单已关闭

3.1 问题分析

这是另一个经典的竞态条件。考虑以下时序:

T+14:59  用户完成支付,微信开始发送回调
T+15:00  超时关单任务触发,查单返回"未支付"(微信侧刚完成扣款,查单结果有毫秒级延迟)
T+15:01  关单任务关闭订单,回补库存
T+15:02  微信回调到达,但订单已经是 TIMEOUT_CLOSED 状态

如果简单地判断"订单非 PENDING 就忽略回调",用户的钱就白扣了。

3.2 解决方案:回调到达时检测已关闭订单并触发退款

@RestController
@RequestMapping("/api/payment/callback")
public class PaymentCallbackController {
​
    @PostMapping("/wechat")
    public String wechatCallback(@RequestBody String xmlBody) {
        // 1. 验签(防伪造)
        WechatCallbackData data = wechatPayClient.parseAndVerify(xmlBody);
        if (data == null) {
            return "<xml><return_code>FAIL</return_code></xml>";
        }
​
        // 2. 查询订单
        Order order = orderService.getByOutTradeNo(data.getOutTradeNo());
        if (order == null) {
            log.error("收到回调但订单不存在: {}", data.getOutTradeNo());
            return buildSuccessResponse(); // 返回成功,避免微信重试
        }
​
        // 3. 根据订单状态分别处理
        switch (order.getStatus()) {
            case PENDING:
                // 正常情况:处理支付成功
                paymentCallbackService.handlePaymentSuccess(buildCallbackDTO(data, order));
                break;
​
            case PAID:
                // 已处理过,忽略重复回调
                log.info("订单 {} 已处理,忽略重复回调", order.getId());
                break;
​
            case TIMEOUT_CLOSED:
                // ⚠️ 关键场景:订单已关闭但支付已成功
                log.warn("订单 {} 已超时关闭但收到支付成功回调,触发自动退款", order.getId());
                handleLatePaymentCallback(order, data);
                break;
​
            case CANCELLED:
                // 用户主动取消后收到回调,同样触发退款
                log.warn("订单 {} 已取消但收到支付成功回调,触发自动退款", order.getId());
                handleLatePaymentCallback(order, data);
                break;
​
            default:
                log.error("订单 {} 状态异常: {},收到支付回调", order.getId(), order.getStatus());
                break;
        }
​
        return buildSuccessResponse(); // 无论什么情况都返回成功,避免微信持续重试
    }
​
    private String buildSuccessResponse() {
        return "<xml><return_code><![CDATA[SUCCESS]]></return_code>"
             + "<return_msg><![CDATA[OK]]></return_msg></xml>";
    }
}

3.3 迟到回调的自动退款处理

@Service
public class LatePaymentHandler {

    public void handleLatePaymentCallback(Order order, WechatCallbackData data) {
        // 1. 记录异常支付事件(审计追溯)
        paymentEventService.recordLateCallback(order.getId(), data.getTransactionId());

        // 2. 尝试恢复订单(如果库存还在)
        boolean recovered = tryRecoverOrder(order);

        if (recovered) {
            // 库存回补成功(名额还在),恢复订单
            log.info("订单 {} 库存恢复成功,将订单状态更新为已支付", order.getId());
            paymentCallbackService.handlePaymentSuccess(buildCallbackDTO(data, order));
            notifyService.send(order.getUserId(), "您的订单已恢复,抢票成功!");
        } else {
            // 库存已被其他人占用,无法恢复,只能退款
            log.warn("订单 {} 库存已耗尽,无法恢复,发起自动退款", order.getId());
            refundService.autoRefund(order, data.getTransactionId(), "订单超时关闭后收到支付,自动退款");
            notifyService.send(order.getUserId(),
                "很抱歉,由于支付超时名额已被释放,系统已自动发起全额退款,预计1-3个工作日到账。");
        }
    }

    /**
     * 尝试恢复订单:重新扣减库存
     */
    private boolean tryRecoverOrder(Order order) {
        // 尝试在 Redis 中重新扣减库存
        String stockKey = "activity:{" + order.getActivityId() + "}:stock";
        Long remain = redisTemplate.opsForValue().decrement(stockKey);

        if (remain != null && remain >= 0) {
            // Redis 扣减成功,MySQL 也要扣减
            int affected = activityMapper.deductStock(order.getActivityId());
            if (affected > 0) {
                // 将订单状态改回 PENDING(以便后续正常走支付成功流程)
                orderMapper.updateStatus(order.getId(), OrderStatus.TIMEOUT_CLOSED, OrderStatus.PENDING);
                return true;
            }
            // MySQL 扣减失败,回补 Redis
            redisTemplate.opsForValue().increment(stockKey);
        } else if (remain != null && remain < 0) {
            // Redis 库存已为负数(扣多了),回补
            redisTemplate.opsForValue().increment(stockKey);
        }

        return false;
    }
}

设计思路是"能恢复就恢复,不能恢复就退款"。 先尝试重新扣减库存恢复订单(对用户来说是最好的体验——付了钱并且拿到了名额)。如果库存已经被其他用户抢走了,无法恢复,那就自动退款并通知用户。

3.4 退款服务的实现

@Service
public class RefundService {

    public void autoRefund(Order order, String transactionId, String reason) {
        // 1. 创建退款记录(先记录再退款,即使退款失败也有记录可查)
        RefundRecord refund = new RefundRecord();
        refund.setOrderId(order.getId());
        refund.setTransactionId(transactionId);
        refund.setRefundAmount(order.getPayAmount());
        refund.setReason(reason);
        refund.setStatus(RefundStatus.PROCESSING);
        refundMapper.insert(refund);

        try {
            // 2. 调用微信退款API
            WechatRefundResult result = wechatPayClient.refund(
                transactionId,
                refund.getRefundNo(),
                order.getPayAmount(),
                order.getPayAmount()  // 全额退款
            );

            // 3. 更新退款状态
            refund.setRefundId(result.getRefundId());
            refund.setStatus(RefundStatus.SUCCESS);
            refundMapper.updateById(refund);

        } catch (Exception e) {
            // 退款失败,标记待人工处理
            refund.setStatus(RefundStatus.FAILED);
            refund.setFailReason(e.getMessage());
            refundMapper.updateById(refund);

            // 告警
            alertService.sendCritical("自动退款失败",
                String.format("订单 %d 退款失败: %s,需人工处理", order.getId(), e.getMessage()));
        }
    }
}

退款失败一定会触发告警。在支付场景中,退款失败意味着用户的钱既没有买到服务也没有退回来,这是最高优先级的问题。

四、幂等性保障

4.1 为什么必须做幂等

支付回调的幂等性不是"最好有",而是"必须有"。原因有三:

微信/支付宝会重试。 如果我们的服务器没有在规定时间内返回 SUCCESS,支付渠道会重复发送回调。微信最多重试 15 次(间隔从 15 秒递增到 10 分钟),支付宝最多重试 8 次。

我们自己的主动查单也可能和回调并发到达。 延时查单和异步回调可能在同一时刻处理同一笔订单。

如果没有幂等保护。 一笔支付可能被处理多次:库存被多次确认扣减(虽然这里用了乐观锁不会出问题)、积分被多次增加(这才是最可能出问题的地方——用户凭空多了好几倍积分)。

4.2 幂等方案:数据库唯一约束 + 状态机

我使用了两层幂等保障:

第一层:支付流水表唯一约束。

CREATE TABLE `payment_record` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `order_id` BIGINT NOT NULL,
    `transaction_id` VARCHAR(64) NOT NULL COMMENT '支付渠道交易号(微信/支付宝返回)',
    `pay_amount` INT NOT NULL,
    `pay_channel` VARCHAR(16) NOT NULL,
    `status` VARCHAR(32) NOT NULL,
    `callback_time` DATETIME,
    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_transaction_id` (`transaction_id`) -- 唯一约束
);

每次处理支付回调时,先尝试插入 payment_record。如果 transaction_id 已存在(唯一约束冲突),说明这笔回调已经处理过了,直接跳过:

public boolean tryRecordPayment(PaymentCallbackDTO callback) {
    PaymentRecord record = new PaymentRecord();
    record.setOrderId(callback.getOrderId());
    record.setTransactionId(callback.getTransactionId());
    record.setPayAmount(callback.getAmount());
    record.setPayChannel(callback.getPayChannel());
    record.setStatus("SUCCESS");
    record.setCallbackTime(LocalDateTime.now());

    try {
        paymentRecordMapper.insert(record);
        return true; // 插入成功,说明是第一次处理
    } catch (DuplicateKeyException e) {
        log.info("支付回调重复到达,transaction_id={}", callback.getTransactionId());
        return false; // 唯一约束冲突,说明已处理过
    }
}

第二层:订单状态机。

@GlobalTransactional(name = "payment-callback-tx", timeoutMills = 30000)
public void handlePaymentSuccess(PaymentCallbackDTO callback) {
    // 幂等检查第一层:支付流水表
    if (!tryRecordPayment(callback)) {
        return; // 已处理过,直接返回
    }

    // 幂等检查第二层:订单状态机(只有 PENDING 才能变为 PAID)
    int affected = orderMapper.updateStatus(
        callback.getOrderId(),
        OrderStatus.PENDING,    // 预期当前状态
        OrderStatus.PAID        // 目标状态
    );

    if (affected == 0) {
        // 状态不是 PENDING(可能已经被另一个并发请求处理了)
        log.info("订单 {} 状态非 PENDING,跳过处理", callback.getOrderId());
        return;
    }

    // 后续操作...
    stockClient.confirmDeduct(callback.getActivityId(), callback.getOrderId());
    pointsClient.addPoints(callback.getUserId(), callback.getActivityId(), calculatePoints(callback.getAmount()));
}

两层保障的关系:支付流水表的唯一约束是"硬幂等"——利用数据库约束做到绝对防重复;订单状态机是"软幂等"——通过业务状态流转做防御性检查。两层同时生效,即使一层被绕过(理论上不会),另一层仍然能挡住。

4.3 积分服务的幂等

积分服务是最容易出问题的环节——如果没有幂等保护,重复回调会导致用户积分被多次增加。

@Service
public class PointsServiceImpl implements PointsService {

    @Transactional
    @Override
    public void addPoints(Long userId, Long activityId, int points) {
        // 幂等检查:同一用户同一活动只能加一次积分
        PointsRecord existing = pointsRecordMapper.selectByUserAndActivity(userId, activityId);
        if (existing != null) {
            log.info("用户 {} 活动 {} 积分已添加,跳过", userId, activityId);
            return;
        }

        // 增加积分
        userPointsMapper.addPoints(userId, points);

        // 记录积分流水
        PointsRecord record = new PointsRecord();
        record.setUserId(userId);
        record.setActivityId(activityId);
        record.setPoints(points);
        record.setType(PointsType.ACTIVITY_REWARD);
        pointsRecordMapper.insert(record); // user_id + activity_id 有唯一约束
    }
}

user_id + activity_id 的唯一约束保证了即使并发调用也只会成功一次。

五、验签:不可绕过的第一道防线

所有讨论的前提是:回调确实来自微信/支付宝,而不是攻击者伪造的。 验签是支付对接中绝对不能省略的步骤。

public WechatCallbackData parseAndVerify(String xmlBody) {
    WechatCallbackData data = XmlUtils.parse(xmlBody, WechatCallbackData.class);

    // 1. 校验签名
    String sign = WechatSignUtils.sign(data, merchantApiKey);
    if (!sign.equals(data.getSign())) {
        log.error("微信回调验签失败!可能是伪造请求。收到签名: {}, 期望签名: {}", data.getSign(), sign);
        return null;
    }

    // 2. 校验金额(防止篡改支付金额)
    Order order = orderMapper.selectByOutTradeNo(data.getOutTradeNo());
    if (order != null && !order.getPayAmount().equals(data.getTotalFee())) {
        log.error("支付金额不一致!订单金额: {}, 回调金额: {}", order.getPayAmount(), data.getTotalFee());
        alertService.sendCritical("支付金额异常", "订单" + order.getId() + "金额不匹配");
        return null;
    }

    return data;
}

验签失败直接丢弃,金额不一致直接告警。宁可漏处理一笔(可以人工补偿),也不能处理一笔伪造的支付。

六、全景总结:支付对接的防御性编程清单

经过趣玩搭的支付对接实战,我总结了一份"支付开发防御清单":

回调处理层: 验签必须做,金额必须校验,所有情况都返回 SUCCESS(避免无限重试),日志必须记录完整的回调原始数据。

幂等保障层: 支付流水表唯一约束(transaction_id),订单状态机(只允许合法状态流转),每个服务的入口都有独立的幂等检查。

异常补偿层: 主动查单机制(延时查单 + 关单前查单),迟到回调的自动退款,退款失败的告警和人工介入通道。

监控告警层: 回调超时未到达的告警,回调验签失败的告警,金额不一致的告警,退款失败的告警。

支付场景没有小问题。每一个边界条件没有处理好,都可能变成一笔资金纠纷。防御性编程不是过度设计,而是对用户资金负责的基本态度。


如果这篇文章对你有帮助,欢迎访问我的博客 robinzhu.top 获取更多实战分享。