简介:面向Java后端开发者的微信退款接口示例工程,完整覆盖基于HTTPS与PKCS12证书调用微信退款API的关键链路,包括KeyStore加载商户证书、SSLContext初始化、创建带证书的HttpClient、组装退款参数、发送POST请求并解析JSON响应,适合正在对接微信支付且需要实现退款功能的项目参考。压缩包共29个文件,整体约1.92MB,包含10个jar依赖、6个java源码与6个class编译文件,还附有配置文件、jsp页面与MyEclipse工程信息,导入开发环境即可直接查看代码结构。已有870人浏览学习,是验证退款接口调用逻辑的实用范例。示例中给出了RSA2048签名与证书处理的实现思路,可帮助开发者理解商户订单号、退款单号、退款金额等参数的传递方式,同时可参考其中的超时设置与异常响应处理来规避证书配置错误和网络延迟等常见问题,减少对接过程中的调试成本。
1. 从一笔退款对账开始:这套代码解决了什么
“退款单对不上账”在电商后端是最高频的翻车现场:用户在 App 上点了退款,钱原路退回了,订单库里却还挂着待退款,财务对账时差出来几毛钱,查了半天发现是微信退款接口的金额单位、回调幂等或者状态更新顺序出了问题。这套 Java 微信退款接口项目,就是一套把微信支付 V3 退款从证书加载、签名构造、退款申请到异步回调验签解密完整串起来的后台代码,基于 Spring Boot 实现,拿来就能跑通“发起退款→微信扣款→回调通知→状态落库”的整条链路。适合正准备对接退款能力、或者想把手写签名和回调解密彻底搞明白的 Java 后端开发者,既有能直接复制的核心实现,也有能撑住生产环境的模块拆分。
2. 架构设计:V3 协议、工程结构与关键配置
2.1 为什么选 API v3 而不是 v2
微信支付的退款接口有 v2 和 v3 两套协议,这个项目选的是 v3。v2 时代用 MD5 或 HMAC-SHA256 做签名,配一个 API 密钥就能请求,接入门槛低,但资金类接口把密钥放在请求里本身就容易出问题;v3 改成了证书签名,所有请求要用商户私钥对“请求方法 + 路径 + 时间戳 + 随机串 + 请求体”做 RSA 签名,回调报文还会用 AEAD_AES_256_GCM 做加密传输,整体安全模型完全不一样。
对退款这种资金操作来说,这个升级是实打实的:v3 就算请求被截获,没有商户私钥伪造不了签名;回调报文就算被看到,没有 APIv3 密钥解不开密文。另一个现实原因是新商户的证书体系默认就走 v3,老项目如果还在 v2 上改退款,迟早要迁过来。这个项目的代码结构从一开始就是按 v3 设计的,省掉了二次迁移的成本。
2.2 工程模块划分与包结构
整个项目是一个标准的 Spring Boot 工程,没有引入微信官方 SDK,退款相关的逻辑全部自己写。包结构拆得比较清楚:配置、核心加密、服务、控制器、DTO 各占一层,随便挑一个点进去都能独立看明白。
com.example.refund ├── config │ ├── WechatPayProperties.java # 配置项绑定:商户号、证书路径、APIv3密钥 │ └── WechatPayConfig.java # 加载商户私钥、初始化平台证书管理器 ├── core │ ├── WechatRefundClient.java # 退款HTTP客户端:发起请求、解析响应 │ ├── WechatSignUtil.java # 构造Authorization签名头 │ ├── AesGcmUtil.java # 回调报文GCM解密 │ └── WechatPlatformCertManager.java # 平台证书加载与轮换 ├── controller │ └── RefundController.java # 对外REST接口 ├── service │ ├── RefundService.java # 退款业务编排 │ └── RefundNotifyService.java # 回调验签、解密、幂等落库 ├── dto │ ├── RefundRequestDTO.java # 退款入参 │ ├── RefundResponseDTO.java # 退款出参 │ └── RefundNotifyDTO.java # 回调报文模型 ├── enums │ └── RefundStatusEnum.java # 退款状态枚举 └── exception └── RefundException.java # 业务异常core 包是整个项目的核心,放的都是和微信支付协议直接相关的类,不掺业务逻辑;service 层负责把 core 的能力串成业务动作;controller 只做参数接收和结果返回。这样拆的好处是,以后如果公司要接多商户,只需要动配置和 core 包的证书管理,service 层基本不用改。
2.3 关键配置项与依赖清单
项目用到的第三方依赖集中在几个点:HTTP 客户端用的 Apache HttpClient,JSON 序列化用的 Jackson,加密相关的 JDK 原生 JCE 够用,没额外引 Bouncy Castle。pom 里核心依赖大概长这样:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency>配置文件里需要关注的参数不多,但每一个都直接决定能不能调通接口:
| 配置项 | 示例值 | 说明 |
|---|---|---|
wechat.mch-id | 190000XXXX | 商户号,商户平台申请 |
wechat.mch-serial-no | 23C4D5E6... | 商户 API 证书序列号,用于告诉微信你是哪张证书 |
wechat.api-v3-key | 32 位随机字符串 | APIv3 密钥,回调报文解密用 |
wechat.private-key-path | /etc/cert/apiclient_key.pem | 商户私钥文件 |
wechat.platform-cert-path | /etc/cert/platform_cert.pem | 微信支付平台证书,验签用 |
wechat.notify-url | https://api.example.com/refund/callback | 退款结果回调地址 |
商户私钥和平台证书是两套不同的东西:私钥是你的身份,签名时用;平台证书是微信的身份,验签和回调解密时用。这个区别后面踩坑部分会再展开。配置项绑定用@ConfigurationProperties(prefix = "wechat")就能接住,代码层面不需要额外处理。
3. 核心模块拆解:证书、签名与退款请求的完整组装
3.1 商户私钥加载:PEM 解析这一步容易细节翻车
退款接口每次请求都要用商户私钥做 SHA256withRSA 签名,所以私钥加载是第一个要过的地方。微信提供的apiclient_key.pem是 PKCS#8 格式的 RSA 私钥,Java 可以直接用PKCS8EncodedKeySpec解析,但前提是 PEM 文件里的换行符和头尾标记要先处理干净。
@Bean public PrivateKey merchantPrivateKey(WechatPayProperties props) throws Exception { String pem = new String(Files.readAllBytes( Paths.get(props.getPrivateKeyPath())), StandardCharsets.UTF_8); 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); }这里的思路是:先把 PEM 内容读成字符串,去掉头和尾的标记行,再把所有空白字符(包括换行和空格)删掉,最后做 Base64 解码。删空白这一步很多人会漏,以为读进来就能用,实际直接解析会报IOException: DerInputStream.getLength()这种异常,看起来像环境问题,其实就是 PEM 里的换行符没清干净。
3.2 构造 Authorization 签名头:五个字段拼一行
微信 v3 接口的签名方式比较朴素:把请求方法、URL 路径、时间戳、随机串、请求体五个字段用换行符拼成一个大字符串,用私钥签名后放进 Authorization 头。这个签名串的顺序和格式有一点出错,接口就会返回签名错误。
public static String buildAuthHeader(String method, String canonicalUrl, String body, String merchantId, String serialNo, PrivateKey privateKey) throws Exception { long timestamp = System.currentTimeMillis() / 1000; String nonce = UUID.randomUUID().toString().replace("-", ""); String message = method + "\n" + canonicalUrl + "\n" + timestamp + "\n" + nonce + "\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()); return "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + merchantId + "\"," + "nonce_str=\"" + nonce + "\"," + "signature=\"" + signature + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + serialNo + "\"," + "sign_type=\"WECHATPAY2-SHA256-RSA2048\""; }几个关键参数说明:method是GET或POST,必须大写;canonicalUrl是带开头斜杠的路径部分,比如/v3/refund/domestic/refunds,不能带域名也不能带 query string;timestamp是秒级时间戳,和服务器时间偏差太大会被拒;nonce每次请求都要重新生成,最简单的方式就是用 UUID 再去掉横线;body即使为空也要传空字符串,否则签名串的换行数不对。返回的 Authorization 头里mchid、nonce_str、signature、timestamp、serial_no五个字段缺一不可。
3.3 组装退款请求体和发送逻辑
退款申请走的是POST /v3/refund/domestic/refunds接口,请求体里最关键的是商户退款单号out_refund_no和金额对象amount。退款单号是幂等键,重复提交同一单号微信会直接报错;金额对账时就靠refund和total两个字段做核对。
public RefundResponseDTO refund(RefundRequestDTO request) throws Exception { // 1. 组装请求体 Map<String, Object> body = new HashMap<>(); body.put("out_trade_no", request.getOutTradeNo()); // 原支付订单号 body.put("out_refund_no", request.getOutRefundNo()); // 商户退款单号,幂等键 body.put("notify_url", props.getNotifyUrl()); Map<String, Object> amount = new HashMap<>(); amount.put("refund", request.getRefundAmount()); // 退款金额,单位分 amount.put("total", request.getTotalAmount()); // 原订单金额,单位分 amount.put("currency", "CNY"); body.put("amount", amount); // 2. 序列化请求体,参与签名 String jsonBody = mapper.writeValueAsString(body); String canonicalUrl = "/v3/refund/domestic/refunds"; // 3. 生成签名头并发送 String auth = WechatSignUtil.buildAuthHeader( "POST", canonicalUrl, jsonBody, props.getMchId(), props.getMchSerialNo(), merchantPrivateKey); HttpPost post = new HttpPost(API_HOST + canonicalUrl); post.setHeader("Authorization", auth); post.setHeader("Content-Type", "application/json"); post.setHeader("Accept", "application/json"); post.setEntity(new StringEntity(jsonBody, StandardCharsets.UTF_8)); // 4. 解析响应,失败时带上 HTTP 状态码和业务码 try (CloseableHttpResponse response = httpClient.execute(post)) { String respBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); if (response.getCode() != 200) { throw new RefundException("退款申请失败, code=" + response.getCode() + ", body=" + respBody); } return mapper.readValue(respBody, RefundResponseDTO.class); } }这里要重点提一下金额的埋点:微信支付所有金额字段都是整数分,refund和total必须用Integer或Long接收,不能用BigDecimal以外的类型做转换运算。还有一点很多人踩过:部分退款时total一定传原订单总金额,不要传本次退款金额,微信要拿这个字段做校验,传错了直接报参数错误。
3.4 退款状态枚举与业务语义
退款提交成功不代表钱已经退回用户账户,微信返回的只是一个受理结果。真正要关注的退款状态有四个:
public enum RefundStatusEnum { SUCCESS, // 退款成功,钱已原路退回 CLOSED, // 退款关闭,通常因为超时或商户主动撤销 PROCESSING, // 退款处理中,等待银行渠道返回 ABNORMAL // 退款异常,需要人工介入排查 }状态流转的规则是:退款申请提交后先进入PROCESSING,正常情况会变SUCCESS,如果超过时间没退成功可能变CLOSED,出现渠道异常就是ABNORMAL。后端在做轮询或处理回调时,必须按这个状态机来更新数据库记录,不能收到一次回调就直接改成成功。
4. 异步回调链路:验签、GCM 解密与幂等落库
4.1 回调通知为什么不能直接信
退款完成后微信会往notify_url发一条POST请求,把退款结果告诉你。但这条通知本质上和普通 HTTP 请求一样,来源不一定可信,所以回调处理的第一步永远不是读业务字段,而是验签——用微信支付平台证书验证Wechatpay-Signature头是否合法。
private boolean verifySignature(HttpServletRequest request, String body) throws Exception { String timestamp = request.getHeader("Wechatpay-Timestamp"); String nonce = request.getHeader("Wechatpay-Nonce"); String signature = request.getHeader("Wechatpay-Signature"); String message = timestamp + "\n" + nonce + "\n" + body + "\n"; // 注意:验签用的是微信支付平台证书,不是商户证书 PublicKey publicKey = platformCertManager.getCertificate(); Signature sign = Signature.getInstance("SHA256withRSA"); sign.initVerify(publicKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return sign.verify(Base64.getDecoder().decode(signature)); }验签通过后,我们才能确认这条通知确实是微信发出来的。这里最容易搞反的一件事是:自己的商户私钥用来签名请求,微信的平台证书用来验证微信的签名,两个证书的角色不能互换。
4.2 回调报文解密:AEAD_AES_256_GCM
验签通过之后,报文里resource字段带着的退款结果还是密文,需要用 APIv3 密钥做 GCM 解密。解密参数全部在报文里:nonce和associated_data从 resource 对象里取,密钥是配置里的api-v3-key。
public String decryptResource(NotifyResource resource) throws Exception { byte[] key = apiV3Key.getBytes(StandardCharsets.UTF_8); byte[] nonce = resource.getNonce().getBytes(StandardCharsets.UTF_8); byte[] associatedData = resource.getAssociatedData() .getBytes(StandardCharsets.UTF_8); byte[] ciphertext = Base64.getDecoder().decode(resource.getCiphertext()); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); GCMParameterSpec spec = new GCMParameterSpec(128, nonce); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), spec); cipher.updateAAD(associatedData); return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); }解密后的明文 JSON 里会有out_refund_no、refund_id、refund_status等字段,这时候才进入真正的业务逻辑。AES/GCM/NoPadding的 tag 长度固定 128 位,GCMParameterSpec第一个参数就传 128;associatedData如果传错或漏传,解密会直接抛AEADBadTagException,这个异常是和解密失败绑定出现的,排查时第一反应应该去看回调报文里的associated_data是否原样传进来了。
4.3 幂等落库:回调重复不可怕,可怕的是重复处理
微信支付的通知机制带有重试策略,回调失败或超时后微信会隔一段时间重新投递,所以同一笔退款结果可能收到多次。如果业务侧不防重,退款记录表里会出现两条成功记录,对账就乱了。
-- 退款记录表对 out_refund_no 建唯一索引 ALTER TABLE refund_record ADD UNIQUE INDEX uk_out_refund_no (out_refund_no);落库逻辑写成先查后改还不够稳,最好的做法是条件更新:只有在当前状态还是PROCESSING的时候才允许改成SUCCESS,用 SQL 的WHERE条件当作乐观锁:
UPDATE refund_record SET refund_status = 'SUCCESS', wechat_refund_id = #{refundId}, notify_time = now() WHERE out_refund_no = #{outRefundNo} AND refund_status IN ('PROCESSING', 'ABNORMAL');如果更新行数为 0,说明这条记录已经处理过了,直接忽略本次通知。这样一来哪怕回调重复十次,最终落库结果也只有一个终态。
4.4 平台证书的加载与轮换
微信支付平台证书不是永久的,微信侧会做证书轮换,上线时间久的商户一定会遇到平台证书过期导致验签失败的情况。常规做法是启动时加载一次本地证书文件,同时定时任务每天去拉一次最新证书:
GET /v3/certificates 响应里带加密的平台证书,解密后按 certificate_serial_no 留存更新时不要直接覆盖旧证书,而是按序列号存多份,验签时先用响应头里的Wechatpay-Serial找到对应证书,找不到再试其他证书。我在生产环境见过因为证书轮换导致回调验签大面积失败的事故,那之后我对所有证书类配置的统一原则就是:永远不要假设证书是永久的,更新逻辑和加载逻辑要解耦。
5. 踩坑实录:金额、签名和回调的六条血泪经验
5.1 金额单位与单号幂等:资金类接口最容易爆雷的地方
第一个坑是金额单位错乱。现象:前端传了0.01元,后端处理完微信返回参数错误;有的甚至退了 1 分变 1 元,对账对不上。原因:微信支付所有金额字段单位都是分,Java 侧如果用double做运算会出现0.01 * 100 = 1.0000000000000002这种精度问题;或者前端传的是元,后端没换算直接透传。解决:所有退款金额入参统一用Integer表示分,只在 Controller 入口做一次元转分换算,用BigDecimal相乘后取整,过程中不让double参与运算。
第二个坑是退款单号重复提交。现象:业务方失败重试时重新生成了一个单号,导致同一笔订单出现两条退款记录;或者同一单号重试时微信返回ORDER_NOT_EXIST或REFUNDNO_ALREADY_EXIST。原因:out_refund_no是微信侧的幂等键,同一商户号下不能重复;失败重试必须沿用第一次生成的单号。解决:退款单号在服务端用订单号加两位流水号生成,例如REF + 订单ID + 随机两位,请求失败但不确定微信是否受理时,先用同一单号查一次退款结果,再决定是否重新提交。
5.2 证书与回调链路:网络环境和技术细节混合翻车
第三个坑是回调收不到。现象:退款成功了,然后本地日志里等了一下午也没有收到通知,甚至开始怀疑微信是不是没调。原因:微信要求回调地址必须是公网可访问的 HTTPS 地址,localhost、内网 IP 一律投递失败;另外回调地址的域名没配 SSL 证书也会导致微信请求失败。解决:生产环境用正式域名;本地联调时可以把notify_url指向公网可达的临时映射地址,把本机 8080 端口暴露出去,但这种方式只适合联调,不适合压测。
第四个坑是验签时用了商户证书而不是平台证书。现象:回调验签时好时坏,线上环境偶尔有验签失败的告警。原因:回调里的签名是微信用平台证书私钥生成的,验签必须用平台证书公钥;个别项目把验签和请求签名搞混,拿着自己的私钥去验,当然验不过。解决:配置里区分private-key-path(商户私钥,发请求用)和platform-cert-path(平台证书,验签用),两个文件按角色写入配置,注释写清楚谁是谁。
第五个坑是签名串和实际请求路径不一致。现象:本地测试退款能通,部署到生产后签名校验一直不通过。原因:签名串里的canonicalUrl是/v3/refund/domestic/refunds,但如果通过网关反向代理时路径被改写,或者代码里手写 URL 时带上了https://api.mch.weixin.qq.com前缀,签名串就算不对。解决:把canonicalUrl定义为常量,和 HttpClient 的请求 URL 复用同一份配置,不要一个路径写两遍。
第六个坑是私钥文件被编辑过。现象:生产环境私钥加载直接抛DerInputStream.getLength(),查环境、查权限都没问题,玄学一样。原因:PEM 文件在 Windows 上被记事本或某些编辑器加工过,换行符变成 CRLF,甚至加了 BOM 头;Java 解析时就不认了。解决:下载证书后不要手工编辑,直接用代码读取;如果必须修改权限或路径,先检查文件行尾是否正常,读取时统一做空白字符清理。
6. 二次开发:查询兜底、对账表与多商户扩展
6.1 给项目加上退款结果查询
回调是异步通知,不代表绝对可靠,生产环境必须加一个主动查询的兜底通道。微信提供了GET /v3/refund/domestic/refunds/{out_refund_no}的查询接口,查询请求的签名方式和退款申请完全一致,只是方法变成GET,请求体为空字符串参与签名。
public RefundQueryResponseDTO query(String outRefundNo) throws Exception { String canonicalUrl = "/v3/refund/domestic/refunds/" + outRefundNo; String auth = WechatSignUtil.buildAuthHeader( "GET", canonicalUrl, "", // body 为空串,不能传 null props.getMchId(), props.getMchSerialNo(), merchantPrivateKey); HttpGet get = new HttpGet(API_HOST + canonicalUrl); get.setHeader("Authorization", auth); get.setHeader("Accept", "application/json"); // 发送请求、解析响应,与退款申请逻辑一致 }有了查询接口,就可以写一个定时任务:每 5 分钟扫一次退款记录表里状态还是PROCESSING的单子,主动调用查询接口刷新状态。这个兜底逻辑上线后,即使回调链路出问题,退款状态最多延迟几分钟更新,不会一直卡在中间状态。
6.2 退款记录表设计
二次开发时建议把退款记录从订单表里拆出来单独建表,字段覆盖对账需要的关键信息:
| 字段名 | 类型 | 说明 |
|---|---|---|
id | bigint | 主键 |
out_refund_no | varchar(64) | 商户退款单号,唯一索引 |
out_trade_no | varchar(64) | 原支付订单号 |
refund_amount | int | 退款金额,单位分 |
total_amount | int | 原订单金额,单位分 |
refund_status | varchar(20) | 状态:PROCESSING/SUCCESS/CLOSED/ABNORMAL |
wechat_refund_id | varchar(64) | 微信退款单号,回调落库 |
notify_time | datetime | 最后回调时间 |
create_time/update_time | datetime | 审计字段 |
6.3 扩展多商户时怎么改配置
多商户系统最常见的改造是把单商户配置改成 Map。WechatPayProperties里把mch-id改成Map<String, MerchantConfig>,MerchantConfig里包含商户号、序列号、私钥路径、平台证书路径;发起退款时根据请求头或订单归属的商户号,从上下文里取出对应的私钥和配置。这样一套代码可以服务多个商户号,签名头和证书管理只要在 core 包做一层按商户维度切换。
6.4 状态更新加入乐观锁约束
最后把数据库更新加一道乐观锁。退款记录从PROCESSING到SUCCESS只能发生一次,如果出现从SUCCESS变成CLOSED这类非法流转,直接抛异常并告警。从那以后,我每次对接微信退款接口,都会强制走一遍这套检查清单:金额单位是不是分、退款单号能不能幂等、回调验签用的是哪个证书、状态更新有没有条件约束。这套流程帮我挡掉了不少线上事故,希望帮到你。
本文还有配套的精品资源,点击获取