资讯详情

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

📅 2026/10/16 0:45:08 | 华诺云谱 👁 阅读
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是已绑定商户号的服务号 APPIDmchid是商户号description是商品描述会展示在收银台out_trade_no是商户订单号必须唯一notify_url是回调地址公网 HTTPSamount.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是商户订单号我习惯用“yyyyMMddHHmmss6位随机数”拼避免同一天内并发下单撞号。description会展示在用户收银台页面上写清楚即可别塞下单时间之类的动态内容。h5_info里的wap_url是发起支付的网页地址要求与商户平台配置的支付授权域名同域名否则报H5_PARAM_ERRORwap_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}?mchidxxx这种带 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_url4xx多半是参数问题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 200body 为{code:SUCCESS,message:成功}如果你用空 body微信也认但建议带结构方便日志排查。处理失败返回HTTP 500微信会按自己的策略重试本地尽量保证接口幂等。5. H5 支付接入避坑五个真实翻车现场这五个坑是我前后接了好几个商城项目攒下来的每个都是线上环境遇到的至少浪费半天到一天不等。写在这里照着排能少走几段弯路。5.1 金额传成了“元”账单差点少一百倍现象用户支付成功后对账展示是 9.90 元微信账单却显示 0.10 元。 原因amount.total的单位是分前端页面展示习惯是元后端没做转换直接把元传上去了。浮点数运算是另一个隐患9.9 * 100在部分精度场景会得到 989.9999。 解决转换用BigDecimalnew 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 mchid1900000001,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}?mchidxxx这个 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、金额三者一致。这套动作和功能测试无关纯粹是验证加密、签名、域名配置在整个链路上没有残留问题。从那以后我每次接支付类项目都强制自己先走一遍“小额交易-查单-退款”闭环再交付代码希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑