简介:这是一套基于Spring Boot框架开发的微信小程序微信支付第三代后端源码,面向需要快速接入微信支付能力的小程序开发者与后端工程师,覆盖统一下单、支付回调验签、订单查询等核心流程,可直接参考其接口设计与参数处理方式。压缩包共四十个文件,内含三十个以Java语言编写的源码文件,另有XML与YML配置、属性文件、SQL数据库脚本、JAR依赖包以及说明文档,整体体积仅九十一KB,结构紧凑清晰。SQL脚本用于初始化订单相关数据表,配置类文件可设置商户号、API密钥与证书路径,文档则简述项目导入与启动步骤,便于按图索骥。目前已有104人浏览学习,适合正在实现小程序支付功能、希望避开微信支付签名与回调坑点的开发者。通过阅读源码可了解支付参数封装、请求签名、回调验签及异常处理全流程,将支付模块摘出后稍作改造即可嵌入自身项目,有效缩短接入周期与试错成本。
1. 拆解微信支付 V3 的 SpringBoot 后端实现:从签名链路开始
把一个微信小程序支付项目从 0 跑到 1,最磨人的往往不是小程序里那几行wx.requestPayment,而是后端 SpringBoot 服务怎么把微信支付 V3 的签名、验签、解密按正确顺序接起来。V3 的 HTTP 接口本身很薄,真正卡人的是它的每一个业务请求都要带基于商户私钥的 RSA 签名,而支付回调的验签用的又是另一张平台证书,两套密钥混在一起,初次接手时很容易绕晕。
这类“微信小程序微信支付 V3 后端源码 SpringBoot版”的代码包,价值不在那几个下单和回调方法,而在签名工具、证书管理、回调幂等这些通用设施有没有放对位置。大部分二次开发问题,比如下单报 401、回调验签不过、重复入账,都是这些基础环节的小错误。合适的读者是接手 SpringBoot 支付项目做二开的工程师、准备从老接口升级上 V3 的团队,以及想把支付模块从业务里拆干净的架构师。下面按我自己组织这套代码的顺序讲,从签名原理到回调排错,每个环节都给能直接抄的部分。
2. 微信支付 V3 签名与 JSAPI 下单的 SpringBoot 核心代码
2.1 商户私钥、证书序列号与 APIv3 密钥:三个配置各管什么
微信支付 V3 的配置看起来只有几个字符串,实际由三套完全不同的东西组成,混用是新手第一个大坑。先理清楚这几项再写代码,后面所有请求头就都顺了。
| 配置项 | 来源 | 格式 | 参与什么 |
|---|---|---|---|
商户 API 私钥apiclient_key.pem | 商户平台下载证书压缩包 | PKCS#8 格式 PEM | 签名每个 API 请求的 Authorization 头 |
商户 API 证书序列号serial_no | apiclient_cert.pem内的序列号 | 16 进制字符串 | 写在 Authorization 头里,让微信认出你的证书 |
| APIv3 密钥 | 商户平台自行设置 | 32 字节字符串 | 回调报文解密、平台证书下载时解密,不参与请求签名 |
| 平台证书/平台公钥 | 微信侧证书,需下载 | PEM 公钥 | 回调验签、解密平台证书响应 |
注意第一行和第二行的关系:商户私钥和商户证书是成对的,签名用的是私钥,微信侧用证书公钥验证,所以请求头里要带上证书序列号。APIv3 密钥是独立的 32 位字符串,很多老代码把 APIv3 密钥当成签名密钥拿去拼 Authorization,结果每个微信支付接口都返回签名错误。
拿到文件后可以用 openssl 快速确认商户证书序列号,避免把证书文件名填进去:
openssl x509 -in apiclient_cert.pem -noout -serial # 输出 serial=xxxx,去掉 "serial=" 前缀就是 serial_no2.2 手写微信支付 V3 请求签名:生成 Authorization 头的最小 Java 实现
微信支付 V3 的签名算法是 RSA-SHA256(即 PKCS#1 v1.5 填充的 SHA256withRSA),签名原文按“HTTP 方法、URL、时间戳、随机串、请求体”五段用换行符拼起来,且最后一段请求体后面仍要保留一个\n。这个末尾换行是官方规范明确要求的,省掉会报签名错误,不少教程都漏了。
用一个独立的WechatPayV3Signer类把签名逻辑封装好,整个项目里所有微信支付接口都能复用:
import java.nio.charset.StandardCharsets; import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; import java.util.UUID; public class WechatPayV3Signer { private final PrivateKey privateKey; private final String mchId; private final String serialNo; public WechatPayV3Signer(String mchId, String serialNo, String privateKeyPem) throws Exception { this.mchId = mchId; this.serialNo = serialNo; String base64Key = privateKeyPem .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); this.privateKey = KeyFactory.getInstance("RSA") .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64Key))); } /** * 生成请求头里的 Authorization * @param method HTTP 方法,如 POST、GET * @param canonicalUrl 去掉域名后的路径,如 /v3/pay/transactions/jsapi * @param body 原始请求体 JSON;GET 请求传空字符串 */ public String buildAuthHeader(String method, String canonicalUrl, String body) throws Exception { long timestamp = System.currentTimeMillis() / 1000; String nonce = UUID.randomUUID().toString().replace("-", ""); String message = method + "\n" + canonicalUrl + "\n" + timestamp + "\n" + nonce + "\n" + (body == null ? "" : body) + "\n"; String signature = sign(message); return "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + mchId + "\"," + "nonce_str=\"" + nonce + "\"," + "signature=\"" + signature + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + serialNo + "\""; } public String sign(String message) throws Exception { Signature signer = Signature.getInstance("SHA256withRSA"); signer.initSign(privateKey); signer.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signer.sign()); } }参数说明:canonicalUrl不需要拼域名,官方要求只取路径和查询串,比如GET /v3/pay/transactions/out-trade-no/20250101001?mchid=1900000001,签名时把?mchid=...原样带上,并把签名后的 Authorization 放到 HTTP 请求头里。时间戳必须是秒级,服务器本地时钟最好打开 NTP 同步,偏离太多微信会判定重放并拒绝。sign方法单独暴露出来,后面统一下单生成小程序端paySign时也要复用。
如果你在完整工程里看到这个类,则它通常落在common或wechat模块里,配合application.yml中用@ConfigurationProperties注入的mchId、serialNo、privateKey字段。拿到手先确认私钥加载时有没有处理掉 PEM 头尾标记,这个步骤漏了会报InvalidKeySpecException。
2.3 JSAPI 统一下单与小程序端拉起收银台的参数组装
小程序端支付走的是 JSAPI 下单接口,后端拿到prepay_id后还需要用商户私钥做“二次签名”,把新参数交给wx.requestPayment。注意这里的签名串内容和前面 API 请求的签名串完全不一样,不能直接把 Authorization 头里的 signature 当作 paySign 传给小程序。
下单逻辑的骨架如下:
public Map<String, String> jsapiPay(JsapiPayRequest req) throws Exception { // 1. 组装统一下单请求体 Map<String, Object> params = new HashMap<>(); params.put("appid", config.getAppId()); params.put("mchid", config.getMchId()); params.put("description", req.getDescription()); params.put("out_trade_no", req.getOutTradeNo()); params.put("notify_url", config.getNotifyUrl()); params.put("amount", Map.of("total", req.getTotalFen(), "currency", "CNY")); params.put("payer", Map.of("openid", req.getOpenid())); String body = objectMapper.writeValueAsString(params); String url = "/v3/pay/transactions/jsapi"; String auth = signer.buildAuthHeader("POST", url, body); // 2. 发送请求,拿到 prepay_id String resp = httpUtil.postJson("https://api.mch.weixin.qq.com" + url, auth, body); String prepayId = objectMapper.readTree(resp).get("prepay_id").asText(); // 3. 对 prepay_id 做第二次签名,生成小程序端参数 String timeStamp = String.valueOf(System.currentTimeMillis() / 1000); String nonceStr = UUID.randomUUID().toString().replace("-", ""); String packageValue = "prepay_id=" + prepayId; String signMessage = config.getAppId() + "\n" + timeStamp + "\n" + nonceStr + "\n" + packageValue + "\n"; String paySign = signer.sign(signMessage); Map<String, String> result = new HashMap<>(); result.put("timeStamp", timeStamp); result.put("nonceStr", nonceStr); result.put("package", packageValue); result.put("signType", "RSA"); result.put("paySign", paySign); return result; }逻辑说明:统一下单请求体里的amount.total单位是分,必须传整数,后端从数据库取值时不要用 double 计算金额;payer.openid是用户在小程序当前 appid 下的 openid,如果你同时维护多个小程序,下单前要校验 openid 是否属于当前 appid,否则唤起收银台会报“openid 与 appid 不匹配”。
第二步签名返回给前端的参数里,有几个点容易踩:timeStamp官方要求是字符串,后端返回 Long 时前端 JSON 解析成数字会导致签名校验失败;signType固定为RSA,不要误写支付宝的RSA2;package的值是prepay_id=开头,不要漏掉前缀。
小程序端拿到这些参数后直接拉起收银台:
wx.requestPayment({ timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.package, signType: 'RSA', paySign: res.data.paySign, success() { // 支付成功,以后端回调/主动查询为准 }, fail(e) { // errMsg 为 requestPayment:fail cancel 表示用户取消 console.error('支付失败', e); } });2.4 平台证书的下载与轮换策略
平台证书是微信侧用来给回调报文签名的证书,收到回调时要拿它的公钥验签。很多二开项目在联调时直接把商户平台网页上“API 安全”里的平台证书下载后塞到本地 resources 里,能跑通,但证书有效期约 30 天,到期后回调验签会突然失败,这是线上事故的高发点。
常见做法是启动时调用GET /v3/certificates下载最新平台证书,响应里的 resource 同样用 APIv3 密钥做 AES-GCM 解密,拿到 PEM 后存储到本地或数据库。之后起一个定时任务,每天凌晨拉取一次,比对证书序列号,发现变化就替换内存中的公钥。如果你的代码包没有这个定时任务,只靠手动上传证书,上线前务必在运维侧加一个证书到期提醒。
还有一种新能力是配置“微信支付公钥”,开启后不再需要下载和维护平台证书,所有验签都走这把固定公钥。它对基础设施更友好,但要求代码里同时兼容两种验签来源,改造前先确认当前使用的 SDK 或自研签名工具是否支持。
3. 微信支付 V3 回调验签解密:从报文到订单状态
3.1 微信支付 V3 回调报文结构与验签顺序
支付结果回调的 Content-Type 是application/json,但报文里的resource是密文,不能直接解析出订单号。原始报文结构如下:
{ "id": "EV-...", "create_time": "2025-01-01T12:00:00+08:00", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", "resource": { "algorithm": "AEAD_AES_256_GCM", "ciphertext": "base64密文", "associated_data": "transaction", "nonce": "resource 专用 nonce", "original_type": "transaction" }, "summary": "支付成功" }回调请求头里还有一组参数:Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial、Wechatpay-Signature。这里最容易混的是 nonce:请求头里的Wechatpay-Nonce用于验签,resource.nonce用于解密,两个值不一样,代码里取错位置会一直解密失败。
Controller 接收回调时,不要用 DTO 直接反序列化请求体。正确顺序是先拿原始字符串验签,验签通过后再解析 JSON、解密 resource,最后才处理业务:
@PostMapping("/notify/pay") public ResponseEntity<String> payNotify( @RequestHeader("Wechatpay-Timestamp") String timestamp, @RequestHeader("Wechatpay-Nonce") String nonce, @RequestHeader("Wechatpay-Signature") String signature, @RequestHeader("Wechatpay-Serial") String serial, @RequestBody String rawBody) throws Exception { // 1. 验签:用平台证书公钥验证签名 boolean valid = certService.verify(serial, timestamp, nonce, rawBody, signature); if (!valid) { return ResponseEntity.status(401) .body("{\"code\":\"FAIL\",\"message\":\"验签失败\"}"); } // 2. 解密 resource,得到支付结果明文 PayResult result = notifyService.decryptResource(rawBody); // 3. 业务处理与幂等判断 payOrderService.handlePaySuccess(result); // 4. 只有返回该结构,微信才会停止重试 return ResponseEntity.ok() .body("{\"code\":\"SUCCESS\",\"message\":\"成功\"}"); }验签的逻辑和 2.2 的sign方法配套:拼接timestamp + "\n" + nonce + "\n" + rawBody + "\n",用平台证书公钥做SHA256withRSA验证。注意这里参与验签的 body 必须是你收到的原始字节,不能经过 Jackson 重新序列化,否则字段顺序和空白字符一变,签名立刻失败。
3.2 用 AES-GCM 解密 resource:四个参数别取错
解密resource使用的是 APIv3 密钥,算法是AES/GCM/NoPadding,这是微信支付 V3 回调里另一个高频出错点。以下是一个可直接复用的解密方法:
private String decryptResource(String rawBody) throws Exception { JsonNode root = objectMapper.readTree(rawBody); JsonNode resource = root.get("resource"); String ciphertext = resource.get("ciphertext").asText(); String associatedData = resource.get("associated_data").asText(); String nonce = resource.get("nonce").asText(); String apiV3Key = config.getApiV3Key(); // 平台设置的 32 字节字符串 Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), "AES"); // 128 表示 GCM tag 长度,单位是 bit GCMParameterSpec gcmSpec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); // AAD 就是报文的 associated_data 字段,一般固定为 "transaction" cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); byte[] plainBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plainBytes, StandardCharsets.UTF_8); }参数说明:GCMParameterSpec的第一个参数是认证标签长度,微信固定 128 bit;nonce取resource.nonce,而不是请求头的Wechatpay-Nonce;associated_data作为 AAD 额外认证数据,解密前必须调用updateAAD;ciphertext必须先 Base64 解码再交给doFinal,不要把 Base64 字符串直接当成密文字节。
这四处任何一个搞错,都会抛AEADBadTagException或解密出乱码。解密后的明文是支付结果对象,包含out_trade_no、transaction_id、trade_state(支付成功为SUCCESS)、amount.total(单位分)、payer.openid、以及下单时传入的attach。拿到明文后再按out_trade_no去查本地订单,比对金额是否一致。
3.3 重复回调与幂等处理:订单状态机怎么设计
微信支付对回调通知有重试机制,只要你的服务没有返回SUCCESS,微信会按 15 秒、15 分钟、1 小时等间隔重试,最多重试数次。如果业务代码在“更新订单状态”和“返回响应”之间抛异常,同一笔订单可能被重复处理。
幂等设计的核心是订单状态机。简单做法是在订单表中增加状态字段,流转关系固定为:CREATED -> PAID,PAID之后不再回退。加上数据库唯一索引兜底,推荐在t_pay_order上建out_trade_no + transaction_id的唯一组合索引,双写保护。
@Transactional public void handlePaySuccess(PayResult result) { PayOrder order = orderMapper.selectByOutTradeNo(result.getOutTradeNo()); if (order == null) { throw new BizException("订单不存在: " + result.getOutTradeNo()); } if (OrderStatus.PAID == order.getStatus()) { return; // 已处理过,直接跳过 } // 金额比较:两边都用 int 分,不用 double if (order.getTotalFen() != result.getTotal()) { throw new BizException("订单金额不一致"); } orderMapper.markPaid(order.getId(), result.getTransactionId()); // 这里可以发 MQ 消息,由异步任务处理发货/积分等业务 }逻辑说明:handlePaySuccess放在事务里,事务提交后再由外层返回SUCCESS。重复回调到来时,selectByOutTradeNo查到已支付就直接 return,方法结束返回 200。如果事务提交后进程崩了没来得及返回,微信重试时看到已支付状态,依然认为是成功处理。比在业务里加 Redis 分布式锁更稳,因为数据库唯一索引是最终兜底。
这里还要留意一个边界:回调里拿到的金额要以分为单位与本地订单比对,防止出现“传 A 订单号、回调金额却是 B 订单”这类数据错乱。核对通过后再落支付流水和修改订单状态。
4. 微信支付 V3 联调排错:高频报错速查与参数调优
4.1 notify_url、out_trade_no、attach:联调前先调好这三个参数
后端源码到手后,最先要调的不是签名代码,而是下单请求体里的三个业务字段。
notify_url必须是公网可访问的 HTTPS 地址,微信回调默认走 443 端口,本地联调通常用内网穿透工具把 SpringBoot 的 8080 端口映射出去,再把映射地址填到下单参数里。这个地址不要带 query string,路径要能直接命中@PostMapping("/notify/pay")的完整前缀,很多项目在网关层统一加了/api前缀,回调地址写错导致收不到通知。
out_trade_no是前端约束最少的参数之一,但建议控制在 6 到 32 位,只使用字母、数字和短横线。数据库里用 varchar 存储,不要用自增 int 直接暴露给微信,否则后续对账、退款、幂等判断都会很别扭。
attach是下单时的透传字段,支付回调和订单查询接口会原样返回。我一般会把用户 ID 或者业务单据号的摘要放进去,回调处理时先用它快速定位对应业务数据,再落库核对。这个字段会出现在回调明文里,不要放手机号、身份证等敏感信息,只放可用来关联业务的 ID。
4.2 高频报错速查:从 401 到回调超时
联调中遇到的大多数问题,看现象定位根因就够了。下面按我实际排查的频率整理了一张速查表:
| 现象 | 最常见根因 | 处理方式 |
|---|---|---|
| 下单返回 401 签名无效 | 签名串里的 body 与发送的 JSON 不一致,或末尾\n缺失 | 用发送前原始字符串参与签名,不要重新序列化 |
| 请求提示证书序列号不存在 | serial_no填成了证书文件名或商户号 | 用openssl x509 -noout -serial重新读取 |
| 回调解密报 AEADBadTagException | resource.nonce与Wechatpay-Nonce混用,或 APIv3 密钥写错 | 统一取resource.nonce,核对 32 字节密钥 |
| 小程序拉起收银台报 sign invalid | paySign直接用了 Authorization 头的签名 | 按appId\n时间戳\n随机串\npackage\n重新签名 |
| 回调接口请求超时、微信持续重试 | 业务逻辑在事务里做了耗时操作,未及时返回 200 | 先返回SUCCESS,异步处理发货等业务 |
| 回调提示“解密失败”但密钥正确 | 平台证书下载接口返回的密文被中途转码 | 确认 Base64 解码未受字符集影响,统一 UTF-8 |
第 4 行的 sign invalid 特别容易出现:前端收到的paySign和请求头里的signature都是 Base64 字符串,格式一样,但签名原文完全不同。判断方法很简单,把两个值分别解码后对比签名原文,paySign的原文一定以appid开头,而 Authorization 的原文以POST或GET开头。
4.3 投诉回调与退款查询如何复用同一套验签逻辑
微信支付投诉回调常常被忽略,但它是 V3 体系里和支付结果回调完全同构的接口:同样的四个Wechatpay-*请求头,同样的resource加密结构,只是event_type不是TRANSACTION.SUCCESS,解密后的明文是投诉对象结构。
这意味着此前实现的验签方法和 AES-GCM 解密方法完全不用改,新增一个 Controller 指向投诉回调的 URL,解密后按投诉 ID 落库即可。退款接口同理,POST /v3/refund/domestic/refunds请求签名的生成方式与下单一模一样,只是 body 字段换成了out_trade_no、out_refund_no、amount.refund等,签名头直接复用 2.2 的buildAuthHeader。所以把signer、证书管理器、解密工具抽成三个公共组件,后续新增对账单下载、商家转账、分账接口,每加一个只多几十行业务代码。
5. 用 openssl 离线验证支付回调签名,把线上问题隔离到代码之外
5.1 把回调原文落盘并用 openssl 验签
回调验签失败时,最难判断的是“微信发的报文有问题”还是“后端代码拼接有问题”。此时可以用 openssl 手工做一次离线验证,把 Java 代码完全排除在外。步骤如下。
第一步,从日志或抓包里拿到原始回调 body,以及请求头里的Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature三个值。如果项目没打完整日志,先在回调入口临时加一行log.info("notify body={}", rawBody)。
第二步,用 shell 构造验签原文。注意微信回调验签原文是timestamp\nnonce\nbody\n:
TS="1720000000" NONCE="回调头里的Wechatpay-Nonce" BODY="$(cat notify_body.txt)" printf '%s\n%s\n%s\n' "$TS" "$NONCE" "$BODY" > /tmp/verify_message.txt这个printf命令会输出三行文本,等价于 Java 代码里timestamp + "\n" + nonce + "\n" + body + "\n"的拼接结果。
第三步,把Wechatpay-Signature做 Base64 解码,然后调用 openssl 验证:
echo "回调头里的Wechatpay-Signature" | base64 -d > /tmp/signature.bin openssl dgst -sha256 -verify wechatpay_platform.pem \ -signature /tmp/signature.bin /tmp/verify_message.txtwechatpay_platform.pem是从商户平台下载的平台证书公钥,如果手头只有.pem证书文件,可以先执行openssl x509 -in wechatpay_platform.pem -pubkey -noout导出公钥再验证。输出Verified OK说明报文和签名本身成立。
5.2 用验签结果快速定位问题
有小概率会遇到 openssl 验证通过、但 Java 代码报验签失败,此时问题已经不在微信侧,而在后端代码:常见的是@RequestBody拿到字符串后又被框架重新编码,或者日志打印时出现了不可见字符被复制进了待验签串。此时直接对比 Java 内存中的rawBody和notify_body.txt的字节数,多数是多了一个换行或空格。
如果 openssl 验证失败,优先检查三件事:服务器时间是否偏移超过五分钟、Wechatpay-Signature是否完整复制、平台证书是否已经过期轮换。确认这些都没问题,再考虑网关层有没有重写过请求体。这套命令建议直接加入项目的排障手册,下次回调再报验签失败,第一步先执行它,能省下大量排查时间。
本文还有配套的精品资源,点击获取