ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java微信退款接口实战:APIv3证书、签名、异步通知与对账避坑指南

Java微信退款接口实战:APIv3证书、签名、异步通知与对账避坑指南 简介这份资源面向需要对接微信支付退款能力的Java后端开发者聚焦商户通过API与微信服务器交互完成退款这一典型场景帮助解决PKCS12证书加载、HTTPS安全通信与签名验签等实现难点。压缩包共29个文件约1.92MB以10个jar依赖库、6个java源码、6个class编译文件为主另含xml配置、jsp页面及MyEclipse工程元数据构成一个可直接导入运行的示例工程。资源围绕退款接口调用流程展开涵盖KeyStore加载.p12证书、SSLContext配置HTTPS连接、HttpClient构建并发送POST请求、按微信规范组织JSON参数、RSA2048签名以及响应结果解析与错误处理等关键环节示例代码展示了从证书管理到请求发送的完整链路。目前已有869人学习下载适合希望快速理解微信退款接口调用逻辑、对照排查签名与证书问题的开发者参考借鉴。1. Java 微信退款接口从申请到到账那条最容易断的链路做过微信支付的人大多有个共识付款是顺风局退款才是逆风局。付款时参数对、证书对、回调地址通基本就过了退款不一样它牵扯到商户证书、双向认证、异步通知、对账兜底任何一环出问题钱就卡在「退款处理中」这个玄学状态里。Java 微信退款接口要解决的核心问题就是让一笔退款从商户系统发起经微信支付网关受理最终原路退回用户账户并且商户侧能可靠地知道结果。它适合已经跑通微信支付、现在要补退款能力的后端同学也适合正在做订单逆向流程、需要处理售后退款的业务开发。这篇不聊概念直接按我实际落地的顺序把证书、请求、回调、对账、踩坑一条条拆开。2. 退款接口的两种调用姿势APIv3 与老版 API 怎么选微信退款目前主流是 APIv3 接口老版 API基于 MD5/HMAC-SHA256 签名仍能用但官方在逐步收口。选型不是看哪个新而是看你现有支付代码走的是哪套。如果支付用的是 APIv3退款必须跟着用 APIv3因为证书体系和签名方式一致复用成本最低如果支付是老版 API短期内可以继续用老版退款接口但新项目没有理由再选它。2.1 APIv3 退款的请求结构与签名逻辑APIv3 退款接口路径是POST /v3/refund/domestic/refunds请求体是 JSON签名放在Authorization头里。签名串的构造规则是HTTP 方法 换行 URL 路径 换行 时间戳 换行 随机串 换行 请求体 换行。注意请求体参与签名所以序列化后的 JSON 必须和实际发送的字节完全一致不能先签名再改字段。// 构造 APIv3 退款请求签名 public String buildAuthorization(String method, String urlPath, String body, String mchId, String serialNo, PrivateKey privateKey) throws Exception { long timestamp System.currentTimeMillis() / 1000; String nonceStr UUID.randomUUID().toString().replace(-, ); // 签名串方法\nURL\n时间戳\n随机串\n请求体\n 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()); // 拼装 Authorization 头 return WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonceStr \, timestamp\ timestamp \, serial_no\ serialNo \, signature\ signature \; }这段代码里几个参数必须对齐serialNo是商户 API 证书的序列号不是平台证书序列号搞混了会直接返回 401privateKey是商户私钥从apiclient_key.pem加载urlPath必须带/v3前缀且不含域名和查询参数。时间戳单位是秒不是毫秒用毫秒会导致签名校验失败。请求体里的out_trade_no和out_refund_no是商户侧单号out_refund_no全局唯一重复提交同一单号微信会返回原退款单这其实是幂等设计别当成 bug。2.2 老版退款接口的签名与适用边界老版退款接口路径是https://api.mch.weixin.qq.com/pay/refund请求是 XML签名用 MD5 或 HMAC-SHA256密钥是 API 密钥32 位。它不需要商户证书做双向认证但需要证书文件用于退款这个特定接口——对老版退款也要证书只是签名和证书是两套东西。很多人第一次做老版退款时只配了 API 密钥没传证书结果报「证书错误」。// 老版退款 XML 组装与签名简化示意 MapString, String params new TreeMap(); params.put(appid, appId); params.put(mch_id, mchId); params.put(out_trade_no, outTradeNo); params.put(out_refund_no, outRefundNo); params.put(total_fee, 100); params.put(refund_fee, 100); params.put(nonce_str, UUID.randomUUID().toString().replace(-, )); // 按 key 字典序拼接末尾追加 keyAPI密钥做 MD5 String signStr params.entrySet().stream() .map(e - e.getKey() e.getValue()) .collect(Collectors.joining()) key apiKey; params.put(sign, DigestUtils.md5Hex(signStr).toUpperCase());老版签名的坑在于空值参数不参与签名但sign字段本身不参与total_fee和refund_fee单位是分不是元XML 里不能有空格和换行干扰。如果现有系统还在用老版建议至少把退款逻辑封装成独立模块方便后续迁移到 APIv3。迁移时注意APIv3 的金额字段是amount.refund和amount.total单位仍是分但结构从平铺变成了嵌套。3. 证书加载与双向认证退款请求为什么总在握手阶段翻车微信退款接口和支付接口最大的区别之一是退款必须用商户证书做双向认证。支付接口在 APIv3 下也需要证书但很多人支付跑通了就以为退款直接复用结果退款请求在 TLS 握手阶段就被拒。证书加载看着简单实际涉及 PKCS12 和 PEM 两种格式、证书序列号获取、私钥读取三个环节每个环节都有血泪经验。3.1 从 apiclient_cert.p12 加载证书与私钥微信商户平台下载的证书包里有apiclient_cert.p12、apiclient_key.pem、apiclient_cert.pem。p12 文件包含证书和私钥密码是商户号mchId。用 Java 加载 p12 的标准做法是通过KeyStore但要注意 p12 的别名和密码。// 加载 p12 证书获取私钥和证书序列号 public void loadP12(String p12Path, String mchId) throws Exception { KeyStore ks KeyStore.getInstance(PKCS12); try (FileInputStream fis new FileInputStream(p12Path)) { // 密码就是商户号不是证书密码 ks.load(fis, mchId.toCharArray()); } EnumerationString aliases ks.aliases(); while (aliases.hasMoreElements()) { String alias aliases.nextElement(); PrivateKey privateKey (PrivateKey) ks.getKey(alias, mchId.toCharArray()); Certificate cert ks.getCertificate(alias); // 证书序列号用于 Authorization 头 String serialNo ((X509Certificate) cert).getSerialNumber().toString(16).toUpperCase(); System.out.println(alias alias , serialNo serialNo); } }这里最容易翻车的是密码。p12 的密码是商户号不是你在商户平台设置的 API 密钥也不是证书下载时可能提示的密码。如果ks.load抛IOException: keystore password was incorrect先确认商户号有没有前后空格。另一个坑是别名p12 里通常只有一个别名但不同批次下载的证书别名可能不同不要硬编码别名遍历获取更稳。序列号转十六进制后要转大写微信侧校验时大小写敏感。3.2 用 PEM 文件构建 SSLContext 做双向认证如果你不想用 p12也可以用apiclient_cert.pem和apiclient_key.pem手动构建SSLContext。这种方式更透明但代码量更大。核心是把证书和私钥加载成X509Certificate和PrivateKey然后初始化KeyManagerFactory。// 用 PEM 构建双向认证的 HttpClient public CloseableHttpClient buildMutualTlsClient(String certPath, String keyPath) throws Exception { // 读取证书 X509Certificate cert PemUtils.readCertificate(certPath); // 读取私钥PKCS8 格式 PrivateKey privateKey PemUtils.readPrivateKey(keyPath); KeyStore keyStore KeyStore.getInstance(PKCS12); keyStore.load(null, null); keyStore.setKeyEntry(merchant, privateKey, .toCharArray(), new Certificate[]{cert}); KeyManagerFactory kmf KeyManagerFactory.getInstance( KeyManagerFactory.getDefaultAlgorithm()); kmf.init(keyStore, .toCharArray()); SSLContext sslContext SSLContext.getInstance(TLS); sslContext.init(kmf.getKeyManagers(), null, null); return HttpClients.custom().setSSLContext(sslContext).build(); }PEM 私钥必须是 PKCS8 格式微信下载的apiclient_key.pem默认就是 PKCS8开头是-----BEGIN PRIVATE KEY-----。如果是-----BEGIN RSA PRIVATE KEY-----那是 PKCS1Java 不能直接读需要先转换。转换命令用 opensslopenssl pkcs8 -topk8 -inform PEM -in apiclient_key.pem -outform PEM -nocrypt -out pkcs8_key.pem。这个转换步骤在容器化部署时经常被忽略因为本地开发环境可能已经转过镜像里没带转换后的文件上线就报InvalidKeyException。注意双向认证的SSLContext只加载了商户证书没有加载微信平台证书。APIv3 的响应验签需要平台证书这是另一套东西不要混在一起。平台证书通过GET /v3/certificates下载用 APIv3 密钥解密后得到。4. 退款结果怎么拿异步通知与主动查询的配合退款请求返回成功不代表钱到账。微信退款是异步处理接口返回的status可能是PROCESSING最终结果通过异步通知推送或者你主动查询。只依赖异步通知的风险是通知可能延迟、可能丢失、可能重复。只依赖主动查询的风险是查询频率高会被限流频率低则到账感知慢。生产环境的标准做法是两者配合异步通知做实时触发主动查询做兜底补偿。4.1 退款异步通知的验签与解密APIv3 的退款通知是加密的resource字段里是 AES-256-GCM 加密的密文需要用 APIv3 密钥解密。解密前先验签验签用微信平台证书。通知的event_type是REFUND.SUCCESS或REFUND.ABNORMAL等。// 退款通知验签与解密 public String handleRefundNotify(String body, String signature, String timestamp, String nonce, String serial) throws Exception { // 1. 验签用平台证书公钥验证 signature String message timestamp \n nonce \n body \n; Signature sign Signature.getInstance(SHA256withRSA); sign.initVerify(platformCert.getPublicKey()); sign.update(message.getBytes(StandardCharsets.UTF_8)); if (!sign.verify(Base64.getDecoder().decode(signature))) { throw new RuntimeException(验签失败); } // 2. 解密 resource JSONObject resource JSON.parseObject(body).getJSONObject(resource); String cipherText resource.getString(ciphertext); String associatedData resource.getString(associated_data); String nonceStr resource.getString(nonce); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec key new SecretKeySpec(apiV3Key.getBytes(), 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); }验签用的平台证书序列号要和通知头里的Wechatpay-Serial匹配不匹配说明平台证书轮换了需要重新下载。解密时associated_data和nonce都来自resource字段不是请求头。AES 密钥是 APIv3 密钥32 位不是 API 密钥。如果解密报AEADBadTagException九成是 APIv3 密钥不对或者associated_data传了 null。4.2 主动查询退款单的补偿策略主动查询接口是GET /v3/refund/domestic/refunds/{out_refund_no}路径参数是商户退款单号。查询不需要请求体签名串里请求体部分为空字符串但换行符不能省。// 查询退款单状态 public String queryRefund(String outRefundNo) throws Exception { String urlPath /v3/refund/domestic/refunds/ outRefundNo; String authorization buildAuthorization(GET, urlPath, , mchId, serialNo, privateKey); HttpGet get new HttpGet(https://api.mch.weixin.qq.com urlPath); get.setHeader(Authorization, authorization); get.setHeader(Accept, application/json); try (CloseableHttpResponse resp mutualTlsClient.execute(get)) { return EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); } }补偿策略我一般这样设计退款请求返回PROCESSING后写入本地退款任务表状态为「处理中」异步通知到达时更新状态同时起一个定时任务每 30 秒扫描「处理中」且超过 1 分钟未更新的单子调查询接口。查询到SUCCESS就更新查询到ABNORMAL就告警人工介入。查询频率不要太高微信对单商户的查询有频率限制30 秒一次对中小商户足够。如果单量很大按退款单号分片查询避免同一时刻集中打满。提示退款状态里SUCCESS是退款成功CLOSED是退款关闭通常因为商户撤销或超时ABNORMAL是退款异常需要人工处理。不要看到非SUCCESS就重试ABNORMAL重试可能造成重复退款。5. 退款接口避坑那些让钱卡住的常见问题退款接口的坑集中在证书、金额、幂等、通知四个地方。下面这几条是我和周围同事实际踩过的按「现象 → 原因 → 解决」写遇到类似报错可以直接对号入座。5.1 避坑一401 签名错误但签名代码看着没问题现象请求返回 401响应体提示SIGN_ERROR或signature verify fail。原因通常有三个一是Authorization头里的serial_no用了平台证书序列号而不是商户证书序列号二是签名串里的 URL 带了域名或查询参数微信只认路径三是请求体在签名后被 Jackson 重新序列化字段顺序或空格变了。解决打印签名前的message和实际发送的 body逐字节比对确认serial_no来自商户证书用ObjectMapper序列化一次后直接复用字符串不要签完再转对象。5.2 避坑二退款金额单位搞错退多了或退少了现象退款成功但金额不对或者报PARAM_ERROR金额不合法。原因微信退款金额单位是分不是元。amount.refund是本次退款金额amount.total是原订单总金额两者都是分。如果订单是 100 元total应该是 10000。解决在业务层统一用分做金额单位只在展示层转元退款前校验refund total - 已退金额避免超额退款。5.3 避坑三重复退款同一笔订单退了两次现象用户收到两笔退款或者微信返回「退款单号已存在」。原因网络超时后重试但out_refund_no没变微信会返回原退款单而不是新建如果out_refund_no变了就会真的退两次。解决out_refund_no用业务退款单号全局唯一且与业务退款记录绑定重试时复用同一个out_refund_no在数据库对out_refund_no加唯一索引从源头防重。5.4 避坑四异步通知收不到退款状态一直不更新现象退款实际到账了但商户系统里状态还是「处理中」。原因通知地址不可达、通知被防火墙拦截、通知处理超时导致微信重试但你的接口没做幂等。解决通知地址必须是公网可达的 HTTPS通知处理逻辑先返回成功再异步处理业务避免超时对通知的out_refund_no做幂等重复通知只处理一次同时保留主动查询兜底不把宝全押在通知上。5.5 避坑五证书过期或轮换导致退款突然全挂现象某天开始所有退款请求都失败报证书相关错误。原因商户证书有有效期到期需要重新申请微信平台证书也会轮换验签用的平台证书过期会导致通知验签失败。解决监控商户证书到期时间提前 30 天更换平台证书通过GET /v3/certificates定期刷新缓存时记录序列号和有效期不要在代码里硬编码平台证书用序列号动态匹配。6. 退款对账与幂等收尾让每一笔退款都有据可查退款做完不是终点对账才是。微信退款单和商户退款记录必须能对上否则财务月底对账就是一场灾难。我一般会在退款成功后落一条退款流水字段包括out_refund_no、transaction_id、refund_id、refund_fee、status、success_time然后用微信账单做 T1 核对。微信退款账单通过GET /v3/bill/refund下载返回的是 CSV 压缩包解压后按refund_id和本地流水匹配。对账脚本的核心逻辑是下载账单、解析 CSV、按out_refund_no关联本地退款记录、比对金额和状态、输出差异。差异通常来自三种情况本地有记录微信没有可能是请求没到微信、微信有记录本地没有可能是通知丢了且查询没覆盖、金额不一致基本是单位或计算错误。前两种靠主动查询和通知补偿能覆盖大部分第三种只能靠代码审查。// 退款对账差异检查简化 public ListString reconcile(String billCsvPath, ListRefundRecord localRecords) { ListString diffs new ArrayList(); MapString, RefundRecord localMap localRecords.stream() .collect(Collectors.toMap(RefundRecord::getOutRefundNo, r - r)); // 解析微信账单 CSV跳过表头 try (BufferedReader reader new BufferedReader(new FileReader(billCsvPath))) { String line; boolean header true; while ((line reader.readLine()) ! null) { if (header) { header false; continue; } String[] cols line.split(,); String outRefundNo cols[0].replace(, ); String refundId cols[1].replace(, ); String status cols[2]; RefundRecord local localMap.get(outRefundNo); if (local null) { diffs.add(微信有本地无: outRefundNo); } else if (!SUCCESS.equals(status) SUCCESS.equals(local.getStatus())) { diffs.add(状态不一致: outRefundNo); } } } catch (IOException e) { throw new RuntimeException(账单解析失败, e); } return diffs; }对账频率我建议每天一次差异单子当天处理完。如果差异量大先查通知和查询的覆盖率再看是不是有退款请求根本没发出去。幂等方面除了out_refund_no唯一索引退款接口本身也要做幂等同一笔业务退款请求先查本地是否已有成功记录有就直接返回不再调微信。这样即使上游重试也不会产生重复退款。最后说个习惯我每次接微信退款都会先写一个最小可跑的退款请求用 1 分钱的订单测通全链路再上业务逻辑。退款这事宁可前期多花两小时把证书、签名、通知、对账跑通也别等上线后用户投诉「退款没到账」再去翻日志。希望帮到你。本文还有配套的精品资源点击获取
返回列表