☰
Java微信支付V3退款实战:小程序支付、回调解密与对账避坑指南
2026/10/5 13:44:19 网站建设 项目流程

简介:Java微信支付小程序退款功能开发资料包,面向具备基础Spring Boot与微信小程序开发经验、需要接入微信支付V3退款接口的中级Java工程师。内容围绕V3版本退款核心流程展开,覆盖获取Access Token、发起退款请求、处理退款结果、回调通知验签、错误重试机制及小程序端交互等关键环节,可帮助读者快速理解从下单到退款闭环的完整实现思路。压缩包共4个文件,以txt源码说明与properties配置为主,包含后端Bean、Controller及支付参数配置样例与pom依赖清单,整体6KB,轻量精炼,适合作为本地调试与联调时的参考脚手架。资源已有4600余人浏览学习,按文件分类即可快速定位所需代码片段。对于正在处理微信支付V3证书签名、回调验签或退款状态同步等问题的开发者,这套资料能提供直接的代码结构与配置参考,降低排查和上手成本。

1. 先说结论:Java对接微信支付V3退款,难点不在接口本身

Java对接微信支付V3,光一个退款功能就能把经验不足的人绊倒一星期。上周帮朋友排查线上问题:小程序订单退款后,用户微信里钱已经退了,后台退款单却一直显示“处理中”,对账对不上。翻日志发现是退款回调解密一直抛异常,罪魁祸首是把API v3密钥填成了旧版V2的32位key。这种“钱已经退了,系统不知道”的翻车现场,在Java小程序支付退款V3落地时非常典型。这篇笔记围绕“java微信支付(小程序)退款(V3版本)”这条主线,从商户证书、JSAPI下单、退款接口、回调解密到状态对账,给出一套可以直接照抄的落地路径。适合正在维护小程序商城支付模块的Java工程师,也适合准备把V2迁移到V3的独立开发者。

2. 接入前必须搞清的三个配置项:商户证书、API v3密钥与证书序列号

2.1 商户API证书和API v3密钥,到底分别管什么

微信支付V3和V2最大的区别,是把“用API key做MD5签名”换成了一套非对称签名体系。要跑通V3,你在商户平台的“API安全”菜单里需要准备四样东西:商户号mchid、API v3密钥、商户API证书、平台证书。很多人第一关就挂在“证书和密钥哪个是哪个”上。

商户API证书代表“你的身份”。它由一对公私钥组成:apiclient_key.pem是私钥,apiclient_cert.pem是公钥证书。任何发给微信支付V3的请求(下单、退款、查单),都要用这个私钥做SHA256withRSA签名,微信拿你上传的公钥验签。请求头里的serial_no,是这个证书的序列号,微信通过它找到对应公钥。所以商户API证书解决的是“我是我”。

API v3密钥是微信在商户平台上给你生成的一串32位字符,它的唯一用途是解密微信回调通知里的报文。注意V3里已经没有“API key + MD5”那套东西了,常见的坑有两个:一是沿用V2习惯,在代码里配置一个32位key去解回调,解密永远报错;二是把apiclient_key.pem的私钥和API v3密钥搞混,以为回调要用私钥解——实际上回调报文是AES-256-GCM加密,密钥就是API v3密钥这个字符串本身。

平台证书是用来“验证微信的身份”的。微信回调你的退款结果时,会用平台证书对应的私钥对报文签名,你用平台证书里的公钥去验签,确认这个通知确实是微信支付发的,而不是别人伪造的POST请求。平台证书可以在商户平台手动下载,也可以调用 /v3/certificates 接口自动获取。我的建议是:新项目直接用接口自动获取并持久化,老项目先手动下载导入,别在证书轮换上做太多手工运维。

另外,商户平台的API安全里还可以配置请求来源IP白名单。很多团队测试环境配好了一切,一上线发现接口返回“请求IP不在白名单”,原因就是微信要求你在商户平台把服务器出口公网IP加进去。这个配置不在代码里,但每次联调都在这里卡一两天。建议上线前把生产服务器公网IP、测试服务器公网IP都加进去,注意这个白名单只对V3管理端接口生效(下单、退款),用户支付时的页面请求不受影响。

2.2 用Java读取商户私钥,生成V3请求签名的骨架代码

不管你是用官方SDK还是自己封装,建议把签名逻辑彻底弄懂,排查问题的时候才不至于两眼一抹黑。下面是生成Authorization请求头的核心代码:

// 引入 hutool-crypto 或直接使用 JDK 的 Signature // 读取 apiclient_key.pem 得到私钥对象 PrivateKey privateKey = readPrivateKey("/certs/apiclient_key.pem"); // nonce_str 每次请求唯一,建议 UUID 去横线 String nonceStr = UUID.randomUUID().toString().replace("-", ""); // timestamp 是当前时间戳(秒),注意不是毫秒 long timestamp = System.currentTimeMillis() / 1000; // 签名原文:HTTP方法 + "\n" + URL路径 + "\n" + timestamp + "\n" + nonceStr + "\n" + 请求体 + "\n" String message = "POST\n/v3/refund/domestic/refunds\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()); String authorization = "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + mchId + "\"," + "nonce_str=\"" + nonceStr + "\"," + "signature=\"" + signature + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + serialNo + "\"";

这段代码的逻辑是:把HTTP方法、请求路径、时间戳、随机串、请求体拼成一个字符串,用商户私钥做一次SHA256withRSA签名,再把签名和商户号、证书序列号一起塞进Authorization头。微信那边拿着serial_no找到你的商户证书,用证书里的公钥验证签名。

几个参数容易写错:message里的URL路径不需要带域名,但查询参数要带上,例如查单接口是 /v3/refund/domestic/refunds/{out_refund_no},路径里有占位符就填实际值;GET请求没有请求体,message末尾的body位置填空字符串,但末尾的换行符不能省;timestamp必须是秒,有些机器取到毫秒直接拼进去,微信返回签名错误。另外,私钥文件读取建议用hutool的SecureUtil或BouncyCastle,不要用Java资源配置一口气读完就完事——商户平台下载的私钥默认是PKCS8格式,但部分老证书是PKCS1,解析方式不同,读取逻辑要兼容。

如果你不想自己维护这套签名和证书轮换,可以直接用微信官方Java SDK(wechatpay-java),它会把签名、验签、证书自动更新都封装好。但我的经验是,SDK能帮你少写代码,不能帮你少踩坑——回调解密、幂等、状态流转这些业务逻辑,始终掌握在自己手里。

3. 小程序支付下单:JSAPI下单、openid换取与前端调起pay全链路

3.1 从wx.login到code2Session:拿到用户openid

小程序支付和App支付最大的不同,是你必须拿到用户的openid,并且在后台下单时把它塞进payer.openid字段。openid的获取链路是:小程序端先调wx.login拿到一个临时js_code,后端再用这个js_code去微信的jscode2session接口换openid和session_key。这个接口不在微信支付V3体系里,但属于支付前置步骤。

后端Java代码大概是:

// GET 请求,参数走 query String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appid + "&secret=" + secret + "&js_code=" + jsCode + "&grant_type=authorization_code"; // 用 HttpClient 发起 GET,解析 JSON 得到 openid 和 session_key String respBody = httpGet(url); JsonObject resp = JsonParser.parseString(respBody).getAsJsonObject(); if (resp.has("openid")) { String openid = resp.get("openid").getAsString(); } else { // errcode 40029 说明 js_code 非法或已过期;45011 说明调用频率被限 }

注意:appsecret是在小程序后台生成的密钥,只能存在后端,绝不能出现在小程序前端代码里,否则别人抓包拿到后可以冒充你的后端去换openid。联调阶段我习惯用Charles抓包小程序请求,确认wx.login真的返回了code、后端真的把code换成了openid——很多支付调起失败,根源不是V3接口问题,而是openid根本没拿到。

3.2 JSAPI下单:POST /v3/pay/transactions/jsapi

拿到openid后,后端调用微信支付V3的JSAPI下单接口。请求体需要包含appid、mchid、描述、商户订单号、金额(单位是分)、回调通知地址,以及支付人openid。代码:

JsonObject body = new JsonObject(); body.addProperty("appid", appid); body.addProperty("mchid", mchId); body.addProperty("description", "小程序商城-订单支付"); body.addProperty("out_trade_no", outTradeNo); body.addProperty("notify_url", "https://api.example.com/pay/notify"); // 金额单位是分,total = 1 表示 0.01 元 JsonObject amount = new JsonObject(); amount.addProperty("total", totalInFen); amount.addProperty("currency", "CNY"); body.add("amount", amount); JsonObject payer = new JsonObject(); payer.addProperty("openid", openid); body.add("payer", payer); String respBody = httpPost("https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi", body.toString(), buildAuthorizationHeader("POST", "/v3/pay/transactions/jsapi", body.toString())); // 解析 respBody 里的 prepay_id

这里参数有三个容易踩的地方。out_trade_no是你自己生成的商户订单号,必须保持唯一,重复下单会返回“订单已存在”;amount.total是分,前端传过来的“19.90元”必须用BigDecimal转成1990,凡是拿double做乘法再强转int的,都对不了账;notify_url必须是可以被外网访问的HTTPS地址,微信回调不会带上自定义header或token,所以你需要在回调接口里通过body里的商户号等信息做安全校验。

3.3 生成pay参数,调起小程序收银台

下单成功返回的prepay_id还不能直接用,你需要把它包装成小程序端wx.requestPayment认识的参数:appId、timeStamp、nonceStr、package(值是prepay_id=xxx)、signType=RSA,最后用商户私钥对这四个参数做一次签名,得到paySign。

String packageStr = "prepay_id=" + prepayId; String nonceStr = UUID.randomUUID().toString().replace("-", ""); String timeStamp = String.valueOf(System.currentTimeMillis() / 1000); // 签名原文按小程序端要求拼接 String message = appid + "\n" + timeStamp + "\n" + nonceStr + "\n" + packageStr + "\n"; String paySign = rsaSign(message, privateKey); Map<String, String> payParams = new HashMap<>(); payParams.put("appId", appid); payParams.put("timeStamp", timeStamp); payParams.put("nonceStr", nonceStr); payParams.put("package", packageStr); payParams.put("signType", "RSA"); payParams.put("paySign", paySign); // 把这个 Map 原样传给小程序端

小程序端拿到这些参数后:

wx.requestPayment({ timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: 'RSA', paySign: payParams.paySign, success: (res) => { /* 支付成功 */ }, fail: (err) => { /* 用户取消或支付失败 */ } })

很多人在这里翻车是因为paySign的签名原文顺序:一定是appid、timeStamp、nonceStr、package,中间用换行符连接,末尾再补一个换行——顺序和文档里写的一样,但网上个别教程把它写成了别的顺序。如果你是用uniapp打包的小程序,调起支付仍然走wx.requestPayment,但一定要在manifest里配置好微信支付模块,否则会报“requestPayment:fail not supported”。调不起收银台时,用Charles抓小程序请求看wx.requestPayment的入参,再对照签名原文,基本一眼能找到问题。paySign是当前会话的一次性签名,生成后过几分钟就失效,所以后端每次下单都要重新生成,不能缓存。

4. 退款(V3版本)接口落地:请求体、状态机与主动查单兜底

4.1 退款接口的请求构造与必传参数

V3退款的接入路径是POST https://api.mch.weixin.qq.com/v3/refund/domestic/refunds,不需要上传退款证书,但仍要用商户API证书签名。请求体核心字段如下:

字段必填说明
out_trade_no与transaction_id二选一原商户订单号
transaction_id与out_trade_no二选一微信支付单号
out_refund_no是商户退款单号,必须唯一
reason否退款原因,建议填写后展示给用户
notify_url否退款结果回调地址,强烈建议必填
amount.refund是本次退款金额,单位分
amount.total是原订单支付金额,单位分
amount.currency否货币类型,默认CNY

这里最关键的是amount对象里必须同时传refund和total。refund是这次退多少钱,total是原单总额。微信拿这两个值去校验退款金额不能超过原支付金额,也不能超过可退余额。如果一笔订单分多次退款,每次的refund是本次金额,total始终是原订单总额。

JsonObject body = new JsonObject(); body.addProperty("out_trade_no", outTradeNo); // 或 transaction_id body.addProperty("out_refund_no", outRefundNo); body.addProperty("reason", "用户申请退款"); body.addProperty("notify_url", "https://api.example.com/refund/notify"); JsonObject amount = new JsonObject(); amount.addProperty("refund", refundFen); amount.addProperty("total", totalFen); amount.addProperty("currency", "CNY"); body.add("amount", amount); String respBody = httpPost("https://api.mch.weixin.qq.com/v3/refund/domestic/refunds", body.toString(), buildAuthorizationHeader("POST", "/v3/refund/domestic/refunds", body.toString())); // 返回 JSON 里有 out_refund_no、refund_status、create_time

返回的refund_status字段有四种取值,我一般会先落库再展示,不只看HTTP状态码。注意:退款接口的HTTP 200只代表受理成功,不代表钱已经退到用户账户。如果你看到200就在前端提示“退款成功”,一定会出客诉。

4.2 退款状态机与主动查单兜底

V3退款状态机如下:

状态含义接下来的动作
PROCESSING退款处理中等待回调或定时查单
SUCCESS退款成功更新订单状态为已退款
CLOSED退款关闭通常是原单已撤销,需人工介入
ABNORMAL退款异常联系微信或重新发起退款

实际运行中,回调通知可能延迟、丢失,所以绝对不能只在回调里更新退款状态。我的做法是:为每个退款单建立一张refund_order表,记录out_refund_no、退款状态、回调收到的原始JSON、更新时间;再写一个定时任务,每分钟扫一次“PROCESSING超过5分钟”的退款单,主动调用查询接口确认最终状态。

// 查单接口:GET /v3/refund/domestic/refunds/{out_refund_no} String urlPath = "/v3/refund/domestic/refunds/" + outRefundNo; String authorization = buildAuthorizationHeader("GET", urlPath, ""); String respBody = httpGet("https://api.mch.weixin.qq.com" + urlPath, authorization); // 解析 refund_status,若 SUCCESS 则更新本地订单状态

查单接口返回的结构和退款接口一致。定时任务建议只处理“处理中超过5分钟”的单子,避免刚提交就去查——微信侧可能还没落库,会返回“退款单不存在”。另外查单接口本身也有频率限制,一轮扫描别把全量PROCESSING都查一遍,分批小流量比较稳。

4.3 把out_refund_no设计成业务幂等键

V3退款没有单独提供幂等头,它的幂等依赖out_refund_no:同一个退款单号重复提交,微信不会重复退款,而是返回已存在的退款单。这个特性既是保护也是约束——你的事务里如果没控制好,同一笔订单被用户重复发起退款,第二次调用会拿到同样的退款单,但你的代码如果不识别,就会在本地插入两条“退款中”记录,对账时看着像退了两笔。

我习惯把out_refund_no生成规则定为“业务退款单号_退款批次”,比如 R20240917001_01。同一笔订单第一次退款用_01,如果退款关闭或异常后重新发起,用_02,保证单号不重复。下面是一个生成退款单号的简单实现:

public String buildRefundNo(String bizOrderNo, int batch) { // 商户号尾部3位 + 日期 + 业务单号后6位 + 批次 return String.format("%s%s%06d_%02d", mchShort, yyyyMMdd, bizSeq, batch); }

这里有一个隐藏坑:退款失败(CLOSED或ABNORMAL)后重试,不能继续沿用原来的out_refund_no,必须换新单号。因为微信侧的退款单是幂等的,你拿旧单号重试,它只会返回原来的失败状态,永远不会重新走流程。所以业务流程上,ABNORMAL状态的单子要走“生成新退款单号、重新提交”的路径,而不是简单重试同一个请求。

5. 退款结果回调解密与状态流转:常见问题与排查方法

5.1 回调解密不是拿私钥解,而是用AES-GCM

退款结果通知的URL是你退款时填的notify_url,微信会POST一个JSON过来。第一次接V3的人容易卡在“回调怎么解不开”。V3回调需要两步:第一步验签,用平台证书公钥验Wechatpay-Signature;第二步解密,用API v3密钥做AES-256-GCM解密,拿到真正的退款结果明文。

回调的HTTP头里有四个关键字段:Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial、Wechatpay-Signature,body里带resource节点。验签时用Wechatpay-Serial找到对应的平台证书公钥,把时间戳、随机串、请求体按特定格式拼接,用SHA256withRSA验签:

// 验签原文:timestamp + "\n" + nonce + "\n" + body + "\n" String message = wechatpayTimestamp + "\n" + wechatpayNonce + "\n" + body + "\n"; Signature verify = Signature.getInstance("SHA256withRSA"); verify.initVerify(platformPublicKey); // 根据 serial 找到对应平台证书公钥 verify.update(message.getBytes(StandardCharsets.UTF_8)); boolean ok = verify.verify(Base64.getDecoder().decode(wechatpaySignature));

这一步失败了,先检查是不是把商户证书当成平台证书用了——平台证书在商户平台的“API安全-微信支付公钥”里下载,和商户API证书不是同一个文件。验签通过后,用API v3密钥解密resource节点:

JSONObject resource = body.getJSONObject("resource"); String ciphertext = resource.getString("ciphertext"); // Base64 密文 String nonce = resource.getString("nonce"); // 明文 nonce String associatedData = resource.optString("associated_data"); // 关联数据 byte[] keyBytes = apiV3Key.getBytes(StandardCharsets.UTF_8); byte[] nonceBytes = nonce.getBytes(StandardCharsets.UTF_8); byte[] cipherData = Base64.getDecoder().decode(ciphertext); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new GCMParameterSpec(128, nonceBytes)); if (associatedData != null && !associatedData.isEmpty()) { cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); } String plaintext = new String(cipher.doFinal(cipherData), StandardCharsets.UTF_8); // plaintext 就是退款结果 JSON

代码里最容易写错的是GCMParameterSpec的nonce参数:直接用resource.nonce的原始字符串转字节数组,而不是对该字符串做Base64解码。微信返回的nonce就是UTF-8明文字符串,不需要二次解码。ciphertext才是Base64编码,必须先解码再喂给Cipher。

5.2 解密后的字段与状态落库

解密得到的JSON格式大致如下,关键字段是out_refund_no、refund_status、success_time和amount:

{ "out_refund_no": "R20240917001_01", "refund_status": "SUCCESS", "success_time": "2024-09-17T15:03:20+08:00", "amount": { "total": 1000, "refund": 100, "payer_total": 1000, "payer_refund": 100 } }

落库时我建议先按out_refund_no查本地退款单,不存在就记录一条“未知退款回调”并告警;存在就更新状态,把原始明文JSON存到log表,方便以后排查和人工对账。状态更新要带条件,比如只允许由PROCESSING更到SUCCESS,不允许SUCCESS被后续回调改成CLOSED——微信偶尔会重复推送同一条通知,如果没有状态机的约束,后到的旧状态会把正确状态覆盖掉。

还有一个常见需求是“部分退款后的剩余金额展示”。amount对象里,total是订单总额,refund是本次退款金额。如果订单被部分退款,后续再退,回调里这次refund就是本次金额,不是累计。要算“已退合计”,请以本地落库的退款流水为准,不要在内存里做累加,服务重启会丢。回调接口处理完后必须返回HTTP 200或204,微信收到非2xx会按失败处理并持续重试,就算你业务已经改了状态,它还会继续推,反复触发你的幂等逻辑。

5.3 五条高频问题排查记录

把我在生产环境遇到过的典型问题整理成现象、原因、解决三段式,帮你少走弯路:

现象一:退款请求返回“商户退款权限未开通”。原因:这个商户号没有开通“退款”产品权限,或者签约的是旧版代金券产品,退款入口在产品中心里没启用。解决:登录商户平台,在“产品中心”里确认已开通“退款”并完成协议签署;如果是服务商模式,还要检查子商户的退款权限是否已授权。

现象二:回调接口一直收到验签失败,查日志发现Wechatpay-Serial对应的证书找不到。原因:微信支付平台证书有有效期,需要定期轮换,本地没有最新证书。解决:写一个定时任务,每周拉取一次/v3/certificates接口自动更新平台证书;或者简单点,每次验签失败时如果serial找不到,就去拉一次最新证书再验一次。

现象三:回调解密抛AEADBadTagException。原因:绝大多数是API v3密钥配错了,比如密钥多了空格、把V2的API key填进来、或者密钥不是32字节。解决:到商户平台重新复制API v3密钥,数一下字符长度,必须是32个字符,不要复制到换行符和多余空格。

现象四:退款金额几分钱对不上,前端显示退了1元,后端却退了100元。原因:元转分时用了double乘法,出现精度问题。解决:统一用BigDecimal,先new BigDecimal("1.00"),再multiply(new BigDecimal("100")),最后intValue();前端传金额一律走字符串,不走float。

现象五:重复收到退款回调,导致退款单状态被覆盖成“处理中”。原因:没有做幂等控制,每次回调都无条件更新本地状态。解决:更新SQL里带上状态约束,例如UPDATE refund_order SET status='SUCCESS' WHERE out_refund_no=? AND status='PROCESSING',受影响行数为0时说明已有终态,不处理。

6. 进阶:本地模拟V3退款回调,把对账做成每天自动跑一遍

V3退款最让人头疼的是“回调不可控”,联调时没法稳定触发一个SUCCESS回调。我的习惯是写一个本地模拟器:把微信回调的JSON明文,用API v3密钥按AES-GCM加密,再POST到本地notify接口,验证解密和落库逻辑。这样可以在不花真钱的情况下把整个流程跑通:

// 模拟退款回调:构造明文 + 加密 + POST 到本地接口 String plain = "{\"out_refund_no\":\"R20240917001_01\",\"refund_status\":\"SUCCESS\"}"; Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); // 用同一个 API v3 密钥加密,nonce 随机生成,associated_data 可以留空 // 加密后把 ciphertext、nonce、associated_data 组装成 resource 节点

模拟器跑通后,再在正式环境拿一笔小额真实订单做一次完整退款验证,确认回调头里的验签也能过。最后给线上加一道保险:每天凌晨跑一个对账任务,从微信支付商户平台的账单接口拉取前一天的退款流水,和本地refund_order表逐条比对。状态不一致的,以微信侧为准触发人工复核。只要把“本地模拟回调验证、定时查单兜底、每日自动对账”这三件套做了,V3退款基本不会在线上的深夜给你打紧急电话。这也是我一直坚持的工作习惯:上线任何涉及钱的接口,先本地伪造回调、再灰度真实退款、最后全量放量,宁可慢一点,也不拿用户的钱试错。希望帮到你。

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

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

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

立即咨询