
简介面向Java开发者的微信支付V3版工具类针对企业项目中的支付、退款、交易状态查询以及企业打款到个人零钱等高频交易需求提供了一站式方法封装。压缩包共七个文件其中五个源码文件承载具体业务逻辑另有工程描述文件与模块配置文件各一个整体仅十一KB体积小巧、依赖清晰适合快速加入现有后端服务。已有两千二百七十七人浏览学习说明这套实现经过较多开发者检验适合正在对接微信支付V3或希望复用支付工具类的后端工程师。工具类来源于企业项目实际使用调用时只需传入对应参数即可发起支付、退款和状态查询同时覆盖企业付款到零钱旧版场景能帮助规避证书、签名、回调等繁琐细节并基于清晰方法边界支持后续二次扩展与维护。1. 微信支付工具类v3版为什么我不直接裸调接口微信支付从V2升级到V3之后接口签名从MD5换成了RSA公私钥对平台证书还要定期轮换回调数据用AES-256-GCM加密。早期我图省事直接在每个业务模块里裸调HTTP接口代码里到处是签名逻辑的复制粘贴两个月后排查一次退款问题差点通宵。后来我把统一下单、退款、订单查询、企业打款这些分散的逻辑收敛到一个工具类里业务方只需要传参拿结果签名、证书、调用细节全部收口。这个extend-weixin就是这么来的Java后端拿到手改一下配置文件就能支持微信支付v3、微信退款v3、微信交易状态查询以及旧版的企业打款到个人零钱。适合自己维护支付模块的工程师也适合刚接手微信支付项目的同事照着封装思路快速落地。2. 微信支付V3的底层账签名、证书与参数结构在动手写工具类之前得先把V3的底层逻辑捋清楚。V3不区分普通接口和回调接口所有请求都要求带Authorization请求头所有回调都要求验签并解密resource。一步不对接口直接报错而且微信返回的错误信息不会告诉你具体哪里签名错了只能自己排查。所以工具类做得好不好核心在签名、证书、HTTP封装这三件事。2.1 商户号、证书序列号、平台证书的对应关系第一次用V3的人最容易把证书搞混。这里有三样东西商户API证书商户平台上生成下载下来是一对apiclient_key.pem和apiclient_cert.pem前者是私钥用于请求签名平台证书微信支付平台自己的证书用于验签和数据加密需要定期从接口下载APIv3密钥在商户平台设置的对称密钥用于解密回调里的resource字段。三者的关系是请求时用商户私钥对请求参数签名微信用自己的平台证书验签回调时微信用平台证书签名我们用平台证书验签再用APIv3密钥解密resource里的业务数据。工具类的第一个职责就是把这套对应关系配置化。参数配置我一般放在一个WxPayV3Properties里结构是下面的样子public class WxPayV3Properties { // 商户号微信支付商户平台的商户ID private String mchId; // 商户API证书序列号从apiclient_cert.pem里读取 private String mchSerialNo; // 商户私钥内容apiclient_key.pem的字符串 private String privateKey; // APIv3密钥商户平台设置的32位对称密钥 private String apiV3Key; // 平台证书公钥用于验签和解密 private String platformPublicKey; // 回调通知加解密用的AES密钥跟apiV3Key实际上同一个 private String notifySecret; }这里有一个容易被忽略的点证书序列号不是商户号也不是证书文件名里的那串数字。很多报错“证书序列号不正确”都是把商户号当成序列号传了。API证书的序列号可以用openssl命令查openssl x509 -in apiclient_cert.pem -noout -serial返回的十六进制串去掉前导的serial就是序列号。这个参数在每一次请求签名时都会放进Authorization的serial字段微信用它找到对应的公钥来验签一旦不匹配整批请求都会失败。2.2 请求头的签名逻辑一段不能写错的拼串逻辑微信V3的签名串格式是固定的必须按这个顺序拼HTTP请求方法、请求路径、请求时间戳、请求随机串、请求体。请求体为空时用空字符串。拼好之后用商户私钥做SHA256withRSA签名再把签名和元信息拼进Authorization头。工具类里最常见的写法是这样public String buildAuthorization(String method, String urlPath, String body) { // 微信要求的时间戳是秒不是毫秒 long timestamp System.currentTimeMillis() / 1000; String nonceStr UUID.randomUUID().toString().replace(-, ).substring(0, 16); String message method \n urlPath \n timestamp \n nonceStr \n body \n; String signature rsaSign(message, privateKey); // SHA256withRSA return WECHATPAY2-SHA256-RSA2048 mchid\ mchId \,nonce_str\ nonceStr \,signature\ signature \,timestamp\ timestamp \,serial_no\ mchSerialNo \; }这段的坑在于换行符。message里的\n是必须存在的少了任何一个都会导致验签失败。另一个坑是urlPath只包含路径部分不包含域名和query参数比如统一下单是/v3/pay/transactions/native回调验签用的也是完整路径不含?后面的内容。业务方如果直接拿完整URL去拼签名必然对不上。签名算法我直接用Java自带的Signature.getInstance(SHA256withRSA)不需要额外引入加密库。私钥从apiclient_key.pem读取时要先把头尾的-----BEGIN PRIVATE KEY-----和换行去掉。某次部署在Windows上私钥字符串里多了\r\n请求一直报验签失败最后才发现是换行符的问题。这些细节工具类都收口在内部调用方感知不到。2.3 统一HTTP客户端的连接和超时参数V3接口对响应速度要求不苛刻但支付场景不允许超时。我封装HTTP请求时固定了三个参数连接超时5秒、读取超时10秒、连接池最大连接数50。微信支付接口并发通常不高但这个参数组合在促销活动时能扛住瞬时流量同时不会让线程长时间卡在等待上。private String execute(String method, String urlPath, String body) { HttpURLConnection conn (HttpURLConnection) new URL(baseUrl urlPath).openConnection(); conn.setRequestMethod(method); conn.setConnectTimeout(5000); conn.setReadTimeout(10000); conn.setRequestProperty(Authorization, buildAuthorization(method, urlPath, body)); conn.setRequestProperty(Content-Type, application/json); conn.setRequestProperty(Accept, application/json); conn.setRequestProperty(User-Agent, java-wxpay-v3/1.0); // 写入body、读取response的完整逻辑需要处理输入输出流关闭 return readResponse(conn); }这里有一个习惯统一把Accept和Content-Type写死成JSON。V3所有业务接口的请求和响应都是JSON但个别接口比如下载账单返回的是CSV或二进制流所以下载类接口不会走这个方法单独处理。这个封装是工具类的底座后续支付、退款、查询都复用同一套签名和连接逻辑。另一个容易忽略的是平台证书的缓存。平台证书会定期轮换如果每次都重新下载再加验签网络抖动会拖垮回调链路。工具类里用一个静态Map缓存证书指纹加载成功后每24小时刷新一次。这个设计在线上跑了一年没有因为证书轮换出过问题。3. 把支付和退款封装成“传参就返回结果”核心方法设计原理部分清楚了接下来看工具类怎么把接口封装得让业务方只传业务参数、不关心签名细节。这一章我拆成支付、回调、退款三个场景每个场景对应一个可抄的方法设计。3.1 统一下单从参数校验到拿到预支付ID支付v3的统一下单接口核心入参是商户订单号、金额、描述、支付方式。工具类方法我设计成接受一个PayOrderRequest对象返回的是统一下单响应由调用方决定是返回二维码还是拉起小程序支付。public PayOrderResult createOrder(PayOrderRequest request) { // 参数校验金额必须大于0描述不能为空outTradeNo长度不能超32 if (request.getAmount() 0 || request.getDescription() null || request.getOutTradeNo().length() 32) { throw new WxPayException(下单参数不合法); } JSONObject body new JSONObject(); body.put(appid, appId); body.put(mchid, mchId); body.put(description, request.getDescription()); body.put(out_trade_no, request.getOutTradeNo()); body.put(notify_url, notifyUrl); JSONObject amount new JSONObject(); amount.put(total, request.getAmount()); amount.put(currency, CNY); body.put(amount, amount); String resp execute(POST, /v3/pay/transactions/native, body.toJSONString()); // 解析resp取出prepay_id或code_url return JSON.parseObject(resp, PayOrderResult.class); }参数说明amount单位是分不是元。业务层传进来的金额如果在界面上是“9.99”转换必须在进接口前完成工具类不做隐式转换避免不同团队口径不一致。outTradeNo是商户订单号必须保证全局唯一微信会把它作为幂等键同一个订单号重复下单会直接返回原单结果而不是新创建一个单。这一点实际上可以用来做请求去重但业务上得保证编号有意义不要用UUID流水号当订单号否则对账时根本看不出是哪笔交易。native支付返回的code_url可以直接拼成二维码但一般不在工具类里处理因为每个系统的二维码生成方式不一样。工具类负责把接口调用结果原样返回表现层自己处理。3.2 回调验参与业务落库先验证再解密回调处理是支付对接里最容易出错的地方也是工具类价值最大的地方。业务方如果自己写很可能把验签和业务处理混在一起导致同一笔订单被重复入账。我的做法是把验签、解密、业务处理分三步。public PayNotifyResult handleNotify(String requestBody, String wechatpaySignature, String wechatpayTimestamp, String wechatpayNonce, String wechatpaySerial) { // 第一步验签验签通过才继续失败直接抛异常 boolean ok verifyPlatformSign(requestBody, wechatpaySignature, wechatpayTimestamp, wechatpayNonce, wechatpaySerial); if (!ok) { throw new WxPayNotifyException(回调验签失败); } // 第二步解密resource字段 JSONObject obj JSON.parseObject(requestBody); JSONObject resource obj.getJSONObject(resource); String plaintext decryptResource(resource.getString(ciphertext), resource.getString(nonce), resource.getString(associated_data)); // 第三步解析明文返回给业务层落库 return JSON.parseObject(plaintext, PayNotifyResult.class); }解密是AES-256-GCMJava端需要自己拼接nonce和ciphertext不能直接调用现成的decrypt方法。微信官方文档给的示例代码是C语言的Java这边GCMParameterSpec的构造参数是(128, nonce)128是GCM的tag长度不是key长度这里写错会导致“解出的数据是乱码”但又不报错。回调处理结束后业务层要返回{code:SUCCESS,message:成功}给微信否则微信会持续重试。工具类不替业务层决定什么时候返回成功因为有的系统要求回调落库成功才返回SUCCESS有的要求即使业务失败也先收下通知再异步处理。两种场景我都遇到过所以回调方法返回的是解密后的业务对象状态码由调用方自己拼。这样工具类没有绑架业务只是把支付链路做干净。3.3 退款申请与异步结果处理注意退款的金额单位坑退款接口和支付接口的签名逻辑完全一样只是路径不同。退款一般是售后的入口每个系统退款规则差别很大工具类能统一的是参数构造和结果解析。public RefundResult refund(RefundRequest request) { JSONObject body new JSONObject(); body.put(out_trade_no, request.getOutTradeNo()); body.put(out_refund_no, request.getOutRefundNo()); body.put(notify_url, refundNotifyUrl); JSONObject amount new JSONObject(); amount.put(refund, request.getRefundAmount()); amount.put(total, request.getTotalAmount()); amount.put(currency, CNY); body.put(amount, amount); String resp execute(POST, /v3/refund/domestic/refunds, body.toJSONString()); return JSON.parseObject(resp, RefundResult.class); }这里有一个常见误用total不是订单原价是订单当前剩余可退金额对应的原交易金额。如果订单已经部分退款total要填未退款前的原金额微信用它来校验退款金额不能超过剩余可退金额。我第一次封装时以为是订单总价结果一笔部分退款直接报“订单金额超限”。退款接口本身支持同步返回退款单状态但最终结果要以退款回调为准。工具类提供refund方法的同时也提供解析退款回调的方法逻辑跟支付回调完全一致唯一区别是解出来的对象类型不同。退款状态是SUCCESS、CLOSED、ABNORMAL三种业务层收到SUCCESS才更新退款单状态。退款回调比支付回调更不可靠微信文档明确说退款结果以查询接口为准所以第4章的查询逻辑在退款场景里是最重要的兜底。4. 交易状态查询与企业打款旧版两个容易被忽略的边界支付、退款之外交易状态查询和企业打款是这套工具类里的另外两个模块。它们技术难度不高但边界条件很考验后端经验。4.1 交易状态查询的轮询策略不能只查一次交易状态查询接口接收商户订单号返回订单当前状态。支付成功、已关闭、待支付、支付中这几种状态里USERPAYING是最让人头疼的。用户在收银台卡住、二维码被扫但未完成输入密码接口就会一直返回USERPAYING。如果业务系统只查一次然后判定支付失败就会造成用户明明付了钱业务单却标记成未支付。我的处理是封装一个带重试轮询的查询方法最多轮询30秒间隔2秒一次超过30秒还没跳出USERPAYING就返回“支付结果未知”由人工介入。public OrderQueryResult queryOrderWithRetry(String outTradeNo, int maxTimes) { int times 0; while (times maxTimes) { OrderQueryResult result queryOrderOnce(outTradeNo); // SUCCESS、CLOSED、REVOKED、PAYERROR都算终态直接返回 if (!USERPAYING.equals(result.getTradeState())) { return result; } times; try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } throw new WxPayException(订单查询超时用户支付状态未确定); }这里的参数maxTimes建议放在配置里不要写死。扫码点餐场景用户不会一直等着30秒够用但小程序支付用户可能中途切出去输入密码需要60秒以上。把轮询参数暴露出来让业务方决定工具类做到“可配置”比“替你决定”更合适。查询接口本身不支持指定支付方式同一个订单号无论走什么支付渠道都能查到。但要注意订单号必须和下单时保持一致查询请求体里只有out_trade_no和mchid不需要appid这个反直觉设计让不少人以为要用transaction_id查结果字段配错。4.2 企业打款到个人零钱旧版新老接口不能混用企业打款也就是向用户零钱转账在老版里是单独的一套接口。之所以说是旧版是因为微信支付后来更新了商家转账到零钱接口但很多企业项目还在用老的mmpaymkttransfers接口这个资源里封装的也是旧版。旧版接口有几个特殊性第一它不走/v3/路径地址是https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers第二它使用V2的MD5签名和XML格式不是V3的RSA签名和JSON格式。所以工具类里这个模块是单独封装的不能复用前面V3的execute方法。很多人接手旧项目时发现打款报验签失败就是因为拿V3的签名方式去请求V2的接口。public TransferResult transferToBalance(TransferRequest request) { // 旧版企业付款到零钱走V2的MD5签名body是XML格式 MapString, String params new HashMap(); params.put(mch_appid, appId); params.put(mchid, mchId); params.put(partner_trade_no, request.getPartnerTradeNo()); params.put(openid, request.getOpenid()); params.put(check_name, NO_CHECK); params.put(amount, String.valueOf(request.getAmount())); params.put(desc, request.getDesc()); String xml buildV2Xml(params); String sign md5Sign(xml, apiV2Key); String resp executeV2(/mmpaymkttransfers/promotion/transfers, xmlsign); return parseV2Xml(resp, TransferResult.class); }注意check_name字段。NO_CHECK表示不校验用户姓名因为很多场景拿不到用户的实名信息。如果开通了校验姓名接口会额外校验re_user_name字段这个字段必须和微信实名一致否则会报“收款人姓名不一致”。做营销活动需要批量打款时我一般建议用NO_CHECK但前提是有风控措施不然资金风险太大。旧版接口的IP白名单也是独立的。商户平台配置的IP白名单如果只加了V3接口服务器的IP打款请求会报“IP地址未授权”。这个问题经常被忽略因为V3接口正常只有打款功能挂掉。排查时第一件事就是看是不是白名单没加。4.3 幂等与对账工具类只负责接口业务层要负责记账这套工具类里还有一个容易踩的坑支付和退款都提供了幂等保障但企业打款旧版接口并没有真正意义上的幂等键。partner_trade_no如果重复使用微信不会拒绝而是会创建两笔打款单。工具类能做的只是在参数校验时检查partner_trade_no是否为空、是否超长不提供分布式锁。所以业务层调用打款接口前必须先按partner_trade_no查本地流水表存在就不要再调接口。对账逻辑也建议放在工具类之外。支付、退款、打款三个模块都提供查询方法业务层每天凌晨拉一遍交易账单拿微信返回的total_fee和本地订单金额比对。我在项目里吃过一次亏本地订单存的是元微信账单返回的是分对账脚本没做转换导致几百笔订单全部对不上最后发现不是钱丢了是单位不一致。从那以后对账时的单位转换我全部在查询结果解析层完成工具类返回值统一用分为单位业务层展示时再转元。5. 避坑微信支付V3常见问题与排查记录这一章把我在企业项目里真实踩过、帮同事排查过的坑整理成记录每条都是“现象-原因-解决”的形式。遇到同样问题可以直接对照。5.1 现象第一次调用下单接口就报“证书序列号不正确”原因mchSerialNo传的是商户号或者证书文件名。证书文件名里那一串数字确实长得像序列号但它不是。从证书里读取序列号时要把提取出来的十六进制字符串中的serial前缀去掉并且更新证书后要重新缓存。解决用openssl x509 -in apiclient_cert.pem -noout -serial获取序列号然后在配置文件里手动核对。工具类加载配置时校验序列号格式长度必须大于0且不能等于商户号启动时就报错而不是等到线上请求才暴露。5.2 现象回调验签永远失败日志显示“Verify signature error”原因微信回调验签用的是平台证书公钥不是商户自己的证书。很多人在工具类里配置了商户证书的公钥验签自然失败。另一个原因是回调通知的请求头里Wechatpay-Signature会用多个值拼接换行符和逗号处理不对也会验签失败。解决平台证书要从微信接口下载并缓存验签前先读请求头Wechatpay-Serial用它定位缓存的平台证书不要用一个固定公钥去验所有请求。头信息里的Wechatpay-Timestamp、Wechatpay-Nonce要参与验签消息串拼接顺序不能错。我在日志里加了每一步拼串的Debug输出线上排查效率提升很多。5.3 现象退款金额比订单金额少一分钱有时平账有时不平原因金额单位混乱。调用退款接口时amount.refund和amount.total单位都是分但业务库里订单金额是元。如果在进接口前用double直接转换9.99元会变成998分出现浮点误差。解决金额转换全部用BigDecimal.valueOf(9.99).movePointRight(2).intValue()禁止用double计算。工具类在接到int金额参数后如果amount 0直接抛异常。另外退款入参增加幂等字段校验out_refund_no不能为空且不能超过64位。5.4 现象交易状态查询轮询超时后用户实际支付成功原因USERPAYING状态持续超过30秒业务方判定查询超时就抛异常。但用户可能在第三十秒之后才输完密码支付其实成功了。异常被调用方捕获后订单状态没有及时更新用户侧显示未支付但资金已扣。解决轮询超时不能直接判定失败要么返回“结果未知”让业务方进入人工处理流程要么继续以更长间隔查询。我把超时后的策略改为异步查询每5分钟再查一次最多查24小时。工具类里提供queryOrderWithRetry和queryOrderAsync两个方法业务方按场景选择。在状态机设计里强制保留“未知”分支避免用“失败”掩盖“未确定”。6. 验证与压测让工具类上线前把路走通工具类封装好之后不能直接丢给调用方。我的做法是先用沙箱环境跑通一条完整链路再配合日志验证每个环节最后才发布到线上。6.1 沙箱链路验证支付-回调-退款-查询微信支付提供沙箱环境和正式环境参数几乎一致。我用它验证步骤如下步骤操作预期结果1调用createOrder生成预支付单返回prepay_id或code_url2模拟支付结果回调回调验签通过解密出订单号3调用queryOrderWithRetry查询状态从USERPAYING变成SUCCESS4调用refund发起退款返回退款单号状态PROCESSING5轮询退款查询接口状态变为SUCCESS沙箱环境的价值在于能把签名拼接错误和回调验签逻辑检测出来这两个问题在正式环境才暴露的话排查成本特别高。6.2 一个压测前必须确认的参数证书私钥的读取性能工具类在每次请求时都会执行一次RSA签名。私钥对象如果每次签名都重新实例化压测时TPS会明显上不去。我在封装时用PrivateKey对象做了缓存加载一次后复用签名方法变成纯计算单机压测下单接口TPS从400提升到900。验证时可以用一段简单代码统计签名耗时时长public static long signCost(WxPayV3Util util, int times) { long start System.currentTimeMillis(); for (int i 0; i times; i) { util.buildAuthorization(POST, /v3/pay/transactions/native, {}); } return System.currentTimeMillis() - start; }如果平均单次签名超过5毫秒优先检查是不是私钥对象每次重新加载了其次看有没有在签名方法里打印了大量Debug日志。6.3 线上验证的最后一道习惯工具类上线后我习惯先打一笔最小金额的真实订单走一遍完整回调确认日志里能看到验签通过、解密成功、落库成功三步才把流量切换过去。这笔最小金额订单的处理记录会一直保留作为后续排查问题的基线。从那以后我每次接入新的支付渠道都强制先走一遍“最小金额-全链路-看日志三步”再聊业务复杂度。这套习惯帮我避开了大半的线上支付问题。希望帮到你。本文还有配套的精品资源点击获取