ARTICLE DETAIL

资讯详情

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

微信支付v3工具类实战:从证书签名到退款封装的Java接入指南

微信支付v3工具类实战:从证书签名到退款封装的Java接入指南 简介面向Java开发者的微信支付工具类V3版封装了微信支付、退款、交易状态查询及企业打款到个人零钱旧版等常用接口适合需要在企业项目中快速集成微信支付能力的后端开发者。这套工具类来自作者真实企业项目中的实践封装覆盖从下单、付款、退款到交易状态查询的完整闭环调用时只需传入对应业务参数即可。资源共7个文件包含5个Java源文件、1个Maven项目配置文件pom.xml及1个工程模块文件iml压缩包仅11KB代码体量精简便于阅读和按需裁剪。已有2277人学习下载说明该封装方案具备较高的复用参考价值。针对微信支付V3版的证书、签名与异步通知等常见处理代码中提供了可直接调用的方法并结合企业打款旧版实现为商户向个人零钱转账场景提供对照文件结构清晰可方便地移植到Spring等主流Java项目中也有助于二次开发与支付异常排查。1. 微信支付工具类v3版为什么它能省掉你三天的联调时间做过支付接入的人都知道微信支付从 API v2 升级到 v3 之后最直观的变化就是加密方式从 MD5 签名换成了 RSA 非对称签名数据格式也从 XML 变成了 JSON。但真正让开发头疼的不是协议本身而是每一个接口都要自己拼签名、管证书、序列化报文稍微有一点格式不对就是各种报错。很多 Java 项目里的支付代码充斥着重复的签名逻辑、硬编码的商户配置甚至有人直接把证书文件扔在 resources 目录下面换环境就得改代码重启。有个现成的微信支付 v3 工具类把支付、退款、交易状态查询、企业打款这几个高频操作统一封装好接支付就不是从零开始造轮子了而是照着工具类的口子把业务参数填进去。这套工具类解决的是 Java 服务端接入微信支付 v3 时最繁琐的公共部分证书的加载与刷新、请求的签名与验签、HTTP 调用的封装、异常信息的统一处理。它适合所有要接微信支付的 Java 项目不管是 Spring Boot 单体服务还是微服务架构拿到手之后只需要配置商户号、证书路径和 API 密钥就能在业务代码里用几行代码完成下单、退款、查状态和打款。本文会把这四个能力的实现思路、参数配置和容易翻车的细节全部拆开讲代码可以直接落地到你的项目里。2. 微信支付 v3 工具类的底层设计证书、签名与 HttpClient 的封装方式2.1 微信支付 v3 的加密模型与 v2 的差别微信支付 API v3 的核心变化是引入了微信支付平台证书所有请求需要用商户私钥签名微信服务器返回的响应和回调通知需要用平台证书验签。这个双向信任模型比 v2 的 MD5 对称签名安全得多但也复杂得多。商户私钥是你自己生成的用来证明请求确实来自你的服务器平台证书是微信签发的用来验证响应确实是微信返回的防止中间人伪造。v2 时代很多团队用微信支付时最粗暴的做法是把 API 密钥写死在代码里MD5 签名算出来拼到 XML 里发出去就行。v3 之后这条路走不通了因为 RSA 签名需要管理私钥文件平台证书还有一个自动轮换的机制——微信会不定期更新平台证书如果工具类不支持动态刷新证书某一天你的验签就会突然全部失败。好的工具类会把证书的加载、缓存、刷新这些问题都处理好业务方根本不用关心这些底层细节。2.2 证书与配置的加载私钥和平台证书分开管工具类的第一步是要把商户私钥和平台证书正确加载进内存。私钥是 PKCS8 格式的 PEM 文件在微信支付商户平台下载商户证书时会一并提供。加载商户私钥的标准做法是用 Java 的KeyFactory配合PKCS8EncodedKeySpec因为 Java 默认不支持直接读取 PKCS1 格式的私钥。public PrivateKey loadPrivateKey(String privateKeyPath) throws Exception { try (BufferedReader reader Files.newBufferedReader(Paths.get(privateKeyPath))) { String content reader.lines().collect(Collectors.joining(\n)); content content.replace(-----BEGIN PRIVATE KEY-----, ) .replace(-----END PRIVATE KEY-----, ) .replaceAll(\\s, ); byte[] keyBytes Base64.getDecoder().decode(content); PKCS8EncodedKeySpec keySpec new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory KeyFactory.getInstance(RSA); return keyFactory.generatePrivate(keySpec); } }这段代码负责把 PEM 格式的私钥文本转成 Java 的PrivateKey对象。逻辑上分三步读取文件内容、去掉 PEM 的头部尾部标记和空白字符、Base64 解码后交给 RSA 的 KeyFactory 生成密钥对象。参数说明privateKeyPath指向你的apiclient_key.pem文件路径这个文件在商户平台下载证书时一并提供一个商户号对应一个绝不能多个环境共用。平台证书的加载方式有两种一种是把平台证书文件下载下来放在本地定期手动更新另一种是启动时从微信支付平台证书下载接口拉取。做工具类时我一般建议先用本地文件方式跑通再考虑动态刷新。平台证书的下载接口本身需要先签名才能调用所以在初始接入阶段会有个先有鸡还是先有蛋的问题——解决方法是第一次从商户平台手动下载平台证书之后工具类里用这个证书去调用接口刷新新的证书。2.3 请求签名与 Authorization 头的组装微信支付 v3 每个 API 请求的Authorization头格式是固定的需要包含商户号、证书序列号、随机字符串、时间戳和签名值。签名串的内容是按照HTTP 方法\nURL路径\n时间戳\n随机串\n请求体\n的规则拼接的这个顺序不能乱任何一个字段变了签名就不通过。public String buildAuthorization(String method, String urlPath, String body) throws Exception { String nonceStr UUID.randomUUID().toString().replace(-, ); long timestamp System.currentTimeMillis() / 1000; String message method \n urlPath \n timestamp \n nonceStr \n (body null ? : body) \n; Signature signature Signature.getInstance(SHA256withRSA); signature.initSign(privateKey); signature.update(message.getBytes(StandardCharsets.UTF_8)); String signStr Base64.getEncoder().encodeToString(signature.sign()); return WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonceStr \, timestamp\ timestamp \, serial_no\ serialNo \, signature\ signStr \; }这段代码的唯一任务就是生成请求头里的签名串。method是 HTTP 方法urlPath是请求路径不含查询参数的部分body是要发送的 JSON 字符串。注意urlPath不能带域名也不能带 query string比如POST /v3/pay/transactions/jsapi只需要传/v3/pay/transactions/jsapi。这个工具方法会被后面的支付、退款、查询所有接口共用签名逻辑本身没有任何业务语义纯粹是协议层的封装。2.4 HttpClient 封装与应答的自动验签请求发出之后还有一个关键动作验签。微信支付 v3 的每个响应头里都带着Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature必须用平台证书对响应体做验签确认数据是微信返回的而不是某个中间人篡改过的。工具类的 HttpClient 封装里需要内置这个验签逻辑。public String execute(String method, String urlPath, String body) throws Exception { HttpRequest request buildRequest(method, urlPath, body); HttpResponseString response httpClient.send(request, HttpResponse.BodyHandlers.ofString()); verifyResponseSignature(response); return response.body(); } private void verifyResponseSignature(HttpResponseString response) throws Exception { String timestamp response.headers().firstValue(Wechatpay-Timestamp).orElse(); String nonce response.headers().firstValue(Wechatpay-Nonce).orElse(); String signature response.headers().firstValue(Wechatpay-Signature).orElse(); String message timestamp \n nonce \n response.body() \n; Signature sign Signature.getInstance(SHA256withRSA); sign.initVerify(platformCertificate); sign.update(message.getBytes(StandardCharsets.UTF_8)); boolean ok sign.verify(Base64.getDecoder().decode(signature)); if (!ok) { throw new SecurityException(微信支付响应验签失败); } }验签逻辑和请求签名是对称的把响应头里的时间戳、随机串、响应体按同样的换行规则拼接用平台证书的公钥做 SHA256withRSA 验签。这里有个细节验签用的证书序列号要再取响应头的Wechatpay-Serial和本地证书序列号比对不一致的时候说明微信换证书了需要触发证书刷新流程。这一段是整个工具类里最容易漏掉的部分很多人在联调时收到 401 或验签失败基本都是卡在这里。3. 支付与退款双向闭环统一下单、回调解密与退款接口的实现3.1 JSAPI 下单与参数的坑微信支付的业务场景通常分 JSAPI 支付公众号/小程序内支付、Native 支付扫码、App 支付和 H5 支付。工具类里最常用的是 JSAPI 下单它要求客户端先通过微信登录拿到用户的 openid然后服务端用这个 openid 发起下单请求拿到prepay_id之后再生成支付参数给前端调起微信支付。public String createJsapiOrder(String openId, String orderNo, Integer totalFee, String description) throws Exception { JSONObject params new JSONObject(); params.put(appid, appId); params.put(mchid, mchId); params.put(description, description); params.put(out_trade_no, orderNo); params.put(notify_url, notifyUrl); params.put(amount, new JSONObject().put(total, totalFee)); JSONObject payer new JSONObject().put(openid, openId); params.put(payer, payer); String body params.toJSONString(); String response execute(POST, /v3/pay/transactions/jsapi, body); JSONObject result JSONObject.parseObject(response); return result.getString(prepay_id); }调用下单接口之后拿到的prepay_id还不能直接返回给前端要在服务端把它和 appId、时间戳、随机串、签名串一起组成小程序端wx.requestPayment需要的参数。这个二次签名用的是商户私钥签名串格式是appId\n时间戳\n随机串\nprepay_id\n很多新手在这里直接用下单接口返回的prepay_id去调支付结果前端一直报签名错误。3.2 回调通知的解密与验签流程支付成功之后微信会往notify_url发回调通知。v3 的回调报文是双重保护的HTTP 头有签名报文里的resource字段是 AES-256-GCM 加密的。工具类里处理回调的方法必须做两件事先验签再解密。验签的报文拼接方式是时间戳\n随机串\n加密报文\n和响应验签一样只是这里的串是body里的resource字段原文。public JSONObject decryptCallback(HttpServletRequest request) throws Exception { String body getRequestBody(request); JSONObject payload JSONObject.parseObject(body); JSONObject resource payload.getJSONObject(resource); 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; verifySignature(message, signature); String apiV3Key 你的APIv3密钥; String associatedData resource.getString(associated_data); String nonceStr resource.getString(nonce); String ciphertext resource.getString(ciphertext); byte[] plainBytes aesGcmDecrypt(apiV3Key, associatedData, nonceStr, ciphertext); return JSONObject.parseObject(new String(plainBytes, StandardCharsets.UTF_8)); }AES-256-GCM 解密是回调处理里最容易踩坑的部分。微信这里用的 nonce 是回调报文resource字段里的 nonce不是 HTTP 头里的Wechatpay-Nonce这两个值不是一回事。解密出来的明文里包含out_trade_no、trade_state、transaction_id、amount等字段业务系统拿到这些信息之后要更新订单状态然后在响应里返回{code: SUCCESS}告诉微信别再重试了。如果业务处理失败返回非 SUCCESS 的报文微信会按频率策略重试。3.3 退款接口相比 v2 的最大变化微信支付 v3 的退款接口比 v2 在参数要求上更严格v2 只需要传out_trade_no、out_refund_no、total_fee、refund_fee四个参数v3 对金额单位做了统一都是分而且不再有 v2 那个容易搞错的 XML 格式嵌套结构。v3 退款接口的路径是/v3/refund/domestic/refunds请求方法变成了 POST 加 JSON 报文。public String createRefund(String orderNo, String refundNo, Integer totalFee, Integer refundFee, String reason) throws Exception { JSONObject params new JSONObject(); params.put(out_trade_no, orderNo); params.put(out_refund_no, refundNo); params.put(reason, reason); params.put(notify_url, notifyUrl); JSONObject amount new JSONObject(); amount.put(refund, refundFee); amount.put(total, totalFee); amount.put(currency, CNY); params.put(amount, amount); String body params.toJSONString(); return execute(POST, /v3/refund/domestic/refunds, body); }退款回调与支付回调走同一个notify_url配置但是在退款的amount参数上有一个容易让新手困惑的点total指的是原订单的总金额refund是本次要退的金额两个字段都必须传否则接口直接报参数错误。另外退款的notify_url和支付的是独立的虽然业务上通常配置同一个地址但工具类里要把退款回调按resource.type为refund还是transaction做分发避免混在一起处理时报错。4. 交易状态查询与企业打款主动对账和资金操作的实现细节4.1 主动查询订单状态什么时候不该依赖回调回调通知并不是可靠的事件系统微信不保证每一次支付成功都立即回调到位网络抖动、服务重启、业务处理超时都有可能导致回调丢失。所以一个健壮的支付工具类必须提供主动查询的能力在订单超时未回调或用户反馈已支付但订单未更新时用out_trade_no去微信侧拉取真实状态。查询接口是GET /v3/pay/transactions/out-trade-no/{out_trade_no}返回体里包含trade_state它有以下几种取值SUCCESS表示支付成功NOTPAY表示未支付CLOSED表示已关闭REVOKED表示已撤销PAYERROR表示支付失败USERPAYING表示用户支付中。public JSONObject queryOrderByOutTradeNo(String outTradeNo) throws Exception { String path /v3/pay/transactions/out-trade-no/ outTradeNo ?mchid mchId; String response execute(GET, path, null); return JSONObject.parseObject(response); }注意这里 GET 请求要把mchid拼在 query string 上但签名时的urlPath是不带 query string 的这就是为什么前面的buildAuthorization方法要单独传urlPath而不是直接在方法内部拼接。有个细节是回调里的trade_state和主动查询的返回值字段是一样的但主动查询更常用于对账任务建议在订单支付超时 5 分钟还未收到回调时启动查询查到SUCCESS后直接更新订单查不到就继续轮询最多轮询 6 次每次间隔 30 秒。4.2 企业打款 API能力边界与权限企业打款是微信支付商户平台向用户零钱转账的能力官方叫法是商家转账在 API v3 里对应的接口是/v3/transfer/batches。这个接口的权限不是默认开通的需要在商户平台申请开通商家转账功能而且每日打款限额、单笔限额都与商户的实名认证等级有关。工具类里封装这个接口重点是批次和明细单号的设计微信侧要求每次请求传out_batch_no商家批次单号和out_detail_no商家明细单号这两个单号都必须唯一重复使用会直接导致请求失败。public String transfer(String outBatchNo, String outDetailNo, String openId, Integer amount, String remark) throws Exception { JSONObject params new JSONObject(); params.put(appid, appId); params.put(out_batch_no, outBatchNo); params.put(batch_name, remark); params.put(batch_remark, remark); params.put(total_amount, amount); params.put(total_num, 1); JSONObject transferDetail new JSONObject(); transferDetail.put(out_detail_no, outDetailNo); transferDetail.put(transfer_amount, amount); transferDetail.put(transfer_remark, remark); transferDetail.put(openid, openId); params.put(transfer_detail_list, new JSONArray().add(transferDetail)); String body params.toJSONString(); return execute(POST, /v3/transfer/batches, body); }企业打款跟支付一样也有异步回调回调地址在商户平台单独配置和支付、退款的不共用。工具类处理这个回调的时候要注意转账结果最终以回调为准接口同步返回batch_id只代表受理成功不代表钱已经到了用户零钱里。转账失败的常见原因是用户未实名、账户状态异常、金额超过单笔限额这些失败的明细会出现在批量转账查询接口的返回里需要业务系统主动做核对。4.3 这三个 API 的共性设计支付、退款、转账三个接口虽然业务场景不同但在工具类层面它们的处理逻辑是完全统一的组装 JSON 参数、签名、发送请求、验签、反序列化响应。因此工具类里不要给每个接口单独写一套 HTTP 调用逻辑而是把execute方法作为公共入口业务接口都走同一个方法。公共部分和业务部分的边界就在execute负责通信和验签调用方负责拼参数和解析结果。如果以后微信侧出了新接口工具类的扩展方式就是新增一个方法调用execute传入对应的 API 路径和报文不需要改动任何通信层的代码。5. 微信支付 v3 工具类的 6 个高频踩坑点与排查思路5.1 证书序列号不匹配换了证书但代码里还是旧的在某次上线后突然所有请求都返回 401看日志发现是签名验证失败。排查之后发现商户平台在到期前更新了商户证书新的证书公钥和序列号已经变了但工具类里还引用着旧的序列号和私钥。这个问题的根源是证书更新后没有同步到代码配置里。解决的办法是在配置中心统一管理mchId、serialNo、privateKeyPath和platformCertPath证书更新时只改配置不改代码。工具类的配置读取做成动态的不要用static常量每次请求都从配置中心拉取最新的证书序列号。5.2 AES-GCM 解密失败密钥长度和 nonce 用错回调解密时报AEADBadTagException这是 AES-GCM 解密时 tag 校验失败原因要么是密钥不对要么是 nonce 不对。很多人第一次写回调解密时把 HTTP 头的Wechatpay-Nonce当成解密用的 nonce但微信回调报文里解密所需的 nonce 在resource.nonce字段里两者都叫 nonce但值完全不同。另外 APIv3 密钥的长度必须是 32 字节如果你在商户平台设置的密钥不是 32 位解密时也会失败。解决方式是在商户平台重新设置 APIv3 密钥设置时它会强制要求 32 位字符串。5.3 签名串拼接时 URL 带了 query string工具类在对接查询类接口时有些同事把完整 URL 拼进去签了名结果微信一直返回签名错误。原因是签名串里的 URL 路径不能包含 query stringGET方法下的?mchidxxx必须单独处理。这也是为什么工具类里buildAuthorization的参数是urlPath而不是完整 URL传参时路径和参数分开传签名用纯路径请求用完整 URL。排查这类问题的最快方式是用微信支付的签名校验工具把请求方法、路径、时间戳、随机串、请求体粘贴进去对比一下通常能很快发现多拼了参数或者少拼了换行符。5.4 平台证书过期之后手动换证书微信的平台证书有时会主动轮换轮换期间如果工具类是本地加载证书文件的就可能出现验签失败。微信会先发一个平台证书更新通知回调然后正式切换。如果没处理更新你的本地证书和微信实际签名证书对不上验签就会挂。解决方式是在工具类里实现对平台证书的自动刷新可以定时调/v3/certificates接口拉取最新的证书链也可以监听微信的证书更新回调。手动方案是定期检查商户平台的证书有效期提前下载替换。5.5 金额单位把元当成传进去了支付和退款的金额单位全部是分不是元。某次联调时测试用真实金额 0.01 元传参工具类里写的是new JSONObject().put(total, 1)没问题但同事在退款接口直接传了0.01结果微信返回参数格式错误。解决方式是在工具类里加一个元转分的方法在入口处强制统一金额单位防止调用方传错。这个方法很简单BigDecimal.valueOf(yuan).movePointRight(2).intValue()然后用Math.multiplyExact做溢出保护。5.6 回调处理没返回 SUCCESS 导致微信重复通知回调处理完业务之后一直忘记返回{code: SUCCESS}微信就会按 15 秒、15 秒、30 秒、3 分钟、10 分钟、20 分钟、30 分钟、30 分钟、30 分钟、60 分钟的间隔持续重试如果业务代码本身不是幂等的重复通知就会导致订单状态被多次更新甚至出现超卖。这个问题本质上是回调处理的语义没有设计对。工具类里回调入口不要直接处理业务先做一步幂等判断——查本地订单号是否已经处理过处理过直接返回 SUCCESS没处理过再执行业务更新逻辑。返回报文的时候用 resp.getWriter() 输出 JSON整个回调方法用 try-catch 包住异常时也返回失败报文但要做好日志记录。6. 让工具类能扛住线上的自测方法从回显校验到对账兜底工具类写完之后不要急着接入业务先做一轮协议层的自测。我自己惯用的方式是在测试环境起一个 Mock 服务模拟微信支付的回调和响应验签把工具类的请求报文、签名结果和验签逻辑全部打日志重点确认三件事每个接口的请求签名能被合法的微信侧证书验过、回调解密能跑通、金额字段的精度没出问题。特别是回调那段Mock 一个加密的回调报文比自己攒一个真实回调省事得多。对账兜底是工具类上线之后必须设计的环节。微信支付有官方的账单下载接口v3 的/v3/bill/tradebill可以下载某一天的交易账单工具类最好预留一个对账任务每天定时拉取前一天的账单跟本地订单表做比对。这样即使某个回调丢了或者通知重试期间业务系统刚好在发布对账任务也能把差异捞出来手动或者自动触发退款和状态修正。接口幂等的问题也值得多说一句。工具类里支付和退款各自都依赖业务传入的单号做幂等——支付是out_trade_no退款是out_refund_no。同一个单号重复请求微信侧只会返回第一次的结果不会重复扣款或重复退款。但工具类这边要有意识地保证这个单号永不重复用数据库唯一索引做兜底比工具类里用synchronized锁更可靠因为后者在分布式环境下根本锁不住。最后聊一个容易被忽视的习惯工具类里所有的异常都要打印出完整的报文和响应体。微信支付 v3 的错误信息其实都挺明确的像SIGN_ERROR、PARAM_ERROR、INVALID_REQUEST但如果你在 catch 里只打了一个e.getMessage()真正的报错原因全在response body里没打出来排查效率就会非常低。我见过太多同事在群里发一个PARAM_ERROR就让人猜半天其实把日志里的 JSON 报文拉出来一眼就能看到是哪个字段不对。这个习惯我后来固化到了所有工具类的catch块里现在新同事接入支付出问题时第一条被要求发出来的就是完整日志。希望帮到你。本文还有配套的精品资源点击获取
返回列表