Java H5微信支付APIv3实战:签名、回调幂等与查单补偿

发布时间:2026/11/2 15:51:29
Java H5微信支付APIv3实战:签名、回调幂等与查单补偿 简介这份资源围绕 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 开放的问题。所以选型上我一般会直接定接口走 APIv3SDK 用官方wechatpay-java同时自己保留一个手写签名工具类方便排查「到底是签名错了还是业务错了」。技术栈在这类项目里几乎没有悬念层次选型说明运行时JDK 8 / 11 / 17用 AES-256 时注意老版本 JDK 需要开启无限制策略框架Spring Boot 2.7 或 3.x回调接口就是一个普通RestController支付 SDKwechatpay-java0.2.x坐标com.github.wechatpay-apiv3持久层MyBatis-Plus / JPA订单表 回调流水表是标配缓存Redis下单防重、回调幂等锁、查单节流HTTPJDK HttpClient / OkHttpSDK 内部自带自己封装时用得上wechatpay-java的引入方式很朴素版本以仓库当前稳定版为准别抄死dependency groupIdcom.github.wechatpay-apiv3/groupId artifactIdwechatpay-java/artifactId version0.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绑定在商户号下的应用 IDH5 支付要求它和商户号有绑定关系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后面拼?tenantId1区分租户结果发现拿不到——正确做法是下单时把业务标识塞进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-ddTHH: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}?mchidxxxquery 部分必须原样带上且参数顺序与你实际请求的顺序一致。参数说明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; MapString, 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:0008:00且不能超过商户平台配置的最长有效期payer_client_ip拿不到真实 IP 时比如经过多层代理会报「参数错误」需要在网关层透传X-Forwarded-For并从中取第一个合法 IPdescription里不能有敏感词和特殊字符否则会触发风控拦截。顺带一提h5_url有个可选参数redirect_url如果想让用户付完自动回到你的订单页要把它拼在h5_url后面做 URLEncodeString 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 bitnonce 来自回调报文里的 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 或传错会抛AEADBadTagExceptionnonce是 12 字节的随机串直接按 UTF-8 取字节不要去 Base64 解码。再看 Controller这个类要写得越薄越好PostMapping(/pay/wx/notify) public ResponseEntityString 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 mchid1900000000,nonce_str...,signature...,timestamp1700000000,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() / 1000URL 部分严格按「path ? 原始 query」拼接。排查时把签名串的五行分别打印出来第五行是体注意别把敏感信息打到生产日志里逐行和官方文档对照。5.3 解密抛 AEADBadTagException现象回调验签通过了但解密时报AEADBadTagException: Tag mismatch。原因主要有三个apiV3Key填成了 API 密钥或者长度不是 32 位associated_data传了 null实际报文里可能是transactionnonce被当成 Base64 解码了而它应该是原样的字符串字节。解决先把apiV3Key的长度打印出来确认是 32associated_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_datexxxbill_typeALL下载返回的是一个下载链接文件是 CSV 且带 gzip。落地方式每天凌晨拉前一天的账单逐行和pay_order比对找出「微信成功但本地未成功」和「本地成功但微信无记录」两类差异前者自动补单后者挂起来人工核。这段代码不难写但能挡掉 90% 的资损投诉。第三个技巧是把回调处理做成可重放。pay_notify_log里存了原始密文配合一个内部管理接口可以指定notify_id重新走一遍解密和业务处理。线上遇到「回调处理到一半服务重启」时这个功能比翻日志快得多。实现上要注意重放时同样走幂等逻辑别把已经发过的货再发一次。最后一个习惯上的教训我早期喜欢在支付回调里顺手做一些「重」的操作比如发短信、推消息、更新统计数据结果有一次短信服务商超时整个回调卡了 8 秒微信判定超时开始重试同一笔订单触发了三次业务动作虽然幂等挡住了重复发币但消息发了三条。从那以后我每次写回调都强制走一遍「事务内只做状态推进和核心账务其余动作一律丢到消息队列异步消费」的流程宁可多写一个消费者也不让回调链路里出现任何外部 RPC。希望这些能帮你在接下一套支付时少熬两个晚上。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询