ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java 实现 H5 微信支付:从统一下单到回调验签的完整链路

Java 实现 H5 微信支付:从统一下单到回调验签的完整链路 简介这份资源面向需要在Java项目中接入H5微信支付的开发者尤其适合电商网站、移动应用等场景下希望打通网页支付流程的中初级工程师。内容围绕微信支付接口文档、统一下单、预支付会话标识生成、H5支付页面唤起、支付回调处理、订单查询、异常重试机制以及安全合规等核心环节展开帮助读者理解从后台下单到前端完成支付、再到异步通知校验的完整链路。资源包共11个文件以5个java源码和3个xml配置为主另含1个properties配置、1个doc部署说明和1个html页面压缩包约16KB结构紧凑便于快速对照代码与配置理解接入细节。目前已有1319人学习下载可作为Java对接H5微信支付的实践参考帮助读者梳理接口调用顺序、回调验证思路与测试环境切换要点减少对接过程中的常见踩坑。1. 从一笔 H5 支付失败说起Java 后端到底要接哪些东西去年帮一个做知识付费的团队排查线上问题用户点「立即购买」后页面卡在微信的中间页既不跳转也不报错后台日志干干净净。最后定位到是下单接口返回的mweb_url里带了redirect_url参数而回调域名没在商户平台配置微信直接把跳转吞掉了。这类问题在 Java 接 H5 微信支付时特别典型——不是代码写错是链路里某个环节的配置和参数没对齐。H5 微信支付也叫 MWEB 支付本质上是微信给「非微信内置浏览器」场景提供的一套支付方案用户在手机浏览器里点支付微信返回一个中间页地址浏览器跳过去后唤起微信客户端完成付款。它和 JSAPI 支付最大的区别在于JSAPI 依赖公众号网页授权拿openid而 H5 支付不需要openid但必须传scene_info里的h5_info字段且对域名有白名单要求。Java 后端要做的是把统一下单、签名、回调验签、订单状态机这几块串起来任何一环参数不对用户看到的就是「支付失败」四个字。这份资源适合两类人一是第一次接微信支付、被签名和证书绕晕的 Java 后端二是已经接过 JSAPI 或 Native想补上 H5 场景的熟手。下面按「下单链路怎么走 → 签名和证书怎么配 → 回调怎么保证不丢单 → 坑在哪」的顺序拆开讲代码基于 Spring Boot 环境用到的 HTTP 客户端和 XML 解析库按项目现有依赖选即可。2. 统一下单接口的 Java 落地从参数拼接到 mweb_url 返回2.1 下单请求的字段清单与签名前的排序逻辑微信 H5 支付的统一下单接口是https://api.mch.weixin.qq.com/pay/unifiedorder请求体是 XML 格式这一点和现在很多 REST 接口不一样第一次接的人容易在 Content-Type 上翻车。核心必填字段包括appid、mch_id、nonce_str、body、out_trade_no、total_fee、spbill_create_ip、notify_url、trade_type其中trade_type固定为MWEB。H5 场景还多一个scene_info格式是 JSON 字符串嵌在 XML 里内容形如{h5_info:{type:Wap,wap_url:https://yourdomain.com,wap_name:商城}}。签名是整条链路里最容易出错的地方。规则是把所有非空参数按参数名 ASCII 码从小到大排序拼接成keyvaluekeyvalue的形式最后拼上key商户API密钥做 MD5 后转大写。注意sign字段本身不参与签名空值参数也不参与。很多人用 TreeMap 自动排序但忘了过滤空字符串导致签名对不上。// 生成微信支付签名参数为待签名的有序 Map public static String generateSign(MapString, String params, String apiKey) { // 过滤空值并按 key 的 ASCII 升序排列 ListString keys params.entrySet().stream() .filter(e - e.getValue() ! null !e.getValue().isEmpty()) .map(Map.Entry::getKey) .sorted() .collect(Collectors.toList()); StringBuilder sb new StringBuilder(); for (String key : keys) { sb.append(key).append().append(params.get(key)).append(); } // 拼上商户密钥后做 MD5结果转大写 sb.append(key).append(apiKey); return DigestUtils.md5Hex(sb.toString()).toUpperCase(); }这段代码里apiKey是商户平台里设置的 32 位密钥不是 APIv3 的密钥两者别混。DigestUtils来自 commons-codec如果项目用 Hutool 就换成SecureUtil.md5()。排序用sorted()默认就是字典序和微信要求的 ASCII 升序一致。过滤空值是关键scene_info如果传空字符串不参与签名但也不该出现在 XML 里否则微信会报参数格式错误。2.2 发起请求与解析 mweb_url拼好 XML 后用 HTTP POST 发出去微信返回的也是 XML。成功时return_code和result_code都是SUCCESS并且带一个mweb_url字段这个地址就是给前端跳转用的。失败时err_code和err_code_des会说明原因比如PARAM_ERROR、ORDERPAID、OUT_TRADE_NO_USED。// 调用统一下单接口并解析返回的 mweb_url public String unifiedOrder(String outTradeNo, int totalFee, String body, String notifyUrl) throws Exception { MapString, String params new HashMap(); params.put(appid, APP_ID); params.put(mch_id, MCH_ID); params.put(nonce_str, UUID.randomUUID().toString().replace(-, )); params.put(body, body); params.put(out_trade_no, outTradeNo); params.put(total_fee, String.valueOf(totalFee)); // 单位分 params.put(spbill_create_ip, 用户真实IP); params.put(notify_url, notifyUrl); params.put(trade_type, MWEB); // scene_info 必须是 JSON 字符串wap_url 要和商户平台配置的域名一致 params.put(scene_info, {\h5_info\:{\type\:\Wap\,\wap_url\:\https://yourdomain.com\,\wap_name\:\商城\}}); params.put(sign, generateSign(params, API_KEY)); String xml mapToXml(params); String resp HttpUtil.post(https://api.mch.weixin.qq.com/pay/unifiedorder, xml); MapString, String result xmlToMap(resp); if (!SUCCESS.equals(result.get(return_code)) || !SUCCESS.equals(result.get(result_code))) { throw new RuntimeException(下单失败: result.get(err_code_des)); } return result.get(mweb_url); }total_fee单位是分传 1 表示 1 分钱这个别搞错否则用户付 100 块你只收到 1 块。spbill_create_ip微信要求传用户端真实 IP如果走 Nginx 反代记得从X-Forwarded-For里取第一个非内网地址传成服务器内网 IP 有时也能过但风控严格时会拒。mweb_url拿到后返回给前端前端用window.location.href跳转即可。如果需要在支付完成后跳回自己的页面可以在mweb_url后面拼redirect_url编码后的地址但前提是回调域名已在商户平台「H5 支付」配置里登记。2.3 前端跳转与 redirect_url 的编码细节前端拿到mweb_url后直接跳转微信会展示一个中间页用户点击「立即支付」后唤起微信客户端。如果传了redirect_url支付完成后会跳回该地址。这里有个细节redirect_url必须做 URLEncode且域名要和商户平台配置的一致否则微信会忽略这个参数用户付完就停在微信的完成页。// 前端拿到后端返回的 mweb_url 后跳转 const mwebUrl https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_idxxxpackagexxx; // 如果需要支付后跳回拼上 redirect_url注意 encodeURIComponent const redirect encodeURIComponent(https://yourdomain.com/pay/result); window.location.href mwebUrl redirect_url redirect;redirect_url不参与签名它是微信中间页自己处理的参数。如果发现支付完成后没跳回先检查域名白名单再检查编码是否正确。有些浏览器对处理不一致建议整个 URL 用encodeURIComponent包一层再跳。3. 支付回调与订单状态机怎么保证不丢单、不重复发货3.1 回调通知的验签与幂等处理微信支付成功后会向notify_url发一个 POST 请求body 是 XML里面带return_code、result_code、out_trade_no、transaction_id、total_fee、sign等字段。后端要做的第一件事是验签把收到的参数除sign外按同样规则排序拼接加上 API 密钥做 MD5和回调里的sign比对。验签通过后再处理业务最后返回一个 XML 告诉微信「收到了」内容是xmlreturn_code![CDATA[SUCCESS]]/return_codereturn_msg![CDATA[OK]]/return_msg/xml。如果不返回或返回失败微信会按策略重试频率大概是 15 秒、15 秒、30 秒、3 分钟、10 分钟这样递增。// 回调验签与幂等处理示例 PostMapping(/pay/notify) public String wxNotify(RequestBody String xmlBody) { MapString, String notify xmlToMap(xmlBody); // 1. 验签 String sign notify.remove(sign); if (!generateSign(notify, API_KEY).equals(sign)) { return xmlreturn_code![CDATA[FAIL]]/return_code/xml; } // 2. 幂等用 out_trade_no 查订单已处理过直接返回成功 String outTradeNo notify.get(out_trade_no); Order order orderService.findByOutTradeNo(outTradeNo); if (order null || order.getStatus() OrderStatus.PAID) { return xmlreturn_code![CDATA[SUCCESS]]/return_code/xml; } // 3. 校验金额防止篡改 if (order.getTotalFee() ! Integer.parseInt(notify.get(total_fee))) { return xmlreturn_code![CDATA[FAIL]]/return_code/xml; } // 4. 更新订单状态发货逻辑放这里 orderService.markPaid(outTradeNo, notify.get(transaction_id)); return xmlreturn_code![CDATA[SUCCESS]]/return_code/xml; }验签时注意notify里可能包含sign之外的额外字段微信文档说参与签名的字段和下单时一致但实际回调里字段更多稳妥做法是把所有非空字段都参与排序。幂等判断必须在更新状态之前用数据库唯一索引或分布式锁兜底否则微信重试时可能重复发货。金额校验不能省曾经有案例是攻击者伪造回调但金额对不上如果只验签不验金额小额支付就能骗过大额订单。3.2 主动查单作为回调的兜底回调不是 100% 可靠网络抖动、服务重启、微信侧延迟都可能导致通知丢失。生产环境一般会加一个定时任务对「已下单未支付」且超过一定时间的订单主动调https://api.mch.weixin.qq.com/pay/orderquery查状态。查单接口同样需要签名返回trade_state为SUCCESS时再走发货逻辑。// 定时查单兜底处理超过 5 分钟仍未收到回调的订单 Scheduled(fixedDelay 60000) public void queryPendingOrders() { ListOrder pending orderService.findPendingBefore(LocalDateTime.now().minusMinutes(5)); for (Order order : pending) { MapString, String params new HashMap(); params.put(appid, APP_ID); params.put(mch_id, MCH_ID); params.put(out_trade_no, order.getOutTradeNo()); params.put(nonce_str, UUID.randomUUID().toString().replace(-, )); params.put(sign, generateSign(params, API_KEY)); String resp HttpUtil.post(https://api.mch.weixin.qq.com/pay/orderquery, mapToXml(params)); MapString, String result xmlToMap(resp); if (SUCCESS.equals(result.get(trade_state))) { orderService.markPaid(order.getOutTradeNo(), result.get(transaction_id)); } } }查单频率别太高微信对单商户的查单 QPS 有限制一般 1 分钟一次、每次批量处理几十条就够。查单结果里trade_state有NOTPAY、CLOSED、REVOKED、USERPAYING、PAYERROR等状态只有SUCCESS才发货CLOSED可以把订单关掉释放库存。3.3 订单状态机的设计要点订单状态建议至少定义CREATED、PAYING、PAID、CLOSED、REFUNDED五个状态。下单成功写CREATED用户跳转微信后可以置PAYING回调或查单确认后置PAID超时未支付置CLOSED退款后置REFUNDED。状态流转要用数据库乐观锁或UPDATE ... WHERE status ?保证原子性避免并发回调把状态改乱。状态触发条件可执行操作CREATED统一下单成功跳转支付、超时关闭PAYING用户已跳转微信等待回调、查单PAID回调验签通过或查单成功发货、退款CLOSED超时未支付或用户取消释放库存REFUNDED退款成功结束状态机不要用字符串硬编码用枚举加转换方法每次变更记录操作日志出问题时能追溯是哪一步把状态改错了。4. 避坑与排查H5 微信支付最常见的五个翻车点4.1 现象签名一直报 SIGNERROR参数看着都对原因通常是三个一是key用错了把 APIv3 的密钥当成 v2 的用二是排序时没过滤空值某个字段传了空字符串参与签名三是 XML 里字段值带了空格或换行解析后没 trim。解决方法是把待签名字符串打印出来和微信官方签名工具比对逐字符看差异。常见做法是写一个单元测试用固定参数生成签名和文档示例比对通过后再接真实请求。4.2 现象mweb_url 拿到了但跳转后提示「商家参数格式有误」这个多半是scene_info的问题。h5_info里的wap_url必须和商户平台「H5 支付」里配置的域名完全一致包括协议和端口。如果配置的是https://yourdomain.com传http://yourdomain.com或带路径都会报错。另外type字段固定Wap大小写敏感。解决方法是登录商户平台在「产品中心 → H5 支付」里核对域名改完后重新下单。4.3 现象回调一直收不到日志里没有任何记录先确认notify_url是公网可访问的不能带内网 IP 或 localhost。其次检查 Nginx 有没有把 POST body 转发过去有些配置会限制 body 大小或过滤 XML。再确认防火墙有没有放行微信的出口 IP微信回调的 IP 段不固定建议不要做 IP 白名单。如果用的是 Spring BootRequestBody接收 XML 需要配置MappingJackson2HttpMessageConverter支持text/xml否则会报 415。解决方法是先用 Postman 模拟微信回调确认接口能通再排查网络。4.4 现象用户付了钱订单还是待支付回调丢了或者验签失败被返回 FAIL微信重试几次后不再通知。这时候靠查单兜底但查单任务如果没跑或者查单也失败订单就卡住了。血泪经验是回调接口里任何异常都要 catch 住并返回 FAIL让微信重试而不是抛 500 让微信以为你没收到。同时查单任务要监控超过 10 分钟还没 PAID 的订单要告警。另外注意out_trade_no全局唯一重复下单会报OUT_TRADE_NO_USED用户重新支付时要生成新的单号或复用未支付的单号。4.5 现象测试环境正常生产环境跳转白屏常见原因是生产域名没在商户平台配置或者redirect_url编码后带了非法字符。还有一种情况是生产环境用了 HTTPS但wap_url配的是 HTTP微信中间页会拒绝跳转。解决方法是生产发布前用真机在 4G 网络下走一遍完整流程别只在微信开发者工具里测。另外 iOS 和 Android 对window.location.href的处理有差异iOS 上如果跳转太快可能被拦截建议加一个用户点击触发。5. 进阶用 APIv3 和平台证书把 H5 支付做得更稳APIv2 的 MD5 签名虽然简单但微信从 2020 年起主推 APIv3用 SHA256-RSA 签名和 AES-256-GCM 解密回调安全性更高。如果你的项目还在用 v2短期没问题但新项目建议直接上 v3。v3 的下单接口是https://api.mch.weixin.qq.com/v3/pay/transactions/h5请求体是 JSON签名放在Authorization头里格式是WECHATPAY2-SHA256-RSA2048 mchid...,nonce_str...,signature...,timestamp...,serial_no...。签名串的构造规则是HTTP方法\nURL路径\n时间戳\n随机串\n请求体\n每行以\n结尾最后一行也要有。然后用商户私钥做 SHA256withRSA 签名再 Base64 编码。回调验签要用微信平台证书的公钥平台证书可以通过https://api.mch.weixin.qq.com/v3/certificates下载但下载接口本身也需要签名。常见做法是启动时拉一次证书缓存起来定期更新。// APIv3 签名构造示例 public String buildV3Authorization(String method, String urlPath, String body) throws Exception { String nonceStr UUID.randomUUID().toString().replace(-, ); long timestamp System.currentTimeMillis() / 1000; // 签名串方法\nURL\n时间戳\n随机串\n请求体\n String message method \n urlPath \n timestamp \n nonceStr \n body \n; // 用商户私钥签名 Signature sign Signature.getInstance(SHA256withRSA); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); String signature Base64.getEncoder().encodeToString(sign.sign()); return String.format( WECHATPAY2-SHA256-RSA2048 mchid\%s\,nonce_str\%s\,signature\%s\,timestamp\%d\,serial_no\%s\, MCH_ID, nonceStr, signature, timestamp, SERIAL_NO); }SERIAL_NO是商户证书的序列号在商户平台下载证书时能看到。私钥文件是apiclient_key.pem加载时用PKCS8EncodedKeySpec注意去掉 PEM 头尾和换行。回调解密用 APIv3 密钥做 AES-256-GCMnonce和associated_data从回调 JSON 里取解密后是明文资源。验证签名是否正确的技巧微信返回的响应头里有Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial用平台证书公钥按同样规则验签通过后再处理业务。如果验签失败先检查平台证书是否过期再检查签名串拼接时 URL 是否带了 query 参数——v3 要求 URL 包含 query string。从那以后我每次接微信支付都会先把「下单 → 跳转 → 回调 → 查单」四条链路在测试环境各跑一遍用真机、真域名、真金额1 分钱确认无误再上生产。这套习惯帮我省了至少三次半夜爬起来排查的麻烦。希望帮到你。本文还有配套的精品资源点击获取
返回列表