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