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

Java对接微信退款接口实战:V2/V3证书签名与回调排障要点

Java对接微信退款接口实战:V2/V3证书签名与回调排障要点 ★ FEATURED ARTICLE
简介这套基于 Java 的微信退款接口实现资料面向需要对接微信支付退款能力的后端开发人员。资源以真实项目工程为主线完整呈现商户系统调用退款接口的核心流程涵盖从数字证书加载、安全连接创建到退款参数构造、请求发送以及返回结果解析的每一个关键环节尤其重点说明了非对称加密签名规则、证书存储格式以及如何利用密钥库完成双向认证。压缩包大小约 1.92MB共收录二十九个文件其中以依赖库、源代码、编译后的类文件为主并包含工程配置、页面文件等辅助内容便于导入集成开发环境后直接对照学习。当前已有八百六十九人学习下载说明该示例具备较高参考价值。通过阅读源码开发者能快速掌握证书安全配置、签名算法实现、异常分支处理及网络超时设置等细节也可借鉴其工程结构完成自身退款模块的搭建有效缩短微信支付相关功能的联调周期适合正在研究网络编程与安全通信的开发者收藏使用。1. Java对接微信退款接口为什么说这是支付系统里最容易翻车的一环Java对接微信退款接口很多人第一反应是“支付都调通了退款不就是反过来发个请求吗”结果第一笔真实退款就挂在“退款中”一整天财务来问的时候才意识到退款接口根本不是同步返回结果。真正决定退款能不能落地的是证书、签名、回调验签和资金幂等这几件看起来不起眼的事。这篇笔记会把微信退款接口从选型到代码、从回调到排障完整过一遍适合正在维护老支付系统、或者准备在 Spring Boot 项目里新接入退款能力的 Java 开发。看完你至少能判断自己的商户号该走 V2 还是 V3也能照着把最小可用的退款流程跑通。2. V2 还是 V3先从报文、签名和证书选型说起2.1 报文与签名XMLMD5 和 JSONRSA 的分水岭微信支付目前可用的退款接口有两套。老接口 V2 的退款地址是https://api.mch.weixin.qq.com/secapi/pay/refund请求和响应都是 XML 报文签名算法是 MD5 或 HMAC-SHA256。新接口 V3 的退款地址是https://api.mch.weixin.qq.com/v3/refund/domestic/refundsJSON 格式签名用 SHA256withRSA。两套接口的商户参数、证书体系完全不同。V2 的签名方式是“参数拼接”把所有业务参数按 key 的 ASCII 升序排列拼成key1value1key2value2的字符串末尾追加上key商户API密钥再做 MD5 并转大写。这个思路直观但缺点也很明显参数一个都不能漏空值和 sign 本身要排除否则签名必挂。V3 不再用商户 API 密钥做签名而是把请求方法、请求路径、时间戳、随机串、请求体拼成一个待签名串用商户 API 证书的私钥做 RSA 签名放进 Authorization 请求头。我一般建议新项目直接上 V3老项目如果 V2 已经稳定跑了好几年别为了“新技术”去重构退款链路。退款本身就是资金操作重构意味着老订单、旧证书、回调报文格式全都得兼容收益不大。V2 的存量代码非常多网上能搜到的“微信退款 Java 实现”大半是 V2这也是本文先讲 V2 的原因。2.2 证书体系双向 TLS 和平台证书验签的运维差异V2 退款接口和支付接口最大的区别在于双向 TLS。普通支付下单只需要 API 密钥做签名但退款、企业转账这类涉及资金出款的接口微信要求调用方必须携带商户 API 证书。实现上就是把apiclient_cert.p12加载进 KeyStore用这个 KeyStore 构造 SSLContext再创建 HttpClient。服务器发起请求时微信会校验客户端证书没有证书直接拒绝。V3 的证书逻辑分成了两半。商户侧只需要保存商户 API 证书的私钥用来给自己的请求签名微信侧返回的响应和回调需要用“微信支付平台证书”的公钥来验签。很多第一次接 V3 的人把这两件事搞混商户私钥是签自己的请求平台证书是验微信的响应方向完全相反。还有 APIv3 密钥32 位字符串要单独保存它是用来解密回调密文的跟签名私钥、平台证书是三个不同的东西。运维上的差异更值得注意。V2 的 p12 证书有密码密码默认是商户号很多人在服务器上加载失败就是因为把密码填成了 API 密钥。V3 的平台证书会过期需要用自动更新机制定时从微信侧拉取不能像 p12 一样手工放一个文件就完事。这些坑在第 5 章会单独展开。2.3 金额与幂等退款接口为什么天生比支付接口难调先看最基本的资金规则total_fee和refund_fee都以“分”为单位。refund_fee不能大于total_fee同一个订单可以多次部分退款但累计退款金额不能超过原单金额。这个规则不是文档里看看就行的支付系统里几乎所有资金事故都出在金额单位混用上。再看幂等规则。V2 和 V3 都要求每次退款必须传out_refund_no这是商户自己生成的退款单号一个退款单号只能对应一笔退款。如果同一个退款单号重复提交微信会直接拒绝或者返回已存在的退款单信息。很多团队在超时重试时重新生成一个退款单号这就可能出现“原请求已经受理但本地没收到响应结果又发起了一笔新退款”的情况等于同一笔订单被退两次。很多人第一次接退款接口会误以为退款申请返回 SUCCESS 就算退完了。实际上微信退款是异步链路申请接口只是受理真正的结果通过回调通知送达中间还可能经历退款中、退款关闭等状态。所以在设计系统时退款状态机、回调幂等、查询补偿这三件事必须一开始就规划好否则退款接口上线后一定会出现对不上账的情况。3. V2 退款接口最小可跑通p12 证书、MD5 签名和 XML 响应3.1 准备材料商户号、API 密钥和 p12 证书接 V2 退款需要确认四样东西商户号mch_id、AppID、API 密钥V2 用的 32 位密钥、apiclient_cert.p12证书文件。证书在微信支付商户平台的“账户中心 → API 安全”里下载下载下来是个 p12 文件密码默认是商户号本身。证书建议放到项目的resources/cert/目录下或者配置一个绝对路径生产环境把路径放到配置中心。不要把 p12 文件提交到 Git 仓库证书泄露等于别人能用你的商户身份发起退款。如果服务器上需要的是 pem 格式可以用 openssl 转一下openssl pkcs12 -in apiclient_cert.p12 -out apiclient_cert.pem -nodes参数说明-in指定 p12 文件路径-out导出 pem-nodes表示私钥不加密导出后文件里能直接看到BEGIN PRIVATE KEY这种文件权限要设为 600。加载 p12 的 KeyStore 代码如下KeyStore keyStore KeyStore.getInstance(PKCS12); try (InputStream in new FileInputStream(certPath)) { keyStore.load(in, mchId.toCharArray()); }逻辑说明KeyStore的加载密码用的是商户号不是 API 密钥。mchId.toCharArray()这一步写错是 V2 证书加载失败的第一大原因。后面构造 SSLContext 时还要再传一次密码给loadKeyMaterial两处必须是同一个值。3.2 关键代码加载证书、生成签名、发退款请求V2 退款请求的完整流程是四步构造参数 TreeMap、按规则生成 MD5 签名、拼 XML、用带双向 TLS 的 HttpClient 发起 POST。下面给出一套可以直接改参数使用的核心代码TreeMapString, String params new TreeMap(); params.put(appid, appId); params.put(mch_id, mchId); params.put(nonce_str, UUID.randomUUID().toString().replace(-, )); params.put(out_trade_no, orderNo); params.put(out_refund_no, refundNo); params.put(total_fee, String.valueOf(totalFee)); params.put(refund_fee, String.valueOf(refundFee)); params.put(notify_url, notifyUrl); params.put(sign, buildSign(params, apiKey)); StringBuilder xml new StringBuilder(xml); for (Map.EntryString, String entry : params.entrySet()) { xml.append().append(entry.getKey()).append() .append(entry.getValue()) .append(/).append(entry.getKey()).append(); } xml.append(/xml);签名函数的实现private String buildSign(TreeMapString, String params, String apiKey) throws Exception { StringBuilder sb new StringBuilder(); for (Map.EntryString, String entry : params.entrySet()) { String k entry.getKey(); String v entry.getValue(); if (v ! null !v.isEmpty() !sign.equals(k)) { sb.append(k).append().append(v).append(); } } sb.append(key).append(apiKey); return DigestUtils.md5Hex(sb.toString()).toUpperCase(); }逻辑说明TreeMap保证了参数按 ASCII 升序排列这就是 MD5 签名要求的顺序。空值参数不参与签名sign字段本身也要排除。最后拼接的key商户API密钥只用于签名计算不会拼进 XML。DigestUtils.md5Hex来自 commons-codec返回的是 32 位小写 hex必须.toUpperCase()否则微信会报签名错误。接下来是带证书的 HttpClient 和 POST 请求SSLContext sslContext SSLContexts.custom() .loadKeyMaterial(keyStore, mchId.toCharArray()) .build(); SSLConnectionSocketFactory factory new SSLConnectionSocketFactory( sslContext, new String[]{TLSv1.2}, null, SSLConnectionSocketFactory.getDefaultHostnameVerifier()); CloseableHttpClient httpClient HttpClients.custom() .setSSLSocketFactory(factory) .build(); HttpPost post new HttpPost(https://api.mch.weixin.qq.com/secapi/pay/refund); post.setEntity(new StringEntity(xml.toString(), ContentType.APPLICATION_XML)); try (CloseableHttpResponse resp httpClient.execute(post)) { String result EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); // result 是 XML交给下一步解析 }参数说明loadKeyMaterial的第一个参数是前一步加载好的 KeyStore第二个参数还是商户号密码。TLS 版本建议固定 TLSv1.2避免服务器 JDK 默认协议不一致导致握手失败。notify_url必须是公网可访问的 HTTPS 地址微信的退款结果会异步 POST 到这个地址。3.3 响应解析两层返回码与错误码表V2 退款响应 XML 里有两层状态。return_code表示通信层是否成功return_msg是对应的提示result_code表示业务层是否成功只有两个都等于SUCCESS才算受理成功。解析时用 DocumentBuilder注意关闭外部实体加载防止 XXE 漏洞DocumentBuilderFactory dbf DocumentBuilderFactory.newInstance(); dbf.setFeature(http://apache.org/xml/features/nonvalidating/load-external-dtd, false); dbf.setXIncludeAware(false); Document doc dbf.newDocumentBuilder() .parse(new ByteArrayInputStream(xmlBytes)); String returnCode doc.getElementsByTagName(return_code).item(0).getTextContent(); String resultCode doc.getElementsByTagName(result_code).item(0).getTextContent(); String refundId doc.getElementsByTagName(refund_id).item(0).getTextContent();解析优先级上先判断return_code。如果通信失败result_code可能为空直接取会抛空指针。refund_id是微信侧生成的退款单号建议落库保存后续对账时要用。V2 退款申请接口的响应只代表“微信受理了退款”不代表钱已经原路退回。退款最终状态要通过回调通知或者退款查询接口获取这一点在代码注释里必须写清楚否则同事接手时容易误判。常见的申请阶段错误码如下表错误码含义处理方式REFUND_FEE_MISMATCH退款金额与原单金额不匹配核对 total_fee 和 refund_fee确认单位是分NOTENOUGH商户账户余额不足充值后再发起退款INVALID_TRANSACTIONID原交易号不存在确认 out_trade_no 或 transaction_id 来自支付单SYSTEMERROR微信侧系统超时用同一个 out_refund_no 重试USER_ACCOUNT_ABNORMAL用户账户异常联系用户处理4. 升级到 V3 退款接口官方 SDK、回调验签与解密4.1 用 wechatpay-java 拉起带签名的 HttpClientV3 直接手写 RSA 签名再加 Authorization 头不是不行但代价是你要同时处理平台证书下载、签名串规范化、请求头拼装代码量和出错面都很大。常见做法是直接用微信官方维护的 wechatpay-java引入依赖后核心只需要配好商户参数。初始化一段带自动验签能力的 HttpClient 是这样的PrivateKey merchantPrivateKey PemUtil.loadPrivateKey(privateKeyPem); AutoUpdateCertificatesVerifier verifier new AutoUpdateCertificatesVerifier( new WechatPay2Credentials(merchantId, new PrivateKeySigner(merchantSerialNo, merchantPrivateKey)), apiV3Key.getBytes(StandardCharsets.UTF_8)); CloseableHttpClient httpClient WechatPayHttpClientBuilder.create() .withMerchant(merchantId, merchantSerialNo, merchantPrivateKey) .withValidator(new WechatPay2Validator(verifier)) .build();逻辑说明merchantSerialNo是商户 API 证书的序列号在商户平台的证书列表里能看到。merchantPrivateKey是商户私钥对象PemUtil.loadPrivateKey用来加载 pem 格式的私钥文件。apiV3Key是 APIv3 密钥它在这里被传给AutoUpdateCertificatesVerifier用于自动下载和更新微信支付平台证书。然后构造退款请求体amount是一个嵌套对象这点和 V2 扁平 XML 的差异很大String url https://api.mch.weixin.qq.com/v3/refund/domestic/refunds; MapString, Object amount new HashMap(); amount.put(total, totalFee); amount.put(refund, refundFee); amount.put(currency, CNY); MapString, Object body new HashMap(); body.put(out_trade_no, orderNo); body.put(out_refund_no, refundNo); body.put(amount, amount); body.put(notify_url, notifyUrl); ObjectMapper mapper new ObjectMapper(); String jsonBody mapper.writeValueAsString(body); HttpPost post new HttpPost(url); post.addHeader(Accept, application/json); post.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON)); try (CloseableHttpResponse resp httpClient.execute(post)) { String respBody EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); }参数说明total和refund还是分currency固定CNY。V3 的notify_url字段在请求体里是可选的但不传的话只能靠主动查询拿最终结果建议每次都传。这里StringEntity保证了请求体可以被 SDK 的拦截器完整读取并参与签名。4.2 退款回调先验签再解密最后落库V3 的退款回调报文结构和 V2 完全不同。V2 回调是明文 XML 加一个 sign 字段V3 回调虽然是 JSON但真正的业务数据被 AES-256-GCM 加密放在resource.ciphertext里而且必须先验签再解密。回调请求头里有四个关键字段Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial。验签的代码逻辑如下核心是构造待验签串并比对String message timestamp \n nonce \n body \n; Signature signature Signature.getInstance(SHA256withRSA); signature.initVerify(platformCert.getPublicKey()); signature.update(message.getBytes(StandardCharsets.UTF_8)); boolean valid signature.verify(Base64.getDecoder().decode(wechatpaySignature));逻辑说明body必须是回调接口收到的原始请求体字符串不能是 Spring MVC 反序列化后再转回的 JSON字段顺序一变化验签就失败。platformCert要根据Wechatpay-Serial从本地平台证书列表里选如果用AutoUpdateCertificatesVerifier管理可以直接按序列号取证书。验签失败直接丢弃请求不能继续解密。验签通过后再解密resource.ciphertextString apiV3Key 你的32位APIv3密钥; byte[] nonceBytes resource.getNonce().getBytes(StandardCharsets.UTF_8); byte[] ciphertext Base64.getDecoder().decode(resource.getCiphertext()); SecretKeySpec key new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), AES); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, nonceBytes)); String plaintext new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);参数说明GCMParameterSpec的第一个参数是 tag 长度微信支付固定 128。解密出的明文是 JSON里面包含out_refund_no、refund_id、refund_status、success_time等字段。refund_status为SUCCESS才是退款成功CLOSED表示退款关闭。4.3 平台证书与 APIv3 密钥V3 的运维底线V3 上线后运维侧有三件事必须盯住。第一商户 API 私钥不能出现在源码仓库和日志里常见做法是放到配置中心或密钥管理服务应用启动时读取。第二微信支付平台证书有有效期AutoUpdateCertificatesVerifier会自动从微信侧下载更新但如果你的系统无法访问微信平台证书下载接口就必须手工维护一个证书刷新任务否则证书过期后验签和响应校验全部失败。第三APIv3 密钥和 APIv2 密钥是两个完全不同的密钥V3 回调加密用的是 APIv3 密钥和老接口里的 32 位 API 密钥不是同一个别混着配。实践里最常看到的翻车现场是V2 项目里已经有一套 API 密钥接 V3 时团队以为继续用同一把结果回调解密出的全是乱码。我建议在配置中心里把api.v2.key、api.v3.key、merchant.serial、merchant.private-key-path四个配置项分开命名避免配置串位。5. 退款接口 5 个高频坑从金额错位到重复退款的排障记录5.1 REFUND_FEE_MISMATCH金额单位把“分”当“元”这个错误几乎每个接退款的人都会遇到。现象是接口返回REFUND_FEE_MISMATCH字面意思是退款金额和原订单金额不一致。查了订单表数值明明对得上那就回头查单位。支付下单时total_fee是按分存的退款时如果从页面上拿了一个带小数的“元”金额直接填进refund_fee必然不匹配。还有一种情况是数据库字段本身存的是元代码里转分时用了 BigDecimal 但丢掉了 scale。解决方法是统一在服务端定义一个金额工具类所有进出微信的参数强制int类型单位为分页面展示才转成元。5.2 网络超时后的重复退款out_refund_no 要幂等现象是发起退款时 HttpClient 超时没有拿到响应程序直接抛异常运维手工重试时重新生成了一个out_refund_no结果这笔订单被退了两次。微信侧的退款受理是服务端完成的网络超时不代表退款没成功同一个退款单号重复请求会得到原退款单信息而不是新增一笔。解决方法是把out_refund_no绑定到业务退款流水表的主键或唯一索引上重试永远复用同一个退款单号。同时把“查询退款状态”作为超时后的默认动作而不是直接重发退款。5.3 p12 证书加载失败密码、路径和文件格式三个变量V2 最常见的问题是keystore password was incorrect。排查思路按三个变量来第一p12 密码是商户号很多人在配置里填成了 API 密钥第二证书路径在本地和服务器上不一致FileInputStream读不到文件时不会报证书错会先报 FileNotFoundException第三有些商家后台下载的证书文件实际是 pem 格式但命名成 p12加载时也会失败。解决方法是先从商户平台重新下载一次 p12用上面的 openssl 命令打开确认格式然后把证书路径和密码都放到配置中心服务器上单独验证一次加载。5.4 回调丢失与重复回调退款状态对不上的根因退款申请成功但本地订单一直停留在“退款中”这种问题的根因多半在回调处理而不是微信没发。微信的回调通知会有重试机制第一次 POST 超时或者返回非 2xx微信会隔一段时间继续重试。如果回调地址写成了内网 IP微信根本打不进来那就只能靠主动查询兜底。还有另一个方向的问题回调被重复投递处理回调的接口没有做幂等导致退款流水表插入了两条记录。解决方法是out_refund_no加唯一索引回调处理逻辑先按退款单号查询存在就更新状态不存在才插入。5.5 服务商模式 appid 与商户号不匹配服务商给子商户退款时会遇到APPID_MCHID_NOT_MATCH。原因是 V2 退款请求里的appid和mch_id必须是同一主体而服务商模式下支付单是子商户的退款时却拿服务商的证书和密钥来调用参数就乱了。解决方法是先确认发起退款的证书属于服务商还是子商户。子商户自己退款用子商户的证书和子商户的 appid服务商代退请求体里的out_trade_no必须是子商户的支付单而接口调用凭据是服务商的同时 appid 要传子商户的 appid。V3 的服务商代退还涉及特约商户商户号字段建议先查清楚自己属于直连模式还是服务商模式再编码。6. 让退款更稳用查询接口做补偿把退款状态机落到数据库退款状态不能只靠回调推动还要有一条主动补偿的链路。微信提供了退款查询接口V2 是GET /pay/refundqueryV3 是GET /v3/refund/domestic/refunds/{out_refund_no}。我在项目里的做法是每次收到回调先查一次退款单状态确认查询结果和回调结果一致再落库如果回调迟迟不来启动一个定时任务扫描所有处于“退款中”超过 2 分钟的退款流水主动调用查询接口刷新状态。数据库侧要有一张退款流水表out_refund_no做唯一索引状态字段按照下面这张表流转本地状态触发条件处理动作REFUNDING退款申请受理成功等待回调超过 2 分钟进补偿查询SUCCESS回调或查询返回 refund_statusSUCCESS通知账务系统关闭退款单CLOSED回调或查询返回 refund_statusCLOSED标记失败引导用户重新发起多加一个查询动作看起来简单实际上解决了退款系统里最隐蔽的一类问题回调报文到了但因为网络或程序异常没落库。状态机落到数据库后任何时刻都能回答“这笔退款到底在哪”财务对账时直接按表导出不需要翻日志。我个人的习惯还有一个退款流水表每次状态变更都记录变更时间和变更来源来源字段标记是callback还是query。这样即使出问题也能清楚看到这笔退款是回调推的还是定时任务查出来的定位问题能少花一半时间。这套做法在真实业务里帮我校正过不少次回调乱序和重复通知的场景。退款这个方向资金安全大于一切宁可多查一次也不要让用户的钱悬在半路。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站