简介:面向微信小程序开发者与Java后端工程师的一份支付后台实现实例文档,旨在帮助读者快速掌握小程序支付从后端到前端的完整对接流程。全文以Java实现为主线,覆盖用户OpenId获取、订单号生成与管理、调用微信统一下单接口并进行签名、解析XML返回数据、二次签名生成支付参数,以及前端wx.requestPayment调起支付等关键环节;同时写明notify_url支付回调、通过查询订单接口校验支付结果以避免假支付与重复支付、异常捕获和处理等注意事项。文档还解释了为何将appid、mch_id等敏感参数放入环境变量,并给出LeanCloud云引擎环境下的实现细节,便于安全部署与二次开发。压缩包内为1个PDF文件,大小约71KB,内容精炼,属于可直接查阅的技术笔记。已有2339人学习下载,适合具备一定Java基础、正在开发微信小程序支付功能的开发者参考。 小程序支付后台这东西,说难不难,说简单也真不简单。我刚入行那会儿,以为后端接个支付就是调个接口、传个参数、收个回调,结果真上手才发现,光签名验签、证书配置、回调解密就能折腾到怀疑人生。尤其是微信支付升级到APIv3之后,整个对接模型跟老的v2完全不同——不再是MD5签名加XML报文那一套了,换成了RSA非对称签名+AES对称加密的组合,光理解透这套体系就得花不少时间。这篇博文,我就拿自己最近做的一个Java后端实例来说说,微信小程序支付后台到底怎么落地,从技术选型到核心代码再到排坑实录,一次性讲透,适合刚接手支付模块的后端开发,也适合准备自己接支付的小团队参考。
1. 整体设计思路:为什么支付后台要独立成服务
1.1 支付模块不该塞进业务代码里
我刚接这个需求的时候,产品经理给的说法是“就在用户下单接口里加个支付功能就行”,但真正做架构设计时我没这么干。支付这个模块有个很特殊的性质:它牵涉到资金、订单状态、回调通知、对账、退款,一旦出错就是钱的问题。如果直接塞在下单业务里,后面退款通知、付款回调这些被动请求就会把业务代码搅得一团糟。
所以我的做法是:支付后台独立成一个Spring Boot服务,只干三件事——下单时生成预支付参数、接收微信支付回调并更新订单状态、提供主动查询和退款接口。业务系统通过内部HTTP接口或者MQ来跟支付服务通信,支付状态变更只认微信支付回调的结果,不认本地下单状态。
这套设计的核心逻辑是“信任边界要清晰”。小程序端唯一信任的是后端返回的支付参数,后端唯一信任的是微信支付服务器发来的回调。业务系统内部的事情,不管是订单状态还是库存扣减,都必须在回调成功之后再去做,这样哪怕某个环节挂了,最多是订单未支付,不会出现钱收了货没发这种大事故。
1.2 技术选型:官方SDK还是自己封装
微信支付官方其实提供了Java SDK,也就是wechatpay-java,它把APIV3的签名、验签、加解密都封装好了,用起来很省事。但我这次没直接用官方SDK,而是自己封装了一层。原因有几点:
第一,官方SDK升级节奏跟业务侧依赖不一定匹配,我们项目里用的Spring Boot版本和HTTP客户端版本跟SDK偶有冲突,自己封装反而可控。第二,支付这块的逻辑说到底是固定的:构造请求、签名、发请求、验签、解密响应,这些步骤了解透了,用HttpClient完全够用,还能让团队里的人都搞明白原理,而不是只会调SDK。
不过我得说句公道话:如果你们团队没有深入排查问题的经验,或者工期特别紧,用官方SDK绝对比自己造轮子稳。官方SDK的坑它是真提前帮你们踩完了,文档也全。我自己封装主要是想彻底搞清楚每个环节,后面排查问题心里更有底。
1.3 部署结构:证书和密钥的安全位置
这是很容易被忽略但又极其重要的一点。商户私钥、APIv3密钥这种敏感信息,绝对不能放在代码仓库里,更不能直接写死在配置文件中。我这次是把私钥文件放在独立的配置目录,部署时通过环境变量传入路径,密钥信息配置在Nacos或环境变量中,代码里只读取${WXPAY_PRIVATE_KEY_PATH}这类占位符。
再说一下证书这块的部署位置。证书请求到的是商户证书和平台证书,其中平台证书建议定时下载更新,因为微信会定期轮换平台证书,如果证书更新了而你本地还是旧的,回调验签就会失败。我这边的做法是搞了个定时任务,每天凌晨检查一次平台证书,有新版本就自动下载更新,避免工作日突然收到验签失败的报警。
2. APIv3签名原理:小程序支付Java实现的核心基础
2.1 微信支付V3的“身份证+手写签名”机制
很多新手被支付V3吓住,主要就是被签名验签这套机制唬住了。我打个比方:微信支付的请求签名,就像你办事要带身份证(商户证书)还要亲手签字(私钥签名)。请求发出去的时候,你用商户私钥对请求内容做签名,微信服务器用你的商户证书公钥来验证——确认真的是你发的。反过来,微信服务器返回数据时,用平台私钥签名,你用平台证书公钥去验——确认真的是微信回的。
这里有个非常关键的细节:请求签名时,怎么签名、哪些内容参与签名,是有固定规则的。APIV3要求的签名串格式是:
HTTP方法\n 请求路径\n 请求时间戳\n 请求随机串\n 请求报文主体\n每一项中间用\n换行隔开,缺一不可。而且请求报文主体如果为空,这一项也要是空字符串,但换行还是要的。很多第一次对接的人在这上面栽跟头:要么多加了空格,要么最后一行多加了换行,结果签名一直对不上。
2.2 核心签名工具类实现
我贴一段我实际在用的签名工具类,做了精简,基本能直接抄:
public class WechatPaySignUtil { // 商户私钥,从证书文件中读取 private PrivateKey privateKey; public WechatPaySignUtil(String privateKeyPath) throws Exception { String privateKeyContent = FileUtils.readFileToString(new File(privateKeyPath), "utf-8"); privateKey = getPrivateKey(privateKeyContent); } // 构造签名 public String sign(String method, String urlPath, String timestamp, String nonceStr, String body) { String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; try { Signature sign = Signature.getInstance("SHA256withRSA"); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(sign.sign()); } catch (Exception e) { throw new RuntimeException("签名失败", e); } } // 读取PKCS8格式私钥 private PrivateKey getPrivateKey(String privateKeyContent) throws Exception { String privateKeyStr = privateKeyContent .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s+", ""); byte[] keyBytes = Base64.getDecoder().decode(privateKeyStr); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); return keyFactory.generatePrivate(keySpec); } }签名算法是SHA256withRSA,私钥格式是PKCS8。这里有个大坑:很多商户从微信支付商户平台下载的证书私钥是.pem格式,但文件内容可能带BEGIN PRIVATE KEY也可能带BEGIN RSA PRIVATE KEY。前者是PKCS8,可以直接按上面代码解析;后者是PKCS1格式,Java原生不支持,得先转格式,或者用BouncyCastle辅助解析。这种情况多见于从某些第三方工具生成的密钥,从微信官方平台下载的一般都是PKCS8。
2.3 请求头里的Authorization这么拼
签名做完之后,真正的请求头Authorization要按这个格式拼:
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="商户号",nonce_str="随机串",timestamp="时间戳",serial_no="商户证书序列号",signature="上一步生成的签名"注意serial_no是商户证书序列号,不是商户号,这俩特别容易搞混。证书序列号可以在商户平台证书管理里看,也可以在证书详情里看,是一串十六进制字符串。我见过有同事把serial_no和mchid写反,然后排查了一整天——微信返回的报错信息只提示“商户证书序列号不正确”,不会直接告诉你哪个字段填错了。
URL路径这块也要小心。比如JSAPI下单的URL是/v3/pay/transactions/jsapi,签名的时候要把域名摘掉,只保留路径部分。如果用了https://api.mch.weixin.qq.com这种完整URL去签名,那不是签名对不上,就是验签失败,因为微信服务端校验时拿到的路径不是这个。
3. 下单与回调:小程序支付后台Java实现的关键流程
3.1 JSAPI下单参数构造
小程序端的支付流程是:用户点击支付→后端调用微信支付下单接口→拿到prepay_id→后端按照小程序调起支付的要求拼接参数→小程序端用wx.requestPayment拉起支付。
我这次对接的是JSAPI下单,地址是https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi。请求体大概长这样:
{ "appid": "小程序appid", "mchid": "商户号", "description": "商品描述", "out_trade_no": "商户订单号", "notify_url": "https://your-domain.com/api/wxpay/callback", "amount": { "total": 100, "currency": "CNY" }, "payer": { "openid": "用户openid" } }amount.total是整数,单位是分。这一条我反复强调:99.9元必须是9990分,绝不能传99.9或者字符串"99.9"。微信支付以分为单位结算,如果这里传错,不是下单失败就是支付金额错误。我建议在入参层就把金额转换做好,比如统一接收“元”为单位,入库和调微信时乘以100,这样业务层和老系统对接时不容易搞混。
out_trade_no这个订单号也很讲究:同一商户号下必须唯一,而且长度有限制,传了重复的号直接报错。我用的规则是“业务前缀+时间戳+随机数”,比如PAY202506081030120001,这样既保证唯一性,排查问题时也能从订单号一眼看出是哪条业务链路的。
下单接口返回的内容比较简单,核心就是prepay_id,但要注意保存关联关系。我的做法是把prepay_id和out_trade_no存到支付流水表里,不仅下单要用,后面查单、退款时也能用到。
3.2 调起支付参数的拼接细节
拿到prepay_id之后,后端需要返回给小程序一组参数,让小程序端能调起支付。这一步也是很多人的重灾区,因为这里不是直接用prepay_id就完事了,而是要重新签一次名。
后端需要返回的参数是:appId、timeStamp、nonceStr、package、signType、paySign。其中package字段的值是prepay_id=xxx,必须完整带上prepay_id=这个前缀。
paySign的签名串格式是:
appId=xxx\n timeStamp=xxx\n nonceStr=xxx\n package=prepay_id=xxx\n注意这里的签名串跟前面请求微信支付的签名串不是一回事:调起支付是给微信小程序端用的,签名规则是appId、timeStamp、nonceStr和package按换行拼接,然后再用商户私钥进行SHA256withRSA签名。我见过不少人把这两套签名串搞混,拿请求微信支付的签名方式去签调起支付参数,结果小程序端一直报“支付签名验证失败”。
timeStamp这里有个细节:它要求是字符串类型,单位是秒。有的语言默认生成的是毫秒,不转就直接报错。我们Java后端要记得String.valueOf(System.currentTimeMillis() / 1000),否则差了1000倍,签名怎么都对不上。
3.3 回调接收与验签解密
支付成功之后,微信支付服务器会往notify_url发一个POST请求,通知支付结果。这个回调是整个支付后台最核心也最容易出事的地方。
回调通知有两大步骤必须做:第一步验签,确认这个通知真的是微信支付发送的;第二步解密,因为通知里的resource字段是加密的,需要用APIv3密钥做AES-256-GCM解密。
验签的逻辑是:微信支付用平台证书私钥对通知内容做了签名,我们收到通知后,用平台证书公钥去验。验签需要的签名串是:
时间戳\n 随机串\n 请求报文主体\n这里的请求报文主体就是回调POST过来的整个JSON字符串,跟前面下单时的签名规则不一样——回调验签不包含HTTP方法。很多人第一次验签失败,就是习惯性地把HTTP方法拼进去了。
解密就按官方给的AES-256-GCM来做,下面是我精简后的解密代码:
public static String decryptCallback(String associatedData, String nonce, String ciphertext) { byte[] keyBytes = "你的APIv3密钥32位字符串".getBytes(StandardCharsets.UTF_8); byte[] nonceBytes = nonce.getBytes(StandardCharsets.UTF_8); byte[] associatedDataBytes = associatedData.getBytes(StandardCharsets.UTF_8); byte[] ciphertextBytes = Base64.getDecoder().decode(ciphertext); try { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(128, nonceBytes); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); cipher.updateAAD(associatedDataBytes); byte[] plainText = cipher.doFinal(ciphertextBytes); return new String(plainText, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException("回调解密失败", e); } }解密成功之后,你会拿到一个JSON,里面有out_trade_no、transaction_id、trade_state、amount这些关键信息。这时候要做的事情很明确:更新本地支付流水状态、通知业务系统订单支付成功、然后立刻返回响应给微信支付。
这里必须强调一个最常见的坑:回调接口收到通知后,必须先返回成功响应,再处理业务逻辑。有的同学会把业务处理放在返回响应前面,一旦业务逻辑抛异常,微信就收不到成功响应,会一直重试。而且重试不是一次两次的事,微信支付会按照一定的间隔多次重试,最典型的后果就是:你的订单状态更新逻辑被重复执行。
正确姿势是:收到回调→验签解密→记录日志→立刻返回{"code":"SUCCESS","message":"成功"}→异步处理业务逻辑。但异步处理也要注意幂等,因为极端情况下微信可能因为超时重发通知,同一笔订单会收到多次回调。所以更新状态时一定要有幂等判断:如果订单已经是“支付成功”状态,直接忽略本次处理。
3.4 查单与退款
除了下单和回调,支付后台日常必备的两个能力是主动查单和退款。
查单接口是GET /v3/pay/transactions/out-trade-no/{out_trade_no},参数为mchid,返回当前订单在微信侧的支付状态。这个接口主要用在两个场景:一是用户支付过程中异常退出,前端迟迟没收到支付结果时,前端轮询后端,后端主动查单确认状态;二是对账时几小时前的订单状态有疑问,主动去微信侧确认。
退款接口是POST /v3/refund/domestic/refunds,请求体里要传out_trade_no(原商户订单号)、out_refund_no(退款单号)、amount(包含refund、total、currency)。退款同样需要验签回调,这个回调跟支付回调的URL要分开配,建议是两个不同的接口分别处理,逻辑更清晰。
退款这块我踩过一个坑:退款金额单位也是分,而且amount.total必须等于原订单金额,不能只是退款金额。有的业务场景是部分退款,比如100元的订单退30元,refund传3000,total传10000。这里total是原单金额,不是退款金额,传错了直接报参数错误。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
我把自己实际对接过程中遇到的高频问题整理成了表格,基本覆盖了大部分人第一次接支付时能踩的坑:
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| 下单接口返回“商户证书序列号不正确” | serial_no填错成商户号或证书过期 | 确认Authorization头里的serial_no是证书序列号 |
| 签名报错“无效签名” | 签名串格式不对,多空格、少换行 | 严格按官方格式拼接,注意最后一项后也有换行 |
| 调起支付时报“支付签名验证失败” | 后端返回给前端时用的签名规则不对 | 调起支付的签名是appId+timeStamp+nonceStr+package组合,不是请求微信的规则 |
| 回调验签失败 | 平台证书太旧,被微信轮换了 | 做平台证书定时更新任务 |
| 回调解密失败,javax.crypto.AEADBadTagException | APIv3密钥配置错误,或nonce/associatedData不对 | 核对APIv3密钥(32位),确认解密参数取自回调body而不能自己生成 |
| 回调重复通知导致订单重复处理 | 业务逻辑在返回响应前执行,或没做幂等 | 先返回成功,再处理业务,处理逻辑加幂等判断 |
| 报错“订单金额不合法” | 金额单位不对或传了字符串 | 金额全部以“分”为单位,int型传输 |
4.2 用抓包工具定位支付问题
小程序端支付出问题时,光靠看后端日志往往不够,还得抓小程序端的请求。微信小程序是运行在微信客户端里的,常规浏览器F12抓不到,我这边用的是抓包工具来定位。Windows上可以用Fiddler或Burp Suite,配好HTTPS证书代理后,微信小程序的请求就能看得一清二楚。
具体配置流程不复杂:代理工具开启HTTPS解密,手机和电脑连同一局域网,手机WiFi设置里把代理指向电脑IP和端口,然后手机会提示下载并信任代理证书。证书安装完成后,小程序里的请求就能明文看到了。
但这里必须要泼盆冷水:抓包有自己的合规边界。抓自己开发调试中的小程序请求,这是开发排查问题的正常操作;但对线上别人的小程序做抓包分析,且不说技术可行性,这本身就涉嫌越权和违规。我在团队里带的规矩是:抓包工具只用于联调测试环境和预发布环境,生产环境一律通过后端日志和微信支付商户平台的订单查询来排查。这个边界守住,既是对用户负责,也是对自己负责。
4.3 模拟回调的本地调试技巧
微信支付回调需要公网地址能访问到你的notify_url,但开发环境通常在内网,微信服务器访问不到。我开发调试时的做法是用内网穿透工具,把本机服务映射到一个临时公网地址,然后用微信支付商户平台的“支付测试”能力或直接用工具模拟回调。
每次回调测试前,记得先把平台证书下载配置好。我遇到过太多次:本地证书是旧的,回调验签一直失败,浪费了半天时间才发现是证书没更新。用工具模拟回调时,要认准微信支付官方文档给出的签名规则,别拿网上随便找的模拟工具——如果那个工具本身签名的逻辑就是错的,你测出来的问题全是被工具误导的。
4.4 支付状态不一致的排查思路
线上运营阶段最头疼的问题就是:用户说付款了,但订单还是待支付。这种问题排查思路一定要清晰。
第一步去微信支付商户平台查这笔订单,确认微信侧状态到底是已支付、未支付还是支付关闭。如果微信侧已支付而本地待支付,那问题基本出在回调链路:要么回调没收到、要么回调处理异常、要么验签解密失败被日志吞了。这时重点查回调接口的访问日志和异常堆栈。
如果微信侧显示未支付,那就要看用户是不是真的完成了支付。有一种常见情况是:用户在小程序端支付成功,但钱包扣款成功页面没弹出来,用户以为没支付又下了新单。这种需要前端配合看支付回调的返回时机,通常是后端返回参数给前端的速度太慢了,前端已经超时,但微信侧的支付流程已经走完。
我通常会在支付流水表里把下单、回调、查单、退款每一步都打上详细日志,包括请求参数、响应结果、耗时等,这样排查问题时能按时间线把整条链路串起来。有了这套日志体系,大部分支付状态不一致的问题半小时内都能定位到原因。
最后再分享两句实在话
支付后台这个东西,很多同学第一次做的时候觉得太高深,其实拆开看就是下单、签名、回调、解密这几件事。我的经验是:前期多花点时间把签名验签原理搞明白,比急着抄代码重要得多;真正上手了之后,遇到问题先别急着改代码,先把链路日志拉出来看,确保每一步请求和响应都正常,再往下排查。我最早做支付网关时,因为没搞懂签名原理,光一个签名错误就排查了两天,后来彻底搞明白了,再回头看那些报错—— 其实就是规则没对齐而已。对接微信支付V3别慌,把它当成一个“按规则办事的对接方”,一步步来,该验签的验签、该解密的解密、该幂等的幂等,基本就稳了。希望这篇东西能帮你少踩几个坑。
本文还有配套的精品资源,点击获取