简介:这套Java微信支付工具类V3版,面向需要在企业项目中快速接入微信支付与退款的开发人员,覆盖微信支付V3、微信退款V3、交易状态查询以及企业打款到个人零钱四个常用场景。调用方只需传入业务参数即可完成接口对接,无需关心证书签名、报文组织等底层细节,适合中高级Java工程师直接使用或参照封装自有支付模块。压缩包共7个文件,包含5个Java源文件、1个pom.xml依赖配置和1个工程描述文件,整体仅11KB,结构清晰,可直接嵌入Maven工程使用。目前已有2277人学习下载,具备较好的实践参考价值。工具类逻辑完整,方法命名直观,涵盖支付、退款、查单、打款的主链路,可帮助开发者缩短支付功能的开发周期,也可作为二次扩展的基线代码。如遇具体问题,下载后可在评论区留言讨论。
1. 微信支付V3工具类:一个类收口支付、退款、状态查询与企业打款
微信支付v3版上线后,老一套基于 XML、MD5 签名的 V2 工具类基本作废:报文变成 JSON,签名换成 SHA256-RSA2048,还多了平台证书和 AES-256-GCM 回调解密。接手过这类项目的 java 工程师都清楚,V2 到 V3 不是改两个参数的事,而是整套报文体系和证书体系推倒重来。这篇笔记把微信支付V3版里最常用的四件事——JSAPI 下单、退款、交易状态查询、企业打款到零钱——封装成一套 Java 工具类的完整做法,含签名、验签、解密和踩坑记录。适合维护商城、多商户结算、跨境或本地生活项目的 Spring Boot + MyBatis 团队直接抄作业。
2. 搭工具类的底座:证书、密钥、签名与HTTP客户端
2.1 V3和V2的本质差异:为什么工具类必须重写
先把新旧协议摆在一起看,你就知道“换一个签名工具类”这种话有多不靠谱。
| 对比项 | V2 | V3 |
|---|---|---|
| 报文格式 | XML | JSON |
| 签名算法 | MD5 / HMAC-SHA256 | SHA256withRSA(商户 API 私钥) |
| 证书体系 | 商户 API 证书单向使用 | 商户 API 证书 + 微信平台证书双向配合 |
| 回调敏感数据 | 明文 | AES-256-GCM 加密 |
| 幂等控制 | 弱,需业务层保证 | 靠 out_trade_no / out_refund_no 强幂等 |
V2 的 MD5 签名是把所有业务参数拼起来加 key 做哈希,V3 则是把你发送的“原始报文”按固定格式拼成签名串,再用商户私钥做 RSA 签名。也就是说,V3 的请求签名和 HTTP 请求体强绑定,body 改一个空格,签名就失效。而验签方向也反过来了:V2 是微信验你的签名,V3 是你要用平台证书验微信的应答和回调签名。这些差异直接决定工具类的骨架——证书加载、签名、验签、HTTP 请求必须各成模块。
2.2 商户私钥、平台证书、APIv3密钥:四参数初始化
工具类初始化只需要四个核心参数:商户号 mchId、AppId、APIv3 密钥 apiV3Key,以及商户 API 证书。商户 API 证书在微信商户平台“账户中心-API 安全”里申请,平台会引导你生成 CSR,最终下载到的是 apiclient_cert.p12 文件,解压口令默认是商户号。平台证书在同一个页面下载,用于验签。
| 参数 | 来源 | 用途 |
|---|---|---|
| mchId | 商户平台账户中心 | 请求体标识、签名参数 |
| appId | 开放平台 / 公众平台 | 下单、调起支付 |
| apiV3Key | 商户平台 API 安全里设置 | AES-GCM 解密回调、解密敏感字段 |
| apiclient_cert.p12 | 申请 API 证书后下载 | 签名、双向 TLS 客户端证书 |
| 平台证书 .pem | 商户平台下载 | 验签微信应答与回调 |
初始化代码里我会直接把 p12 里的私钥和证书序列号读出来,平台证书也一并加载:
public class WechatPayV3Util { private static String mchId; private static String appId; private static String apiV3Key; private static PrivateKey merchantPrivateKey; private static String merchantSerialNo; private static X509Certificate platformCertificate; public static void init(String mchId, String appId, String apiV3Key, String merchantCertPath, String merchantCertPwd, String platformCertPath) throws Exception { WechatPayV3Util.mchId = mchId; WechatPayV3Util.appId = appId; WechatPayV3Util.apiV3Key = apiV3Key; // 读取商户 API 证书(PKCS12 格式),别名通常只有一个 KeyStore ks = KeyStore.getInstance("PKCS12"); try (FileInputStream in = new FileInputStream(merchantCertPath)) { ks.load(in, merchantCertPwd.toCharArray()); } String alias = ks.aliases().nextElement(); merchantPrivateKey = (PrivateKey) ks.getKey(alias, merchantCertPwd.toCharArray()); X509Certificate cert = (X509Certificate) ks.getCertificate(alias); merchantSerialNo = cert.getSerialNumber().toString(16).toUpperCase(); // 微信支付平台证书,用于验签 CertificateFactory cf = CertificateFactory.getInstance("X.509"); try (FileInputStream in = new FileInputStream(platformCertPath)) { platformCertificate = (X509Certificate) cf.generateCertificate(in); } } }p12 的 keystore 别名不一定是商户号,所以用ks.aliases().nextElement()取第一个最稳。证书序列号要转成十六进制大写,因为 Authorization 头里的 serial_no 用的就是这个格式。平台证书建议放到类路径下或配置目录里,生产环境最好定时调用/v3/certificates接口自动更新,避免平台证书轮换后验签失败。
2.3 请求签名与响应验签:SHA256-RSA2048 的实现
V3 的签名串格式固定为五段,用换行符拼接:
HTTP方法\n URL路径\n 时间戳\n 随机串\n 请求体\n注意几个细节:URL 路径只取 path 和 query,不带域名;GET 请求没有请求体,这一行拼空字符串,但换行符不能省;时间戳是秒级。实现如下:
private static String buildSignMessage(String method, String urlPath, long timestamp, String nonce, String body) { return method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + (body == null ? "" : body) + "\n"; } private static String sign(String message, PrivateKey privateKey) throws Exception { Signature signature = Signature.getInstance("SHA256withRSA"); signature.initSign(privateKey); signature.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signature.sign()); } private static boolean verify(String message, String base64Sign) { try { Signature signature = Signature.getInstance("SHA256withRSA"); signature.initVerify(platformCertificate.getPublicKey()); signature.update(message.getBytes(StandardCharsets.UTF_8)); return signature.verify(Base64.getDecoder().decode(base64Sign)); } catch (Exception e) { return false; } }这里sign和verify用的是同一套算法,区别只在密钥方向。验签时平台证书的公钥来自微信,微信的应答头和回调头里会带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial四个字段,验签的 message 就是“时间戳 + 换行 + nonce + 换行 + 响应体 + 换行”。经常有人把请求签名串和验签串搞混,请求签名是“方法 + 路径”,验签是“时间戳 + nonce”,两套拼接规则别互相套。
2.4 带证书的HTTP客户端与统一请求入口
V3 接口大部分要求双向 TLS,也就是你的 HTTP 客户端要带上商户 API 证书做客户端证书认证。统一请求入口把签名和证书逻辑收拢在一起,业务方法只管传 method、path、body:
private static String doRequest(String method, String urlPath, String body) throws Exception { long timestamp = System.currentTimeMillis() / 1000; String nonce = UUID.randomUUID().toString().replace("-", ""); String message = buildSignMessage(method, urlPath, timestamp, nonce, body); String sign = sign(message, merchantPrivateKey); String authorization = "WECHATPAY2-SHA256-RSA2048 mchid=\"" + mchId + "\",nonce_str=\"" + nonce + "\",timestamp=\"" + timestamp + "\",serial_no=\"" + merchantSerialNo + "\",signature=\"" + sign + "\""; HttpURLConnection conn = (HttpURLConnection) new URL( "https://api.mch.weixin.qq.com" + urlPath).openConnection(); conn.setRequestMethod(method); conn.setRequestProperty("Authorization", authorization); conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("Content-Type", "application/json"); if (body != null) { conn.setDoOutput(true); conn.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8)); } // 用商户私钥和商户证书构造 SSLContext,作为客户端证书 SSLContext sslContext = buildMerchantSslContext(); if (conn instanceof HttpsURLConnection) { ((HttpsURLConnection) conn).setSSLSocketFactory(sslContext.getSocketFactory()); } int code = conn.getResponseCode(); String response = readBody(conn, code == 200 ? conn.getInputStream() : conn.getErrorStream()); if (code != 200) { throw new RuntimeException("微信支付接口调用失败(" + code + "): " + response); } return response; }buildMerchantSslContext()里用之前从 p12 读到的商户私钥和证书构建 KeyManager,这部分是标准 JSSE 代码,网上模板很多,不展开。统一入口的好处是:签名、超时、异常处理都集中在一块,后面每加一个接口,业务代码只写三五行。GET 请求传 null body,签名串里拼空字符串,这个已经在buildSignMessage里处理了。
3. 微信支付V3下单与回调:从prepay_id到支付成功
3.1 构造JSAPI下单请求:金额、回调URL与幂等键
JSAPI 支付适合公众号和小程序内打开的场景,下单接口是POST /v3/pay/transactions/jsapi。下单前需要拿到用户的 openid,openid 必须和下单用的 appId 属于同一个主体,跨主体下单会直接报错。
public JsapiPayResult createJsapiOrder(String openid, String description, String outTradeNo, Integer totalFen, String notifyUrl) throws Exception { Map<String, Object> body = new HashMap<>(); body.put("appid", appId); body.put("mchid", mchId); body.put("description", description); body.put("out_trade_no", outTradeNo); body.put("notify_url", notifyUrl); Map<String, Object> amount = new HashMap<>(); amount.put("total", totalFen); amount.put("currency", "CNY"); body.put("amount", amount); Map<String, Object> payer = new HashMap<>(); payer.put("openid", openid); body.put("payer", payer); String response = doRequest("POST", "/v3/pay/transactions/jsapi", JSON.toJSONString(body)); String prepayId = JSON.parseObject(response).getString("prepay_id"); return buildPayParams(prepayId); }金额单位是分,totalFen 是 int,不要在调用方传 double 再强转。out_trade_no 是商户侧订单号,一个商户号下必须唯一,这就是 V3 的幂等键:同样的 out_trade_no 重复下单,微信会返回已存在的订单或明确报错。notify_url 是 HTTPS 地址,微信支付会把支付结果异步通知到这里。
3.2 调起支付参数二次签名:package 和 paySign
下单接口返回的是 prepay_id,前端并不能直接用,你还需要给它生成调起 JSAPI 支付的一串参数。这一步的签名串只有四段:appId、时间戳、nonceStr、package,不需要 method 和请求体。
private JsapiPayResult buildPayParams(String prepayId) throws Exception { long timestamp = System.currentTimeMillis() / 1000; String nonce = UUID.randomUUID().toString().replace("-", ""); String packageStr = "prepay_id=" + prepayId; String message = appId + "\n" + timestamp + "\n" + nonce + "\n" + packageStr + "\n"; String paySign = sign(message, merchantPrivateKey); return new JsapiPayResult(appId, String.valueOf(timestamp), nonce, packageStr, "RSA", paySign); }返回给前端的对象里 signType 是RSA,不是RSA2,也不是MD5。很多新手拿 V2 的思维写成MD5,前端调wx.requestPayment直接报签名错误。这个签名本质还是 SHA256withRSA,只是拼串规则和接口签名不同,注释里写清楚,别顺手把buildSignMessage拿过来用。
3.3 支付回调验签与AES-256-GCM解密
支付完成后微信会 POST 你的 notify_url,请求头带四个Wechatpay-*字段用于验签,请求体里的resource是三段加密数据:ciphertext、nonce、associated_data。解密用的密钥就是 APIv3 密钥,算法是 AES-256-GCM。
public JSONObject parseNotify(String requestBody, Map<String, String> headers) throws Exception { String signature = headers.get("Wechatpay-Signature"); String serialNo = headers.get("Wechatpay-Serial"); String timestamp = headers.get("Wechatpay-Timestamp"); String nonce = headers.get("Wechatpay-Nonce"); String message = timestamp + "\n" + nonce + "\n" + requestBody + "\n"; if (!verify(message, signature)) { throw new IllegalArgumentException("回调验签失败,serial_no=" + serialNo); } JSONObject resource = JSON.parseObject(requestBody).getJSONObject("resource"); String decrypt = decryptAesGcm(resource.getString("ciphertext"), resource.getString("nonce"), resource.getString("associated_data"), apiV3Key); return JSON.parseObject(decrypt); } private static String decryptAesGcm(String ciphertext, String nonce, String associatedData, String apiV3Key) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec key = new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), "AES"); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8))); if (associatedData != null) { cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); } byte[] plaintext = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plaintext, StandardCharsets.UTF_8); }解密后的 JSON 里有trade_state、out_trade_no、transaction_id、amount.total等字段。注意处理完业务后一定要返回 HTTP 200 且 body 为{"code":"SUCCESS","message":"成功"},如果返回非 200,微信会按策略重试通知。回调接口必须做幂等,同一个 out_trade_no 可能收到多次通知,后面避坑章节会专门说。
4. 微信退款V3与交易状态查询:从退款申请到主动查单
4.1 退款申请接口:金额校验与 out_refund_no 幂等
退款接口是POST /v3/refund/domestic/refunds,它不按订单号退款,而是按“退款单号”退款。out_refund_no 是商户侧退款单号,一个退款单号只能退一次,这就是退款接口的幂等键。同样的退款单号重复提交,微信会返回已有退款单信息,不会重复扣款。
public String refund(String outTradeNo, String outRefundNo, int refundFen, int totalFen, String notifyUrl) throws Exception { Map<String, Object> body = new HashMap<>(); body.put("out_trade_no", outTradeNo); body.put("out_refund_no", outRefundNo); body.put("notify_url", notifyUrl); Map<String, Object> amount = new HashMap<>(); amount.put("refund", refundFen); amount.put("total", totalFen); amount.put("currency", "CNY"); body.put("amount", amount); String response = doRequest("POST", "/v3/refund/domestic/refunds", JSON.toJSONString(body)); return JSON.parseObject(response).getString("refund_id"); }这里的total是原订单支付金额,refund是本次退款金额,两个都以分为单位,且refund不能超过total。原订单如果已经部分退款,再次退款时total依然传原订单总额,refund传本次退款额。这个字段配对经常写错,把total传成剩余金额,微信会报PARAM_ERROR。
4.2 退款回调:复用解密方法,注意事件类型
退款结果通知的报文结构和支付回调一样,同样用Wechatpay-*头验签、resource字段解密,所以第 3 章的parseNotify方法可以直接复用。唯一区别是解密后 JSON 里的事件类型不同,支付回调是TRANSACTION.SUCCESS,退款回调是REFUND.SUCCESS、REFUND.ABNORMAL等。
JSONObject notifyData = parseNotify(requestBody, headers); String eventType = notifyData.getString("event_type"); if ("REFUND.SUCCESS".equals(eventType)) { JSONObject refundInfo = notifyData.getJSONObject("resource").getJSONObject("refund"); // 或按实际字段 String outRefundNo = refundInfo.getString("out_refund_no"); String refundStatus = refundInfo.getString("status"); // 更新本地退款单状态 }有一点容易被忽略:退款是异步过程,提交退款接口成功只代表微信受理了,不代表钱已经退回用户账户。退款可能被银行拦截、银行卡注销导致异常,所以必须以回调或主动查询的结果为准。生产环境里我会把退款回调当作“快照更新”,真正的资金确认靠定时任务主动查询兜底。
4.3 交易状态查询:支付查询与退款查询的统一封装
回调不可靠是分布式系统的常态,回调丢失、回调延迟、重复回调都可能发生。工具类里必须提供主动查询能力,交易状态查询包括两部分:支付订单查询和退款单查询。
public String queryOrderStatus(String outTradeNo) throws Exception { String urlPath = "/v3/pay/transactions/out-trade-no/" + outTradeNo + "?mchid=" + mchId; String response = doRequest("GET", urlPath, null); return JSON.parseObject(response).getString("trade_state"); } public String queryRefundStatus(String outRefundNo) throws Exception { String urlPath = "/v3/refund/domestic/refunds/" + outRefundNo; String response = doRequest("GET", urlPath, null); return JSON.parseObject(response).getString("status"); }支付查询的trade_state常见值:SUCCESS支付成功、NOTPAY未支付、CLOSED已关闭、REFUND转入退款。退款查询的status常见值:SUCCESS退款成功、PROCESSING退款处理中、ABNORMAL退款异常、CLOSED退款关闭。主动查询的典型场景是:支付回调没收到时,按订单号查支付状态;退款超过 5 分钟状态还是 PROCESSING 时,查退款单确认是否要人工介入。
5. 微信支付V3对接避坑排查:五个最容易翻车的现场
5.1 验签失败:签名串末尾缺了换行符
现象:同样的签名代码,下单接口调通了,但回调验签一直失败,日志里报Wechatpay-Signature验不过。原因:回调验签的 message 是“时间戳 + 换行 + nonce + 换行 + 响应体 + 换行”,很多人把它和请求签名串搞混,拼成“方法 + 路径 + 时间戳 + nonce + body”,或者响应体后面少拼一个换行。解决:把验签 message 的拼装单独抽一个方法,只允许时间戳、nonce、响应体三段,末尾换行符用\n补上。开发阶段可以把微信回调的原始头和 body 打全日志,对照文档逐字节检查,这个坑基本一眼就能看出来。
5.2 回调解密失败:associated_data 传成了 null
现象:验签通过了,但 AES-GCM 解密抛AEADBadTagException,或者能解密出来但内容乱码。原因:resource 里的associated_data字段被当成可选参数,代码里判空后没调用updateAAD。微信回调的 GCM 模式把 associated_data 当作认证数据,漏传或传错,解密结果都不正确。解决:解密前先String aad = resource.getString("associated_data"),不为空就必须cipher.updateAAD(aad.getBytes(UTF_8))。注意 also 不要自作聪明把请求头的 nonce 当成解密 nonce,解密 nonce 必须是 resource 里的nonce,回调头的 nonce 只用于验签。
5.3 金额少了1分:double 转 int 的精度陷阱
现象:用户支付 9.9 元,下单传给微信的金额是 989 分,订单创建成功但支付后对不上账。原因:代码里用Double.parseDouble(amount) * 100再强转 int,9.9 在 double 里是 9.899999...,乘以 100 后转 int 变成 989。解决:金额一律用 BigDecimal,而且从字符串构造:new BigDecimal("9.90").movePointRight(2).intValue()。工具类里只认分为单位,前端传入金额统一 String 类型,避免在工具类内部做单位换算,把换算责任放到调用方。
5.4 同一笔订单回调两次:把库存扣成了负数
现象:支付成功的订单重复发货或积分重复到账,数据库里有两条支付成功记录。原因:微信支付的回调有重试机制,网络超时、你返回非 200 都会导致同一笔订单再次通知。解决:用 out_trade_no 加通知里的 transaction_id 做唯一消费。常见做法是 Redis 里SETNX pay_notify:{out_trade_no} 1 EX 300,或者数据库给支付流水表加唯一索引。业务更新时先查流水是否存在,存在直接返回 SUCCESS,不再重复处理。
5.5 企业打款被拦截:transfer_scene_id 和 openid 归属问题
现象:调用商家转账接口返回TRANSFER_SCENE_ID_INVALID或PARAM_ERROR。原因:企业打款到零钱的接口要求传商户后台申请好的转账场景编号,很多项目直接照抄网上的示例值;另一个高频原因是 openid 和下单的 appId 不属于同一主体,用户不是在当前 appId 下授权的 openid。解决:先在商户平台确认商家转账功能已开通、transfer_scene_id 已审批,再到对应的公众号/小程序后台拿 openid。联调阶段先用微信支付官方文档里的在线调试工具确认参数格式,再进代码排查,能少走很多弯路。
6. 企业打款到零钱:商家转账接口的请求参数与结果核验
6.1 发起转账:batch 与 detail 两层幂等键
商家转账到零钱的接口是POST /v3/transfer/batches,它和退款有一个相似点:接口拿到的是“受理成功”,不是“打款成功”。发起转账时有两层幂等键:out_batch_no是商户批次单号,out_detail_no是批次内明细单号,两者配合可以防止重复打款。
public String transferToUser(String openid, String outBatchNo, String outDetailNo, int amountFen, String remark) throws Exception { Map<String, Object> body = new HashMap<>(); body.put("appid", appId); body.put("out_batch_no", outBatchNo); body.put("batch_name", "结算打款"); body.put("batch_remark", "订单结算"); body.put("total_amount", amountFen); body.put("total_num", 1); body.put("transfer_scene_id", transferSceneId); // 商户平台申请的场景编号 List<Map<String, Object>> detailList = new ArrayList<>(); Map<String, Object> detail = new HashMap<>(); detail.put("out_detail_no", outDetailNo); detail.put("transfer_amount", amountFen); detail.put("transfer_remark", remark); detail.put("openid", openid); detailList.add(detail); body.put("transfer_detail_list", detailList); body.put("notify_url", transferNotifyUrl); String response = doRequest("POST", "/v3/transfer/batches", JSON.toJSONString(body)); return JSON.parseObject(response).getString("batch_id"); }单笔打款时total_num传 1,total_amount和明细里的transfer_amount保持一致。如果发起一批多笔,total_amount是所有明细金额之和,这个字段对不上会直接报错。transfer_scene_id是商户在商家转账功能里申请的场景编号,不同业务场景对应不同编号,没有申请就调用接口基本都会被拦截。
6.2 结果核验:提交成功不等于到账成功
转账批次提交后,微信返回 batch_id,但用户是否真的收到钱,需要查批次详情。查询接口是GET /v3/transfer/batches/out-batch-no/{out_batch_no}?need_query_detail=true,返回的 detail 列表里每个明细有transfer_state,只有它是 SUCCESS 才算这笔打款真正完成。
查批次和查订单、查退款一样,都走工具类的doRequest,只是 URL 路径不同。字段含义需要翻文档逐个核,尤其注意批次状态和明细状态是两套枚举,别拿批次的FINISHED去判断明细。回调通知同样只做提醒,查单才做最终确认。
前年第一次接商家转账时,我没查批次详情就给用户发了“已到账”的提示,结果用户一小时后反馈没到账,后台一查明细状态是 FAIL,原因是用户银行卡有交易限制。现在我的习惯是:提交批次后立刻按 out_batch_no 查询,等明细 SUCCESS 再发通知;回调只当提醒,从不拿回调当最终依据。这套“先查后通知”的思路,放在支付、退款、打款三个场景里通用。希望帮到你。
本文还有配套的精品资源,点击获取