ARTICLE DETAIL

资讯详情

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

Java微信退款接口实战:证书、签名、回调与幂等处理全解析

Java微信退款接口实战:证书、签名、回调与幂等处理全解析 简介这份资源是面向Java后端开发者的微信退款接口实现参考包聚焦商户在用户发起退款时通过API与微信服务器交互的完整流程适合需要对接微信支付退款能力的初中级开发者学习与复用。包内共29个文件以10个jar依赖库、6个java源码、6个class编译文件为主另含xml配置、jsp页面及工程元数据文件压缩包约1.92MB可直接导入MyEclipse等IDE运行调试。内容围绕PKCS12证书加载、SSLContext配置、HttpClient发送POST请求、JSON参数组织与响应解析等关键环节展开示例代码展示了从加载证书、构造退款参数到处理返回结果的完整链路并涉及RSA2048签名与超时设置等易错点。目前已有869人学习下载可作为快速跑通微信退款调用、排查证书与签名问题的实用起点。1. Java 微信退款接口从申请到到账这套源码把回调坑都填了做过微信支付的人都知道付款那一步其实是最顺的真正让人半夜爬起来看日志的是退款。用户一句「我不要了」运营一句「给他退了吧」落到代码里就是一套比下单还绕的流程证书、双向认证、退款单号、异步通知解密、状态机对账任何一环没接住钱就可能卡在「退款中」这个玄学状态里。这份 Java 微信退款接口资源核心就是把申请退款、退款查询、退款回调通知这三条链路用可运行的代码串起来适合正在做商城、知识付费、SaaS 订阅这类需要资金原路退回场景的后端同学。它不教你微信支付是什么而是直接给你能改参数就跑的接口封装和回调处理骨架让你少在证书格式和签名串上翻车。2. 退款接口的请求构造证书、签名与参数怎么摆2.1 为什么退款比下单多一道证书门槛微信支付的下单接口用商户号加 API 密钥做签名就够了但退款不一样。退款涉及资金流出微信要求走双向认证也就是你要带上商户 API 证书去请求。这个证书不是随便一个 p12 文件就行它得是从商户平台下载的、和当前商户号绑定的那一份。很多新手第一次调退款报的错是401 Unauthorized或者签名错误八成不是代码写错而是证书没加载对或者加载了但密码填的是登录密码而不是证书密码。常见做法是把apiclient_cert.p12放在项目资源目录下用KeyStore加载再构造SSLContext去初始化HttpClient。这里有个细节JDK 自带的HttpURLConnection对 p12 的支持比较别扭我一般会换成 Apache HttpClient 或者 OkHttp代码更干净也方便复用连接池。资源里的实现用的是 OkHttp因为它的SSLSocketFactory配置起来直观而且拦截器里能顺手把日志打了。2.2 退款请求参数与签名串的拼装退款接口的 URL 是https://api.mch.weixin.qq.com/v3/refund/domestic/refunds注意是 v3 版本不是老掉牙的 v2。v3 的签名规则和 v2 完全不同它用 SHA256withRSA拼的是「方法\nURL\n时间戳\n随机串\n请求体\n」然后拿商户私钥签名再把签名放到Authorization头里。这个拼装顺序错一个换行符签名就过不了。下面这段是构造请求体的核心代码参数名和微信文档一一对应// 构造退款请求体字段名必须和微信 v3 文档一致 MapString, Object body new HashMap(); body.put(out_trade_no, outTradeNo); // 原支付订单号和 transaction_id 二选一 body.put(out_refund_no, outRefundNo); // 商户退款单号自己生成保证唯一 body.put(reason, 用户申请退款); // 退款原因会展示在用户账单里 body.put(notify_url, notifyUrl); // 退款结果回调地址必须公网可访问 // 金额信息单位是分不是元 MapString, Object amount new HashMap(); amount.put(refund, refundFee); // 退款金额不能超过原订单金额 amount.put(total, totalFee); // 原订单总金额 amount.put(currency, CNY); body.put(amount, amount); // 序列化成 JSON注意不要用会改变字段顺序的库 String requestBody objectMapper.writeValueAsString(body);逻辑说明out_trade_no和transaction_id选一个填就行但如果你系统里两个都有建议用transaction_id因为它更唯一不会因为商户订单号重复生成而出问题。out_refund_no是退款单号必须全局唯一我一般用「原订单号 时间戳 随机数」拼避免并发退款时撞号。金额单位是分这是最容易翻车的地方前端传过来的是元后端一定要乘 100 再取整别用double用BigDecimal或者直接long。参数说明notify_url必须是 HTTPS而且不能带参数微信会往这个地址 POST 退款结果。如果你填的是内网地址或者带 query string回调永远收不到退款状态就只能靠轮询查询接口去补。2.3 发起请求与响应解析请求发出去之后微信返回的是 JSON里面有几个关键字段refund_id微信退款单号、status退款状态、create_time。status常见值有SUCCESS、PROCESSING、ABNORMAL、CLOSED。注意申请退款成功不代表钱已经到用户账上PROCESSING才是常态真正到账要看回调或者查询接口。// 用 OkHttp 发起带证书的 POST 请求 Request request new Request.Builder() .url(https://api.mch.weixin.qq.com/v3/refund/domestic/refunds) .post(RequestBody.create(requestBody, MediaType.parse(application/json))) .addHeader(Authorization, buildAuthorization(POST, /v3/refund/domestic/refunds, requestBody)) .addHeader(Accept, application/json) .build(); try (Response response client.newCall(request).execute()) { String respBody response.body().string(); if (!response.isSuccessful()) { // 非 2xx 说明请求被拒先看 respBody 里的 code 和 message log.error(退款申请失败, code{}, body{}, response.code(), respBody); throw new RuntimeException(退款申请失败); } // 解析成功响应拿到 refund_id 和 status JsonNode node objectMapper.readTree(respBody); String refundId node.get(refund_id).asText(); String status node.get(status).asText(); log.info(退款申请已提交, refundId{}, status{}, refundId, status); }逻辑说明buildAuthorization是自定义方法负责拼签名串并做 RSA 签名。这里容易忽略的是签名用的 URL 是「路径 query」如果退款查询接口带了?out_refund_noxxx签名串里也要带上这部分否则签名对不上。响应解析时不要只判断 HTTP 状态码微信在 200 的情况下也可能返回业务错误比如「订单不存在」「退款金额超过订单金额」这些都在code字段里。参数说明refundFee和totalFee都是long类型单位分。如果原订单有优惠券total填的是用户实际支付的金额不是商品原价否则退款金额校验会失败。3. 退款回调通知解密、验签与幂等处理3.1 回调报文的结构与解密流程微信退款回调不是明文 JSON而是加密的。报文里有个resource字段里面包含ciphertext、nonce、associated_data你需要用 APIv3 密钥做 AES-256-GCM 解密才能拿到真正的退款结果。这个设计是为了防止回调被伪造但也让很多第一次接的人卡在解密报错上。解密的核心步骤是拿ciphertext做 Base64 解码用nonce和associated_data作为 GCM 的参数密钥是 APIv3 密钥不是 API 密钥这两个别搞混。解密出来的明文是一个 JSON里面有out_refund_no、refund_id、refund_status、success_time等字段。// 解密回调报文中的 resource 字段 String ciphertext resourceNode.get(ciphertext).asText(); String nonce resourceNode.get(nonce).asText(); String associatedData resourceNode.get(associated_data).asText(); byte[] keyBytes apiV3Key.getBytes(StandardCharsets.UTF_8); GCMParameterSpec spec new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, AES), spec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); byte[] plainBytes cipher.doFinal(Base64.getDecoder().decode(ciphertext)); String plainJson new String(plainBytes, StandardCharsets.UTF_8); // plainJson 里就是退款结果可以解析出 refund_status 和 out_refund_no逻辑说明apiV3Key是你在商户平台设置的 32 位字符串不是 API 密钥。GCMParameterSpec的 tag 长度固定 128 位别改。updateAAD必须在doFinal之前调用顺序反了会抛AEADBadTagException。解密失败最常见的原因是密钥填错或者nonce和associated_data传了 null。参数说明nonce是 12 字节的随机串微信回调里会给直接拿来用就行。associated_data可能是空字符串但也要传不能跳过。3.2 验签与幂等别让重复回调把钱退两次回调处理有两个必须做的动作验签和幂等。验签是确认这个回调真的来自微信不是有人伪造的。微信在请求头里放了Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial你需要用微信平台证书公钥去验。平台证书可以定期从微信下载也可以手动在商户平台获取。幂等更关键。微信的回调可能会重复推送如果你不做幂等同一笔退款可能会被处理两次虽然微信侧不会重复退钱但你的业务逻辑可能会重复发货、重复改状态。常见做法是用out_refund_no作为唯一键在数据库里建唯一索引回调进来先查这个单号是否已经处理过处理过就直接返回成功不再走业务逻辑。// 幂等处理先查退款单是否已处理 RefundRecord record refundMapper.selectByOutRefundNo(outRefundNo); if (record ! null SUCCESS.equals(record.getStatus())) { // 已经处理过直接返回成功避免重复业务 return SUCCESS; } // 没处理过更新状态并落库 refundMapper.updateStatus(outRefundNo, refundStatus, successTime);逻辑说明selectByOutRefundNo要走唯一索引不然并发回调下还是可能查到两条。更新状态时最好用乐观锁或者update ... where status PROCESSING这种条件更新确保只有一个线程能改成功。回调接口返回的字符串必须是SUCCESS或者空返回其他内容微信会认为你处理失败然后继续重推。参数说明refundStatus解密后可能是SUCCESS、CLOSED、ABNORMAL只有SUCCESS才代表钱已退到用户。success_time是退款成功时间格式是 RFC3339存库时注意时区转换。3.3 退款查询接口回调没来时的后悔药回调不是 100% 可靠的网络抖动、服务重启、证书过期都可能导致回调丢失。这时候就需要退款查询接口来兜底。查询接口是GET /v3/refund/domestic/refunds/{out_refund_no}用商户退款单号去查返回的字段和回调解密后的内容基本一致。我一般会写一个定时任务每隔几分钟扫一遍状态还是PROCESSING的退款单调查询接口去同步状态。如果查询到SUCCESS就补上业务处理如果查到CLOSED或者ABNORMAL就告警让人工介入。这个兜底逻辑比回调本身还重要因为回调是「推」查询是「拉」推拉结合才能保证最终一致。// 定时任务同步处理中的退款单状态 ListRefundRecord processingList refundMapper.selectByStatus(PROCESSING); for (RefundRecord record : processingList) { String url https://api.mch.weixin.qq.com/v3/refund/domestic/refunds/ record.getOutRefundNo(); // 发起 GET 请求签名方法同 POST但 body 为空 String resp httpGetWithAuth(url); JsonNode node objectMapper.readTree(resp); String status node.get(status).asText(); if (SUCCESS.equals(status)) { // 补上业务处理注意幂等 handleRefundSuccess(record.getOutRefundNo()); } }逻辑说明查询接口的签名串里请求体是空字符串但换行符不能省。httpGetWithAuth里拼签名时URL 要带路径不带域名。定时任务的频率别太高微信有频率限制一般 5 到 10 分钟一次就够。参数说明out_refund_no是你自己生成的退款单号不是微信的refund_id。如果你只存了refund_id查询接口也支持用refund_id查但路径参数要换成refund_id对应的值。4. 避坑与排查退款接口最常见的五个翻车现场4.1 证书加载报PKIX path building failed现象本地跑得好好的一上服务器就报证书路径构建失败。原因服务器 JDK 的信任库不认微信的证书链或者你加载的 p12 证书本身不完整。解决确认apiclient_cert.p12是从商户平台下载的不是自己用工具生成的如果还不行把微信平台证书也导入到 JDK 的cacerts里或者用代码显式加载平台证书做信任。4.2 签名一直报401 Unauthorized现象请求返回 401提示签名错误。原因签名串拼错了最常见的是 URL 带了域名、时间戳单位不对、或者请求体被 JSON 库重新序列化后字段顺序变了。解决签名串里的 URL 只写路径和 query时间戳是秒级不是毫秒请求体用原始字符串去签名不要签完再序列化。4.3 回调解密抛AEADBadTagException现象回调进来解密直接异常。原因APIv3 密钥填错或者nonce、associated_data传了 null或者ciphertext被 URL 解码过一次。解决确认 APIv3 密钥是 32 位不是 API 密钥associated_data即使是空也要传空字符串ciphertext从 JSON 里取出来直接 Base64 解码不要再做 URL 解码。4.4 退款金额校验失败现象微信返回「退款金额超过订单金额」。原因total填的是商品原价但用户实际支付时用了优惠券实际支付金额小于原价。解决total必须填用户实际支付的金额这个金额在支付回调里能拿到存订单表的时候就要存对。4.5 回调重复处理导致业务重复现象同一笔退款业务侧处理了两次比如发了两次货或者加了两次积分。原因回调没有做幂等或者幂等判断和业务处理不在同一个事务里。解决用out_refund_no建唯一索引回调进来先插入处理记录插入成功才走业务插入冲突就直接返回成功。5. 进阶把退款状态机和对账脚本串起来退款这件事单笔调通不难难的是量大之后的状态一致性。我一般会在项目里建一张退款流水表字段包括out_refund_no、refund_id、status、apply_time、success_time、retry_count然后用一个状态机来驱动APPLIED - PROCESSING - SUCCESS / CLOSED / ABNORMAL。每次回调或者查询到新状态都走状态机流转非法流转直接告警。对账脚本是另一道保险。每天凌晨跑一次拉取前一天所有PROCESSING和ABNORMAL的单子批量调查询接口把状态同步回来。如果发现微信侧已经SUCCESS但本地还是PROCESSING就自动补处理如果微信侧ABNORMAL就发告警让人工看。这个脚本用 Java 写的话可以直接复用接口封装里的 HTTP 客户端和签名方法不用重新造轮子。// 对账脚本核心逻辑批量同步异常状态退款单 ListRefundRecord abnormalList refundMapper.selectByStatusIn(Arrays.asList(PROCESSING, ABNORMAL)); for (RefundRecord record : abnormalList) { try { String status queryRefundStatus(record.getOutRefundNo()); if (!status.equals(record.getStatus())) { // 状态有变化走状态机流转 refundStateMachine.transition(record.getOutRefundNo(), status); } } catch (Exception e) { // 单笔失败不影响其他单子记录日志后续重试 log.warn(对账查询失败, outRefundNo{}, record.getOutRefundNo(), e); } }逻辑说明queryRefundStatus就是前面说的查询接口封装返回微信侧的最新状态。refundStateMachine.transition里做合法性校验和业务处理比如从PROCESSING到SUCCESS才触发发货或者通知用户。retry_count字段用来控制重试次数超过阈值就标记为人工处理不再自动重试。参数说明对账脚本的执行时间建议放在凌晨低峰期避免和正常业务抢数据库连接。批量查询时注意微信的频率限制单次不要超过 50 笔批次之间加个sleep。从那以后我每次接退款都强制先把证书加载、签名拼装、回调解密这三段单独写成单元测试跑一遍再往业务里集成。退款这玩意儿宁可前期多花两小时把边界测清楚也别等用户投诉了再去翻日志。希望帮到你。本文还有配套的精品资源点击获取
返回列表