简介:这份资源面向使用Java开发微信小程序支付的开发者,聚焦微信支付V3版本的退款功能实现,帮助解决退款接口调用、签名验证与回调处理等实际问题。压缩包共4个文件,以3个txt示例代码和1个properties配置文件为主,分别对应支付V3核心Bean、控制器逻辑、依赖说明与商户参数配置,整体约6KB,轻量便于快速集成到现有工程。目前已有4693人学习下载,说明其在同类场景中具备一定参考价值。内容围绕V3退款流程展开,涵盖获取Access Token、发起退款请求、处理退款结果、回调通知验签、错误重试机制及小程序端交互等关键环节,读者可结合示例代码理解请求参数封装与签名方式,并借助配置文件快速替换商户信息完成本地调试,适合需要落地微信支付退款功能的中高级Java开发者参考。
1. Java 小程序退款 V3:从「能付不能退」到一次跑通的落地路径
小程序商城上线第一周,订单来了,退款请求也来了。很多团队用 Java 接微信支付时,付款那一步照着文档抄一遍就能跑通,等到要做退款才发现:V2 的证书、签名、回调那一套在 V3 里全变了,旧代码直接报签名错误,日志里只有一句SIGN_ERROR,连错在哪都不告诉你。这篇讲的就是 Java 后端在小程序支付场景下,怎么把微信支付 V3 版本的退款接口真正跑通——不是贴一段官方示例,而是把商户私钥、平台证书、请求签名、回调验签、幂等重试这几件事按落地顺序讲清楚。适合已经接过小程序支付、正在补退款能力的 Java 后端;也适合准备做小程序商城、需要提前把退款链路设计进订单状态机的人。看完你应该能自己写出一段可复现的退款代码,并且知道出问题时先看哪里。
2. 退款 V3 的请求到底长什么样:先搞懂签名和证书
微信支付 V3 和 V2 最大的区别,是把「签名」这件事从 MD5/HMAC 换成了 SHA256-RSA,并且请求和应答都走 JSON。很多人第一次接 V3 退款翻车,不是业务逻辑写错,而是根本没搞明白一次退款请求里到底有哪几层东西。这一章先把请求结构、签名机制、证书体系拆开,后面写代码才不会靠猜。
2.1 一次退款请求的组成:URL、Header、Body 三段
V3 退款接口的路径是固定的:
POST https://api.mch.weixin.qq.com/v3/refund/domestic/refunds一次合法请求由三部分组成,缺一不可:
- URL:包含接口路径,部分接口还带商户号或退款单号作为路径参数。
- Header:至少要有
Authorization、Accept、Content-Type、User-Agent。其中Authorization是签名串,格式是WECHATPAY2-SHA256-RSA2048 mchid="...",nonce_str="...",signature="...",timestamp="...",serial_no="..."。 - Body:JSON,退款场景里核心字段是
out_trade_no(或transaction_id)、out_refund_no、amount.refund、amount.total、currency。
这里最容易忽略的是serial_no。它不是商户号,也不是 APIv3 密钥,而是商户 API 证书的序列号。你从微信支付商户平台下载的apiclient_cert.pem,用 openssl 就能看到它的序列号:
openssl x509 -in apiclient_cert.pem -noout -serial输出的那串十六进制就是serial_no。很多「签名错误」的根因,就是这里填了 APIv3 密钥或者平台证书序列号。
2.2 签名串怎么拼:五行字符串,顺序不能错
V3 的签名不是对整个 JSON 做摘要,而是对一段固定格式的五行字符串做 SHA256-RSA 签名。格式是:
HTTP请求方法\n URL路径\n 请求时间戳\n 随机字符串\n 请求报文主体\n注意几个细节:
- 方法必须大写,比如
POST。 - URL 路径要带 query string,比如
/v3/refund/domestic/refunds?x=1,不带域名。 - 时间戳是秒级,不是毫秒。
- 随机字符串就是 Header 里的
nonce_str,两边必须一致。 - 请求报文主体:GET 请求为空字符串,POST 请求就是原始 JSON 字符串,不能重新序列化,否则字段顺序变了签名就对不上。
拼好之后用商户私钥做 SHA256withRSA 签名,再 Base64 编码,塞进Authorization头。下面这段是签名核心逻辑:
// 构造待签名串,注意每行结尾的 \n 不能省 String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; // 用商户私钥签名,SHA256withRSA Signature sign = Signature.getInstance("SHA256withRSA"); sign.initSign(privateKey); // privateKey 来自 apiclient_key.pem sign.update(message.getBytes(StandardCharsets.UTF_8)); byte[] signed = sign.sign(); String signature = Base64.getEncoder().encodeToString(signed); // 拼 Authorization String authorization = "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + mchId + "\"," + "nonce_str=\"" + nonceStr + "\"," + "signature=\"" + signature + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + serialNo + "\"";参数说明:privateKey是从apiclient_key.pem读出来的 PKCS8 私钥;serialNo是上面用 openssl 查到的证书序列号;body必须是最终发出去的原始字符串,建议在发请求前先把它存成变量,签名和发送用同一个引用,避免被框架二次序列化。
2.3 平台证书和回调验签:为什么你收到的通知验不过
退款是异步的,微信会把退款结果通过回调通知给你。V3 的回调带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial这几个头,验签用的是微信支付平台证书,不是你的商户证书。
平台证书需要通过GET /v3/certificates接口下载,返回的是加密的,要用 APIv3 密钥做 AES-256-GCM 解密。解密后拿到平台证书,再用它的公钥验签。验签串的拼法和请求签名一样,也是五行:
应答时间戳\n 应答随机串\n 应答报文主体\n这里有个高频坑:回调的报文主体必须用原始字节流读,不能先被 Spring 的@RequestBody反序列化成对象再toString(),那样空格和字段顺序都变了,验签必挂。常见做法是在 Controller 里用HttpServletRequest.getInputStream()读原始 body,读完再手动解析 JSON。
提示:平台证书会轮换,不要把它硬编码在配置里。建议启动时拉一次,缓存起来,验签时按
Wechatpay-Serial匹配对应证书,匹配不到就重新拉一次。
3. 用 Java 写一个能跑的退款接口:从下单到退款的完整链路
原理讲完,这一章直接落到代码。我一般会把微信支付 V3 的调用封装成一个WechatPayClient,退款只是其中一个方法。下面按「准备材料 → 发起退款 → 处理回调 → 查退款状态」的顺序走一遍,每一步都给可抄的代码。
3.1 准备四样东西:商户号、证书、密钥、序列号
在写代码前,先把这几样东西准备好,缺一个都跑不起来:
| 材料 | 来源 | 用途 |
|---|---|---|
| 商户号 mchid | 商户平台 | 请求参数 |
| 商户 API 证书 apiclient_cert.pem | 商户平台下载 | 查序列号 |
| 商户 API 私钥 apiclient_key.pem | 商户平台下载 | 请求签名 |
| APIv3 密钥 | 商户平台设置 | 解密平台证书、解密回调 |
读私钥的代码:
// 读取 apiclient_key.pem,转成 PrivateKey String keyContent = new String(Files.readAllBytes(Paths.get("apiclient_key.pem")), StandardCharsets.UTF_8) .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] keyBytes = Base64.getDecoder().decode(keyContent); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec);参数说明:apiclient_key.pem是 PKCS8 格式,去掉头尾和换行后 Base64 解码即可。如果你的文件是 PKCS1 格式(头是BEGIN RSA PRIVATE KEY),需要先转换,否则generatePrivate会抛InvalidKeySpecException。
3.2 发起退款:一个方法搞定签名和请求
下面是退款方法的核心实现,用HttpURLConnection是为了减少依赖,实际项目里换成 OkHttp 或 HttpClient 都行,签名逻辑不变。
public String refund(String outTradeNo, String outRefundNo, int refundFen, int totalFen) throws Exception { String urlPath = "/v3/refund/domestic/refunds"; String method = "POST"; long timestamp = System.currentTimeMillis() / 1000; String nonceStr = UUID.randomUUID().toString().replace("-", ""); // 构造请求体,字段顺序固定,签名和发送用同一个字符串 String body = String.format( "{\"out_trade_no\":\"%s\",\"out_refund_no\":\"%s\"," + "\"amount\":{\"refund\":%d,\"total\":%d,\"currency\":\"CNY\"}}", outTradeNo, outRefundNo, refundFen, totalFen); // 签名 String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; Signature sign = Signature.getInstance("SHA256withRSA"); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(sign.sign()); String authorization = "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + mchId + "\"," + "nonce_str=\"" + nonceStr + "\"," + "signature=\"" + signature + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + serialNo + "\""; // 发请求 HttpURLConnection conn = (HttpURLConnection) new URL("https://api.mch.weixin.qq.com" + urlPath).openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setRequestProperty("Authorization", authorization); conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("User-Agent", "my-shop/1.0"); conn.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8)); // 读响应 int code = conn.getResponseCode(); InputStream is = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream(); String resp = new String(is.readAllBytes(), StandardCharsets.UTF_8); if (code != 200) { throw new RuntimeException("refund failed: " + code + " " + resp); } return resp; }逻辑说明:金额单位是分,refundFen是本次退款金额,totalFen是原订单总额,两者都必须是整数。out_refund_no是商户自己的退款单号,必须全局唯一,重复提交同一个单号微信会返回同一笔退款结果,这正好可以用来做幂等。响应里会带refund_id、status、amount等字段,status为SUCCESS表示退款成功,PROCESSING表示还在处理中。
参数说明:mchId是商户号;serialNo是商户证书序列号;privateKey是 3.1 里读出来的私钥。这三个建议放在配置中心,不要写死在代码里。
3.3 处理退款回调:验签 + 解密 + 幂等
退款成功后微信会回调你配置的notify_url。回调处理分三步:验签、解密resource、更新订单状态。
@PostMapping("/wx/refund/notify") public String refundNotify(HttpServletRequest request) throws Exception { // 1. 读原始 body,不能反序列化后再 toString String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); String timestamp = request.getHeader("Wechatpay-Timestamp"); String nonce = request.getHeader("Wechatpay-Nonce"); String signature = request.getHeader("Wechatpay-Signature"); String serial = request.getHeader("Wechatpay-Serial"); // 2. 用平台证书验签 String message = timestamp + "\n" + nonce + "\n" + body + "\n"; Signature sign = Signature.getInstance("SHA256withRSA"); sign.initVerify(platformCertMap.get(serial).getPublicKey()); sign.update(message.getBytes(StandardCharsets.UTF_8)); if (!sign.verify(Base64.getDecoder().decode(signature))) { return "{\"code\":\"FAIL\",\"message\":\"sign error\"}"; } // 3. 解密 resource,拿到退款结果 JSONObject root = JSON.parseObject(body); JSONObject resource = root.getJSONObject("resource"); String plain = decryptAesGcm(resource.getString("ciphertext"), resource.getString("nonce"), resource.getString("associated_data")); JSONObject refund = JSON.parseObject(plain); // 4. 幂等更新:用 out_refund_no 做唯一键 String outRefundNo = refund.getString("out_refund_no"); String status = refund.getString("refund_status"); refundService.updateStatusIfAbsent(outRefundNo, status); return "{\"code\":\"SUCCESS\",\"message\":\"OK\"}"; }逻辑说明:验签用的是平台证书公钥,platformCertMap是启动时拉取并缓存的平台证书,key 是证书序列号。解密用 AES-256-GCM,密钥是 APIv3 密钥,associated_data和nonce都从resource里取。第 4 步的updateStatusIfAbsent是关键,微信回调可能重复推送,必须用out_refund_no做唯一约束,重复的直接忽略。
参数说明:decryptAesGcm里 GCM 的 tag 长度是 128 位,密文最后 16 字节是认证标签,解密时要单独取出来。APIv3 密钥必须是 32 位,短了或长了都会解密失败。
3.4 主动查退款:回调没来时的兜底
回调不是 100% 可靠,网络抖动、服务重启都可能丢。所以退款发起后,建议起一个定时任务,对PROCESSING状态的退款单主动查询:
// GET /v3/refund/domestic/refunds/{out_refund_no} public String queryRefund(String outRefundNo) throws Exception { String urlPath = "/v3/refund/domestic/refunds/" + outRefundNo; // GET 请求 body 为空字符串,签名串最后一行是空行 // 其余签名逻辑与 POST 一致 // ... }查询接口是 GET,签名串的第五行是空字符串,但那个\n还是要保留。查到SUCCESS就更新订单,查到CLOSED或ABNORMAL就走人工介入。这个兜底逻辑看起来多余,但线上真出问题时,它是你唯一的后悔药。
4. 退款链路的避坑清单:那些文档不会告诉你的翻车点
退款这块的坑,大多不是逻辑复杂,而是细节没对齐。下面这几条是我和身边同行踩过的,按「现象 → 原因 → 解决」写,遇到问题可以对着排查。
4.1 签名一直报 SIGN_ERROR,日志里看不出哪错了
现象:请求发出去,返回 401,body 里code是SIGN_ERROR,但没有任何细节。
原因:九成是签名串拼错了。常见的有:URL 路径带了域名、时间戳用了毫秒、body 被 JSON 库重新序列化导致字段顺序变了、serial_no填成了 APIv3 密钥、私钥格式不对(PKCS1 当成 PKCS8 用)。
解决:把待签名串原样打印出来,逐行核对。特别注意 body 那一行,必须是最终发出去的字符串。如果用了 OkHttp 的RequestBody.create(json, ...),要确保json变量和签名用的是同一个。另外确认serial_no是 openssl 查出来的证书序列号,不是商户号。
4.2 回调验签失败,但请求签名是好的
现象:主动调退款接口正常,回调进来验签一直失败。
原因:多半是 body 被框架处理过了。Spring 的@RequestBody会把 JSON 反序列化成对象,你再toString()出来的字符串和原始报文不一致,空格、字段顺序都可能变。另一个原因是平台证书没更新,Wechatpay-Serial对应的证书不在你的缓存里。
解决:Controller 方法不要用@RequestBody,改用HttpServletRequest读原始流。平台证书按Wechatpay-Serial匹配,匹配不到就重新调/v3/certificates拉一次。验签串的拼法是timestamp\nnonce\nbody\n,注意最后那个\n。
4.3 重复退款:同一笔订单退了两次
现象:用户点了一次退款,网络超时又点了一次,结果退了两笔。
原因:out_refund_no每次请求都重新生成,微信认为是两笔不同的退款。
解决:out_refund_no用业务规则生成,比如订单号 + 退款序号,同一笔退款请求复用同一个单号。微信对同一个out_refund_no的重复请求会返回同一笔退款结果,天然幂等。数据库里对out_refund_no加唯一索引,插入失败就直接返回已有结果。
4.4 金额对不上:退款金额和订单金额不一致
现象:退款接口返回参数错误,提示amount.total不匹配。
原因:amount.total必须是原订单的实际支付金额,不是商品原价。如果订单用了优惠券,实付金额和订单金额不一样,填错了就会报错。
解决:下单时把微信返回的amount.total存下来,退款时直接用这个值。单位统一用分,不要中途转成元再转回来,浮点数会丢精度。
4.5 退款成功但订单状态没更新
现象:微信后台显示退款成功,但你的订单还是「已支付」。
原因:回调没收到,或者收到了但处理逻辑抛异常回滚了。
解决:回调处理里先落库再返回成功,不要先返回再异步处理。同时加定时任务主动查PROCESSING的退款单,查到SUCCESS就补更新。回调接口要保证幂等,重复推送不能重复扣库存或重复改状态。
5. 把退款做扎实:状态机、对账和几个进阶习惯
退款能跑通只是第一步,真正上线后你会发现,问题都出在边界上。这一章讲几个让退款链路更稳的做法,都是我在实际项目里验证过的。
5.1 用状态机管退款,别用布尔值
很多团队一开始用refunded一个布尔字段表示退款状态,结果遇到「部分退款」「退款中」「退款失败」就抓瞎。建议退款单独立一张表,状态至少包含:
| 状态 | 含义 | 下一步 |
|---|---|---|
| INIT | 已创建,未提交微信 | 提交退款 |
| PROCESSING | 微信处理中 | 等回调或主动查 |
| SUCCESS | 退款成功 | 终态 |
| FAIL | 退款失败 | 人工介入 |
| CLOSED | 退款关闭 | 终态 |
状态流转只允许单向,每次变更记录操作时间和来源(回调还是主动查)。这样出问题时,你能一眼看出卡在哪一步。
5.2 对账:每天拉一次退款账单
微信支付提供对账单下载接口,建议每天凌晨拉一次前一天的账单,和你自己的退款单做比对。重点核对三件事:金额、状态、退款单号。不一致的记下来人工处理。这个习惯看起来笨,但它是发现「回调丢了」「状态没更新」最可靠的手段。我一般会把对账结果写进一张差异表,连续三天有差异就告警。
5.3 密钥和证书的轮换别等到过期
商户 API 证书有有效期,平台证书也会轮换。建议在配置里记录证书的到期时间,提前 30 天告警。平台证书的缓存要支持按序列号查找,新证书拉下来后旧证书保留一段时间,避免正在处理中的回调验签失败。APIv3 密钥如果泄露,要立刻在商户平台重置,重置后所有加解密都会用新密钥,旧的回调会解不开,所以重置前要确保没有积压的PROCESSING退款单。
5.4 一个我自己的习惯:退款前先查订单
最后说个我踩过坑之后养成的习惯:发起退款前,先调一次订单查询接口,确认订单状态是SUCCESS且金额一致,再提交退款。多一次查询看起来浪费,但能挡掉「订单没支付成功就退款」「金额填错」这类低级错误。线上跑了一年多,这个前置检查至少帮我拦下了十几次异常退款请求。退款这事,宁可慢一步,也别退错一笔。
希望帮到你。
本文还有配套的精品资源,点击获取