简介微信支付V2 Java服务端示例代码包面向有Java基础的支付接入者聚焦商家与开发者对接微信官方支付接口的核心流程覆盖统一下单、生成预付单、支付结果回调、订单查询与退款等常见环节。代码包共23个文件17个Java文件承载支付逻辑与工具类3个JSP页面便于演示服务端交互与调试2个Jar为微信支付SDK及相关依赖库1个XML为配置文件整体压缩包仅208KB结构轻量易读。目前已有1027人学习/下载。除了可直接运行的demo代码中特别演示了MD5/HMAC-SHA256签名生成、回调验签、异常处理与证书管理思路适合正在对接微信支付V2的Java后端开发者帮助梳理从发起支付到结果通知的完整数据链路及防篡改细节。1. 微信支付V2 Java代码为什么老协议还在线上跑着如果你的项目里出现了“微信支付V2 Java代码”这个检索词大概率不是想学新东西而是手里正压着一个用了三四年的老支付模块可能是某个电商后台、某套SaaS系统甚至是别人留下的没人敢动的历史代码。微信支付V2这套接口协议虽然官方早就主推V3了但存量商户里仍有大量V2单子在持续跑着尤其是那些当年用原生Java HttpClient 手写支付的系统代码风格和如今Spring Boot 封装SDK完全是两个时代的东西。这篇笔记不打算给你讲全量V2文档而是聚焦最常被翻出来重读的三个场景统一下单、回调处理、退款对账。我会直接把我在项目里验证过的Java代码结构、签名工具、XML解析和证书加载方式拆开讲顺手把金额单位、回调重复通知、沙箱密钥混用这些“线上翻车”的坑也一并写了。适合两种人一是新接手V2老系统的后端二是必须在老接口上做二次开发的工程师。看完你至少能把一个支付模块独立跑通而不是对着文档猜签名。2. 微信支付V2的签名规则与Java侧选型先读懂再动手2.1 V2和V3的核心差异XML、MD5签名与Java项目怎么选微信支付V2的协议形态是XML HTTPS POST签名算法默认MD5也支持HMAC-SHA256密钥是商户平台里自设的32位API Key。V3则改成了JSON AES-GCM加密 证书自动下载签名方式也换成了RSA和SHA256。两者最大的区别不只是报文格式而是安全性模型V2的API Key相当于一把万能钥匙谁拿到谁就能查单、退款V3把证书和密钥拆开权限粒度更细。在Java项目里做选型时我一般按下面这个逻辑判断全新项目、没有历史包袱直接走V3。V2的签名逻辑虽然简单但退款需要双向证书回调报文里的敏感字段还要单独解密复杂度并不低。老系统维护、支付模块还能正常跑别动协议版本。V2的坑都在代码里被踩平了强行升级V3反而会引入证书续期、加解密新逻辑。公司内部多个系统共用一套支付能力优先做个支付中台把V2/V3封装成同一套接口给上游而不是让每个业务系统各接各的。V2官方Java SDK确实存在但它停更很久了依赖的老版本HTTP组件和现在Spring Boot 3.x的包冲突很严重。我在一个模拟项目X里试过引入官方V2 SDK结果它自带的旧版xstream直接和项目里的Jackson打架最后我干脆自己写了个轻量工具类反而清爽很多。2.2 Java侧三种常见落法官方SDK、自封装、HttpClient手写在Java生态里实现V2常见做法无非三种适用场景完全不同落法优点缺点适合场景官方SDK签名/解签/解密都有现成方法停止维护依赖老包冲突多老项目已在用且没出过问题自封装工具类代码可控依赖只选自己需要的要自己维护签名、XML解析、解密大多数新建的V2对接模块HttpClient手写能精确控制每次请求细节代码量大容易漏签名细节只是临时调试几个接口我自己的实践经验是在Spring Boot项目里用第三方的轻量XML处理工具 自己写的签名工具类是性价比最高的方案。不要引入重量级SDK只依赖两个小库一个负责XML序列化一个负责HTTP调用。这套组合在JDK 8和JDK 17下都能跑不会因为框架版本被迫改代码。2.3 工程里的包结构与配置项设计V2支付模块的代码组织我习惯按“客户端、签名、回调”三个维度切包不要把支付逻辑和业务Service混在一起com.example.payment ├── config // 微信支付配置属性类 ├── client // HTTP客户端封装统一处理请求/响应 ├── signature // MD5签名、验签、AES解密 ├── model // 请求/响应DTOXML注解 └── controller // 下单、回调入口配置文件里这几个项是必须的缺一个后面都会踩坑wx: pay: app-id: wx1234567890abcdef mch-id: 1900000109 api-key: 32位API密钥 cert-path: /data/cert/apiclient_cert.p12 notify-url: https://api.example.com/pay/v2/notify api-base: https://api.mch.weixin.qq.com注意api-key一定不要直接写在代码里更不要提交到Git仓库。我在某公司审计时见过把API Key明文写在Java常量类里的那基本等于把资金风险敞开着。至少放到配置中心或环境变量里上线前用占位符替换。XML的字段命名和Java对象之间是有映射对应关系的比如appid对应appIdmch_id对应mchId。如果手动拼XML这种下划线转驼峰的映射很容易写漏。我会直接用XmlElement注解或者一个小工具把下划线键转成驼峰字段避免每次手写都错一个字段名的尴尬。3. 统一下单与签名让第一笔V2单子在Java里跑通3.1 下单接口的必传参数与Java模型统一下单是V2里最核心的一个接口支付二维码、JSAPI调起参数都从它返回的prepay_id派生。在Java代码里我一般用一个DTO类承载所有请求参数这里列一下必传项public class UnifiedOrderRequest { private String appid; private String mchId; private String outTradeNo; private int totalFee; // 金额单位分 private String body; private String notifyUrl; private String tradeType; // NATIVE / JSAPI / APP / MWEB private String openid; // JSAPI必传其它可为空 private String spbillCreateIp; private String sign; }这里有一个新手最容易踩的大坑totalFee的单位是分不是元。如果直接把前端传过来的“99.99”这个小数塞进这个字段XML报文里出现带小数点的金额接口会直接报错金额格式错误。正确的做法是金额一律以分为单位在后端流转前端传元后端(int)(Double.parseDouble(amount) * 100)转分或者更严谨地用BigDecimal乘以100后取整。关于这个坑后面避坑章节还会重点展开。另一个值得注意的字段是spbillCreateIp它是下单终端的IP地址。虽然很多老项目传一个固定IP或者直接填127.0.0.1也能过但风控严格的商户号在夜间大额交易时可能因为这个字段异常而触发拦截。我一般取请求来源IP填进去实在拿不到就用网关IP。3.2 生成签名与发送请求的完整代码签名生成是整个V2里最容易写错的部分支付宝是先拼接再RSAV2是先按字典序拼接参数再MD5。逻辑不复杂但漏一个参数、多拼一个空值都会导致签名错误。下面这段是我在项目里直接用的签名工具类做了参数过滤和空值处理public class WxPaySignature { private static final String FIELD_SIGN sign; /** * 生成V2的MD5签名 * param params 待签名参数不含sign * param apiKey 商户平台API密钥 */ public static String sign(TreeMapString, String params, String apiKey) { // 1. 过滤空值与sign字段本身 StringBuilder sb new StringBuilder(); for (Map.EntryString, String entry : params.entrySet()) { String key entry.getKey(); String value entry.getValue(); if (value ! null !value.isEmpty() !FIELD_SIGN.equals(key)) { sb.append(key).append().append(value).append(); } } // 2. 拼接API Key sb.append(key).append(apiKey); // 3. MD5并转大写 return md5(sb.toString()).toUpperCase(); } private static String md5(String content) { try { MessageDigest digest MessageDigest.getInstance(MD5); byte[] bytes digest.digest(content.getBytes(StandardCharsets.UTF_8)); return HexUtil.toHexString(bytes); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(MD5算法不可用, e); } } }逻辑说明第一步先把参数放进TreeMap它会自动按字典序排好这比手动排序靠谱第二步过滤掉空值和sign本身防止把多余的拼进去第三步是所有参数用连接后末尾拼keyAPI密钥做MD5后转大写因为微信服务端校验时也是把签名转大写再比对的。参数说明这段代码依赖你引入一个工具类提供HexUtil.toHexString比如Hutool里的HexUtil或者自己写十行十六进制转换。另一个细节是字符集必须统一用UTF-8如果你项目里某些历史接口用的是GBKMD5结果会完全不一样。请求发送部分我用Hutool的HttpUtil发POST省去写HttpClient模板代码的功夫整个下单流程看起来是这样的public String createNativeOrder(UnifiedOrderRequest request) { // 1. 参数转TreeMap并签名 TreeMapString, String params new TreeMap(); params.put(appid, config.getAppId()); params.put(mch_id, config.getMchId()); params.put(out_trade_no, request.getOutTradeNo()); params.put(total_fee, String.valueOf(request.getTotalFee())); params.put(body, request.getBody()); params.put(notify_url, config.getNotifyUrl()); params.put(trade_type, request.getTradeType()); params.put(spbill_create_ip, request.getSpbillCreateIp()); params.put(sign, WxPaySignature.sign(params, config.getApiKey())); // 2. Bean转XML String xmlBody XmlUtil.mapToXml(params); // 3. POST到统一下单接口 String responseXml HttpUtil.post(config.getApiBase() /pay/unifiedorder, xmlBody); // 4. 解析响应 MapString, String result XmlUtil.xmlToMap(responseXml); if (SUCCESS.equals(result.get(return_code)) SUCCESS.equals(result.get(result_code))) { return result.get(code_url); // NATIVE二维码链接 } // 5. 失败时记录完整报文用于排查 log.error(统一下单失败, 请求: {}, 响应: {}, xmlBody, result); throw new WxPayException(result.get(err_code_des)); }这段代码的注意点有两个第一return_code表示通信是否成功result_code表示业务是否成功两个都要判第二失败时的日志一定要把请求报文和响应报文都打全否则非遗查日志时你会对着一个ORDER_PAID干瞪眼不知道是重复下单还是异常状态。3.3 二维码返回、订单号规则与幂等处理下单成功拿到的code_url是给用户扫码的链接Java后端需要把它转成二维码图片。常见做法是用zxing库生成Base64图片直接返回给前端或者把code_url下发给小程序端用wx.showQrcode渲染两种都行。我偏好把code_url存到Redis里设置2小时过期同时把out_trade_no作为Redis Key的一部分这样用户刷新页面时不用反复调下单接口。订单号规则这块特别提醒一下out_trade_no在商户号内必须全局唯一且长度限制是32个字符以内。我用的是“业务前缀 yyyyMMddHHmmss 4位随机数”比如OD202501101530123456789。曾经见过一个项目用UUID截断当前缀结果因为字符集问题和另一个系统拼单号时撞了追了一天数据才发现是单号重复导致的状态覆盖。幂等处理也不能省同一笔订单重复调下单接口微信侧会返回相同的prepay_id但你在自己数据库里如果没加唯一索引就会插出两条脏数据。我会在out_trade_no上建唯一索引插入时捕获冲突异常命中则复用已存在的支付单。4. 支付回调验签、解密、更新订单状态4.1 回调通知的报文结构与验证顺序支付成功后微信服务器会向notify_url发一个POST请求报文是XML格式里面包含return_code、result_code、out_trade_no、transaction_id、total_fee以及一个需要解密的req_info字段。这个req_info是V2回调里最容易让人懵的地方它不是明文而是经过Base64 AES-256-ECB加密的密文。回调处理的顺序很重要我总结的顺序是先验证签名再解密req_info最后更新业务订单状态。千万不能反过来。如果先解密再验签一旦报文被篡改解密就可能报错或得到乱码你还要回头排查是加密问题还是签名问题白白浪费时间。签名验证的逻辑和下单时完全一样把收到的参数放进TreeMap过滤空值和sign拼keyMD5对比签名是否一致。不一致的直接返回失败应答不要做任何业务处理。4.2 Java实现回调验签与敏感信息解密回调解密是V2里技术含量最高的一个环节密钥不是API Key本身而是API Key取MD5后前16位的小写字符串。这个细节不知道的人真的会卡一整天我第一次对接时用的就是完整API Key结果AES解密出来全是乱码查了半天文档才发现是取MD5后的前16位。下面是完整的回调处理代码一个方法做完验签和解密RequestMapping(/pay/v2/notify) public String wechatNotify(RequestBody String xmlData) { // 1. 解析XML为Map MapString, String params XmlUtil.xmlToMap(xmlData); String sign params.get(sign); // 2. 验签签名不一致直接拒绝 TreeMapString, String sorted new TreeMap(params); String expectedSign WxPaySignature.sign(sorted, config.getApiKey()); if (!expectedSign.equals(sign)) { return failResponse(签名校验失败); } // 3. 解密req_info拿到真实支付信息 String reqInfo params.get(req_info); String decryptKey WxPaySignature.md5(config.getApiKey()).substring(0, 16).toLowerCase(); String plainText decryptReqInfo(reqInfo, decryptKey); // 4. 解析明文中的真实数据 MapString, String payData XmlUtil.xmlToMap(plainText); String outTradeNo payData.get(out_trade_no); String transactionId payData.get(transaction_id); String totalFee payData.get(total_fee); // 5. 业务处理幂等更新订单状态 boolean success orderService.handlePaidNotify(outTradeNo, transactionId, totalFee); return success ? successResponse() : failResponse(业务处理失败); } private String decryptReqInfo(String reqInfo, String decryptKey) { byte[] keyBytes decryptKey.getBytes(StandardCharsets.UTF_8); SecretKeySpec keySpec new SecretKeySpec(keyBytes, AES); // Base64解码后再AES解密 byte[] encryptedBytes Base64.getDecoder().decode(reqInfo); Cipher cipher Cipher.getInstance(AES/ECB/PKCS5Padding); cipher.init(Cipher.DECRYPT_MODE, keySpec); byte[] decrypted cipher.doFinal(encryptedBytes); return new String(decrypted, StandardCharsets.UTF_8); }逻辑说明req_info的解密分两步首先是Base64解码然后才是AES解密。密钥的生成规则是API Key的MD5值取前16位并且要求转小写。这个顺序错一步都不行。参数说明handlePaidNotify里必须做幂等因为微信回调机制是“不成功就反复通知”同一笔订单可能收到多次内容相同的回调。我会在方法里先查订单当前状态如果是已支付就直接返回成功不要重复改状态。回调应答的格式也有讲究。成功应答要返回xmlreturn_code![CDATA[SUCCESS]]/return_code/xml失败应答返回FAIL并附原因。微信收到SUCCESS才停止通知收到FAIL会继续重试重试频率是15秒、15秒、30秒、3分钟……间隔逐渐拉长。如果业务处理失败宁可返回FAIL让微信重试也不要返回SUCCESS然后数据缺失。4.3 通知处理失败时应答与重试语义这里有个血泪经验回调处理里如果有一步依赖外部系统比如调用积分服务超时你用try-catch把异常吞了然后直接返回SUCCESS那这笔订单的积分可能永远补不上。正确做法是捕获所有异常返回FAIL同时在日志里打上out_trade_no和异常栈让重试机制帮你在外部系统恢复后自动补上。另外回调接口要设置合理的超时时间。我曾经见过某项目把Controller的超时设成了5秒但微信服务器的回调请求经常因为公网抖动超过5秒导致请求被容器提前断开微信收不到响应自然不断重试形成日志刷屏和订单状态更新延迟的双重压力。处理完业务数据后立即把XML响应写回不要在回调里做任何耗时操作。5. 微信支付V2 Java代码常见避坑金额、证书与回调连环坑5.1 金额单位分转元还是元转分字符串别用小double现象下单时传了total_fee99.99接口返回金额参数错误或者下单传的是分回调里却按元去更新数据库导致订单金额差了100倍。原因微信支付V2所有金额字段都以“分”为单位但很多后端同学习惯用元做领域模型在往XML里写时忘了做转换而回调里返回的total_fee是字符串“9999”用Integer.parseInt转完直接存库时又把单位当成元了。解决在Java代码里统一用int或long保存“分”接口边界处做显式转换前端传元就用BigDecimal乘以100后再intValue()从微信返回的分转成元展示时用BigDecimal.valueOf(totalFee).divide(new BigDecimal(100))。禁止用Double做乘法再转int浮点误差会导致99.99元变成9998分。5.2 证书序列号报错退款、拉取平台证书需要双向认证现象统一下单没问题一旦调退款接口就报证书验证失败或私钥匹配错误。原因V2的退款、企业付款、拉取平台证书这几个接口需要加载商户证书apiclient_cert.p12而统一下单和查单不需要。很多老项目在下单和退款共用一个HTTP客户端根本就没加载过证书所以下单正常、退款必挂。解决退款接口单独建一个带证书的HTTP客户端。加载代码我一般用Apache HttpClient的SSL上下文KeyStore keyStore KeyStore.getInstance(PKCS12); try (InputStream certStream new FileInputStream(config.getCertPath())) { keyStore.load(certStream, config.getMchId().toCharArray()); } SSLContext sslContext SSLContexts.custom() .loadKeyMaterial(keyStore, config.getMchId().toCharArray()) .build(); CloseableHttpClient httpClient HttpClients.custom() .setSSLContext(sslContext) .build();这里的密码是商户号mch_id不是API Key。有的同学把这两者弄混证书加载直接报Keystore was tampered with, or password was incorrect。另外证书文件放在服务器上要注意读写权限别让其他系统用户能读到私钥。5.3 回调重复通知幂等没做订单状态被覆盖现象一笔订单已经支付并发货了第二天突然收到同一笔订单的支付回调把订单状态从已发货改回了已支付。原因微信回调只认应答结果不认业务处理状态。你的系统在回调处理时如果只是简单update order set status 已支付 where out_trade_no ?那任何一次迟到的重复回调都会把状态“倒退”。解决在支付状态流转逻辑里加状态机校验只允许从待支付流转到已支付其它状态直接忽略。SQL里可以写成update order set status PAID where out_trade_no ? and status PENDING更新行数为0说明是重复通知或状态异常直接返回成功应答即可。5.4 沙箱环境与线上环境配置混用现象联调时一切正常切到线上后所有请求都报签名错误或者商户号不存在。原因微信支付V2有沙箱环境api.mch.weixin.qq.com/sandboxnew沙箱有独立的沙箱密钥需要用真实API Key去换。很多项目的配置文件中沙箱参数和线上参数放在同一个application.yml里切换环境时漏改了API Key或者接口地址。解决我用Spring的Profile机制隔离沙箱和线上配置application-dev.yml // 沙箱配置 application-prod.yml // 线上配置并且在启动时打印当前配置的api-base和mch-id脱敏后的值方便确认环境。另外沙箱环境不支持所有接口退款和下载对账单经常报沙箱环境不支持联调时就该在接口文档里确认好哪些需要返回到线联调环境测。5.5 回调XML里CDATA与普通字符串混用现象回调通知解析正常但发起退款时XML里的![CDATA[xxx]]标签被当成了普通字符串拼进签名。原因V2的XML里return_code等字段是用CDATA包裹的但你手动拼装请求XML时如果拷贝了这种格式签名字段的值就成了![CDATA[abc]]而不是abc签名自然对不上。解决自己组报文时统一用工具类转义不要把CDATA写死在模板里。我在项目里直接规定签名计算用的是纯文本值XML序列化由工具统一处理两边不混用。凡是从文档复制模板来拼XML的代码Review时我会重点检查。6. 最后一块硬骨头退款与对账单的Java落法退款接口的签名算法和统一下单相同唯一的差别在于请求必须走双向证书认证的HTTP客户端。退款参数里out_trade_no和out_refund_no至少得传一个total_fee是原订单金额refund_fee是退款金额两者的单位都是分。这里有一个很值得注意的业务细节退款金额允许小于等于原订单金额但不允许退款金额超过可退余额。如果用户已经部分退款后再申请全额退款接口会返回REFUND_FEE_NOT_MATCH。处理退款响应时同样要判断return_code和result_code两个字段。退款申请成功不代表退款到账最终结果是通过退款回调通知的。我在项目里会为退款单独建一张refund_record表记录out_refund_no、refund_fee、status解析退款回调更新状态这样前后台都能实时看到退款进度而不是只能靠人工去商户平台查。对账单下载是个容易被忽略的工作。V2提供的下载接口返回的不是XML而是纯文本的账单数据编码是GBK里面有按行分隔的交易记录和末尾汇总行。Java侧用InputStreamReader读的时候必须指定GBK否则中文商品名直接乱码。下载后我会把账单按行解析跳过表头两行和末尾的汇总行剩下的逐条比对本地订单表的金额和状态差异这个环节能发现不少“前端显示已支付但回调丢单”的问题。排查线上支付问题时我有个习惯保留了很多年所有支付相关的日志必须包含out_trade_no、transaction_id、total_fee、return_code四个字段并且打印请求和响应报文敏感字段脱敏后再打。这样出问题时按单号一查链路清清楚楚。最后一个个人体会V2这套代码的读写难度不在算法而在边界条件——金额精度、证书加载时机、幂等设计、编码格式每个都是线上事故的高发点。写的时候把单位转换和状态流转单独抽出工具类后面维护的人会感谢你。希望这篇笔记能帮你把V2这个老协议快速理顺少走几趟弯路。本文还有配套的精品资源点击获取