首页 / 资讯中心 / 文章详情

Java微信支付V3退款实战:签名、证书与避坑指南

Java微信支付V3退款实战:签名、证书与避坑指南 ★ FEATURED ARTICLE
简介这份资源面向使用Java开发微信小程序支付的开发者聚焦微信支付V3版本的退款功能实现帮助解决退款接口调用、签名验证与回调处理等实际问题。压缩包共4个文件以3个txt示例代码和1个properties配置文件为主分别承载支付V3核心Bean、控制器逻辑、依赖说明与商户参数配置整体约6KB轻量便于快速集成到项目中。目前已有4693人学习下载说明其在同类支付开发场景中具备一定参考价值。内容围绕V3退款流程展开涵盖获取Access Token、发起退款请求、处理退款结果、回调通知验签、错误重试机制及小程序端交互等关键环节读者可据此理解退款接口的参数封装与签名方式掌握回调状态判断与数据库更新思路并借鉴日志记录与排错方法在测试环境中完成调试后再上线从而提升退款功能的稳定性与安全性。1. 从一笔退不掉的订单说起Java 小程序微信支付 V3 退款到底难在哪小程序里用户付完钱要退款看着只是调一个接口真上手才发现坑全在细节里。微信支付 V3 版本把签名算法从 MD5 换成了 SHA256-RSA请求体要 JSON 序列化后参与签名回调要用 AES-256-GCM 解密证书还得定期下载轮换。很多用 Java 做小程序商城的团队支付能跑通退款却卡在「签名验证失败」「证书序列号不匹配」「解密报错」这几步上。这篇笔记就围绕 Java 微信支付小程序退款 V3 这条链路把选型理由、最小可跑代码、参数怎么设、翻车点在哪讲清楚。适合已经接过小程序支付、现在要补退款能力的 Java 后端也适合正在做小程序商城、需要处理售后退款场景的开发者。读完你能拿到一套能直接抄进项目的退款实现思路而不是只停留在「调个 API」的层面。2. 退款 V3 的签名与证书为什么老代码直接搬过来会翻车微信支付 V3 和 V2 最大的差别不在接口地址而在整套安全模型换了。V2 用 MD5 拼串加签名密钥就是一个 API 密钥V3 改成 SHA256-RSA 非对称签名商户要用自己的私钥签名用微信平台证书验签回调数据还要用 APIv3 密钥做 AES-256-GCM 解密。这意味着你从旧项目里复制过来的WXPayUtil那套东西在 V3 退款里一行都用不上。2.1 V3 签名的四段式构造与常见误用V3 请求签名不是把整个 JSON 丢进去算哈希而是按固定格式拼一个「签名串」再用商户私钥做 SHA256withRSA 签名。签名串的构造规则是四行加一个换行结尾HTTP请求方法\n URL路径\n 请求时间戳\n 请求随机串\n 请求报文主体\n这里有几个容易搞错的地方。第一URL 路径要带 query string比如/v3/refund/domestic/refunds?limit10不能只写/v3/refund/domestic/refunds。第二请求时间戳是秒级不是毫秒用System.currentTimeMillis() / 1000。第三请求报文主体在 GET 请求里是空字符串但仍然要保留那一行和换行。第四签名结果要 Base64 编码后放进Authorization头。Authorization 头的格式也有讲究WECHATPAY2-SHA256-RSA2048 mchid商户号,nonce_str随机串,signature签名,timestamp时间戳,serial_no商户证书序列号注意serial_no是商户 API 证书的序列号不是微信平台证书的序列号这两个在退款场景里经常被搞混。商户证书序列号在你下载证书时就能看到平台证书序列号要通过/v3/certificates接口获取。2.2 平台证书下载与本地缓存策略退款本身不强制要求平台证书但如果你要验签微信的回调通知就必须有平台证书。平台证书会定期轮换常见做法是启动时拉一次之后每隔一段时间刷新同时把证书序列号和公钥缓存到本地。// 下载平台证书并缓存注意解密用的是 APIv3 密钥 public void refreshPlatformCertificates(String apiV3Key) throws Exception { String url https://api.mch.weixin.qq.com/v3/certificates; String method GET; String timestamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr UUID.randomUUID().toString().replace(-, ); // GET 请求 body 为空字符串但签名串里仍要保留这一行 String message method \n /v3/certificates \n timestamp \n nonceStr \n \n; String signature signWithPrivateKey(message, merchantPrivateKey); // 组装 Authorization 头serial_no 用商户证书序列号 String auth WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonceStr \, signature\ signature \, timestamp\ timestamp \, serial_no\ merchantCertSerialNo \; // 发起 HTTPS 请求拿到证书列表后逐个用 APIv3 密钥解密 // 解密算法 AES-256-GCMnonce 和 associated_data 来自返回体 // 解密后的证书用 X509 解析缓存 serial_no - PublicKey }这段代码的关键点在于签名串里 GET 请求的 body 是空行但那个换行不能省Authorization 头里的serial_no是商户证书序列号拿到证书列表后每个证书的encrypt_certificate字段要用 APIv3 密钥解密解密时nonce和associated_data必须和返回体里的一致否则 GCM 校验会失败。提示平台证书缓存建议加一个过期时间比如 12 小时刷新一次。不要每次请求都去拉证书微信那边有频率限制拉太频繁会被限流。2.3 退款接口的请求体构造与金额单位退款接口是POST /v3/refund/domestic/refunds请求体里金额单位是「分」不是「元」。这一点和支付接口一致但退款时容易因为订单金额换算出错导致退款金额不对。// 构造退款请求体金额单位是分 MapString, Object refundBody new HashMap(); refundBody.put(out_trade_no, 订单号); // 原支付订单号 refundBody.put(out_refund_no, 退款单号); // 商户自己的退款单号唯一 refundBody.put(reason, 用户申请退款); // 退款原因可选 refundBody.put(notify_url, https://你的域名/refund/notify); // 退款结果回调 MapString, Object amount new HashMap(); amount.put(refund, 100); // 退款金额单位分 amount.put(total, 100); // 原订单总金额单位分 amount.put(currency, CNY); refundBody.put(amount, amount); String bodyJson JSON.toJSONString(refundBody); // 用 bodyJson 参与签名注意签名串里的 body 要和实际发送的完全一致out_refund_no是商户侧退款单号必须全局唯一。如果你用同一个out_refund_no重复请求微信会返回同一笔退款的结果不会重复退款这算是一个幂等保护。但如果你换了out_refund_no对同一笔订单再次退款只要累计退款金额不超过原订单金额微信是允许的这叫「多次部分退款」。notify_url是退款结果异步通知地址必须是 HTTPS且不能带 query 参数。退款是异步操作调完接口拿到的是「退款受理成功」不是「退款到账」。真正的退款结果要通过回调或者主动查询来确认。3. 用 Java 把退款请求发出去最小可跑代码与参数说明这一章给一套能直接跑的最小实现。不依赖微信官方 SDK用 HttpClient 加 Jackson 手写方便你理解每一步在干什么。如果你项目里已经引了wechatpay-java也可以对照着看哪些参数是必须的。3.1 依赖引入与私钥加载先加依赖用 Jackson 处理 JSON用 Java 自带的 HttpClient 发请求!-- pom.xml 片段 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.15.2/version /dependency私钥加载是第一个容易翻车的地方。微信商户私钥是apiclient_key.pem文件内容以-----BEGIN PRIVATE KEY-----开头。用 Java 加载时要去掉头尾和换行再做 Base64 解码// 加载商户私钥pem 文件去掉头尾和换行后 Base64 解码 public PrivateKey loadPrivateKey(String pemPath) throws Exception { String pem new String(Files.readAllBytes(Paths.get(pemPath))); String privateKeyContent pem .replace(-----BEGIN PRIVATE KEY-----, ) .replace(-----END PRIVATE KEY-----, ) .replaceAll(\\s, ); byte[] keyBytes Base64.getDecoder().decode(privateKeyContent); PKCS8EncodedKeySpec spec new PKCS8EncodedKeySpec(keyBytes); KeyFactory kf KeyFactory.getInstance(RSA); return kf.generatePrivate(spec); }注意这里用的是PKCS8EncodedKeySpec因为微信下载的私钥是 PKCS#8 格式。如果你拿到的是 PKCS#1 格式以-----BEGIN RSA PRIVATE KEY-----开头需要先转换否则会报InvalidKeySpecException。3.2 签名生成与 Authorization 头组装签名是整个退款请求里最核心的一步写错了微信直接返回 401 或 403// 生成 V3 签名message 是四行签名串 public String sign(String message, PrivateKey privateKey) throws Exception { Signature sign Signature.getInstance(SHA256withRSA); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(sign.sign()); } // 组装退款请求的 Authorization 头 public String buildAuthHeader(String method, String urlPath, String body, String mchId, String serialNo, PrivateKey privateKey) throws Exception { String timestamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr UUID.randomUUID().toString().replace(-, ); // 签名串四行方法、路径、时间戳、随机串、body最后加换行 String message method \n urlPath \n timestamp \n nonceStr \n body \n; String signature sign(message, privateKey); return WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonceStr \, signature\ signature \, timestamp\ timestamp \, serial_no\ serialNo \; }urlPath要带 query stringbody要和实际发送的 JSON 字符串完全一致包括空格和字段顺序。如果你用 Jackson 序列化后再手动改字段顺序签名就会对不上。常见做法是序列化一次把结果同时用于签名和发送。3.3 发送退款请求并解析响应把上面几步串起来发一个完整的退款请求// 发起退款请求 public RefundResult refund(String outTradeNo, String outRefundNo, int refundFen, int totalFen) throws Exception { String urlPath /v3/refund/domestic/refunds; String url https://api.mch.weixin.qq.com urlPath; MapString, Object body new HashMap(); body.put(out_trade_no, outTradeNo); body.put(out_refund_no, outRefundNo); MapString, Object amount new HashMap(); amount.put(refund, refundFen); amount.put(total, totalFen); amount.put(currency, CNY); body.put(amount, amount); String bodyJson objectMapper.writeValueAsString(body); String auth buildAuthHeader(POST, urlPath, bodyJson, mchId, merchantCertSerialNo, privateKey); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .header(Authorization, auth) .header(Content-Type, application/json) .header(Accept, application/json) .POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8)) .build(); HttpResponseString response httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() ! 200) { // 非 200 时响应体里有 code 和 message直接打日志排查 throw new RuntimeException(退款失败: response.body()); } return objectMapper.readValue(response.body(), RefundResult.class); }响应里关键字段有refund_id微信退款单号、out_refund_no商户退款单号、status退款状态、amount退款金额明细。status为SUCCESS表示退款成功PROCESSING表示处理中ABNORMAL表示退款异常需要人工介入。注意退款接口返回 200 不代表退款到账只代表受理成功。最终结果要等回调或者主动查询/v3/refund/domestic/refunds/{out_refund_no}。3.4 退款回调的验签与解密退款结果回调是 POST 请求body 是加密的。处理流程是先验签再解密最后处理业务。// 退款回调处理验签 解密 public String handleRefundNotify(String body, String signature, String timestamp, String nonce, String serial) throws Exception { // 1. 验签用平台证书公钥验证签名 String message timestamp \n nonce \n body \n; PublicKey platformPublicKey platformCertCache.get(serial); if (platformPublicKey null) { throw new RuntimeException(平台证书不存在serial serial); } Signature verifier Signature.getInstance(SHA256withRSA); verifier.initVerify(platformPublicKey); verifier.update(message.getBytes(StandardCharsets.UTF_8)); if (!verifier.verify(Base64.getDecoder().decode(signature))) { throw new RuntimeException(回调验签失败); } // 2. 解密用 APIv3 密钥做 AES-256-GCM 解密 JsonNode node objectMapper.readTree(body); JsonNode resource node.get(resource); String ciphertext resource.get(ciphertext).asText(); String associatedData resource.get(associated_data).asText(); String nonceStr resource.get(nonce).asText(); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec key new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), AES); GCMParameterSpec spec new GCMParameterSpec(128, nonceStr.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, key, spec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); byte[] plain cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plain, StandardCharsets.UTF_8); }验签用的serial是回调头里的Wechatpay-Serial对应平台证书序列号。解密时associated_data和nonce都来自回调 body 的resource字段不能自己编。GCM 的 tag 长度是 128 位写 128 就行。4. 退款状态查询与对账别等用户来问才查退款是异步的回调可能延迟也可能因为网络问题丢失。生产环境里不能只依赖回调必须有一套主动查询和对账机制。4.1 主动查询退款结果的正确姿势查询接口是GET /v3/refund/domestic/refunds/{out_refund_no}路径里带退款单号。签名时 URL 路径要包含这个单号// 查询退款结果 public RefundResult queryRefund(String outRefundNo) throws Exception { String urlPath /v3/refund/domestic/refunds/ outRefundNo; String url https://api.mch.weixin.qq.com urlPath; String auth buildAuthHeader(GET, urlPath, , mchId, merchantCertSerialNo, privateKey); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .header(Authorization, auth) .header(Accept, application/json) .GET() .build(); HttpResponseString response httpClient.send(request, HttpResponse.BodyHandlers.ofString()); return objectMapper.readValue(response.body(), RefundResult.class); }查询频率要有节制。常见做法是退款请求发出后隔几秒查一次连续查几次还没结果就拉长间隔。不要写个while(true)死循环猛查微信有频率限制查太猛会被限流甚至封禁。4.2 退款对账与状态机设计退款状态建议在本地建一张退款记录表字段至少包括商户退款单号、微信退款单号、原订单号、退款金额、状态、创建时间、更新时间。状态机可以设计成本地状态触发条件下一步INIT创建退款记录调退款接口PROCESSING接口返回受理成功等回调或主动查询SUCCESS回调或查询返回 SUCCESS更新订单状态ABNORMAL回调或查询返回 ABNORMAL人工介入CLOSED退款关闭记录原因对账时拿本地PROCESSING状态的记录去查微信把结果同步回来。如果本地是PROCESSING但微信已经是SUCCESS说明回调丢了以微信为准更新本地。反过来如果微信是ABNORMAL要去看是不是退款金额超了或者原订单有问题。提示退款对账建议每天跑一次把前一天所有非终态的退款记录捞出来查一遍。这个习惯能帮你提前发现回调丢失的问题而不是等用户投诉。5. 退款 V3 避坑清单这 5 个坑我踩过5.1 坑一签名串 body 和实际发送不一致现象微信返回 401提示SIGN_ERROR或签名验证失败。原因签名时用的 body 和实际 HTTP 发送的 body 不是同一个字符串。常见于用 Jackson 序列化两次或者发送前又改了字段。解决序列化一次把结果存到一个变量里签名和发送都用这个变量。不要签名用JSON.toJSONString(map)发送用objectMapper.writeValueAsString(map)两个库的输出可能不一样。5.2 坑二证书序列号用错现象返回 403提示证书序列号不匹配。原因Authorization 头里的serial_no填成了平台证书序列号或者填成了 APIv3 密钥的某个值。解决serial_no必须是商户 API 证书的序列号。这个序列号在商户平台下载证书时能看到也可以从证书文件里解析出来。平台证书序列号只在验签回调时用。5.3 坑三退款金额单位搞错现象退款金额变成原金额的 100 倍或者报PARAM_ERROR。原因微信退款金额单位是分但业务系统里可能用元。如果直接把元传进去100 元会变成 100 分退少了如果乘了 100 又乘了一次就退多了。解决在退款入口统一做单位转换入参用分出参也明确标注单位。数据库里存金额建议也存分避免浮点误差。5.4 坑四回调解密时 nonce 和 associated_data 用错现象解密报AEADBadTagException或GCM 校验失败。原因nonce和associated_data没有从回调 body 的resource字段里取而是自己生成的或者用了请求时的值。解决解密参数必须严格来自回调 body 的resource.nonce和resource.associated_dataciphertext也是。这三个字段是一组不能混用。5.5 坑五重复退款请求没有幂等保护现象同一笔订单被退了两次或者用户收到两笔退款。原因out_refund_no每次请求都重新生成导致微信认为是两笔不同的退款。解决out_refund_no用业务退款单号全局唯一且可追溯。同一笔退款请求重试时复用同一个out_refund_no微信会返回同一笔退款结果天然幂等。6. 把退款做成可复用的能力几个进阶技巧退款跑通之后下一步是把它做成团队里可复用的能力而不是每个项目重写一遍。我一般会抽一个WechatPayV3Client把签名、请求、验签、解密都封进去业务层只传参数。一个具体技巧是把签名和请求封装成一个方法用泛型返回结果这样支付、退款、查询都能复用同一套签名逻辑。// 通用 V3 请求方法支付/退款/查询共用 public T T request(String method, String urlPath, Object bodyObj, ClassT respType) throws Exception { String bodyJson bodyObj null ? : objectMapper.writeValueAsString(bodyObj); String auth buildAuthHeader(method, urlPath, bodyJson, mchId, merchantCertSerialNo, privateKey); HttpRequest.Builder builder HttpRequest.newBuilder() .uri(URI.create(https://api.mch.weixin.qq.com urlPath)) .header(Authorization, auth) .header(Accept, application/json); if (GET.equals(method)) { builder.GET(); } else { builder.header(Content-Type, application/json) .POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8)); } HttpResponseString response httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); if (response.statusCode() ! 200) { throw new RuntimeException(微信接口错误: response.body()); } return objectMapper.readValue(response.body(), respType); }这样退款就是一行调用request(POST, /v3/refund/domestic/refunds, refundBody, RefundResult.class)。支付、查询、关单也都走同一个方法签名逻辑只有一份改起来不会漏。另一个技巧是回调处理做成幂等的。微信回调可能重复推送同一个退款单号可能收到多次通知。处理前先查本地退款记录如果已经是终态就直接返回成功不要重复处理业务逻辑。验证退款是否真的成功不要只看接口返回。我的习惯是接口返回受理成功后本地记PROCESSING收到回调或主动查询到SUCCESS后才更新订单和账务。中间任何一步异常都留日志和重试。退款这件事宁可多查一次也别让用户等。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站