
简介一套Java微信退款接口实现示例面向需要对接微信支付退款功能的Java后端开发者重点解决证书加载、请求签名与HTTPS通信等难点。示例完整演示了PKCS12证书读取、KeyStore与SSLContext初始化、HttpClient配置、退款参数组装、RSA签名以及JSON响应解析的流程并给出超时和错误处理思路可帮助快速理解退款API的调用链路。压缩包共29个文件包括10个jar依赖库、6个java源码、6个class编译产物以及xml、project、classpath等工程配置整体大小约1.92MB工程内保留了WebRoot等Web应用目录便于放入Java Web项目直接调试。已有869人学习下载。对支付二次开发而言这套示例提供了从底层通信到业务处理的完整参考核心方法可移植复用也可作为微信支付SDK的替代或补充适合中级及以上Java工程师借鉴学习。1. Java 微信退款接口先判断这笔退款该找谁订单状态已经改成“退款中”用户的钱却迟迟没到账后台一查退款单压根没提交到微信——这种场景在 Java 退款接口对接里太常见了。微信退款接口不是一个普通的 URL 调用它牵扯到商户号、证书、签名、回调验签和退款状态机任何一个环节错位表现都是“调不通”或者“钱没退出去”。这份资源解决的就是 Java 服务里跑通微信官方退款接口的主干链路先选对版本再构造请求最后把钱退出去并确认结果。适合那些手里已经有微信支付商户号、订单系统里有真实流水、想把用户付款原路退回的从业者——不管你是第一次接退款还是老系统里退款模块一直半死不活都能在下面找到对应环节。2. 选 V2 还是 V3两张证书、三把钥匙先对齐版本2.1 接口版本与路径的差异微信退款接口有两个版本Java 项目里最常踩的第一个坑就是版本混用。V2 接口走的是https://api.mch.weixin.qq.com/secapi/pay/refund传输格式是 XML签名方式用 MD5 或 HMAC-SHA256需要的是 API 密钥32 位字符串同时请求时还要带上商户 API 证书做双向 TLS。V3 接口走的是https://api.mch.weixin.qq.com/v3/refund/domestic/refunds传输格式是 JSON签名方式换成 SHA256-RSA2048用商户 API 证书的私钥做签名回调通知用 APIv3 密钥做 AES-256-GCM 解密。我说句实在话如果是新写的 Java 服务直接上 V3。微信从 2018 年之后主推的就是 V3支付分、商家转账这些新能力都只在 V3 上开放官方 SDK 的迭代重心也全在 V3。老系统里已经在跑 V2、且没有改动意愿的继续用 V2 也能跑但要想清楚一个代价V2 的 XML 报文在 Java 侧要做很多字符串拼装和转义处理签名逻辑也依赖固定字段顺序出问题排查起来比 V3 费劲得多。下表把两者差异列清楚选型时直接对号入座。对比项V2 退款接口V3 退款接口请求路径/secapi/pay/refund/v3/refund/domestic/refunds报文格式XMLJSON签名方式MD5 或 HMAC-SHA256SHA256-RSA2048签名材料API 密钥32 位商户 API 证书私钥 证书序列号是否要求双向 TLS要求不要求回调通知无需解密APIv3 密钥 AES-256-GCM 解密适合场景存量老系统新接入、功能扩展2.2 证书与密钥的四个概念必须分清楚微信支付 V3 体系里大家习惯叫“三把钥匙”其实严格说是两把密钥、一对证书外加一个序列号。第一把叫 API 密钥它是 V2 体系的产物32 位字符串放在商户平台“API 安全”里设置用来在 V2 请求里做对称签名。第二把叫 APIv3 密钥同样是 32 位字符串但它只负责一件事解密微信推送的回调通知里加密过的报文跟请求签名没有任何关系。很多项目把这两个密钥混着用结果就是 V3 请求签名报错、回调解密报错来回折腾半天。另外两个概念是商户 API 证书和证书序列号。商户 API 证书包含公钥和私钥申请后拿到的是apiclient_cert.pem和apiclient_key.pem两个文件V3 请求签名用的是 apiclient_key.pem 里的私钥微信侧验签用证书里的公钥。证书序列号是一个大字符串从apiclient_cert.pem里可以读出来签名时要放进 Authorization 头。这里有个容易忽略的点证书序列号不是商户号也不是 API 密钥的编号它只标识证书本身改过一次证书后序列号就会变。我一般会在项目的application.yml里把这几个配置分开维护并加注释区分用途配置键值示例用途wxpay.mch-id1900000109商户号退款请求里必填wxpay.cert-serial-no2B3F4A...13 位十六进制标识商户 API 证书放进 Authorizationwxpay.private-key-path/data/cert/apiclient_key.pem请求签名私钥离线保存wxpay.api-v3-key32 位随机字符串回调通知 AES-GCM 解密这几个配置的存放顺序建议固定商户号 - 证书序列号 - 私钥 - APIv3 密钥。很多开发者在参数风暴里迷失就是因为这四样东西没在文档里排好序。3. 跑通申请退款POST /v3/refund 的参数组装与手写签名3.1 退款参数哪些必填、哪些极易填错V3 退款申请接口的路径是POST https://api.mch.weixin.qq.com/v3/refund/domestic/refunds请求体是 JSON。必填参数里有三组最容易出错订单号、退款单号和金额。订单号这块out_trade_no和transaction_id二选一前者是商户侧的订单号后者是微信支付侧的订单号两个都传时微信会优先用 transaction_id。退款单号out_refund_no是商户侧生成的退款单号同一笔退款单号不能重复使用这是微信退款接口的幂等键。金额参数是最容易翻车的地方。amount对象里包含三个字段refund表示本次退款金额total表示原订单支付金额currency默认 CNY。单位全部是分Integer 类型够用但如果你在服务里用 Double 存金额换算时分币种就容易出现 0.1 0.2 ! 0.3 这种问题。我一般会在退款服务里强制用 Long 表示分入库时也统一存分前端展示时才转成元。代码组装如下// 退款金额一律用分为单位用整数类型传输禁止使用 Double MapString, Object amount new HashMap(); amount.put(refund, 100); // 本次退款金额100 分 1.00 元 amount.put(total, 100); // 原订单金额必须等于下单时的 total_fee amount.put(currency, CNY); MapString, Object params new HashMap(); params.put(out_trade_no, ORDER20250101001); // 与 transaction_id 二选一 params.put(out_refund_no, REFUND20250101001); // 商户侧退款单号全局唯一 params.put(amount, amount); params.put(notify_url, https://api.your-domain.com/v3/refund-notify); String bodyJson JSON.toJSONString(params);这段代码的逻辑不复杂但参数陷阱藏在细节里total必须和下单时的total_fee完全一致否则接口直接报PARAM_ERRORnotify_url必须公网可访问且不能用 HTTP微信要求 HTTPS。3.2 手写 SHA256-RSA2048 签名V3 请求不是简单地把参数拼起来放 Header 里就完事。微信要求每个请求头里带Authorization值是以WECHATPAY2-SHA256-RSA2048开头的签名串。签名原文由五部分组成按行拼接请求方法大写、请求路径从域名后开始含参数、时间戳、随机字符串、请求体。注意请求体必须是原始的 JSON 字符串不能对 key 排序不能压缩成一行以外的格式。拼接完成后用商户 API 证书的私钥做 SHA256withRSA 签名再用 Base64 编码。// 构造签名原文并签名 String timestamp String.valueOf(System.currentTimeMillis() / 1000); String nonce UUID.randomUUID().toString().replace(-, ); String message POST\n /v3/refund/domestic/refunds\n timestamp \n nonce \n bodyJson \n; Signature signature Signature.getInstance(SHA256withRSA); signature.initSign(privateKey); // privateKey 从 apiclient_key.pem 加载 signature.update(message.getBytes(StandardCharsets.UTF_8)); String sign Base64.getEncoder().encodeToString(signature.sign()); // 组装 Authorization 头 String authHeader WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonce \, signature\ sign \, timestamp\ timestamp \, serial_no\ certSerialNo \;有两个细节值得留意timestamp 是秒级不是毫秒级微信会校验时间窗口与服务器时间偏差超过五分钟会报签名错误nonce 每次请求都要换新避免重放。签名原文最后一行永远是请求体加一个换行符GET 请求的请求体为空时也要保留那个空串加换行符。很多人在这一步和前面说的response_body顺序上栽过跟头。3.3 发起请求并处理响应签名构造好之后HTTP 请求本身很简单用原生的HttpURLConnection或者 OkHttp 都行核心是要把 Authorization 头、Content-Type、Accept 三个请求头设对。下面是完整调用代码URL url new URL(https://api.mch.weixin.qq.com/v3/refund/domestic/refunds); HttpURLConnection conn (HttpURLConnection) url.openConnection(); conn.setRequestMethod(POST); conn.setRequestProperty(Content-Type, application/json); conn.setRequestProperty(Accept, application/json); conn.setRequestProperty(Authorization, authHeader); conn.setDoOutput(true); try (OutputStream os conn.getOutputStream()) { os.write(bodyJson.getBytes(StandardCharsets.UTF_8)); } int statusCode conn.getResponseCode(); String responseBody readStream(conn.getInputStream()); // 200 时读取正常流到这里为止申请退款这件事就算跑通了但真正的战斗才刚开始——退款申请发出后微信返回的只是受理结果不代表钱已经退到用户账户。响应报文里有一个refund_id字段这是微信侧生成的退款单号后续查状态、对账都用它。如果返回的 HTTP 状态码不是 200常见的是 400 参数错误、401 签名错误、403 权限不足、429 频率过高这时候先别急着改代码把响应体里的code和message拿出来对着官方错误码表查一遍比瞎猜高效得多。4. 退款结果闭环查询兜底与回调解密4.1 查询接口的设计与超时兜底退款申请成功只是第一步从“受理”到“成功到账”之间还有时间差。微信官方建议优先依赖回调通知判断最终结果但回调通知有网络延迟也不能保证百分百送达。所以只靠回调不够查询接口才是兜底方案。查询路径是GET https://api.mch.weixin.qq.com/v3/refund/domestic/refunds/{out_refund_no}把退款单号拼进 URL。这个接口的签名方式和申请接口一样但签名原文有区别请求方法是 GET请求体为空但签名原文里仍然要保留那个空行。String queryPath /v3/refund/domestic/refunds/ outRefundNo; String message GET\n queryPath \n timestamp \n nonce \n \n; // 注意GET 请求体为空但签名原文仍要保留这一行 // 组装 Authorization 头的逻辑和 3.2 一致只是请求方法变了查询接口返回的字段里最关键的是status取值有SUCCESS退款成功、CLOSED退款关闭、PROCESSING退款处理中、ABNORMAL退款异常这几种。常见的轮询策略是退款单发起后每隔 5 秒查一次连续查 5 次仍处于 PROCESSING 就告警转人工处理。不建议把查询次数调太密微信对接口频率有限制499 这种报错多半就是短时间内请求太频繁。4.2 回调通知的验签与 AES-GCM 解密微信退款结果会异步推送到申请退款时填的notify_url但推送过来的报文里resource字段是加密的。解密用的是 APIv3 密钥加 AES-256-GCM 算法这一步是很多 Java 项目扫码半天也解不开的重灾区。解密前要先做两件事一是验签用Wechatpay-Serial响应头里的证书序列号找到对应的微信平台证书公钥验证Wechatpay-Signature头里的签名防止收到的报文是伪造的二是从请求体里取出resource对象它里面有algorithm、ciphertext、associated_data、nonce四个字段。解密逻辑如下String ciphertext resource.getCiphertext(); String nonce resource.getNonce(); // 这个是资源里的 nonce不是请求头里的 String associatedData resource.getAssociatedData() null ? : resource.getAssociatedData(); // 用 APIv3 密钥做 AES-256-GCM 解密 SecretKeySpec key new SecretKeySpec( apiV3Key.getBytes(StandardCharsets.UTF_8), AES); GCMParameterSpec spec new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, key, spec); if (!associatedData.isEmpty()) { cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); } byte[] plaintextBytes cipher.doFinal( Base64.getDecoder().decode(ciphertext)); String plaintextJson new String(plaintextBytes, StandardCharsets.UTF_8);解密后的 JSON 里有out_trade_no、out_refund_no、refund_status、refund_id、success_time这些字段。这里有个惯性坑associated_data可能为空为空时不能调用cipher.updateAAD()否则直接抛异常所以代码里要先判空。另一个容易出错的地方是解密时用的 nonce 必须是resource对象里的 nonce而不是微信回调请求头里的Wechatpay-Nonce。这两个值不一样用错任何一个解密出来的都是一堆乱码或者直接异常。处理完回调后接口要返回 HTTP 200 和应答体应答体固定格式如下否则微信会认为通知失败并重复推送重复推送带来的幂等压力全在你自己这边。{code:SUCCESS,message:成功}4.3 退款状态机与幂等处理退款回调收到的refund_status和查询接口返回的status是对应关系。SUCCESS 就更新订单为“已退款”CLOSED 就把退款单关闭ABNORMAL 则提交工单。这里最忌讳的是回调一到就无条件更新订单状态因为微信会重复推送同一条通知重复推送时如果不做幂等判断订单状态就会被反复覆盖。我在退款回调的入口处强制要求先查本地退款单表如果refund_id已经存在且状态已经是 SUCCESS直接返回成功应答不重复处理。这个判断是落库前的最后一道闸。退款单的幂等键最好是out_refund_no申请退款时由商户侧生成生成规则我一般用“业务前缀 日期 随机串”既保证全局唯一线上排查时也容易一眼看出是哪条业务线发起的退款。5. 微信退款避坑五个高频异常的现象、根因与修法5.1 报错“签名错误”但签名代码看起来没问题现象请求 V3 退款接口返回401响应体提示签名错误检查代码里签名算法、Authorization 头格式都没问题。原因最常见的是把 APIv3 密钥当成了 API 密钥来用或者 APIv3 密钥在商户平台重置过但代码里配置的还是旧值。另有一种隐蔽情况签名原文里的时间戳用的是毫秒微信要求秒级时间窗口校验不过也会报签名错。解决把配置文件里的 APIv3 密钥和商户平台核对一遍重置后立刻换新时间戳统一在签名工具类里用System.currentTimeMillis() / 1000取秒。校验 Authorization 里 timestamp 与服务器时间的偏差不要超过 5 分钟。5.2 退款单号重复导致的“订单不存在”现象申请退款时传入out_refund_no返回提示“订单不存在”或PARAM_ERROR但单号明明刚生成。原因out_refund_no在商户侧全局唯一一旦之前有一笔退款单已经用过这个单号哪怕是失败的单就不能再复用。另一个常见原因是测试环境和生产环境共用了同一个退款单号生成规则两边各自生成相同单号微信侧把它识别成重复请求。解决生成out_refund_no时加入环境标识例如测试环境加TEST前缀生产环境不加同一笔退款若发起失败后续重试时带上同样的out_refund_no实现幂等而不是换一个新单号。5.3 金额解密对不上0.1 元退出来变成 0 元现象用户申请退款 0.1 元支付回调里显示退款金额变成 0或者退款成功但用户只收到了 0 元。原因金额单位没统一。订单系统里存的是“元”退款接口要求“分”换算时用了double或者直接int强转0.1 元转成 1 分时四舍五入被截断成 0。还有些项目在传total时把单位搞混导致refund大于total接口直接拒绝。解决金额在服务内部一律用 Long 类型存分退款时原样传入不经过任何浮动运算换算只发生在展示层。传参前加一个断言refund total不满足直接抛业务异常。5.4 回调解密失败日志里是乱码现象回调接口收到了微信推送但AES/GCM/NoPadding解密时抛AEADBadTagException或者解密出来的 JSON 是乱码。原因多半是解密时用了请求头Wechatpay-Nonce当 nonce而正确值是resource对象里的 nonce再一个可能是associated_data字段本身存在但为空串代码里没有判空直接执行updateAAD导致异常。解决解密前先打日志把resource对象里的 algorithm、nonce、associated_data 原样打印出来对比是不是和文档一致。nonce 严格取resource对象里的值associated_data 为空时传空串AAD 参数永远不要传 null。5.5 退款长时间处于 PROCESSING 不落地现象退款单状态一直停在 PROCESSING用户侧迟迟收不到到账通知查询接口也没返回最终结果。原因微信侧退款处理确实存在延迟比如银行通道异常、用户银行卡状态异常。但更多时候是回调通知没被正确接收——notify_url配置成了内网地址或者回调接口收到通知后没有返回 HTTP 200微信一直在重推而你的服务根本没成功处理。解决先检查退款单在微信商户平台的“交易中心 - 退款单”里的真实状态确认微信侧是不是真的处理中。如果是回调问题把回调接口的响应码和应答体打印出来确认返回的是{code:SUCCESS,message:成功}并保证公网能访问到notify_url。6. 上线前的自测技巧小额订单全链路验证与日志留痕6.1 用 0.01 元把三个环节完整走一遍退款接口对接完不要急着放真实业务先用一笔 0.01 元的测试订单把申请、查询、回调三个环节完整走一遍任何一个环节断了都能在测试阶段暴露出来。我一般按下面这张清单逐项验证验证项预期结果检查点申请退款返回 HTTP 200拿到 refund_id请求参数单位、签名、Authorization查询退款status 从 PROCESSING 变 SUCCESSout_refund_no 是否正确拼接进 URL回调通知notify_url 收到推送解密成功associated_data/nonce 是否用对回调应答返回 200 固定应答体是否触发微信重复推送本地对账退款金额与订单金额一致日志里的 refund 金额单位是否为分这五步里我反复强调的就是“日志留痕”。申请退款时把out_refund_no、请求体、响应体、Authorization 签名串里 nonce 和 timestamp 都打出来查询时把refund_id、状态、耗时打出来回调时把原始请求体、解密后的明文、处理结果打出来。线上排查退款问题90% 的线索都在这些日志里。6.2 幂等重试与日志约定退款场景有一句血泪经验钱出去容易追回来难。所以在设计上要把“后悔药”备足。out_refund_no是幂等键同一笔退款如果第一请求超时应该用同一个out_refund_no发起重试而不是重新生成一个。这样即使第一次请求其实已经成功微信侧也不会重复退款。日志我习惯按统一模板打[REFUND] out_refund_noRF20250101001 out_trade_noORDER20250101001 statusSUCCESS refund_id503003012420250101000000001 cost185ms这段日志一看就能定位问题单号、状态、微信侧退款单号、耗时全有。从那以后我每次对接微信退款接口都会先花十分钟把商户号、证书序列号、私钥、APIv3 密钥对着配置表核一遍再用 0.01 元的单把申请、查询、回调三步全跑通最后才敢碰真实金额。这套流程看着笨但帮我挡掉了太多上线后才发现的问题。希望帮到你。本文还有配套的精品资源点击获取