微信支付V2 Java对接实战:签名机制与核心代码详解

发布时间:2026/9/9 9:52:08
微信支付V2 Java对接实战:签名机制与核心代码详解 简介这是面向Java服务端开发者的微信支付V2实现代码包涵盖统一下单、订单查询、退款、回调验签等核心环节也包含客户端发起支付所需的接口返回处理。代码按业务模块拆分共23个文件以17个Java源码为主配合3个JSP页面用于支付与结果展示2个Jar包提供签名与HTTP通信依赖1个XML文件用于接口与回调地址配置整体压缩后仅208KB便于快速导入项目改造。描述中重点突出了MD5/HMAC-SHA256签名算法、异步回调验证、异常处理、证书管理以及沙箱测试等实践要点能够帮助开发者避开证书配置和回调验签的常见坑。已有1021人学习下载适合刚开始接入微信支付V2、需要参考真实服务端demo的Java工程师可直接对照代码梳理支付流程并迁移到自己的业务系统中。 接私活或者维护老项目时遇到微信支付V2的Java对接需求还是蛮常见的。虽然微信支付官方主推V3但V2接口因为文档稳定、服务商模式支持好至今仍在大量存量项目中运行。这篇博文就结合我个人实操经验把微信支付V2在Java里的对接流程、核心代码、签名机制以及那些文档里不写的坑一次性讲清楚。1. 微信支付V2与V3的核心差异以及为什么还需要V2很多刚开始做支付对接的同学会有个疑问微信支付官方现在都在推APIv3怎么还有人在用V2这个问题的答案其实很简单——存量系统太多迁移成本太高。先看一张我整理的核心对比表对比项V2接口V3接口报文格式XMLJSON签名算法MD5 / HMAC-SHA256基于RSA的SHA256withRSA证书要求退款需要双向证书普通接口不需要所有接口需要商户证书回调验签MD5签名验证平台证书验签需下载平台公钥参数风格snake_case下划线命名camelCase驼峰命名对接门槛较低理解成本小较高需处理证书轮换等官方推荐度老接口维护模式新项目首选V2本质上就是“拼接XML签名然后POST”的模式思路直白只要理解了签名逻辑写起代码来非常顺手。而且V2很多接口不强制要求加载商户证书只有涉及退款时用p12或pem双向证书。这一点在服务商模式特约商户代发、分账等场景尤其方便。另一个V2至今没被完全淘汰的重要原因是不少第三方电商系统、旧版小程序后端、erp系统里的支付模块都是基于V2写的直接换V3意味着前后端联调、退款逻辑、对账单解析全要重写。很多老板不愿意为“内部优化”出这笔预算于是V2就这么一直挂在生产环境里跑着。我的观点是新项目可以优先考虑V3但如果你接手的是老系统或者客户明确指定了V2那学会V2对接依旧是一项能立刻变现的技能。2. 对接前的准备工作与核心机制解读2.1 必须准备的四个配置项在写第一行业务代码之前先把下面这些信息准备好缺一个都会卡住商户号mch_id微信支付商户平台的唯一标识形如16开头的数字。API密钥APIv2密钥在商户平台“账户中心—API安全”里设置32位字符串用于生成签名。注意这跟APIv3的密钥是两码事。回调地址notify_url用户支付成功后微信服务器会向这个地址发一条POST的XML通知必须是公网可访问的HTTPS地址。退款证书申请退款时需要的apiclient_cert.p12含商户证书和私钥在商户平台下载后妥善保存。很多新手在联调阶段最常犯的一个错误是用内网IP或localhost当天回调地址结果永远收不到通知。微信要求回调地址必须是公网且为HTTPS沙箱环境也建议用内网穿透工具临时测试这一点务必提前确认。2.2 签名机制V2接口的灵魂V2接口的签名逻辑是所有对接的核心流程如下将请求参数除去sign本身和值为空的参数按参数名的ASCII码从小到大排序。排序后的参数以键值形式用连接拼接成待签名串。在待签名串末尾追加上key你的API密钥。计算MD5或HMAC-SHA256值转大写后作为sign字段值。举个例子假设有三个参数appidwx1234567890 body测试商品 mch_id1600000000 nonce_strabc123 out_trade_no20250101001 total_fee1将这些参数名按字典序排序后拼成字符串再加上key你的32位密钥最后做MD5。这里要注意字典序排序是ASCII码排序不是中文拼音排序大多数是数字和字母参杂的场景。MapString, String params new TreeMap(); params.put(appid, wx1234567890); params.put(body, 测试商品); params.put(mch_id, 1600000000); params.put(nonce_str, abc123); params.put(out_trade_no, 20250101001); params.put(total_fee, 1); StringBuilder sb new StringBuilder(); for (Map.EntryString, String entry : params.entrySet()) { if (entry.getValue() ! null !entry.getValue().isEmpty()) { sb.append(entry.getKey()).append().append(entry.getValue()).append(); } } sb.append(key).append(你的API密钥); String sign DigestUtils.md5Hex(sb.toString()).toUpperCase();这段代码里用到了TreeMap它会自动按键的自然顺序排序省去了手动排序的麻烦。注意一点拼的时候每个参数后面都带但最后一截是直接拼key...不要再带。注意签名算法里的MD5和官方说的“MD5”是一样的就是常规的32位小写MD5再转大写。HMAC-SHA256方式则需要在请求参数里额外带上sign_typeHMAC-SHA256不传默认按MD5处理。3. 微信支付V2 Java对接完整实操3.1 项目依赖与配置类笔者的项目用的是Spring Boot但下面这段代码不依赖Spring封装的微信SDK全部用原生HttpClient实现方便你迁移到任何Java项目中。只用了一个Apache HttpComponents和Hutool的工具类如果你不想引Hutool完全可以用原生JDK的UUID.randomUUID()和Map替代不影响核心逻辑。dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpclient/artifactId version4.5.14/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-core/artifactId version5.8.22/version /dependency然后建一个配置类承载前面提到的四个核心配置。这里要特别说明一个细节apiKey在真实项目中尽量通过配置中心或环境变量注入不要明文写在代码仓库里否则一旦源码泄露别人就能用你的密钥伪造回调通知。Component ConfigurationProperties(prefix wechat.pay) public class WechatPayConfig { private String appId; private String mchId; private String apiKey; private String notifyUrl; private String refundCertPath; // getter和setter方法省略 }对应的application.yml配置如下wechat: pay: app-id: wx1234567890 mch-id: 1600000000 api-key: 你的32位API密钥 notify-url: https://你的域名/api/pay/notify refund-cert-path: /path/to/apiclient_cert.p123.2 统一下单Native扫码支付微信支付V2的统一下单接口地址是https://api.mch.weixin.qq.com/pay/unifiedorder需要POST一个XML格式的请求体。微信返回的XML中包含prepay_id、code_urlNative支付的二维码链接等信息。代码实现的话我习惯先写一个通用的发送请求方法把请求参数转成XML加上签名发起POST再把响应XML解析成Map这样能少写很多重复代码。下面这段是核心实现public MapString, String unifiedOrder(String body, String outTradeNo, Integer totalFee, String tradeType) { String nonceStr UUID.randomUUID().toString().replaceAll(-, ).substring(0, 32); MapString, String params new TreeMap(); params.put(appid, config.getAppId()); params.put(mch_id, config.getMchId()); params.put(body, body); params.put(out_trade_no, outTradeNo); params.put(total_fee, String.valueOf(totalFee)); params.put(spbill_create_ip, 127.0.0.1); params.put(notify_url, config.getNotifyUrl()); params.put(trade_type, tradeType); params.put(nonce_str, nonceStr); // 生成签名 String sign sign(params); params.put(sign, sign); // 请求体XML String xml mapToXml(params); String responseXml postXml(https://api.mch.weixin.qq.com/pay/unifiedorder, xml); return xmlToMap(responseXml); }需要注意几个点total_fee单位是分不是元。1元要传100这个单位错误导致的金额问题在自测时比较容易踩雷。spbill_create_ip是终端IPNative支付可传用户扫码设备的IP但实测传商户服务器出口IP也能通过。nonce_str随机字符串官方建议长度32位以内用UUID去掉横线截取前32位即可。trade_type常用值有NATIVE扫码、JSAPI小程序/公众号、APPApp支付、MWEBH5支付不同场景对应不同的拉起参数。拿到返回结果后判断return_code和result_code是否都为SUCCESS只有两个都成功才算下单成功。如果是NATIVE模式把code_url生成二维码给用户扫就行了。如果是JSAPI模式还需要用prepay_id调起支付接口这个后面会讲到。3.3 JSAPI支付与小程序支付调起JSAPI支付适用于微信公众号和小程序内支付。统一下单成功后微信返回的是prepay_id但前端不能直接拿这个ID调起支付还需要后端二次签名生成调起支付所需的参数。对于小程序端调起微信支付需要以下5个参数timeStamp、nonceStr、package值固定为prepay_idxxx、signType、paySign。其中paySign的签名算法需要特别注意它的签名串格式比统一下单多了一层拼接规则public MapString, String buildJsapiPayParams(String prepayId) { String timeStamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr UUID.randomUUID().toString().replaceAll(-, ).substring(0, 32); String packageStr prepay_id prepayId; String signStr appId config.getAppId() nonceStr nonceStr package packageStr signTypeMD5timeStamp timeStamp key config.getApiKey(); String paySign DigestUtils.md5Hex(signStr).toUpperCase(); MapString, String result new HashMap(); result.put(timeStamp, timeStamp); result.put(nonceStr, nonceStr); result.put(package, packageStr); result.put(signType, MD5); result.put(paySign, paySign); return result; }这里有一个老手容易犯的迷糊点JSAPI调起支付参数里的签名跟统一下单的签名方式不完全一样。调起支付时是多个字段直接拼成字符串再加密而不需要像统一下单那样做ASCII排序。我当时第一次对接时就因为套用了排序逻辑导致前端一直报paySign验证失败排查了大半天。所以写代码时一定要区分这两个场景。小程序端拿到这些参数后直接调用wx.requestPayment即可前端代码大致这样wx.requestPayment({ timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.package, signType: MD5, paySign: res.data.paySign, success: function () { /* 支付成功 */ }, fail: function () { /* 支付失败 */ } });3.4 回调通知验签与业务处理用户支付成功后微信服务器会异步通知你设置的回调地址。这一步是整个支付流程中最关键的环节因为你的系统要以“微信服务器发的通知”为准去更新订单状态而不是以用户在前端看到的支付成功页为准。回调处理代码要干这几件事从HttpServletRequest里读取Body中的XML字符串。将XML解析成Map。剔除sign字段后用相同签名算法重新计算签名对比是否一致。检查return_code与result_code是否为SUCCESS。检查订单金额是否与本地订单一致。更新本地订单状态为已支付。返回微信规定的XML响应。PostMapping(/pay/notify) public String handleNotify(HttpServletRequest request) throws Exception { String xml readBody(request); MapString, String params xmlToMap(xml); String sign params.get(sign); String calculatedSign sign(params); if (!sign.equals(calculatedSign)) { return xmlreturn_code![CDATA[FAIL]]/return_codereturn_msg![CDATA[签名失败]]/return_msg/xml; } if (SUCCESS.equals(params.get(return_code)) SUCCESS.equals(params.get(result_code))) { String orderNo params.get(out_trade_no); int totalFee Integer.parseInt(params.get(total_fee)); // 加锁、查询本地订单、校验金额、更新状态 // 注意幂等处理如果订单已经是已支付状态直接返回成功避免重复处理 return xmlreturn_code![CDATA[SUCCESS]]/return_codereturn_msg![CDATA[OK]]/return_msg/xml; } return xmlreturn_code![CDATA[FAIL]]/return_codereturn_msg![CDATA[业务失败]]/return_msg/xml; }回调处理有个特别重要的幂等设计原则你的处理逻辑必须能接受重复通知。因为微信官方策略是如果没收到SUCCESS响应会以递增间隔多次重发通知15秒/15秒/30秒/3分钟/10分钟/20分钟/30分钟/30分钟/30分钟/60分钟/3小时/12小时/24小时。所以更新订单状态前先查询一下如果已经是已支付就不要再重复处理直接返回成功。否则可能因为网络抖动导致重复通知把订单状态覆盖成错误的最终状态。注意回调接口返回给微信的响应必须是一段完整的XML且return_code为SUCCESS。如果返回非XML格式或HTTP 200以外的状态码微信会判定为通知失败并继续重发。3.5 查询订单与申请退款订单查询相对简单只需调用https://api.mch.weixin.qq.com/pay/orderquery传out_trade_no或transaction_id即可不需要证书。查询接口最大的用途是做前端轮询查单、掉单补偿以及对账。后端流程里我会写一个定时任务每隔一段时间把超过5分钟未支付但本地订单状态未更新的订单捞出来调用微信查询接口确认最终状态避免因为回调丢失导致订单一直卡在“未支付”。退款接口就必须用到证书了。V2退款接口地址是https://api.mch.weixin.qq.com/secapi/pay/refund需要使用p12证书建立双向HTTPS连接。public MapString, String refund(String outTradeNo, int totalFee, int refundFee) { MapString, String params new TreeMap(); params.put(appid, config.getAppId()); params.put(mch_id, config.getMchId()); params.put(nonce_str, UUID.randomUUID().toString().replaceAll(-, )); params.put(out_trade_no, outTradeNo); params.put(out_refund_no, R outTradeNo); params.put(total_fee, String.valueOf(totalFee)); params.put(refund_fee, String.valueOf(refundFee)); params.put(sign, sign(params)); String xml mapToXml(params); String response postXmlWithCert(https://api.mch.weixin.qq.com/secapi/pay/refund, xml); return xmlToMap(response); }postXmlWithCert方法需要加载p12证书核心代码如下SSLContext sslContext SSLContexts.custom() .loadKeyMaterial(new File(config.getRefundCertPath()), config.getMchId(), config.getMchId()) .build(); SSLConnectionSocketFactory socketFactory new SSLConnectionSocketFactory(sslContext); CloseableHttpClient httpClient HttpClients.custom().setSSLSocketFactory(socketFactory).build();加载p12证书的密码默认是商户号mch_id这一点很多人不知道。如果你下载的是apiclient_cert.pem和apiclient_key.pem则需要用loadKeyMaterial时传入私钥对象和密码写法会更复杂一些。建议直接用p12代码简洁很多。4. 常见问题与排查技巧实录4.1 签名错误签名错误,请检查后再试这个错误在对接期算是最常遇到的。可能原因有几个API密钥填错特别注意商户平台的APIv2密钥和APIv3密钥是两套我用老项目对接时发现有人把v3密钥填进了v2配置导致签名一直不过。参数排序错误TreeMap会自动排序但如果你用了HashMap再手动拼接就很容易漏掉排序步骤。有值为空或null的参数参与了签名官方规定值为空或null的参数不参与签名如果你把空值也拼进去签名必然不一致。编码问题微信官方要求使用UTF-8编码如果你项目默认编码是GBK中文字段签名和验签都会出问题。最好在拼接签名串前统一用UTF-8处理。4.2 回调验签失败回调验签失败绝大多数原因是拿到了HttpServletRequest的输入流后只读了一次读取之后流就关闭了后续再想读就没有内容了。解决方法是把Body一次性读成字符串后续所有解析都用这个字符串。另外还有个小坑回调通知的XML里部分字段是CDATA包裹的解析XML时要用DOM4J或XStream这类库正确处理CDATA避免把CDATA标记本身当成值。4.3 证书加载失败或PKCS12错误加载p12证书时报PKCS12 key store not initialized或Keystore was tampered with, or password was incorrect基本都是密码不对。前面提到了p12文件的密码默认是商户号不是你在商户平台设置的API密钥。另外下载证书后不要随意改动文件内容或格式不要用文本编辑器打开再保存这样会破坏二进制文件结构。4.4 金额对不上处理回调验签时一定记得对比微信返回的total_fee和本地订单金额。如果本地金额是元而微信返回的是分一比较就必崩。还有一个顺序陷阱先验签再改订单状态不要因为逻辑写反导致金额校验被跳过。退款时refund_fee必须小于或等于total_fee且退款金额不能大于可退余额否则会报NOTENOUGH。4.5 订单掉单掉单的原因多种多样最常见的是回调地址网络不通、回调处理代码抛异常导致微信不断重试最后放弃以及本地订单状态更新逻辑被前面某一步拦截。我个人的兜底方案是写一个定时任务扫描一段时间内未支付的订单主动调订单查询接口。查询结果如果显示已支付则由定时任务补单把本地订单置为已支付。这套机制投产之后掉单率基本能降到零。5. 写在最后的一点经验微信支付V2虽然技术栈偏老但只要你理解了“参数排序拼接密钥加密签名”这一条主线基本上所有接口都能举一反三。我见过不少刚接触支付开发的同事一上来就找各种SDK封装反而把最核心的签名机制给忽略了出了问题连排查方向都没有。我的建议是哪怕你最终会引入官方SDK或第三方封装自己也动手写一遍统一下单和回调验签这对理解整个支付流程的帮助非常大。另外还有两个小经验值得分享。第一所有涉及金额的字段在Java代码里尽量用int或long单位分不要用float或double否则会有精度问题。第二对接过程中存放证书、密钥的目录要做好权限控制生产环境不要把p12证书放在Web应用的静态资源目录下建议放在应用外部环境变量指定的路径。自己在本地调试没问题一旦涉及线上安全这些细节就得当回事了。本文还有配套的精品资源点击获取