ARTICLE DETAIL

资讯详情

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

Java微信支付V3退款实战:小程序支付、回调解密与对账避坑指南

Java微信支付V3退款实战:小程序支付、回调解密与对账避坑指南 简介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 SDKwechatpay-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_typeauthorization_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_idxxx、signTypeRSA最后用商户私钥对这四个参数做一次签名得到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); MapString, 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-Signaturebody里带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:2008: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 statusSUCCESS WHERE out_refund_no? AND statusPROCESSING受影响行数为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退款基本不会在线上的深夜给你打紧急电话。这也是我一直坚持的工作习惯上线任何涉及钱的接口先本地伪造回调、再灰度真实退款、最后全量放量宁可慢一点也不拿用户的钱试错。希望帮到你。本文还有配套的精品资源点击获取
返回列表