简介:这是一份面向Java开发者的H5微信支付接入实战资料包,主要解决电商网站、移动端网页等场景下的微信公众号H5支付集成问题。内容围绕微信支付核心流程展开,覆盖官方接口文档解读、统一下单获取prepay_id、支付链接与二维码生成、H5页面调起支付、异步回调验签、订单查询兜底、异常重试与安全合规等要点,同时也包含商户号、商品描述、交易金额、回调URL等关键参数说明,适合需要快速落地支付功能或理解支付闭环的初中级Java工程师。压缩包共11个文件,以5个Java源码、3个XML配置、1个properties配置、1份部署必看doc和1个HTML测试页面为主,整体仅16KB,结构轻量且路径清晰,便于对照pom.xml与wxpay-test材料梳理依赖和调试环境。部署说明文档记录了测试与正式环境切换、参数配置、签名生成等注意事项,能帮助规避回调地址错误、签名失败等常见坑;HTML测试页面则提供了模拟支付场景,方便在接入阶段快速验证流程。目前已有1320人学习下载,是一份轻巧实用的H5支付对接参考。
1. 从手机网页到微信收银台:这套 Java 微信支付 H5 链路到底长什么样
在手机浏览器里打开商城页面,点“微信支付”,下一秒被带进微信收银台,这个链路就是 H5 支付的核心动作。很多 Java 后端会把微信内置浏览器的公众号支付和这个搞混,结果同一套代码在普通浏览器里死活拉不起收银台。区别在 User-Agent:微信 App 内的浏览器(MicroMessenger)走 JSAPI,普通手机浏览器走 H5。我拆的这套 Java 微信支付 H5 资源包,把服务端签名、下单、回调验签解密、查单对账整条链路落成了可直接改的代码,适合正在做商城、预约系统、活动页的 Java 后端,也适合外包交付时拿去当底子改。
2. 接入前准备:四个密钥参数与一条 UA 分流逻辑
H5 支付接入前,先把商户平台上的身份信息捋清楚。你后面每个请求签名、每一条回调解密,都依赖这几个参数,少一个请求都发不出去。这个环节不太能靠代码调试解决,配置错了就全链路返工。
2.1 商户平台先备齐四个参数
| 参数 | 在哪拿 | 作用 |
|---|---|---|
| 商户号 mchid | 商户平台-账户中心-商户信息 | 标识收款主体,下单、查单都要带 |
| 服务号 appid | 公众平台-开发-基本配置 | 与商户号绑定,H5 支付的来源标识 |
| APIv3 密钥 | 商户平台-API安全-APIv3密钥 | 32 字节对称密钥,回调数据解密用 |
| 商户私钥 + API 证书序列号 | 申请 API 证书时生成的 apiclient_key.pem | 请求签名私钥,序列号是签名头的一部分 |
mchid 和 appid 是两个体系的东西,很多人混在一起。H5 支付里 appid 要用已完成微信认证且与商户号绑定的服务号;个人主体、未认证的服务号,拉起收银台时会报错。APIv3 密钥是 32 字节的字符串,自己设置后要妥善保管,回调解密就靠它,丢了只能重置。申请 API 证书时生成的商户私钥建议直接放到配置中心,不要提交进代码仓库。
2.2 先分流:User-Agent 决定走 H5 还是 JSAPI
微信对 H5 支付有约束:在微信 App 内置浏览器打开的页面,不允许直接拉起 H5 收银台,强制走 JSAPI。所以业务入口处要做 UA 判断,别等到下单接口报了H5_PARAM_ERROR再回头排查。
// 入口处先分流:微信内置浏览器走JSAPI,普通浏览器走H5支付 String userAgent = request.getHeader("User-Agent"); boolean isWeChatBrowser = userAgent != null && userAgent.contains("MicroMessenger"); if (isWeChatBrowser) { // 微信内置浏览器:走JSAPI,用chooseWXPay调起 } else { // 普通手机浏览器:走H5支付,后端返回mweb_url给前端跳转 }这段代码要放在生成支付方式的前置接口里,不要只放在下单一处,因为前端需要根据这个结果渲染不同的支付按钮。MicroMessenger是微信内置浏览器的固定 UA 标识,几乎不会变;userAgent为 null 时默认按非微信处理,不要直接抛异常。如果你在内网调试时 UA 被浏览器插件改写过,这里可能判断错,排查时先打印日志看真实 UA。
2.3 手写签名还是直接上官方 SDK
支付接入最大的阻力不是接口多,而是签名、验签、解密这些环节散落在代码里不好排查。我一般建议先手写一遍签名逻辑,再上官方 SDK,这样 Header 里每个字段出错时都知道去哪查。手写方式适合理解底层原理,官方 SDK 适合生产环境快速交付。
| 方式 | 优点 | 缺点 |
|---|---|---|
| 手写签名 | 每个字段可控,出错好排查 | 平台证书轮换要自己跟进 |
| 官方 SDK | 签名验签自动处理 | 出问题是个黑匣子,只能看底层日志 |
// 官方SDK方式:配置一次,后续请求自动带签名 HttpClient httpClient = WechatPayHttpClientBuilder.create() .withMerchant(mchId, serialNo, privateKey) .withValidator(new WechatPayValidator(platformCertVerifier)) .build(); // 后续通过该client发起的请求,SDK会自动计算Authorization头并校验响应上面这段是官方 SDK 的典型配置,withMerchant里传入商户号、商户证书序列号和商户私钥,withValidator传入平台证书验证器。资源包里手写签名和 SDK 两套实现都留了。生产环境我更建议用 SDK,因为平台证书更新时 SDK 能跟着换,手写方案还要每月自己跑一遍证书下载脚本,忘了就回调验签失败。
3. 下单与拉起:从构造 JSON 到 mweb_url 跳转
H5 支付的核心动作就两个:后台下单拿 mweb_url,前端跳转进收银台。下单接口的请求体结构不复杂,但每个字段都有讲究,尤其是场景信息,少传一个就报错。这一章把下单接口拆开讲。
3.1 H5 下单接口参数:字段不多,但每个都决定成败
H5 支付的下单接口是POST https://api.mch.weixin.qq.com/v3/pay/transactions/h5,注意不是/v3/pay/transactions/jsapi,很多人第一步就复制错。这个接口和 JSAPI 最大的区别是必须传scene_info.h5_info场景信息,这个字段是 H5 支付的身份证。
| 参数 | 必填 | 说明 |
|---|---|---|
| appid | 是 | 已绑定商户号的服务号 APPID |
| mchid | 是 | 商户号 |
| description | 是 | 商品描述,会展示在收银台 |
| out_trade_no | 是 | 商户订单号,必须唯一 |
| notify_url | 是 | 回调地址,公网 HTTPS |
| amount.total | 是 | 总金额,单位是分 |
| scene_info.h5_info | 是 | H5 专用场景信息 |
| redirect_url | 否 | 支付完成后的回跳地址 |
// 以fastjson为例构造请求体,金额单位是“分”,int类型 int totalFee = 990; // 9.90元,由前端传参换算后得到 JSONObject amount = new JSONObject(); amount.put("total", totalFee); amount.put("currency", "CNY"); JSONObject h5Info = new JSONObject(); h5Info.put("type", "Wap"); h5Info.put("wap_url", "https://pay.shop.example.com"); // 与支付授权域名一致 h5Info.put("wap_name", "模拟项目X"); JSONObject sceneInfo = new JSONObject(); sceneInfo.put("h5_info", h5Info); JSONObject body = new JSONObject(); body.put("appid", appId); body.put("mchid", mchId); body.put("description", "测试商品-联名款水杯"); body.put("out_trade_no", orderNo); body.put("notify_url", "https://pay.shop.example.com/api/pay/callback"); body.put("amount", amount); body.put("scene_info", sceneInfo); body.put("redirect_url", "https://shop.example.com/order/detail?orderNo=" + orderNo);out_trade_no是商户订单号,我习惯用“yyyyMMddHHmmss+6位随机数”拼,避免同一天内并发下单撞号。description会展示在用户收银台页面上,写清楚即可,别塞下单时间之类的动态内容。h5_info里的wap_url是发起支付的网页地址,要求与商户平台配置的支付授权域名同域名,否则报H5_PARAM_ERROR;wap_name是这个网页的名称,中文最多 6 个字。我把redirect_url放在此处一并传,用户支付成功后回跳到此地址,不传默认停在“支付成功”页面。
3.2 Authorization 头:四行签名串和两个易错点
V3 接口的请求头需要WECHATPAY2-SHA256-RSA2048签名。签名的原材料是:请求方法、规范化 URL、时间戳、随机串、请求体。这个签名串格式是固定的,五个部分之间用换行符分隔。
String message = httpMethod + "\n" + canonicalUrl + "\n" + timestamp + "\n" + nonce + "\n" + bodyStr + "\n"; // 最后一个字段后面也要有换行符 Signature signer = Signature.getInstance("SHA256withRSA"); signer.initSign(privateKey); signer.update(message.getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(signer.sign()); String authHeader = "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + mchId + "\"," + "nonce_str=\"" + nonce + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + merchantSerialNo + "\"," + "signature=\"" + signature + "\"";canonicalUrl对 POST 就是去掉 query 的/v3/pay/transactions/h5;对 GET 查单要走/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid=xxx这种带 query 的路径,query 要原样拼进去再参与签名。这里有两个高频错误:一是serial_no用的是商户 API 证书的序列号,不是平台证书序列号,两者搞混会报SIGN_ERROR;二是签名串每个字段间用\n换行,最后一个bodyStr后面也要补一个\n,这个换行符丢了,微信返回验签失败。nonce每次请求都要随机生成,不能复用,我一般用 UUID 去掉横线取 32 位。
3.3 mweb_url 跳转与前端时序
下单成功返回的 JSON 里,核心字段是mweb_url。前端拿到后直接跳转,不要在中间再加一个自己的中转页,那样容易被风控判定为异常调起。这块前端代码很短,但时序处理决定体验。
// 前端:拿到后端返回的 mweb_url 后直接跳转 const res = await fetch('/api/pay/h5/createOrder', { method: 'POST', body: JSON.stringify({ orderNo: currentOrderNo }) }); const { mwebUrl } = await res.json(); window.location.href = mwebUrl; // 微信收银台mweb_url有有效期,用户点了支付但停在前置页面超过 2 小时,再点跳转会失效,需要重新下单。前端这里最好加一个“支付生成中”的 loading 状态,避免重复点击产生多个重复订单。为了兼容微信 App 内置浏览器打开的场景,前端需要通过 UA 把 H5 和 JSAPI 入口分开,这个判断要和后端保持一致,别前端跳了、后端又在下单时走 JSAPI 逻辑。另外,下单接口返回的 HTTP 状态码要判断,200才解析 mweb_url,4xx多半是参数问题,5xx是微信侧问题,重试即可。
4. 回调处理:验签、AES-256 解密与幂等更新
支付结果微信不会同步返回,而是异步 POST 到下单时填的 notify_url。回调处理是整条链路里最容易被轻视的一环,但AEADBadTagException和重复通知这两个问题,几乎每个项目都会遇到一次。这一章把验签、解密和业务更新拆开讲。
4.1 回调头里四个字段,少验一个都是隐患
回调请求头里有四个关键字段:Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial。第一步验签,验的是签名串timestamp + "\n" + nonce + "\n" + body + "\n",和下单签名的结构一致,但用的是平台证书公钥。
String timestamp = request.getHeader("Wechatpay-Timestamp"); String nonce = request.getHeader("Wechatpay-Nonce"); String signature = request.getHeader("Wechatpay-Signature"); String serial = request.getHeader("Wechatpay-Serial"); String verifyMessage = timestamp + "\n" + nonce + "\n" + bodyStr + "\n"; PublicKey publicKey = platformCertManager.getPublicKeyBySerial(serial); Signature verifier = Signature.getInstance("SHA256withRSA"); verifier.initVerify(publicKey); verifier.update(verifyMessage.getBytes(StandardCharsets.UTF_8)); boolean ok = verifier.verify(Base64.getDecoder().decode(signature)); if (!ok) { // 验签失败:返回非200状态,微信会稍后重试 response.setStatus(401); return; }platformCertManager用来管理平台证书,资源包里默认放了一组证书,生产上要对接证书下载接口定期更新,证书更换后通过serial找到对应公钥。验签失败不要返回SUCCESS,否则微信以为你处理成功了,订单状态永远停在已支付未通知。常见验签失败原因是服务器时钟漂移导致时间戳对不上,或平台证书换了你还没更新。
4.2 解密 resource 节点:APIv3 密钥与 AES-GCM 参数
通知体里的数据是加密的,密文放在resource.ciphertext里。解密算法固定是AES/GCM/NoPadding,密钥是 APIv3 密钥,这个环节的参数最容易取错。
JSONObject resource = body.getJSONObject("resource"); String ciphertext = resource.getString("ciphertext"); String nonce = resource.getString("nonce"); String associatedData = resource.getString("associated_data"); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); GCMParameterSpec gcmSpec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), "AES"), gcmSpec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); byte[] plain = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); JSONObject payData = JSON.parseObject(new String(plain, StandardCharsets.UTF_8));这段代码看起来简单,但坑不少。apiV3Key是从商户平台设置的 32 字节 APIv3 密钥,不是商户私钥,也不是证书密码;nonce来自 resource 节点,不是请求头里那个Wechatpay-Nonce,两者不是一回事。解密后得到的 JSON 包含out_trade_no、transaction_id、trade_state和amount.total等字段,其中trade_state为SUCCESS才表示支付成功。associated_data可能为空字符串,但不能不传。
4.3 幂等更新与正确应答
微信回调会投递多次,所以回调处理必须幂等:先查本地订单状态,再决定要不要更新。顺序反了,订单状态就可能被后到的通知覆盖回去。
// 1. 先查本地订单,已成功则直接应答SUCCESS OrderBo order = orderService.getByOrderNo(payData.getString("out_trade_no")); if (order != null && TradeStatus.SUCCESS == order.getStatus()) { return successResponse(response); // 已处理过,直接应答 } // 2. 校验金额用分比对,避免精度问题 int paidTotal = payData.getJSONObject("amount").getIntValue("total"); if (paidTotal != order.getTotalFee()) { response.setStatus(500); // 金额不一致,让微信重试 return; } // 3. 更新订单为已支付,并记录transaction_id orderService.markPaid(order.getOrderNo(), payData.getString("transaction_id")); successResponse(response);先查再改,是防重复回调最朴素也最可靠的做法。金额校验通过后,再用事务把订单标记为已支付,同时把微信交易单号transaction_id存下来,对账时要拿它和商户平台账单核对。应答成功时,我返回HTTP 200,body 为{"code":"SUCCESS","message":"成功"};如果你用空 body,微信也认,但建议带结构方便日志排查。处理失败返回HTTP 500,微信会按自己的策略重试,本地尽量保证接口幂等。
5. H5 支付接入避坑:五个真实翻车现场
这五个坑是我前后接了好几个商城项目攒下来的,每个都是线上环境遇到的,至少浪费半天到一天不等。写在这里,照着排能少走几段弯路。
5.1 金额传成了“元”:账单差点少一百倍
现象:用户支付成功后对账,展示是 9.90 元,微信账单却显示 0.10 元。 原因:amount.total的单位是分,前端页面展示习惯是元,后端没做转换,直接把元传上去了。浮点数运算是另一个隐患,9.9 * 100在部分精度场景会得到 989.9999。 解决:转换用BigDecimal,new BigDecimal("9.90").movePointRight(2).intValue(),不要在 double 上做乘法。
5.2 scene_info 缺失:下单直接报 SCENE_INFO_ERROR
现象:下单接口返回SCENE_INFO_ERROR,参数看着都对,签名也验过。 原因:H5 支付接口要求scene_info.h5_info.type固定为Wap,且wap_url域名要和商户平台配置的支付授权域名一致。少传或域名对不上都会报这个错,日志里不会提示具体哪个字段,只能自己核对。 解决:下单前把wap_url和redirect_url的域名打印到日志,和商户平台的“支付授权域名”逐字符核对,注意https和http算不同域名。
5.3 回调解密报 AEADBadTagException
现象:回调日志里出现AEADBadTagException: Tag mismatch,解密出来乱码或直接抛异常。 原因:多半是nonce取错了地方——取了请求头Wechatpay-Nonce,而正确的是resource.nonce;另一种是 APIv3 密钥改过但服务没重启,内存里还是旧值。 解决:换成resource.getString("nonce"),改完密钥后重启服务。重放测试通知时,用商户平台“交易中心-回调日志”里的原始报文最稳妥。
5.4 重复通知覆盖了订单状态
现象:一笔订单收到两次回调,第二次把订单状态从“已支付”覆盖回“待支付”。 原因:代码里没有先查本地订单,而是直接执行“根据回调更新状态”,后到的通知把状态又改了。 解决:严格按先查再改的顺序,等于已支付状态下直接应答,不做二次更新。这个顺序不要图省事去掉。
5.5 用户点支付后停留在“无法拉起”页面
现象:能下单、能拿到 mweb_url,但用户跳转后看到报错,无法进入收银台。 原因:appid 未与商户号完成绑定,或该 appid 对应的服务号主体和商户主体不一致,也可能支付授权目录没配。这个问题的奇怪之处在于下单阶段不报错,等拉起时才暴露。 解决:到商户平台“产品中心”检查 AppID 绑定关系,绑定需要公众号管理员扫码确认;同时确认服务号已完成微信认证,“支付授权目录”和“支付授权域名”都指向实际域名。
6. 落地验证三板斧:curl 复现、查单兜底、对账单核对
接入完成后,最怕的不是代码跑不起来,而是线上出问题没法复现。我习惯在本地把下单请求原样打出来,用 curl 重放;再用查单接口做兜底,最后用对账单收尾。这套流程跑通了,交付才踏实。
6.1 用 curl 复现下单请求
curl --location 'https://api.mch.weixin.qq.com/v3/pay/transactions/h5' \ --header 'Content-Type: application/json' \ --header 'Accept: application/json' \ --header 'Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900000001",nonce_str="..."' \ --data '{"appid":"wx...","mchid":"1900000001","description":"测试商品","out_trade_no":"20250101001","notify_url":"https://pay.shop.example.com/api/pay/callback","amount":{"total":1,"currency":"CNY"},"scene_info":{"h5_info":{"type":"Wap","wap_url":"https://pay.shop.example.com","wap_name":"测试"}}}'Authorization 里的签名串,需要从服务端日志里把完整值抄过来,不要自己拼,拼错反而干扰排查。用这个命令可以复现签名、参数、域名任何一环的问题,本地改完参数再发起,响应结果立刻能看出来。
6.2 查单接口兜底:回调丢了也不慌
回调偶尔会延迟或丢,接通查单接口是标配。查单地址是GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid=xxx,这个 GET 请求照样要签名,注意规范化 URL 要带 query。
String query = "mchid=" + mchId; String canonicalUrl = "/v3/pay/transactions/out-trade-no/" + orderNo + "?" + query; String message = "GET\n" + canonicalUrl + "\n" + timestamp + "\n" + nonce + "\n" + "\n"; // 请求头带上与下单时相同的Authorization结构GET 的规范化 URL 要把 query 带进签名串,这和 POST 不同。查单返回的trade_state如果为SUCCESS,就走和回调里一样的订单更新逻辑。我一般把查单做成定时补偿任务,对创建超过 5 分钟仍处于待支付的订单发起查单,这样即使回调没到,订单状态也能收敛。
6.3 对账单:上线前最后一道核对
上线前我会让测试环境先跑几笔一分钱交易,然后在商户平台下载当日对账单,逐笔核对out_trade_no、transaction_id、金额三者一致。这套动作和功能测试无关,纯粹是验证加密、签名、域名配置在整个链路上没有残留问题。从那以后我每次接支付类项目,都强制自己先走一遍“小额交易-查单-退款”闭环,再交付代码,希望帮到你。
本文还有配套的精品资源,点击获取