资讯动态

微信支付V3 Java工具类:支付、退款、查询与打款全封装

发布时间:2026/10/9 3:01:37 来源:尧图企业网站定制
简介这套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的本质差异为什么工具类必须重写先把新旧协议摆在一起看你就知道“换一个签名工具类”这种话有多不靠谱。对比项V2V3报文格式XMLJSON签名算法MD5 / HMAC-SHA256SHA256withRSA商户 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、bodyprivate 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。下单前需要拿到用户的 openidopenid 必须和下单用的 appId 属于同一个主体跨主体下单会直接报错。public JsapiPayResult createJsapiOrder(String openid, String description, String outTradeNo, Integer totalFen, String notifyUrl) throws Exception { MapString, 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); MapString, Object amount new HashMap(); amount.put(total, totalFen); amount.put(currency, CNY); body.put(amount, amount); MapString, 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, MapString, 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 { MapString, Object body new HashMap(); body.put(out_trade_no, outTradeNo); body.put(out_refund_no, outRefundNo); body.put(notify_url, notifyUrl); MapString, 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再强转 int9.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 { MapString, 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); // 商户平台申请的场景编号 ListMapString, Object detailList new ArrayList(); MapString, 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传 1total_amount和明细里的transfer_amount保持一致。如果发起一批多笔total_amount是所有明细金额之和这个字段对不上会直接报错。transfer_scene_id是商户在商家转账功能里申请的场景编号不同业务场景对应不同编号没有申请就调用接口基本都会被拦截。6.2 结果核验提交成功不等于到账成功转账批次提交后微信返回 batch_id但用户是否真的收到钱需要查批次详情。查询接口是GET /v3/transfer/batches/out-batch-no/{out_batch_no}?need_query_detailtrue返回的 detail 列表里每个明细有transfer_state只有它是 SUCCESS 才算这笔打款真正完成。查批次和查订单、查退款一样都走工具类的doRequest只是 URL 路径不同。字段含义需要翻文档逐个核尤其注意批次状态和明细状态是两套枚举别拿批次的FINISHED去判断明细。回调通知同样只做提醒查单才做最终确认。前年第一次接商家转账时我没查批次详情就给用户发了“已到账”的提示结果用户一小时后反馈没到账后台一查明细状态是 FAIL原因是用户银行卡有交易限制。现在我的习惯是提交批次后立刻按 out_batch_no 查询等明细 SUCCESS 再发通知回调只当提醒从不拿回调当最终依据。这套“先查后通知”的思路放在支付、退款、打款三个场景里通用。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑