
简介这份资源面向需要在Java项目中接入H5微信支付的开发者尤其适合电商网站、移动应用等场景下负责支付模块的后端与全栈工程师。内容围绕微信支付接口文档、统一下单、预支付会话标识生成、H5支付页面唤起、异步回调处理、订单查询、异常重试机制以及安全合规等核心环节展开帮助读者理清从下单到支付结果确认的完整链路。压缩包共11个文件约16KB以5个java源码和3个xml配置为主另含properties配置、doc部署说明与html页面结构紧凑便于快速对照调试。目前已有1319人学习下载。资源提供了可运行的接入示例与部署说明读者可据此理解签名生成、回调验证与测试环境切换等关键实现减少对接微信支付时的试错成本适合作为H5微信支付接入的参考模板。1. JavaH5微信支付从下单到回调一条链路要打通哪些环节用户在手机浏览器里点开一个 H5 页面选好商品点“立即支付”页面跳转到微信收银台输密码、扣款、跳回商户页面显示“支付成功”。这四步背后Java 后端至少要做四件事生成预支付订单、拼接跳转链接、接收异步回调、处理订单状态。任何一环出问题用户看到的就是“支付失败”或者“付了钱订单没变”。JavaH5微信支付指的是用 Java 后端对接微信支付 H5 下单接口前端在浏览器里完成支付流程的整套方案。它和 JSAPI 支付、小程序支付最大的区别在于H5 支付不依赖微信内置浏览器用户可以在手机 Chrome、Safari 或者 App 的 WebView 里直接完成支付。适合谁做移动端商城、在线教育、会员充值、知识付费的 Java 后端开发尤其是那些没有小程序、没有公众号、只有一个 H5 站点的团队。这条链路里Java 后端要处理签名、证书、回调验签、订单幂等、金额校验每一步都有翻车的可能。下面按实际落地顺序拆开讲。2. 下单接口怎么调预支付订单的生成与跳转链接拼接2.1 先搞清楚 H5 支付和 JSAPI 支付的边界很多人第一次做微信支付会下意识去翻 JSAPI 的文档结果发现 H5 支付根本不需要传 openid。这是第一个分水岭。JSAPI 支付要求用户在微信内置浏览器里打开页面后端通过网页授权拿到 openid下单时必须带上这个 openid。H5 支付不要求用户在微信里也不要求 openid它返回的是一个跳转链接用户点击后由微信客户端接管支付流程。但 H5 支付有一个硬性限制它只能在手机浏览器里发起不能在微信内置浏览器里直接调起。如果你的页面同时在微信里和微信外被打开常见做法是判断 User-Agent微信内走 JSAPI微信外走 H5。这个判断逻辑后面会给出代码。另一个容易混淆的点是场景值。H5 支付的 scene_info 里需要传wap_url和h5_url前者是商户网站域名后者是支付完成后跳转的页面地址。这两个字段不是随便填的域名必须和商户号配置的一致否则微信会拒绝下单。2.2 用 Java 构造统一下单请求微信支付 V3 接口用 JSON 传参签名用 SHA256withRSA。下面是一个最小可用的下单方法基于 Spring Boot 环境用 RestTemplate 发请求。import org.springframework.http.*; import org.springframework.web.client.RestTemplate; import java.security.*; import java.util.*; public class WxH5PayService { private static final String MCH_ID 你的商户号; private static final String APP_ID 你的AppID; private static final String MCH_SERIAL_NO 证书序列号; private static final String API_V3_KEY APIv3密钥; private static final String PRIVATE_KEY_PATH /path/to/apiclient_key.pem; // 构造 H5 下单请求体 public String createH5Order(String outTradeNo, int totalFee, String description, String clientIp, String h5Url) throws Exception { MapString, Object body new LinkedHashMap(); body.put(appid, APP_ID); body.put(mchid, MCH_ID); body.put(description, description); body.put(out_trade_no, outTradeNo); body.put(notify_url, https://your-domain.com/wx/notify); MapString, Object amount new LinkedHashMap(); amount.put(total, totalFee); // 单位分 amount.put(currency, CNY); body.put(amount, amount); MapString, Object sceneInfo new LinkedHashMap(); sceneInfo.put(payer_client_ip, clientIp); MapString, String h5Info new LinkedHashMap(); h5Info.put(type, Wap); h5Info.put(wap_url, https://your-domain.com); h5Info.put(wap_name, 你的网站名称); sceneInfo.put(h5_info, h5Info); body.put(scene_info, sceneInfo); String url https://api.mch.weixin.qq.com/v3/pay/transactions/h5; String jsonBody new com.fasterxml.jackson.databind.ObjectMapper() .writeValueAsString(body); // 生成签名 String token buildAuthorization(POST, /v3/pay/transactions/h5, jsonBody); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set(Authorization, token); headers.set(Accept, application/json); HttpEntityString entity new HttpEntity(jsonBody, headers); RestTemplate restTemplate new RestTemplate(); ResponseEntityString response restTemplate.exchange( url, HttpMethod.POST, entity, String.class); // 返回体里包含 h5_url前端跳转这个地址即可 return response.getBody(); } // 构造 Authorization 头 private String buildAuthorization(String method, String urlPath, String body) throws Exception { long timestamp System.currentTimeMillis() / 1000; String nonceStr UUID.randomUUID().toString().replace(-, ); String message method \n urlPath \n timestamp \n nonceStr \n body \n; String signature sign(message); return WECHATPAY2-SHA256-RSA2048 mchid\ MCH_ID \,nonce_str\ nonceStr \,signature\ signature \,timestamp\ timestamp \,serial_no\ MCH_SERIAL_NO \; } private String sign(String message) throws Exception { Signature sign Signature.getInstance(SHA256withRSA); sign.initSign(loadPrivateKey()); sign.update(message.getBytes(UTF-8)); return Base64.getEncoder().encodeToString(sign.sign()); } private PrivateKey loadPrivateKey() throws Exception { String key new String(java.nio.file.Files.readAllBytes( java.nio.file.Paths.get(PRIVATE_KEY_PATH))); key key.replace(-----BEGIN PRIVATE KEY-----, ) .replace(-----END PRIVATE KEY-----, ) .replaceAll(\\s, ); byte[] keyBytes Base64.getDecoder().decode(key); PKCS8EncodedKeySpec spec new PKCS8EncodedKeySpec(keyBytes); return KeyFactory.getInstance(RSA).generatePrivate(spec); } }这段代码的逻辑分三层第一层组装请求体把商户号、订单号、金额、场景信息按 V3 接口的 JSON 结构拼好第二层构造签名微信 V3 的签名串是“方法\nURL路径\n时间戳\n随机串\n请求体\n”五段拼接用商户私钥做 SHA256withRSA 签名第三层发 HTTP 请求拿到响应后解析出h5_url字段返回给前端。参数上有几个必须注意的点。total的单位是分不是元传错会导致金额差一百倍。out_trade_no是商户侧订单号同一商户号下必须唯一重复下单微信会直接报错。notify_url必须是一个公网可访问的 HTTPS 地址微信回调时会往这个地址 POST 加密数据。payer_client_ip传用户真实 IP微信会做风控校验传内网 IP 或者 127.0.0.1 可能被拒。2.3 前端拿到 h5_url 之后怎么跳后端返回的响应体里有一个h5_url字段前端拿到后直接window.location.href h5_url即可。微信收银台会接管后续流程用户支付完成后微信会跳转到你在scene_info.h5_info里配置的wap_url对应的页面。这里有一个血泪经验支付完成后的跳转页面不能作为订单状态的依据。用户可能支付完成后直接关掉页面也可能网络问题导致跳转失败。订单状态的唯一可信来源是异步回调。// 前端发起支付 async function wxH5Pay(orderId) { const res await fetch(/api/wx/create-order, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ orderId }) }); const data await res.json(); if (data.h5_url) { // 跳转到微信收银台 window.location.href data.h5_url; } else { alert(下单失败 data.message); } }前端这段代码只做一件事调后端下单接口拿到h5_url后跳转。不要在前端做签名、不要在前端拼金额、不要在前端判断支付结果。所有敏感逻辑都在后端。3. 回调验签与订单状态更新异步通知怎么处理才不丢单3.1 微信回调的数据结构长什么样用户支付成功后微信会往你配置的notify_url发一个 POST 请求。请求体是 JSON关键字段包括id通知ID、event_type事件类型支付成功是TRANSACTION.SUCCESS、resource加密的订单数据。resource里包含ciphertext、nonce、associated_data三个字段需要用 APIv3 密钥做 AES-256-GCM 解密才能拿到明文订单信息。明文里包含out_trade_no、transaction_id、trade_state、amount.total等字段。很多新手第一次接回调看到密文就懵了。其实解密逻辑不复杂微信官方文档给了示例Java 用 JDK 自带的javax.crypto就能做。3.2 回调接口的完整实现import org.springframework.web.bind.annotation.*; import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; RestController RequestMapping(/wx) public class WxNotifyController { private static final String API_V3_KEY 你的APIv3密钥; PostMapping(/notify) public String handleNotify(RequestBody String body, RequestHeader(Wechatpay-Signature) String signature, RequestHeader(Wechatpay-Timestamp) String timestamp, RequestHeader(Wechatpay-Nonce) String nonce, RequestHeader(Wechatpay-Serial) String serial) { try { // 第一步验签生产环境必须做 // 用微信平台证书公钥验证 signature防止伪造回调 // 验签逻辑略见下文说明 // 第二步解析通知体 com.fasterxml.jackson.databind.ObjectMapper mapper new com.fasterxml.jackson.databind.ObjectMapper(); com.fasterxml.jackson.databind.JsonNode root mapper.readTree(body); String eventType root.get(event_type).asText(); if (!TRANSACTION.SUCCESS.equals(eventType)) { return {\code\:\SUCCESS\,\message\:\忽略非支付成功事件\}; } // 第三步解密 resource com.fasterxml.jackson.databind.JsonNode resource root.get(resource); String ciphertext resource.get(ciphertext).asText(); String nonceStr resource.get(nonce).asText(); String associatedData resource.get(associated_data).asText(); String plainText decryptResource(ciphertext, nonceStr, associatedData); // 第四步解析明文订单信息 com.fasterxml.jackson.databind.JsonNode orderInfo mapper.readTree(plainText); String outTradeNo orderInfo.get(out_trade_no).asText(); String transactionId orderInfo.get(transaction_id).asText(); String tradeState orderInfo.get(trade_state).asText(); int totalFee orderInfo.get(amount).get(total).asInt(); // 第五步更新订单状态必须做幂等 if (SUCCESS.equals(tradeState)) { updateOrderPaid(outTradeNo, transactionId, totalFee); } // 第六步返回成功否则微信会重复通知 return {\code\:\SUCCESS\,\message\:\成功\}; } catch (Exception e) { // 返回失败微信会按策略重试 return {\code\:\FAIL\,\message\:\处理失败\}; } } private String decryptResource(String ciphertext, String nonce, String associatedData) throws Exception { Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec key new SecretKeySpec( API_V3_KEY.getBytes(StandardCharsets.UTF_8), AES); GCMParameterSpec spec new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, key, spec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); byte[] plainBytes cipher.doFinal( Base64.getDecoder().decode(ciphertext)); return new String(plainBytes, StandardCharsets.UTF_8); } private void updateOrderPaid(String outTradeNo, String transactionId, int totalFee) { // 幂等处理先查订单状态已支付则直接返回 // 再校验金额是否一致 // 最后更新订单状态并记录微信交易号 // 具体 SQL 略见下文说明 } }这段代码的核心逻辑是验签 → 解析事件类型 → 解密 resource → 校验订单状态 → 更新数据库 → 返回成功。每一步都不能省。验签这一步生产环境必须做。微信回调的请求头里有Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial四个字段需要用微信平台证书的公钥验证签名。不做验签的话任何人构造一个 POST 请求就能把你的订单改成已支付。解密用的是 AES-256-GCM密钥就是 APIv3 密钥nonce和associated_data从resource里取。解密后的明文是一个 JSON包含订单的完整信息。3.3 订单更新的幂等与金额校验微信的回调机制是如果你返回的不是成功它会按 15s、15s、30s、3m、10m、20m、30m、30m、30m、60m、3h、3h、3h、6h、6h、6h、6h、6h 的频率重试。也就是说同一个订单你可能会收到多次回调。幂等处理的标准做法是在更新订单之前先查一次订单状态。如果已经是“已支付”直接返回成功不做任何更新。如果还是“待支付”再校验金额是否和下单时一致一致才更新。-- 幂等更新只有待支付状态才更新 UPDATE orders SET status PAID, transaction_id #{transactionId}, pay_time NOW() WHERE out_trade_no #{outTradeNo} AND status PENDING AND total_fee #{totalFee};这条 SQL 的WHERE条件里带了status PENDING和total_fee #{totalFee}保证只有待支付且金额一致的订单才会被更新。如果影响行数为 0说明订单已经处理过或者金额不对直接返回成功即可不要报错。金额校验这一步很多人会忽略。如果不校验金额攻击者可以用 1 分钱的订单号去回调一个 100 元的订单虽然概率低但属于基本的安全漏洞。4. 避坑与排查H5 微信支付最常见的 5 个翻车现场4.1 回调收不到notify_url 配置的隐形坑现象用户支付成功微信也扣款了但后端订单状态一直是待支付日志里没有任何回调记录。原因notify_url必须是公网可访问的 HTTPS 地址且不能带参数、不能是内网地址、不能有重定向。常见问题包括用了 HTTP 而不是 HTTPS、域名解析到了内网、Nginx 配置了 301 跳转、防火墙拦截了微信的 IP 段。解决先用curl从外网机器测一下notify_url是否可达。检查 Nginx 日志有没有收到微信的 POST 请求。如果 Nginx 收到了但 Java 没收到检查反向代理配置。如果 Nginx 都没收到检查防火墙和域名解析。微信支付回调的 IP 段不固定不要用 IP 白名单。4.2 验签失败平台证书和商户证书搞混了现象回调能收到但验签一直失败日志报Signature verification failed。原因微信支付有两套证书——商户证书apiclient_cert.pem / apiclient_key.pem和平台证书。商户证书用来给请求签名平台证书用来验证微信回调的签名。很多人拿商户证书去验回调签名必然失败。解决平台证书需要通过微信的“获取平台证书”接口下载或者用微信提供的证书下载工具。下载后缓存到本地定期更新。验签时用平台证书的公钥不是商户证书。4.3 金额差一百倍分和元的单位陷阱现象用户实际支付了 1 元但订单显示的金额是 100 元或者反过来。原因微信支付的amount.total单位是分不是元。前端传金额时如果传的是元后端没做转换就会差一百倍。解决统一约定——前端传给后端的金额单位是分后端存数据库也是分调微信接口也是分。只有在展示给用户时才除以 100。在代码里加注释在接口文档里加粗标注。4.4 重复回调导致重复发货现象用户只支付了一次但系统发了两次货或者加了两次积分。原因微信回调会重试如果第一次回调处理成功但返回给微信的响应超时了微信会认为你没收到继续重试。如果你的更新逻辑没有幂等就会重复处理。解决更新订单的 SQL 必须带status PENDING条件保证只有第一次回调能更新成功。发货、加积分等操作也要做幂等可以用订单号作为唯一键插入前先查。4.5 微信内置浏览器打不开 H5 支付现象在微信里打开页面点支付没反应或者报错“当前页面不支持此操作”。原因H5 支付只能在微信外的浏览器里发起微信内置浏览器不支持 H5 支付。这是微信的产品限制不是技术问题。解决判断 User-Agent微信内走 JSAPI 支付微信外走 H5 支付。判断逻辑很简单String userAgent request.getHeader(User-Agent); boolean isWechat userAgent ! null userAgent.contains(MicroMessenger); if (isWechat) { // 走 JSAPI 支付需要先获取 openid } else { // 走 H5 支付 }如果项目只做了 H5 支付微信内打开时给用户一个提示“请点击右上角在浏览器中打开”。5. 进阶技巧用对账单做最终对账把丢单概率降到零回调不是百分之百可靠的。网络抖动、服务器重启、代码 bug 都可能导致回调丢失。真正稳妥的做法是每天定时拉取微信对账单和本地订单做比对。微信支付提供了对账单下载接口可以按天下载 CSV 格式的对账单。对账单里包含所有成功支付的订单包括商户订单号、微信订单号、交易金额、交易时间。// 下载对账单简化逻辑 public void downloadBill(String billDate) throws Exception { String url https://api.mch.weixin.qq.com/v3/bill/tradebill ?bill_date billDate bill_typeALL; // 构造签名发 GET 请求 // 响应里包含 download_url再下载 CSV // 解析 CSV逐行比对本地订单 }拿到对账单后和本地订单做比对。比对逻辑分三种情况对账单有本地有处理方式已支付待支付补单更新为已支付已支付已支付正常跳过无已支付异常人工排查补单的逻辑和回调处理一样也要做幂等和金额校验。对账脚本可以每天凌晨跑一次跑完发邮件或者钉钉通知。对账这件事平时看起来没用一旦出问题就是救命稻草。我自己的习惯是上线第一天就把对账脚本跑起来不要等到出了问题才想起来做。还有一个细节对账单的下载链接有有效期拿到download_url后要尽快下载。下载下来的 CSV 是压缩包解压后是纯文本用BufferedReader逐行读即可。注意编码是 UTF-8不要用 GBK。最后说一个我踩过的坑对账单里的金额单位也是分和下单接口一致。但 CSV 里的字段顺序可能会变不要用固定下标去取用表头映射。微信偶尔会调整对账单的字段顺序用下标取值的代码迟早会翻车。希望帮到你。本文还有配套的精品资源点击获取