资讯动态

微信支付V3退款接口Java实战:签名、回调解密与幂等落库

发布时间:2026/10/16 8:08:25 来源:尧图企业网站定制
简介面向Java后端开发者的微信退款接口示例工程完整覆盖基于HTTPS与PKCS12证书调用微信退款API的关键链路包括KeyStore加载商户证书、SSLContext初始化、创建带证书的HttpClient、组装退款参数、发送POST请求并解析JSON响应适合正在对接微信支付且需要实现退款功能的项目参考。压缩包共29个文件整体约1.92MB包含10个jar依赖、6个java源码与6个class编译文件还附有配置文件、jsp页面与MyEclipse工程信息导入开发环境即可直接查看代码结构。已有870人浏览学习是验证退款接口调用逻辑的实用范例。示例中给出了RSA2048签名与证书处理的实现思路可帮助开发者理解商户订单号、退款单号、退款金额等参数的传递方式同时可参考其中的超时设置与异常响应处理来规避证书配置错误和网络延迟等常见问题减少对接过程中的调试成本。1. 从一笔退款对账开始这套代码解决了什么“退款单对不上账”在电商后端是最高频的翻车现场用户在 App 上点了退款钱原路退回了订单库里却还挂着待退款财务对账时差出来几毛钱查了半天发现是微信退款接口的金额单位、回调幂等或者状态更新顺序出了问题。这套 Java 微信退款接口项目就是一套把微信支付 V3 退款从证书加载、签名构造、退款申请到异步回调验签解密完整串起来的后台代码基于 Spring Boot 实现拿来就能跑通“发起退款→微信扣款→回调通知→状态落库”的整条链路。适合正准备对接退款能力、或者想把手写签名和回调解密彻底搞明白的 Java 后端开发者既有能直接复制的核心实现也有能撑住生产环境的模块拆分。2. 架构设计V3 协议、工程结构与关键配置2.1 为什么选 API v3 而不是 v2微信支付的退款接口有 v2 和 v3 两套协议这个项目选的是 v3。v2 时代用 MD5 或 HMAC-SHA256 做签名配一个 API 密钥就能请求接入门槛低但资金类接口把密钥放在请求里本身就容易出问题v3 改成了证书签名所有请求要用商户私钥对“请求方法 路径 时间戳 随机串 请求体”做 RSA 签名回调报文还会用 AEAD_AES_256_GCM 做加密传输整体安全模型完全不一样。对退款这种资金操作来说这个升级是实打实的v3 就算请求被截获没有商户私钥伪造不了签名回调报文就算被看到没有 APIv3 密钥解不开密文。另一个现实原因是新商户的证书体系默认就走 v3老项目如果还在 v2 上改退款迟早要迁过来。这个项目的代码结构从一开始就是按 v3 设计的省掉了二次迁移的成本。2.2 工程模块划分与包结构整个项目是一个标准的 Spring Boot 工程没有引入微信官方 SDK退款相关的逻辑全部自己写。包结构拆得比较清楚配置、核心加密、服务、控制器、DTO 各占一层随便挑一个点进去都能独立看明白。com.example.refund ├── config │ ├── WechatPayProperties.java # 配置项绑定商户号、证书路径、APIv3密钥 │ └── WechatPayConfig.java # 加载商户私钥、初始化平台证书管理器 ├── core │ ├── WechatRefundClient.java # 退款HTTP客户端发起请求、解析响应 │ ├── WechatSignUtil.java # 构造Authorization签名头 │ ├── AesGcmUtil.java # 回调报文GCM解密 │ └── WechatPlatformCertManager.java # 平台证书加载与轮换 ├── controller │ └── RefundController.java # 对外REST接口 ├── service │ ├── RefundService.java # 退款业务编排 │ └── RefundNotifyService.java # 回调验签、解密、幂等落库 ├── dto │ ├── RefundRequestDTO.java # 退款入参 │ ├── RefundResponseDTO.java # 退款出参 │ └── RefundNotifyDTO.java # 回调报文模型 ├── enums │ └── RefundStatusEnum.java # 退款状态枚举 └── exception └── RefundException.java # 业务异常core 包是整个项目的核心放的都是和微信支付协议直接相关的类不掺业务逻辑service 层负责把 core 的能力串成业务动作controller 只做参数接收和结果返回。这样拆的好处是以后如果公司要接多商户只需要动配置和 core 包的证书管理service 层基本不用改。2.3 关键配置项与依赖清单项目用到的第三方依赖集中在几个点HTTP 客户端用的 Apache HttpClientJSON 序列化用的 Jackson加密相关的 JDK 原生 JCE 够用没额外引 Bouncy Castle。pom 里核心依赖大概长这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.apache.httpcomponents.client5/groupId artifactIdhttpclient5/artifactId /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependency配置文件里需要关注的参数不多但每一个都直接决定能不能调通接口配置项示例值说明wechat.mch-id190000XXXX商户号商户平台申请wechat.mch-serial-no23C4D5E6...商户 API 证书序列号用于告诉微信你是哪张证书wechat.api-v3-key32 位随机字符串APIv3 密钥回调报文解密用wechat.private-key-path/etc/cert/apiclient_key.pem商户私钥文件wechat.platform-cert-path/etc/cert/platform_cert.pem微信支付平台证书验签用wechat.notify-urlhttps://api.example.com/refund/callback退款结果回调地址商户私钥和平台证书是两套不同的东西私钥是你的身份签名时用平台证书是微信的身份验签和回调解密时用。这个区别后面踩坑部分会再展开。配置项绑定用ConfigurationProperties(prefix wechat)就能接住代码层面不需要额外处理。3. 核心模块拆解证书、签名与退款请求的完整组装3.1 商户私钥加载PEM 解析这一步容易细节翻车退款接口每次请求都要用商户私钥做 SHA256withRSA 签名所以私钥加载是第一个要过的地方。微信提供的apiclient_key.pem是 PKCS#8 格式的 RSA 私钥Java 可以直接用PKCS8EncodedKeySpec解析但前提是 PEM 文件里的换行符和头尾标记要先处理干净。Bean public PrivateKey merchantPrivateKey(WechatPayProperties props) throws Exception { String pem new String(Files.readAllBytes( Paths.get(props.getPrivateKeyPath())), StandardCharsets.UTF_8); String privateKeyContent pem .replace(-----BEGIN PRIVATE KEY-----, ) .replace(-----END PRIVATE KEY-----, ) .replaceAll(\\s, ); byte[] keyBytes Base64.getDecoder().decode(privateKeyContent); PKCS8EncodedKeySpec spec new PKCS8EncodedKeySpec(keyBytes); KeyFactory kf KeyFactory.getInstance(RSA); return kf.generatePrivate(spec); }这里的思路是先把 PEM 内容读成字符串去掉头和尾的标记行再把所有空白字符包括换行和空格删掉最后做 Base64 解码。删空白这一步很多人会漏以为读进来就能用实际直接解析会报IOException: DerInputStream.getLength()这种异常看起来像环境问题其实就是 PEM 里的换行符没清干净。3.2 构造 Authorization 签名头五个字段拼一行微信 v3 接口的签名方式比较朴素把请求方法、URL 路径、时间戳、随机串、请求体五个字段用换行符拼成一个大字符串用私钥签名后放进 Authorization 头。这个签名串的顺序和格式有一点出错接口就会返回签名错误。public static String buildAuthHeader(String method, String canonicalUrl, String body, String merchantId, String serialNo, PrivateKey privateKey) throws Exception { long timestamp System.currentTimeMillis() / 1000; String nonce UUID.randomUUID().toString().replace(-, ); String message method \n canonicalUrl \n timestamp \n nonce \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()); return WECHATPAY2-SHA256-RSA2048 mchid\ merchantId \, nonce_str\ nonce \, signature\ signature \, timestamp\ timestamp \, serial_no\ serialNo \, sign_type\WECHATPAY2-SHA256-RSA2048\; }几个关键参数说明method是GET或POST必须大写canonicalUrl是带开头斜杠的路径部分比如/v3/refund/domestic/refunds不能带域名也不能带 query stringtimestamp是秒级时间戳和服务器时间偏差太大会被拒nonce每次请求都要重新生成最简单的方式就是用 UUID 再去掉横线body即使为空也要传空字符串否则签名串的换行数不对。返回的 Authorization 头里mchid、nonce_str、signature、timestamp、serial_no五个字段缺一不可。3.3 组装退款请求体和发送逻辑退款申请走的是POST /v3/refund/domestic/refunds接口请求体里最关键的是商户退款单号out_refund_no和金额对象amount。退款单号是幂等键重复提交同一单号微信会直接报错金额对账时就靠refund和total两个字段做核对。public RefundResponseDTO refund(RefundRequestDTO request) throws Exception { // 1. 组装请求体 MapString, Object body new HashMap(); body.put(out_trade_no, request.getOutTradeNo()); // 原支付订单号 body.put(out_refund_no, request.getOutRefundNo()); // 商户退款单号幂等键 body.put(notify_url, props.getNotifyUrl()); MapString, Object amount new HashMap(); amount.put(refund, request.getRefundAmount()); // 退款金额单位分 amount.put(total, request.getTotalAmount()); // 原订单金额单位分 amount.put(currency, CNY); body.put(amount, amount); // 2. 序列化请求体参与签名 String jsonBody mapper.writeValueAsString(body); String canonicalUrl /v3/refund/domestic/refunds; // 3. 生成签名头并发送 String auth WechatSignUtil.buildAuthHeader( POST, canonicalUrl, jsonBody, props.getMchId(), props.getMchSerialNo(), merchantPrivateKey); HttpPost post new HttpPost(API_HOST canonicalUrl); post.setHeader(Authorization, auth); post.setHeader(Content-Type, application/json); post.setHeader(Accept, application/json); post.setEntity(new StringEntity(jsonBody, StandardCharsets.UTF_8)); // 4. 解析响应失败时带上 HTTP 状态码和业务码 try (CloseableHttpResponse response httpClient.execute(post)) { String respBody EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); if (response.getCode() ! 200) { throw new RefundException(退款申请失败, code response.getCode() , body respBody); } return mapper.readValue(respBody, RefundResponseDTO.class); } }这里要重点提一下金额的埋点微信支付所有金额字段都是整数分refund和total必须用Integer或Long接收不能用BigDecimal以外的类型做转换运算。还有一点很多人踩过部分退款时total一定传原订单总金额不要传本次退款金额微信要拿这个字段做校验传错了直接报参数错误。3.4 退款状态枚举与业务语义退款提交成功不代表钱已经退回用户账户微信返回的只是一个受理结果。真正要关注的退款状态有四个public enum RefundStatusEnum { SUCCESS, // 退款成功钱已原路退回 CLOSED, // 退款关闭通常因为超时或商户主动撤销 PROCESSING, // 退款处理中等待银行渠道返回 ABNORMAL // 退款异常需要人工介入排查 }状态流转的规则是退款申请提交后先进入PROCESSING正常情况会变SUCCESS如果超过时间没退成功可能变CLOSED出现渠道异常就是ABNORMAL。后端在做轮询或处理回调时必须按这个状态机来更新数据库记录不能收到一次回调就直接改成成功。4. 异步回调链路验签、GCM 解密与幂等落库4.1 回调通知为什么不能直接信退款完成后微信会往notify_url发一条POST请求把退款结果告诉你。但这条通知本质上和普通 HTTP 请求一样来源不一定可信所以回调处理的第一步永远不是读业务字段而是验签——用微信支付平台证书验证Wechatpay-Signature头是否合法。private boolean verifySignature(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 message timestamp \n nonce \n body \n; // 注意验签用的是微信支付平台证书不是商户证书 PublicKey publicKey platformCertManager.getCertificate(); Signature sign Signature.getInstance(SHA256withRSA); sign.initVerify(publicKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return sign.verify(Base64.getDecoder().decode(signature)); }验签通过后我们才能确认这条通知确实是微信发出来的。这里最容易搞反的一件事是自己的商户私钥用来签名请求微信的平台证书用来验证微信的签名两个证书的角色不能互换。4.2 回调报文解密AEAD_AES_256_GCM验签通过之后报文里resource字段带着的退款结果还是密文需要用 APIv3 密钥做 GCM 解密。解密参数全部在报文里nonce和associated_data从 resource 对象里取密钥是配置里的api-v3-key。public String decryptResource(NotifyResource resource) throws Exception { byte[] key apiV3Key.getBytes(StandardCharsets.UTF_8); byte[] nonce resource.getNonce().getBytes(StandardCharsets.UTF_8); byte[] associatedData resource.getAssociatedData() .getBytes(StandardCharsets.UTF_8); byte[] ciphertext Base64.getDecoder().decode(resource.getCiphertext()); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); GCMParameterSpec spec new GCMParameterSpec(128, nonce); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, AES), spec); cipher.updateAAD(associatedData); return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); }解密后的明文 JSON 里会有out_refund_no、refund_id、refund_status等字段这时候才进入真正的业务逻辑。AES/GCM/NoPadding的 tag 长度固定 128 位GCMParameterSpec第一个参数就传 128associatedData如果传错或漏传解密会直接抛AEADBadTagException这个异常是和解密失败绑定出现的排查时第一反应应该去看回调报文里的associated_data是否原样传进来了。4.3 幂等落库回调重复不可怕可怕的是重复处理微信支付的通知机制带有重试策略回调失败或超时后微信会隔一段时间重新投递所以同一笔退款结果可能收到多次。如果业务侧不防重退款记录表里会出现两条成功记录对账就乱了。-- 退款记录表对 out_refund_no 建唯一索引 ALTER TABLE refund_record ADD UNIQUE INDEX uk_out_refund_no (out_refund_no);落库逻辑写成先查后改还不够稳最好的做法是条件更新只有在当前状态还是PROCESSING的时候才允许改成SUCCESS用 SQL 的WHERE条件当作乐观锁UPDATE refund_record SET refund_status SUCCESS, wechat_refund_id #{refundId}, notify_time now() WHERE out_refund_no #{outRefundNo} AND refund_status IN (PROCESSING, ABNORMAL);如果更新行数为 0说明这条记录已经处理过了直接忽略本次通知。这样一来哪怕回调重复十次最终落库结果也只有一个终态。4.4 平台证书的加载与轮换微信支付平台证书不是永久的微信侧会做证书轮换上线时间久的商户一定会遇到平台证书过期导致验签失败的情况。常规做法是启动时加载一次本地证书文件同时定时任务每天去拉一次最新证书GET /v3/certificates 响应里带加密的平台证书解密后按 certificate_serial_no 留存更新时不要直接覆盖旧证书而是按序列号存多份验签时先用响应头里的Wechatpay-Serial找到对应证书找不到再试其他证书。我在生产环境见过因为证书轮换导致回调验签大面积失败的事故那之后我对所有证书类配置的统一原则就是永远不要假设证书是永久的更新逻辑和加载逻辑要解耦。5. 踩坑实录金额、签名和回调的六条血泪经验5.1 金额单位与单号幂等资金类接口最容易爆雷的地方第一个坑是金额单位错乱。现象前端传了0.01元后端处理完微信返回参数错误有的甚至退了 1 分变 1 元对账对不上。原因微信支付所有金额字段单位都是分Java 侧如果用double做运算会出现0.01 * 100 1.0000000000000002这种精度问题或者前端传的是元后端没换算直接透传。解决所有退款金额入参统一用Integer表示分只在 Controller 入口做一次元转分换算用BigDecimal相乘后取整过程中不让double参与运算。第二个坑是退款单号重复提交。现象业务方失败重试时重新生成了一个单号导致同一笔订单出现两条退款记录或者同一单号重试时微信返回ORDER_NOT_EXIST或REFUNDNO_ALREADY_EXIST。原因out_refund_no是微信侧的幂等键同一商户号下不能重复失败重试必须沿用第一次生成的单号。解决退款单号在服务端用订单号加两位流水号生成例如REF 订单ID 随机两位请求失败但不确定微信是否受理时先用同一单号查一次退款结果再决定是否重新提交。5.2 证书与回调链路网络环境和技术细节混合翻车第三个坑是回调收不到。现象退款成功了然后本地日志里等了一下午也没有收到通知甚至开始怀疑微信是不是没调。原因微信要求回调地址必须是公网可访问的 HTTPS 地址localhost、内网 IP 一律投递失败另外回调地址的域名没配 SSL 证书也会导致微信请求失败。解决生产环境用正式域名本地联调时可以把notify_url指向公网可达的临时映射地址把本机 8080 端口暴露出去但这种方式只适合联调不适合压测。第四个坑是验签时用了商户证书而不是平台证书。现象回调验签时好时坏线上环境偶尔有验签失败的告警。原因回调里的签名是微信用平台证书私钥生成的验签必须用平台证书公钥个别项目把验签和请求签名搞混拿着自己的私钥去验当然验不过。解决配置里区分private-key-path商户私钥发请求用和platform-cert-path平台证书验签用两个文件按角色写入配置注释写清楚谁是谁。第五个坑是签名串和实际请求路径不一致。现象本地测试退款能通部署到生产后签名校验一直不通过。原因签名串里的canonicalUrl是/v3/refund/domestic/refunds但如果通过网关反向代理时路径被改写或者代码里手写 URL 时带上了https://api.mch.weixin.qq.com前缀签名串就算不对。解决把canonicalUrl定义为常量和 HttpClient 的请求 URL 复用同一份配置不要一个路径写两遍。第六个坑是私钥文件被编辑过。现象生产环境私钥加载直接抛DerInputStream.getLength()查环境、查权限都没问题玄学一样。原因PEM 文件在 Windows 上被记事本或某些编辑器加工过换行符变成 CRLF甚至加了 BOM 头Java 解析时就不认了。解决下载证书后不要手工编辑直接用代码读取如果必须修改权限或路径先检查文件行尾是否正常读取时统一做空白字符清理。6. 二次开发查询兜底、对账表与多商户扩展6.1 给项目加上退款结果查询回调是异步通知不代表绝对可靠生产环境必须加一个主动查询的兜底通道。微信提供了GET /v3/refund/domestic/refunds/{out_refund_no}的查询接口查询请求的签名方式和退款申请完全一致只是方法变成GET请求体为空字符串参与签名。public RefundQueryResponseDTO query(String outRefundNo) throws Exception { String canonicalUrl /v3/refund/domestic/refunds/ outRefundNo; String auth WechatSignUtil.buildAuthHeader( GET, canonicalUrl, , // body 为空串不能传 null props.getMchId(), props.getMchSerialNo(), merchantPrivateKey); HttpGet get new HttpGet(API_HOST canonicalUrl); get.setHeader(Authorization, auth); get.setHeader(Accept, application/json); // 发送请求、解析响应与退款申请逻辑一致 }有了查询接口就可以写一个定时任务每 5 分钟扫一次退款记录表里状态还是PROCESSING的单子主动调用查询接口刷新状态。这个兜底逻辑上线后即使回调链路出问题退款状态最多延迟几分钟更新不会一直卡在中间状态。6.2 退款记录表设计二次开发时建议把退款记录从订单表里拆出来单独建表字段覆盖对账需要的关键信息字段名类型说明idbigint主键out_refund_novarchar(64)商户退款单号唯一索引out_trade_novarchar(64)原支付订单号refund_amountint退款金额单位分total_amountint原订单金额单位分refund_statusvarchar(20)状态PROCESSING/SUCCESS/CLOSED/ABNORMALwechat_refund_idvarchar(64)微信退款单号回调落库notify_timedatetime最后回调时间create_time/update_timedatetime审计字段6.3 扩展多商户时怎么改配置多商户系统最常见的改造是把单商户配置改成 Map。WechatPayProperties里把mch-id改成MapString, MerchantConfigMerchantConfig里包含商户号、序列号、私钥路径、平台证书路径发起退款时根据请求头或订单归属的商户号从上下文里取出对应的私钥和配置。这样一套代码可以服务多个商户号签名头和证书管理只要在 core 包做一层按商户维度切换。6.4 状态更新加入乐观锁约束最后把数据库更新加一道乐观锁。退款记录从PROCESSING到SUCCESS只能发生一次如果出现从SUCCESS变成CLOSED这类非法流转直接抛异常并告警。从那以后我每次对接微信退款接口都会强制走一遍这套检查清单金额单位是不是分、退款单号能不能幂等、回调验签用的是哪个证书、状态更新有没有条件约束。这套流程帮我挡掉了不少线上事故希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑