简介:面向C#.NET开发者的拉卡拉支付接口对接参考资料与可运行示例工程,聚焦在线支付、条码支付、退款申请与交易查询等典型业务场景的落地实现。压缩包共78个文件、约5.65MB,以23个cs源码和1个sln解决方案为主体,配合9个dll依赖库、8个xml与4个config配置项以及若干txt报文样例;另有crt、cer、key、p7s等证书与密钥文件用于签名验签演示,并附nupkg、ps1等构建脚本。测试环境参数与报文字段覆盖微信、支付宝的支付成功、支付中、退款成功与退款失败等多种应答状态,WindowsForms示例直观呈现请求发起、参数签名、回调通知处理与异常分支等环节。已有1006人学习,适合需要快速验证接口连通性、对照真实报文排错或补齐签名与证书环节的中初级开发者参考。
1. 「支付通道换一下,下周上线」——一个 zip 包背后要补的东西
接手一个已经跑了两年的收银台,最怕的场景不是需求变更,而是有人发来一个压缩包:“这是 C# .NET 的拉卡拉支付接口对接包,你看看能不能接到现在的系统里,下周上线。”包解开之后大概率是这几样:一个 SignUtil 工具类、几个 Request/Response 实体、一份 PDF 接口文档、一个能跑但只跑通了“下单”的 Demo。真正能让人睡不着的,从来不是签名函数写不出来,而是签名函数写对了、订单也付了,用户却被扣了两次钱,或者回调重复发了五遍导致仓库发了五箱货。
这套接口包解决的是“和支付网关说上话”这一段:拼报文、算签名、发请求、处理异步通知。它不解决订单状态机、不解决幂等、不解决对账差异。所以这篇文字面向的是手上有现成 C# 业务系统、需要把支付通道接进去的一线开发者,从拆包选型一路讲到签名、下单、回调、排错和 T+1 轧账。新手照着抄能跑通,熟手可以重点看第 3 章的签名排序和第 5 章的翻车记录。
2. 拆包与选型:C# .NET 项目里这套接口包该挂在哪一层
2.1 包里有什么,哪些文件必须改
一个支付接口包的结构大同小异,我一般先花二十分钟把目录过一遍,把「可以直接用」「必须改」「可以删」分三堆。下面这张表是这类 pos/网关类接口包最常见的组成,不同版本会有出入,但职责划分基本一致:
| 目录/文件 | 职责 | 处理方式 |
|---|---|---|
SignUtil.cs/SignHelper | 参数排序、拼接待签名串、MD5/RSA 计算 | 必须逐行核对,别直接信 |
HttpUtil.cs | 表单 POST、超时、返回解析 | 换成 HttpClient 单例 |
Request/、Response/实体 | 字段名、类型定义 | 按需保留,字段名一律改成和文档一致 |
Demo/控制台项目 | 演示下单、查询 | 只用来做签名对照,不进生产 |
doc/接口文档.pdf | 网关地址、字段表、返回码 | 必读,尤其返回码表 |
cert/证书或公钥 | RSA 模式使用 | 走配置中心,别提交进 Git |
很多人拿到包之后的第一反应是“Demo 能跑通就说明没问题”,然后直接把 Demo 的调用方式搬到 Controller 里。这会留下两个隐患:Demo 用的是同步HttpWebRequest,在高并发下会耗尽连接池;Demo 里 public 的网关地址和商户号是硬编码的,上生产时忘了换,导致测试环境的单子打到了正式网关。
选型上首先要搞清楚的是:这套接口包属于哪一种接入形态。常见的三种是网关支付(浏览器跳转到收银台)、扫码支付(主扫/被扫)、H5 与小程序支付。它们共用同一套签名和报文规则,但交互流程差别很大:网关支付是表单跳转,用户离开你的站点;扫码支付是拿到二维码内容自己渲染;H5 需要处理唤起 App 的跳转链路。同一个项目里可能会同时用到两三种,所以我一般不会把 SDK 直接塞进业务代码,而是在上面加一层薄封装。
2.2 用一层接口把 SDK 包起来
封装的目的很实际:一是把“拼参数 + 签名 + 发请求 + 解析”这四步收进一个类里,业务层只关心“给多少钱、什么单号”;二是以后换通道或者加通道时,业务代码不用动。
// IPaymentGateway.cs —— 业务层只依赖这个接口 public interface IPaymentGateway { // 统一下单,返回二维码内容或跳转地址 Task<CreateOrderResult> CreateAsync(CreateOrderInput input, CancellationToken ct = default); // 主动查询,回调丢了靠它兜底 Task<QueryOrderResult> QueryAsync(string outTradeNo, CancellationToken ct = default); // 退款 Task<RefundResult> RefundAsync(RefundInput input, CancellationToken ct = default); // 验证异步通知的签名,返回解析后的字段 bool TryParseNotify(IDictionary<string, string> form, out NotifyPayload payload); } public sealed record CreateOrderInput( string OutTradeNo, // 商户订单号,全局唯一,建议 业务前缀+yyyyMMdd+雪花后缀 long AmountFen, // 金额单位:分,绝不用 decimal 传网关 string Subject, // 商品标题,注意各渠道有长度上限 string PayMode); // SCAN / GATEWAY / H5这里的AmountFen用long而不是decimal是刻意的。网关要的是“分”,业务库里存的是“元”,中间任何一次double参与运算都可能出现0.1 + 0.2那种尾巴。我的做法是:数据库存decimal(18,2),出站时统一decimal.Round(x, 2) * 100转成long,入站时再除回去。这条约定写进团队规范,比事后查账舒服得多。
封装的另一层价值是隔离 .NET 版本差异。接口包如果是给 .NET Framework 4.6 写的,里面多半有ServicePointManager、HttpWebRequest、WebClient这些类型;搬到 .NET 6/8 上编译能过,但行为不完全一致。多一层封装,你就有地方把老 API 换掉,而不必去改业务层的每一处调用。
3. 签名串拼错一次,后面全是白工
3.1 排序、剔空、追加密钥的三条硬规则
签名占对接失败原因的八成以上,而且失败现象高度统一:网关返回一个签名校验失败的错误码,看不出到底哪里错。要把这件事做对,先得把规则拆成可验证的步骤。绝大多数国内支付网关用的是同一套思路,常见做法是:
- 取所有业务参数,排除
sign字段本身,排除值为空的字段(注意是空字符串,不是null字符串); - 按参数名做字典序升序排序,比较基准必须是
Ordinal,不是当前区域文化; - 拼成
k1=v1&k2=v2&...&kn=vn,末尾不加&; - 末尾追加
&key=商户密钥(有些网关是直接拼密钥不加key=,一定以包里的文档为准); - 对整串做 MD5 取 32 位大写,或者做 SHA256withRSA 再 Base64。
第 2 步是最容易翻车的地方。C# 的OrderBy(x => x.Key)默认走Comparer<string>.Default,也就是StringComparer.CurrentCulture,在某些区域设置下大小写和重音的排序结果和 ASCII 码序不一致。参数名里一旦出现大写字母,比如outTradeNo和payMode,就可能排错顺序。同理,拼接时如果用string.Join加&,最后会多出一个尾随的&,加上key=就变成&&key=。
还有一个隐蔽的点:空值到底算不算参与签名。有的文档写“非空参数参与签名”,有的写“所有参数参与签名”,一字之差结果完全不同。我的处理方式是把这个开关做成配置项ExcludeEmptyInSign,然后在测试环境拿同一组参数跑两遍,比对网关返回的错误码,一次就能确定。
3.2 一份能直接替换的 C# 签名工具类
接口包自带的SignUtil我一般不会直接用,而是重写一份可读、可单测的。下面这份覆盖 MD5 和 RSA 两条分支,签名源串单独暴露出来方便打印排查:
public static class SignUtil { /// <summary> /// 生成待签名串:剔除 sign 与空值,按 Ordinal 升序,末尾保留一个 & /// </summary> public static string BuildSource(IEnumerable<KeyValuePair<string, string>> parameters, bool excludeEmpty = true) { var sb = new StringBuilder(256); foreach (var kv in parameters .Where(p => !string.Equals(p.Key, "sign", StringComparison.Ordinal)) .Where(p => !excludeEmpty || !string.IsNullOrEmpty(p.Value)) .OrderBy(p => p.Key, StringComparer.Ordinal)) // 关键:Ordinal { sb.Append(kv.Key).Append('=').Append(kv.Value).Append('&'); } return sb.ToString(); // 形如 "a=1&b=2&" } /// <summary>MD5 模式:源串 + "key=密钥",取 32 位大写</summary> public static string Md5Upper(string source, string merchantKey, string charset = "UTF-8") { var full = source + "key=" + merchantKey; using var md5 = MD5.Create(); var hash = md5.ComputeHash(GetEncoding(charset).GetBytes(full)); var sb = new StringBuilder(32); foreach (var b in hash) sb.Append(b.ToString("X2")); return sb.ToString(); } /// <summary>RSA 模式:SHA256 + PKCS#1 v1.5,输出 Base64</summary> public static string RsaSha256(string source, string privateKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); // .NET 5+;Framework 需用证书或第三方库导入 var sig = rsa.SignData(Encoding.UTF8.GetBytes(source), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(sig); } private static Encoding GetEncoding(string name) { // .NET Core 默认不带 GBK,需要注册 CodePages Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); return Encoding.GetEncoding(name); } }BuildSource末尾故意留了一个&,这样 MD5 分支直接拼key=即可,不用在调用处判断谁最后一位。如果你手上的接口包文档写的是“末尾不保留&,直接拼密钥”,把Md5Upper里的"key="去掉就行,但不要在BuildSource里改,否则排序逻辑和拼接逻辑耦合在一起,后面没法复用。
charset参数看着多余,实际很关键。接口包文档里如果写的是charset=GBK,那你必须用 GBK 的字节去算哈希,用 UTF-8 算出来必然是错的。而 .NET Core 3.0 之后Encoding.GetEncoding("GBK")默认会抛NotSupportedException,必须先注册CodePagesEncodingProvider。这个坑我在两个项目里都踩过,第一次排查花了整整一个下午。
3.3 金额、字符集、时间戳:三个参数错一个就 100% 失败
除了签名顺序,还有三个字段值得单独拎出来。
金额。网关要“分”,业务要“元”。常见的错误是把1.00直接当字符串传过去,或者用double算出99.99999999再ToString()。正确做法是全程decimal,只在出站那一刻转long,并且用CultureInfo.InvariantCulture转字符串,避免在某些服务器区域设置下变成1,00这种带千分位的形态。
字符集。签名用的字节集和传输用的字节集必须一致。如果charset=UTF-8,那StringContent(body, Encoding.UTF8, ...)也要 UTF-8;如果对方要 GBK 而你在 HTTP 层用了 UTF-8,即使签名对了,网关解出来的中文商品名也是乱码,某些网关会因为“摘要与内容不符”直接拒绝。
时间戳。要万分留意,是 13 位毫秒还是yyyy-MM-dd HH:mm:ss。13 位毫秒用DateTimeOffset.Now.ToUnixTimeMilliseconds(),不要用DateTime.Now.Millisecond(那是秒内的毫秒数,只有三位)。同时注意服务器时区,容器里默认 UTC 而网关按北京时间校验,差 8 小时就会报“请求已过期”。
4. 下单与异步回调:把 HttpClient 和幂等一起做对
4.1 统一下单:请求对象与表单提交
下单这一步的本质是“组装字典 → 签名 → 表单 POST → 解析返回”。看着简单,但有两处细节决定成败:HttpClient 的实例化和表单编码方式。
public sealed class GatewayClient : IPaymentGateway { private readonly HttpClient _http; // 单例注入,别每次 new private readonly GatewayOptions _opt; private readonly ILogger<GatewayClient> _logger; public GatewayClient(HttpClient http, IOptions<GatewayOptions> opt, ILogger<GatewayClient> logger) { _http = http; _opt = opt.Value; _logger = logger; } public async Task<CreateOrderResult> CreateAsync(CreateOrderInput input, CancellationToken ct = default) { var p = new Dictionary<string, string>(StringComparer.Ordinal) { ["version"] = "1.0", ["charset"] = "UTF-8", ["signType"] = _opt.SignType, // MD5 / RSA ["merchantNo"] = _opt.MerchantNo, ["outTradeNo"] = input.OutTradeNo, // <=32 位,全局唯一 ["totalAmount"] = input.AmountFen.ToString(CultureInfo.InvariantCulture), // 单位:分 ["subject"] = input.Subject, ["payMode"] = input.PayMode, ["notifyUrl"] = _opt.NotifyUrl, // 必须是公网可达的 https ["timestamp"] = DateTimeOffset.Now.ToUnixTimeMilliseconds().ToString(), ["nonceStr"] = Guid.NewGuid().ToString("N"), // 防重放,32 位 }; var source = SignUtil.BuildSource(p); p["sign"] = _opt.SignType == "RSA" ? SignUtil.RsaSha256(source, _opt.PrivateKey) : SignUtil.Md5Upper(source, _opt.MerchantKey); // 自己拼表单,避免 FormUrlEncodedContent 把空格编码成 '+' 导致对方验签不一致 var body = string.Join("&", p.Select(kv => $"{kv.Key}={Uri.EscapeDataString(kv.Value ?? string.Empty)}")); using var req = new HttpRequestMessage(HttpMethod.Post, _opt.GatewayUrl) { Content = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded") }; using var resp = await _http.SendAsync(req, ct); var text = await resp.Content.ReadAsStringAsync(ct); // 原始报文一定要留,排错时这是唯一的黑匣子 _logger.LogInformation("gateway.resp outTradeNo={No} http={Code} body={Body}", input.OutTradeNo, (int)resp.StatusCode, text); if (!resp.IsSuccessStatusCode) throw new GatewayException($"HTTP {(int)resp.StatusCode}"); var result = JsonSerializer.Deserialize<CreateOrderResult>(text); if (result is null || !result.IsSuccess) throw new GatewayException($"{result?.Code}:{result?.Message}"); return result; } }代码里有三处是刻意为之的。StringComparer.Ordinal前面讲过了。Uri.EscapeDataString而不是FormUrlEncodedContent,是因为后者按application/x-www-form-urlencoded规则会把空格编码成+,而不少网关在验签前只做一次UrlDecode,+会被还原成空格,和你签名时用的原始值对不上,于是报签名错误——现象是“英文商品名没问题,带空格的商品名就失败”。最后是原始报文日志,这是唯一的后悔药,出事时能直接看到网关到底返回了什么。
HttpClient的生命周期也要注意。在 .NET Framework 上如果每次请求 new 一个,会累积TIME_WAIT;用单例又会遇到 DNS 缓存不刷新的问题。我的做法是IHttpClientFactory命名客户端,PooledConnectionLifetime设成 5 分钟,超时设 10 秒——支付网关超时设太短容易误判失败,设太长会把线程池拖住。
4.2 异步通知:验签、读原始报文、返回什么
异步通知是这套接口包里最容易写错的部分,因为它不像下单那样有明确返回值,而是“你回了什么字符串,对方就认为你处理成没成功”。
[HttpPost("/pay/notify")] [AllowAnonymous] public async Task<IActionResult> Notify() { Request.EnableBuffering(); using var reader = new StreamReader(Request.Body, Encoding.UTF8, false, 1024, leaveOpen: true); var raw = await reader.ReadToEndAsync(); // 一定要读原文,别依赖模型绑定 Request.Body.Position = 0; var form = raw.Split('&') .Select(x => x.Split('=', 2)) .Where(a => a.Length == 2) .ToDictionary(a => a[0], a => Uri.UnescapeDataString(a[1]), StringComparer.Ordinal); var source = SignUtil.BuildSource(form); var expect = _opt.SignType == "RSA" ? SignUtil.RsaSha256(source, _opt.PrivateKey) // 通知验签用公钥/平台公钥 : SignUtil.Md5Upper(source, _opt.MerchantKey); if (!form.TryGetValue("sign", out var sign) || !string.Equals(sign, expect, StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning("notify.sign.invalid outTradeNo={No}", form.GetValueOrDefault("outTradeNo")); return Content("FAIL"); // 让对端重试 } // 幂等:靠数据库唯一约束 + 状态机 CAS,不靠内存锁 var changed = await _orderService.MarkPaidAsync( form["outTradeNo"], form["channelTradeNo"], form.GetValueOrDefault("payTime")); if (!string.Equals(form.GetValueOrDefault("tradeStatus"), "SUCCESS", StringComparison.Ordinal)) return Content("SUCCESS"); // 非成功状态也要应答,避免无意义重推 return Content(changed >= 0 ? "SUCCESS" : "FAIL"); }几个必须点出来的地方。第一,读Request.Body之前要EnableBuffering(),否则读完就没了,后续中间件或模型绑定拿到空流。第二,不要用[FromForm]的强类型绑定来做验签,模型绑定会丢掉空字段、会改变字段原始值,验签必然失败。第三,返回的字符串内容要和文档一致——有的网关认SUCCESS,有的认success,有的认OK,大小写敏感,这个必须照抄文档,不能凭感觉。
4.3 订单状态机与主动查询兜底
回调是不可靠的。网关会重试,可能 5 秒一次、可能几分钟一次,也可能因为你的服务重启而彻底丢失。所以状态机必须能承受“同一笔单被处理多次”和“永远收不到通知”两种情况。
我的做法是订单表只允许单向流转:PENDING → PAID → REFUNDING → REFUNDED,每次更新都带上前置状态做 CAS:
-- 只有处于 PENDING 的订单才能被置为已支付,影响行数即“是否首次处理” UPDATE orders SET status = 'PAID', paid_time = @paidTime, channel_trade_no = @channelTradeNo, update_time = NOW() WHERE out_trade_no = @outTradeNo AND status = 'PENDING';影响行数返回 0 有两种可能:订单已经是PAID(重复通知),或者订单不存在/状态异常。这两种情况都直接返回成功,不要抛异常让网关继续重试。发货、加积分这些副作用放在事务提交之后,用领域事件或者简单的“本地消息表 + 定时任务”来保证最终一致。
兜底查询交给一个定时任务:扫描status='PENDING'且创建时间超过 2 分钟但不到 24 小时的订单,逐笔调QueryAsync。频率别太高,我一般用 1 分钟一轮、每轮限流 50 笔,加指数退避。这个任务跑起来之后,“用户付了钱但订单没变状态”的客诉会少掉九成。
5. 排查清单:上线前最容易翻车的五类现场
5.1 签名类:算出来和网关对不上
现象一:所有订单都返回签名错误,但把参数贴进官方 Demo 里算,结果又一样。
原因多半在待签名串的可见性上——你以为自己拼的是a=1&b=2,实际拼出来的可能是a=1&b=2&,或者中间夹了一个你没注意到的null字段(null转字符串变成空串,但如果你的过滤条件只判断了!= null而没判断!= "",空串就会进串)。解决办法很土但有效:在签名前把source用_logger.LogDebug打出来,同时把 Demo 的 source 也打出来,两行文本并排放,肉眼看差异,通常前三秒就能发现是多了个&还是少了某个字段。
现象二:参数完全一致,本地 MD5 和 Demo 的 MD5 值不同。
先检查编码。Demo 里如果写了Encoding.GetEncoding("GBK"),而你的代码用了 UTF-8,只要商品名带中文,哈希就必然不同。其次是检查有没有把sign字段本身也算进去了——重复签名的结果当然不一样。最后检查密钥末尾是不是多了空格或换行,这个坑非常经典:从文档 PDF 里复制密钥时带了一个不可见字符,肉眼看不出来,只有在十六进制编辑器里才现形。
5.2 运行时类:TLS、编码、超时
现象三:本地 .NET 6 跑得好好的,部署到 .NET Framework 4.6.1 的服务器上直接抛“基础连接已经关闭: 发送时发生错误”。
原因是 .NET Framework 4.6 的默认安全协议不包含 TLS 1.2,而网关早就关掉了 TLS 1.0/1.1。解决办法是在Global.asax的Application_Start里加一行:
// 仅 .NET Framework 需要;.NET Core / .NET 5+ 由操作系统协商,不必写 ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;顺便提一句,如果你的项目里同时还有老代码在设Tls或Ssl3,后执行的会覆盖前面的,最好全局搜一遍SecurityProtocol,只留一处。
现象四:回调报文里的中文全是问号或者乱码。
两种可能:一是读取时用了错误的编码,StreamReader默认 UTF-8,如果对方发的是 GBK,中文就废了;二是 .NET Core 上Encoding.GetEncoding("GBK")直接抛异常,被上层 catch 之后静默返回了失败。前者的解法是读原始字节数组再按配置的字符集解码,后者的解法是在Program.cs最开头注册CodePagesEncodingProvider。另外,appsettings.json里存的中文如果不带 BOM,读出来也可能是乱码,配置文件里的密钥、商户名建议纯 ASCII。
5.3 业务类:重复通知与金额单位
现象五:一笔订单发了两箱货,查日志发现回调在同一秒内进来了三次。
这不是网关抽风,是它没收到你的成功应答所以重试。但根子在你这边:MarkPaidAsync里先SELECT判断状态再UPDATE,两个请求同时读到PENDING,都判定为“需要发货”,于是发了两次。这是典型的检查后写入竞态。改成UPDATE ... WHERE status='PENDING'并检查影响行数,或者给out_trade_no加唯一索引让数据库来兜底,问题立刻消失。血泪经验是:任何涉及钱的写操作,都不要用“先查后改”,要么用条件更新,要么用行锁。
金额单位的问题也归到这一类。现象是“订单金额 1 元,实际扣了 100 元”,或者反过来。原因通常是某一段链路里把“分”当“元”用了,而且因为100 * 100 = 10000这种巧合,测试时用小额(0.01 元)反而没暴露出来。我的习惯是:所有变量名一律带单位后缀,amountFen、amountYuan,看到不带后缀的amount就当成代码异味处理。
6. 对账与上线验证:用 T+1 对账单把账轧平
接口跑通只能说明“能付钱”,能对上账才说明“能长期跑”。支付通道的 T+1 对账单一般是一个按行分隔的文本文件,字段包含商户订单号、通道流水号、交易金额、手续费、交易状态、交易时间。我一般先做一张字段映射表,把对账单字段和本地订单表一一对上,再写轧账逻辑:
| 对账单字段 | 本地字段 | 校验规则 |
|---|---|---|
| 商户订单号 | out_trade_no | 必须能查到,查不到记为「长款」 |
| 通道流水号 | channel_trade_no | 与回调写入的一致 |
| 交易金额(分) | amount_fen | 严格相等,不做容差 |
| 手续费(分) | fee_fen | 仅记录,参与利润核算 |
| 交易状态 | status | SUCCESS对应PAID |
轧账代码不需要复杂,一次全量拉取 + 两个方向的差集足够:
// 1. 读出对账单,key 用商户订单号 var channel = File.ReadLines(billPath) .Skip(1) // 跳过表头 .Select(l => l.Split('|')) .ToDictionary(a => a[0].Trim(), a => new { TradeNo = a[1].Trim(), AmountFen = long.Parse(a[2].Trim()), Status = a[4].Trim() }); // 2. 拉当天本地已支付订单 var local = await _orderRepo.GetPaidByDateAsync(billDate); // 3. 三个方向的差异 var missingLocal = channel.Keys.Except(local.Select(o => o.OutTradeNo)).ToList(); // 通道有、本地没有 → 掉单 var missingChannel = local.Select(o => o.OutTradeNo).Except(channel.Keys).ToList(); // 本地有、通道没有 → 假成功 var amountMismatch = local.Where(o => channel.TryGetValue(o.OutTradeNo, out var c) && c.AmountFen != o.AmountFen).ToList(); // 金额不一致 _logger.LogWarning("reconcile date={Date} 掉单={A} 假成功={B} 金额不一致={C}", billDate, missingLocal.Count, missingChannel.Count, amountMismatch.Count);这三类差异的处理策略不同。「掉单」是用户付了钱但回调没到,直接按对账单补状态并补发货,属于必须自动化的场景。「假成功」最危险,通常是本地把失败的单标成了已支付,必须先冻结再人工核。「金额不一致」基本只有一种可能——单位换算错了,此时不要相信任何一方的数字,去翻原始报文。
上线前的自测用例我固定跑四个:0.01 元的最小额下单并支付,验证金额换算;创建订单后放弃支付,等 25 小时验证关单任务;用同一份回调报文重放五次,验证幂等;把notifyUrl临时指向一个会返回 500 的地址,验证网关重试和本地主动查询兜底。这四个用例跑完,八成的问题都能在上线前暴露。
说个自己的教训。早些年接第一个支付通道时,我把“签名正确”当成了“对接完成”,测试环境跑通就上线了,结果第一天就有三笔掉单——回调地址在网关侧配的是 http,而我们的站点强制跳 https,POST 请求被 301 成了 GET,参数全丢。从那以后我每次接新通道,都强制走一遍「回调地址可达性验证 + 报文原始日志开关 + 对账任务空跑三天」这三步,哪怕需求再急也不跳。希望帮到你。
本文还有配套的精品资源,点击获取