简介:这是一份面向Java开发者的H5微信支付接入资源包,适用于电商网站、移动端H5页面等需要网页内完成交易的项目。资源围绕微信支付H5对接流程,覆盖统一下单、支付链接生成、异步回调校验、订单查询及异常重试等关键环节,从接口文档理解、参数构造到签名与安全校验均有涉及,可帮助中高级后端工程师快速掌握支付集成要点与安全规范。包内含11个文件,以Java源文件5个为主,辅以XML配置、properties配置文件、部署说明文档和HTML演示页面,整体约16KB,结构精简便于阅读复用。通过源码、配置与文档组合,读者能获得可运行的接入示例、回调处理思路及测试环境验证方法;部署说明文档对环境和配置做了梳理,测试模拟工具可辅助调试支付场景。资源已有1320人学习,适合需要在Java项目中接入H5微信支付并规避常见坑位的开发者参考。
1. H5 微信支付在 Java 服务端的完整落地:别把 JSAPI 的思路搬过来
一个典型的翻车现场:A 同学负责的移动 H5 商城要接微信支付,他照着公众号支付的教程写了 JSAPI 逻辑,结果在手机浏览器里根本调不起收银台——没有 openid,而且 H5 支付(MWEB)走的是另一套下单接口。JavaH5微信支付 这个工程就是为这件事拆的:完整的 Spring Boot 后端,覆盖 V3 签名、H5 统一下单、异步回调验签、AES-GCM 解密、订单查询与退款全链路。适合后端是 Java、前端是 H5 页面、需要在微信外的手机浏览器里拉起微信收银台的团队。读完你不仅能跑起一个能二次开发的支付模块,还能避开签名和回调这两大雷区,少走我当年踩过的弯路。
2. 技术选型与整体架构:下单、回调与状态机怎么串成闭环
2.1 为什么 H5 支付和 JSAPI 支付是两套逻辑
先纠正一个最常见的误区。微信支付官方把 H5 支付(MWEB)和 JSAPI 支付分成两个独立产品,很多人以为 H5 支付就是 JSAPI 的网页版,其实它们的差异贯穿整个链路。
| 维度 | H5 支付(MWEB) | JSAPI 支付 |
|---|---|---|
| 运行环境 | 微信外的手机浏览器 | 微信内置浏览器 / 公众号网页 |
| 用户标识 | 不需要 openid | 必须 openid |
| 下单接口 | POST /v3/pay/transactions/h5 | POST /v3/pay/transactions/jsapi |
| 调起方式 | 返回 h5_url,前端跳转 | 前端 wx.chooseWXPay 窗口调起 |
| 场景信息 | 必传 scene_info.h5_info | 不需要 |
| 商户限制 | 需单独开通 H5 支付权限 | 开通微信支付即有 |
差异直接决定了代码结构:H5 下单时要把场景信息(支付来源网页的域名和名称)传给微信,否则接口报错;而 JSAPI 要在下单前先用 OAuth 换 openid。两者共同点是都走 V3 API 的签名鉴权和异步回调通知,这正是这个工程里最值得复用的部分。
技术栈用的是 Spring Boot 2.x + 微信支付 V3 API + 平台证书验签,HTTP 客户端用 RestTemplate,依赖最少。V3 相比 V2 最大的变化是签名改为 SHA256withRSA,回调报文用 AES-256-GCM 加密,代码里必须自己处理“验签”和“解密”两道关,没法像 V2 那样一个 MD5 搞定。这也是我推荐直接抄 V3 方案的原因:新商户默认只能开 V3,与其维护两套兼容代码,不如一步到位。
提示:如果你的项目还在用微信支付 V2 的 MD5 签名方案,建议趁早迁 V3,新商户默认只能开 V3,老商户的 V2 也在逐步收紧。
2.2 工程结构与模块划分
工程按“配置-接口-服务-客户端”四层切分,目录结构如下:
wxpay-h5-demo ├── pom.xml ├── sql │ └── schema.sql └── src/main/java/com/demo/wxpay ├── WxpayApplication.java ├── config │ ├── WxPayProperties.java │ ├── WxPayConfig.java │ └── RestTemplateConfig.java ├── controller │ ├── PayController.java │ └── NotifyController.java ├── service │ ├── PayOrderService.java │ └── WxPayService.java ├── client │ ├── WxPayClient.java │ └── WxPaySigner.java ├── entity │ └── PayOrder.java ├── enums │ └── OrderPayState.java └── util └── AesGcmUtil.javaconfig 层负责把证书和密钥读进内存,WxPayConfig 里生成 RestTemplate Bean;client 层屏蔽 HTTP 细节,只暴露 createH5Order、queryOrder、refund 三个方法;service 层处理订单校验和回调后的状态流转;controller 只做参数接收和响应包装。
我一般会把“发请求”和“写业务”严格分开,原因很简单:联调时如果签名失败,能直接定位到 client 层,不用在业务代码里翻半天。将来接支付宝或银联,只需要换掉 client 层,service 层基本不动,这一条在最后一章的二次开发里会展开。
2.3 订单状态机的设计与数据库表
支付系统核心就是状态机。表里 state 字段用 tinyint,枚举定义如下:
public enum OrderPayState { INIT(0, "待支付"), PAYING(1, "支付中"), SUCCESS(2, "支付成功"), REFUNDING(3, "退款中"), REFUNDED(4, "已退款"), CLOSED(5, "已关闭"); }状态流转只有三条主线:待支付 → 成功、成功 → 退款中 → 已退款、待支付 → 已关闭。特别注意:不允许“成功”变回“待支付”,也不允许“已退款”再被回调覆盖成“成功”。所以更新订单状态时要用 CAS(Compare And Set)写法,只更新当前状态符合预期的行:
UPDATE pay_order SET state = #{targetState}, transaction_id = #{transactionId}, paid_at = #{paidAt}, notify_raw = #{notifyRaw} WHERE out_trade_no = #{outTradeNo} AND state IN (#{expectState1}, #{expectState2})回调通知可能重复推送,这种带状态条件的 UPDATE 天然幂等,比先 SELECT 再 UPDATE 少一次查询,也避免并发下把错误状态覆盖掉。这是我整个工程里觉得最值得抄的一段。
建表脚本如下,amount 直接用 int 存分,不要存元,更不要用 decimal 之后再在代码里转来转去——精度问题几乎都出在“分转元”和“元转分”的路上,避坑章节会展开:
CREATE TABLE `pay_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `out_trade_no` varchar(32) NOT NULL COMMENT '商户订单号,同一商户下唯一', `transaction_id` varchar(64) DEFAULT NULL COMMENT '微信支付单号,回调后写入', `amount` int(11) NOT NULL COMMENT '订单金额,单位:分', `state` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0待支付 1支付中 2成功 3退款中 4已退款 5已关闭', `notify_raw` text COMMENT '最近一次回调原文,排障用', `paid_at` datetime DEFAULT NULL COMMENT '支付完成时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_out_trade_no` (`out_trade_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;3. 核心模块拆解:V3 签名、H5 下单与回调解密
3.1 WxPaySigner:签名串构造与 SHA256withRSA
V3 签名是所有请求的第一步。签名串格式是固定的:HTTP 方法 + 换行 + URL 路径(带 query)+ 换行 + 时间戳 + 换行 + 随机串 + 换行 + 请求体 + 换行。拼接完用商户私钥做 SHA256withRSA 签名,再把结果和商户号、证书序列号等塞进 Authorization 头。
public class WxPaySigner { public static String sign(String method, String urlPath, String timestamp, String nonce, String body, PrivateKey privateKey) throws Exception { String signStr = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + (body == null ? "" : body) + "\n"; Signature signature = Signature.getInstance("SHA256withRSA"); signature.initSign(privateKey); signature.update(signStr.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signature.sign()); } public static String buildAuthHeader(String mchId, String serialNo, String nonce, String timestamp, String signature) { return "WECHATPAY2-SHA256-RSA2048 " + "mchid=\"" + mchId + "\"," + "nonce_str=\"" + nonce + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + serialNo + "\"," + "signature=\"" + signature + "\""; } }几个容易被坑的细节:URL 路径必须和实际请求的 path 完全一致,GET 请求带 query 时必须把 query 也拼进去;body 为空时用空字符串而不是 "null";签名串里每个字段之间的换行符是 "\n",多一个 \r 都不行。Authorization 头里的 serial_no 是商户 API 证书序列号,不是平台证书序列号,这两个证书的区分在后文避坑章节会再强调。
3.2 H5 统一下单:场景信息与请求组装
H5 下单走的是 POST /v3/pay/transactions/h5,请求体里有两个别家教程很少讲全的字段:amount.total 是整数分,scene_info 里必须带 h5_info.type 和 payer_client_ip。
public String createH5Order(String outTradeNo, int amountInFen, String description, String clientIp, String wapUrl, String wapName) { JSONObject sceneInfo = new JSONObject(); sceneInfo.put("payer_client_ip", clientIp); JSONObject h5Info = new JSONObject(); h5Info.put("type", "Wap"); h5Info.put("wap_url", wapUrl); h5Info.put("wap_name", wapName); sceneInfo.put("h5_info", h5Info); JSONObject amount = new JSONObject(); amount.put("total", amountInFen); amount.put("currency", "CNY"); JSONObject bodyJson = new JSONObject(); bodyJson.put("appid", wxPayProperties.getAppid()); bodyJson.put("mchid", wxPayProperties.getMchid()); bodyJson.put("description", description); bodyJson.put("out_trade_no", outTradeNo); bodyJson.put("notify_url", wxPayProperties.getNotifyUrl()); bodyJson.put("amount", amount); bodyJson.put("scene_info", sceneInfo); String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String nonce = UUID.randomUUID().toString().replace("-", ""); String urlPath = "/v3/pay/transactions/h5"; String sign = WxPaySigner.sign("POST", urlPath, timestamp, nonce, bodyJson.toJSONString(), merchantPrivateKey); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Authorization", WxPaySigner.buildAuthHeader(mchId, serialNo, nonce, timestamp, sign)); headers.set("Accept", "application/json"); headers.set("User-Agent", "wxpay-h5-demo/1.0"); HttpEntity<String> entity = new HttpEntity<>(bodyJson.toJSONString(), headers); ResponseEntity<String> response = restTemplate.exchange( "https://api.mch.weixin.qq.com" + urlPath, HttpMethod.POST, entity, String.class); JSONObject resp = JSON.parseObject(response.getBody()); if (resp.containsKey("h5_url")) { return resp.getString("h5_url"); } throw new WxPayException(resp.getString("code") + ": " + resp.getString("message")); }下单成功后返回的 h5_url 是有时效的,官方文档写的是 5 分钟,前端拿到后要尽快 window.location.href 跳过去。超过时效再跳会提示“链接已失效”,这时候只能重新下单,所以不要把 h5_url 缓存起来复用。h5_url 的结构就是微信域名下的一串跳转地址,后端透传给前端即可,不要做任何解析。
如果响应体里不是 h5_url 而是 {"code":"..."},基本是请求体参数不合法或者签名没过,此时直接抛异常让上层记日志,别把微信返回的 JSON 原样抛给前端。
3.3 回调处理:验签、AES-GCM 解密与幂等更新
回调是整个工程最重的环节。微信把通知 POST 到 notify_url,请求头带 Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial 四个字段,body 里是加密后的 resource。处理分三步:验签、解密、更新订单。
@PostMapping("/api/wxpay/notify") public ResponseEntity<String> notify(HttpServletRequest request) throws Exception { String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); String timestamp = request.getHeader("Wechatpay-Timestamp"); String nonce = request.getHeader("Wechatpay-Nonce"); String signature = request.getHeader("Wechatpay-Signature"); String serial = request.getHeader("Wechatpay-Serial"); // 1. 验签:用平台证书公钥验,不是商户证书 String signStr = timestamp + "\n" + nonce + "\n" + body + "\n"; boolean passed = verifyWithPlatformCert(serial, signStr, signature); if (!passed) { return ResponseEntity.status(401).body("{\"code\":\"FAIL\",\"message\":\"验签失败\"}"); } // 2. 解密 resource JSONObject resource = JSON.parseObject(body).getJSONObject("resource"); String plaintext = aesGcmUtil.decrypt( resource.getString("ciphertext"), resource.getString("nonce"), resource.getString("associated_data")); JSONObject payInfo = JSON.parseObject(plaintext); // 解密后结构: {appid, mchid, out_trade_no, transaction_id, trade_state, ...} // 3. 幂等更新:待支付/支付中 -> 成功,其余状态拒绝覆盖 if ("SUCCESS".equals(payInfo.getString("trade_state"))) { int updated = payOrderService.casMarkPaid( payInfo.getString("out_trade_no"), payInfo.getString("transaction_id"), body); if (updated == 0) { // 要么订单不存在,要么状态不允许流转,都按成功应答避免微信反复推送 log.warn("notify ignored. out_trade_no={}", payInfo.getString("out_trade_no")); } } return ResponseEntity.ok("{\"code\":\"SUCCESS\",\"message\":\"成功\"}"); }验签这一步必须用平台证书公钥,不是商户证书私钥。平台证书可以从 V3 接口下载,也可以工程启动时自动拉取并缓存。更省事的做法是把证书文件放 classpath 下,但平台证书有轮换机制,长期运行的项目建议做定时刷新,否则轮换后回调验签会突然失败,这种故障特别难排查。
解密用的 AES-256-GCM 密钥就是 APIv3 密钥本身,这个值只保存在服务端配置里,不要下发到前端。GCM 解密对 nonce 和 associated_data 的要求很严格,一个字符都不能错,工具类如下:
public String decrypt(String ciphertext, String nonce, String associatedData) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); if (associatedData != null && !associatedData.isEmpty()) { cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); } return new String(cipher.doFinal(Base64.getDecoder().decode(ciphertext)), StandardCharsets.UTF_8); }注意:回调处理完必须返回 200 和 {"code":"SUCCESS","message":"成功"},否则微信按失败处理,会在后续一段时间内重复推送,间隔越来越长,最长能推 24 小时。这也是很多“线上偶发重复通知”的根源。
3.4 主动查询与退款:闭环里的兜底手段
回调是不可靠的,虽然失败会重试,但有些边界情况(比如支付成功但回调丢失且重试也失败)要靠主动查询兜底。
public JSONObject queryOrder(String outTradeNo) { String urlPath = "/v3/pay/transactions/out-trade-no/" + outTradeNo + "?mchid=" + mchId; String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String nonce = UUID.randomUUID().toString().replace("-", ""); String sign = WxPaySigner.sign("GET", urlPath, timestamp, nonce, null, merchantPrivateKey); HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", WxPaySigner.buildAuthHeader(mchId, serialNo, nonce, timestamp, sign)); HttpEntity<Void> entity = new HttpEntity<>(headers); ResponseEntity<String> response = restTemplate.exchange( "https://api.mch.weixin.qq.com" + urlPath, HttpMethod.GET, entity, String.class); return JSON.parseObject(response.getBody()); }查询接口返回的 trade_state 字段有 SUCCESS、NOTPAY、CLOSED、REFUND、USERPAYING 等取值。工程里可以写一个定时任务,把创建超过 N 分钟仍处于待支付的订单捞出来查一次,状态变成 SUCCESS 就补登回调,是 CLOSED 就关单。退款走 POST /v3/refund/domestic/refunds,参数结构类似,唯一要注意的是 refunds 接口对同一笔订单可以多次退,每次用不同的 out_refund_no 区分,退款回调也要做同样的验签和解密。
4. 本地跑通与联调:从配置到真机验证
4.1 环境准备与配置项清单
跑这个工程需要准备的物料:一个已开通 H5 支付的商户号、商户 API 证书(apiclient_key.pem)、商户证书序列号、APIv3 密钥、平台证书。证书申请方式不展开,注意一点:商户 API 证书和微信支付平台证书是两个东西,前者在商户平台“API 安全”里申请,后者通过接口下载或从官方 SDK 获取,别搞混。
application.yml 里的配置如下:
server: port: 8080 wxpay: appid: wx8888888888888888 mchid: 1900000109 api-v3-key: 一串32位随机字符串 merchant-serial-no: 商户API证书序列号 merchant-private-key-path: classpath:cert/apiclient_key.pem platform-cert-path: classpath:cert/wechatpay_platform.pem notify-url: https://你的公网域名/api/wxpay/notifyWxPayProperties 用 @ConfigurationProperties 绑定,启动时 WxPayConfig 把 PEM 文件解析成 PrivateKey 和 X509Certificate 并缓存。常见翻车点:PEM 文件放在 src/main/resources/cert 下,打包后路径变了导致读取失败,建议用 classpath: 前缀并确认 jar 里文件存在;私钥如果带密码(有些工具导出的是 PKCS8 加密格式),解析时要按对应算法处理,直接抛 InvalidKeySpecException 八成是格式问题。
4.2 启动工程与下单验证步骤
启动顺序:先建库建表,执行 sql/schema.sql 里的脚本;再改 application.yml 里的配置;然后 mvn spring-boot:run 起服务。起来后用 curl 模拟一次下单:
curl -X POST http://localhost:8080/api/wxpay/h5/create \ -H "Content-Type: application/json" \ -d '{"outTradeNo":"DEMO20250101001","amount":100,"description":"测试商品","clientIp":"123.125.115.110"}'正常响应会返回一个 h5_url,用手机浏览器打开能看到微信收银台页面。微信没有支付宝那种完整沙箱,最接近的做法是用测试商户号下单,再支付一分钱验证真实链路。如果返回错误码,先看是 PARAM_ERROR 还是 SIGN_ERROR:PARAM_ERROR 基本是请求体某个字段不合法,SIGN_ERROR 就是签名或证书问题,照着第 3 章的签名串格式逐项核对。
4.3 回调链路联调:内网穿透与日志定位
本地开发时微信服务器访问不到 localhost,回调联调必须有一个公网可达的 HTTPS 地址。常见做法是用内网穿透工具把 8080 端口映射到一个公网域名,免费版通常够用。映射好了以后把 notify-url 临时改成穿透域名,注意穿透工具提供的地址必须支持 HTTPS,微信要求回调地址必须是 HTTPS。
排查回调问题时,有两个日志必须打全:一是请求进来时的原始 headers,特别是 Wechatpay-Timestamp 和 Wechatpay-Serial;二是 body 原文。验签失败时把这两个打印出来和文档核对,比盲猜快得多。我会在 NotifyController 入口加一行 log.info 打印请求头和 body,联调完再降级成 debug,线上保留 debug 级别的完整链路日志,出问题能直接拉出来看。
5. 微信支付避坑指南:签名、金额与回调的五类常见问题
5.1 签名一直报错
现象:下单接口反复返回 SIGN_ERROR,或请求直接报 401 无法通过鉴权。
原因:九成是签名串拼接不对——换行符用了 \r\n、GET 请求漏掉 query、body 为空时拼了 "null" 字符串、或者序列号填错成平台证书的。另外签名时间戳要求与微信服务器时间差在 5 分钟内,服务器时钟漂移也会导致验证失败。
解决:把签名串明文打出来,肉眼核对每个字段的换行和内容;服务器用 NTP 同步时间;记牢 Authorization 头里的 serial_no 是商户证书序列号,回调验签用的是平台证书序列号,两者严格区分。
5.2 回调迟迟不来
现象:用户支付成功但业务侧一直收不到通知,订单卡在待支付。
原因:notify_url 是 HTTPS 但证书链不受信任;或者回调处理返回了非 200 状态码、响应体不是 SUCCESS JSON,微信只能持续重试;还有一类是回调里抛了异常没走到 return ok,微信收到 500 继续重发。
解决:先用 curl 把微信回调报文原样 POST 到本地接口,看返回码;再把日志里 headers 和 body 打印出来核对;最后确认服务器能从外网收到请求,而不是被防火墙或云安全组挡掉。
5.3 金额对不上
现象:数据库存的金额和微信支付金额差 1 分或恰好相反。
原因:用 double 或 float 存金额,0.1+0.2 这类浮点误差在“分转元”时被放大;或者下单时元转分做了四舍五入,回调里又按另一套规则转回去,两边规则不一致。
解决:全链路用 int/long 存分,前端传元时后端做整数转换,任何计算都不要经过 double。我一般会在下单和回调两处各加一个金额断言,对不上直接抛异常,比事后对账发现少一分钱再返工强得多。
5.4 同一笔订单被回调处理两次
现象:日志里同一 out_trade_no 的 SUCCESS 回调打印了两条,订单状态被覆盖成异常值。
原因:微信本身会重试通知,或者网络超时导致微信没收到 200 再次推送;代码里先 SELECT 再 UPDATE 的写法在并发下会把已成功的订单改回待支付。
解决:用 2.3 节那种带 state 条件的 UPDATE,状态不允许流转就忽略并按成功应答,让微信停止重试。同时把 notify_raw 字段存起来,下次排查时能直接对比两次回调报文差异。
5.5 H5 支付调不起来
现象:返回了 h5_url,但手机浏览器打开白屏或提示“请在微信客户端打开”。
原因:最可能是用微信内置浏览器访问了 H5 支付链接——微信内置浏览器只认 JSAPI;或者下单时场景信息的 wap_url 与实际发起支付的页面域名不一致被微信拦截;还有 h5_url 超时未使用。
解决:用户侧判断 UA 里的 MicroMessenger 字段,是微信内置浏览器就走 JSAPI 分支,否则走 H5;wap_url 和 wap_name 保证与实际页面一致;h5_url 只透传不缓存,失效就重新下单。
6. 二次开发:把支付模块改成可插拔的渠道层
6.1 抽一个 PayChannel 接口
如果业务要接多个支付渠道(微信、支付宝、银联),我的做法是先把渠道无关的公共流程收进接口,再让微信实现类去适配。接口设计成三个动作加一个标识:下单、查询、退款,这样上层业务不需要感知渠道差异。
public interface PayChannel { PayResult createPayment(PayRequest request); // 统一下单 PayQueryResult queryPayment(String outTradeNo); // 主动查询 RefundResult refund(RefundRequest request); // 退款 String getChannelName(); // 渠道标识 } public class WxH5PayChannel implements PayChannel { private final WxPayClient wxPayClient; // 内部把 PayRequest 转换成微信参数对象,再调用 client }改造后 controller 只面向 PayChannel,订单表加一列 channel_code 就能支持多渠道并存。注意别一开始就把微信特有的字段透传到接口层,每个渠道有自己的参数对象,在实现类里转换即可。这样加新渠道时只新增一个实现,不碰业务代码。
6.2 加一个定时对账任务
回调补登这层,我用 @Scheduled 每 5 分钟扫一次超时待支付订单,主动查询微信侧状态:
@Component @RequiredArgsConstructor public class PayCompensateJob { private final PayOrderService payOrderService; private final WxH5PayChannel wxH5PayChannel; @Scheduled(cron = "0 */5 * * * *") public void compensate() { List<String> pendingNoList = payOrderService.listPendingOutTradeNo(20); pendingNoList.forEach(no -> { PayQueryResult result = wxH5PayChannel.queryPayment(no); if (result.isSuccess()) { payOrderService.casMarkPaid(no, result.getTransactionId(), "{\"source\":\"compensate\"}"); } }); } }6.3 改完怎么验证
每次改动后我习惯走一遍“模拟下单 → 跳转收银台 → 支付一分钱 → 等待回调 → 核对订单状态”的完整链路,再额外验证两个边界:用同一个回调报文 POST 两次,确认第二次被幂等忽略;把回调处理里的网络断掉让它超时,确认日志里能看出微信的重试链路。
支付这块的坑几乎都藏在细节里,序列号用错、换行符不对、金额单位不一致,任何一个都能让你排查一整天。从那以后我每次动签名或回调代码,都强制走一遍上述全链路,还要求同类项目必须保留一份“回调报文样例”文件,用来在本地快速复现问题。希望帮到你。
本文还有配套的精品资源,点击获取