ARTICLE DETAIL

资讯详情

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

Java小程序微信支付V3退款实战:从证书签名到回调幂等

Java小程序微信支付V3退款实战:从证书签名到回调幂等 简介面向 Java 后端开发者的微信支付 V3 小程序退款实战资源围绕小程序支付后发起退款、回调通知、验签与失败重试等核心流程整理出可直接参考的接口调用示例与配置项适合正在对接微信支付或需要排查退款链路的初中级开发者。压缩包共 4 个文件以 3 个 txt 源码/说明文件和 1 个 properties 配置文件为主整体仅 6KB轻量易用可快速复制到工程中核对签名参数、请求地址和回调处理逻辑。资源浏览量已超 4600 人说明该套件在同类需求中具有较高参考价值。内容包含参数封装类、Controller 层调用入口、支付退款相关配置及 Maven 依赖说明能帮助读者少走弯路缩短联调时间同时结合文档中的步骤梳理可加深对 V3 版本 API 鉴权和退款状态流转的理解适合作为本地调试与代码 review 时的对照材料。1. java 小程序微信支付 V3 退款把“下单-支付-退款”闭环落到能上线小程序商城上线后退款比支付更容易被运营和用户追着问用户点一下“申请退款”钱没原路回来就是客诉。Java 后端做微信支付 V3 的退款难点并不在“发一个 POST 请求”这一步而在三处证书与密钥对不对得上、金额口径一不一致、回调解密那块黑匣子怎么打开。很多人把支付调通后照同样的思路去写退款结果要么收到“签名错误”要么拿到一个 PROCESSING 状态不知道下一步该做什么。这篇文章会从 V3 的密钥模型讲到退款状态机再落到能直接改着用的 Java 代码和几条实实在在的踩坑记录适合正在用 Java 做小程序商城、需要把售后闭环补完整的开发者。2. V3 退款先立住模型证书、密钥与签名串少一个都调不通2.1 三个“钥匙”各管什么商户 API 证书、APIv3 密钥、平台证书微信支付 V3 的整套机制本质上是用三把“钥匙”把两个动作分开出站请求由你签名入站回调由微信签名、再由你用密钥解密。第一把是商户 API 证书包含 apiclient_cert.pem 和 apiclient_key.pem商户申请 API 证书后下载得到。这个证书里的私钥用来给你的每个请求签名微信端拿你证书的公钥验签。第二把是 APIv3 密钥商户平台里自己设置的 32 字节字符串它不参与请求签名主要用来解密微信支付回调里的报文。第三把是平台证书微信支付的公钥证书用来验证微信回调的签名同时也用于部分敏感字段的加密传输。这三把钥匙的用途经常被搞混最常见的翻车现场是用 APIv3 密钥去做请求签名或者拿商户私钥去解回调密文。签名和加密是两套体系不能用同一把钥匙。实操里我一般会建一个配置文件把商户号、商户证书序列号、商户私钥路径、APIv3 密钥、平台证书路径分开放绝不混在同一个字段里。序列号不是商户号是证书本身的序列号打开 apiclient_cert.pem 能看到一串十六进制那个才是 Authorization 头里要传的 serial_no。名称用途什么时候用到形式商户 API 证书私钥给请求签名下单、退款、查单、下载账单等所有出站请求apiclient_key.pem商户 API 证书公钥含序列号微信端验你的请求签名申请时下载序列号写在请求头apiclient_cert.pemAPIv3 密钥解密回调密文支付回调、退款回调的通知解密32 字节字符串平台证书验微信回调签名收到回调通知时平台证书 pem这里还要提一句微信支付这几年推广了“平台公钥”模式可以不用手动下载平台证书改用公钥 ID 和公钥做验签省掉证书自动更新的麻烦。老项目里平台证书模式依然最普遍官方 SDK 的 RSAAutoCertificateConfig 也在做自动更新。我个人的习惯是项目刚起步就用官方 SDK 的自动更新模式等踩过一遍验签流程后再决定要不要换成平台公钥。2.2 退款请求的签名串从商户号到 Authorization 头V3 的每个请求都要在 Header 里带 Authorization格式固定为WECHATPAY2-SHA256-RSA2048 mchid商户号,nonce_str随机串,signature签名值,timestamp时间戳,serial_no商户证书序列号。这里的签名值不是直接对请求体做 SHA256withRSA而是先拼出一个签名串再对这个签名串签名。签名串的拼法是HTTP 方法 换行 带查询参数的 URL 路径 换行 时间戳 换行 随机串 换行 请求体 换行。注意几个细节URL 只包含路径部分不包含域名但包含查询参数比如查单接口的 URL 是 /v3/pay/transactions/out-trade-no/订单号?mchidxxx。请求体为空时最后一段就是空字符串但换行不能少。时间戳单位是秒随机串用 UUID 去掉横线就可以。很多人第一次写这里会栽在换行上尤其是用 String.format 拼串时%n 或 \n 用错位置签名串顺序一变微信端验签必然失败。public static String buildAuthHeader(String method, String urlPath, String body, PayConfig config) throws Exception { String nonceStr UUID.randomUUID().toString().replace(-, ); long timestamp System.currentTimeMillis() / 1000; String signStr method \n urlPath \n timestamp \n nonceStr \n (body null ? : body) \n; Signature instance Signature.getInstance(SHA256withRSA); instance.initSign(config.getPrivateKey()); instance.update(signStr.getBytes(StandardCharsets.UTF_8)); String signature Base64.getEncoder().encodeToString(instance.sign()); return WECHATPAY2-SHA256-RSA2048 mchid\ config.getMchid() \, nonce_str\ nonceStr \, timestamp\ timestamp \, serial_no\ config.getSerialNo() \, signature\ signature \; }这段代码的思路是先把参与签名的五个要素按固定顺序拼成一个字符串相邻字段用 \n 分隔然后用商户私钥做 SHA256withRSA 签名最后把 Base64 编码后的签名连同商户号、随机串、时间戳、证书序列号一起塞进 Authorization 头。核心参数里urlPath 必须与请求 URL 的路径部分逐字符一致查询参数的顺序也要和实际请求一致body 必须和实际发送的请求体完全相同任何格式化操作比如重新序列化导致字段顺序变化都会让签名失效。2.3 为什么退款接口敢同步给结果退款状态机与回调边界支付结果是靠回调通知来确认的你发起了下单用户是否真的付了钱得等 pay 回调。但退款不一样调退款接口时微信支付会在这个请求的响应体里直接返回退款单状态不需要等通知也能知道结果。状态主要有四个SUCCESS 表示退款成功CLOSED 表示退款关闭PROCESSING 表示退款处理中ABNORMAL 表示退款异常。严格说ABNORMAL 意味着退款没成功但也不是彻底失败需要商户主动介入查单。所以你在代码里不能把一个退款请求的结果当成最终结果PROCESSING 不代表失败它是常态。多数银行通道的退款是异步完成的你提交退款后立刻查可能是 PROCESSING过几秒再查变成 SUCCESS。正确的做法是收到退款接口响应后先落库为“退款处理中”然后通过退款回调通知或者主动轮询退款查单接口把最终状态更新回去。退款回调是可选的但强烈建议配。支付回调用于确认“用户已付款”退款回调用于确认“退款已成功”两者使用同一套验签与解密机制区别只在于通知的 resource 对象里装的是支付单还是退款单。理解了这套模型后面写代码时就知道哪些逻辑放在退款请求返回后同步做哪些逻辑必须等通知或轮询确认。3. 从下单到支付拉起小程序支付前必须有的 prepay_id3.1 用 Java 调 JSAPI 下单先拿到 prepay_id小程序支付走的是 JSAPI 下单接口路径是 POST /v3/pay/transactions/jsapi。下单的前提是拿到用户的小程序 openidopenid 通常由后端用 wx.login 的临时 code 调 jscode2session 接口换取前端拿不到。下单请求体里appid 是小程序 appidmchid 是商户号description 是商品描述out_trade_no 是商户侧订单号notify_url 是支付结果回调地址amount.total 是支付金额单位是分payer.openid 是刚刚换到的用户 openid。响应里会返回 prepay_id这个 id 是后面拉起小程序支付的关键凭证。public String jsapiPay(String openid, String outTradeNo, int totalFee) throws Exception { String urlPath /v3/pay/transactions/jsapi; String body { \appid\:\ config.getAppid() \, \mchid\:\ config.getMchid() \, \description\:\商城订单- outTradeNo \, \out_trade_no\:\ outTradeNo \, \notify_url\:\https://api.example.com/wxpay/pay-callback\, \amount\:{\total\: totalFee , \currency\:\CNY\}, \payer\:{\openid\:\ openid \} }; String auth buildAuthHeader(POST, urlPath, body, config); String resp httpPost(https://api.mch.weixin.qq.com urlPath, auth, body); // 解析响应提取 prepay_id return parsePrepayId(resp); }这段代码把下单请求拆成了三步拼请求体、生成签名头、发送请求。逻辑上要注意先用 buildAuthHeader 对同样的 path 和 body 做签名再把这个 Authorization 头带到 HTTP 请求里。这样签名和请求体天然一致不会出现签名串用的是一份 body、实际发送的是另一份 body 的情况。参数方面totalFee 必须是整数分比如 9.9 元要传 990out_trade_no 在商户系统内必须唯一同一个订单号重复下单微信会返回报错而不是生成新单。3.2 小程序端拉起支付二次签名那几个参数拿到 prepay_id 之后不能直接把它丢给前端。小程序端的 wx.requestPayment 需要六个参数timeStamp、nonceStr、package、signType、paySign外加 appId 在初始化时指定。其中 package 的值固定是 prepay_idxxxsignType 在小程序支付里用 RSA。paySign 要用商户私钥对一段固定格式的字符串做签名签名串是appId小程序appid 换行 timeStamp时间戳 换行 nonceStr随机串 换行 packageprepay_idxxx 换行。注意这里没有请求方法、没有 URL和前面接口签名完全不一样。public MapString, String buildPayParams(String prepayId) throws Exception { String timeStamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr UUID.randomUUID().toString().replace(-, ); String packageValue prepay_id prepayId; String signStr appId config.getAppid() \n timeStamp timeStamp \n nonceStr nonceStr \n package packageValue \n; String paySign rsaSign(signStr, config.getPrivateKey()); MapString, String params new HashMap(); params.put(timeStamp, timeStamp); params.put(nonceStr, nonceStr); params.put(package, packageValue); params.put(signType, RSA); params.put(paySign, paySign); return params; }这里的参数要原样传给小程序前端前端拿到 paySign 后调用 wx.requestPayment({...})。最容易出错的是签名串的拼接顺序必须是 appId、timeStamp、nonceStr、package 这个固定顺序每段之间用换行末尾还要有一个换行。我曾经见过有人把 package 写成 {prepay_idxxx} 带了大括号微信端会直接报签名错误。另外一个经验是timeStamp 字符串别用 double 或 Long 的 toString 带出科学计数法直接用 String.valueOf 最稳妥。3.3 支付结果回调先验签再做业务用户支付成功后微信会向 notify_url 发起一个 POST 请求请求头里带 Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial 四个核心字段。处理回调的第一步是验签用请求头里 serial 对应的平台证书对 timestamp 换行 nonce 换行 body 换行 这个签名串做验签。验签通过后第二步才是解密把 body 里的 resource 对象用 APIv3 密钥做 AES-256-GCM 解密拿到真正的支付结果。public PayCallbackData decryptCallback(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 serial request.getHeader(Wechatpay-Serial); String verifyStr timestamp \n nonce \n body \n; boolean ok verify(verifyStr, signature, platformCertificateMap.get(serial)); if (!ok) { throw new SecurityException(pay callback signature verify failed); } JSONObject resource JSON.parseObject(body).getJSONObject(resource); String ciphertext resource.getString(ciphertext); String associatedData resource.getString(associated_data); String nonceAes resource.getString(nonce); String plaintext aesGcmDecrypt(ciphertext, config.getApiV3Key(), nonceAes, associatedData); return JSON.parseObject(plaintext, PayCallbackData.class); }这段代码把回调处理分成了两个阶段先验签后解密。验签阶段把微信请求头里的时间戳、随机串、原始 body 拼成验签串用请求头里的 serial 找到对应的平台证书去验。解密阶段则从 resource 里取出密文、关联数据、随机数组合出 AES-GCM 解密需要的完整参数。这里最忌讳的是跳过验签直接解密哪怕密文能解开也不能确认它真的来自微信。解密成功后回调数据里的 trade_state 是 SUCCESS 才更新订单状态其余状态一律不处理。回调处理完要给微信返回一个固定格式的 JSON{code:SUCCESS,message:成功}如果返回其他内容微信会认为通知失败并自动重试。4. 退款落库Java 里把 V3 退款接口完整跑通的代码4.1 发起退款参数、签名与请求构造退款接口是 POST /v3/refund/domestic/refunds。它和支付下单最明显的区别是请求体里没有 notify_url 也不会报错但建议带上amount 里要有两个金额refund 是本次退款金额total 是原订单实际支付金额单位都是分。这两个值一旦传错微信会报“退款金额超限”或“订单金额不一致”。请求参数里还必须有 out_refund_no这是商户侧的退款单号和 out_trade_no 一样要求唯一。有一个很容易忽略的细节out_refund_no 的幂等作用比 out_trade_no 更强同一个退款单号重复发起微信返回的是第一次的结果而不会给你退两笔。public RefundResult createRefund(String outTradeNo, String outRefundNo, int refundFee, int totalFee) throws Exception { String urlPath /v3/refund/domestic/refunds; String body { \out_trade_no\:\ outTradeNo \, \out_refund_no\:\ outRefundNo \, \amount\:{ \refund\: refundFee , \total\: totalFee , \currency\:\CNY\}, \notify_url\:\https://api.example.com/wxpay/refund-callback\ }; String auth buildAuthHeader(POST, urlPath, body, config); String resp httpPost(https://api.mch.weixin.qq.com urlPath, auth, body); JSONObject obj JSON.parseObject(resp); RefundResult result new RefundResult(); result.setRefundId(obj.getString(refund_id)); result.setStatus(obj.getString(status)); result.setRawResponse(resp); return result; }这段代码的调用时机通常是在用户在小程序里提交退款申请后后端核验完订单归属、库存、售后流程后调用。status 字段直接来自同步响应可能是 PROCESSING也可能是 SUCCESS但绝大多数时候是 PROCESSING。注意不要在拿到 PROCESSING 后就把订单状态写成“退款成功”正确状态应该是“退款处理中”。refundFee 和 totalFee 都建议从数据库订单表读取不要由前端传过来前端只传“我要退货”的意图金额永远以系统计算为准。4.2 退款回调与状态回写怎么把结果同步进订单表退款回调的报文结构和支付回调基本一致只是 resource 解密后的字段不同。退款通知里比较关键的字段有out_refund_no、refund_status、success_time、amount.refund_fee。refund_status 为 SUCCESS 表示退款成功为 CLOSED 表示退款关闭。收到回调后要做的核心工作是把退款单状态回写到订单表并且把退款失败或异常的单子暴露给运营处理。public void handleRefundCallback(HttpServletRequest request, String body) throws Exception { // 先验签再解密与支付回调共用同一套逻辑 JSONObject refundNotify decryptCallback(request, body); if (!REFUND.SUCCESS.equals(refundNotify.getString(event_type))) { return; } JSONObject resource refundNotify.getJSONObject(resource); // 解密后的 resource 才是退款详情 JSONObject plainResource decryptResource(resource); String outRefundNo plainResource.getString(out_refund_no); String refundStatus plainResource.getString(refund_status); int refundFee plainResource.getInteger(amount).getInteger(refund_fee); // 更新退款单只有数据库里的状态不是终态时才更新 refundOrderService.markAsSuccess(outRefundNo, refundStatus, refundFee); }这里的关键是 event_type 的判断退款成功通知的事件类型是 REFUND.SUCCESS别和支付回调的 TRANSACTION.SUCCESS 混了。resource 字段的结构在支付和退款通知里长得不一样支付通知里的 resource 解密后直接是支付结果退款通知里的 resource 解密后是退款结果。我在实际项目里会把 decryptCallback 和 decryptResource 拆成两个方法避免业务字段混在一起。回写数据库时要加状态判断只有当前状态不是终态时才允许更新否则重复通知会把已成功的单子改坏。4.3 幂等与重试退款失败不能直接置为“不可退”退款流程里最常见的重试场景是请求超时很可能微信端已经受理了但你没收到响应。这种时候直接用同一个 out_refund_no 再调一次退款接口即可微信会返回已创建的退款单而不是给你再退一笔。但有个前提你必须用同一个 out_refund_no如果重新生成一个新退款单号去重试那微信会真真实实地退两笔。这个坑在线上出过生产事故处理超时的原则是先主动查单查到状态再决定是继续等还是重发原单号。public RefundQueryResult queryRefundByOutRefundNo(String outRefundNo) throws Exception { String urlPath /v3/refund/domestic/refunds/ outRefundNo; String auth buildAuthHeader(GET, urlPath, , config); String resp httpGet(https://api.mch.weixin.qq.com urlPath, auth); JSONObject obj JSON.parseObject(resp); RefundQueryResult result new RefundQueryResult(); result.setStatus(obj.getString(status)); result.setRefundId(obj.getString(refund_id)); return result; }退款查单接口的 URL 直接用 out_refund_no不需要带查询参数这点和支付查单接口不太一样。在重试机制里查单的优先级应该高于再次发起退款。收到退款请求超时异常后先查这个退款单号的状态如果存在且是 PROCESSING等待如果存在且是 SUCCESS直接回写成功如果不存在再用原 out_refund_no 发起退款。另外退款失败不等于订单不可退很多失败原因是银行通道临时问题或余额不足这种单子应该进入人工可重试列表而不是把售后单直接关掉。5. 避坑手册V3 退款最常见的 5 个翻车现场5.1 签名一直失败多一个换行、少一个换行都不行现象退款请求发出去微信返回“签名错误”但同样的签名代码在下单时是好的。原因签名串里 URL 路径、时间戳、随机串、请求体之间的换行顺序错了或者请求体在签名后又被 JSON 库重新序列化了一次字段顺序不同导致签名串和实际发送的 body 不一致。解决把 buildAuthHeader 里的 signStr 完整打到日志里逐行核对特别是 URL 路径里是否带了多余的斜杠或空格同时在发请求前用同一个字符串对象去签名和发送不要先签名再重新序列化。5.2 可退金额对不上优惠、代金券与实际支付金额的口径现象退款时报“退款金额超限”或“可退金额不足”。原因把商品原价当成 total 传给了退款接口但用户实际支付时用了优惠券或满减订单实际支付金额小于商品原价微信允许退的金额以实际支付金额为准。解决total 字段务必使用订单表里记录的用户实付金额而不是商品金额。如果订单有部分退款历史可退金额还要减去已退成功的部分。我一般在退款前先调支付查单接口拉一次最新状态拿到 buyer_pay_amount 再决定可退金额。5.3 平台证书加载失败序列号不匹配与证书过期现象本地验签时抛“证书序列号不存在”或“证书已过期”。原因回调请求头 Wechatpay-Serial 指向的是微信平台证书的序列号不是商户证书序列号平台上证书会定期轮换老证书失效后新请求会带新的序列号。解决不要硬编码平台证书用官方 SDK 的自动更新能力或者把证书缓存按序列号存到本地验签时先查请求头 serial再查对应证书。自实现方案要定期调证书下载接口拉取新证书更新频率建议不低于一天一次证书过期当天的凌晨到中午是最容易炸的时间段。5.4 退款回调收不到notify_url 的外网可达性与 HTTPS 要求现象退款单状态在微信侧已经是 SUCCESS但商户系统里一直停在“退款处理中”。原因notify_url 配置的是内网地址或者域名证书链不完整微信回调无法送达还有人把回调地址配了 HTTP微信支付要求必须是 HTTPS。解决先到微信商户平台看是否配置了退款回调地址再用 curl 从一台公网机器上测一下这个地址能否返回 SUCCESS 格式的 JSON注意别在回调里做耗时超过 5 秒的同步操作否则微信会超时重试重试过多还可能暂时屏蔽通知。回调接口要设计成幂等的微信在收不到成功响应时会以递增间隔重试多次同一个退款成功通知可能收到好几遍。5.5 幂等失效重复退款单号带来的重复打款隐患现象运营在后台点了两次“退款”用户收到两笔钱。原因第一次退款请求超时第二次操作时业务代码重新生成了 out_refund_no两个不同的退款单号都对同一笔订单发起了退款微信自然认为这是两笔合法的退款。解决退款单表对 out_refund_no 建唯一索引发起退款前先查本地退款单是否存在发起时用订单 ID 加退款批次生成固定的 out_refund_no比如 RMB20250101120001。对账脚本要每天拉取商户账单把账单里的退款记录与本地的退款单逐条核对金额和状态不一致的要及时告警。6. 从能退款到好退款日志、验证与自动化回归6.1 把请求和响应的“痕迹”留下来退款接口调通后最先要做的事是在发送前、发送后各打一条结构化日志。发送前记录out_trade_no、out_refund_no、refundFee、totalFee、签名用的 serial_no发送后记录HTTP 状态码、返回的 refund_id、status、响应原文。响应原文一定要留微信返回的报错信息里经常带 err_code 和 err_msg比如“AMOUNT_OVERDUE”或者“PARAM_ERROR”这些是排查问题的第一手材料。日志里不要打签名串全文签名串里含商户私钥产生的签名值泄露了会被伪造请求打一个摘要或只打前 8 位即可。6.2 我建议的验证路径全额退、部分退、状态轮询上线前我会用测试商户号配真实的小程序走一遍完整流程先支付一笔 0.01 元的订单确认支付回调到达然后发起全额退款确认退款回调到达且订单状态变为已退款再支付一笔发起部分退款确认剩余金额可查、可用最后模拟一次退款提交后没有收到回调的场景用查单接口主动拉取状态确认轮询逻辑能把订单拉到终态。这三条路径跑通后把退款回调接口的验签逻辑单独抽成单元测试用真实回调报文做固定样例防止后续改动把验签弄坏。这套流程跑顺之后线上反而很少需要人工介入退款真正要盯的是对账脚本和异常退款单列表。我自己的教训是第一次做退款时把 PROCESSING 当成“已经退了”结果运营发券发早了后来强制要求所有状态变化必须来自微信侧确认人工操作只能发起退款不能直接改终态。这个原则帮我挡掉了好几次重复退款的风险。希望帮到你。本文还有配套的精品资源点击获取
返回列表