
简介微信支付V2 Java代码是一份面向Java后端开发者的支付接入示例适用于电商、内容付费、小程序等需要快速开通微信支付的业务场景。压缩包共23个文件包含17个Java源文件、3个JSP页面、2个Jar依赖和1个XML配置文件整体大小约208KB结构清晰便于直接对照或嵌入现有项目。资源覆盖统一下单、签名生成、异步回调验签、支付结果查询与退款等核心流程并对MD5/HMAC-SHA256签名算法、回调数据校验、证书使用、异常处理等易错环节给出代码实现JSP文件可作为联调入口帮助理解客户端与服务端的交互过程XML配置则方便快速调整商户参数。目前已有1027人学习下载适合具备基础Java知识、希望快速掌握微信支付V2服务端逻辑的开发者参考也可作为排错与二次开发时的实用手册。1. 微信支付 V2 在 Java 里的真实处境老协议为什么还值得花力气先说一个反直觉的结论微信支付 V2 协议虽然官方已不再推荐新商户接入但存量商户、第三方支付渠道包、以及大量外包项目中它依然是绝对主力。原因很直接——V3 要求 APIv3 密钥、双向证书、微信支付平台证书轮换而 V2 只需一个 API 密钥加一个 apiclient_cert.p12很多老系统的支付模块从 2017 年跑到现在没动过。做 Java 开发的尤其是接外包或维护老项目的手上这套 V2 代码基本是吃饭的家伙。这套资源解决的核心问题很具体微信支付 V2 从统一下单、回调验签、订单查询到退款全流程的 Java 实现代码直接可用省去对着官方文档翻来翻去、踩签名坑的时间。适合三类人第一次在 Spring Boot 里接微信支付的新手需要把老项目从 V2 迁移到 V3 但暂时不能动的熟手以及要写支付模块给别人做二次开发的技术负责人。本章先把整体流程捋清楚后续从准备参数开始逐步落到代码和坑。2. 支付前置准备证书、密钥与参数配置的完整梳理2.1 商户平台里到底要拿哪几个参数很多新手在微信支付 V2 的准备工作里卡住不是因为代码难而是不知道去商户平台哪个菜单拿哪几个值。这里把 V2 必须的参数列一份清单按这个去对就不会漏商户号 mch_id商户平台首页右上角一串 10 位以内的数字。API 密钥 key商户平台 - 账户中心 - API 安全 - 设置APIv2密钥32 位字符串自己设置的。AppID公众号或小程序的 AppID在公众平台拿不是商户平台。apiclient_cert.p12商户平台 - 账户中心 - API 安全 - API证书 - 下载证书含商户号的 p12 文件。apiclient_key.pem 和 apiclient_cert.pemV2 虽用 p12 居多但某些第三方 SDK 或 HTTP 工具要求 pem 格式下载的证书包里直接有。这里有一个选型要点V2 的请求签名用的是 MD5 或 HMAC-SHA256而 V3 用的是 RSA-SHA256。你这套代码如果可能被拿去做二次开发建议直接统一用 HMAC-SHA256因为 MD5 在部分新接口已经不支持而且 HMAC-SHA256 安全性更高。2.2 Java 侧的配置文件与初始化拿到上述参数后写一个配置类把这些值统一管理。常见做法是放在 application.yml 里然后用 ConfigurationProperties 绑定。下面这份配置类可以直接用到代码包里Component ConfigurationProperties(prefix wechat.pay.v2) public class WechatPayConfig { private String appId; private String mchId; private String apiKey; private String certPath; // p12 证书路径如 /config/apiclient_cert.p12 private String certPassword; // 证书密码默认就是商户号 mch_id private String notifyUrl; // 支付回调地址公网可访问 // getter and setter 省略 }对应的 application.yml 配置wechat: pay: v2: app-id: wx1234567890abcdef mch-id: 1600000000 api-key: 32位密钥字符串 cert-path: /config/apiclient_cert.p12 cert-password: 1600000000 notify-url: https://api.yourdomain.com/pay/notify逻辑说明这个配置类把证书路径、API 密钥等敏感信息从代码里抽出来改环境时只动配置文件不用重新编译。证书密码这里特别注意——微信官方生成的 p12 证书密码固定为商户号 mch_id 本身不是你自己设置的任意值写错的话初始化证书时会直接报错。参数说明certPath 建议放在 resources 目录下打包时注意不要被 maven 的 filter 机制误处理二进制文件notifyUrl 必须是 HTTPS 且公网可达微信服务器回调不认 HTTP 和内网地址。API 密钥泄露的风险远大于证书泄露因为有了 key 就可以自己构造签名。2.3 HTTP 客户端与 XML 工具的准备V2 协议的消息格式是 XML 不是 JSON这是很多从 V3 转头用 V2 的人最不适应的点。所有请求和响应都是 XML 字符串请求体还要做签名所以需要准备两个基础设施一个能发 HTTPS 请求的 HTTP 客户端一个能解析和构建 XML 的工具类。public class HttpUtil { private static final HttpClient HTTP_CLIENT HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .sslContext(createSslContext()) .build(); private static SSLContext createSslContext() { try { // 加载 p12 证书双向 TLS 认证 KeyStore keyStore KeyStore.getInstance(PKCS12); try (InputStream ins new FileInputStream(certPath)) { keyStore.load(ins, certPassword.toCharArray()); } KeyManagerFactory kmf KeyManagerFactory.getInstance(SunX509); kmf.init(keyStore, certPassword.toCharArray()); SSLContext sslContext SSLContext.getInstance(TLS); sslContext.init(kmf.getKeyManagers(), null, null); return sslContext; } catch (Exception e) { throw new RuntimeException(初始化微信支付证书失败, e); } } public static String postXml(String url, String xml) { // 使用 HTTP_CLIENT 发送 POST 请求body 是 XML 字符串 // 返回响应体的 XML 字符串 } }逻辑说明微信支付 V2 的部分接口需要双向证书认证也就是客户端要拿着 apiclient_cert.p12 证明自己是合法商户。这段代码把 p12 加载进 KeyStore构建带客户端证书的 SSLContext。后续所有请求都通过这个 HTTP_CLIENT 发送不用每次请求都重新加载证书。参数说明HTTP 请求超时一般设 10 秒微信支付接口偶尔会延迟太短容易误报超时太长影响用户体验。这里有实际踩坑——某个项目把超时设成 5 秒高峰期微信接口响应 8 秒左右导致订单实际支付成功但本地查询超时后来统一改成 10 秒并加一次主动查询兜底。XML 工具这边用原生 DOM 解析即可不需要引额外的库。注意 V2 响应的 CDATA 节点处理比如 prepay_id、code_url 等字段用 XPath 取值时要注意节点类型。3. 统一下单与支付参数生成核心链路的代码实现3.1 统一下单请求的签名逻辑与字段组装统一下单是 V2 里最核心的接口交互流程是后端组装订单参数 - 签名 - POST 到微信支付接口 - 拿到 prepay_id - 再生成前端 JSAPI 拉起支付用的参数。这个过程拆开看就是两个签名环节第一个环节就是下面这段代码public String unifiedOrder(UnifiedOrderParam param) { // 1. 组装请求参数 SortedMapString, String params new TreeMap(); params.put(appid, config.getAppId()); params.put(mch_id, config.getMchId()); params.put(nonce_str, generateNonceStr()); // 32位随机字符串 params.put(body, param.getBody()); // 商品描述 params.put(out_trade_no, param.getOutTradeNo()); // 商户订单号 params.put(total_fee, String.valueOf(param.getTotalFee())); // 金额单位分 params.put(spbill_create_ip, param.getSpbillCreateIp()); // 终端IP params.put(notify_url, config.getNotifyUrl()); params.put(trade_type, param.getTradeType()); // JSAPI / NATIVE / MWEB // 2. 生成签名 String sign WechatPaySignUtil.sign(params, config.getApiKey(), HMAC-SHA256); // 3. 拼接 XML 请求体 params.put(sign, sign); String xml XmlUtil.buildXml(params); // 4. 发送请求并解析响应 String respXml HttpUtil.postXml(UNIFIED_ORDER_URL, xml); MapString, String resp XmlUtil.parseXml(respXml); // 5. 校验响应签名防止中间人篡改响应 String respSign resp.get(sign); resp.remove(sign); if (!WechatPaySignUtil.verify(resp, config.getApiKey(), HMAC-SHA256, respSign)) { throw new RuntimeException(统一下单响应签名校验失败); } return resp.get(prepay_id); }逻辑说明这里的关键是 SortedMap签名规则要求把所有参与签名的参数按字典序排序然后拼成 keyvaluekeyvalue 格式末尾拼接 keyAPI密钥再做 HMAC-SHA256 或 MD5最后转大写。TreeMap 天然保证 key 按字典序排列省掉手动排序的麻烦。参数说明total_fee 单位是分不是元这里几乎每个人都会踩一次——金额 10.5 元要传 1050如果直接传入 10.5 或 10.5 会被微信直接拒绝报「订单金额不正确」。out_trade_no 商户订单号要保证唯一长度限制 32 位以内建议用「商户号时间戳随机数」的拼法不要用 UUID 直接做订单号因为 UUID 里有横杠后续在退款、对账查询里容易出问题。3.2 前端拉起支付的二次签名怎么补拿到 prepay_id 之后前端 JSAPI 拉起支付还需要一套参数appId、timeStamp、nonceStr、package值是 prepay_idxxx、signType然后对这五个参数再做一次签名。这次签名的 key 和刚才的一样但是参与签名的字段完全不同很多人在这一步翻车——用统一下单的签名结果直接给前端结果拉起支付失败报「签名错误」。public MapString, String buildPayParams(String prepayId) { SortedMapString, String params new TreeMap(); params.put(appId, config.getAppId()); params.put(timeStamp, String.valueOf(System.currentTimeMillis() / 1000)); params.put(nonceStr, generateNonceStr()); params.put(package, prepay_id prepayId); params.put(signType, HMAC-SHA256); String sign WechatPaySignUtil.sign(params, config.getApiKey(), HMAC-SHA256); params.put(paySign, sign); return params; }逻辑说明这段代码返回的 Map 直接传给前端前端 wx.choosePayment 接口的入参就是这五个字段。注意 timeStamp 单位是秒如果用毫秒会导致签名过期微信那边校验时间戳发现不合法直接报错。参数说明signType 要和统一下单时一致统一下单用的什么算法这里必须同样用那个算法否则签名校验必失败。前端拉起支付失败时报的「invalid signature」基本是这里的问题。3.3 NATIVE 扫码支付的差异点如果是 PC 端扫码支付trade_type 传 NATIVE统一下单成功后返回的不是 prepay_id 而是 code_url前端把这个字符串生成二维码让用户扫。无需二次签名。很多在 Java 里做收银台的人会误以为 NATIVE 也要走 JSAPI 的二次签名流程其实不需要。NATIVE 模式下拿到 code_url 后直接返回给前端生成二维码即可。下单成功后 code_url 的有效期是 2 小时过期需要重新下单。如果项目有 PC 端和移动端两套界面建议分开两个下单入口因为 trade_type 不同前端处理逻辑也不同。3.4 完整下单流程的时序校验把上面几段串起来一个完整的下单用例大致是前端请求后端下单接口 - 后端校验商品状态和库存 - 调微信统一下单 - 拿到 prepay_id 或 code_url - 如果是 JSAPI 再生成二次签名 - 返回给前端拉起支付或展示二维码。这里有个容易被忽略的操作统一下单之前要把订单状态先落库状态设为「待支付」。不是因为微信要求这样做而是你的回调处理逻辑需要一个订单状态字段来保证幂等——如果订单还没落库就先调微信下单那回调来了你都不知道这笔订单是哪个用户的。4. 回调验签与订单查询别把支付结果寄托在回调一次送达上4.1 回调验签的完整逻辑先验签、再解密、后更新支付成功后的回调通知是 V2 里最容易被轻视的环节。很多人在回调里直接解析 XML、拿 out_trade_no 更新订单状态就算完事这是不对的——回调是可以伪造的而且回调通知可能重复发送。正确顺序是先验签再处理业务。RequestMapping(/pay/notify) public String payNotify(HttpServletRequest request) throws Exception { // 1. 读取请求体 XML String xml StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); // 2. 解析 XML 并取出 sign 字段 MapString, String params XmlUtil.parseXml(xml); String sign params.get(sign); params.remove(sign); // 3. 验签 boolean verify WechatPaySignUtil.verify(params, config.getApiKey(), params.get(sign_type) null ? MD5 : params.get(sign_type), sign); if (!verify) { return failResp(签名验证失败); } // 4. 校验关键业务字段 if (!SUCCESS.equals(params.get(return_code))) { return failResp(通信失败); } if (!SUCCESS.equals(params.get(result_code))) { return failResp(支付失败); } // 5. 校验金额防止金额被篡改 String outTradeNo params.get(out_trade_no); Order order orderService.findByOutTradeNo(outTradeNo); if (order.getTotalFee() ! Integer.parseInt(params.get(total_fee))) { return failResp(金额不一致); } // 6. 更新订单状态幂等处理 if (PAID.equals(order.getStatus())) { return successResp(); } orderService.updateStatus(outTradeNo, PAID); // 7. 返回成功标识 return successResp(); }逻辑说明第 5 步金额校验很多人会漏掉。回调里带着商户订单号总金额如果你在更新订单状态前不去比对数据库里的订单金额就等于把一个不设防的支付入口打开——理论上攻击者构造一个同样 out_trade_no 的假回调金额是 0.01 元如果没有金额校验这笔订单就可能被标记为已支付。正常微信支付回调里金额和本地一致才会放行。第 6 步是幂等操作的核心。微信官方说明回调通知可能发送多次且不保证按顺序到达也可能你处理完业务后网络异常导致微信没收到你的成功响应微信会重新发送回调。所以更新前先判断订单状态已经是 PAID 直接返回成功不再重复处理。参数说明failResp 和 successResp 返回的不是 JSON而是 XML 字符串格式要求是给微信回 return_code /return_code 这样的。微信支付如果没有收到 SUCCESS 响应会按照一定时间间隔重复通知间隔逐步拉长——15 秒、15 秒、30 秒、3 分钟、10 分钟、20 分钟、30 分钟、30 分钟、30 分钟、60 分钟、3 小时、3 小时、3 小时、6 小时、6 小时一天内最多通知 15 次。这里的常见错误是把验签放在 return_code 判断之后。如果请求本身是伪造的return_code 可能成功也可能失败验签应该最先做不能因为 return_code 不是 SUCCESS 就跳过验签流程。4.2 订单查询接口给回调兜底的主动方案回调通知机制在极端场景下会丢——微信服务器推送失败或者你自己服务器在回调那一刻宕机了恢复后不做主动查询这笔订单就永远卡在「待支付」。所以正规做法是定时任务主动查微信订单状态来兜底。Component public class OrderQueryTask { Scheduled(fixedRate 60000) // 每分钟跑一次 public void queryPendingOrders() { // 1. 找出数据库中超时未支付的订单 ListOrder pendingOrders orderService.findTimeoutOrders(5, TimeUnit.MINUTES); for (Order order : pendingOrders) { // 2. 调用微信查单接口 MapString, String result queryWechatOrder(order.getOutTradeNo()); String tradeState result.get(trade_state); if (SUCCESS.equals(tradeState)) { // 3. 微信侧已支付但本地回调没收到补更新订单状态 orderService.updateStatus(order.getOutTradeNo(), PAID); } else if (NOTPAY.equals(tradeState)) { // 4. 用户确实没支付可以做关闭订单或标记超时处理 orderService.updateStatus(order.getOutTradeNo(), CLOSED); } } } private MapString, String queryWechatOrder(String outTradeNo) { SortedMapString, String params new TreeMap(); params.put(appid, config.getAppId()); params.put(mch_id, config.getMchId()); params.put(out_trade_no, outTradeNo); params.put(nonce_str, generateNonceStr()); String sign WechatPaySignUtil.sign(params, config.getApiKey(), HMAC-SHA256); params.put(sign, sign); String xml XmlUtil.buildXml(params); String respXml HttpUtil.postXml(QUERY_ORDER_URL, xml); MapString, String resp XmlUtil.parseXml(respXml); // 验签逻辑与下单类似省略 return resp; } }逻辑说明这个定时任务作为回调的补偿机制解决的是「微信支付成功但回调没更新订单」的场景。每 60 秒扫描一次数据库中超过 5 分钟还处于待支付状态的订单然后主动问微信这笔订单的真实状态。这里 5 分钟不是拍脑袋定的而是给用户扫码或者拉起支付留出充足时间如果一上来就把 1 分钟内的订单查一遍大概率是用户还在输入密码的路上。参数说明查询接口的返回字段 trade_state 有多个取值SUCCESS、REFUND、NOTPAY、CLOSED、REVOKED、USERPAYING、PAYERROR。其中 USERPAYING 表示用户正在支付中这种状态不要立刻关单等下一次轮询再查。NOTPAY 可以做关单但要注意如果用户正在用微信扫码支付关单动作会直接中止交易。4.3 查询接口的边界条件超时、异常与订单不存在查单接口还有几个边界条件要处理微信返回 OUT_TRADE_NO_NOT_EXIST 表示订单号不存在可能是商户订单号确实没下单成功也可能是查单参数拼错——比如 out_trade_no 带了空格或者不可见字符。另一个情况是查单返回 SYSTEMERROR这种建议隔几秒重试不要直接把订单标成失败。实际操作中我一般会把查单逻辑做成一个独立方法供定时任务和用户手动查询按钮共用。用户端如果支付后页面卡住前端可以做一次主动查单请求这比让用户干等回调体验好很多。5. 微信支付 V2 避坑指南沙箱测试、金额单位、签名大小写与回调幂等的五个真实案例5.1 沙箱环境的密钥与真实环境不一致现象用微信支付 V2 沙箱环境测试统一下单一直报「签名错误」但切换到真实环境同样的代码却能通。原因微信支付 V2 的沙箱环境并不是直接把真实环境的 API 密钥拿来用而是需要先调用沙箱密钥获取接口用真实密钥去换沙箱专用 key。很多人不知道这一点直接用真实密钥去沙箱环境验签自然全部失败。沙箱环境的 AppID 和商户号还是真实的那套但 API 密钥必须换成沙箱 key。解决代码里做环境隔离沙箱配置和真实配置分开成两套沙箱用 sandboxkey真实环境用 apiclient key。如果项目已经接了配置中心把这个切换做成动态开关测试联调用沙箱生产切回真实。5.2 金额单位不一致导致的金额校验失败现象本地订单金额是 10.5 元微信回调里 total_fee 是 1050数据库里存的是 10.5做金额比对时直接不相等订单永远无法更新为已支付。原因微信支付 V2 所有金额字段单位都是分包括下单、回调、查询、退款。但很多团队在数据库里用 BigDecimal 存元后端编码时忘记转换或者在比对时一个转了一个没转两个值错位。解决统一在数据库里用 int 存分实体类上和微信交互的字段全部用 Integer 类型。如果历史表已经用 BigDecimal 存元比对时手动乘以 100代码里写明确注释——防止三个月后你自己都忘了这行乘 100 是干什么的。这是支付代码里最玄学的一个点但排查起来就几行日志就能定位。5.3 签名结果为小写字母现象同样的参数和密钥本地工具类算出的签名报文里带着 a、b、c 等小写字母微信返回「签名错误」。原因微信支付 V2 的签名规则中MD5 和 HMAC-SHA256 计算结果都需要转成大写后再参与传输和校验但部分方式生成的签名默认是小写 Hex 格式。很多脚手架代码里直接返回 Hex 编码结果没有调 toUpperCase()。解决签名工具类里统一处理生成签名的最后一行强制 sign.toUpperCase()。验签时微信返回的 sign 是大写的本地的签名串也要转大写后再比较。日志打印时保持原始大小写方便排查时对齐看。5.4 回调重复通知导致重复发货现象业务上用户支付成功后发送积分、会员充值等动作执行了两遍排查日志发现微信回调同一笔订单通知了三次每次都走了完整业务。原因回调处理里没有幂等判断直接把「更新订单状态」和「发积分」放在同一个事务里。第二次通知来了之后订单状态虽然已经变成 PAID但发积分只检查了订单是否存在没有检查积分是否已发放。解决所有回调业务处理前先查订单状态PAID 直接 return SUCCESS 不再重复执行。如果有多步业务动作状态更新、发积分、发短信建议引入消息队列或单独的事务表做幂等控制。这里有一个细节——不要在回调里做耗时过长的操作微信支付等待响应有时间限制超时后会立刻重发通知如果回调 10 秒没响应相当于自己触发了一次重复通知。5.5 回调验签报「验证失败」但日志里签名明明是对的现象回调验签一直失败把参数打出来人工拼一遍签名跟微信返回的 sign 完全一致代码里却校验不过。原因XML 解析出来的参数值可能带了 \n 或空格尤其是商品描述字段 body 里有换行时签名串拼接会把换行一起拼进去算出的签名和微信原始签名完全对不上。还有参数顺序问题——TreeMap 排序后拼出的原文顺序与微信服务器签名时使用的字段顺序可能不一致比如 sign_type 这个字段某些接口不参与签名、某些接口参与漏掉或多余一个字段签名就对不上。解决验签前对参数值做 trimbody 等中文描述字段尤其要注意。写一个签名 debug 方法把最终拼接的待签名串完整打出来放到一个在线的 HMAC-SHA256 计算器里跟微信返回的 sign 做比对哪一步不一样一目了然。日志打印时不要用 JSON 序列化直接逐行打印 keyvalue 的拼接串方便复制出来对照。5.6 JSAPI 拉起支付报「支付验证签名失败」现象统一下单成功prepay_id 拿到了二次签名也生成了但前端 wx.choosePayment 返回「支付验证签名失败」。原因二次签名的参数顺序和字段名与统一下单不同。统一下单签名时是 appid、mch_id二次签名时是 appId、packageprepay_idxxx。部分老代码里二次签名用的字段名写错把 package 写成了 packages或者 timeStamp 用毫秒传了 13 位数字。解决二次签名严格按微信文档的五个字段来appId、timeStamp、nonceStr、package、signType。timeStamp 用 System.currentTimeMillis() / 1000 取秒。package 的值必须带上前缀 prepay_id拼完后整体参与签名不要单独拿 prepay_id 的原始值去签。6. 从 V2 平稳迁向 V3 的三个务实建议与一个调试思路先说迁移评估。V2 转 V3 不是改个 URL 和加密方式那么简单两类核心差异决定了工作量第一V3 的请求签名从 MD5/HMAC-SHA256 换成了 RSA-SHA256签名工具类要重写p12 证书换成 pub_key.pem、key.pem、平台证书三个文件并且平台证书有轮换机制本地代码要做证书自动更新或定期手动替换。第二V3 的请求和响应都是 JSON 格式回调通知的数据是 AES-256-GCM 加密解密逻辑比 V2 的 XML 解析多了两个步骤。如果项目只是给内部商户用的收银台V2 短期内继续跑没有大问题但如果是新开发的支付平台从第一天起就该接 V3。给正在迁移的人三个建议。第一个建议是先把回调验签部分抽出来独立成一个服务V2 和 V3 的验签入口分开业务逻辑共用。这样迁移过程中可以只切一部分流量到 V3 验签入口观察一段时间再全量切换。第二个建议是共用订单表和状态机不要为了适配 V3 另建一套订单表。V2 的 out_trade_no 规则、回调幂等逻辑、对账逻辑在 V3 下完全复用重写一遍反而容易引入金额单位、状态不统一这种低级错误。第三个建议是退款接口优先迁移V2 的退款需要加载证书发起请求V3 改成了 API 私钥签名代码结构会更干净。最后说一个调试思路——所有支付相关的联调问题第一件事不是看代码而是开一个抓包工具的代理把请求和响应的原始报文完整录一遍。微信支付 V2 是 XML 明文传输V3 是 JSON 明文传输回调通知里带的是密文但请求头能看到密钥 ID。录下来的报文里有微信服务器返回的完整签名字段、时间戳、随机串把这些原样拿去跟自己的工具类比对。这个习惯帮我解决了大量排查超过半小时的签名类问题。每次遇到支付接口报错先录报文再看日志最后再看代码。从那以后我每次接支付相关的需求都强制走一遍这个流程。希望这个 V2 代码包和这套调试思路能帮你少走点弯路。提示V2 的 API 密钥一旦泄露攻击者可以伪造支付回调泄露后请立即到商户平台重置重置后所有需要签名的接口都要用新 key。本文还有配套的精品资源点击获取