ASP.NET 微信支付 JSAPI 全链路实战:下单、签名、回调与幂等

发布时间:2026/10/24 6:48:57
ASP.NET 微信支付 JSAPI 全链路实战:下单、签名、回调与幂等

简介:这份 ASP.NET 微信支付 JSAPI 调用示例面向需要在微信内置浏览器中接入支付的 C# 开发者与中小项目团队,重点解决 JSAPI 支付配置繁琐、旧版 Demo 代码冗长的问题。压缩包共 122 个文件、约 2.36MB,包含 44 个 cs 源码、24 个 aspx 页面、11 个 dll 依赖、9 个 config 配置以及 js、txt、日志与证书等辅助文件,覆盖支付页面、商品页、退款与退款查询等模块。已有 1073 人学习下载,说明其在同类接入场景中具有一定参考价值。示例基于官方支付 v3 版本整理,代码相对简洁,且无需额外引入 JS,作者实践表明配通周期可控制在十分钟左右;借助完整页面结构与配置样例,读者可对照梳理微信浏览器内的支付调用链路、退款处理和常见排错思路,减少从旧 Demo 迁移的时间成本。

1. 从一笔「待支付」订单说起:ASP.NET 接 JSAPI 支付到底要写多少代码

后台订单列表里显示着一笔「待支付」,用户却截图说钱已经扣了。翻日志才发现:统一下单成功了,prepay_id 也拿到了,前端收银台也弹出并输完密码了,唯独回调地址返回 500,重试十几次全被 ASP.NET 的异常页拦截。这类问题不看链路根本查不出来,和 C# 语法一点关系都没有。

这份 ASP.NET 微信支付 JSAPI 调用 Demo,把 C# 侧的整条链路固定成一套可对照的骨架:网页授权拿 openid、JSAPI 下单换 prepay_id、二次签名交给前端调起、回调验签解密、幂等落库。适合已经能写 ASP.NET MVC / Web API、但第一次对接微信支付 v3 的人,也适合接手半成品项目、需要一份参照物来逐段比对的人。

2. 把链路摆平:从 openid 到 prepay_id 的六个环节与参数表

JSAPI 支付最容易让人误判的地方,是它看起来像「前端弹个收银台」这么简单。实际上服务端要完成的事情有六件,前端只负责最后一件。把这条链路拆成环节之后,每个环节都有自己的输入、输出和典型报错,出问题时你只要判断「断在第几个环节」即可,不用从头到尾读日志。

2.1 六个环节与各自最容易失败的位置

先看一张表,这是我对着 Demo 的调用顺序整理出来的,也是排查时的对照表。

环节执行方关键输入产出失败时的典型表现
1 网页授权前端跳转 + 服务端换票appid、redirect_uri、scope=snsapi_basecode回调地址不带 code,或 code 用过一次就失效
2 换 openid服务端code、appid、AppSecretopenid40125 类错误,多半是 AppSecret 用错或 appid 不匹配
3 统一下单服务端openid、金额、订单号、notify_urlprepay_id400 参数错误、401 签名错误
4 二次签名服务端prepay_id、时间戳、随机串paySign前端报「支付验证签名失败」
5 调起收银台前端上面四个参数 + paySign用户输密码收银台不弹、白屏、提示系统繁忙
6 异步通知微信侧回调加密报文订单终态回调一直重试、订单永远待支付

环节 1 和 2 是很多人忽略的一步:JSAPI 支付必须带 payer.openid,而这个 openid 必须是「和下单 appid 同一主体下」的 openid。公众号的 openid 和小程序的 openid 不是一回事,同一用户在两个 appid 下拿到的值完全不同。我见过最典型的翻车是:页面在公众号里跑,用的却是小程序那套 appid 和密钥,openid 拿到了,一下单就报「openid 与 appid 不匹配」。

环节 3 是整条链路的分水岭。这一步成功只代表「微信那边登记了一笔待支付订单」,不代表用户付了钱。很多人在这里就开始改业务状态,结果用户关掉收银台,订单变成「已支付但没付钱」,对账时只能人工处理。

环节 6 才是唯一可信的支付结果来源。前端get_brand_wcpay_request:ok只是「用户在收银台上点了确认」,网络抖动、用户中途退出、微信侧扣款失败都会让它和真实结果不一致。所以服务端的判断顺序永远是:收到回调 → 验签 → 解密 → 核对金额 → 幂等更新 → 返回 2xx。

2.2 统一下单的字段表与一段能直接跑的调用代码

下单接口是POST /v3/pay/transactions/jsapi,请求体是 JSON。下面这几个字段是最容易设错的,我按「改了会怎样」的方式列出来。

字段取值要点设错的后果
appid与拿 openid 的那个应用一致openid 不匹配,下单直接失败
mchid商户号,纯数字字符串与证书不匹配时签名校验不过
out_trade_no6~32 位,字母数字和 `_-*`
description商品描述,别塞特殊字符部分字符会被判为非法参数
notify_url必须 https,不带 query,不能是内网地址回调永远收不到
amount.total单位是分,整数写成元,金额差 100 倍
payer.openid用户在当前 appid 下的 openid报 openid 与 appid 不匹配

对应代码如下,我把它放在一个服务类里,控制器只负责传参:

// 统一下单:只负责拿到 prepay_id,不代表用户已付款 public async Task<string> CreateJsapiOrderAsync(string openid, string outTradeNo, int totalFen, string description) { var body = new { appid = _options.AppId, // 必须与获取 openid 的应用一致 mchid = _options.MchId, // 商户号,字符串形式 description = description, // 商品描述,尽量短且无特殊字符 out_trade_no = outTradeNo, // 商户订单号,全局唯一 notify_url = _options.NotifyUrl, // https、无 query、公网可达 amount = new { total = totalFen, currency = "CNY" }, // 单位:分 payer = new { openid = openid } }; var json = JsonSerializer.Serialize(body); using var req = new HttpRequestMessage(HttpMethod.Post, "/v3/pay/transactions/jsapi"); req.Content = new StringContent(json, Encoding.UTF8, "application/json"); // 注意 pathWithQuery 参与签名,必须和实际请求路径逐字符一致 req.Headers.TryAddWithoutValidation("Authorization", BuildAuthorization("POST", "/v3/pay/transactions/jsapi", json)); using var resp = await _http.SendAsync(req); var text = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"下单失败 {(int)resp.StatusCode}: {text}"); using var doc = JsonDocument.Parse(text); return doc.RootElement.GetProperty("prepay_id").GetString()!; }

代码里有两处细节值得单独说。第一,totalFen我在方法签名上就写成「分」,而不是在方法体内做换算,这样调用方一眼就知道单位,避免出现「传了 9.9 结果变成 9 分」这种血泪事故。第二,BuildAuthorization的第二个参数是「带 query 的路径」,如果你的接口带查询串,必须把?a=1&b=2原样拼进签名串,不能先 URL 编码再拼,也不能只传路径部分。

HttpClient不要在每个方法里new,用IHttpClientFactory注入,超时设 10 秒左右。下单接口本身很快,超过 10 秒基本是网络或 DNS 问题,继续等只会把线程池占满。

2.3 签名串:APIv3 的换行位置少一个都不行

APIv3 的请求签名串是五行,最后一行后面也要有一个换行符:

HTTP方法\n URL(带query)\n 时间戳(秒)\n 随机串\n 请求体\n

拼接完成后用商户 API 私钥做 SHA256withRSA,Base64 编码后放进Authorization头。这里的「血泪经验」是:绝大多数 401 签名错误都不是密钥不对,而是拼接差了一个字符。

// 请求签名:注意结尾的 \n,少了它必报 401 private string BuildAuthorization(string method, string pathWithQuery, string body) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); // 秒级,误差别超 5 分钟 var nonce = Guid.NewGuid().ToString("N"); // 32 位随机串 var message = $"{method}\n{pathWithQuery}\n{timestamp}\n{nonce}\n{body}\n"; var signature = Sign(message); return $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_options.MchId}\"," + $"nonce_str=\"{nonce}\",signature=\"{signature}\"," + $"timestamp=\"{timestamp}\",serial_no=\"{_options.MerchantSerialNo}\""; } private string Sign(string message) { using var rsa = RSA.Create(); rsa.ImportFromPem(_options.MerchantPrivateKeyPem); // 也支持 ImportFromPkcs12 读 .p12 var sig = rsa.SignData(Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(sig); }

serial_no是商户 API 证书的序列号,不是证书内容,也不是 APIv3 密钥。序列号在证书详情里能看到,格式是一串十六进制字符串。这里每次调用都新建RSA实例,在 Demo 里没问题,生产环境建议在启动时导入一次并缓存,否则高并发下会有明显的 CPU 开销。

还有一个容易混淆的点:APIv3 没有「API 密钥参与签名」这回事。APIv3 密钥只用来解密回调报文,不参与请求签名。把 APIv3 密钥当签名密钥用,是最常见的一类误用。

3. 拆开 Demo 的代码骨架:配置、服务类与前端调起怎么分层

拿到一份支付 Demo,最忌讳的做法是「先跑通再说」。跑通之后你会得到一堆散落在控制器里的签名代码、硬编码的商户号和一段直接写在 cshtml 里的 JS,改一个环境就全崩。这一章讲的是这份 Demo 的分层方式,以及每一层为什么这么切。

3.1 配置项与证书文件的落位

支付相关的配置分成三类:可以进代码仓库的普通配置(appid、商户号、回调地址)、绝对不能进仓库的密钥材料(商户私钥、APIv3 密钥)、以及需要定期轮换的平台证书或公钥。这三类要分开管。

// appsettings.json —— 下面的值全部是占位符,真实值走环境变量或密钥库 { "WechatPay": { "AppId": "wx****************", // 与获取 openid 的应用一致 "MchId": "1900000000", // 商户号 "MerchantSerialNo": "****************", // 商户 API 证书序列号 "MerchantPrivateKeyPath": "cert/apiclient_key.pem", "ApiV3Key": "************************", // 必须 32 位,仅用于解密回调 "NotifyUrl": "https://your.domain/api/pay/notify", "PlatformCertPath": "cert/wechatpay_platform.pem" } }

几个实操上的判断标准:

  • 私钥文件不要放在wwwroot下,哪怕目录名看起来不显眼。放在项目外的相对路径,或者直接走系统证书存储。
  • ApiV3Key长度必须是 32 位。设错了不会在启动时报错,而是等到回调解密时才抛出,非常难查。
  • 平台证书会轮换,不要把它当成永久文件写死。启动时加载、失败时能重新拉取,比「打进镜像」稳得多。
  • 证书序列号建议在启动时打印到日志(只打序列号,不打私钥),出问题时第一眼看的就是它和后台配置是否一致。

我一般会在启动时做一次自检:读私钥、读证书、校验序列号是否与配置一致、校验 APIv3 密钥长度是否为 32。这四个检查加起来不到二十行代码,但能提前挡掉后面八成的「莫名其妙的 401」。

3.2 服务类怎么切:一个下单方法,一个签名方法,一个通知处理器

Demo 里我把它拆成三个职责:

  1. WechatPayClient:只负责发 HTTPS 请求和处理 HTTP 层错误,方法签名是「输入业务参数,输出 prepay_id 或异常」。
  2. WechatPaySigner:只负责拼签名串、签名、验签、解密,纯计算,不碰网络。
  3. PayNotifyController:只负责读原始报文、调 signer 验签解密、调业务服务落库、返回状态码。

这种切法的好处是,签名相关的错误可以单独写单元测试。比如「签名串末尾少一个换行」这种 bug,用真实网络请求去调试要来回折腾十几分钟,写成一个断言只要一秒。

// 注册:统一注入 HttpClient,避免端口耗尽和 DNS 缓存问题 builder.Services.AddHttpClient<IWechatPayClient, WechatPayClient>(c => { c.BaseAddress = new Uri("https://api.mch.weixin.qq.com/"); c.Timeout = TimeSpan.FromSeconds(10); // 超过 10 秒基本是网络问题,继续等只会拖垮线程池 }); builder.Services.AddSingleton<IWechatPaySigner, WechatPaySigner>(); // 内部缓存 RSA 实例

订单号的生成规则也放在这一层,因为它是幂等的基础。常见的做法是「业务前缀 + 时间 + 随机位」,好处是能直接从订单号看出业务线和日期,排查时不用反查数据库。

// 商户订单号:6~32 位,只允许数字、字母和 _-|* public static string NewOutTradeNo(string prefix) { // .NET 6+ 用 Random.Shared;.NET Framework 下换成 RandomNumberGenerator var tail = Random.Shared.Next(100000, 999999); return $"{prefix}{DateTime.Now:yyyyMMddHHmmss}{tail}"; }

注意订单号一旦生成,就不能因为「用户重新点了一次支付」而重新生成。正确做法是用同一个out_trade_no再调一次下单,微信侧会返回同一笔订单的 prepay_id(在有效期内的前提下)。如果每次都生成新订单号,用户点三次就会产生三笔待支付订单,对账时是一团乱麻。

3.3 前端调起:WeixinJSBridge 和 JSSDK 两条路的取舍

前端拿到服务端返回的appId / timeStamp / nonceStr / package / signType / paySign之后,调起方式有两种:直接WeixinJSBridge.invoke,或者引入 JSSDK 用wx.chooseWXPay。前者不需要额外引入脚本,后者在部分老版本客户端上更稳,但多一次配置注入。

Demo 里默认走WeixinJSBridge,因为它对 ASP.NET 这种服务端渲染的页面更友好——不需要额外的签名注入流程,页面加载完就能用。

// 前端只做一件事:把服务端二次签名后的参数交给微信,绝不在这里算签名 function invokePay(p) { WeixinJSBridge.invoke('getBrandWCPayRequest', { appId: p.appId, timeStamp: p.timeStamp, // 字符串,别传数字 nonceStr: p.nonceStr, package: p.package, // 固定格式:prepay_id=xxx signType: p.signType, // APIv3 是 RSA,不是 MD5 paySign: p.paySign }, function (res) { // 这里只做 UI 提示,不作为发货依据 if (res.err_msg === 'get_brand_wcpay_request:ok') { location.href = '/pay/result?outTradeNo=' + encodeURIComponent(p.outTradeNo); } else if (res.err_msg === 'get_brand_wcpay_request:cancel') { // 用户主动取消,订单仍是待支付,可以引导重新支付 } }); } // 微信内部分老版本需要等桥接对象就绪 if (typeof WeixinJSBridge === 'undefined') { document.addEventListener('WeixinJSBridgeReady', () => invokePay(params), false); } else { invokePay(params); }

package字段是固定格式prepay_id=xxx,不是只传 prepay_id 本身,这个格式错了会直接提示「参数错误」。signType在 APIv3 下固定是RSA,很多从 v2 迁移过来的代码还留着MD5,前端就会报「支付验证签名失败」。

二次签名的签名串是四行:appId\ntimeStamp\nnonceStr\npackage\n,同样要带结尾换行,用商户私钥签。它和下单请求的签名串不是一回事,不能复用。

4. 回调才是主战场:验签、AES-256-GCM 解密与幂等落库

下单成功只是开始,回调才是决定这笔钱有没有真正入账的地方。这一章按「读报文 → 验签 → 解密 → 核对 → 落库 → 应答」的顺序拆,每一步都给出失败时的判断依据。

4.1 验签顺序:先验签再解密,反过来就是安全漏洞

回调请求带四个头:Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial。验签串是三行:时间戳\n随机串\n原始报文\n,用平台证书或平台公钥验,注意不是商户私钥。

这里有一个必须强调的点:读原始报文。如果你在 ASP.NET 里先[FromBody]反序列化再序列化回去,字段顺序、空格、转义都会变,验签必然失败。正确做法是直接读Request.Body的字符串。

[HttpPost("/api/pay/notify")] public async Task<IActionResult> Notify() { // 必须读原始字符串,不能先绑定模型再序列化 using var reader = new StreamReader(Request.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(); var timestamp = Request.Headers["Wechatpay-Timestamp"].ToString(); var nonce = Request.Headers["Wechatpay-Nonce"].ToString(); var signature = Request.Headers["Wechatpay-Signature"].ToString(); var serial = Request.Headers["Wechatpay-Serial"].ToString(); // 1. 验签:三段拼串,用平台证书/平台公钥验 var message = $"{timestamp}\n{nonce}\n{body}\n"; if (!_signer.Verify(message, signature, serial)) return StatusCode(401); // 验签失败直接拒绝,不要继续解密 // 2. 验签通过后再处理 resource,见下一节 return await HandleResourceAsync(body); }

验签失败的可能原因有三个:平台证书过期或轮换了、拼串时用了格式化后的 JSON、系统时间偏差过大。前两个改代码或重新拉证书即可,第三个在容器环境里很常见——容器时间没同步,时间戳差几分钟,签名就校验不过。

顺序上还有一个安全考量:先验签能挡掉伪造请求。如果先解密再验签,攻击者可以用构造的密文触发解密逻辑,虽然 AES-GCM 本身有认证标签,但把不必要的计算暴露在未认证的输入上不是好习惯。

4.2 AES-256-GCM 解密与金额核对

验签通过后,报文里的resource是一段加密数据,字段包括algorithm、ciphertext、nonce、associated_data。解密密钥就是那个 32 位的 APIv3 密钥,算法是 AEAD_AES_256_GCM,密文末尾 16 字节是认证标签。

// AES-256-GCM 解密:末尾 16 字节是 tag,前面是密文 public static string DecryptGcm(string ciphertextBase64, string nonce, string associatedData, string apiV3Key) { var raw = Convert.FromBase64String(ciphertextBase64); var tag = raw[^16..]; // 认证标签 var cipher = raw[..^16]; // 真正的密文 // .NET 6+ 自带 AesGcm;.NET Framework 需要引入 BouncyCastle 等等价实现 using var aes = new AesGcm(Encoding.UTF8.GetBytes(apiV3Key)); var plain = new byte[cipher.Length]; aes.Decrypt(Encoding.UTF8.GetBytes(nonce), cipher, tag, plain, Encoding.UTF8.GetBytes(associatedData)); return Encoding.UTF8.GetString(plain); }

解密出来的 JSON 里有out_trade_no、transaction_id、trade_state、amount.total、payer.openid等字段。这里要做三件核对,缺一不可:

  1. trade_state必须是SUCCESS,其他状态(比如NOTPAY、CLOSED)不能当成功处理。
  2. amount.total必须和你库里这笔订单的应付金额完全一致。金额对不上说明订单被篡改或串单,必须走人工核查,绝对不能直接落库。
  3. mchid和appid必须与配置一致,防止串商户。

金额这一项是最容易被跳过的,但它恰恰是资损的最后一道闸门。我见过一个案例:测试环境改了订单金额做压测,代码里没做核对,结果上线后一笔被改价的订单直接按低价发货。

4.3 幂等:同一笔订单收到三次通知,只能发一次货

微信侧的回调是「至少一次」语义,同一笔订单收到多次通知是正常现象。而且它重试的触发条件很宽:你返回非 2xx、处理超时、连接被重置,都会触发下一次重试,间隔递增并持续较长时间。

幂等的实现有两种常见做法,Demo 里用的是数据库条件更新,因为最简单也最可靠:

-- 只有从「未支付」改到「已支付」才算这次处理有效 UPDATE pay_order SET status = 'PAID', transaction_id = @txid, paid_at = @now WHERE out_trade_no = @outTradeNo AND status = 'UNPAID'; -- 受影响行数 = 0:说明已经处理过,直接返回 2xx,不要重复发货

配合out_trade_no上的唯一索引和transaction_id上的唯一索引,即使两个回调同时到达,也只有一个能更新成功。另一个受影响行数为 0,直接返回成功即可。

还有一个细节:回调处理要快。你的业务逻辑(发货、发消息、写流水)如果超过几秒,微信那边可能已经超时并开始重试,于是同一笔订单被并发处理两次。稳妥的做法是「回调里只做状态流转和落库,重活丢进队列异步做」。

应答也有讲究:处理成功返回 200 或 204 即可,body 不要塞业务数据;处理失败不要返回 200 装作成功,否则微信不再重试,这笔订单就永远卡在中间状态。真正需要「我先收下稍后处理」的场景,也应该先落库再返回成功,而不是先返回成功再去落库。

5. 避坑清单:签名、金额、回调五类翻车现场与排查顺序

这一章是我自己踩过、也在别人代码里见过的五类问题。每一条都按「现象 → 原因 → 解决」写,方便你直接对照日志排查。

5.1 签名与证书类的三个坑

坑一:401 签名错误,密钥换了三遍还是错

现象:下单接口稳定返回 401,错误码提示签名错误,把私钥重新导出、序列号重新抄了一遍,依旧报错。

原因:90% 是签名串拼接问题。最常见的是结尾少了换行符,其次是 URL 部分带 query 时编码方式不一致,再次是时间戳用了毫秒。少数情况是serial_no填成了 APIv3 密钥或者证书内容。

解决:把签名串原样打到日志里(生产环境记得脱敏),逐字符对比。我的习惯是先把签名串写成一个可断点的字符串变量,肉眼确认五个部分和四个换行都在,再去怀疑密钥。时间戳统一用秒,服务器开 NTP 同步。

坑二:回调验签一直失败,但下单是好的

现象:下单、支付、前端都正常,回调进来验签必失败,日志里没有任何异常。

原因:一是用了商户私钥验签,回调要用平台证书或平台公钥;二是把请求体先反序列化再序列化,格式变了;三是平台证书轮换了,本地还是旧证书。

解决:确认验签用的密钥来源,确认读的是原始 body,然后加一个「证书序列号不匹配时自动重新拉取平台证书」的逻辑。这个自动刷新一定要有,否则证书一轮换,你的回调就会集体失联。

坑三:本地跑得好好的,一上容器就全挂

现象:开发机正常,部署到容器后所有请求 401,重启偶尔好一次。

原因:容器基础镜像里没有可信根证书,HTTPS 请求本身失败;或者容器时间没同步,时间戳偏差超过限制。

解决:基础镜像装好 CA 证书包,容器启动时校验系统时间。这两条在支付场景里是硬性要求,不是「有空再说」的优化项。

5.2 业务与回调类的两个坑

坑四:用户说付了钱,订单还是待支付

现象:用户拿着扣款记录来找,后台订单状态没变,日志里没有任何回调记录。

原因:notify_url配置有问题。常见的有:用了 http、带了 query 参数、指向内网地址、被网关拦了 POST、被 WAF 挡了、路由大小写不一致导致 404。

解决:先用外网工具直接 POST 一下你的回调地址,确认能通;再确认网关和 WAF 放行了这个路径;最后检查 ASP.NET 的路由和中间件顺序,别让鉴权中间件把回调请求拦在门外。回调接口不应该要求登录态。

坑五:金额差 100 倍,或者同一订单发了两次货

现象:对账时发现收款金额和订单金额对不上,或者用户收到两份权益。

原因:金额单位混用(元 vs 分),以及回调没做幂等,重试时重复执行业务。

解决:在方法签名层面统一用「分」,命名上带Fen后缀;落库用条件更新加唯一索引;把「发货」这个动作做成幂等操作,用一个独立的发货流水表记录,重复请求直接短路返回。这两条做完,基本可以杜绝资损类事故。

6. 上线前的最后一公里:用自建 mock 和小额真实单验边界

Demo 跑通不等于能上线。我在上线前一定会做两轮验证,一轮是自建的 mock 网关,一轮是真实小额下单。

mock 网关的作用是把「回调」这条最难复现的路径变成可控输入:起一个本地 HTTP 服务,按微信的回调报文格式构造请求,头部带齐四个字段,body 用 APIv3 密钥加密。这样可以批量验证验签失败、时间戳过期、金额不一致、重复通知这四种边界,而不用真的去付款。注意 mock 只能验证你自己的处理逻辑,验签用的证书得用真实平台证书,否则验签这关永远过不了。

第二轮是小额真实下单,金额设成最低档,重点看四件事:

验证项观察点通过标准
回调可达服务端日志是否收到 POST5 秒内出现一条回调记录
验签解密解密后的 JSON 字段是否完整out_trade_no、transaction_id、amount 齐全
幂等手动重放同一报文三次订单只更新一次,发货流水只有一条
异常应答故意返回 500 一次微信按间隔重试,后续能恢复正常

还有一个容易被忽略的点:把「支付成功页」和「回调」的结果对齐。用户付完钱跳回结果页时,回调可能还没到,这时候结果页应该显示「支付结果确认中」并轮询订单状态,而不是直接显示「支付成功」。我就吃过这个亏——结果页抢在回调之前把状态改成了已支付,后来发现那笔订单实际是支付失败,用户白拿了一份权益。

从那以后,我每次接支付,都会强制走一遍「mock 边界 + 小额真单 + 手动重放」这三步,一步都不跳过。支付这块没有后悔药,只有上线前多花的两小时。希望帮到你。

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