☰
微信退款接口Java实战:V3证书加载、回调验签与多商户路由
2026/10/20 13:35:57 网站建设 项目流程

简介:这份资源面向需要对接微信支付退款能力的Java后端开发者,聚焦商户在用户发起退款时通过API与微信服务器完成退款交互的完整实现。包内共29个文件,以10个jar依赖、6个java源码、6个class编译文件为主,另含xml配置、jsp页面及工程元数据,压缩包约1.92MB,可直接导入MyEclipse等IDE运行调试。内容围绕PKCS12证书加载、SSLContext构建、HttpClient安全连接配置、退款参数JSON组织、RSA2048签名与响应解析等关键环节展开,示例代码展示了从加载证书到发送POST请求、处理退款结果的完整链路,并涉及超时设置与错误处理等实践细节。已有870人学习下载,适合希望快速跑通微信退款流程、理解HTTPS双向认证与数字签名机制的开发者参考,也可作为项目集成时的排错与对照模板。

1. 微信退款接口:从支付回调到退款到账,一个 Java 后端的完整落地链路

做微信支付接入的兄弟大概率都经历过这个场景:用户下单付款一切正常,结果运营跑来说“有个客户要退款,你手动处理一下”。这时候你打开代码一看,当初只写了统一下单和支付回调,退款接口压根没写。微信退款不像支付宝那样在商户后台点一下就能搞定,它需要后端主动调用微信的退款 API,还要处理证书、回调通知、退款状态查询这一整套链路。这份 Java 微信退款接口资源,解决的就是从“收到退款申请”到“微信侧退款成功”之间的全部工程问题。它适合正在做微信支付二次开发的后端,也适合想搞清楚退款证书怎么加载、退款回调怎么验签的开发者。下面我按实际拆项目的顺序,把这份资源里的关键实现一层层剥开。

2. 退款链路的技术选型:为什么用微信 V3 接口而不是老 V2

2.1 V2 与 V3 退款接口的核心差异

微信支付退款接口目前有两套:老的 V2 和新的 V3。这份资源用的是 V3,原因很直接——V2 的退款接口虽然还能用,但微信官方从 2023 年开始就在推 V3,新申请的商户号默认只给 V3 的密钥和证书。V2 退款走的是 XML 报文加 MD5/HMAC-SHA256 签名,V3 走的是 JSON 报文加 SHA256-RSA 签名。看起来只是格式变了,实际影响最大的是证书体系。

V2 退款只需要一个 apiclient_cert.p12 证书,加载方式简单粗暴。V3 退款需要商户 API 证书(apiclient_cert.pem 和 apiclient_key.pem),还要配合微信平台证书做验签。很多开发者第一次接 V3 退款时,卡在“平台证书怎么获取”这一步——因为平台证书不是固定的,微信会定期轮换,你得通过接口去下载。

这份资源里把证书加载和平台证书下载都封装好了,这是它比网上那些只贴一段退款请求代码的片段强的地方。下面看核心的证书加载逻辑。

/** * 加载商户 API 证书,用于 V3 接口的请求签名 * 证书文件从微信商户平台下载,放在 resources/cert 目录下 */ public class WxPayCertManager { // 商户证书序列号,在商户平台查看,用于请求头 Wechatpay-Serial private String merchantSerialNo; // 商户私钥,用于 SHA256-RSA 签名 private PrivateKey merchantPrivateKey; /** * 从 classpath 加载证书和私钥 * @param certPath 证书路径,如 cert/apiclient_cert.pem * @param keyPath 私钥路径,如 cert/apiclient_key.pem */ public void loadMerchantCert(String certPath, String keyPath) throws Exception { // 读取证书文件,解析出 X509 证书对象 X509Certificate certificate = PemUtil.loadCertificate( new FileInputStream(ResourceUtils.getFile("classpath:" + certPath)) ); // 从证书中提取序列号,后续请求头需要带上 this.merchantSerialNo = certificate.getSerialNumber().toString(16).toUpperCase(); // 读取私钥文件,构建 PrivateKey 对象 this.merchantPrivateKey = PemUtil.loadPrivateKey( new FileInputStream(ResourceUtils.getFile("classpath:" + keyPath)) ); } /** * 生成 V3 接口要求的 Authorization 请求头 * 格式:WECHATPAY2-SHA256-RSA2048 mchid="...",nonce_str="...",signature="...",timestamp="...",serial_no="..." */ public String buildAuthorization(String method, String url, String body) throws Exception { long timestamp = System.currentTimeMillis() / 1000; String nonceStr = UUID.randomUUID().toString().replace("-", ""); // 待签名串的拼接规则:方法\nURL\n时间戳\n随机串\n请求体\n String message = method + "\n" + url + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; // 用商户私钥做 SHA256withRSA 签名 Signature sign = Signature.getInstance("SHA256withRSA"); sign.initSign(merchantPrivateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(sign.sign()); return "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + merchantId + "\"," + "nonce_str=\"" + nonceStr + "\"," + "signature=\"" + signature + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + merchantSerialNo + "\""; } }

这段代码里有两个关键点容易翻车。第一,merchantSerialNo是从证书里提取的,不是商户号,很多人把商户号填进去导致 401。第二,待签名串的拼接顺序和换行符必须严格按微信文档来,少一个\n或者多一个空格,签名就会失败,微信返回的是“签名错误”而不是具体哪里错了,排查起来很痛苦。

2.2 平台证书的获取与缓存策略

V3 退款还有一个必须处理的环节:微信平台证书。当你调用退款接口后,微信会异步回调你的服务器,回调报文里带了Wechatpay-Signature等请求头,你需要用微信平台证书来验签。平台证书不能写死在代码里,得通过GET /v3/certificates接口下载。

这份资源里的做法是:应用启动时拉取一次平台证书,缓存到内存,同时记录证书的serial_no和有效期。每次收到回调时,根据请求头里的Wechatpay-Serial找到对应的平台证书验签。如果找不到,说明微信轮换了证书,需要重新拉取。

/** * 平台证书管理器,负责下载、缓存、刷新微信平台证书 */ @Component public class WxPlatformCertManager { // 缓存:serial_no -> X509Certificate private final Map<String, X509Certificate> certCache = new ConcurrentHashMap<>(); // 上次刷新时间,用于控制刷新频率 private volatile long lastRefreshTime = 0; // 刷新间隔,默认 12 小时,微信平台证书有效期较长但会轮换 private static final long REFRESH_INTERVAL = 12 * 60 * 60 * 1000L; /** * 获取指定序列号的平台证书,如果缓存没有则触发刷新 */ public X509Certificate getCertificate(String serialNo) { X509Certificate cert = certCache.get(serialNo); if (cert == null) { // 缓存未命中,可能是微信轮换了证书,强制刷新一次 refreshCertificates(); cert = certCache.get(serialNo); } return cert; } /** * 调用微信 /v3/certificates 接口下载平台证书 * 注意:这个接口本身也需要用商户证书签名 */ public void refreshCertificates() { // 防止并发刷新,加锁 synchronized (this) { if (System.currentTimeMillis() - lastRefreshTime < 1000) { return; // 刚刷新过,避免重复请求 } try { String url = "https://api.mch.weixin.qq.com/v3/certificates"; String authorization = certManager.buildAuthorization("GET", "/v3/certificates", ""); // 发起 HTTP 请求获取证书列表 String response = httpClient.get(url, authorization); JSONObject json = JSON.parseObject(response); // 解析证书列表,每个证书包含 serial_no 和 encrypt_certificate JSONArray data = json.getJSONArray("data"); for (int i = 0; i < data.size(); i++) { JSONObject item = data.getJSONObject(i); String serialNo = item.getString("serial_no"); // 证书内容是加密的,需要用 APIv3 密钥解密 String encryptCert = item.getJSONObject("encrypt_certificate") .getString("ciphertext"); X509Certificate cert = decryptCertificate(encryptCert); certCache.put(serialNo, cert); } lastRefreshTime = System.currentTimeMillis(); } catch (Exception e) { log.error("刷新微信平台证书失败", e); } } } }

这里有个血泪经验:平台证书的encrypt_certificate是用 APIv3 密钥做 AES-256-GCM 加密的,解密时需要用到nonce和associated_data,这三个字段都在返回报文里。很多人只拿了ciphertext就去解密,结果报“解密失败”。另外,平台证书刷新不要每次回调都触发,否则微信那边会限流,建议用定时任务或者按需刷新。

2.3 退款接口的请求组装与参数说明

V3 退款接口的地址是POST /v3/refund/domestic/refunds,请求体是 JSON。核心参数包括:

参数名类型必填说明
out_trade_nostring二选一商户订单号,和 transaction_id 二选一
transaction_idstring二选一微信订单号
out_refund_nostring是商户退款单号,同一订单多次退款需唯一
amount.refundint是退款金额,单位分
amount.totalint是原订单总金额,单位分
amount.currencystring否币种,默认 CNY
notify_urlstring否退款结果回调地址,不传则用商户平台配置的

这份资源里把退款请求封装成了一个WxRefundRequest对象,用 Builder 模式组装,避免参数漏传。下面看核心的请求发送逻辑。

/** * 发起微信退款请求 * @param request 退款请求参数 * @return 退款响应,包含退款单号和状态 */ public WxRefundResponse refund(WxRefundRequest request) throws Exception { // 1. 组装请求体 JSON JSONObject body = new JSONObject(); body.put("out_trade_no", request.getOutTradeNo()); body.put("out_refund_no", request.getOutRefundNo()); JSONObject amount = new JSONObject(); amount.put("refund", request.getRefundAmount()); amount.put("total", request.getTotalAmount()); amount.put("currency", "CNY"); body.put("amount", amount); if (StringUtils.isNotBlank(request.getNotifyUrl())) { body.put("notify_url", request.getNotifyUrl()); } String bodyStr = body.toJSONString(); // 2. 生成签名请求头 String authorization = certManager.buildAuthorization( "POST", "/v3/refund/domestic/refunds", bodyStr ); // 3. 发送 HTTPS 请求 String response = httpClient.post( "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds", bodyStr, authorization ); // 4. 解析响应 JSONObject respJson = JSON.parseObject(response); WxRefundResponse refundResp = new WxRefundResponse(); refundResp.setRefundId(respJson.getString("refund_id")); refundResp.setOutRefundNo(respJson.getString("out_refund_no")); refundResp.setStatus(respJson.getString("status")); refundResp.setCreateTime(respJson.getString("create_time")); return refundResp; }

参数说明:out_refund_no是商户自己生成的退款单号,必须全局唯一。如果同一笔订单要分多次退款,每次的out_refund_no不能重复,否则微信会返回“退款单号重复”。amount.refund和amount.total都是整数,单位是分,别传成元了,这是新手最容易犯的错。notify_url如果不传,微信会用商户平台里配置的默认回调地址,但建议显式传,方便不同业务线区分。

3. 退款回调处理:验签、解密、幂等一个都不能少

3.1 回调报文的验签流程

微信退款成功后,会向notify_url发送一个 POST 请求,请求体是加密的 JSON。处理回调的第一步是验签,用请求头里的Wechatpay-Serial找到对应的平台证书,然后用Wechatpay-Signature做 SHA256-RSA 验签。

验签的待签名串拼接规则和请求时类似,但用的是回调报文里的时间戳和随机串:

应答时间戳\n 应答随机串\n 应答报文主体\n

这份资源里的验签代码是这样的:

/** * 验证微信回调请求的签名 * @param timestamp 请求头 Wechatpay-Timestamp * @param nonce 请求头 Wechatpay-Nonce * @param body 请求体原始字符串 * @param signature 请求头 Wechatpay-Signature * @param serialNo 请求头 Wechatpay-Serial */ public boolean verifyNotify(String timestamp, String nonce, String body, String signature, String serialNo) throws Exception { // 1. 根据序列号获取平台证书 X509Certificate platformCert = platformCertManager.getCertificate(serialNo); if (platformCert == null) { log.error("未找到序列号对应的平台证书: {}", serialNo); return false; } // 2. 拼接待验签串 String message = timestamp + "\n" + nonce + "\n" + body + "\n"; // 3. 用平台证书公钥验签 Signature sign = Signature.getInstance("SHA256withRSA"); sign.initVerify(platformCert.getPublicKey()); sign.update(message.getBytes(StandardCharsets.UTF_8)); return sign.verify(Base64.getDecoder().decode(signature)); }

验签失败最常见的原因是body被框架处理过了。比如 Spring MVC 的@RequestBody会把请求体反序列化成对象,你再toString()出来的字符串和原始报文不一致,验签必然失败。正确做法是用HttpServletRequest的getInputStream()读原始字节流,或者用@RequestBody String body直接接收原始字符串。

3.2 回调报文解密与退款状态更新

验签通过后,回调报文里的resource字段是加密的,需要用 APIv3 密钥做 AES-256-GCM 解密。解密后的内容包含退款单号、退款状态、退款金额等信息。

/** * 解密回调报文中的 resource 字段 * @param resource 回调报文里的 resource 对象 * @return 解密后的 JSON 字符串 */ public String decryptResource(JSONObject resource) throws Exception { String ciphertext = resource.getString("ciphertext"); String nonce = resource.getString("nonce"); String associatedData = resource.getString("associated_data"); // AES-256-GCM 解密 Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec key = new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), "AES"); GCMParameterSpec spec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, key, spec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); byte[] plaintext = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plaintext, StandardCharsets.UTF_8); }

解密后的 JSON 里,refund_status字段表示退款状态,常见值有SUCCESS(退款成功)、CLOSED(退款关闭)、PROCESSING(退款处理中)。业务侧需要根据这个状态更新本地退款单,并且做幂等处理——微信可能会重复发送回调,同一笔退款单不能重复更新。

幂等的做法很简单:在更新退款单之前,先查一下当前状态,如果已经是终态(SUCCESS 或 CLOSED),直接返回成功,不再处理。这份资源里用了一个refund_order表,out_refund_no做唯一索引,更新时用UPDATE ... WHERE status != 'SUCCESS'来保证幂等。

3.3 退款状态查询作为兜底

回调不是百分百可靠的,网络抖动、服务器重启都可能导致回调丢失。所以生产环境必须有一个定时任务,扫描“处理中”的退款单,主动调用微信的退款查询接口GET /v3/refund/domestic/refunds/{out_refund_no}来同步状态。

/** * 定时任务:每 5 分钟扫描一次处理中的退款单,主动查询微信侧状态 */ @Scheduled(fixedDelay = 5 * 60 * 1000) public void syncRefundStatus() { // 查询本地所有状态为 PROCESSING 的退款单 List<RefundOrder> processingOrders = refundOrderMapper.selectByStatus("PROCESSING"); for (RefundOrder order : processingOrders) { try { // 调用微信查询接口 WxRefundResponse resp = wxRefundService.queryRefund(order.getOutRefundNo()); if ("SUCCESS".equals(resp.getStatus())) { // 更新本地状态为成功 refundOrderMapper.updateStatus(order.getOutRefundNo(), "SUCCESS"); } else if ("CLOSED".equals(resp.getStatus())) { refundOrderMapper.updateStatus(order.getOutRefundNo(), "CLOSED"); } // 其他状态继续等待下次扫描 } catch (Exception e) { log.error("查询退款状态失败: {}", order.getOutRefundNo(), e); } } }

这个兜底逻辑很重要,我见过太多项目只依赖回调,结果回调丢了导致退款单一直挂在“处理中”,用户投诉到运营那边才发现。定时查询的频率建议 5 到 10 分钟一次,太频繁会给微信接口造成压力。

4. 避坑与常见问题排查

4.1 签名失败:90% 是待签名串拼错了

现象:调用退款接口返回 401,错误信息是SIGN_ERROR或签名错误。

原因:V3 接口的待签名串拼接规则非常严格,方法、URL、时间戳、随机串、请求体之间必须用\n分隔,请求体为空时也要保留最后一个\n。很多人用字符串拼接时漏了换行,或者 URL 带了 query 参数但签名时没带。

解决:把待签名串打印出来,逐字符对比微信文档里的示例。特别注意 URL 部分只包含路径和 query,不包含域名。比如退款接口的 URL 是/v3/refund/domestic/refunds,不是完整地址。

4.2 证书加载失败:路径和格式都要对

现象:应用启动时报FileNotFoundException或InvalidKeyException。

原因:证书文件没放在 classpath 下,或者用FileInputStream读 classpath 里的文件。另外,微信下载的证书是 PEM 格式,但有些开发者误用了 PKCS12 格式的解析方式。

解决:证书文件放在src/main/resources/cert/目录下,用ResourceUtils.getFile("classpath:cert/apiclient_cert.pem")读取。私钥文件apiclient_key.pem是 PKCS8 格式,用PemUtil.loadPrivateKey()解析,不要用KeyStore加载。

4.3 回调验签失败:body 被框架改过了

现象:回调接口收到请求,但验签一直返回 false。

原因:Spring MVC 的@RequestBody把 JSON 反序列化后再序列化,字段顺序和空格都变了,导致验签串和微信发的原始报文不一致。

解决:用@RequestBody String body接收原始字符串,或者用HttpServletRequest的getInputStream()读字节流。如果用了拦截器或过滤器,确认没有对请求体做二次处理。

4.4 退款金额传错:单位是分不是元

现象:退款接口返回PARAM_ERROR,提示金额格式不正确。

原因:微信退款接口的金额单位是分,整数。很多开发者习惯用元,传了99.9这样的浮点数,微信直接拒绝。

解决:在业务层统一用分做单位,数据库存整数。如果前端传的是元,在 Controller 层就乘以 100 转成整数,避免浮点精度问题。

4.5 重复退款:out_refund_no 没有唯一约束

现象:同一笔订单被退了两次,用户收到了两笔退款。

原因:out_refund_no没有做唯一约束,或者代码里没有检查退款单是否已存在。

解决:数据库给out_refund_no加唯一索引,代码里在发起退款前先查一下是否已有成功的退款单。微信侧对同一笔订单的多次退款是允许的,但总退款金额不能超过订单总额,所以业务侧也要校验累计退款金额。

5. 二次开发改造:从单商户到多商户的退款路由

这份资源默认是单商户模式,商户号、证书、APIv3 密钥都配在配置文件里。但实际项目里,很多系统需要支持多商户——比如平台型电商,每个入驻商家有自己的微信商户号,退款时要路由到对应的商户。

改造的核心思路是把证书和配置从静态单例改成按商户号动态获取。下面是我一般会用的改造方案。

首先,建一张商户配置表,存商户号、证书路径、APIv3 密钥、回调地址:

CREATE TABLE wx_merchant_config ( id BIGINT PRIMARY KEY AUTO_INCREMENT, merchant_id VARCHAR(32) NOT NULL COMMENT '微信商户号', cert_path VARCHAR(255) NOT NULL COMMENT '商户证书路径', key_path VARCHAR(255) NOT NULL COMMENT '商户私钥路径', api_v3_key VARCHAR(64) NOT NULL COMMENT 'APIv3 密钥', notify_url VARCHAR(255) COMMENT '退款回调地址', status TINYINT DEFAULT 1 COMMENT '状态:1启用 0禁用', UNIQUE KEY uk_merchant_id (merchant_id) );

然后,把WxPayCertManager改造成一个工厂,根据商户号返回对应的证书管理器实例,并且用 Caffeine 做本地缓存,避免每次退款都重新加载证书:

/** * 多商户证书管理器工厂 * 根据商户号动态获取对应的证书管理器,带本地缓存 */ @Component public class WxCertManagerFactory { @Autowired private WxMerchantConfigMapper merchantConfigMapper; // 本地缓存:merchantId -> WxPayCertManager,过期时间 1 小时 private final Cache<String, WxPayCertManager> certManagerCache = Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.HOURS) .maximumSize(100) .build(); /** * 获取指定商户的证书管理器 */ public WxPayCertManager getCertManager(String merchantId) { return certManagerCache.get(merchantId, id -> { WxMerchantConfig config = merchantConfigMapper.selectByMerchantId(id); if (config == null) { throw new BizException("商户配置不存在: " + id); } WxPayCertManager manager = new WxPayCertManager(); manager.setMerchantId(config.getMerchantId()); manager.setApiV3Key(config.getApiV3Key()); try { manager.loadMerchantCert(config.getCertPath(), config.getKeyPath()); } catch (Exception e) { throw new BizException("加载商户证书失败: " + id, e); } return manager; }); } }

改造之后,退款服务的入口方法需要多传一个merchantId参数,内部通过工厂获取对应的证书管理器。回调处理也要改造:回调 URL 里带上商户号,比如/wx/refund/notify/{merchantId},这样收到回调时就知道用哪个商户的平台证书验签。

验证改造是否成功,我一般会走一遍这个清单:用商户 A 的订单发起退款,确认请求头里的mchid和serial_no是商户 A 的;用商户 B 的订单发起退款,确认走的是商户 B 的证书;模拟商户 A 的回调,确认验签用的是商户 A 的平台证书;把商户 A 的证书换掉,确认缓存过期后能重新加载。

还有一个容易忽略的点:平台证书是按商户号隔离的,不同商户的平台证书序列号可能不同。所以WxPlatformCertManager也要改成按商户号缓存,不能全局共享一个证书 Map。

从那以后我每次接微信支付相关的需求,都会先把证书加载和回调验签这两块单独写个测试用例跑通,再写业务逻辑。因为这两块一旦出问题,排查成本极高,微信返回的错误信息又不够具体,很容易在联调阶段卡住。希望这份拆解能帮到你,少走一些我踩过的弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询