简介:这份资源围绕 Java 后端接入微信 H5 支付展开,面向需要为电商网站、移动端网页补齐在线收款能力的 Java 开发者,也适合作为课程设计或小型项目中的支付模块参考。内容涵盖统一下单获取预支付标识、前端唤起支付、异步回调校验、订单查询、异常重试与 HTTPS 加密传输等关键环节,并对测试环境与正式环境的切换、部署文档的编写提出了可落地的说明。压缩包共 11 个文件,以 java 源码和 xml 配置为主,另含 properties 参数文件、doc 部署说明和 html 页面,整体约 16KB,结构轻量、便于快速通读与二次改造。该资源已有 1322 人学习下载。借助其中的示例代码与配置样例,读者可以对照梳理签名生成、回调验签与订单状态同步的实现思路,减少联调阶段的反复试错,也能把它当作支付接口入门与代码骨架复用的材料。
1. 从「下单页跳不过去」说起:Java 接 H5 微信支付到底在接什么
用户在手机浏览器里点了一份 198 元的课,收银台弹出「微信支付」,点完之后先跳到微信的确认页面,付完又回到你自家的订单详情页。这条链路的服务端部分,就是 Java 后端要实现的 H5 微信支付:前端只调两个自家接口(创建订单、查询状态),后端拿着商户号私钥去调微信的/v3/pay/transactions/h5,把h5_url原样丢给浏览器,剩下的等回调。它和公众号 JSAPI 支付最大的区别在于「支付发生地」——用户已经跳出了微信,没有现成的openid,所以你不能靠 JSAPI 支付,也不必强求公众号授权。适合谁?适合做 PC 扫码之外还想吃移动浏览器流量的电商、SaaS、知识付费后端,以及需要在 App 内嵌 H5 页里收单的团队。这篇笔记就按「选型 → 目录 → 核心代码 → 跑起来 → 踩坑 → 二次开发」的次序,把一套能上线的 Java H5 微信支付骨架拆开讲。
2. 技术选型与工程骨架:把支付塞进 Spring Boot 的正确姿势
2.1 为什么直接上 APIv3,而不是抱着 v2 的 mweb_url 不放
H5 支付在 v2 时代叫「MWEB 支付」,下单后拿到mweb_url,前端跳过去,微信侧再回跳redirect_url。v2 的问题是签名算法用 MD5/HMAC-SHA256 拼字典序,签名串靠&key=结尾,密钥一旦泄露等于全盘皆输;回调是明文 XML,验签也只是校验签名头,报文本身不加密。APIv3 换成了 SHA256-RSA2048 非对称签名 + AES-256-GCM 加密回调报文,至少「谁能发指令」「报文有没有被改」这两件事有了结构性保障。
更现实的原因是生态。现在新开的商户号默认就是 APIv3 的密钥体系,平台证书可以换成「微信支付公钥」模式,官方 Java SDKwechatpay-java只维护 APIv3 这一条线。继续用 v2,你迟早要面对证书更换、回调加解密、以及部分新能力只在 v3 开放的问题。所以选型上我一般会直接定:接口走 APIv3,SDK 用官方wechatpay-java,同时自己保留一个手写签名工具类,方便排查「到底是签名错了还是业务错了」。
技术栈在这类项目里几乎没有悬念:
| 层次 | 选型 | 说明 |
|---|---|---|
| 运行时 | JDK 8 / 11 / 17 | 用 AES-256 时注意老版本 JDK 需要开启无限制策略 |
| 框架 | Spring Boot 2.7 或 3.x | 回调接口就是一个普通@RestController |
| 支付 SDK | wechatpay-java0.2.x | 坐标com.github.wechatpay-apiv3 |
| 持久层 | MyBatis-Plus / JPA | 订单表 + 回调流水表是标配 |
| 缓存 | Redis | 下单防重、回调幂等锁、查单节流 |
| HTTP | JDK HttpClient / OkHttp | SDK 内部自带,自己封装时用得上 |
wechatpay-java的引入方式很朴素,版本以仓库当前稳定版为准,别抄死:
<dependency> <groupId>com.github.wechatpay-apiv3</groupId> <artifactId>wechatpay-java</artifactId> <version>0.2.14</version> </dependency>这个依赖提供RSAAutoCertificateConfig、JsapiServiceExtension、NotificationParser等封装,省掉平台证书下载与轮换的体力活。但要注意它并不替你管业务幂等,回调里该写的唯一索引、状态判断,一行都不能少。
2.2 目录结构:payment 模块里每个类到底管什么
真实的支付代码最怕「什么都塞进 Controller」。下面这套划分是我拆过几个项目之后固定下来的,横竖不超过十个类:
src/main/java/com/example/pay ├── config │ ├── WxPayProperties.java # 商户号、证书序列号、私钥路径、APIv3 密钥 │ └── WxPayConfig.java # 构建 RSAAutoCertificateConfig 单例 ├── controller │ ├── PayOrderController.java # 前端入口:创建订单 / 查询状态 │ └── WxNotifyController.java # 微信回调入口,只做验签解密与转交 ├── service │ ├── PayOrderService.java # 订单落库、状态机流转 │ └── WxH5PayService.java # 调微信下单、查单、关单 ├── client │ └── WxPaySigner.java # 手写签名 / 验签 / 解密工具 ├── domain │ ├── PayOrder.java # 订单实体 │ └── PayNotifyLog.java # 回调原始报文留痕 └── job └── OrderSyncJob.java # 定时补偿查单,兜住丢回调职责边界要说清楚:WxH5PayService只负责「和微信说话」,不碰本地订单状态;PayOrderService只负责「本地这笔单子是死是活」,不知道 HTTP 长什么样;WxNotifyController是个薄壳,验签失败立刻返回 401,验签成功把解密后的 JSON 丢给PayOrderService就完事。这样拆的好处是,将来加 JSAPI、Native 扫码、退款,只需要在同层横向加类,订单状态机不用动。
2.3 商户平台要准备的四份材料与配置项
代码跑不起来,八成不是 Java 的问题,而是配置项拿错了。开写之前先在商户平台把这些抄下来:
appid:绑定在商户号下的应用 ID,H5 支付要求它和商户号有绑定关系;mchid:商户号,纯数字字符串;- 商户 API 证书:
apiclient_key.pem(私钥)和证书序列号,序列号在「API 安全」里能查到; - APIv3 密钥:32 位,回调报文的 AES-256-GCM 解密用的就是它。
配置类长这样,把敏感信息从代码里挪到配置文件,路径类配置用classpath:或绝对路径都行:
@Data @ConfigurationProperties(prefix = "wxpay") public class WxPayProperties { /** 应用 ID,与商户号绑定 */ private String appId; /** 商户号 */ private String mchId; /** 商户 API 证书序列号,注意不是平台证书序列号 */ private String serialNo; /** 私钥文件位置,apiclient_key.pem */ private String privateKeyPath; /** APIv3 密钥,32 位,用于回调解密 */ private String apiV3Key; /** 支付结果回调地址,必须公网可访问且不带参数 */ private String notifyUrl; }notifyUrl这里有个容易被忽略的细节:微信要求它是 HTTPS(部分场景允许 HTTP 但强烈不建议),不能带 query 参数,回调时微信不保证按你 URL 里的参数回传。很多人想在notify_url后面拼?tenantId=1区分租户,结果发现拿不到——正确做法是下单时把业务标识塞进attach字段,回调解密后从attach里取。这个坑后面第 5 章还会再提一次。
3. 核心链路拆解:从签名串到订单状态机
3.1 签名串与 Authorization 头:手写一遍 SHA256-RSA2048
官方 SDK 把签名藏起来了,但线上出问题时你必须能自己拼出签名串做比对,所以这个工具类我建议每个项目都留一份。APIv3 的请求签名由五行组成,顺序固定,每行必须以\n结尾,最后一行也要:
public final class WxPaySigner { private static final String SCHEMA = "WECHATPAY2-SHA256-RSA2048"; private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"); /** * 构造请求头 Authorization * @param url 只传 path + query,不带协议和域名,例如 /v3/pay/transactions/h5 * @param body 请求体原文,GET 请求传空字符串 */ public static String buildAuthorization(String mchId, String serialNo, PrivateKey privateKey, String method, String url, String body) throws Exception { long timestamp = System.currentTimeMillis() / 1000; // 秒级,不是毫秒 String nonceStr = RandomStringUtils.randomAlphanumeric(32); // 32 位随机串 // 签名串:方法 \n URL \n 时间戳 \n 随机串 \n 报文主体 \n String message = method + "\n" + url + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; Signature signer = Signature.getInstance("SHA256withRSA"); signer.initSign(privateKey); signer.update(message.getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(signer.sign()); return SCHEMA + " mchid=\"" + mchId + "\"" + ",nonce_str=\"" + nonceStr + "\"" + ",signature=\"" + signature + "\"" + ",timestamp=\"" + timestamp + "\"" + ",serial_no=\"" + serialNo + "\""; } }逻辑说明:message的五行任何一行错位都会导致 401 签名错误,其中最常错的是第二行 URL——必须是 path 加 query,不含https://api.mch.weixin.qq.com。如果接口带查询参数(比如查单的/v3/pay/transactions/out-trade-no/{no}?mchid=xxx),query 部分必须原样带上,且参数顺序与你实际请求的顺序一致。
参数说明:serialNo是商户 API 证书序列号,不是平台证书序列号,两者混用会得到「签名验证失败」;timestamp是秒级,用毫秒会直接被判定时间戳非法;nonceStr官方要求不长于 32 位,用 UUID 去掉横线也行。
对应地,回调验签用的是平台证书(或微信支付公钥)验证微信发来的签名,验签串只有三行:timestamp\nnonce\nbody\n,比请求签名少两行,这个不对称是很多人第一次接 APIv3 时最懵的地方。
还有一个容易被忽略的工具方法:加载 PEM 私钥。JDK 没有现成的pem解析,得自己剥掉头尾:
public static PrivateKey loadPrivateKey(String pem) throws Exception { String content = pem.replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] der = Base64.getDecoder().decode(content); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); return KeyFactory.getInstance("RSA").generatePrivate(spec); }注意这里只处理 PKCS#8 格式。如果你从某些工具导出的私钥开头是-----BEGIN RSA PRIVATE KEY-----,那是 PKCS#1,得先转换,否则会抛InvalidKeySpecException。这个报错信息非常不友好,只会告诉你 key 格式不对,不会告诉你是哪种格式。
3.2 H5 统一下单:h5_url 是怎么拼出来的
下单接口是POST /v3/pay/transactions/h5,请求体关键字段不多,但每个都有存在意义:
public String createH5Order(PayOrder order, String clientIp) throws Exception { String path = "/v3/pay/transactions/h5"; Map<String, Object> body = new LinkedHashMap<>(); body.put("appid", props.getAppId()); body.put("mchid", props.getMchId()); body.put("description", order.getDescription()); // 商品描述,会展示给用户 body.put("out_trade_no", order.getOutTradeNo()); // 商户订单号,全局唯一 body.put("time_expire", order.getExpireAt().format(FMT)); // 过期时间,RFC3339 格式 body.put("attach", order.getTenantId()); // 业务透传,回调原样带回 body.put("notify_url", props.getNotifyUrl()); body.put("amount", Map.of( "total", order.getAmountTotal(), // 单位分,整数 "currency", "CNY")); body.put("scene_info", Map.of( "payer_client_ip", clientIp, // 用户真实 IP,必填 "h5_info", Map.of("type", "Wap"))); // 固定 Wap String json = JSON.toJSONString(body); String auth = WxPaySigner.buildAuthorization(props.getMchId(), props.getSerialNo(), privateKey, "POST", path, json); HttpResponse resp = HttpRequest.post("https://api.mch.weixin.qq.com" + path) .header("Authorization", auth) .header("Content-Type", "application/json") .header("Accept", "application/json") .body(json) .timeout(8000) .execute(); // 关键:HTTP 200 不代表下单成功,必须看 body 里的 code 字段 JSONObject result = JSON.parseObject(resp.body()); if (result.containsKey("code")) { throw new PayException(result.getString("code"), result.getString("message")); } return result.getString("h5_url"); // 形如 https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?... }逻辑说明:先落库生成out_trade_no,再调微信,拿到h5_url后回写订单表并返回给前端,前端window.location.href = h5_url即可。顺序不能反——如果先调微信再落库,一旦落库失败,微信侧已经有单,本地却查不到,对账时会很难看。
参数说明:amount.total单位是分,198 元要传 19800,这个错法每年都能见到几十次;time_expire必须是 RFC3339 格式且带时区,例如2025-01-01T12:00:00+08:00,且不能超过商户平台配置的最长有效期;payer_client_ip拿不到真实 IP 时(比如经过多层代理)会报「参数错误」,需要在网关层透传X-Forwarded-For并从中取第一个合法 IP;description里不能有敏感词和特殊字符,否则会触发风控拦截。
顺带一提,h5_url有个可选参数redirect_url,如果想让用户付完自动回到你的订单页,要把它拼在h5_url后面做 URLEncode:
String redirect = URLEncoder.encode("https://your-domain.com/order/result?no=" + outTradeNo, "UTF-8"); return h5Url + "&redirect_url=" + redirect;不编码会直接跳转失败,这是新手最常翻的车之一。不过即便用redirect_url,也不能把它当成支付成功的依据——用户可以手机关掉页面,回调才是唯一可信的来源。
3.3 回调处理:验签、AES-256-GCM 解密、幂等落库
微信回调是整条链路里最容易写崩的地方,因为它同时要求三件事:验签、解密、幂等。先看解密,AES-256-GCM 的写法很固定:
public static String decryptResource(String apiV3Key, String associatedData, String nonce, String ciphertext) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), "AES"); // GCM tag 长度固定 128 bit,nonce 来自回调报文里的 resource.nonce GCMParameterSpec spec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, spec); if (associatedData != null) { cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); } byte[] plain = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plain, StandardCharsets.UTF_8); }参数说明:apiV3Key是商户平台上设置的 32 位密钥,不是 API 密钥,也不是商户号;associatedData就是报文里的resource.associated_data,绝大多数情况是字符串transaction,但有值就必须传,传 null 或传错会抛AEADBadTagException;nonce是 12 字节的随机串,直接按 UTF-8 取字节,不要去 Base64 解码。
再看 Controller,这个类要写得越薄越好:
@PostMapping("/pay/wx/notify") public ResponseEntity<String> notify(@RequestBody String body, @RequestHeader("Wechatpay-Signature") String signature, @RequestHeader("Wechatpay-Timestamp") String timestamp, @RequestHeader("Wechatpay-Nonce") String nonce, @RequestHeader("Wechatpay-Serial") String serial) { // 1. 验签,失败直接 401,不要往下走 if (!wxPaySigner.verify(signature, timestamp, nonce, body, serial)) { return ResponseEntity.status(401).body("{\"code\":\"FAIL\",\"message\":\"签名错误\"}"); } // 2. 解密并转交业务层,异常不吞掉,返回 500 让微信重试 JSONObject json = JSON.parseObject(body); String plain = WxPaySigner.decryptResource(props.getApiV3Key(), json.getJSONObject("resource").getString("associated_data"), json.getJSONObject("resource").getString("nonce"), json.getJSONObject("resource").getString("ciphertext")); payOrderService.handlePaidNotify(plain); // 3. 必须返回 200 且 body 形如 {"code":"SUCCESS"},否则微信会持续重试 return ResponseEntity.ok("{\"code\":\"SUCCESS\",\"message\":\"成功\"}"); }逻辑说明:验签失败返回 401,微信会认为通知未送达并重试;业务处理抛异常时不要 catch 后返回 200,那会直接丢掉这笔支付结果,正确做法是让异常冒泡成 500,触发微信的退避重试(大约 15 秒、15 秒、30 秒、3 分钟……逐步拉长)。
幂等落库这一段是整个支付里最值钱的代码:
@Transactional(rollbackFor = Exception.class) public void handlePaidNotify(String plainJson) { JSONObject tx = JSON.parseObject(plainJson); String outTradeNo = tx.getString("out_trade_no"); String transactionId = tx.getString("transaction_id"); // 行锁 + 状态判断,挡住并发重复通知 PayOrder order = orderMapper.selectForUpdate(outTradeNo); if (order == null) { throw new IllegalStateException("订单不存在: " + outTradeNo); } if (order.getStatus() == OrderStatus.PAID) { return; // 已处理,直接幂等返回 } if (!"SUCCESS".equals(tx.getString("trade_state"))) { return; // 非成功状态不推进订单 } // 金额校验,防止串单 if (tx.getJSONObject("amount").getIntValue("total") != order.getAmountTotal()) { throw new IllegalStateException("金额不一致: " + outTradeNo); } orderMapper.markPaid(outTradeNo, transactionId, tx.getString("success_time")); // 支付成功后的业务动作:发货、开会员、记流水,全部在同一个事务里 orderService.onPaid(order); }参数说明:selectForUpdate必须是走唯一索引的等值查询,否则会锁全表;amount.total一定要和本地订单金额比对,这是防篡改的最后一道;success_time是微信侧的支付完成时间,落库比用now()更利于对账。
3.4 查单与状态机:把「已支付」这件事钉死
回调会丢,这是常态而不是异常。网络抖动、服务重启、部署时正好赶上通知,都可能让回调没落到你手上。所以必须有一个主动查单的兜底:GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={mchid}。
public String queryState(String outTradeNo) throws Exception { String path = "/v3/pay/transactions/out-trade-no/" + outTradeNo + "?mchid=" + props.getMchId(); String auth = WxPaySigner.buildAuthorization(props.getMchId(), props.getSerialNo(), privateKey, "GET", path, ""); // GET 请求签名串的 body 位置传空字符串 HttpResponse resp = HttpRequest.get("https://api.mch.weixin.qq.com" + path) .header("Authorization", auth) .header("Accept", "application/json") .execute(); JSONObject result = JSON.parseObject(resp.body()); // trade_state: SUCCESS / REFUND / NOTPAY / CLOSED / REVOKED / USERPAYING / PAYERROR return result.getString("trade_state"); }参数说明:GET 请求参与签名的 URL 必须包含 query,且mchid参数不能少;body 位置传空串但\n不能省,这是签名串的第五行。
订单状态机建议收敛成四个值:CREATED(0) → PAID(1) / CLOSED(2) → REFUNDED(3)。所有状态更新都写成带前置条件的 UPDATE,而不是先查再改:
UPDATE pay_order SET status = 1, transaction_id = #{transactionId}, paid_at = #{paidAt}, updated_at = NOW() WHERE out_trade_no = #{outTradeNo} AND status = 0;影响行数为 0 就说明已经有人推进过了,直接返回,不报错。这套「条件更新 + 唯一索引」的组合,比任何分布式锁都可靠,因为它把幂等性压在了数据库这一层,不依赖 Redis 是否可用。
定时补偿任务就很简单了:扫出 5 分钟前创建、状态仍是CREATED的单子,逐个调查单接口,SUCCESS就走和回调一样的处理逻辑。频率别太高,一分钟一轮、每轮限流几十笔就够,微信对查单有频次限制。
4. 本地跑起来:环境、穿透与联调顺序
4.1 数据库表与初始化
两张表就够起步,一张主订单,一张回调流水留痕:
CREATE TABLE pay_order ( id BIGINT NOT NULL AUTO_INCREMENT, out_trade_no VARCHAR(64) NOT NULL COMMENT '商户订单号', transaction_id VARCHAR(64) DEFAULT NULL COMMENT '微信支付订单号', amount_total INT NOT NULL COMMENT '金额,单位分', description VARCHAR(128) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已关闭 3已退款', attach VARCHAR(128) DEFAULT NULL COMMENT '业务透传字段', paid_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_out_trade_no (out_trade_no), UNIQUE KEY uk_transaction_id (transaction_id), KEY idx_status_created (status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE pay_notify_log ( id BIGINT NOT NULL AUTO_INCREMENT, notify_id VARCHAR(64) NOT NULL COMMENT '微信通知 id,用于去重', event_type VARCHAR(32) NOT NULL, out_trade_no VARCHAR(64) DEFAULT NULL, raw_body TEXT COMMENT '原始密文报文,排查用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_notify_id (notify_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_transaction_id这个唯一索引别漏,它是防「同一笔微信订单被两条不同商户单号认领」的最后一道闸。pay_notify_log存在的意义是:线上出问题时,你能拿到当时的原始报文重放一遍,否则只能靠猜。日志里的raw_body是密文,不含敏感信息,留存合规上问题不大,但建议定期归档。
4.2 本地配置与公网回调
配置文件大概长这样,私钥建议用环境变量注入,别直接提交到仓库:
wxpay: app-id: wx1234567890abcdef mch-id: "1900000000" serial-no: "5157F09EFDC096DE15EBE81A47057A72AEXAMPLE" private-key-path: ${WXPAY_KEY_PATH:/opt/certs/apiclient_key.pem} api-v3-key: ${WXPAY_V3_KEY:} notify-url: https://pay-demo.example.com/pay/wx/notify本地调试最大的障碍是回调地址必须公网可达。常见做法是用内网穿透工具把本地 8080 映射出去,拿到一个临时域名填进notify-url,然后每换一次域名都要重新下单——因为notify_url是下单时随请求一起传给微信的,已经下过的单不会改。这一点不少人第一次会栽:改了配置重启服务,拿旧订单去付,回调还是发到旧地址。
另外一个安静的坑是 JDK 版本。AES-256-GCM 在 JDK 8 早期版本上会抛InvalidKeyException: Illegal key size,因为默认策略限制了密钥长度。JDK 8u161 之后默认开启无限制策略,所以要么升级到较新的 8u 版本,要么手动替换local_policy.jar和US_export_policy.jar。报错信息里完全不会提到微信支付,第一次遇到很容易怀疑人生。
4.3 联调顺序:一次跑通的四步
按这个顺序走,能省掉大量来回:
- 先测签名,不测业务。写个单元测试调
/v3/certificates或者任意查询接口,只要不返回 401,说明商户号、序列号、私钥三件套对了。这一步过不了,后面全是白搭。 - 下单拿 h5_url。用 curl 或者 Postman 打你自家的创建订单接口,确认数据库里有
CREATED状态的记录,返回值里h5_url不为空。如果报PARAM_ERROR,八成是payer_client_ip或amount.total的问题。 - 手机浏览器打开 h5_url,用一个真实小额(比如 0.01 元)走完支付。注意必须用手机浏览器,微信内置浏览器打开会被引导到公众号支付,行为不一样。
- 看回调日志。支付完成后 5 秒内应该能看到回调进日志,如果没看到,去商户平台的「交易记录」里看通知状态,那里会显示微信重试了几次、返回码是什么。
自测脚本用 curl 更直观,可以快速验证签名:
curl -X POST 'https://api.mch.weixin.qq.com/v3/pay/transactions/h5' \ -H 'Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900000000",nonce_str="...",signature="...",timestamp="1700000000",serial_no="..."' \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{"appid":"wx123","mchid":"1900000000","description":"测试","out_trade_no":"T20250101001","notify_url":"https://your-domain/pay/wx/notify","amount":{"total":1,"currency":"CNY"},"scene_info":{"payer_client_ip":"1.2.3.4","h5_info":{"type":"Wap"}}}'注意时间戳要现算,过期超过 5 分钟会报签名错误,所以手工 curl 时基本上每次都要重新生成一遍。
5. 避坑与排查:支付链路上最容易翻车的五个点
5.1 回调一直重试,日志里只有一行 200
现象:商户平台显示通知失败,重试了七八次,但你自己的接口日志里看到的是 200 返回。原因:Controller 里 try-catch 把异常吞了,或者返回体不是{"code":"SUCCESS","message":"成功"}而是{"code":0}这类自定义结构。微信只认它规定的那两个字段,字段名不对一律当失败处理。
解决:回调接口的返回值写死成常量字符串,不做任何包装;业务异常让它抛出去变成 5xx,让微信重试;只有真正处理完(或确认幂等跳过)才返回 SUCCESS。另外,处理逻辑要控制在 5 秒内完成,超过这个时间微信会判超时重试,重试又会带来并发,必须靠行锁扛住。
5.2 签名总是失败,但用官方 SDK 就正常
现象:自己拼 Authorization 头,10 次里有 3 次报 401SIGN_ERROR。原因:随机串用了带特殊字符的内容、时间戳用了毫秒、或者 GET 请求忘了把 query 拼进签名 URL。还有一个隐蔽原因:HTTP 客户端对 body 做了二次序列化,你签名用的是 A 字符串,实际发出去的是 B 字符串(比如 JSON 字段顺序变了、多了空格)。
解决:签名用的body和实际发送的body必须是同一个字符串变量,不要重复序列化;时间戳统一用System.currentTimeMillis() / 1000;URL 部分严格按「path + ? + 原始 query」拼接。排查时把签名串的五行分别打印出来(第五行是体,注意别把敏感信息打到生产日志里),逐行和官方文档对照。
5.3 解密抛 AEADBadTagException
现象:回调验签通过了,但解密时报AEADBadTagException: Tag mismatch。原因主要有三个:apiV3Key填成了 API 密钥或者长度不是 32 位;associated_data传了 null(实际报文里可能是transaction);nonce被当成 Base64 解码了,而它应该是原样的字符串字节。
解决:先把apiV3Key的长度打印出来确认是 32;associated_data有值就传、没值就传 null,不要传空字符串"";nonce直接getBytes(UTF_8)。另外注意,密钥轮换后老订单的回调仍用老密钥加密,如果刚换过 APIv3 密钥,需要保留旧密钥一段时间做兼容。
5.4 用户付了钱,订单还是待支付
现象:微信侧显示支付成功,本地订单死活不变。原因排查顺序:先看回调有没有进来(查pay_notify_log和网关访问日志),再看是不是notify_url填错了或者被 WAF 拦截了,最后看是不是业务层抛了异常但被吞掉。
排查手段:商户平台「交易记录 → 通知」里能看到每次通知的返回状态,返回 200 但业务没生效说明是代码问题,返回 404/502 说明是网络或网关问题。兜底手段就是第 3.4 节那个定时查单任务,把CREATED超过 5 分钟的单子扫一遍。我一般会在上线前就把这个补偿任务打开,它救过的场次比回调本身还多。
5.5 redirect_url 跳不回来,或者跳回来参数丢了
现象:用户支付完成后停在微信页面,或者回到订单页但 URL 上的no参数没了。原因:redirect_url没有做 URLEncode,或者你自己那个地址本身还带了 query,编码之后被解析错;还有一种情况是用户中途取消支付,微信也会按redirect_url跳回,但订单其实是未支付状态。
解决:redirect_url的值一定要URLEncoder.encode(..., "UTF-8")之后再拼到h5_url后面;回跳页面不要直接信任 URL 上的状态,一律调自己的查单接口确认(或者等回调落库),回跳只负责展示「支付处理中」。另外提醒一句,h5_url的有效期默认 5 分钟,用户如果在页面上磨蹭太久,点支付会报错,需要在页面上做个倒计时或者重新下单的按钮。
6. 二次开发:把这一坨支付代码收敛成可复用的支付网关
单个 H5 支付跑通之后,真正的麻烦才刚开始——业务方会陆续提「再加个扫码」「App 也要付」「要支持退款」「要分账」。如果一开始就把WxH5PayService写死,后面每加一种方式就是一次复制粘贴。我的做法是先抽一层渠道接口:
public interface PayChannel { /** 渠道标识:WX_H5 / WX_NATIVE / WX_JSAPI */ String code(); /** 预下单,返回给前端的跳转参数 */ PrepayResult prepay(PayOrder order, PrepayContext ctx); /** 主动查单,统一返回内部状态 */ ChannelState query(String outTradeNo); /** 关单 */ void close(String outTradeNo); }WxH5PayChannel只是其中一个实现,prepay里做的事就是第 3.2 节那段代码,query把微信的trade_state翻译成内部枚举。这样订单服务只依赖接口,新增渠道时业务代码一行不改。退款的扩展点同理,抽一个RefundChannel,参数里统一用「内部退款单号 + 金额(分)+ 原因」。
第二个值得提前做的是对账。微信每天会生成对账单,通过/v3/bill/tradebill?bill_date=xxx&bill_type=ALL下载,返回的是一个下载链接,文件是 CSV 且带 gzip。落地方式:每天凌晨拉前一天的账单,逐行和pay_order比对,找出「微信成功但本地未成功」和「本地成功但微信无记录」两类差异,前者自动补单,后者挂起来人工核。这段代码不难写,但能挡掉 90% 的资损投诉。
第三个技巧是把回调处理做成可重放。pay_notify_log里存了原始密文,配合一个内部管理接口,可以指定notify_id重新走一遍解密和业务处理。线上遇到「回调处理到一半服务重启」时,这个功能比翻日志快得多。实现上要注意重放时同样走幂等逻辑,别把已经发过的货再发一次。
最后一个习惯上的教训:我早期喜欢在支付回调里顺手做一些「重」的操作,比如发短信、推消息、更新统计数据,结果有一次短信服务商超时,整个回调卡了 8 秒,微信判定超时开始重试,同一笔订单触发了三次业务动作,虽然幂等挡住了重复发币,但消息发了三条。从那以后我每次写回调都强制走一遍「事务内只做状态推进和核心账务,其余动作一律丢到消息队列异步消费」的流程,宁可多写一个消费者,也不让回调链路里出现任何外部 RPC。希望这些能帮你在接下一套支付时少熬两个晚上。
本文还有配套的精品资源,点击获取