☰
JavaH5微信支付全链路实战:从下单、签名到回调与查单
2026/10/8 3:02:45 网站建设 项目流程

简介:这份资源面向需要在Java项目中接入H5微信支付的开发者,尤其适合电商网站、移动应用等场景下负责支付模块的后端与全栈工程师。内容围绕微信支付接口文档、统一下单、预支付会话标识生成、H5支付页面唤起、异步回调处理、订单查询、异常重试机制以及安全合规等核心环节展开,帮助读者理清从下单到支付结果确认的完整链路。压缩包共11个文件,约16KB,以5个java源码和3个xml配置为主,另含properties配置、doc部署说明与html页面,结构紧凑,便于对照调试。目前已有1319人学习下载。通过源码与部署文档,读者可快速理解签名生成、回调验证与测试环境切换等关键实现,并借助排错思路减少对接中的常见问题。

1. JavaH5微信支付:从下单到回调,一条链路要打通哪些环节

用户在手机浏览器里点开一个 H5 页面,选好商品点「立即支付」,页面跳转到微信的收银台,付完钱再跳回来,订单状态变成已支付——这条链路看着简单,背后是 Java 后端和微信支付网关之间的一整套交互。JavaH5微信支付,说的就是用 Java 服务端对接微信支付的 H5 支付产品,覆盖下单、签名、唤起、回调、查单、退款这几个核心动作。它解决的是「非微信内置浏览器里怎么完成微信付款」这个问题,适合做移动端商城、知识付费、报名缴费这类场景的后端和全栈工程师。很多人第一次接会卡在签名和回调上,这篇就把这条链路拆开讲清楚。

H5 支付和 JSAPI 支付最大的区别在于:JSAPI 跑在微信内置浏览器里,能直接调WeixinJSBridge唤起支付;H5 支付跑在外部浏览器(比如手机自带浏览器、App 内嵌 WebView),微信给的是一个mweb_url,需要跳转过去。这个差异决定了后端要处理的东西不一样——H5 支付必须传scene_info,必须配好域名,回调也更容易因为网络问题丢。下面按实际落地顺序,从选型、下单、签名、回调一路讲到排错和进阶。

2. 选型与前置准备:H5 支付到底该用哪套接口

2.1 先分清 v2 和 v3,别混着用

微信支付目前有两套 API:v2 用 MD5/HMAC-SHA256 签名,XML 报文;v3 用 SHA256-RSA 签名,JSON 报文,并且引入了平台证书和回调验签。新项目我一般直接上 v3,原因是 v2 的密钥管理更粗糙,v3 的签名和验签机制更规范,出问题也更好定位。但要注意,v3 的签名用的是商户 API 私钥,验签用的是微信平台证书,这两样东西必须都配齐,少一个回调就验不过。

对比项APIv2APIv3
报文格式XMLJSON
签名算法MD5 / HMAC-SHA256SHA256-RSA
验签无需验签回调需平台证书验签
密钥API 密钥商户私钥 + 平台证书
推荐度维护老项目新项目首选

选 v3 之后,前置准备有这么几件事:申请商户号并开通 H5 支付权限、在商户平台配置 H5 支付域名(必须是 ICP 备案的域名,且不能带端口和路径)、下载商户 API 证书、获取 APIv3 密钥、下载平台证书。这几步在商户平台后台都能完成,缺任何一项后面都会报错。

2.2 依赖引入与配置项落地

Java 侧对接 v3,常见做法是用官方wechatpay-javaSDK,也可以用 HTTP 客户端自己拼。我一般先用 SDK 跑通,再按需替换。Maven 依赖如下:

<dependency> <groupId>com.github.wechatpay-apiv3</groupId> <artifactId>wechatpay-java</artifactId> <version>0.2.12</version> </dependency>

配置文件里把商户号、证书路径、APIv3 密钥、appid 都放进去,别硬编码在代码里:

wxpay: mch-id: "1900000001" app-id: "wxxxxxxxxxxxxxxxxx" api-v3-key: "你的32位APIv3密钥" private-key-path: "/opt/cert/apiclient_key.pem" merchant-serial-no: "商户证书序列号" notify-url: "https://yourdomain.com/api/wxpay/notify"

mch-id是商户号,app-id是公众号或小程序的 appid,H5 支付要求 appid 和商户号有绑定关系。api-v3-key是 32 位字符串,用来解密回调报文里的敏感信息。private-key-path指向商户私钥文件,merchant-serial-no是证书序列号,这两个必须匹配,否则签名直接失败。notify-url是回调地址,必须是 HTTPS,且公网可访问。

提示:H5 支付的notify_url不能带参数,微信会原样回调这个地址,参数要放在请求体里解析。

3. 下单与签名:H5 支付请求怎么拼才不报错

3.1 统一下单接口的请求体构造

v3 的 H5 下单接口是POST https://api.mch.weixin.qq.com/v3/pay/transactions/h5。请求体里几个关键字段:appid、mchid、description、out_trade_no、notify_url、amount、scene_info。其中scene_info是 H5 支付特有的,必须传payer_client_ip和h5_info,h5_info.type一般填Wap。

public String createH5Order(String outTradeNo, int totalFen, String description, String clientIp) { Map<String, Object> body = new HashMap<>(); body.put("appid", appId); body.put("mchid", mchId); body.put("description", description); body.put("out_trade_no", outTradeNo); body.put("notify_url", notifyUrl); Map<String, Object> amount = new HashMap<>(); amount.put("total", totalFen); // 单位:分 amount.put("currency", "CNY"); body.put("amount", amount); Map<String, Object> sceneInfo = new HashMap<>(); sceneInfo.put("payer_client_ip", clientIp); Map<String, Object> h5Info = new HashMap<>(); h5Info.put("type", "Wap"); h5Info.put("app_name", "你的应用名"); h5Info.put("app_url", "https://yourdomain.com"); sceneInfo.put("h5_info", h5Info); body.put("scene_info", sceneInfo); // 调用 SDK 或自行签名后 POST,返回 mweb_url return wxPayClient.createOrder(body); }

total单位是分,别传成元,这是最常见的翻车点之一。out_trade_no是商户订单号,必须全局唯一,重复下单会报ORDERPAID或OUT_TRADE_NO_USED。payer_client_ip传用户真实 IP,微信会做风控校验,传错可能导致下单失败。h5_info里的app_name和app_url是必填,虽然文档说部分场景可省,但实测不传容易报参数错误。

3.2 签名与请求头

v3 的签名规则是:构造method\nurl\ntimestamp\nnonce_str\nbody\n这样的字符串,用商户私钥做 SHA256-RSA 签名,再拼成Authorization头。用 SDK 的话这一步是自动的,但你要理解它,因为排错时经常要看签名串。

// 手动构造签名串的示意(SDK 内部逻辑) String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonceStr + "\n" + requestBody + "\n"; String signature = signWithPrivateKey(message, privateKey); String authorization = "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + mchId + "\"," + "nonce_str=\"" + nonceStr + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + merchantSerialNo + "\"," + "signature=\"" + signature + "\"";

urlPath是带 query 的路径,比如/v3/pay/transactions/h5,不带域名。timestamp是秒级时间戳,和微信服务器时间差不能超过 5 分钟,服务器时间不同步会直接报签名错误。serial_no是商户证书序列号,不是平台证书序列号,这两个容易搞混。requestBody是原始 JSON 字符串,不能重新序列化,否则签名对不上。

3.3 拿到 mweb_url 之后前端怎么处理

下单成功后返回的mweb_url是微信收银台地址,前端拿到后直接window.location.href = mweb_url跳转即可。跳转时可以拼redirect_url参数,支付完成后微信会跳回这个地址。注意redirect_url要做 URLEncode,且域名必须在商户平台配置过。

// 前端拿到后端返回的 mweb_url 后跳转 const res = await fetch('/api/wxpay/create', { method: 'POST', body: JSON.stringify(order) }); const data = await res.json(); if (data.mweb_url) { const redirect = encodeURIComponent('https://yourdomain.com/pay/result'); window.location.href = data.mweb_url + '&redirect_url=' + redirect; }

redirect_url只是跳转回商户页面,不代表支付成功,真正的支付结果必须以回调为准。很多人在这里踩坑:以为跳回来了就是付成功了,结果用户没付钱也显示成功。正确做法是跳回后用订单号去查单,或者等回调更新状态。

4. 回调处理与验签:支付结果怎么收才可靠

4.1 回调报文的结构与解密

微信回调是POST请求,body 是 JSON,里面resource字段是加密的。解密流程:用 APIv3 密钥对resource.ciphertext做 AES-256-GCM 解密,得到明文订单信息。解密前要先验签,验签用的是微信平台证书。

@PostMapping("/api/wxpay/notify") public String notify(@RequestBody String body, HttpServletRequest request) { // 1. 验签:从请求头取 Wechatpay-Signature 等,用平台证书验 boolean verified = wxPayVerifier.verify(request, body); if (!verified) { return "{\"code\":\"FAIL\",\"message\":\"验签失败\"}"; } // 2. 解密 resource JSONObject json = JSON.parseObject(body); JSONObject resource = json.getJSONObject("resource"); String plain = aesGcmDecrypt( resource.getString("ciphertext"), resource.getString("nonce"), resource.getString("associated_data"), apiV3Key); // 3. 处理业务 JSONObject order = JSON.parseObject(plain); String outTradeNo = order.getString("out_trade_no"); String tradeState = order.getString("trade_state"); if ("SUCCESS".equals(tradeState)) { orderService.markPaid(outTradeNo, order.getString("transaction_id")); } // 4. 必须返回 200 和成功报文,否则微信会重试 return "{\"code\":\"SUCCESS\",\"message\":\"成功\"}"; }

ciphertext是 Base64 编码的密文,nonce是随机串,associated_data是附加数据,这三个都要传给解密函数。apiV3Key就是配置里的 32 位密钥。解密后拿到的trade_state是SUCCESS才代表支付成功,其他状态比如NOTPAY、CLOSED要分别处理。返回体必须是{"code":"SUCCESS","message":"成功"},否则微信会按策略重试,重试多次后可能触发告警。

4.2 幂等与重试:回调可能来多次

微信回调不是只来一次,网络抖动、你返回慢了、返回体不对,都会触发重试。所以回调处理必须幂等:先查订单状态,已处理过就直接返回成功,不要再改数据。

@Transactional public void markPaid(String outTradeNo, String transactionId) { Order order = orderMapper.selectByOutTradeNo(outTradeNo); if (order == null) { throw new BizException("订单不存在"); } if (order.getStatus() == OrderStatus.PAID) { return; // 已处理,直接返回,保证幂等 } order.setStatus(OrderStatus.PAID); order.setTransactionId(transactionId); orderMapper.updateById(order); }

这里用selectByOutTradeNo加状态判断做幂等,也可以用数据库唯一索引兜底。注意事务范围别太大,回调里不要做耗时操作,否则微信等不到响应会重试。如果业务逻辑复杂,建议回调里只更新订单状态,后续动作走异步消息。

4.3 主动查单兜底

回调可能因为各种原因丢失,所以要有主动查单。定时任务扫那些「下单成功但状态还是待支付」的订单,调查单接口确认。

public void queryAndSync(String outTradeNo) { // GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid=xxx JSONObject result = wxPayClient.queryOrder(outTradeNo); String tradeState = result.getString("trade_state"); if ("SUCCESS".equals(tradeState)) { orderService.markPaid(outTradeNo, result.getString("transaction_id")); } else if ("CLOSED".equals(tradeState) || "PAYERROR".equals(tradeState)) { orderService.markClosed(outTradeNo); } }

查单接口是GET,路径里带out_trade_no,query 里带mchid。查单结果和回调报文结构类似,trade_state字段含义一致。建议下单后 5 分钟开始查,间隔逐渐拉长,避免频繁请求。

5. 避坑与排查:H5 支付最常见的 5 个翻车现场

5.1 签名错误:SIGN_ERROR

现象:调下单接口返回SIGN_ERROR,或者回调验签失败。原因通常是这几种:私钥和证书序列号不匹配、签名串拼接时 body 被重新序列化、服务器时间偏差超过 5 分钟、urlPath带了域名。解决:先打印签名串和微信返回的报错信息对比,确认serial_no用的是商户证书序列号,检查服务器 NTP 同步,body 用原始字符串不要重新JSON.stringify。

5.2 下单报 PARAM_ERROR 且提示 scene_info

现象:H5 下单返回PARAM_ERROR,提示scene_info相关。原因:h5_info没传或type不对,或者payer_client_ip传了内网 IP。解决:h5_info.type固定Wap,payer_client_ip从请求头X-Forwarded-For取第一个公网 IP,别用request.getRemoteAddr()拿到的内网地址。

5.3 回调收不到

现象:用户付了钱,订单状态没变。原因:notify_url不是 HTTPS、域名没备案、防火墙拦了微信 IP、回调返回体不是成功格式。解决:先用在线工具测notify_url是否公网可达,确认返回 200 且 body 是{"code":"SUCCESS"},检查 Nginx 有没有拦截 POST,检查证书是否有效。

5.4 金额对不上

现象:订单金额和实际支付金额不一致。原因:total单位传错,把元当分传了,或者用了float计算导致精度丢失。解决:金额统一用int存分,前端展示时再除以 100,数据库用int或bigint,别用decimal再转来转去。

5.5 重复支付或重复回调

现象:同一订单被支付两次,或者回调处理了两次导致库存扣多了。原因:out_trade_no重复使用,或者回调没做幂等。解决:out_trade_no用「业务前缀 + 时间戳 + 随机数」保证唯一,回调里先查状态再更新,数据库加唯一索引兜底。

6. 进阶技巧:用对账单和退款把资金链路闭环

支付跑通只是第一步,真正上线还要处理退款和对账。退款接口是POST /v3/refund/domestic/refunds,请求体里带out_trade_no、out_refund_no、amount.refund和amount.total。退款也有回调,逻辑和支付回调类似,同样要验签、解密、幂等。

public void refund(String outTradeNo, String outRefundNo, int refundFen, int totalFen) { Map<String, Object> body = new HashMap<>(); body.put("out_trade_no", outTradeNo); body.put("out_refund_no", outRefundNo); Map<String, Object> amount = new HashMap<>(); amount.put("refund", refundFen); amount.put("total", totalFen); amount.put("currency", "CNY"); body.put("amount", amount); wxPayClient.createRefund(body); }

out_refund_no是退款单号,也要唯一。refund是退款金额,total是原订单金额,两者单位都是分。退款是异步的,提交后要等退款回调或者主动查退款单确认结果。

对账这块,微信提供交易账单和资金账单下载接口,返回的是 CSV 压缩包。我一般写个定时任务,每天凌晨拉前一天的账单,和本地订单表做比对,找出「本地已支付但账单没有」和「账单有但本地没记录」的差异。这个习惯帮我提前发现过好几次回调丢失的问题。

// 下载对账单示意 String billUrl = wxPayClient.downloadBill("2025-01-01", "ALL"); // 返回的是下载链接,再 GET 下载,解压后逐行解析

对账文件里的字段包括交易时间、商户订单号、微信订单号、交易状态、应结金额等,按out_trade_no和本地订单关联即可。差异订单要人工介入,别自动改状态,避免误判。

最后说个我自己的习惯:每次接新的支付场景,先拿 1 分钱的商品跑通全链路,确认下单、支付、回调、查单、退款都正常,再上真实金额。这个「1 分钱测试」帮我省过很多次线上事故,也推荐你养成这个习惯。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询