ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

微信支付V3退款实战:从签名封装到回调验签的完整链路

微信支付V3退款实战:从签名封装到回调验签的完整链路 简介一份面向Java开发者的微信支付V3小程序退款实现资料包适合正在接入微信支付、需要快速落地退款流程的后端研发与运维人员。包体共4个文件以txt源码/说明文件为主另含1个properties配置文件整体仅6KB。txt文件承载退款核心类、控制层示例与依赖清单properties文件用于维护商户号、证书序列号、APIv3密钥等配置信息结构紧凑便于在调试环境中对照迁移。已有4693人学习下载作为轻量型支付代码片段尤其适合用于快速验证退款链路、梳理V3版本接口调用逻辑。内容基于官方V3接口整理覆盖Access Token获取、退款请求发起、结果校验、回调通知、错误重试及小程序端联动处理等关键环节并突出签名规则、请求参数结构、退款状态判断和日志记录等易错点能帮助开发者绕开接口联调中的常见坑位并在此基础上扩展为生产可用的退款模块。1. 把微信支付 V3 退款跑通先搞懂流程再写代码做小程序支付开发的都知道支付只是第一步真正让人头疼的是退款。支付接口网上教程一抓一把退款 V3 版本却常常让新手卡在签名、证书和回调验签上。这套 wxpayV3 资源把 Java 微信支付小程序退款的完整链路拆开了从商户私钥签名、HTTP 请求封装到退款状态落库、回调通知验签都给了可直接改的代码。适合手里有小程序商城、需要尽快上线退款功能、但又不想从零啃官方文档的 Java 后端开发者。我拆完这套代码最直观的感受是V3 和 V2 的退款逻辑差别不大核心差异在请求签名方式和回调验签机制上这两个点搞通了其他都是配置问题。2. 微信支付 V3 的加签与请求核心商户私钥、证书序列号与 HttpClient 封装2.1 为什么 V3 的签名方式和 V2 完全不同微信支付 V2 用的是 MD5 或 HMAC-SHA256 签名参数按字典序拼接后再加密。V3 换成了 RSA-SHA256 签名机制而且要放在 HTTP 头的 Authorization 字段里。这个改动让很多从 V2 迁移过来的老代码直接失效。V3 的签名规则并不复杂构造签名串、用商户私钥做 SHA256 摘要后 RSA 加密、把签名结果连同商户号和证书序列号一起拼进 Authorization。难点在平台证书的处理上——V3 的响应和回调都用平台证书的公钥验签而商户私钥只用来请求签名这两个角色分清楚就不容易晕。这套 wxpayV3 资源里的代码把签名过程封装得很干净拿到 MerchantPrivateKey 之后核心只需要关心请求参数和 URL签名细节全部收敛到一个工具类里。这也是我推荐照着它改的原因之一改动面小适合接进已有的支付服务。2.2 从 pom 依赖到手写 HttpClient 请求封装先看资源里的 pom 依赖微信支付 V3 官方推荐用 wechatpay-java SDK但很多老项目还在用 httpclient 4.5.x没必要为了一个退款接口把整个 HTTP 层换掉。这里拆出来的做法是用 httpclient 4.5 jackson 自己封装请求。!-- 微信支付 V3 退款所需的最小依赖集 -- dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpclient/artifactId version4.5.13/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.13.4/version /dependency dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15to18/artifactId version1.70/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.24/version /dependencyhttpclient 负责连接管理jackson 负责 JSON 序列化bcprov 用来读取 PKCS8 格式的商户私钥。如果你项目里已经有这些依赖直接复用即可不需要重复引入。注意 bcprov 的版本1.70 之后对 RSA 加解密的支持更完整老版本在解析某些证书格式时会有兼容问题。接下来是签名工具类的核心代码。这段代码解决的是「构造签名串 → SHA256withRSA 签名 → 拼 Authorization」的完整过程是整个退款请求的发起点。public class WechatPayV3Signer { private final String mchId; // 商户号 private final String merchantSerialNo; // 商户证书序列号 private final PrivateKey merchantPrivateKey; public WechatPayV3Signer(String mchId, String merchantSerialNo, PrivateKey merchantPrivateKey) { this.mchId mchId; this.merchantSerialNo merchantSerialNo; this.merchantPrivateKey merchantPrivateKey; } // method: GET/POST, canonicalUrl: 去掉域名部分的路径(带query), body: POST请求体 public String buildAuthorization(String method, String canonicalUrl, String body) { long timestamp System.currentTimeMillis() / 1000; String nonceStr UUID.randomUUID().toString().replace(-, ).substring(0, 32); String message method \n canonicalUrl \n timestamp \n nonceStr \n body \n; String signature rsaSign(message, merchantPrivateKey); return WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonceStr \, timestamp\ timestamp \, serial_no\ merchantSerialNo \, signature\ signature \; } private String rsaSign(String message, PrivateKey privateKey) { try { Signature sign Signature.getInstance(SHA256withRSA); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(sign.sign()); } catch (Exception e) { throw new RuntimeException(商户私钥签名失败, e); } } }这里有个容易搞错的点canonicalUrl 是去掉域名后的路径如果 URL 带查询参数需要把 query string 也拼进去。例如退款查询接口https://api.mch.weixin.qq.com/v3/refund/domestic/refunds/outRefundNocanonicalUrl 就是/v3/refund/domestic/refunds/outRefundNo。签名串里最后一定要跟一个换行符很多签名失败都是因为漏了这个\n。请求封装我直接用了最直接的方式一个doRequest方法接收 HTTP method、路径和 JSON body内部先构建 Authorization再发请求、解析响应。这样退款接口和退款查询接口都能复用同一套逻辑不用各自写一遍。2.3 私钥加载与平台证书的关系别把这两个混为一谈加载商户私钥的代码很机械但非常重要。私钥文件是从微信支付商户平台下载的 apiclient_key.pem注意下载格式是 PKCS8直接读入后用KeyFactory转成PrivateKey对象。public static PrivateKey loadPrivateKey(String privateKeyPath) { try (BufferedReader reader new BufferedReader(new FileReader(privateKeyPath))) { StringBuilder keyContent new StringBuilder(); String line; while ((line reader.readLine()) ! null) { if (line.contains(BEGIN PRIVATE KEY) || line.contains(END PRIVATE KEY)) { continue; } keyContent.append(line.trim()); } byte[] encoded Base64.getDecoder().decode(keyContent.toString()); PKCS8EncodedKeySpec keySpec new PKCS8EncodedKeySpec(encoded); KeyFactory keyFactory KeyFactory.getInstance(RSA); return keyFactory.generatePrivate(keySpec); } catch (Exception e) { throw new RuntimeException(加载商户私钥失败请检查 apiclient_key.pem 文件, e); } }商户私钥用于请求签名平台证书公钥用于响应验签和回调解密两者不可混淆。你可以同时持有多个密钥对象一个WechatPayV3Signer管出站请求签名一个CertificateVerifier管入站请求的验签。很多开发者的第一反应是「我用商户私钥去验回调签名」这是不对的——回调请求是用平台私钥签的必须用平台证书的公钥去验。2.4 实际请求中必须处理的 Content-Type 与 Accept 头除了 Authorization微信支付 V3 还强制要求Content-Type: application/json和Accept: application/json。漏掉 Accept 头时接口通常会返回 415 或 406这个错误信息很容易误导人看起来像签名问题实际是 HTTP 头没带全。public String doPost(String path, String body) throws IOException { String url https://api.mch.weixin.qq.com path; HttpPost httpPost new HttpPost(url); httpPost.setHeader(Content-Type, application/json); httpPost.setHeader(Accept, application/json); httpPost.setHeader(Authorization, signer.buildAuthorization(POST, path, body)); httpPost.setEntity(new StringEntity(body, StandardCharsets.UTF_8)); try (CloseableHttpResponse response httpClient.execute(httpPost)) { String respBody EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); if (response.getStatusLine().getStatusCode() ! 200) { // 记录响应体方便排查 throw new RuntimeException(退款请求失败, status response.getStatusLine().getStatusCode() , body respBody); } return respBody; } }这段代码的关键点path 变量在传给HttpPost和buildAuthorization时必须是同一个字符串确保签名串和实际请求 URL 完全一致。如果你在构建 URL 时拼接了 query 参数签名用的 canonicalUrl 也要包含同样的 query否则微信服务端按请求 URL 重新拼的签名串和你本地不一致直接报签名错误。3. 小程序退款主流程从退款参数到状态落库的实现3.1 退款接口的入参设计金额单位别再搞错微信支付 V3 退款接口地址是POST https://api.mch.weixin.qq.com/v3/refund/domestic/refunds和摘要里提到的旧地址refundments/orders不是同一个。以官方文档当前版本为准接口路径是/v3/refund/domestic/refunds。请求体至少包含这几个字段out_trade_no商户订单号、out_refund_no商户退款单号、amount退款金额对象。其中amount里包含refund退款金额、total订单总金额、currency币种默认 CNY。{ out_trade_no: 20250101120000001, out_refund_no: REF20250101120000001, reason: 用户申请退货退款, amount: { refund: 1, total: 1, currency: CNY } }这里最常翻车的点是金额单位。V3 接口的单位是「分」不是「元」refund和total都必须是整数。如果前端传到后端的是「元」字符串如 1.00后端一定要先转成 int 分再传微信。我习惯在后端统一用BigDecimal计算后multiply(100).intValue()处理避免浮点数精度问题。3.2 Controller 层接口设计参数校验、幂等键与异步响应退款接口建议设计成同步返回退款受理结果但最终退款状态以回调为准。Controller 层主要做三件事参数校验、生成退款单号、调用 service 层发起退款。RestController RequestMapping(/api/pay/refund) public class WechatPayV3Controller { Autowired private WechatPayV3Service refundService; PostMapping(/apply) public ResultRefundApplyResponse applyRefund(RequestBody Valid RefundApplyRequest request) { // 参数校验退款金额必须小于等于订单金额且大于 0 if (request.getRefundFee() 0) { return Result.error(退款金额必须大于0); } // outRefundNo 建议用业务单号 时间戳防重 String outRefundNo request.getOrderNo() _ System.currentTimeMillis(); RefundApplyResponse response refundService.applyRefund(request, outRefundNo); return Result.ok(response); } }退款单号的生成策略是个容易忽略的点。如果同一笔订单用户多次发起退款而你的out_refund_no一样微信会直接报「退款单号已存在」而不是退两次款。从业务上讲这其实是防重保护但从用户体验上讲直接报错会让人困惑。所以我习惯用订单号加时间戳拼 out_refund_no或者至少在前端控制按钮的重复提交。3.3 Service 层实现发起退款、记录日志、保存退款单Service 层负责把上面的请求封装串起来组建退款请求体、调微信接口、解析响应、落库保存退款单状态。这套代码里的WechatPayV3Service已经把完整闭环写好了我拆一个核心方法出来看看。public RefundApplyResponse applyRefund(RefundApplyRequest request, String outRefundNo) { MapString, Object body new HashMap(); body.put(out_trade_no, request.getOrderNo()); body.put(out_refund_no, outRefundNo); body.put(reason, request.getReason()); MapString, Object amount new HashMap(); amount.put(refund, request.getRefundFee()); amount.put(total, request.getTotalFee()); amount.put(currency, CNY); body.put(amount, amount); String jsonBody JSON.toJSONString(body); log.info(发起微信退款, outTradeNo{}, outRefundNo{}, jsonBody{}, request.getOrderNo(), outRefundNo, jsonBody); // 调用微信退款接口 String respBody httpClient.doPost(/v3/refund/domestic/refunds, jsonBody); log.info(微信退款响应, outRefundNo{}, resp{}, outRefundNo, respBody); WechatRefundResponse resp JSON.parseObject(respBody, WechatRefundResponse.class); // 落库保存退款单初始状态为 PROCESSING RefundOrder refundOrder new RefundOrder(); refundOrder.setOutTradeNo(request.getOrderNo()); refundOrder.setOutRefundNo(outRefundNo); refundOrder.setRefundFee(request.getRefundFee()); refundOrder.setStatus(resp.getStatus()); refundOrder.setRefundId(resp.getRefundId()); refundOrderMapper.insert(refundOrder); return new RefundApplyResponse(resp.getRefundId(), resp.getStatus()); }这段代码在发起退款后立刻把退款单落库状态初始化为微信返回的PROCESSING退款受理中。响应的status字段有三种取值SUCCESS表示退款成功、CLOSED表示退款关闭、PROCESSING表示退款处理中。同步接口拿到的通常都是PROCESSING最终成功的确认一定来自回调或主动查询这个状态机心里要有数。3.4 退款查询接口给前端一个主动获取状态的路径回调不是百分百可靠的网络抖动、服务重启、消息丢失都可能发生所以退款查询接口是必备的「后悔药」。微信提供了按商户退款单号查询的接口GET /v3/refund/domestic/refunds/{out_refund_no}。public WechatRefundResponse queryRefund(String outRefundNo) { String path /v3/refund/domestic/refunds/ outRefundNo; String respBody httpClient.doGet(path); WechatRefundResponse resp JSON.parseObject(respBody, WechatRefundResponse.class); // 查到了状态有变化就更新数据库 if (!PROCESSING.equals(resp.getStatus())) { refundOrderMapper.updateStatusByOutRefundNo(outRefundNo, resp.getStatus()); } return resp; }查询接口同样需要签名只是 HTTP method 换成 GET请求体为空串。注意 doGet 方法里构造签名串时body 参数传而不是null否则签名串拼接会出问题。这个查询接口建议做成定时任务兜底比如每 5 分钟扫一次处理中的退款单批量查询防止回调丢失导致的退款单永久挂起。4. 退款回调验签与幂等处理别把系统做成黑匣子4.1 回调报文结构拿到的是密文先解密再验签微信支付 V3 的回调通知有一个重要特性回调请求体的resource字段是加密的需要用 APIv3 密钥32 位字符串解密后才能拿到明文退款结果。很多新手第一次调回调接口打印日志发现全是密文还以为微信返回的是乱码。解密逻辑在资源里已经封装好了。核心步骤是取resource.ciphertext用 APIv3 密钥和resource.nonce、resource.associated_data做 AES-256-GCM 解密。注意这个nonce是微信返回的不是你自己生成的。public String decryptResource(Resource resource) { try { // APIv3 密钥必须是 32 字节 SecretKeySpec key new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), AES); byte[] ciphertext Base64.getDecoder().decode(resource.getCiphertext()); byte[] associatedData resource.getAssociatedData().getBytes(StandardCharsets.UTF_8); byte[] nonce resource.getNonce().getBytes(StandardCharsets.UTF_8); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); GCMParameterSpec spec new GCMParameterSpec(128, nonce); cipher.init(Cipher.DECRYPT_MODE, key, spec); cipher.updateAAD(associatedData); byte[] plaintext cipher.doFinal(ciphertext); return new String(plaintext, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException(回调报文解密失败, e); } }解密拿到的是 JSON 字符串包含out_refund_no、refund_status、success_time等字段这些字段用于更新退款单状态。解密失败基本是 APIv3 密钥配错了去商户平台核对一遍 32 位密钥是否完全一致即可。4.2 回调验签顺序先验签、再解密、最后更新业务状态回调处理的顺序不能乱先验证微信签名确认消息确实来自微信然后解密拿到明文再根据明文里的退款状态更新数据库。如果先解密再验签万一验签失败你已经在解密上花了开销而且解密用的 APIv3 密钥和验签用的平台证书是两套东西逻辑上应该先做认证再做数据解析。验签的对象是请求头Wechatpay-Signature里的 base64 签名值待验签的签名串由Wechatpay-Timestamp、Wechatpay-Nonce、请求体三部分拼成最后同样需要一个换行符。这个验签过程可以用之前加载的平台证书公钥来做。public boolean verifyCallback(HttpServletRequest request, String body) { String timestamp request.getHeader(Wechatpay-Timestamp); String nonce request.getHeader(Wechatpay-Nonce); String signature request.getHeader(Wechatpay-Signature); String serialNo request.getHeader(Wechatpay-Serial); // 先用 serialNo 找到对应平台证书 PublicKey publicKey platformCertificateProvider.getCertificate(serialNo).getPublicKey(); String message timestamp \n nonce \n body \n; try { Signature sign Signature.getInstance(SHA256withRSA); sign.initVerify(publicKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return sign.verify(Base64.getDecoder().decode(signature)); } catch (Exception e) { log.error(回调验签失败, e); return false; } }Wechatpay-Timestamp还有个隐藏功能防重放攻击。微信要求回调时间戳与当前时间差超过 5 分钟就拒绝处理。我见过有项目把回调接口写得像黑匣子收到就更新状态结果被第三方模拟回调恶意刷接口所以时间戳校验一定不能省。4.3 幂等处理回调重复到达时只更新一次状态微信支付回调至少一次投递极端情况下同一笔退款订单会收到多次回调。如果不在业务层做幂等可能出现重复更新数据库的问题。解决方式很简单查退款单当前状态如果已经是终态SUCCESS 或 CLOSED直接忽略本次回调。public void handleRefundCallback(RefundNotifyData notifyData) { String outRefundNo notifyData.getOutRefundNo(); RefundOrder order refundOrderMapper.selectByOutRefundNo(outRefundNo); if (order null) { log.error(退款单不存在, outRefundNo{}, outRefundNo); return; } // 已经处理成功幂等直接返回 if (SUCCESS.equals(order.getStatus()) || CLOSED.equals(order.getStatus())) { log.info(退款单已是终态, 忽略重复回调, outRefundNo{}, status{}, outRefundNo, order.getStatus()); return; } // 校验退款金额是否一致防止超退 if (!order.getRefundFee().equals(notifyData.getRefundFee())) { log.error(回调退款金额与退款单不一致, outRefundNo{}, outRefundNo); return; } order.setStatus(notifyData.getRefundStatus()); order.setSuccessTime(notifyData.getSuccessTime()); refundOrderMapper.updateById(order); }这里还顺带做了退款金额校验。回调里的refund_fee必须等于本地退款单里的金额不一致说明数据异常不能直接更新状态。这种防御性检查在支付系统里非常有必要宁可多打一条错误日志也不能让异常数据流入下游。回调处理成功后接口要返回微信要求的响应格式HTTP 200 { code: SUCCESS, message: 成功 }。如果返回非 200 或超时微信会隔一段时间重新投递回调直到收到成功响应。所以处理完业务逻辑后字符串常量直接返回即可。PostMapping(/notify) public String refundNotify(HttpServletRequest request, RequestBody String body) { boolean valid callbackVerifier.verifyCallback(request, body); if (!valid) { return {\code\:\FAIL\,\message\:\验签失败\}; } // 解密并处理退款通知 RefundNotifyData notifyData callbackService.decryptAndParse(body); callbackService.handleRefundCallback(notifyData); return {\code\:\SUCCESS\,\message\:\成功\}; }注意这里RequestBody String body拿的是原始字符串不能用 DTO 直接接。因为验签时需要原始的请求体字符串来拼接签名串如果用框架帮你反序列化成对象后再转 String顺序可能变化导致验签失败。这是回调开发中最隐蔽的坑之一。5. 避坑清单签名错误、证书过期与重复退款的常见排查5.1 报错「签名错误」但 Authorization 看起来没问题现象请求微信退款接口返回 401错误信息Invalid request signature但代码里签名逻辑检查了很多遍没发现问题。原因最常见的两个原因一个是签名串里的canonicalUrl带了域名或者多了斜杠比如写成了https://api.mch.weixin.qq.com/v3/refund/domestic/refunds或/v3/refund/domestic/refunds/而微信是按去掉域名的标准路径/v3/refund/domestic/refunds来拼签名串的另一个是 POST 请求体在签名时和实际发出去的不一致——比如用 Map 转 JSON 时字段顺序变了或者签名时传的是空字符串实际请求体却有内容。解决在签名前打印完整的签名串形如POST\n/v3/refund/domestic/refunds\n1710000000\nnonce\n{json}\n把拼出来的串和微信文档逐字符对照。再看发送请求时 body 是否和签名时用的一致建议在同一个方法里生成 body 字符串后同时用于签名和发送不要分两次序列化。5.2 报错「证书序列号不存在」现象请求微信接口返回 401错误信息类似The certificate serial_no is invalid但证书序列号明明是从商户平台复制的。原因这个 serial_no 是商户 API 证书的序列号不是平台证书的序列号也不是证书文件的文件名。很多人在商户平台下载证书后把 apiclient_cert.pem 打开看到的Certificate Serial Number当成序列号那个格式带有冒号分隔符直接用会报错。解决序列号要去微信支付商户平台 → API 安全 → API 证书里复制或者在商户平台下载的证书文件中用openssl x509 -in apiclient_cert.pem -noout -serial命令提取把输出里的serial后面的十六进制字符串复制出来去掉可能的冒号填入配置。证书续期后序列号也会变记得同步更新代码里的配置。5.3 回调验签一直失败但用工具测签名又是对的现象线下用签名工具生成的 Authorization 能通过测试但回调接口始终验签失败日志里verifyCallback返回 false。原因回调验签和请求签名用的证书不同。请求签名用的是商户私钥回调验签用的是微信支付平台证书公钥。如果你在回调验签代码里误用了商户证书的公钥去验大概率是失败的另外平台证书会定期轮换旧证书过期后没有更新到本地也会导致验签不通过。解决建议在回调处理逻辑里增加证书自动更新机制用微信提供的证书下载接口/v3/certificates定时拉取最新平台证书按Wechatpay-Serial缓存在内存或本地。如果不想引入自动更新至少要在商户平台手动下载最新证书并替换掉配置里的旧证书文件然后重启服务。替换后记得把序列号一起更新。5.4 用户明明只申请退一次款却收到了两笔退款现象用户在小程序端点了退款过一会儿发现银行卡收到了两笔相同金额的退款后台退款单也出现了两条记录。原因前端退款按钮没有做防重复提交用户双击或网络延迟时发起了两次退款请求后端又没有按out_refund_no做唯一约束或幂等校验导致同一笔订单发起了两笔微信退款。虽然微信侧也有防重机制但使用不同退款单号时它会把两笔都当作合法请求来处理。解决后端生成退款单号时强制在同一订单的维度上加分布式锁或数据库唯一索引out_refund_no唯一在 applyRefund 方法入口先查一次本地退款单是否存在存在直接返回已有状态。前端也要在收到响应前禁用退款按钮双保险才能防止这类资金事故。5.5 测试环境一切正常上线后退款一直卡在 PROCESSING现象本地联调退款接口响应正常回调也能收到但生产环境退款单一直停在 PROCESSING 状态没有变成 SUCCESS。原因生产环境和测试环境用的回调 URL 配置不同但回调 URL 没在商户平台正确配置成外网可访问的 HTTPS 地址或者生产环境的 APIv3 密钥和测试环境不一致生产回调报文解密失败后你只打了日志没报警退款单就永久卡住了。解决上线前在商户平台确认「支付回调通知」和「退款回调通知」的 URL 都配置为生产环境的正式地址且该地址外网可以访问。另外把回调解密失败日志级别设为 ERROR并配合告警监控确保回调处理链路异常时能第一时间感知。再加一个定时任务兜底扫描超过 10 分钟还在 PROCESSING 的退款单主动调用退款查询接口刷新状态这是止损的最后一根稻草。6. 把假数据当试金石本地模拟微信回调验证全链路6.1 用 Java 直接构造回调报文做联调开发期最痛苦的事是微信回调只在退款状态变更时触发而沙箱环境里退款流程走得太快回调经常一闪而过抓不到调试时机。我的做法是自己写一个模拟回调的测试方法构造和微信相同的回调报文打到本地 notify 接口上这样就能稳定地验证从验签到解密再到状态落库的全链路。public void mockRefundNotify(RefundOrder order) { // 构造微信回调通知的明文内容 RefundNotifyResource resource new RefundNotifyResource(); resource.setOutRefundNo(order.getOutRefundNo()); resource.setRefundStatus(SUCCESS); resource.setRefundFee(order.getRefundFee()); resource.setSuccessTime(LocalDateTime.now().toString()); // 加密 resource 字段实际直接用明文构造也行验签通过即可 String plaintext JSON.toJSONString(resource); String ciphertext encryptByApiV3Key(plaintext); // 用本地 APIv3 密钥加密 // 构造回调请求体 MapString, Object notifyBody new HashMap(); MapString, Object resourceObj new HashMap(); resourceObj.put(ciphertext, ciphertext); resourceObj.put(nonce, randomNonce()); resourceObj.put(associated_data, refund); notifyBody.put(resource, resourceObj); // 用平台证书私钥生成签名需要下载平台私钥做测试用 String body JSON.toJSONString(notifyBody); String timestamp String.valueOf(System.currentTimeMillis() / 1000); String nonce UUID.randomUUID().toString(); String message timestamp \n nonce \n body \n; String signature signByPlatformPrivateKey(message); // 模拟请求头 HttpHeaders headers new HttpHeaders(); headers.add(Wechatpay-Timestamp, timestamp); headers.add(Wechatpay-Nonce, nonce); headers.add(Wechatpay-Signature, signature); headers.add(Wechatpay-Serial, platformSerialNo); // 发送到本地 notify 接口 restTemplate.postForEntity(http://localhost:8080/api/pay/refund/notify, new HttpEntity(body, headers), String.class); }这个方法的妙处在于它完全绕开了微信沙箱环境的不确定性。本地造的数据能反复触发同一场景比如金额不一致、验签失败、重复回调这些问题在生产环境很难稳定复现但在 mock 场景下想跑几次跑几次。我每次改完回调处理的代码都会先用这个方法把终态、重复回调、验签失败三种场景各跑一遍确认通过后再提交联调。6.2 用 curl 模拟一个完整退款闭环有些时候不想写 Java 代码用 curl 配合脚本也能模拟大部分场景。核心思路是先直接调微信沙箱退款接口拿一个真实的 PROCESSING 状态的退款单再手动把微信回调的报文改成自己想要的状态模拟推给本地服务。# 1. 先发起一笔真实的退款请求 curl -X POST https://api.mch.weixin.qq.com/v3/refund/domestic/refunds \ -H Authorization: $AUTH_HEADER \ -H Content-Type: application/json \ -d {out_trade_no:20250101120000001,out_refund_no:REF20250101120000001,amount:{refund:1,total:1,currency:CNY}} # 2. 模拟微信回调替换成你的回调地址 curl -X POST http://localhost:8080/api/pay/refund/notify \ -H Wechatpay-Timestamp: $(date %s) \ -H Wechatpay-Nonce: testnonce123 \ -H Wechatpay-Signature: generated_by_test_script \ -H Wechatpay-Serial: platform_cert_serial_no \ -H Content-Type: application/json \ -d {resource:{ciphertext:...,nonce:...,associated_data:refund}}这套 curl 方案适合快速验证回调接口通不通、返回值对不对但验签和加解密逻辑还是得用真实代码跑一遍才算数。我的习惯是 Java mock 方法和 curl 配合着来mock 方法负责验证完整链路curl 负责验证接口响应格式和 HTTP 状态码。两种手段都在本地稳定跑通之后再去商户平台发起真实测试退款那种「一把过」的顺畅感是直接对着微信接口瞎试完全比不了的。从那以后我每次接新的支付渠道都强制把模拟回调作为联调前的标配动作省下的时间远大于写这几行假数据的成本。希望帮到你。本文还有配套的精品资源点击获取
返回列表