简介:围绕 Java 后端接入 H5 微信支付的实战代码包,面向需要在小程序之外的移动网页、电商站点中完成下单与收款的初中级 Java 开发者。内容从统一下单接口出发,串起预支付会话标识获取、支付链接与二维码生成、前端调起微信支付控件、异步回调验签与订单状态更新,并补充订单查询防漏单、网络异常重试与超时控制、HTTPS 传输与敏感信息加密等合规要点,测试环境与正式环境的切换也有交代。压缩包共 11 个文件、约 16KB,以 5 个 Java 源码为主体,配合 3 个 XML 配置、1 个 properties 参数文件、1 个 HTML 页面和 1 份部署说明文档,体量轻巧,便于对照梳理调用链路。已有 1322 人学习下载。读者可据此搭起可运行的支付骨架,理解签名生成与回调校验的落地写法,并借助文档快速完成环境配置与联调排错。
1. H5 支付这条链路:为什么 Java 后端要单独包一层 MWEB 下单
很多人第一次接入 Java 微信支付,会把 H5 微信支付当成“网页版扫码支付”:下单接口调通了,h5_url也拿到了,用户点开却停在“请在外部浏览器中打开”的提示页;又或者回调明明收到了,订单状态却一直是待支付。这套源码包把 H5 微信支付拆成三层来写:Java 侧的下单与 V3 签名、回调解密与幂等落库、前端唤起与结果页兜底,代码可以直接放进 Spring Boot 工程里跑。它适合两类人:已经能写普通下单、但对签名串拼接、平台证书验签、AEAD 解密心里没底的后端;以及需要在两天内把一个 H5 收银台完整链路搭起来的小团队。读下去你能判断清楚,这份 Java 微信支付源码里哪部分拿去就能用,哪部分必须按自己的商户配置改。
2. 下单与签名:MWEB 接口的参数表、签名串和 Java 落地
把 H5 支付跑通,难点不在“调接口”,而在调之前那几行字符串。签名串少一个\n、URL 少一段 query、请求体被序列化框架重新排过一次序,返回的都是同一个 401,报错信息还都一样,排查起来极为费劲。
2.1 为什么 H5 支付走 /v3/pay/transactions/h5 而不是 JSAPI
微信支付的下单接口按支付场景分了几个入口,选错了后面全错:
| 场景 | 接口路径 | 前提条件 | 典型触发环境 |
|---|---|---|---|
| 扫码支付 Native | /v3/pay/transactions/native | 无 | PC 浏览器展示二维码 |
| 公众号/服务窗 JSAPI | /v3/pay/transactions/jsapi | 必须有用户openid,且 appid 与商户号已绑定 | 微信内置浏览器 |
| H5 支付 MWEB | /v3/pay/transactions/h5 | 商户号开通 H5 支付,配置 H5 支付域名 | 手机外部浏览器(Safari、Chrome 等) |
| 小程序支付 | /v3/pay/transactions/jsapi | 小程序 appid +openid | 小程序内 |
关键区别在“谁来唤起支付”。JSAPI 返回的是prepay_id加一串前端签名参数,由微信 JS-SDK 在微信内置浏览器里唤起收银台;H5 支付返回的是一个h5_url,它本质上是一个跳转链接,用户被带到微信的中间页再唤起微信客户端完成付款。因此 H5 支付拿不到openid也能下单,这是它相对 JSAPI 唯一但很实用的优势。
反过来也要认清边界:H5 支付在微信内置浏览器里打不开,会提示让用户去外部浏览器。如果你的主流量入口是公众号菜单或者聊天里分享的链接,那用户九成在微信内,这时候正确做法是 JSAPI,H5 支付只能作为降级兜底。这套源码包把两种场景的判断逻辑放在同一层网关里,先判 UA 再决定走哪个接口,这个设计比自己拍脑袋选一个要稳。
2.2 下单参数:哪些必填,哪些是最容易填错的地方
下单请求体是 JSON,字段不多,但每一个都有明确的坑位:
| 参数 | 必填 | 格式/取值 | 实际踩过的坑 |
|---|---|---|---|
appid | 是 | 字符串 | 填了公众号 appid,但商户号没和它绑定,报appid 与 mchid 不匹配 |
mchid | 是 | 字符串,不要转数字 | 转成 long 后前导零丢失,签名对但下单失败 |
description | 是 | 最长 127 字符 | 塞了商品标题全文,超长被截断后对账对不上 |
out_trade_no | 是 | 6–32 位,同一商户号下唯一 | 用自增 ID 且跨环境共用,测试单号污染生产 |
time_expire | 否 | RFC3339,如2024-06-01T12:00:00+08:00 | 时区写成Z,实际比预期早 8 小时关闭 |
notify_url | 是 | 必须 https,不能带 query 参数 | 带了?from=wx,微信直接拒绝 |
amount.total | 是 | 整数,单位分 | 传了元,1 元变 1 分 |
amount.currency | 否 | 固定CNY | 不传默认 CNY,传错币种直接报错 |
scene_info.payer_client_ip | 是 | 用户真实公网 IP | 拿的是 Nginx 内网 IP 或网关 IP |
scene_info.h5_info.type | 是 | 固定Wap,首字母大写 | 写成wap或H5,报参数错误 |
scene_info.h5_info.app_name | 否 | 应用名 | 建议填,微信中间页会展示 |
scene_info.h5_info.app_url | 是 | 网站域名 | 与商户平台配置的 H5 支付域名不一致,下单被拒 |
payer_client_ip这个字段值得单独说。生产环境一般有 SLB 或 Nginx,request.getRemoteAddr()拿到的是内网地址,微信侧会拒绝或者风控打分异常。常见做法是从X-Forwarded-For里取第一个非内网地址,并且要在最外层网关做一次白名单校验,防止用户伪造请求头。
// 取真实客户端 IP:优先 X-Forwarded-For 第一个非内网地址 public static String realIp(HttpServletRequest request) { String xff = request.getHeader("X-Forwarded-For"); if (StringUtils.isNotBlank(xff)) { for (String ip : xff.split(",")) { String candidate = ip.trim(); // 跳过内网段,避免拿到网关地址 if (!isPrivate(candidate)) { return candidate; } } } return request.getRemoteAddr(); }这段逻辑的意义不在于“能取到 IP”,而在于取到一个微信侧认可的公网 IP。如果你们前面是多层代理,X-Forwarded-For会是逗号分隔的一串,最左边是客户端,往右是各级代理;直接取整串或者取最后一个都会出问题。
2.3 签名串:五行字符串,一个换行都不能错
V3 接口的签名用的是商户 API 私钥做 SHA256withRSA,签名串固定五行,每行以\n结尾,最后一行后面也必须有一个\n:
HTTP请求方法\n URL(含 query,不含协议和域名)\n 时间戳(秒级)\n 随机串\n 请求报文主体\n这里有两个高频翻车点。一是 URL 必须包含 query string,查单接口GET /v3/pay/transactions/out-trade-no/xxx?mchid=yyy如果把?mchid=yyy漏掉,签名必错。二是请求体如果是GET,主体为空但那一行\n不能省,很多人写成四行,报错和五行写错完全一样。
public final class WxPayV3Signer { private final PrivateKey merchantPrivateKey; // PKCS#8 格式的商户 API 私钥 private final String mchId; private final String serialNo; // 商户 API 证书序列号 /** * @param method GET / POST * @param urlPath 以 / 开头,含 query,例如 /v3/pay/transactions/h5 * @param body GET 传空字符串,不能传 null */ public String buildAuthorization(String method, String urlPath, String body) { long timestamp = System.currentTimeMillis() / 1000; String nonce = RandomStringUtils.randomAlphanumeric(32); String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"; String signature = sign(message); return "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + mchId + "\"," + "nonce_str=\"" + nonce + "\"," + "signature=\"" + signature + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + serialNo + "\""; } private String sign(String message) { try { Signature signer = Signature.getInstance("SHA256withRSA"); signer.initSign(merchantPrivateKey); signer.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signer.sign()); } catch (GeneralSecurityException e) { throw new IllegalStateException("微信支付签名失败", e); } } }serial_no是商户 API 证书的序列号,不是平台证书序列号,这两个经常被搞混。商户证书序列号在你申请证书时就有,平台证书序列号是微信那边的,用于验签响应。body必须和真正发出去的字节完全一致,如果你用的是Map拼参数,签名前先序列化一次,发请求时复用同一个字符串,千万别签一遍、发一遍各自序列化。
2.4 完整的下单方法与响应处理
把参数拼装、签名、发请求串起来,一个可用的 Java 服务方法大概长这样:
public H5OrderResult createH5Order(PayOrder order, String clientIp) { Map<String, Object> body = new LinkedHashMap<>(); body.put("appid", props.getAppId()); body.put("mchid", props.getMchId()); body.put("description", StringUtils.abbreviate(order.getSubject(), 120)); body.put("out_trade_no", order.getOutTradeNo()); body.put("notify_url", props.getNotifyUrl()); body.put("time_expire", order.getExpireAt() .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"))); body.put("amount", Map.of("total", order.getAmountFen(), "currency", "CNY")); body.put("scene_info", Map.of( "payer_client_ip", clientIp, "h5_info", Map.of( "type", "Wap", "app_name", props.getAppName(), "app_url", props.getAppUrl() ) )); String json = JSON.toJSONString(body); // 只序列化一次,签名和发送复用 String path = "/v3/pay/transactions/h5"; String auth = signer.buildAuthorization("POST", path, json); HttpResponse<String> resp = http.send(path, json, auth); if (resp.statusCode() != 200) { // 微信的错误体是 JSON:code / message / detail,一定要打到日志里 throw new WxPayException(resp.statusCode(), resp.body()); } // 响应也要验签,这里交给统一拦截器处理 return JSON.parseObject(resp.body(), H5OrderResult.class); }返回体里最有用的字段是h5_url,有效期只有 5 分钟,过期后用户再点就废了。所以正确做法是:用户点“去支付”时才实时下单,不要把h5_url存库然后定时推送,那种设计一定会碰到“链接已失效”。
响应验签容易被忽略。微信返回的报文带Wechatpay-Serial、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature四个响应头,验签串是“时间戳\n随机串\n响应体\n”。如果不验签,理论上中间人可以把h5_url换成自己的,这类问题在内部系统里不致命,但对外收银台属于必须做的一环。验签用的证书有两种模式:平台证书模式按Wechatpay-Serial去本地证书缓存里找对应公钥;公钥模式则是Wechatpay-Serial直接就是公钥 ID。这两种模式配置项不一样,迁移时最容易漏改。
3. 回调、查单与幂等:把“支付成功”这件事做扎实
下单成功只是开始。用户付完钱,钱到账是微信的事,订单状态翻不翻得过来是你的事。这一层做不好,用户付了钱订单还是待支付,客服工单马上就来。
3.1 异步通知解密:AEAD_AES_256_GCM 的三个参数
微信的回调报文长这样,真正的订单信息在resource里,而且是加密的:
{ "id": "EV-2024060100001", "event_type": "TRANSACTION.SUCCESS", "resource": { "algorithm": "AEAD_AES_256_GCM", "ciphertext": "base64...", "associated_data": "transaction", "nonce": "12位随机串", "original_type": "transaction" } }解密用的是 APIv3 密钥,也就是你在商户平台自己设置的那串 32 位字符,不是 API 密钥、不是商户号、不是证书密码。三个参数缺一不可:nonce是 GCM 的 IV,长度 12 字节;associated_data是附加认证数据,交易类通知固定为transaction;ciphertext是密文,末尾自带 16 字节认证标签,所以GCMParameterSpec的 tag 长度写 128。
public String decryptResource(String ciphertext, String nonce, String associatedData) { try { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec key = new SecretKeySpec( props.getApiV3Key().getBytes(StandardCharsets.UTF_8), "AES"); // tag 长度 128 bit,nonce 长度 12 字节,这两个写错就是 AEADBadTagException 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[] plain = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plain, StandardCharsets.UTF_8); } catch (GeneralSecurityException e) { throw new WxPayException("回调报文解密失败", e); } }解密之后拿到的 JSON 里有out_trade_no、transaction_id、trade_state、amount.total、payer.openid等字段。其中amount.total必须和你自己库里的金额再比一次,单位是分。这一步是防“金额被篡改”的最后一道闸,虽然报文是加密的,但业务侧对一遍账没坏处。
3.2 幂等:微信的重试机制决定了你不能只写一次
微信在没收到成功应答时会重试,重试间隔从 15 秒开始逐步拉长,最长跨度接近 24 小时,累计十几次。也就是说,同一笔订单的回调你的接口很可能收到多遍,而且可能在你发货之后才来第二遍。
正确的表结构要给状态机留字段,而不是只有一个status:
CREATE TABLE pay_order ( out_trade_no VARCHAR(32) NOT NULL PRIMARY KEY, transaction_id VARCHAR(64) DEFAULT NULL COMMENT '微信支付订单号', amount_fen BIGINT NOT NULL COMMENT '单位:分', status VARCHAR(16) NOT NULL COMMENT 'WAIT/PAYING/SUCCESS/CLOSED/REFUND', paid_at DATETIME DEFAULT NULL, notify_count INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_transaction (transaction_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;uk_transaction这个唯一索引是第二道保险:万一out_trade_no的幂等没兜住,transaction_id重复插入会直接失败,至少不会出现两条成功记录。
业务侧的状态推进用带条件的更新,而不是先查后改:
@Transactional(rollbackFor = Exception.class) public boolean markPaid(String outTradeNo, String transactionId, long paidFen) { // 只有从 PAYING 才能推 SUCCESS,重复回调时影响行数为 0,直接返回 int rows = jdbc.update( "UPDATE pay_order SET status='SUCCESS', transaction_id=?, paid_at=NOW(), " + "notify_count=notify_count+1, version=version+1 " + "WHERE out_trade_no=? AND status='PAYING' AND amount_fen=?", transactionId, outTradeNo, paidFen); if (rows == 0) { // 已经成功过,或者金额对不上,记一条告警日志就好 log.warn("重复回调或金额不一致 outTradeNo={}, fen={}", outTradeNo, paidFen); return false; } // 发货、加积分、发消息,都在同一个事务里,或者走本地消息表 deliveryService.deliver(outTradeNo); return true; }这段代码的关键在WHERE status='PAYING'。它等价于一个乐观锁,让并发进来的两次回调里只有一次能更新成功,另一次影响行数为 0,天然幂等。比“先select判断状态再update”靠谱得多,后者在并发下必然出问题。
回调接口的应答也很讲究:处理成功必须返回 HTTP 200 或 204,不要返回{"code":"SUCCESS"}这种业务包装体,微信只看 HTTP 状态码。返回 500 或者 200 但带错误信息,都会被判定为失败并触发重试,你这边就会收到十几遍同样的通知。
3.3 主动查单:回调丢失时的补漏机制
回调会丢,这是常态不是异常。网关限流、发布重启、证书过期、公网抖动,任何一个环节都能让通知打不进来。所以必须有一个主动查单的补偿任务,扫那些停留在PAYING太久的订单,去微信那边问一句。
@Scheduled(fixedDelay = 60_000) public void compensate() { // 只捞 2 分钟前创建、仍处于 PAYING 的单,避免和正常回调打架 List<PayOrder> pending = orderMapper.selectPending( LocalDateTime.now().minusMinutes(2), 100); for (PayOrder order : pending) { String path = "/v3/pay/transactions/out-trade-no/" + order.getOutTradeNo() + "?mchid=" + props.getMchId(); String auth = signer.buildAuthorization("GET", path, ""); HttpResponse<String> resp = http.get(path, auth); if (resp.statusCode() != 200) { continue; // 查不到就下一轮再来,别在这里抛异常打断整批 } JSONObject body = JSON.parseObject(resp.body()); String state = body.getString("trade_state"); if ("SUCCESS".equals(state)) { markPaid(order.getOutTradeNo(), body.getString("transaction_id"), body.getJSONObject("amount").getLongValue("total")); } else if ("CLOSED".equals(state) || "REVOKED".equals(state)) { orderMapper.close(order.getOutTradeNo()); } } }查单接口的签名串里 URL 必须带?mchid=xxx,这是前面提过的高频错误。另外trade_state不止SUCCESS一种,还有NOTPAY、CLOSED、REFUND、PAYERROR,只判成功不判关闭,会导致大量订单永远挂在中间态。
4. 前端唤起与结果页:h5_url 拼接、域名白名单和浏览器判定
后端把h5_url返给前端,事情只做了一半。用户从点击到看到“支付成功”,中间隔着跳转、唤起、返回、轮询四道关,任何一道断了都会变成“我付了但没显示成功”。
4.1 redirect_url 的编码规则与域名白名单
h5_url本身是一个完整的跳转链接,你需要在后面追加redirect_url参数,告诉微信支付完成后把人送回哪里:
public String buildRedirectUrl(String h5Url, String returnUrl) { // redirect_url 必须整体 urlencode,否则里面的 ? 和 & 会把外层参数截断 String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8); String separator = h5Url.contains("?") ? "&" : "?"; return h5Url + separator + "redirect_url=" + encoded; }两个约束必须记住。第一,returnUrl必须是公网可访问的 https 地址,本机localhost和测试环境的http://192.168.x.x都不行,微信那边根本跳不过去。第二,returnUrl的域名要和商户平台里配置的 H5 支付域名一致,否则支付完成后跳转会被拦。本地联调的常见做法是配置一个内网穿透域名并加进白名单,同时准备一套测试商户号,别拿生产商户号试。
redirect_url回来后,URL 上会带一些参数,但不要依赖这些参数判断支付结果。用户的网络环境、是否手动改地址、是否中途关页面,都能让参数丢失。正确姿势是回来之后拿自己的out_trade_no去后端查状态。
4.2 微信内置浏览器的判定与降级
H5 支付在微信内打不开,这一点必须在点击“去支付”之前就判断掉,而不是等用户跳过去看到提示再回来:
function isWeChatBrowser() { return /MicroMessenger/i.test(navigator.userAgent); } async function goPay(orderNo) { if (isWeChatBrowser()) { // 微信内走 JSAPI:先拿 openid,再调 jsapi 下单拿 prepay_id const cfg = await fetch(`/api/pay/jsapi/config?outTradeNo=${orderNo}`).then(r => r.json()); return wx.chooseWXPay(cfg); // 需要提前 wx.config 注入签名 } const { h5Url } = await fetch(`/api/pay/h5/create?outTradeNo=${orderNo}`, { method: 'POST' }).then(r => r.json()); window.location.href = h5Url; // 外部浏览器直接跳转 }如果你们没做 JSAPI,只做了 H5,那微信内的降级方案只能是引导用户“点右上角,在浏览器中打开”。这个引导页要做成可点击复制的链接形式,因为部分机型不允许直接复制地址栏。体验很差,但比让用户对着“请在外部浏览器打开”的白屏发呆强。
4.3 结果页轮询:把“支付中”当成一个正经状态
用户从微信回到你的结果页时,回调可能还没到。这时候不能直接显示“支付失败”,也不能一直转圈,得轮询:
async function pollStatus(orderNo, maxTimes = 15) { for (let i = 0; i < maxTimes; i++) { const res = await fetch(`/api/pay/status?outTradeNo=${orderNo}`).then(r => r.json()); if (res.status === 'SUCCESS') { location.replace(`/pay/result?orderNo=${orderNo}`); // 用 replace 防止用户回退重复支付 return; } if (res.status === 'CLOSED') { showFail('订单已关闭,请重新下单'); return; } await new Promise(r => setTimeout(r, 1500)); // 1.5 秒一次,别太密 } // 15 次约 22 秒还没结果,落到“处理中”页面,后台继续查 showPending('支付结果确认中,稍后可在订单列表查看'); }轮询间隔别设成 200 毫秒,那只是给自家接口增加无意义的压力。1.5 到 2 秒一次、总共二十来秒是常见取值,超过就跳到“处理中”页面,让后端的查单补偿任务去收尾。用location.replace而不是location.href,是为了避免用户点返回又回到支付页重复下单。
5. 避坑与排查:签名 401、回调丢失、金额单位这几处血泪经验
下面几条都是我在实际项目里撞过的,按“现象 → 原因 → 解决”写,遇到同类问题可以直接对照。
现象:所有 v3 接口返回 401,错误码SIGN_ERROR,但签名代码看着没错。原因通常有三个:一是签名串里的 URL 漏了 query string,查单、关单、退款这类带路径参数或查询参数的接口必错;二是请求体被序列化了两次,签名用的字符串和HttpClient实际发出去的字节不一致,常见于用Map传参又让框架自己转 JSON 的写法;三是私钥格式不对,商户平台下载的是 PKCS#8,如果你把它转成 PKCS#1 再读,Signature初始化阶段就会报错,但有些人捕获异常后吞掉了,最后表现为 401。 解决:在签名方法里把message原文打成 debug 日志,和官方文档的示例逐字符比对,重点看换行符和末尾的空行;私钥统一用 PKCS#8 加载,读取时去掉 PEM 的头尾行和换行。
现象:下单成功,用户也付了钱,但回调接口一条日志都没有。原因:notify_url用了 http、带了 query 参数、指向内网地址,或者被公司网关的 WAF 拦了 POST。还有一个隐蔽的:回调接口要求登录鉴权,微信的请求没带 token,直接被 302 到登录页,微信收到 302 判定失败。 解决:notify_url用纯 https 且不带任何参数;回调路径加进网关鉴权白名单;先用 curl 从公网机器上打一次自己的回调地址,确认能拿到 200。
现象:同一笔订单发了两遍货,用户收到两份。原因:微信重试机制叠加没做幂等,业务代码是“查状态 → 如果不是成功就发货 → 更新状态”,并发下两次请求都读到未成功。 解决:改成带条件的UPDATE ... WHERE status='PAYING',用影响行数判断是否首次处理;再加transaction_id唯一索引兜底。发货这类不可逆动作,最好挂在本地消息表上异步执行,别塞在回调线程里。
现象:金额全部差 100 倍,1 元的商品显示 1 分。原因:微信支付全链路金额单位是分,前端传的是元,后端没乘 100 就往下传;或者反过来,数据库存的是分,展示时又除了一次。 解决:约定死一条规则——进入支付层之前一律转成分,用long存,字段名带上_fen后缀。退款金额同样以分为单位,且不能超过原订单金额。
现象:用户在微信里点支付,页面提示“请在外部浏览器打开”,转化率断崖。原因:场景选错了。H5 支付(MWEB)只支持外部浏览器,微信内置浏览器必须走 JSAPI。 解决:在入口处用 UA 判断,微信内走 JSAPI,外部浏览器走 H5; JSAPI 需要openid,可以通过公众号网页授权拿到。如果短期内做不了 JSAPI,至少做一个可复制的引导页,别让用户卡在白屏上。
现象:h5_url生成后过一会儿再点,提示链接失效。原因:h5_url有效期是 5 分钟,很多人习惯下单后把链接存库、通过短信或消息推送发给用户。 解决:链接不落库,改成推送一条“去支付”的通知,用户点进来时实时下单。如果一定要预生成,就把有效期一起存下来,超过 4 分钟直接重新生成。
6. 上线前的验证清单与一套可复用的自测脚本
功能跑通不等于能上线。我一般会在提测前把下面这张表走一遍,尤其是前面几项,出问题概率最高:
| 检查项 | 判定标准 | 出问题的后果 |
|---|---|---|
| 商户私钥加载 | 用 PKCS#8 能正常初始化Signature | 所有 v3 接口 401 |
| 平台证书/公钥缓存 | 启动时拉取成功,过期前自动刷新 | 验签失败,回调全部丢弃 |
| APIv3 密钥长度 | 正好 32 字节 | 回调解密抛AEADBadTagException |
notify_url可达性 | 公网 curl 返回 200 | 回调收不到,订单卡在 PAYING |
| 回调幂等 | 同一报文连发 5 次只发货 1 次 | 重复发货、重复加积分 |
| 金额单位 | 全链路分,数据库字段带_fen | 差 100 倍 |
h5_url时效 | 下单后 4 分钟内使用 | 用户点开提示失效 |
| 微信内 UA | 自动降级到 JSAPI 或引导页 | 转化率骤降 |
| 关单逻辑 | 超时订单调/close并置为 CLOSED | 库存被长期占用 |
自测脚本不用写得多复杂,一个能复现签名串的单元测试就够挡住八成问题。我通常会把官方文档给出的示例参数固化成测试用例,断言自己生成的签名串和预期字符串完全相等——注意是字符串相等,不是“验签通过”,因为验签通过只能说明你自己的签名和验签是自洽的,签错位置照样自洽:
@Test void signMessageShouldMatchOfficialSample() { String message = "POST\n" + "/v3/pay/transactions/h5\n" + "1554208460\n" + "593BEC0C930BF1AFEB40B4A08C8FB242\n" + "{\"appid\":\"wxd678efh567hg6787\"}\n"; // 用固定的测试私钥签,比对 Base64 结果;换密钥时同步更新期望值 String actual = signer.sign(message); assertEquals(EXPECTED_SIGNATURE, actual); }写完这个测试,把message打成一行带可见换行符的日志(比如把\n替换成\\n再打印),对着文档逐段核。这一步花十分钟,能省掉后面两小时的 401 排查。
还有一个习惯:所有和微信交互的请求响应都落一份脱敏日志,保留Wechatpay-Request-Id这个响应头。真出问题时,拿着这个 ID 去找微信侧核对,比自己瞎猜快得多。日志里手机号、openid 这类字段要打码,但订单号、金额、状态码必须完整保留。
从那以后我每次接新的支付渠道,都会先把“签名串自测 + 回调连发五次 + 公网 curl 回调地址”这三件事跑一遍再提测,翻车的次数确实少了很多。这套 H5 支付的 Java 源码按上面的顺序读下来,下单、回调、前端三段都能直接对上号,剩下的就是把自己的商户参数填进去。希望帮到你。
本文还有配套的精品资源,点击获取