简介:这是面向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接口 |
|---|---|---|
| 报文格式 | XML | JSON |
| 签名算法 | 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字段值。
举个例子,假设有三个参数:
appid=wx1234567890 body=测试商品 mch_id=1600000000 nonce_str=abc123 out_trade_no=20250101001 total_fee=1将这些参数名按字典序排序后拼成字符串,再加上&key=你的32位密钥,最后做MD5。这里要注意,字典序排序是ASCII码排序,不是中文拼音排序,大多数是数字和字母参杂的场景。
Map<String, 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.Entry<String, 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_type=HMAC-SHA256,不传默认按MD5处理。
3. 微信支付V2 Java对接完整实操
3.1 项目依赖与配置类
笔者的项目用的是Spring Boot,但下面这段代码不依赖Spring封装的微信SDK,全部用原生HttpClient实现,方便你迁移到任何Java项目中。只用了一个Apache HttpComponents和Hutool的工具类(如果你不想引Hutool,完全可以用原生JDK的UUID.randomUUID()和Map替代,不影响核心逻辑)。
<dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.14</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-core</artifactId> <version>5.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_url(Native支付的二维码链接)等信息。
代码实现的话,我习惯先写一个通用的发送请求方法,把请求参数转成XML,加上签名,发起POST,再把响应XML解析成Map,这样能少写很多重复代码。下面这段是核心实现:
public Map<String, String> unifiedOrder(String body, String outTradeNo, Integer totalFee, String tradeType) { String nonceStr = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 32); Map<String, 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是终端IP,Native支付可传用户扫码设备的IP,但实测传商户服务器出口IP也能通过。nonce_str随机字符串,官方建议长度32位以内,用UUID去掉横线截取前32位即可。trade_type常用值有NATIVE(扫码)、JSAPI(小程序/公众号)、APP(App支付)、MWEB(H5支付),不同场景对应不同的拉起参数。
拿到返回结果后,判断return_code和result_code是否都为SUCCESS,只有两个都成功才算下单成功。如果是NATIVE模式,把code_url生成二维码给用户扫就行了。如果是JSAPI模式,还需要用prepay_id调起支付接口,这个后面会讲到。
3.3 JSAPI支付与小程序支付调起
JSAPI支付适用于微信公众号和小程序内支付。统一下单成功后,微信返回的是prepay_id,但前端不能直接拿这个ID调起支付,还需要后端二次签名生成调起支付所需的参数。
对于小程序端,调起微信支付需要以下5个参数:timeStamp、nonceStr、package(值固定为prepay_id=xxx)、signType、paySign。其中paySign的签名算法需要特别注意,它的签名串格式比统一下单多了一层拼接规则:
public Map<String, 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 + "&signType=MD5&timeStamp=" + timeStamp + "&key=" + config.getApiKey(); String paySign = DigestUtils.md5Hex(signStr).toUpperCase(); Map<String, 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); Map<String, String> params = xmlToMap(xml); String sign = params.get("sign"); String calculatedSign = sign(params); if (!sign.equals(calculatedSign)) { return "<xml><return_code><![CDATA[FAIL]]></return_code><return_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 "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"; } return "<xml><return_code><![CDATA[FAIL]]></return_code><return_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 Map<String, String> refund(String outTradeNo, int totalFee, int refundFee) { Map<String, 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应用的静态资源目录下,建议放在应用外部环境变量指定的路径。自己在本地调试没问题,一旦涉及线上安全,这些细节就得当回事了。
本文还有配套的精品资源,点击获取