
简介面向有微信支付对接需求的Java开发者这份工具类封装覆盖微信支付v3版、微信退款v3版、交易状态查询以及企业打款到个人零钱旧版四项核心功能适合在企业级项目中快速接入支付场景省去逐项调通官方接口、核对参数与签名的繁琐环节。资源压缩包共7个文件以5个Java源文件为主体另含Maven工程配置文件pom.xml与iml模块描述文件体积仅11KB结构精简易用。拿到即可调用对应方法并传入参数完成支付、退款、查询等操作方便整合到存量业务系统避免重复造轮子。该资源已有2277人学习作者分享的是自己项目中的实际封装并欢迎留言讨论遇到参数错误、签名校验或回调验签等问题时可对照排错。无论首次集成还是替换旧版实现这套代码都能显著降低联调成本适合正在做微信支付二次开发的工程师参考。1. 微信支付v3工具类要解决什么四个接口合成一个类把微信支付官方 API 从十几处散落的 HTTP 调用收敛成一个 Java 工具类这事情看起来小做起来坑不少。APIv3 的签名规则、商户证书序列号、金额以“分”为单位的约定、回调里的 AES-GCM 解密再加上企业打款商家转账的批次状态机任何一个环节理解偏了联调时就是一遍遍的 401、解密失败和退款流水悬空。这篇笔记按我实际封装一个 WxPayV3Util 的路径来写先讲签名与证书体系再给统一下单、交易状态查询、退款 v3 和企业打款的落地代码最后列 5 条踩坑记录。适合正在接入微信支付 v3、或者想把老接口升级成 v3 的 Java 后端开发照着复现比自己从官方文档里拼要快得多。2. APIv3签名与证书体系动手封装前的三个关键概念2.1 APIv3签名算法从商户私钥到 Authorization 请求头老版微信支付接口用 MD5 或 HMAC-SHA256 加商户 key 做签名v3 换成了更标准的 SHA256withRSA 非对称签名。这意味着签名不再靠一个“密钥字符串”而是靠商户 API 证书里的私钥。封装工具类之前有两样东西必须先拿到商户 API 证书和商户证书序列号。私钥对应 apiclient_key.pem证书序列号在 apiclient_cert.pem 里注意这是商户证书的序列号不是微信支付平台证书的序列号这两者经常被搞混。签名原文的拼接规则是固定五段HTTP 方法、请求 URL带 query 的要把 query 一起放进去、时间戳、随机串、请求体。每段之间用换行符\n连接最后再加一个空行。请求体为空时比如 GETbody 那段就是空字符串。一句话描述就是“方法、URL、时间戳、随机串、请求体各占一行”。时间戳用秒级随机串我用 UUID 去掉横线长度 32 位。private static String buildSignMessage(String method, String url, String timestamp, String nonceStr, String body) { return method \n url \n timestamp \n nonceStr \n (body null ? : body) \n; } public static String sign(String message, PrivateKey privateKey) throws Exception { Signature signature Signature.getInstance(SHA256withRSA); signature.initSign(privateKey); signature.update(message.getBytes(StandardCharsets.UTF_8)); byte[] signed signature.sign(); return Base64.getEncoder().encodeToString(signed); }这段代码里buildSignMessage负责拼签名原文sign负责用商户私钥做 SHA256withRSA 签名并做 Base64 编码。私钥从 apiclient_key.pem 读取后用PKCS8EncodedKeySpec转成PrivateKey对象证书序列号建议在工具类加载时把 apiclient_cert.pem 里的序列号读取出来而不是硬编码到代码里因为证书到期换证时硬编码最容易漏改。签名最终要放进请求头Authorization格式固定为WECHATPAY2-SHA256-RSA2048开头后面跟五个参数。private static String buildAuthHeader(String mchId, String nonceStr, long timestamp, String serialNo, String signature) { return WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonceStr \, timestamp\ timestamp \, serial_no\ serialNo \, signature\ signature \; }请求头的参数顺序官方文档允许任意但五个参数一个都不能少mchid 是商户号nonce_str 必须与签名原文里的随机串一致timestamp 必须与签名原文里的时间戳一致serial_no 是商户证书序列号signature 就是签名结果。这里有个玄学般的坑微信服务器会校验时间戳与服务器时间差超过 5 分钟直接拒绝本地时钟不准时会收到“时间戳误差过大”的报错。我在工具类里统一用System.currentTimeMillis() / 1000并要求部署机器做 NTP 时间同步。2.2 用 OkHttp 封装公共请求层签名、超时与重试签名逻辑单独写好之后剩下就是把这套逻辑挂到真实的 HTTP 请求上。我一般用 OkHttp 做底层因为超时控制、连接池和拦截器都够用也没有太重。封装一个WxPayHttpClient内部持有商户号、私钥、证书序列号对外只暴露post和get两个方法。每个方法内部先造随机串、算签名、拼 Authorization 请求头再发请求。响应体永远是 JSON但错误信息也在 JSON 里所以非 2xx 时不能只抛异常要把 body 内容带出来方便排查。public class WxPayHttpClient { private static final MediaType JSON MediaType.parse(application/json; charsetutf-8); private final String mchId; private final String serialNo; private final PrivateKey privateKey; private final OkHttpClient httpClient; public WxPayHttpClient(String mchId, String serialNo, PrivateKey privateKey, int timeoutSeconds) { this.mchId mchId; this.serialNo serialNo; this.privateKey privateKey; this.httpClient new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(timeoutSeconds, TimeUnit.SECONDS) .writeTimeout(5, TimeUnit.SECONDS) .build(); } public String post(String url, String body) throws Exception { String nonceStr UUID.randomUUID().toString().replace(-, ); long timestamp System.currentTimeMillis() / 1000; String message buildSignMessage(POST, url, String.valueOf(timestamp), nonceStr, body); String signature sign(message, privateKey); String auth buildAuthHeader(mchId, nonceStr, timestamp, serialNo, signature); Request request new Request.Builder() .url(url) .post(RequestBody.create(body, JSON)) .header(Authorization, auth) .header(Accept, application/json) .build(); try (Response response httpClient.newCall(request).execute()) { String respBody response.body() null ? : response.body().string(); if (response.code() 200 response.code() 300) { return respBody; } throw new WxPayException(HTTP response.code() : respBody); } } }构造参数里的timeoutSeconds我按接口区别对待统一下单和查单给 15 秒退款接口给 30 秒因为退款偶尔会在银行侧卡一下。OkHttp 的 connectTimeout 和 readTimeout 分别控制建连和读响应我习惯把 connect 设短一点5 秒避免微信网络抖动时线程长时间挂住。错误处理上WxPayException是我自定义的运行时异常message 里带上完整响应体日志里直接能看到微信返回的code和message字段省去抓包确认的步骤。关于重试这里有个重要原则GET 请求可以放心重试POST 请求绝不能盲目重试。退款、下单这类 POST 接口网络超时只代表客户端没收到响应服务端可能已经成功了此时重试会导致重复单。正确的做法是先查单根据订单状态决定是继续还是补偿。我在工具类里没有给 POST 加任何自动重试只在调用方暴露了查询接口这也是后面第 5 章避坑清单里会重点说的一条。3. 封装支付下单与交易状态查询核心代码与参数说明3.1 JSAPI 与 Native 统一下单请求体构造与调用支付工具类最核心的入口是统一下单。JSAPI 支付公众号/H5 内用/v3/pay/transactions/jsapiNative 支付扫码用/v3/pay/transactions/native两者请求体结构几乎一样差别只在 JSAPI 需要传用户的 openidNative 不需要。下单成功后返回prepay_id后续拉起支付用的就是它。下单参数里out_trade_no是商户订单号必须保证唯一这是后面做幂等的关键amount.total单位是分不是元用 int 或 long 类型。public JSONObject createOrder(String appId, String description, String orderNo, int totalFee, String notifyUrl, String openId) throws Exception { JSONObject req new JSONObject(); req.put(appid, appId); req.put(mchid, mchId); req.put(description, description); req.put(out_trade_no, orderNo); req.put(notify_url, notifyUrl); JSONObject amount new JSONObject(); amount.put(total, totalFee); amount.put(currency, CNY); req.put(amount, amount); if (openId ! null) { JSONObject payer new JSONObject(); payer.put(openid, openId); req.put(payer, payer); } String url openId ! null ? https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi : https://api.mch.weixin.qq.com/v3/pay/transactions/native; String resp httpClient.post(url, req.toJSONString()); JSONObject result JSONObject.parseObject(resp); if (!result.containsKey(prepay_id)) { throw new WxPayException(下单失败缺少 prepay_id: resp); } return result; }totalFee是调用方传入的“分”单位金额工具类内部不做任何单位转换这样能强行把金额精度问题挡在工具类外面。description是商品描述微信要求不能为空而且长度有限制我一般截断到 60 个字符以内。JSAPI 和 Native 的判断我直接用了openId是否为 null业务上更清晰的写法是单独传一个tradeType参数但那样代码分支会更多。下单成功后JSAPI 还需要用prepay_id生成前端拉起支付用的参数这里面又要签一次名但这次签的是appId\ntimeStamp\nnonceStr\npackageprepay_idxxx\n这四段签名方式一样只是原文内容不同。拉起支付的二次签名很容易被忽略很多人下单成功后直接拿prepay_id丢给前端前端拿到也没法调起支付。正确做法是后端把appId、timeStamp、nonceStr、package和paySign五个字段拼好返回给前端。其中package的值固定是prepay_idxxxpaySign用商户私钥对前面说的四段原文做 SHA256withRSA然后 Base64。这个签名结果和请求头里的 Authorization 没有关系完全是两套东西别复用。3.2 交易状态查询查单接口与订单状态处理交易状态查询对应的是/v3/pay/transactions/out-trade-no/{out_trade_no}这是一个 GET 请求商户订单号放在 URL 路径里同时要用 query 参数带上mchid。这里最容易忽略的是URL 上的 query 参数也要参与签名。也就是说签名原文里的 URL 要写成完整带 query 的形式比如https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/ORDER123?mchid1900000001。漏掉?mchidxxx会导致签名校验失败这是查单接口最高频的报错原因之一。public JSONObject queryOrder(String orderNo) throws Exception { String url https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/ orderNo ?mchid mchId; String resp httpClient.get(url); return JSONObject.parseObject(resp); }查询结果里的trade_state字段是订单状态取值有SUCCESS、REFUND、NOTPAY、CLOSED、REVOKED、USERPAYING、PAYERROR这些。工具类里我会做一个状态机映射把微信返回的状态转成内部枚举这样业务层不需要关心微信的状态字符串。USERPAYING是用户支付中此时不能直接关单要等几秒再查PAYERROR是支付失败但订单还有机会被继续支付也不能直接关单。查单的典型用途有两个一是支付回调没收到时主动确认结果二是 POST 超时后用来判断是否需要重试。public enum OrderState { SUCCESS, REFUND, NOTPAY, CLOSED, REVOKED, USERPAYING, PAYERROR, UNKNOWN; public static OrderState from(String state) { try { return OrderState.valueOf(state); } catch (Exception e) { return UNKNOWN; } } }这段枚举映射看起来简单但避免了业务代码里到处比较字符串。UNKNOWN兜底很重要微信未来如果新增状态码老版本工具类不会因为valueOf抛异常而挂掉。查单接口本身是 GET可以安全重试我一般建议调用方在支付回调缺失时按 1 秒、5 秒、30 秒的间隔查三次三次都查不到再走人工介入流程。另外还有一个POST /v3/pay/transactions/out-trade-no/{out_trade_no}/close关单接口用于关闭未支付订单但它只对NOTPAY状态的订单有效关单后再支付会直接失败业务上要谨慎调用。4. 微信退款v3与回调通知从申请到确认结果4.1 申请退款与退款查询金额、幂等与 notify_url退款接口的路径是/v3/refund/domestic/refunds这是 v3 版退款和 v2 最大的区别是不再需要证书双向认证签名方式和前面讲的 APIv3 完全一致。退款参数里三个关键字段是out_trade_no原支付订单号、out_refund_no商户退款单号、amount退款金额。out_refund_no是退款幂等键同一个退款单号重复请求微信不会生成两笔退款这是做退款重试的底气。金额对象里refund是本次退款金额total是原订单金额单位都是分currency固定CNY。public JSONObject refund(String orderNo, String refundNo, int refundFee, int totalFee, String notifyUrl, String reason) throws Exception { JSONObject req new JSONObject(); req.put(out_trade_no, orderNo); req.put(out_refund_no, refundNo); req.put(notify_url, notifyUrl); req.put(reason, reason); JSONObject amount new JSONObject(); amount.put(refund, refundFee); amount.put(total, totalFee); amount.put(currency, CNY); req.put(amount, amount); String resp httpClient.post( https://api.mch.weixin.qq.com/v3/refund/domestic/refunds, req.toJSONString()); return JSONObject.parseObject(resp); }为什么amount里要同时传refund和total这是微信用来校验退款金额不能超过原订单金额的服务端会做比对传错会被拒绝。refundFee必须小于等于totalFee等于就是全额退款小于就是部分退款。reason字段在部分退款时建议必填否则某些商户类型会被拒。退款成功后返回的refund_id是微信侧退款单号status初始一般是PROCESSING真正确认退款成功要靠退款查询或回调。退款查询接口是GET /v3/refund/domestic/refunds/{out_refund_no}通过商户退款单号查返回里的status只有三种需要关心SUCCESS、CLOSED、PROCESSING。CLOSED表示退款单被关闭通常是用户注销或银行卡异常钱会原路退不回去需要人工处理。这里有一个常见误用退款查询 URL 里不需要拼mchid和订单查询不一样如果照抄订单查询的参数容易多传一个没用的 query虽然不报错但不规范。退款处理是异步的我习惯的做法是申请退款后用退款单号轮询三次间隔 5 秒、30 秒、60 秒三次还没到SUCCESS就以回调通知为准不要无限轮询打爆接口。4.2 回调通知验签与解密把明文数据变成可信数据支付和退款都会配置notify_url微信支付成功或退款结果确认后会往这个地址发 POST 请求。回调通知的请求头里同样带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial这几个字段body 是一个 JSON真正的业务数据在resource字段里是加密的。第一步要验签验签原文的拼接顺序和请求签名不一样时间戳\n随机串\n请求体\n用微信支付平台证书的公钥验不是用商户私钥。这是最容易翻车的地方。public boolean verifyNotify(String timestamp, String nonce, String body, String signature) throws Exception { String message timestamp \n nonce \n body \n; Signature verifier Signature.getInstance(SHA256withRSA); verifier.initVerify(platformPublicKey); verifier.update(message.getBytes(StandardCharsets.UTF_8)); byte[] signBytes Base64.getDecoder().decode(signature); return verifier.verify(signBytes); }验签用的platformPublicKey是微信支付平台证书的公钥。平台证书可以通过两种方式维护一是从微信支付官方接口下载并定时更新二是从回调通知的Wechatpay-Serial字段判断证书序列号是否在本地不在就动态拉取。个人项目用静态证书文件问题不大生产环境一定要做证书的定时轮换因为平台证书会过期。验签通过后还要解密resource用的是 AES-256-GCM密钥就是商户平台的 APIv3 密钥32 字节的字符串不是商户私钥。public String decryptResource(JSONObject resource, String apiV3Key) throws Exception { String ciphertext resource.getString(ciphertext); String nonce resource.getString(nonce); String associatedData resource.getString(associated_data); byte[] cipherBytes Base64.getDecoder().decode(ciphertext); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec( apiV3Key.getBytes(StandardCharsets.UTF_8), AES); GCMParameterSpec gcmSpec new GCMParameterSpec( 128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); return new String(cipher.doFinal(cipherBytes), StandardCharsets.UTF_8); }associated_data在微信文档里叫附加数据解密时必须通过updateAAD传进去很多人解密报错就是漏了这一步。nonce是解密用的随机串和请求头里的 Wechatpay-Nonce 不是同一个别搞混。解密后的明文是 JSON 字符串支付回调里有out_trade_no和trade_state退款回调里有out_refund_no和refund_status。处理完业务逻辑后接口必须返回{code: SUCCESS}否则微信会按既定策略重发通知重发间隔一般是 15 秒、15 秒、30 秒、3 分钟、10 分钟等越来越长直到成功或重发上限。支付回调里还有一个细节amount.total要和自己订单表里的金额比对防止有人伪造通知。虽然验签能确认通知来自微信但业务上仍要校验“订单号存在、金额一致、订单状态未处理过”这三道检查缺一不可。我见过只验签不查金额的案例结果订单状态被异常数据带偏。回调处理逻辑必须幂等同一个out_trade_no的通知重复来第二次要直接返回成功不能再走一遍加余额的流程。5. 微信支付v3工具类避坑清单5个高频踩坑点与排查方法5.1 证书序列号搞错导致 401现象是请求发出去后微信返回 401响应体里提示证书序列号错误或签名无效。原因一般是两个一是把微信支付平台证书的序列号当成了商户 API 证书序列号二是商户证书更换后代码里还留着旧序列号。解决方法是启动工具类时从 apiclient_cert.pem 读取证书并打印序列号和商户平台后台的“API 安全”页面做比对换证后同步更新配置不要等到证书过期前一天才换。5.2 金额单位与精度问题现象是支付金额差一分、退款被拒提示金额超限。原因是业务代码里用了 double 或 float 算金额0.1 元加 0.2 元的浮点误差在累计到一定笔数后就会漏出问题。解决方法是工具类内部统一接收 int/long 类型的分单位金额外层负责把元转成分时用BigDecimal并multiply(100).intValue()绝不直接用浮点数做乘法。退款时还要在代码里显式校验refundFee totalFee把校验放在调用微信之前比等微信返回错误再处理快得多。5.3 回调验签失败与重复通知现象是回调接口老是验签失败或者同一个订单收到好几条相同通知。验签失败先看原文拼接顺序回调验签是timestamp \n nonce \n body \n和请求签名的method url ...完全不一样很多人习惯性套用请求签名代码导致失败。重复通知则是微信的正常机制接口返回非 SUCCESS 就会重发。解决方式是验签用平台证书公钥处理逻辑按out_trade_no加唯一索引做幂等处理过的订单直接返回 SUCCESS。5.4 敏感字段加密问题现象是企业打款传了用户姓名后报错或者退款回调的req_info解不开。原因是对两类加密场景没分清一类是请求里的敏感字段姓名、身份证号等需要用微信支付平台证书的公钥做 RSAES-OAEP 加密后放到user_name字段另一类是回调通知里的resource需要用 APIv3 密钥做 AES-GCM 解密。解决方法是把这两种加密分开封装成两个方法分别测试。退款回调解密失败的另一个常见原因是 APIv3 密钥配置错误密钥必须在商户平台手动设置不是商户号也不是证书密码。5.5 POST 超时重试导致重复下单现象是网络抖动时工具类抛超时异常业务方重试了一次结果产生了两个订单或两笔退款。原因是 POST 接口超时只能说明客户端没收到响应服务端可能已经成功。解决方法是为每个业务单号设计幂等键支付的out_trade_no、退款的out_refund_no、转账的out_batch_no重试前先用查单接口确认状态已经成功的直接返回原结果不要重复提交。这条是最贵的踩坑我在生产环境吃过一次亏从那以后所有 POST 提交代码都强制走“先查后写”的模式。6. 企业打款商家转账接口选型、落地代码与验证技巧6.1 商家转账到零钱接口参数与请求体标题里的“企业打款”在微信支付官方文档里现在的产品名是“商家转账”老文档里叫“企业付款到零钱”。接口路径是POST /v3/transfer/batches支持批量转账一次最多三千笔。请求体里外层是out_batch_no、batch_name、batch_remark、total_amount、total_num明细在transfer_detail_list数组里每项包含out_detail_no、transfer_amount、transfer_remark、openid。public JSONObject merchantTransfer(String appId, String batchNo, String openId, int amount) throws Exception { JSONObject detail new JSONObject(); detail.put(out_detail_no, D batchNo); detail.put(transfer_amount, amount); detail.put(transfer_remark, 业务打款); detail.put(openid, openId); JSONArray list new JSONArray(); list.add(detail); JSONObject req new JSONObject(); req.put(appid, appId); req.put(out_batch_no, batchNo); req.put(batch_name, 批量打款); req.put(batch_remark, 自动转账); req.put(total_amount, amount); req.put(total_num, 1); req.put(transfer_detail_list, list); String resp httpClient.post(https://api.mch.weixin.qq.com/v3/transfer/batches, req.toJSONString()); return JSONObject.parseObject(resp); }total_num必须和transfer_detail_list的长度一致明细里的金额总和必须等于total_amount这两个校验微信服务端会做对不上直接报参数错误。接口返回后只能说明受理成功不代表钱已经到账真正到账要等异步回调或主动查询。单笔金额较大时用户需要确认收款未确认的转账单会在一定时间后退回这块规则每个商户号开通时可能不同以商户平台的权限说明为准。6.2 用批次查询接口做自动化对账转账发起后必须主动跟踪结果不能只靠回调。查询接口是GET /v3/transfer/batches/batch-id/{batch_id}通过批次号查批次详情加need_query_detailtrue可以带出每笔明细的状态。我一般在工具类里封装一个对账方法定时任务每 5 分钟扫一遍状态为“受理成功但没有最终结果”的批次拉取明细后更新到本地转账记录表。对于FAIL状态的明细要记录失败原因并触发补偿流程。接口方法路径幂等键统一下单POST/v3/pay/transactions/jsapi 或 nativeout_trade_no订单查询GET/v3/pay/transactions/out-trade-no/{no}无申请退款POST/v3/refund/domestic/refundsout_refund_no商家转账POST/v3/transfer/batchesout_batch_no转账批次查询GET/v3/transfer/batches/batch-id/{id}无我早期做企业打款时只调了发起接口看到返回成功就认为钱已经转出结果有笔单子因为用户未确认被退回财务对账时才发现。现在我的习惯是所有涉及资金的操作发起后 30 秒内必须主动查一次结果并把查询结果写入审计日志回调到达时再补一次状态更新两边都对了才算闭环。工具类做得再顺手也别省掉这笔查询它既是后悔药也是排查问题时的第一现场。希望帮到你。本文还有配套的精品资源点击获取