微信支付对接实战:从下单到回调的完整落地过程
这篇文章尝试从一套跑了多年的生产支付系统出发把微信支付从下单到回调确认收款的完整过程讲一遍。代码都来自真实线上代码,关键逻辑一行没动你可以直接拿去改改用在自己的项目里。这套系统对接的是微信支付V2版接口XML报文。V3版换成了JSON加RSA证书体系报文格式和鉴权方式不同但下单、调起、回调确认的交互骨架是一样的这里的流程和防护思路对两个版本都适用。先看全貌一笔支付要经过哪几层生产环境的支付系统一般不是单个应用而是按职责拆成几层。这套系统是微服务形态拆成了五个服务逻辑上就是四层角色业务层订单系统用户点支付后发起请求带着订单号和支付渠道支付编排层支付中心负责预创建支付单、组装渠道参数、调渠道下单、处理回调、推进支付单状态渠道层微信渠道服务唯一和微信支付API打交道的地方统一下单、查单都在这数据层支付数据服务支付单、支付明细、商户配置的持久化单体应用把这四层理解成四个模块就行调用链路和防护逻辑完全一样。一笔小程序支付的完整时序是这样的微信官方文档把每一步的报文字段都写得很清楚我不逐字段贴了。下面只讲每个环节真正决定成败的部分。准备工作开始写代码之前先把四样东西备齐商户号mch_id和对应的appid在微信支付商户平台申请小程序支付用的是小程序的appidAPI密钥商户平台自行设置的32位密钥用于签名和验签。注意它既不是小程序的appSecret也不是V3的APIv3密钥三套东西经常被人搞混回调地址公网可直接访问的地址不能带参数不能有重定向生产环境建议用httpsSDK不建议裸调HTTP签名、验签、XML序列化这些琐事SDK都做掉了。Java生态里常用的是WxJavadependencygroupIdcom.github.binarywang/groupIdartifactIdweixin-java-pay/artifactIdversion3.8.0/version/dependency这套生产系统用的就是这个版本跑了多年没出过大问题。统一下单六个核心参数统一下单接口的参数有几十个真正必须传对的是这六个其余按需查文档参数说明容易踩的坑tradeType交易类型小程序和公众号填JSAPIAPP填APPH5填MWEB填错直接报错body商品描述简单描述即可别堆营销文案outTradeNo商户订单号32字符以内同一商户号下唯一totalFee总金额单位是分不是元notifyUrl回调地址公网直达不带参数不能重定向openid用户标识JSAPI必传服务商模式传sub_openid这张表建议收藏下单接口的报错大部分都能在这六个参数里找到原因。outTradeNo用支付单号不用业务订单号一个值得注意的设计这套系统的outTradeNo不是业务订单号而是支付中心生成的支付单号。原因在于业务订单和支付单不是一对一的。用户取消支付后重新发起、换个渠道再付同一笔业务订单会对应多次支付尝试。如果拿业务订单号当outTradeNo第二次发起就会被微信以商户订单号重复为由拒绝。支付单号每次生成都不同业务单和支付单解耦重复发起支付就不受限制。金额元转分用BigDecimal微信要的金额单位是分系统内部一般存元。转换就一行// 元转分BigDecimal直接乘100不要用doubleinttotalFeeamount.multiply(BigDecimal.valueOf(100)).intValue();用double做金额运算是新手最常犯的错误0.1加0.2不等于0.3,这种问题在支付系统里就是资损。多商户配置怎么选稍具规模的系统都不止一套商户配置普通商户一套、服务商模式子商户一套、APP支付又一套。下单前要根据请求特征选出正确的配置这套系统的做法是在请求对象上放一个pickConfig方法publicWxPayConfigpickConfig(PayPropertiesproperties){WxPayConfigconfignewWxPayConfig();// 请求显式携带配置时优先支持动态指定商户if(paymentConfig!null){config.setAppId(paymentConfig.getAppId());config.setMchId(paymentConfig.getMchId());config.setMchKey(paymentConfig.getKey());returnconfig;}// 服务商模式除了服务商自己的appid和商户号还要带上子商户信息if(isSub()){config.setAppId(properties.getSpAppId());config.setMchId(properties.getSpMchId());config.setMchKey(properties.getSpKey());config.setSubAppId(properties.getSubAppId());config.setSubMchId(properties.getSubMchId());returnconfig;}// 默认普通商户config.setAppId(properties.getAppId());config.setMchId(properties.getMchId());config.setMchKey(properties.getKey());returnconfig;}配置选错是联调阶段最常见的问题之一报签名错误时先怀疑配置再怀疑代码。下单调用与一个重要的线程安全坑选好配置调用本身只有三行// WxPayService不能做成单例或成员变量setConfig会修改内部共享状态多线程下会串配置WxPayServicewxPayServicenewWxPayServiceImpl();wxPayService.setConfig(request.pickConfig(payProperties));WxPayUnifiedOrderResultresultwxPayService.unifiedOrder(request.toWxPayUnifiedOrderRequest());第一行注释来自线上代码的原意注释里明确写着这个service不能当全局变量用。背后的原因WxJava的设计是每次使用前setConfigconfig存在实例字段上如果把WxPayService做成单例共享多线程并发时配置必然互相覆盖A商户的单可能拿B商户的密钥去签名结果就是大面积验签失败。每个请求new一个对象很轻不用担心开销。异常处理上有个细节WxPayException里带着微信返回的错误码和错误信息要原样记进日志。联调和线上排查时非常有用。下单成功后把配置快照存下来拿到微信返回的prepay_id后支付中心把支付单状态推进到支付中同时存两样东西prepay_id和这次下单用的商户配置快照。配置快照的作用是后面回调来了要用当初下单的那套商户密钥去验签、去查单。如果系统商户配置中途调整过用当前配置去验历史单的回调就会失败。把配置跟着支付单存下来这笔支付就永远自带它出生时的上下文。二次签名把参数安全地交给前端统一下单拿到的prepay_id不能直接丢给前端。前端调起微信支付需要六个参数其中paySign要用商户密钥对另外五个再签一次。这个签名必须在服务端做密钥永远不能出服务端。六个参数appId、timeStamp、nonceStr、package固定格式prepay_idxxx、signType、paySign。WxJava里对应WxPayMpOrderResultWxPayMpOrderResultpayResultWxPayMpOrderResult.builder().appId(appId).timeStamp(timestamp).nonceStr(nonceStr).packageValue(prepay_idprepayId).signType(MD5).build();// 用商户密钥对上面五个参数二次签名结果写进paySignpayResult.setPaySign(SignUtils.createSign(payResult,MD5,config.getMchKey(),null));签名方式以商户平台的配置为准这套系统用的是V2接口默认的MD5如果你的商户号配置了HMAC-SHA256下单和二次签名两处要一起换。前端拿到这六个参数小程序里直接调起wx.requestPayment({timeStamp:data.timeStamp,nonceStr:data.nonceStr,package:data.packageValue,signType:data.signType,paySign:data.paySign,success(res){// 只代表用户完成了支付操作不代表系统已确认收款}})最后这个点值得单独说前端success回调只说明用户在微信收银台完成了支付动作不代表钱已到账更不代表你的系统知道了。发货、加积分、开通权益的依据永远是服务端确认过的支付状态也就是下面要讲的回调。支付回调整个对接里最该花心思的地方用户付完钱微信会向下单时传的notifyUrl推送一条XML报文。这条报文是支付状态推进的权威触发源处理它要过四道防线。回调入口原样接收规范应答PostMapping(/callback/wechat)publicStringwechatPayCallback(RequestBodyStringxmlData){// xml原样接收不要提前做任何反序列化booleanhandledpayCallbackHandler.doCallback(PayConstant.CHANNEL_WECHAT,xmlData);returnhandled?WxPayNotifyResponse.success(OK):WxPayNotifyResponse.fail(FAIL);}两个细节。第一报文用String原样接验签需要原始报文提前转成对象反而麻烦。第二应答必须用微信规定的XML格式处理成功返回success任何失败都返回fail。返回fail或HTTP非200时微信会在接下来的一段时间里按逐渐拉长的间隔重推这条通知。这个重推机制既是你的兜底也是后面幂等这道防线必须存在的原因。四道防线第一道验签。WxJava把解析XML和验签合成了一步验签不过直接抛异常WxPayServicewxPayServicenewWxPayServiceImpl();WxPayConfigconfignewWxPayConfig();// 用这笔支付单当初下单时的商户密钥验签来自前面存的配置快照config.setMchKey(configSnapshot.getMchKey());wxPayService.setConfig(config);WxPayOrderNotifyResultnotifyResultwxPayService.parseOrderNotifyResult(xmlData);验签确认报文确实来自微信且传输中没被篡改。但这只说明报文来源可信不代表内容可以无条件采信后面还有三道。第二道幂等。微信会重推网络抖动也可能让同一条通知到达多次。处理前先查库支付单已经是成功状态直接应答成功让它别再推了// 已成功处理的单重复通知直接应答成功if(PayConstant.STATUS_SUCCESS.equals(paymentTrade.getPayStatus())){returntrue;}注意这里返回的是成功而不是失败。重复通知不是错误场景返回fail只会让微信继续重推白白增加无效流量。第三道订单号和金额校验。回调里的outTradeNo要在库里找到唯一一笔对应渠道的支付明细找不到或对不上就拒绝。金额更要一分不差// 回调金额单位是分转回元再和支付单金额比对BigDecimalnotifyAmountBigDecimal.valueOf(notifyResult.getTotalFee()).divide(BigDecimal.valueOf(100),2,RoundingMode.DOWN);if(notifyAmount.compareTo(tradeDetail.getAmount())!0){log.error(回调金额与支付单不一致tradeNo{},notifyResult.getOutTradeNo());returnfalse;}金额校验防两类问题报文内容被构造或串单以及自己系统的金额单位换算出错。不管哪类金额对不上还继续走下去就是错账。第四道主动查单核实。前三道都过了这套系统仍不直接相信回调而是拿着订单号主动调微信的查单接口以微信服务端的权威状态为准WxPayOrderQueryResultqueryResultwxPayService.queryOrder(null,outTradeNo);// return_code、result_code、trade_state三个字段都是SUCCESS才算真的支付成功booleanreallyPaidSUCCESS.equals(queryResult.getReturnCode())SUCCESS.equals(queryResult.getResultCode())SUCCESS.equals(queryResult.getTradeState());多这一步的理由回调是推过来的报文查单是你主动向微信服务端发起的询问。推送链路出任何问题权威查询都能纠回来。查单同样要用支付单存的那套配置快照。四道全过才把支付单状态推进到成功写入支付完成时间和微信侧交易号。通知业务发事件别直连支付状态确认后订单要推进、权益要发放。这些动作不该塞进回调主流程这套系统的做法是发一个Spring事件// 回调主流程只做确认和落库业务通知走事件异步化applicationEventPublisher.publishEvent(newPaymentCallbackEvent(xmlData,paymentTrade,payChannel,notifyResult));监听器按订单类型加支付渠道两个维度匹配到对应的业务通知器各业务自己处理后续。这样回调主流程足够短微信不会因为下游业务慢而判超时业务通知失败也能独立重试不影响支付状态本身。回调检查清单这张清单建议直接收藏以后接任何支付渠道都照着过一遍检查项不做的后果验签伪造回调直接造成资损幂等微信重推导致重复发货、重复加积分订单号加金额校验串单、错账主动查单核实推送链路的问题没有任何兜底失败返回fail微信不重推这笔支付在系统里永远卡在支付中异步通知业务回调超时微信反复重推雪崩风险常见坑速查表把这套系统多年踩过的坑整理成一张表遇到问题时先在这里对一遍现象大概率原因回调一直收不到notifyUrl带了参数、公网不可达、有重定向或https证书链不完整验签失败密钥用错API密钥、appSecret、APIv3密钥是三个东西多商户场景用了错的商户配置同一笔回调来了多次正常现象微信重推机制检查你的幂等用户付了钱订单没推进回调处理失败但应答了success微信不再重推掉单了totalFee和系统金额对不上单位搞错微信是分大部分系统内部是元前端success了但系统查不到正常时序差前端success不等于服务端已确认拿它当发货依据是原则性错误下单报签名错误先查商户配置是否选对再查密钥是否带空格或换行掉单那条单独补一句。回调是推送推送就可能丢稳妥的做法是加一个对账补偿任务定时扫描支付中超过一定时间比如十分钟的支付单主动调微信查单核实状态把回调漏掉的单捞回来。前面第四道防线的查单能力正好可以直接复用。小结支付对接做到后面考验的不是调API的能力而是状态机加对账的思维方式。支付单的每一次状态推进都要能回答一个问题我凭什么相信这笔钱到了。验签回答的是报文来自微信幂等回答的是重复通知不会重复入账金额校验回答的是数额没被改动主动查单回答的是最终只信微信服务端的权威状态。四道防线看着繁琐背后其实都是前人踩过的资损案例。支付系统里快不重要准才重要。另一个是架构分层。渠道服务只做一件事和微信支付API打交道密钥、签名细节全部收在这一层上层业务甚至感知不到微信支付有V2和V3两个版本。哪天微信接口升级或者要加第二个支付渠道改动范围都很清楚。对接支付这件事把容易变的部分和不容易变的部分分开比把API调通重要得多。