news 2026/10/16 0:44:51

Java微信支付H5接入教程:从下单签名到回调验签解密

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java微信支付H5接入教程:从下单签名到回调验签解密

简介:这是一份面向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、金额三者一致。这套动作和功能测试无关,纯粹是验证加密、签名、域名配置在整个链路上没有残留问题。从那以后我每次接支付类项目,都强制自己先走一遍“小额交易-查单-退款”闭环,再交付代码,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 0:27:47

Wazuh定制规则实战:构建三层感知模型实现精准日志审计

1. 项目概述:为什么“Wazuh日志审计——定制规则”不是可选项,而是必选项你刚接手某公司安全运维工作第三天,凌晨两点收到告警:一台核心数据库服务器的SSH登录失败次数在5分钟内飙升至87次,但Wazuh控制台只显示一条模糊…

作者头像 李华
网站建设 2026/10/16 0:22:42

Python屏幕捕获原理与高性能实践指南

1. 项目概述:这不是“黑客工具”,而是一次屏幕监控原理的深度拆解“Python黑客攻防(一)屏幕窃取”这个标题,乍看容易引发误解——仿佛在教人非法获取他人设备画面。但作为从业十多年、带过几十个安全与自动化项目的博主…

作者头像 李华
网站建设 2026/10/16 0:21:34

SAP销售订单MWST条件丢失排查:从定价链到税配置的完整修复路径

简介:针对SAP顾问、运维及技术支持人员,解决创建销售订单时提示“定价条件错误:必要条件MWST丢失”的典型故障。文档从MWST增值税条件概念入手,系统梳理物料主数据、客户主数据、条件记录、定价程序等五类常见成因;以具…

作者头像 李华
网站建设 2026/10/16 0:21:00

从零手搓AI工程:深入理解张量、自动微分与注意力机制

1. 从零手搓AI工程:为什么我不建议你直接调包很多人一上来就想跑通一个能对话的模型,结果卡在环境配置、显存不足、依赖冲突上,三天热情耗尽直接放弃。我见过太多这样的案例,包括我自己第一次尝试时,光是一个CUDA版本和…

作者头像 李华
网站建设 2026/10/16 0:17:32

hex 文件详解 stm32:用 TaoToken 统一 Key 打通烧录校验链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华