ARTICLE DETAIL

资讯详情

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

微信支付v3工具类封装实战:从签名验签到退款查询全场景避坑

微信支付v3工具类封装实战:从签名验签到退款查询全场景避坑 简介针对企业微信交易场景封装的Java开发工具类涵盖微信支付V3版、微信退款V3版、交易状态查询与企业打款到个人零钱旧版四大功能。这套代码源自作者真实企业项目中的自我封装将微信官方接口调用逻辑统一归纳为可直接调用的方法使用时传入对应业务参数即可完成交易闭环省去大量重复对接工作显著降低微信支付接入门槛适合需要快速集成支付能力的后端工程师。资源共7个文件其中5个Java文件为核心工具类实现iml与xml文件承担工程配置与依赖声明整体压缩包仅11KB结构轻量、便于阅读。目前已有2277人学习下载读者既能直接参考封装思路也可复制方法至自身项目快速验证遇到问题还可在评论区留言讨论。1. 微信支付工具类 v3 版为什么 Java 项目越来越需要自己封装微信支付 v3 接口跟 v2 不是换个地址那么简单请求签名被放进了 Authorization 头回调报文变成了 AES-GCM 加密的 JSON证书还被拆成了商户证书和微信支付平台证书两套。很多 Java 项目从网上复制过来的老工具类一旦碰上证书更新、退款回调、交易状态主动查询就是各种验签失败和黑匣子一样的报错。这篇文章要拆的是一个能覆盖微信支付 v3 版下单、微信退款 v3 版、微信交易状态查询、企业打款到零钱全场景的工具类封装方案每一段代码为什么这么写、参数怎么取、失败了先看哪里都会讲透。适合自己维护支付模块、需要二次开发或想摆脱官方 SDK 黑匣子的中级 Java 工程师。2. 先把签名体系焊死初始化 v3 工具类的证书与 Authorization 头微信支付 v3 工具类最核心的不是 HTTP 请求本身而是那套 RSA 签名。很多项目移植官方 SDK 时遇到SIGN_ERROR最后定位下来都是签名串拼错、私钥格式不对或证书序列号带进了冒号。这一章先把签名基础打牢再做具体接口。2.1 v2 到 v3 的变化签名挪进了 Authorization证书分成了商户和平台两把v2 的报文是 XML商户用 MD5 或 HMAC-SHA256 把签名放在请求体里一个 API 密钥就能跑。v3 把签名从报文体里挪了出来放进 HTTP 头的Authorization算法换成 RSA-SHA256并且要求每次请求都带时间戳和随机串来防止重放。请求体统一是 JSON回调通知如果涉及敏感数据还会用 AES-GCM 再加密一层。每次请求前微信要求按固定顺序拼一个待签名串POST /v3/pay/transactions/jsapi 1715000000 9d0f5c3c9c124d0b9e6c3a0c7f {appid:wx8888,mchid:1900009191}也就是HTTP方法 \n 规范化URL \n 时间戳 \n 随机串 \n 请求体 \n。注意 URL 要做编码比如商户号里的特殊字符不能直接粘进去请求体必须和实际发送的 JSON 完全一致多一个空格都会让验签失败。GET 请求的请求体为空但最后的空行必须保留。对应的请求头长这样Authorization: WECHATPAY2-SHA256-RSA2048 mchid1900009191,nonce_str9d0f5c3c9c124d0b9e6c3a0c7f,timestamp1715000000,serial_no1AB2C3D4...,signatureBASE64签名Header 里的键名是固定的nonce_str、timestamp、serial_no、signature一个都不能少顺序错了也可能被拒。这里签名用的私钥是商户 API 证书私钥而回调验签用的却是微信支付平台证书的公钥两把钥匙各管一段不能混用。2.2 写一个 PayV3Signer加载私钥、构造签名串、生成请求头在工具类里我一般会把“签名”单独抽成一个类后续所有支付、退款、查询、打款接口都复用同一个实例。下面是最小可用的PayV3Signerimport java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; import java.util.UUID; public class PayV3Signer { private final String mchId; // 商户号例如 1900009191 private final String serialNo; // 商户 API 证书序列号注意不能带冒号 private final PrivateKey privateKey; public PayV3Signer(String mchId, String serialNo, String pemPath) throws Exception { this.mchId mchId; this.serialNo serialNo; this.privateKey loadPrivateKey(Files.readString(Paths.get(pemPath))); } /** 微信支付 v3 要求 PKCS8 格式私钥很多 v2 老代码里是 PKCS1这是验签失败的最大原因 */ private PrivateKey loadPrivateKey(String pem) throws Exception { String body pem .replace(-----BEGIN PRIVATE KEY-----, ) .replace(-----END PRIVATE KEY-----, ) .replaceAll(\\s, ); PKCS8EncodedKeySpec spec new PKCS8EncodedKeySpec(Base64.getDecoder().decode(body)); return KeyFactory.getInstance(RSA).generatePrivate(spec); } /** * 组装 Authorization 头 * param method POST/GET * param url 规范化 URL例如 /v3/pay/transactions/jsapi * param body 请求体 JSONGET 请求传空字符串 */ public String buildAuthHeader(String method, String url, String body) throws Exception { String timestamp String.valueOf(System.currentTimeMillis() / 1000); String nonce UUID.randomUUID().toString().replace(-, ); String message method \n url \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\ mchId \, nonce_str\ nonce \, timestamp\ timestamp \, serial_no\ serialNo \, signature\ signature \; } }逻辑说明loadPrivateKey把 PEM 文本里的头和尾剥掉剩下的 Base64 用 PKCS8 解码。微信商户平台下载的apiclient_key.pem默认就是 PKCS8但如果你从别处拷贝过私钥或者经过旧版 OpenSSL 转换很可能变成 PKCS1导致invalid key format这个问题在避坑章节还会展开。参数说明serialNo是商户 API 证书序列号不是证书文件里openssl x509 -noout -serial打印出来的serialAB:CD:...。复制出来后要去掉冒号只留十六进制字母否则签名头里的序列号匹配不上。nonce用 UUID 去掉横线就够用微信没有要求非要用 SecureRandom但如果你对安全等级敏感可以换SecureRandom生成 16 字节十六进制串。实际发送请求时用 Java 11 的HttpClient就能实现替换成 OkHttp 或 Feign 也只是一行改动HttpClient client HttpClient.newHttpClient(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(https://api.mch.weixin.qq.com canonicalUrl)) .header(Authorization, signer.buildAuthHeader(POST, canonicalUrl, jsonBody)) .header(Content-Type, application/json) .header(Accept, application/json) .header(User-Agent, java-pay-v3-demo/1.0) .POST(BodyPublishers.ofString(jsonBody)) .build();2.3 商户证书、平台证书与 APIv3 密钥一张表分清三把“钥匙”工具类配置里最容易混的就是这三样东西。很多新手以为回调验签用的是自己下载的那个证书实际上完全不是一回事。配置项来源用途商户 API 证书私钥商户平台 → API安全 → API证书发起所有请求时生成 Authorization 签名商户 API 证书序列号证书详情页放进 Authorization 头的 serial_no微信支付平台证书公钥证书下载接口或商户平台验证微信回调通知和平台下发的响应签名APIv3 密钥商户平台 → API安全 → APIv3密钥AES-GCM 解密回调里的 resource 密文商户 API 证书是客户端身份凭证用来证明这是你平台证书是服务端身份凭证用来证明响应真的来自微信。两者都可以轮换但轮换节奏不一样。APIv3 密钥则完全不参与签名只负责解密和加密建议用独立的随机字符串别跟 API 密钥混用。工具类初始化时通常会把商户号、AppId、证书序列号、私钥路径、APIv3 密钥放成一个配置对象。平台证书不建议写死在代码里因为微信会不定期更新后面避坑章节会说怎么处理。3. 用工具类跑通微信支付 v3下单、调起支付与回调验签解密支付是整套工具类的主干跑通一次下单、收到一次回调并成功解密后面退款和查询基本就是复制这个骨架。3.1 构造 JSAPI 下单参数金额单位、openid 和回调地址别踩坑JSAPI 下单对应POST /v3/pay/transactions/jsapi适合公众号、小程序里发起支付。最精简的请求体长这样{ appid: wx8888888888888888, mchid: 1900009191, description: 测试商品-001, out_trade_no: T202406120001, notify_url: https://api.example.com/pay/notify, amount: { total: 1, currency: CNY }, payer: { openid: oUpF8uMuAJO_M2pxb1Q9zNjWeS6o } }amount.total的单位是分1 就是 0.01 元out_trade_no是商户侧订单号同一商户号下必须唯一这是后续查询和退款的幂等键description长度限制 1127 个字符notify_url不能带自定义 query 参数回调地址在 v3 里只认固定格式带?fromxx这类参数微信会直接拒收。金额计算我建议全程用 BigDecimal用 String 构造而不是new BigDecimal(double)。单价 0.29 元乘以 3 这种计算如果中间用了浮点最后转分就可能是 86 而不是 87这类问题在避坑章节有详细案例。3.2 发送下单请求并生成调起支付参数发送逻辑跟其他 POST 接口一样把签名头带上解析返回的prepay_idpublic static JSONObject createJsapiOrder(PayV3Signer signer, JSONObject orderBody, String canonicalUrl) throws Exception { String auth signer.buildAuthHeader(POST, canonicalUrl, orderBody.toString()); HttpClient client HttpClient.newHttpClient(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(https://api.mch.weixin.qq.com canonicalUrl)) .header(Authorization, auth) .header(Content-Type, application/json) .header(Accept, application/json) .header(User-Agent, java-pay-v3-demo/1.0) .POST(BodyPublishers.ofString(orderBody.toString())) .build(); HttpResponseString resp client.send(request, HttpResponse.BodyHandlers.ofString()); return new JSONObject(resp.body()); }常见做法是把这个方法封装成通用的postJson(signer, url, body)所有接口复用。下单成功后接口会返回prepay_id但不能直接把prepay_id给前端还需要用 AppId、时间戳、随机串、packageprepay_idxxx再签一次名才能用于小程序或 App 的调起支付String prepayId result.getString(prepay_id); String timeStamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr UUID.randomUUID().toString().replace(-, ); // App 调起支付参数里的 paySign 签名串 String payMessage appId \n timeStamp \n nonceStr \n prepay_id prepayId \n; // 用 PayV3Signer 里的同一个私钥按 SHA256withRSA 签名参数说明这里的签名串跟请求头签名串不同它是给前端 SDK 做验签用的所以 key 顺序是 AppId、timeStamp、nonceStr、package。前端拿到paySign后唤起微信用户完成支付微信会把结果异步 POST 到notify_url那才是真正的“支付成功”依据。3.3 回调通知先验签再解密解密后按 out_trade_no 幂等回调通知的报文外面包了一层加密结构请求头里有Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial四个字段。第一步是用Wechatpay-Serial找到对应的平台证书然后拼验签串String message timestamp \n nonce \n requestBody \n; Signature sig Signature.getInstance(SHA256withRSA); sig.initVerify(platformCert.getPublicKey()); sig.update(message.getBytes(StandardCharsets.UTF_8)); boolean ok sig.verify(Base64.getDecoder().decode(signatureFromHeader));验签通过后再解析请求体拿到resource里的ciphertext、nonce、associated_data用 APIv3 密钥做 AES-GCM 解密public static String decryptNotify(String apiV3Key, JSONObject resource) throws Exception { String ciphertext resource.getString(ciphertext); String nonce resource.getString(nonce); // 注意取 resource.nonce不是请求头的 nonce String associatedData resource.optString(associated_data, ); byte[] key apiV3Key.getBytes(StandardCharsets.UTF_8); byte[] iv nonce.getBytes(StandardCharsets.UTF_8); byte[] cipherBytes Base64.getDecoder().decode(ciphertext); Cipher gcm Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec(key, AES); GCMParameterSpec gcmSpec new GCMParameterSpec(128, iv); gcm.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); gcm.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); return new String(gcm.doFinal(cipherBytes), StandardCharsets.UTF_8); }解密后的 JSON 里能拿到out_trade_no、transaction_id、trade_state、amount.total等关键字段。这里要特别注意顺序先验签后解密再改订单状态。如果验签不过说明这条通知可能来自伪造请求直接丢弃并返回 401 或 500。处理成功后要返回 HTTP 200 和{code:SUCCESS,message:成功}否则微信会按 15 秒、15 秒、30 秒的节奏重试最长可能推 24 小时直到你返回成功。4. 微信退款 v3 与交易状态查询把支付闭环补成对账闭环支付跑通只是开始退款和查单才是日常运维里最常用的能力。微信退款 v3 版不需要再拼 XML也不用像 v2 那样双证书同时上工具类里直接复用同一个签名器即可。4.1 微信退款 v3 版退款接口、out_refund_no 与金额校验退款接口是POST /v3/refund/domestic/refunds一个方法就能覆盖全额退款和部分退款public static JSONObject refund(PayV3Signer signer, String outTradeNo, String outRefundNo, int refundFee, int totalFee, String refundNotifyUrl) throws Exception { JSONObject amount new JSONObject(); amount.put(refund, refundFee); // 退款金额单位分 amount.put(total, totalFee); // 原订单金额单位分 amount.put(currency, CNY); JSONObject body new JSONObject(); body.put(out_trade_no, outTradeNo); // 原交易订单号 body.put(out_refund_no, outRefundNo); // 退款单号同一商户下唯一 body.put(amount, amount); body.put(notify_url, refundNotifyUrl); String url /v3/refund/domestic/refunds; String auth signer.buildAuthHeader(POST, url, body.toString()); // 沿用上一章的 postJson 方法发送即可 return postJson(auth, url, body); }逻辑说明out_refund_no是退款请求的幂等键重复提交同一个退款单号不会生成多笔退款而是返回相同的退款单。这个字段设计得非常巧妙可以当作一次失败重试的“后悔药”。amount.refund是本次要退的金额amount.total是原订单实付金额两边的单位都是分。部分退款时refund小于total全额退款时两者相等。参数说明如果没有传notify_url退款结果就只能靠主动查询我是建议每次都传独立退款回调地址。退款回调通知的验签和解密方式与支付回调完全一致只是解密后的event_type是REFUND.SUCCESSoriginal_type是refund里面有refund_status字段。退款接口返回的响应体里有一个status字段通常一开始是PROCESSING最终变成SUCCESS或CLOSED。需要注意退款成功不代表钱马上到账尤其是退到银行账户可能有 T1 延迟所以业务上要以退款回调或查询结果为准。4.2 交易状态查询主动查询的时机和 trade_state 判断查询订单状态用GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid{mchid}这个接口专门解决回调漏单、回调乱序、用户支付后没等回调就先跳回页面等问题public static JSONObject queryTrade(PayV3Signer signer, String outTradeNo, String mchId) throws Exception { String url /v3/pay/transactions/out-trade-no/ outTradeNo ?mchid mchId; // GET 请求的 body 传空字符串但签名串末尾的空行不能丢 String auth signer.buildAuthHeader(GET, url, ); HttpResponseString resp getJson(auth, url); return new JSONObject(resp.body()); }返回的 JSON 中trade_state是核心字段常见取值如下trade_state含义业务动作SUCCESS支付成功发货、放行REFUND已退款关闭权限、标记退款NOTPAY未支付可继续等待或关闭CLOSED已关闭不再接受支付USERPAYING用户支付中等待结果稍后再查PAYERROR支付失败引导用户重新支付小程序支付有一个典型场景用户输入密码后还没等回调回来就杀掉了 App服务端一直没收到通知订单躺在NOTPAY状态。如果只依赖回调这个订单就永远无法发货。血泪经验告诉我下单后必须留一个手动“补查”入口或者在订单创建 5 分钟后跑一个定时任务把超时未支付但可能已付款的订单捞出来主动查询查询结果SUCCESS就直接走支付成功流程把状态补上。如果要查询退款结果对应接口是GET /v3/refund/domestic/refunds/{out_refund_no}同样无 query 参数直接用退款单号拼在路径里。返回值里的status和refund_account能告诉你退款单是处理中、成功还是已关闭。5. 微信支付工具类避坑清单签名失败、回调解密、金额精度是前三名这一章记录的是我自己踩过、以及帮别人排查过的真实翻车点。每一条都按现象、原因、解决这样的顺序写方便你对着问题直接定位。5.1 坑一Authorization 一直 401错误码 SIGN_ERROR现象同样的请求体拿 Postman 测就能过Java 代码发出去就报401 Unauthorized响应体里code是SIGN_ERROR。原因最常见是私钥格式不对。微信某些下载渠道提供的私钥是 PKCS1 格式开头是-----BEGIN RSA PRIVATE KEY-----而 Java 的PKCS8EncodedKeySpec只认-----BEGIN PRIVATE KEY-----。其次serial_no被复制成了带冒号的格式或者签名串里拼的 URL 和实际请求 URL 不一致。还有少数情况是请求体里的 JSON 被框架自动加了多余空格或重排了字段导致签名串里的 body 和实际发出去的 body 不同。解决先把私钥转成 PKCS8用 OpenSSL 一条命令就能校验openssl pkcs8 -topk8 -inform PEM -in apiclient_key.pem -out apiclient_key_pkcs8.pem -nocrypt然后把serial_no里的冒号、空格全部去掉。最后如果你用的是 Jackson 或 Fastjson 序列化请求体记得在发送时把“序列化后的字符串”原样交给签名方法和 HTTP 请求不要签完名又让框架重新序列化一遍。5.2 坑二回调解密报 AEADBadTagException 或解密出来是乱码现象回调验签通过了但执行decryptNotify抛javax.crypto.AEADBadTagException或者解密出来的字符串不是合法的 JSON。原因绝大多数是把 GCM 解密用的 nonce 取错了。微信回调里有三个 nonce 概念请求头Wechatpay-Nonce、resource.nonce、以及你请求签名时自己生成的随机串。AES-GCM 解密必须用resource.nonce如果误用了请求头的Wechatpay-NonceGCM 的 tag 校验必然失败报 AEADBadTagException。另一个原因是associated_data处理不当微信文档说它可能是空字符串但有些实现直接把associated_data拼进密文一起解密这会把分组边界破坏。解决严格按上一章代码段的写法nonce从resource里取associated_data用optString兜底空串加解密都使用AES/GCM/NoPaddingtag 长度固定 128 位。另外ciphertext是标准 Base64 编码不要先做 URL decode直接Base64.getDecoder().decode即可。5.3 坑三金额精度丢失1 元支付变成 0.99 元现象退款计算时明明是按 0.30 元退的到微信侧却变成 29 分或者购买多个商品后订单金额跟前端展示的总额差 1 分。原因业务系统把金额在数据库里存成decimal但读到 Java 里用了Double做运算0.1 * 3在二进制浮点里并不精确等于 0.3。转分时如果直接(int) (amount * 100)1.03 元会变成 102 分而不是 103 分这就是金额“玄学”差异的来源。解决统一用分作为存储和传输单位接口层只收整数如果业务层必须以元为单位展示用BigDecimal且必须以 String 构造BigDecimal yuan new BigDecimal(1.03); int fen yuan.setScale(2, RoundingMode.HALF_UP) .multiply(new BigDecimal(100)) .intValue(); // 结果是 103不是 102凡是涉及微信支付 v3 的金额字段请求前都打一条日志把分打印出来减少排查成本。5.4 坑四证书更新后回调验签全部失败请求却一切正常现象某天支付请求还能正常发起但所有回调验签都返回失败查看日志发现Wechatpay-Serial对应的证书已经过期。原因微信支付平台证书是有有效期的轮换周期通常几个月一次。如果工具类里把平台证书的公钥写死成常量证书更新后旧的验签公钥自然失效。而商户 API 证书更新和平台证书更新是两条线所以会出现“请求能用、回调全挂”的割裂状态。解决给工具类加一个“平台证书缓存”。处理回调时先看Wechatpay-Serial是否在缓存里不在就去拉平台证书列表。证书下载接口返回的证书需要解密后才能拿到公钥解密用的还是 APIv3 密钥。稳妥的做法是启动时拉一次运行期发现未知序列号再拉一次缓存刷新周期设 12 小时。5.5 坑五同一笔订单回调重复推送订单状态被覆盖现象订单已经标记为“已支付”几分钟后又收到一次TRANSACTION.SUCCESS通知把订单状态重新刷成“待发货”并发场景下甚至出现两条支付流水。原因微信支付回调没有 exactly-once 语义业务方必须在回调里做幂等。如果你处理回调直接update order set status 支付成功后到的重复通知就可能覆盖掉已经推进到下一步的状态。解决用out_trade_no event_type作为消费去重键处理前先查当前订单状态只有“待支付”才允许流转到“已支付”已经“已支付”的订单收到重复通知直接返回SUCCESS不再更新状态。退款回调同理out_refund_no就是天然的幂等键重复通知直接丢弃。6. 企业打款到零钱批次转账的幂等与资金核对企业打款在微信支付 v3 里对应的是“商家转账到零钱”典型场景是返利、佣金、报销款直接打到用户微信零钱。接口路径是POST /v3/transfer/batches和前面的支付、退款一样用的是同一套 RSA 签名但报文结构多了批次和明细两层转账时要特别关注幂等设计。JSONObject detail new JSONObject(); detail.put(out_detail_no, D202406120001); // 明细单号幂等键 detail.put(transfer_amount, 100); // 单笔转账金额单位分 detail.put(transfer_remark, 6月返利); detail.put(openid, oUpF8uMuAJO_M2pxb1Q9zNjWeS6o); JSONObject body new JSONObject(); body.put(appid, appId); body.put(out_batch_no, B202406120001); // 批次单号批次幂等键 body.put(batch_name, 6月返利批次); body.put(batch_remark, 6月返利发放); body.put(total_amount, 100); // 批次总金额单位分 body.put(total_num, 1); // 转账明细条数 body.put(transfer_detail_list, new JSONArray().put(detail)); String url /v3/transfer/batches; String auth signer.buildAuthHeader(POST, url, body.toString()); // 返回 out_batch_no 与 out_detail_no后续用它们查询批次状态逻辑说明批次号和明细号都是幂等键重复提交同一组编号不会重复打款。返回后的batch_status只有WAIT_PAY、ACCEPTED、FINISHED、CLOSED等几种打款结果最终要以批量查询接口为准。如果明细里需要做实名校验且转账金额达到平台规定阈值就得把user_name先用微信平台公钥做非对称加密再传不要直接放明文。资金类接口我还会额外打一条包含out_batch_no、请求体哈希、响应码的日志排查时不用去翻黑匣子。最后说一个我个人的习惯每当在新的支付项目里把 v3 工具类搭好第一件事不是写业务接口而是先写失败用例。比如用一个已知错误的私钥去调签名方法确认异常用一段被篡改的回调 body 去验签确认返回 false再把固定的一组ciphertext、nonce、associated_data灌进解密方法断言明文 JSON 跟预期一致。这样做一次后面证书更新、SDK 升级时能立刻知道是哪一环出了问题。微信支付这种链路靠的不只是文档还有这些能复现的最小验证用例希望帮到你。本文还有配套的精品资源点击获取
返回列表