大数跨境
0
0

微信支付:服务端如何防止订单重复支付?

微信支付:服务端如何防止订单重复支付? Java技术图谱
2025-12-02
3

线上最容易惹麻烦的一件事,就是“钱相关”的 bug。 微信支付里最典型的一个坑,就是:同一笔业务订单,被付了两次、甚至多次。

这篇就聊聊从服务端视角,怎么设计“防重复支付”,尽量用人话说清楚,用 Java 举例子。

一、先搞清楚:什么叫“重复支付”?

很多同学一说重复支付,就只想到“用户连点了两次支付按钮”。其实场景比这个多:

  • 用户付完钱,页面卡住了,以为没付成功,又重新点了一次。
  • 手机信号不好,微信那边回调你服务端多次。
  • 业务方自己手欠,用同一个业务单号,调了两次“下单”接口。
  • 定时任务补偿时没做幂等,又把支付逻辑跑了一遍。

注意一个细节:微信侧有自己的订单号 transaction_id,商户侧有自己的业务订单号,比如 bizOrderNo / out_trade_no我们要防的是:同一个业务订单(比如同一张商品订单),最终只应该成功收一笔钱

二、微信本身已经做了什么防重?

微信支付其实已经帮我们做了一部分工作:

  1. out_trade_no 在同一个商户号下面要求唯一
  2. 同一个 out_trade_no 重复发起,微信有的场景会直接告诉你“订单已支付”或者“订单号已存在”。
  3. 微信的支付结果通知(回调)是至少一次投递,也就是说有可能重复。

翻译成人话就是:

  • 你只要保证自己给微信传的 out_trade_no 不乱搞,它就不会帮你扣两次钱。
  • 但是:业务系统的“扣一次库存、送一次优惠券、改一次订单状态”这一整串操作,要你自己保证只执行一次。

三、服务端整体思路:所有动作都围着“支付单”转

比较稳妥的做法是:把“支付”当成一个独立的“支付单”来管理,而不是到处散落逻辑。

简单设计一个支付表(随便举个例子):

CREATE TABLE pay_order (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_order_no    VARCHAR(64NOT NULL,   -- 业务订单号(例如商城订单)
    out_trade_no    VARCHAR(64NOT NULL,   -- 传给微信的商户订单号
    amount          BIGINT NOT NULL,        -- 支付金额,单位分
    status          VARCHAR(16NOT NULL,   -- INIT / PAYING / SUCCESS / FAILED / CLOSED
    pay_channel     VARCHAR(32NOT NULL,   -- WECHAT
    version         INT NOT NULL DEFAULT 0-- 乐观锁版本号
    create_time     DATETIME NOT NULL,
    update_time     DATETIME NOT NULL,
    UNIQUE KEY uk_biz_order (biz_order_no),
    UNIQUE KEY uk_out_trade_no (out_trade_no)
);

这里真正防重复的“刺刀”有几个:

  • biz_order_no 唯一:同一张业务订单,只会对应一条支付单。
  • out_trade_no 唯一:同一个商户订单号,不会在微信那边被创建两次。
  • status + version:配合乐观锁,解决并发更新的问题。

四、创建支付单时如何防重?

最常见的流程是:用户在前端点“去支付”,服务端收到请求,生成一条支付单,然后调微信统一下单接口。

要防重,核心有两句话:

  1. 先查/创建本地支付单,再考虑调微信。
  2. 对同一个 biz_order_no,只允许存在状态正常的一条记录。

伪代码(Spring 风格的大概长这样):

@Service
public class PayService {

    @Transactional
    public WechatPrepayResponse createPayOrder(String bizOrderNo, Long userId) {
        // 1. 查业务订单状态(比如商城订单),已经支付成功直接返回
        Order order = orderRepository.findByBizOrderNo(bizOrderNo);
        if (order == null) {
            throw new IllegalArgumentException("业务订单不存在");
        }
        if (order.isPaid()) {
            // 幂等处理:已经是已支付的订单,就不要再下微信单了
            return buildPaidResponseFromHistory(bizOrderNo);
        }

        // 2. 查是否已有支付单
        PayOrder payOrder = payOrderRepository.findByBizOrderNo(bizOrderNo);

        if (payOrder == null) {
            // 2.1 没有就新建一条
            payOrder = new PayOrder();
            payOrder.setBizOrderNo(bizOrderNo);
            payOrder.setOutTradeNo(generateOutTradeNo(bizOrderNo));
            payOrder.setAmount(order.getPayAmount());
            payOrder.setStatus(PayStatus.INIT.name());
            payOrder.setPayChannel("WECHAT");
            payOrderRepository.save(payOrder);
        } else {
            // 2.2 已经存在支付单
            if (PayStatus.SUCCESS.name().equals(payOrder.getStatus())) {
                // 本地记着已经成功了,直接走幂等返回
                return buildPaidResponseFromHistory(bizOrderNo);
            }
            // 如果是 INIT / PAYING,可以复用这笔 payOrder 继续调微信
        }

        // 3. 调微信统一下单
        WechatUnifiedOrderRequest request = buildWechatRequest(order, payOrder);
        WechatUnifiedOrderResult result = wechatClient.unifiedOrder(request);

        // 4. 更新支付单状态
        payOrder.setStatus(PayStatus.PAYING.name());
        payOrderRepository.save(payOrder);

        // 5. 返回给前端 prepay 信息
        return buildPrepayResponse(result);
    }
}

这里其实就干了三件事:

  • 同一个 biz_order_no,永远只操作那一条 pay_order
  • 发现本地已经是支付成功,就直接走“幂等返回”,不再让用户创建新单。
  • 即便前端误操作、接口重试多次,**都只是在重复使用同一条支付单和同一个 out_trade_no**。

五、最关键的一环:回调通知如何防重?

真正决定“这笔钱算不算付成功”的,是微信的异步通知(notify_url)。

特点很简单:

  • 微信会重试通知,直到你返回 SUCCESS 或者超出最大重试次数。
  • 所以你必须把通知处理做成幂等操作

伪代码示例:

@RestController
@RequestMapping("/wechat/pay")
public class WechatPayNotifyController {

    @PostMapping("/notify")
    public String payNotify(HttpServletRequest request) {
        // 1. 读取并验签
        String body = readRequestBody(request);
        WechatNotifyData notifyData = wechatClient.parseAndVerify(body);
        if (notifyData == null) {
            // 验签失败,按微信要求返回失败
            return buildFailXml("SIGN_ERROR");
        }

        String outTradeNo = notifyData.getOutTradeNo();
        String transactionId = notifyData.getTransactionId();
        Long totalFee = notifyData.getTotalFee();
        String tradeState = notifyData.getTradeState(); // SUCCESS / 其他

        // 2. 查本地支付单
        PayOrder payOrder = payOrderRepository.findByOutTradeNo(outTradeNo);
        if (payOrder == null) {
            // 本地没有记录,通常是配置错误或者恶意请求
            return buildFailXml("ORDER_NOT_FOUND");
        }

        // 3. 金额、状态校验
        if (!Objects.equals(payOrder.getAmount(), totalFee)) {
            return buildFailXml("AMOUNT_NOT_MATCH");
        }

        // 4. 幂等处理:如果已经是成功了,直接返回 SUCCESS
        if (PayStatus.SUCCESS.name().equals(payOrder.getStatus())) {
            return buildSuccessXml();
        }

        // 5. 使用乐观锁更新支付单状态
        int updated = payOrderRepository.updateStatusToSuccess(
                payOrder.getId(),
                PayStatus.SUCCESS.name(),
                payOrder.getVersion(),
                transactionId
        );

        if (updated == 0) {
            // 说明有别的线程比我先一步处理了,同样走幂等
            return buildSuccessXml();
        }

        // 6. 在同一个事务中,更新业务订单的状态
        try {
            orderService.onPaySuccess(payOrder.getBizOrderNo());
        } catch (Exception e) {
            // 这里要十分小心:
            // - 要么抛异常回滚,让微信稍后再通知
            // - 要么把失败记录到本地表,走人工/任务补偿
            throw e;
        }

        return buildSuccessXml();
    }
}

updateStatusToSuccess 可以这么写(伪代码):

@Modifying
@Query("update PayOrder p set p.status = :status, p.version = p.version + 1, " +
       "p.transactionId = :transactionId, p.updateTime = now() " +
       "where p.id = :id and p.version = :version " +
       "and p.status in ('INIT', 'PAYING')")
int updateStatusToSuccess(@Param("id") Long id,
                          @Param("status") String status,
                          @Param("version") Integer version,
                          @Param("transactionId") String transactionId)
;

这里有几个关键点:

  • 用 status in ('INIT','PAYING') 限制状态机,避免从 CLOSED / FAILED 反向改回 SUCCESS。
  • 带上 version 做乐观锁,防止高并发时多次写入产生脏数据。
  • 更新支付单和业务单,最好放在一个事务里面,保证“要么都成功,要么都失败”。

六、查询接口也要“防重思维”

不少产品会给前端一个“轮询查询支付结果”的接口。这里也容易踩坑。

思路其实很简单:

  • 查询接口永远只查本地支付单 / 业务订单状态
  • 如果本地还没成功,可以适当去调一次微信“订单查询”接口同步一下。
  • 无论前端怎么疯狂轮询,最多也只是让你多查了几次数据库/微信,不应该重新发起支付。

伪代码:

@RestController
@RequestMapping("/pay")
public class PayQueryController {

    @GetMapping("/status")
    public PayStatusDTO queryPayStatus(@RequestParam String bizOrderNo) {
        PayOrder payOrder = payOrderRepository.findByBizOrderNo(bizOrderNo);
        if (payOrder == null) {
            return PayStatusDTO.notFound();
        }

        // 已成功,直接返回
        if (PayStatus.SUCCESS.name().equals(payOrder.getStatus())) {
            return PayStatusDTO.success();
        }

        // 还在 INIT / PAYING,可以选用:本地直接返回“支付中”即可
        // 或者再调一次微信订单查询接口做同步(注意限流)
        return PayStatusDTO.processing();
    }
}

注意千万别在这个查询接口里,发现没成功就“顺手再帮用户下单一次”,那就真成“官方制造重复支付”了。

七、再补几招“锦上添花”的防重复策略

上面这些是服务端必须做的,再说几个常见但不是核心的:

  1. 前端按钮防抖 / 禁用支付按钮点击后直接置灰,等结果回来再恢复,能减少“误点多次”的概率,但绝对不能当成唯一手段。

  2. 支付 token / nonce服务端在创建支付前发一个一次性的 token,前端带着 token 调起支付接口,成功/失败后 token 作废,同一个 token 只能用一次。 这个更适合“防重复提单”,和支付本身一起用效果更好。

  3. Redis 分布式锁比如对 bizOrderNo 做一个短期锁:

    String lockKey = "lock:pay:" + bizOrderNo;
    boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1"10, TimeUnit.SECONDS);
    if (!locked) {
        // 有别的请求正在处理这笔订单,可以直接返回“处理中”
    }

    但要记住:锁只是辅助,真正硬保证的还是数据库的唯一约束 + 状态机

如果只记得一件事,那就是:

防重复支付 = 支付单表 + 唯一约束 + 幂等回调 + 正确的状态机。

-END-

我为大家打造了一份RPA教程,完全免费:songshuhezi.com/rpa.html


🔥东哥私藏精品🔥


东哥作为一名老码农,整理了全网最全《Java高级架构师资料合集》。总量高达650GB点击下方公众号回复关键字java 全部免费领取

【声明】内容源于网络
0
0
Java技术图谱
回复 java,领取Java面试题。分享AI编程,AI工具,Java教程,Java下载,Java技术栈,Java源码,Java课程,Java技术架构,Java基础教程,Java高级教程,idea教程,Java架构师,Java微服务架构。
内容 1111
粉丝 0
Java技术图谱 回复 java,领取Java面试题。分享AI编程,AI工具,Java教程,Java下载,Java技术栈,Java源码,Java课程,Java技术架构,Java基础教程,Java高级教程,idea教程,Java架构师,Java微服务架构。
总阅读62
粉丝0
内容1.1k