微信支付对接实战:从下单到回调的完整落地过程

这篇文章尝试从一套跑了多年的生产支付系统出发,把微信支付从下单到回调确认收款的完整过程讲一遍。代码都来自真实线上代码,关键逻辑一行没动,你可以直接拿去改改用在自己的项目里。

这套系统对接的是微信支付V2版接口(XML报文)。V3版换成了JSON加RSA证书体系,报文格式和鉴权方式不同,但下单、调起、回调确认的交互骨架是一样的,这里的流程和防护思路对两个版本都适用。

先看全貌:一笔支付要经过哪几层

生产环境的支付系统一般不是单个应用,而是按职责拆成几层。这套系统是微服务形态,拆成了五个服务,逻辑上就是四层角色:

  • 业务层:订单系统,用户点支付后发起请求,带着订单号和支付渠道
  • 支付编排层:支付中心,负责预创建支付单、组装渠道参数、调渠道下单、处理回调、推进支付单状态
  • 渠道层:微信渠道服务,唯一和微信支付API打交道的地方,统一下单、查单都在这
  • 数据层:支付数据服务,支付单、支付明细、商户配置的持久化

单体应用把这四层理解成四个模块就行,调用链路和防护逻辑完全一样。

一笔小程序支付的完整时序是这样的:

微信官方文档把每一步的报文字段都写得很清楚,我不逐字段贴了。下面只讲每个环节真正决定成败的部分。

准备工作

开始写代码之前,先把四样东西备齐:

  • 商户号mch_id和对应的appid:在微信支付商户平台申请,小程序支付用的是小程序的appid
  • API密钥:商户平台自行设置的32位密钥,用于签名和验签。注意它既不是小程序的appSecret,也不是V3的APIv3密钥,三套东西经常被人搞混
  • 回调地址:公网可直接访问的地址,不能带参数,不能有重定向,生产环境建议用https
  • SDK:不建议裸调HTTP,签名、验签、XML序列化这些琐事SDK都做掉了。Java生态里常用的是WxJava:
XML 复制代码
<dependency>
    <groupId>com.github.binarywang</groupId>
    <artifactId>weixin-java-pay</artifactId>
    <version>3.8.0</version>
</dependency>

这套生产系统用的就是这个版本,跑了多年没出过大问题。

统一下单

六个核心参数

统一下单接口的参数有几十个,真正必须传对的是这六个,其余按需查文档:

参数 说明 容易踩的坑
tradeType 交易类型 小程序和公众号填JSAPI,APP填APP,H5填MWEB,填错直接报错
body 商品描述 简单描述即可,别堆营销文案
outTradeNo 商户订单号 32字符以内,同一商户号下唯一
totalFee 总金额 单位是分,不是元
notifyUrl 回调地址 公网直达,不带参数,不能重定向
openid 用户标识 JSAPI必传,服务商模式传sub_openid

这张表建议收藏,下单接口的报错,大部分都能在这六个参数里找到原因。

outTradeNo用支付单号,不用业务订单号

一个值得注意的设计:这套系统的outTradeNo不是业务订单号,而是支付中心生成的支付单号。

原因在于业务订单和支付单不是一对一的。用户取消支付后重新发起、换个渠道再付,同一笔业务订单会对应多次支付尝试。如果拿业务订单号当outTradeNo,第二次发起就会被微信以商户订单号重复为由拒绝。支付单号每次生成都不同,业务单和支付单解耦,重复发起支付就不受限制。

金额:元转分,用BigDecimal

微信要的金额单位是分,系统内部一般存元。转换就一行:

Java 复制代码
// 元转分,BigDecimal直接乘100,不要用double
int totalFee = amount.multiply(BigDecimal.valueOf(100)).intValue();

用double做金额运算是新手最常犯的错误,0.1加0.2不等于0.3,这种问题在支付系统里就是资损。

多商户配置怎么选

稍具规模的系统都不止一套商户配置:普通商户一套、服务商模式(子商户)一套、APP支付又一套。下单前要根据请求特征选出正确的配置,这套系统的做法是在请求对象上放一个pickConfig方法:

Java 复制代码
public WxPayConfig pickConfig(PayProperties properties) {
    WxPayConfig config = new WxPayConfig();
    // 请求显式携带配置时优先,支持动态指定商户
    if (paymentConfig != null) {
        config.setAppId(paymentConfig.getAppId());
        config.setMchId(paymentConfig.getMchId());
        config.setMchKey(paymentConfig.getKey());
        return config;
    }
    // 服务商模式:除了服务商自己的appid和商户号,还要带上子商户信息
    if (isSub()) {
        config.setAppId(properties.getSpAppId());
        config.setMchId(properties.getSpMchId());
        config.setMchKey(properties.getSpKey());
        config.setSubAppId(properties.getSubAppId());
        config.setSubMchId(properties.getSubMchId());
        return config;
    }
    // 默认普通商户
    config.setAppId(properties.getAppId());
    config.setMchId(properties.getMchId());
    config.setMchKey(properties.getKey());
    return config;
}

配置选错是联调阶段最常见的问题之一,报签名错误时先怀疑配置,再怀疑代码。

下单调用与一个重要的线程安全坑

选好配置,调用本身只有三行:

Java 复制代码
// WxPayService不能做成单例或成员变量,setConfig会修改内部共享状态,多线程下会串配置
WxPayService wxPayService = new WxPayServiceImpl();
wxPayService.setConfig(request.pickConfig(payProperties));
WxPayUnifiedOrderResult result = wxPayService.unifiedOrder(request.toWxPayUnifiedOrderRequest());

第一行注释来自线上代码的原意,注释里明确写着这个service不能当全局变量用。背后的原因:WxJava的设计是每次使用前setConfig,config存在实例字段上,如果把WxPayService做成单例共享,多线程并发时配置必然互相覆盖,A商户的单可能拿B商户的密钥去签名,结果就是大面积验签失败。每个请求new一个,对象很轻,不用担心开销。

异常处理上有个细节:WxPayException里带着微信返回的错误码和错误信息,要原样记进日志。联调和线上排查时,非常有用。

下单成功后,把配置快照存下来

拿到微信返回的prepay_id后,支付中心把支付单状态推进到支付中,同时存两样东西:prepay_id和这次下单用的商户配置快照。

配置快照的作用是:后面回调来了,要用当初下单的那套商户密钥去验签、去查单。如果系统商户配置中途调整过,用当前配置去验历史单的回调就会失败。把配置跟着支付单存下来,这笔支付就永远自带它出生时的上下文。

二次签名:把参数安全地交给前端

统一下单拿到的prepay_id不能直接丢给前端。前端调起微信支付需要六个参数,其中paySign要用商户密钥对另外五个再签一次。这个签名必须在服务端做,密钥永远不能出服务端。

六个参数:appId、timeStamp、nonceStr、package(固定格式prepay_id=xxx)、signType、paySign。WxJava里对应WxPayMpOrderResult:

Java 复制代码
WxPayMpOrderResult payResult = WxPayMpOrderResult.builder()
        .appId(appId)
        .timeStamp(timestamp)
        .nonceStr(nonceStr)
        .packageValue("prepay_id=" + prepayId)
        .signType("MD5")
        .build();
// 用商户密钥对上面五个参数二次签名,结果写进paySign
payResult.setPaySign(SignUtils.createSign(payResult, "MD5", config.getMchKey(), null));

签名方式以商户平台的配置为准,这套系统用的是V2接口默认的MD5,如果你的商户号配置了HMAC-SHA256,下单和二次签名两处要一起换。

前端拿到这六个参数,小程序里直接调起:

JavaScript 复制代码
wx.requestPayment({
  timeStamp: data.timeStamp,
  nonceStr: data.nonceStr,
  package: data.packageValue,
  signType: data.signType,
  paySign: data.paySign,
  success(res) {
    // 只代表用户完成了支付操作,不代表系统已确认收款
  }
})

最后这个点值得单独说:前端success回调只说明用户在微信收银台完成了支付动作,不代表钱已到账,更不代表你的系统知道了。发货、加积分、开通权益的依据,永远是服务端确认过的支付状态,也就是下面要讲的回调。

支付回调:整个对接里最该花心思的地方

用户付完钱,微信会向下单时传的notifyUrl推送一条XML报文。这条报文是支付状态推进的权威触发源,处理它要过四道防线。

回调入口:原样接收,规范应答

Java 复制代码
@PostMapping("/callback/wechat")
public String wechatPayCallback(@RequestBody String xmlData) {
    // xml原样接收,不要提前做任何反序列化
    boolean handled = payCallbackHandler.doCallback(PayConstant.CHANNEL_WECHAT, xmlData);
    return handled ? WxPayNotifyResponse.success("OK") : WxPayNotifyResponse.fail("FAIL");
}

两个细节。

第一,报文用String原样接,验签需要原始报文,提前转成对象反而麻烦。

第二,应答必须用微信规定的XML格式:处理成功返回success,任何失败都返回fail。返回fail或HTTP非200时,微信会在接下来的一段时间里按逐渐拉长的间隔重推这条通知。这个重推机制既是你的兜底,也是后面幂等这道防线必须存在的原因。

四道防线

第一道:验签。

WxJava把解析XML和验签合成了一步,验签不过直接抛异常:

Java 复制代码
WxPayService wxPayService = new WxPayServiceImpl();
WxPayConfig config = new WxPayConfig();
// 用这笔支付单当初下单时的商户密钥验签,来自前面存的配置快照
config.setMchKey(configSnapshot.getMchKey());
wxPayService.setConfig(config);
WxPayOrderNotifyResult notifyResult = wxPayService.parseOrderNotifyResult(xmlData);

验签确认报文确实来自微信且传输中没被篡改。但这只说明报文来源可信,不代表内容可以无条件采信,后面还有三道。

第二道:幂等。

微信会重推,网络抖动也可能让同一条通知到达多次。处理前先查库,支付单已经是成功状态,直接应答成功让它别再推了:

Java 复制代码
// 已成功处理的单,重复通知直接应答成功
if (PayConstant.STATUS_SUCCESS.equals(paymentTrade.getPayStatus())) {
    return true;
}

注意这里返回的是成功而不是失败。重复通知不是错误场景,返回fail只会让微信继续重推,白白增加无效流量。

第三道:订单号和金额校验。

回调里的outTradeNo,要在库里找到唯一一笔对应渠道的支付明细,找不到或对不上就拒绝。金额更要一分不差:

Java 复制代码
// 回调金额单位是分,转回元再和支付单金额比对
BigDecimal notifyAmount = BigDecimal.valueOf(notifyResult.getTotalFee())
        .divide(BigDecimal.valueOf(100), 2, RoundingMode.DOWN);
if (notifyAmount.compareTo(tradeDetail.getAmount()) != 0) {
    log.error("回调金额与支付单不一致,tradeNo={}", notifyResult.getOutTradeNo());
    return false;
}

金额校验防两类问题:报文内容被构造或串单,以及自己系统的金额单位换算出错。不管哪类,金额对不上还继续走下去,就是错账。

第四道:主动查单核实。

前三道都过了,这套系统仍不直接相信回调,而是拿着订单号主动调微信的查单接口,以微信服务端的权威状态为准:

Java 复制代码
WxPayOrderQueryResult queryResult = wxPayService.queryOrder(null, outTradeNo);
// return_code、result_code、trade_state三个字段都是SUCCESS,才算真的支付成功
boolean reallyPaid = "SUCCESS".equals(queryResult.getReturnCode())
        && "SUCCESS".equals(queryResult.getResultCode())
        && "SUCCESS".equals(queryResult.getTradeState());

多这一步的理由:回调是推过来的报文,查单是你主动向微信服务端发起的询问。推送链路出任何问题,权威查询都能纠回来。查单同样要用支付单存的那套配置快照。

四道全过,才把支付单状态推进到成功,写入支付完成时间和微信侧交易号。

通知业务:发事件,别直连

支付状态确认后,订单要推进、权益要发放。这些动作不该塞进回调主流程,这套系统的做法是发一个Spring事件:

Java 复制代码
// 回调主流程只做确认和落库,业务通知走事件异步化
applicationEventPublisher.publishEvent(
        new PaymentCallbackEvent(xmlData, paymentTrade, payChannel, notifyResult));

监听器按订单类型加支付渠道两个维度匹配到对应的业务通知器,各业务自己处理后续。这样回调主流程足够短,微信不会因为下游业务慢而判超时;业务通知失败也能独立重试,不影响支付状态本身。

回调检查清单

这张清单建议直接收藏,以后接任何支付渠道都照着过一遍:

检查项 不做的后果
验签 伪造回调直接造成资损
幂等 微信重推导致重复发货、重复加积分
订单号加金额校验 串单、错账
主动查单核实 推送链路的问题没有任何兜底
失败返回fail 微信不重推,这笔支付在系统里永远卡在支付中
异步通知业务 回调超时,微信反复重推,雪崩风险

常见坑速查表

把这套系统多年踩过的坑整理成一张表,遇到问题时先在这里对一遍:

现象 大概率原因
回调一直收不到 notifyUrl带了参数、公网不可达、有重定向,或https证书链不完整
验签失败 密钥用错:API密钥、appSecret、APIv3密钥是三个东西;多商户场景用了错的商户配置
同一笔回调来了多次 正常现象,微信重推机制,检查你的幂等
用户付了钱订单没推进 回调处理失败但应答了success,微信不再重推,掉单了
totalFee和系统金额对不上 单位搞错,微信是分,大部分系统内部是元
前端success了但系统查不到 正常时序差,前端success不等于服务端已确认,拿它当发货依据是原则性错误
下单报签名错误 先查商户配置是否选对,再查密钥是否带空格或换行

掉单那条单独补一句。回调是推送,推送就可能丢,稳妥的做法是加一个对账补偿任务:定时扫描支付中超过一定时间(比如十分钟)的支付单,主动调微信查单核实状态,把回调漏掉的单捞回来。前面第四道防线的查单能力,正好可以直接复用。

小结

支付对接做到后面,考验的不是调API的能力,而是状态机加对账的思维方式。支付单的每一次状态推进,都要能回答一个问题:我凭什么相信这笔钱到了。验签回答的是报文来自微信,幂等回答的是重复通知不会重复入账,金额校验回答的是数额没被改动,主动查单回答的是最终只信微信服务端的权威状态。四道防线看着繁琐,背后其实都是前人踩过的资损案例。

支付系统里,快不重要,准才重要。

另一个是架构分层。渠道服务只做一件事:和微信支付API打交道,密钥、签名细节全部收在这一层,上层业务甚至感知不到微信支付有V2和V3两个版本。哪天微信接口升级,或者要加第二个支付渠道,改动范围都很清楚。对接支付这件事,把容易变的部分和不容易变的部分分开,比把API调通重要得多。

相关推荐
神奇小汤圆1 小时前
架构师必备:分布式锁方案选型
后端
shengjk11 小时前
付费上班的时代,真的来了
后端
玉鸯1 小时前
旧概念还是新范式?Agent 框架集体"图化"背后的矛盾与必然
后端·llm·agent
倾颜2 小时前
断线之后,不要重跑 AI:在 POST + NDJSON 中实现可恢复 Agent 流
前端·后端·agent
程序员黑豆2 小时前
鸿蒙应用开发之父子组件传参:@Prop 装饰器使用教程
前端·后端·harmonyos
程序员黑豆2 小时前
鸿蒙应用开发之V2状态管理:@Local、@ObservedV2、@Trace 使用教程
前端·后端·harmonyos
tonydf2 小时前
传统老手艺之容器编排
后端·容器·aiops
Slice_cy2 小时前
Mint 自研框架设计与实现:从重复开发走向配置驱动(三)
前端·后端·架构
写代码的强哥2 小时前
TiDB 和 OceanBase 对比:架构师视角下的企业选型实战指南
数据库·云原生·架构