news 2026/10/1 3:04:56

Java 小程序退款 V3 实战:签名、证书与回调验签一次跑通

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java 小程序退款 V3 实战:签名、证书与回调验签一次跑通

简介:这份资源面向使用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且金额一致,再提交退款。多一次查询看起来浪费,但能挡掉「订单没支付成功就退款」「金额填错」这类低级错误。线上跑了一年多,这个前置检查至少帮我拦下了十几次异常退款请求。退款这事,宁可慢一步,也别退错一笔。

希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 3:04:54

NBA比赛数据抓取与机器学习预测:Python课程设计实战指南

简介&#xff1a;这份资源面向计算机相关专业学生与Python初学者&#xff0c;提供一套可直接参考的NBA比赛结果预测课程设计完整方案&#xff0c;解决从数据采集到模型预测的全流程实现问题。包内共11个文件&#xff0c;以6个CSV数据文件、4个Python脚本和1份docx说明文档为主&…

作者头像 李华
网站建设 2026/10/1 3:04:40

蝴蝶分类数据集20类实战:从压缩包到可训练模型的完整落地路径

简介&#xff1a;蝴蝶分类数据集20类.zip 面向机器学习与图像识别方向的开发者、深度学习入门者及生物多样性研究者&#xff0c;用于训练和测试蝴蝶物种自动识别模型。压缩包共1870个文件&#xff0c;以1866张jpg图像为主体&#xff0c;另含2个txt标签文件、1个json字典文件和1…

作者头像 李华
网站建设 2026/10/1 3:03:45

基于Mask R-CNN的缺陷检测Python工程:从框到轮廓的分割实践

简介&#xff1a;针对图像缺陷检测任务打造的完整Python实现包&#xff0c;面向本科与硕士阶段进行计算机视觉、工业质检相关教研学习的学生。内容以Defect Eye缺陷检测为主线&#xff0c;覆盖数据示例、模型推理、评估验证等环节。压缩包共227个文件&#xff0c;以py源码为核心…

作者头像 李华
网站建设 2026/10/1 3:03:31

Synapse数据集实战:医学图像分割从CT预处理到多器官分割

简介&#xff1a;医学图像分割是计算机辅助诊断与手术规划的核心技术之一&#xff0c;而高质量标注数据集是训练可靠模型的基础。在实际工程中&#xff0c;面对CT影像&#xff0c;数据预处理与标注格式的标准化往往比网络结构更影响最终精度。窗宽窗位调整、体素重采样、标签映…

作者头像 李华
网站建设 2026/10/1 3:02:55

结核杆菌YOLO小目标检测数据集与部署实战

简介&#xff1a;本资源是面向医学图像分析与AI辅助诊断研究者的结核杆菌目标检测专用数据集&#xff0c;适用于YOLO系列模型训练与验证&#xff0c;助力肺结核早期筛查、自动化病原识别等实际医疗场景落地。数据包共2000个文件&#xff0c;含1265张痰液显微图像&#xff08;JP…

作者头像 李华
网站建设 2026/10/1 3:01:47

东莞GEO优化优质企业有哪些?省心不踩坑的服务商挑选全攻略

东莞GEO优化优质企业有哪些?省心不踩坑的服务商挑选全攻略 开篇&#xff1a;选东莞GEO优化服务商&#xff0c;你可能正在踩这4个大坑找东莞本地的GEO优化服务商时&#xff0c;很多企业都会踩坑&#xff1a;要么选了不懂珠三角本地化运营的外地团队&#xff0c;关键词布局脱离本…

作者头像 李华