微信支付V3退款签名与回调验签实践详解
简介Java微信支付V3小程序退款实现资源包面向需要在小程序端接入微信支付退款能力的后端开发者帮助其解决退款流程不清晰、参数易出错等实际问题。内容聚焦微信支付V3版本退款API的完整调用流程覆盖获取Access Token、发起退款请求、处理退款结果、回调通知验签、错误处理与重试机制、小程序端交互以及日志记录等关键环节并结合样例代码展示具体实现方式为开发者梳理清前后端分工与异常场景。资源包共4个文件以txt源码示例和properties配置文件为主整体仅6KB便于快速查阅和导入工程其中包含退款Bean与Controller示例、properties配置项以及pom依赖说明可辅助理解请求参数的封装、API调用和项目配置同时帮助开发者在测试环境完成调试减少联调时因签名或回调问题产生的返工有效缩短上手周期。已有4691人学习下载适合熟悉Java但初次接触微信支付V3退款业务的开发者作为参考。1. 小程序退款为什么绕不开微信支付 V3 的签名微信支付 V3 的小程序退款表面看只是 POST 一个退款接口实际上 90% 的报错都出在签名和证书上。很多 Java 后端习惯用 V2 的思路去找 Access Token但 V3 没有 access_token取而代之的是商户私钥对请求体做 RSA 签名的 Authorization 头。解压 wxpayV3.rar 后wxpayV3 目录下的 WechatPayV3Bean.txt、WechatPayV3Controller.txt、wechat_pay_v3.properties 和 pom依赖.txt 正好对应参数模型、控制层、配置和依赖四块。这篇博客用这套工程结构把退款请求、回调验签、幂等落库和排查技巧串起来适合被 401 和 SIGN_ERROR 卡住、想搞懂 V3 签名机制的 Java 开发者。2. 退款前先搭好 V3 证书配置与签名 HTTP 客户端V3 的每个接口调用都要求商户系统用商户私钥签名微信服务端再用商户证书公钥验签。所以第一步不是写退款业务而是把私钥、证书序列号、APIv3 密钥和发送请求的 HTTP 客户端准备对。这个基础不打好后续所有退款请求都会在授权上失败。2.1 wechat_pay_v3.properties 里的配置项wxpayV3 工程中的 wechat_pay_v3.properties 是唯一不需要改 Java 代码就能切换环境的文件。下面是一份贴近实际使用的配置wechat.pay.mchid1600000000 wechat.pay.appidwx1234567890abcdef wechat.pay.mch-serial-no1234567890ABCDEF wechat.pay.private-key-pathclasspath:cert/apiclient_key.pem wechat.pay.apiv3-key0123456789abcdef0123456789abcdef wechat.pay.refund-notify-urlhttps://api.example.com/wxpay/refund/notify wechat.pay.api-basehttps://api.mch.weixin.qq.commchid 是商户号appid 对应小程序的 AppID。mch-serial-no 是商户证书的序列号不是证书文件的文件名也不等于证书内容的签名摘要。可以在本机执行openssl x509 -in apiclient_cert.pem -noout -serial查看输出里的serial...后面那段值就是序列号。private-key-path 指向的是 apiclient_key.pem也就是申请支付证书时下载到的私钥文件通常放在 src/main/resources/cert/ 下。apiv3-key 是你在商户平台设置的 32 位 APIv3 密钥它不是证书私钥而是用于回调内容解密的对称密钥。注意不要把这两者搞混。我之前帮同事排查过一个案例他把 apiclient_cert.pem 的内容当成私钥加载结果一调用就报SIGN_ERROR。因为证书文件是公钥载体签名必须用单独的私钥文件。所以配置文件里 private-key-path 永远指向 key 文件不是 cert 文件。下面这张表整理了最容易配错的三个点参数类型常见误配private-key-path商户私钥文件误填为 apiclient_cert.pemmch-serial-no商户证书序列号填成证书备注名或文件名apiv3-keyAPIv3 对称密钥与商户 APIv3 密钥设置不一致2.2 用 OkHttp 组装带签名的 HTTP 客户端pom依赖.txt 里会列出 okhttp 和 jackson-databind。这里不贴具体版本使用你项目里已有的 3.14 以上版本就可以。为什么用 OkHttp因为它的 Interceptor 能直观地看到请求头和 body对排查签名问题很有用Apache HttpClient 也能做但调试体验不如 OkHttp。发送退款请求的核心是生成 Authorization 头。V3 的规范是WECHATPAY2-SHA256-RSA2048后面跟着 mchid、nonce_str、timestamp、serial_no、signature 五个字段。这里给出一个最小实现public String buildAuthHeader(String method, String path, String body) throws Exception { String nonceStr UUID.randomUUID().toString().replace(-, ); String timestamp String.valueOf(System.currentTimeMillis() / 1000); // 拼接微信支付 V3 的签名原串每一段都以换行符结尾 String message method \n path \n timestamp \n nonceStr \n (body null ? : body) \n; Signature signer Signature.getInstance(SHA256withRSA); signer.initSign(loadPrivateKey()); signer.update(message.getBytes(StandardCharsets.UTF_8)); String signature Base64.getEncoder().encodeToString(signer.sign()); return WECHATPAY2-SHA256-RSA2048 mchid\ mchid \, nonce_str\ nonceStr \, timestamp\ timestamp \, serial_no\ mchSerialNo \, signature\ signature \; }签名串的拼接规则是固定的五段HTTP 方法、URL 路径、时间戳、随机字符串、请求体每一段以换行符结尾。特别要注意 path 只包含路径部分比如/v3/refund/domestic/refunds不包含域名和 query使用 GET 查询时没有请求体body 位置传空字符串。我看到很多代码在 body 上直接塞入 null结果导致拼接出来的是null签名永远对不上。签名算法固定是SHA256withRSA私钥加载方式如下private PrivateKey loadPrivateKey() throws Exception { byte[] keyBytes Files.readAllBytes(Paths.get(privateKeyPath)); String pem new String(keyBytes, StandardCharsets.UTF_8) .replace(-----BEGIN PRIVATE KEY-----, ) .replace(-----END PRIVATE KEY-----, ) .replaceAll(\\s, ); byte[] decoded Base64.getDecoder().decode(pem); PKCS8EncodedKeySpec spec new PKCS8EncodedKeySpec(decoded); return KeyFactory.getInstance(RSA).generatePrivate(spec); }这里用的是 PKCS#8 格式微信下载的 apiclient_key.pem 通常是 PKCS#1大多数情况下 Java 的 PKCS8EncodedKeySpec 也能解析如果报 InvalidKeySpec可以先确认文件头部是不是BEGIN RSA PRIVATE KEY如果是就用 Bouncy Castle 的 PEMParser 转换一次。常见做法是直接把私钥文件转成 PKCS#8避免不同环境下的解析差异。2.3 预置回调解密工具退款回调的 resource 字段是 AES-256-GCM 加密的这一步和签名分开处理。在写退款业务前先把解密函数准备好后面回调验签时才不用临时找代码。可以参考下面的方法public static String decryptResource(String associatedData, String nonce, String ciphertext) { SecretKeySpec key new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), AES); try { Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); GCMParameterSpec spec new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, key, spec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); return new String(cipher.doFinal(Base64.getDecoder().decode(ciphertext)), StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException(退款回调解密失败, e); } }GCM 参数里的 128 是 tag 长度微信服务端加密时使用 128 位认证标签。nonce 和 associated_data 都来自回调 JSON 的 resource 字段ciphertext 是该字段里的密文。解密时不要额外再做 URL 解码直接用 Base64 解码即可。这个函数在后面的回调处理中会直接复用。3. 退款 Bean 与 Controller 层参数校验、JSON 组装与幂等落库wxpayV3 工程里 WechatPayV3Bean.txt 不是复杂的东西它就是退款请求的 Java 模型。真正容易出错的地方在于你用什么顺序把字段序列化成 JSON签名就会用哪个字符串。如果 Bean 里的字段顺序和微信文档不一致签名照样能算出但微信服务端按自己顺序拼接后验签失败。所以理解这个 Bean 的字段角色比一次性写下所有 getter/setter 更重要。3.1 WechatPayV3Bean 的字段与文档对应关系退款申请接口的请求体只需要几个字段。常见字段整理成下面的表格字段类型是否必填含义out_trade_noString与 transaction_id 二选一商户原订单号transaction_idString与 out_trade_no 二选一微信支付订单号out_refund_noString是商户退款单号必须唯一refund_feeInteger是退款金额单位分total_feeInteger是原订单支付金额单位分reasonString否退款原因notify_urlString否异步通知地址注意金额字段的类型是 Integer不是 BigDecimal。微信支付 V3 的所有金额单位都是“分”0.01 元要用 1 表示。用元做单位直接调接口会得到 PARAM_ERROR而且这种错误在日志里非常难察觉因为返回信息只会提示金额格式不对。另一个细节是 out_refund_no 的命名规则建议直接关联原始订单号加随机后缀例如REFUND_ORDER202501010001_001这样排查问题时能一眼看出该退款属于哪个订单同时也能保证多次调用之间的唯一性。如果使用第三方生成的 UUID日志关联会麻烦很多。3.2 Controller 层只做参数翻译不写支付逻辑WechatPayV3Controller.txt 中一般会提供一个 POST 接口给小程序后端调用。思路是小程序端拿到用户操作后只传出来几个关键参数服务端去补齐商户号、回调地址等敏感信息。下面是一个符合工程实践的接口骨架RestController RequestMapping(/wxpay/refund) public class WechatPayV3Controller { private final RefundService refundService; public WechatPayV3Controller(RefundService refundService) { this.refundService refundService; } PostMapping public ApiResult refund(RequestBody RefundApplyParam param) { // 金额校验必须是正整数单位是分 if (param.getRefundFee() null || param.getRefundFee() 0) { return ApiResult.fail(refundFee must be positive integer in fen); } RefundOrderDO refundOrder refundService.applyRefund(param); return ApiResult.ok(refundOrder); } }Controller 里不出现私钥、证书、Authorization 相关内容只负责接收参数和调用 service。这样做的原因是签名相关代码要在多个接口间复用散落在 Controller 里会导致后续维护时改一处漏一处。RefundApplyParam 一般是 outTradeNo、refundFee、reason 三个字段outRefundNo 和 notifyUrl 由 Service 层生成或从配置读取。3.3 组装退款 JSON 并发送Service 层组装退款对象并生成签名请求。这里的关键是序列化顺序要固定推荐直接使用 ObjectMapper 的默认字段顺序不要在实体类上使用JsonProperty重排也不要手写 JSON 字符串。String path /v3/refund/domestic/refunds; RefundRequest request new RefundRequest(); request.setOutTradeNo(param.getOutTradeNo()); request.setOutRefundNo(generateRefundNo(param.getOutTradeNo())); request.setRefundFee(param.getRefundFee()); request.setTotalFee(order.getActualPayFee()); request.setNotifyUrl(refundNotifyUrl); ObjectMapper mapper new ObjectMapper(); String body mapper.writeValueAsString(request); Request httpRequest new Request.Builder() .url(apiBase path) .post(RequestBody.create(body, MediaType.parse(application/json))) .header(Authorization, buildAuthHeader(POST, path, body)) .header(Accept, application/json) .build();这里body变量被使用两次一次构造 RequestBody一次传入签名方法。必须确保两个地方使用的是同一个字符串不要在签名后再对 body 做格式化或转义。如果用了日志输出去美化 JSON那只是复制到控制台给人看的不影响签名但如果你把美化后的字符串回填进签名逻辑就会报 SIGN_ERROR。还有一点total_fee必须是原订单实际支付金额不能拿商品原价或应付金额去凑。微信侧会校验退款金额是否超过可退余额一旦超过就返回REFUND_FEE_MISMATCH。3.4 幂等落库用 out_refund_no 做唯一键退款场景天然会重试。网络超时后接口不确定是否已经受理重发一次可能产生两条退款记录。所以本地数据库必须以 out_refund_no 为唯一索引落库的 DDL 简单但关键CREATE TABLE t_refund_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, out_refund_no VARCHAR(64) NOT NULL, out_trade_no VARCHAR(64) NOT NULL, refund_fee INT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT CREATED, refund_id VARCHAR(64) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_out_refund_no (out_refund_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;插入时不要用“先 select 再 insert”高并发重试下一定会穿透。最稳妥的是直接执行 insert捕获 DuplicateKeyException 后走查询分支拿到已存在的退款单并返回。这样无论请求多少次数据库里同一个 out_refund_no 只有一条记录。状态字段 status 可以先给一个本地初始值等微信回调后再更新为 PROCESSING、SUCCESS 这些最终状态。注意不要在发起退款请求前就置为 SUCCESS因为微信侧还没有受理。4. 回调验签、状态机与重试V3 退款最容易翻车的三处退款申请接口返回的 status 并不代表退款已经结束真正决定结果的是异步回调。回调处理如果验签不严可能被伪造通知如果状态判断错误会把 PROCESSING 当成 SUCCESS 更新数据库。这一章把回调验签、状态字符和重试策略放在一起说。4.1 回调报文先验平台证书签名微信退款回调的请求头带有 Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial 四个字段。其中 Wechatpay-Serial 是微信支付平台证书的序列号不是文件里的商户证书序列号。需要用微信侧下发的平台证书来验签不能拿 apiclient_cert.pem 去验。验签原始字符串的拼接规则与请求签名一致// 回调验签时间戳、nonce、原始 body 组成待签名串 String message timestamp \n nonce \n body \n; Signature verifier Signature.getInstance(SHA256withRSA); verifier.initVerify(platformPublicKey); verifier.update(message.getBytes(StandardCharsets.UTF_8)); boolean valid verifier.verify(Base64.getDecoder().decode(signature));报文里的 body 是整个回调 JSON 字符串不能经过任何格式化。验签成功后使用 2.3 节里的 decryptResource 方法解密 resource 字段得到退款结果对象。如果本机没有平台证书可以从微信支付平台证书下载接口定期拉取并缓存也可以手动下载放到 cert 目录。测试环境里最常见的问题是平台证书过期或用错证书导致验签一直返回 false。注意区分平台证书和商户证书回调验签使用平台证书请求签名使用商户私钥二者不是同一个文件。遇到过不少把两个序列号搞反的排查方向直接跑偏。确认序列号时看请求头里的 Wechatpay-Serial 对应哪个证书文件再去加载对应的公钥即可。4.2 不要把 result_code 带到 V3 的状态判断里有些博客会写“响应中 result_code 为 SUCCESS 就代表退款成功”这是 V2 的接口语义。V3 的退款申请接口正常响应返回的是 HTTP 200body 里根本没有 result_code而是{ out_refund_no: REFUND202501010001_001, refund_id: 5030020001, status: PROCESSING, create_time: 2025-01-01T10:00:0008:00 }后续状态通过回调里的 refund_status 变化。常见状态如下表refund_status含义本地处理动作PROCESSING退款处理中保持等待不修改已落库状态SUCCESS退款成功更新订单退款状态为成功CLOSED退款关闭按失败/关闭处理释放退款单ABNORMAL退款异常标记人工介入发告警如果项目是从 V2 迁移到 V3尤其要把字段名从 result_code 改成 status/refund_status。V3 退款申请接口返回的是 status回调里返回的是 refund_status两个字段在时间维度上不一样status 是申请接口当时的受理状态refund_status 是后续的流转结果。不要在同一个变量里混用否则会出现“退款还没处理完就被标记成功”的脏数据。4.3 重试与退避超时后先查单再重发退款接口超时后最怕的是请求已经到达微信侧但响应丢失。此时如果盲目重发可能把同一笔退款提交两次导致生成两个 out_refund_no。所以重试策略应该是先查询退款单状态再决定是否重新发起申请。for (int i 0; i 3; i) { try { RefundQueryResp queryResp refundApi.query(outRefundNo); if (queryResp ! null) { return handleExisting(queryResp); } return refundApi.apply(request); } catch (SocketTimeoutException e) { RefundQueryResp queryResp refundApi.query(outRefundNo); if (queryResp null i 2) { Thread.sleep(1000L i); // 退避 1s、2s continue; } throw e; } }这里的查询接口是GET /v3/refund/domestic/refunds/{out_refund_no}。先把查询结果拿出来看如果已经存在就直接返回不再重复申请。Thread.sleep 只是简化示意生产环境建议替换成 ScheduledExecutorService 或消息队列里的延迟消费。回调处理本身要轻量不要在收到回调后同步去调用其他外部接口或做重计算否则微信在 5 秒内收不到响应会重复推送。接住回调后先验签解密更新数据库状态再把结果推给小程序前端即可。5. 一个排查 V3 签名问题的实操技巧把请求原样扣下来排查 V3 签名问题时最忌讳的是拿着“错误请求”的日志去猜。你需要的是把实际发送的 Authorization 头、请求体和签名串完整记录下来然后手工复现计算签名。OkHttp 的 Application Interceptor 能做到这一点并且不会污染业务代码。5.1 用拦截器复制 body 并打印签名素材public class SignatureLogInterceptor implements Interceptor { Override public Response intercept(Chain chain) throws IOException { Request original chain.request(); String bodyStr ; if (original.body() ! null) { Buffer buffer new Buffer(); original.body().writeTo(buffer); bodyStr buffer.readUtf8(); } System.out.println(wxpay-v3-request: original.method() original.url().encodedPath() original.header(Authorization)); System.out.println(wxpay-v3-body: bodyStr); return chain.proceed(original); } }这个拦截器会把发送到微信服务器的请求路径、Authorization 和 body 一次性拍下来。注意original.body()在执行chain.proceed(original)时还会再读取一次因为 OkHttp 的 RequestBody 被 writeTo 写入 Buffer 后原始 body 依然可以再次写入网络流不会因为拦截器里的读取而失效。拿到日志后把 Authorization 里的 timestamp、nonce_str、signature 字段值以及 body 粘贴到临时文件再写一个独立的方法用同样的算法重新计算签名。如果结果不一致就是签名串、私钥加载或 body 内容出了问题。5.2 常见 V3 退款错误码速查错误码含义处理建议SIGN_ERROR签名不匹配检查 serial_no 是否对应私钥文件检查拼接串是否多换行/少换行确认 body 未被格式化PARAM_ERROR参数不合法金额必须是正整数out_refund_no 不能包含空格或中文NOT_FOUND订单不存在确认 out_trade_no 或 transaction_id 来自同一商户号REFUND_FEE_MISMATCH退款金额超限复查原单实付金额注意优惠金额和分账场景NO_AUTH无接口权限确认商户号已开通退款权限且 AppID 与支付主体一致遇到 SIGN_ERROR 时优先查服务器时间是否偏差过大。V3 请求签名里的 timestamp 是 Unix 秒如果和微信服务器时间差超过 5 分钟会直接拒绝很多调试到一半突然开始 SIGN_ERROR 的情况都源于服务器 NTP 失效。5.3 把 timestamp 和 nonce_str 固定住签名就能复现在本地写一个不被框架调用的 test 方法把 timestamp、nonce_str、serial_no 全部固定传入固定的 body输出计算出的 signature。这样每次跑出来的值都一样适合做回归验证。唯一要注意的是固定值不能频繁使用否则 nonce 被微信服务端记录后重复请求会被拦截。这个固定签名的测试方法保留在工程里之后接 V3 商家转账、分账、投诉回调时都能复用同一套验签逻辑。本文还有配套的精品资源点击获取