☰
C# .NET支付接口接入实战:签名验签、回调幂等与对账避坑
2026/10/29 2:40:30 网站建设 项目流程

简介:围绕拉卡拉支付接口的 C#.NET 接入与联调,这份压缩包覆盖条码支付、交易查询、退款申请与退款查询等常见业务场景,也涉及 SSL 加密通信、签名验签、异步回调通知与异常处理等关键环节,适合正在对接第三方支付的初中级开发者参考。压缩包共 78 个文件、约 5.65MB,以 23 个 cs 源码、13 个 txt 报文示例、9 个 dll 与 nupkg 依赖库为主,辅以 xml、config 配置文件和 cer、crt、key 等测试环境证书与私钥素材,目录按测试参数、报文样例与示例工程分块组织,直观呈现签名所需材料。其中包含 WindowsForms 示例工程与完整 sln 解决方案,并附商户终端号、appId 及使用说明,可对照微信、支付宝支付成功与支付中等报文结果,理解请求组装、返回码判断与退款状态流转。已有 1006 人学习,适合需要快速搭建调试环境、排查验签失败与订单状态异常者对照使用。

1. 支付接入里最容易翻车的,往往不是加签算法

把这份 C# .NET 支付接口压缩包解开之前,我以为是那种塞满几百个类的“全家桶”SDK,结果骨架比想象中干净:下单、查单、退款、异步回调验签、配置装载、日志落盘,几块拼起来就是一条完整链路。它解决的不是“怎么调一个方法”,而是“怎么把第三方支付网关那套表单协议、签名规则和回调确认正确地用 C# 表达出来”。适合两类人:一类是 .NET 后端,手上有个商城或 SaaS 要接支付,需要一个能直接改的骨架;另一类是已经接完但线上偶发验签失败、重复回调、金额对不上,想找一份可对照的参考实现。真正的难点从来不在 MD5 那几行,而在空值该不该参与签名、回调该返回 JSON 还是纯文本、金额用元还是分这些细节上。

2. 项目结构与技术选型:一个 .NET 支付接入包的分层拆解

拿到压缩包先别急着 F5,先看清楚它把“协议”和“业务”切在了哪里。支付接入最怕把签名、HTTP、订单状态机全糊在一个 Controller 里,后面加一个退款接口就得复制一遍签名逻辑。这套结构基本按“协议层不依赖业务层”的思路分层,读起来顺,改起来也不容易互相牵连。

2.1 目录结构:协议层、站点层、任务层三块分开

解压后的目录大致是这样组织的,下面这份是我按同类项目常见做法还原的骨架,实际文件名可能略有出入,但分层意图一致:

PaymentDemo/ ├── src/ │ ├── Payment.Sdk/ # 协议层:参数模型、签名、HTTP 收发、响应解析 │ │ ├── Signature.cs # 签名与验签 │ │ ├── GatewayClient.cs # 统一请求出口,持有 HttpClient │ │ ├── Models/ # PayRequest / RefundRequest / NotifyPayload │ │ └── PaymentOptions.cs # 商户号、密钥、网关地址等强类型配置 │ ├── Payment.Api/ # ASP.NET Core 站点:下单入口 + 回调入口 │ │ ├── Controllers/PayController.cs │ │ ├── Controllers/NotifyController.cs │ │ └── appsettings.json │ └── Payment.Jobs/ # 后台任务:对账拉单、超时未支付关单 ├── tests/ │ └── Payment.Sdk.Tests/ # 签名向量测试、回调验签测试 ├── tools/ │ └── MockGateway/ # 本地假网关,断网也能联调 └── PaymentDemo.sln

Payment.Sdk是整个包的重量所在,它不引用 ASP.NET Core,只依赖System.Net.Http和System.Security.Cryptography,这意味着控制台程序、Windows 服务、甚至 WinForm 收银端都能直接复用。Payment.Api只干两件事:接收前端提交、把结果丢给 Sdk;接收网关回调、验签后交给业务。Payment.Jobs是很多人会漏掉的一块——支付不是“请求成功就完事”,还有超时关单和 T+1 对账,这两件事必须有个独立进程兜底。

tools/MockGateway我觉得是这个包里最值钱的部分之一。真实网关的沙箱环境经常要申请、要白名单、要等审批,本地假网关让你在没网、没账号的情况下也能把签名链路跑通,属于典型的“后悔药”。

2.2 技术栈与依赖:.NET 6+、HttpClientFactory、Options 模式

技术选型没有花活,全是 .NET 后端的主流搭配:

层面选型选它的理由
运行时.NET 6 / .NET 8长期支持版本,Convert.ToHexString、ImportFromPem这些 API 从 .NET 5 起就自带,不用再引第三方加密库
Web 框架ASP.NET Core MVC / 最小 API回调入口需要读表单,MVC 的[FromForm]最省事
HTTPIHttpClientFactory避免new HttpClient()带来的端口耗尽和 DNS 不刷新问题
配置Microsoft.Extensions.Options商户号、密钥这类配置强类型化,配合IValidateOptions做启动校验
JSONSystem.Text.Json只用于日志和内部接口,和网关交互本身是表单,不涉及 JSON
日志Serilog 或 NLog请求报文必须落盘,出问题时是唯一的黑匣子
测试xUnit签名这类纯函数最适合用固定向量做回归

这里有个选型细节值得说:网关协议是application/x-www-form-urlencoded,不是 JSON。所以别用PostAsJsonAsync,要用FormUrlEncodedContent,并且注意它对null值的处理——传null进去会抛异常,得先过滤。

2.3 配置与密钥管理:别把商户密钥写进 appsettings.json

配置文件通常长这样,字段名按你实际对接的网关文档调整:

{ "Payment": { "GatewayUrl": "https://gateway.example-pay.test/api/v1/trade", "MerchantNo": "800000000001", "TerminalNo": "T0000001", "MerchantKey": "", "NotifyUrl": "https://your-domain.com/pay/notify", "TimeoutSeconds": 10, "SignType": "MD5" } }

MerchantKey故意留空,本地开发用dotnet user-secrets注入,生产环境走环境变量或密钥管理服务,绝不进 Git。代码侧用 Options 绑定并做启动期校验:

public sealed class PaymentOptions { public const string SectionName = "Payment"; public string GatewayUrl { get; set; } = string.Empty; public string MerchantNo { get; set; } = string.Empty; public string TerminalNo { get; set; } = string.Empty; public string MerchantKey { get; set; } = string.Empty; public string NotifyUrl { get; set; } = string.Empty; public int TimeoutSeconds { get; set; } = 10; public string SignType { get; set; } = "MD5"; } // 启动时校验,缺配置直接崩,别等到第一笔订单才暴雷 public sealed class PaymentOptionsValidator : IValidateOptions<PaymentOptions> { public ValidateOptionsResult Validate(string? name, PaymentOptions options) { var missing = new List<string>(); if (string.IsNullOrWhiteSpace(options.MerchantNo)) missing.Add(nameof(options.MerchantNo)); if (string.IsNullOrWhiteSpace(options.MerchantKey)) missing.Add(nameof(options.MerchantKey)); if (string.IsNullOrWhiteSpace(options.GatewayUrl)) missing.Add(nameof(options.GatewayUrl)); if (!Uri.TryCreate(options.NotifyUrl, UriKind.Absolute, out _)) missing.Add(nameof(options.NotifyUrl)); return missing.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail($"支付配置缺失或非法:{string.Join(", ", missing)}"); } }

这段代码的价值在于“早失败”。支付配置缺失是典型的低频高杀伤问题:开发环境用假数据跑得好好的,上线切生产环境配置少填一个TerminalNo,第一笔订单直接报签名错误,排查半小时才发现是配置问题。启动期校验把这类问题挡在进程启动阶段,日志里一眼能看出缺哪个键。

TimeoutSeconds也别省。网关侧偶尔抖动,不设超时的话 HttpClient 默认 100 秒,一个请求能把线程池拖住。我一般设 10 秒,配合一次重试就够了。

3. 核心链路拆解:从下单到回调的四个关键节点

协议层的代码量不大,但每一处都值得逐行看,因为支付接入的 bug 有八成集中在这四段里:请求怎么组装、签名怎么算、回调怎么验、状态怎么更新。

3.1 统一请求封装:先字典化,再排序,最后拼串

网关要求的是扁平表单,所以业务模型得先转成Dictionary<string, string?>。这一步别偷懒用反射直接序列化对象,字段名、空值处理、大小写都会出问题。

public sealed class PayRequest { public string OutTradeNo { get; init; } = default!; // 商户订单号,全局唯一,建议 业务前缀+雪花ID public long AmountFen { get; init; } // 金额单位:分,用 long 不用 decimal public string Subject { get; init; } = default!; // 商品标题,注意长度限制 public string NotifyUrl { get; init; } = default!; // 回调地址,必须公网可达 public string? ClientIp { get; init; } // 可选,风控用 public string? Attach { get; init; } // 透传字段,回调原样带回 public IDictionary<string, string?> ToForm() { var form = new Dictionary<string, string?>(StringComparer.Ordinal) { ["service"] = "trade.pay", ["out_trade_no"] = OutTradeNo, ["amount"] = AmountFen.ToString(), // 统一用分,避免 0.1+0.2 的浮点玄学 ["subject"] = Subject, ["notify_url"] = NotifyUrl, ["client_ip"] = ClientIp, ["attach"] = Attach }; return form; } }

AmountFen用long是刻意的。金额一旦用decimal或double,从数据库读出来、经过一次 JSON 序列化、再转成字符串,就可能变成19.90还是19.9的问题,签名对不上,网关直接拒。你会在日志里看到两个看起来一模一样的金额,然后开始怀疑人生。单位统一成分、类型统一成整数,这类问题一次都不会有。

StringComparer.Ordinal也是必须的。签名排序要求按 ASCII 码升序,而OrderBy(x => x.Key)默认走的是当前区域性比较,某些区域性设置下_和大小写字母的顺序会不一样,本地跑得好好的,部署到另一台服务器就全错。

3.2 签名与验签:MD5 与 RSA 两条路

签名规则各家略有差异,但套路高度一致:剔除sign字段和空值,按 key 升序,拼成k=v&k=v,末尾追加商户密钥,做摘要后转大写。

public static class Signature { /// <param name="parameters">完整参数字典,可包含 sign(会被内部剔除)</param> /// <param name="merchantKey">商户密钥,放拼接串末尾,不参与排序</param> /// <param name="upper">网关大多要大写十六进制,少数要小写,按文档来</param> public static string SignMd5(IDictionary<string, string?> parameters, string merchantKey, bool upper = true) { var sb = new StringBuilder(); foreach (var kv in parameters .Where(p => p.Key != "sign" && !string.IsNullOrWhiteSpace(p.Value)) .OrderBy(p => p.Key, StringComparer.Ordinal)) { sb.Append(kv.Key).Append('=').Append(kv.Value).Append('&'); } sb.Append("key=").Append(merchantKey); // 密钥固定拼在最后 var raw = sb.ToString(); // 这一行建议打日志,排查时能救命 var hash = MD5.HashData(Encoding.UTF8.GetBytes(raw)); var hex = Convert.ToHexString(hash); // 返回的已是大写 return upper ? hex : hex.ToLowerInvariant(); } }

三个参数里最容易出错的是upper。MD5 的十六进制表示有大小写两种,如果你算出来是小写、网关要的是大写,字符串比较永远不等。别靠肉眼比对,把raw打出来,用在线工具算一遍对照,是最快的定位方式。

如果网关走的是 RSA 签名,逻辑换一下,前缀串规则不变:

public static string SignRsa(string raw, string privateKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); // .NET 5+ 直接支持 PKCS#8 / PKCS#1 PEM,不用再装 BouncyCastle var sig = rsa.SignData(Encoding.UTF8.GetBytes(raw), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(sig); }

验签侧要做一个容易被忽略的动作:定长比较。普通的==会在第一个不同字符处返回,理论上可以被用来做时序探测,虽然支付场景风险不大,但成本几乎为零,顺手写了更省心:

public static bool VerifyMd5(IReadOnlyDictionary<string, string> form, string merchantKey) { if (!form.TryGetValue("sign", out var remoteSign) || string.IsNullOrWhiteSpace(remoteSign)) return false; var expect = SignMd5(form.ToDictionary(k => k.Key, v => (string?)v.Value), merchantKey); var a = Encoding.ASCII.GetBytes(expect.ToUpperInvariant()); var b = Encoding.ASCII.GetBytes(remoteSign.ToUpperInvariant()); return a.Length == b.Length && CryptographicOperations.FixedTimeEquals(a, b); }

ToUpperInvariant()这一步在验签侧同样重要——网关回传的sign可能大小写和你算的不一致,统一转成大写再比,能省掉一半“明明参数一样却验签失败”的工单。

3.3 统一出口与回调入口:一个类管发,一个类管收

GatewayClient是所有出站请求的唯一出口,好处是签名、公共参数、日志、超时全部收口在一处:

public sealed class GatewayClient { private readonly HttpClient _http; private readonly PaymentOptions _opt; private readonly ILogger<GatewayClient> _log; public GatewayClient(HttpClient http, IOptions<PaymentOptions> opt, ILogger<GatewayClient> log) { _http = http; _opt = opt.Value; _log = log; } public async Task<string> PostFormAsync(IDictionary<string, string?> form, CancellationToken ct = default) { // 公共参数在这里统一补齐,业务侧不用关心 nonce 和时间戳 var payload = new Dictionary<string, string?>(form, StringComparer.Ordinal) { ["merchant_no"] = _opt.MerchantNo, ["terminal_no"] = _opt.TerminalNo, ["nonce_str"] = Guid.NewGuid().ToString("N"), ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() }; payload["sign"] = Signature.SignMd5(payload, _opt.MerchantKey); var pairs = payload .Where(p => !string.IsNullOrWhiteSpace(p.Value)) .Select(p => new KeyValuePair<string, string>(p.Key, p.Value!)); using var content = new FormUrlEncodedContent(pairs); using var resp = await _http.PostAsync(_opt.GatewayUrl, content, ct); var body = await resp.Content.ReadAsStringAsync(ct); // 只记业务单号和响应体,密钥绝不落日志 _log.LogInformation("pay out={OutTradeNo} status={Status} body={Body}", form.TryGetValue("out_trade_no", out var no) ? no : "-", (int)resp.StatusCode, body); resp.EnsureSuccessStatusCode(); return body; } }

注意timestamp用的是DateTimeOffset.UtcNow.ToUnixTimeSeconds(),不是本地时间。网关侧一般允许几百秒的偏差,用本地时间在东八区环境里没问题,但一旦容器时区是 UTC、宿主机是东八区,时间戳前后差 8 小时,签名直接过期。统一走 UTC 是零成本的保险。

回调侧是一个独立的 Controller,它和下单入口的职责完全不同:

[ApiController] [Route("pay")] public sealed class NotifyController : ControllerBase { [HttpPost("notify")] public async Task<IActionResult> Notify([FromForm] Dictionary<string, string> form, CancellationToken ct) { // 1. 验签:必须拿原始表单字段算,不能先反序列化成对象再拼 if (!Signature.VerifyMd5(form, _opt.MerchantKey)) { _log.LogWarning("notify sign invalid, raw={Raw}", string.Join('&', form.Select(kv => $"{kv.Key}={kv.Value}"))); return Content("FAIL", "text/plain"); } // 2. 幂等:网关流水号做唯一键,重复插入直接短路 if (!await _notifyLog.TryAppendAsync(form["gateway_no"], form, ct)) return Content("success", "text/plain"); // 已处理过也要回 success,否则一直重推 // 3. 金额校验:回调金额必须与本地订单完全一致(都换算成分) var order = await _orders.FindAsync(form["out_trade_no"], ct); if (order is null || order.AmountFen != long.Parse(form["amount"])) { _log.LogError("notify amount mismatch out={Out} local={Local} remote={Remote}", form["out_trade_no"], order?.AmountFen, form["amount"]); return Content("FAIL", "text/plain"); } // 4. 更新状态 + 发货,放同一个事务里 await _orders.MarkPaidAsync(order.Id, form["gateway_no"], ct); return Content("success", "text/plain"); } }

四个步骤顺序不能乱。验签放最前面,防止伪造回调把订单刷成已支付;幂等放第二步,防止网关重推导致重复发货;金额校验放第三步,防止有人拿一毛钱的订单号去配一万块的实付;最后才是业务状态流转。返回体必须是纯文本success,别返回 JSON——很多网关只认字符串完全匹配,你回{"code":0}它认为失败,会按 5 秒、10 秒、30 秒的节奏一直重推,直到把回调队列刷爆。

3.4 查单、退款与对账:状态以查单为准

下单成功不等于支付成功,中间还有用户取消、超时、银行侧扣款成功但回调丢失这些情况。稳妥的做法是:前端轮询先查本地库,本地库状态是“待支付”且距创建时间超过 2 分钟,服务端主动查一次网关。

public async Task<PayStatus> QueryAsync(string outTradeNo, CancellationToken ct) { var form = new Dictionary<string, string?> { ["service"] = "trade.query", ["out_trade_no"] = outTradeNo }; var body = await _client.PostFormAsync(form, ct); var dict = body.Split('&') .Select(s => s.Split('=', 2)) .Where(a => a.Length == 2) .ToDictionary(a => a[0], a => Uri.UnescapeDataString(a[1])); // 查单结果也要验签,防止中间人篡改 if (!Signature.VerifyMd5(dict, _opt.MerchantKey)) throw new PaymentException("查单响应验签失败"); return dict["trade_status"] switch { "SUCCESS" => PayStatus.Paid, "CLOSED" => PayStatus.Closed, "REFUND" => PayStatus.Refunded, _ => PayStatus.Pending }; }

退款要注意两件事:一是退款单号必须独立生成,不能复用原支付单号;二是退款是异步的,提交成功只代表受理,最终结果还得靠退款回调或退款查询确认。对账任务则每天凌晨拉一次对账单,按网关流水号逐笔比对本地记录,差异单落到一张pay_diff表里人工处理——这块代码不长,但少了它,出了资金差异你连从哪查起都不知道。

4. 跑起来:本地联调与回调验证的完整步骤

代码看懂和跑起来是两回事。支付接入有个特殊之处:下单接口好跑,回调接口难跑,因为回调需要公网可达的地址。所以这套流程要分两段走——先用 Mock 网关把协议跑通,再切真实沙箱验证回调。

4.1 环境准备与首次启动

需要 .NET SDK 6.0 或以上,dotnet --list-sdks确认一下。然后还原依赖、注入本地密钥、启动站点:

# 1. 还原并编译 dotnet restore PaymentDemo.sln dotnet build PaymentDemo.sln -c Debug # 2. 本地开发密钥走 user-secrets,不落文件 cd src/Payment.Api dotnet user-secrets init dotnet user-secrets set "Payment:MerchantNo" "800000000001" dotnet user-secrets set "Payment:TerminalNo" "T0000001" dotnet user-secrets set "Payment:MerchantKey" "local-dev-key-please-change" dotnet user-secrets set "Payment:NotifyUrl" "http://localhost:5080/pay/notify" # 3. 启动假网关(另开一个终端) dotnet run --project tools/MockGateway --urls http://localhost:5090 # 4. 启动站点,把网关地址指向假网关 cd ../.. dotnet run --project src/Payment.Api --urls http://localhost:5080

启动日志里会打印PaymentOptions的校验结果,如果缺配置会直接抛异常并指出缺哪个键,这时候别急着改代码,先看appsettings.json和环境变量谁覆盖了谁。ASP.NET Core 的配置优先级是appsettings.json<appsettings.{Env}.json< 环境变量 < 命令行参数,本地调试时最容易踩的坑是环境变量里还留着上一次测试的旧值。

4.2 发起第一笔下单

用 curl 打一笔最小订单,参数够用就行:

curl -i -X POST http://localhost:5080/pay/create \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "outTradeNo=TEST20240101000001" \ --data-urlencode "amountFen=1" \ --data-urlencode "subject=测试商品" \ --data-urlencode "notifyUrl=http://localhost:5080/pay/notify"

金额传 1 分,是为了让后续对账一眼能认出来。返回里通常带一个支付链接或二维码串,Mock 网关会直接返回一个固定的跳转地址,点开就能看到假收银台。如果这一步返回签名错误,先别怀疑代码,把请求参数按 key 排序打印出来,用同一份串手动算一次 MD5 对照,九成问题出在某个字段的空值处理上。

4.3 验证回调:先 Mock,再切沙箱

Mock 网关的收银台页面上一般有个“模拟支付成功”按钮,点下去它会往你配置的NotifyUrl发一笔表单 POST。这一步能验证的东西比想象中多:

  • 验签是否通过(用同一份密钥,通不过说明验签逻辑有问题)
  • 表单字段名是否对得上([FromForm]绑定失败会得到 400)
  • 返回值格式对不对(Mock 网关会打印它收到的响应体,看到success才算通)

真沙箱环境通常要求回调地址是公网 HTTPS,本地调试时先把站点部署到一台有公网地址的测试机上,把NotifyUrl改成那个地址。别把回调地址配成带查询参数的 URL,部分网关会对notify_url做校验,带了?直接拒绝。

联调阶段还有一个技巧:把每一笔请求的raw签名串和响应体都打到单独的文件里,按日期切分。支付问题往往发生在半夜,第二天看日志时如果没有原始报文,就只能靠猜。

4.4 用签名向量做回归测试

签名和验签是纯函数,最适合写固定向量测试。上线前跑一遍,比在沙箱里点十次都可靠:

[Theory] [InlineData("out_trade_no=TEST001&amount=1&key=local-dev-key-please-change", "A1B2C3...")] public void SignMd5_ShouldMatch_FixedVector(string raw, string expected) { var form = raw.Split('&') .Select(s => s.Split('=', 2)) .ToDictionary(a => a[0], a => (string?)a[1]); var key = form["key"]!; form.Remove("key"); var actual = Signature.SignMd5(form, key); Assert.Equal(expected, actual); }

InlineData里的期望值用你自己的密钥算一遍填进去。这类测试的意义在于:以后谁改了排序规则、改了大小写处理,测试会立刻红,而不是等线上出问题再回头翻代码。

5. 避坑与排查:签名、金额、回调那几笔糊涂账

下面这几条都是支付接入里出现频率最高的问题,按“现象、原因、解决”三段记录,遇到了直接对号入座。

5.1 签名类问题:参数肉眼一致但就是验不过

现象:把请求参数和网关文档逐字对比,一个字符都不差,返回依然是签名错误。

原因:九成是空值参与拼接了。你的字典里有个attach值是null或空字符串,拼接时变成了attach=&,而网关侧压根没这个字段;另一成是排序用了区域相关比较,_和字母的顺序变了。

解决:拼接前统一过滤string.IsNullOrWhiteSpace,排序显式指定StringComparer.Ordinal,并且把拼好的raw字符串打日志。有raw之后,用在线 MD5 工具算一次对照,两分钟能定位。我一般会在签名方法里加一个#if DEBUG的输出,省得每次手动加日志。

5.2 回调类问题:验签失败与重复推送

现象一:回调接口收到请求,日志里参数齐全,验签却一直失败。

原因:用[FromBody]接收了表单,或者先反序列化成对象再拼字典。反序列化过程会丢掉值为空的字段、改变字段顺序,还可能做一次 URL 解码,算出来的串和网关签的串根本不是一回事。

解决:用[FromForm] Dictionary<string, string>原样接收,直接拿这个字典去验签,中间不做任何转换。

现象二:用户明明只付了一次,系统里发了两份货。

原因:网关在没收到success响应时会按退避策略重推,同一笔回调可能来五六次。业务侧只判断了“订单是否已支付”,但判断和更新之间没有原子性。

解决:加一张pay_notify_log表,gateway_no建唯一索引,插入冲突就直接返回success短路:

CREATE TABLE pay_notify_log ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, gateway_no VARCHAR(64) NOT NULL COMMENT '网关流水号,幂等键', out_trade_no VARCHAR(64) NOT NULL, amount_fen BIGINT NOT NULL, raw_body TEXT NOT NULL COMMENT '原始报文,排查用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_gateway (gateway_no) );

靠数据库唯一约束做幂等,比在代码里if (exists) return可靠得多——后者在并发下会漏。

5.3 金额与时区:差 100 倍的经典事故

现象:订单金额 19.90 元,回调验金额时判断不一致,或者金额变成了 1990。

原因:元与分混用。前端传的是元,网关要的是分,中间某处乘了 100 又乘了一次;或者用double存金额,19.90实际是19.899999...,转字符串变成19.9。

解决:全链路只用long存分,字段名直接叫amountFen,谁看到都不会搞错。只在展示层做一次除以 100 的格式化,且用decimal做除法。

现象:白天一切正常,凌晨的订单全部签名超时。

原因:timestamp用了DateTime.Now,服务器时区是 UTC,和网关的东八区差了 8 小时。

解决:统一DateTimeOffset.UtcNow.ToUnixTimeSeconds(),容器基础镜像里也把TZ显式固定,别依赖宿主机。

5.4 出站请求:超时与连接耗尽

现象:压测或大促时段,下单接口偶发TaskCanceledException,之后一段时间所有请求都变慢。

原因:每次请求new HttpClient(),socket 来不及回收,端口被占满;或者用了单例HttpClient但没设Timeout,一个卡住的请求把连接池占死。

解决:用IHttpClientFactory注册命名客户端,设Timeout为 10 秒,配一次指数退避重试,但只对网络异常和 5xx 重试,业务失败(比如余额不足)绝不重试,否则会重复下单。

builder.Services.AddHttpClient<GatewayClient>((sp, http) => { var opt = sp.GetRequiredService<IOptions<PaymentOptions>>().Value; http.BaseAddress = new Uri(opt.GatewayUrl); http.Timeout = TimeSpan.FromSeconds(opt.TimeoutSeconds); }) .AddTransientHttpErrorPolicy(p => p.WaitAndRetryAsync(1, _ => TimeSpan.FromMilliseconds(300)));

提示:重试策略一定要区分异常类型。HttpRequestException和 5xx 可以重试,4xx和业务状态码为失败的一律不重试,否则会产生多笔订单。

6. 二次开发:把示例包改成能长期维护的支付组件

示例包能跑通,跟能长期维护是两码事。真接了业务之后,你会遇到这些需求:接第二个支付渠道、按渠道做路由、给运营一个退款后台、出问题时能按订单号反查全链路日志。这几个需求决定了改造方向。

第一个改造点是把GatewayClient抽成接口。现在它是具体类,加第二个渠道时只能在里面写if (channel == "A"),越写越乱。改成IPaymentChannel,每个渠道一个实现,用IEnumerable<IPaymentChannel>注入,按渠道名路由。这样新增渠道只是加一个类,不动原有代码。

改造点现状建议改法
渠道扩展单一实现抽IPaymentChannel,注册多实现按名路由
签名算法硬编码 MD5抽ISignatureProvider,MD5/RSA 各一个实现
密钥来源配置文件改为密钥管理服务或加密配置,启动时解密
回调幂等依赖业务表独立pay_notify_log,唯一索引兜底
可观测性文本日志请求带上TraceId,下单、回调、查单串起来

第二个改造点是状态机。别再用status = 1/2/3这种魔法值,把订单状态流转写成显式的状态机:Created → Paying → Paid → Refunding → Refunded,每个转换校验前置状态,非法转换直接抛异常并记日志。我见过因为并发回调把已退款订单改回已支付的案例,加了状态机之后这类问题从根上没了。

第三个是验证方法。改完之后不要只测正常流程,专门构造这几组用例:同一笔回调连发五次、回调金额比订单少一分、回调签名改一个字符、订单不存在时收到回调。这四个用例全过,基本可以认为幂等和验签是可靠的。再补一个对账任务的差异检测用例,人为把本地金额改错,看它能不能报出来。

最后一个技巧:给每笔交易生成一个TraceId,在下单时写入订单表,之后所有的请求日志、回调日志、退款日志都带上它。支付问题排查最耗时的不是定位原因,而是把散落在几个服务里的日志拼成一条线。有了TraceId,一个订单的完整生命周期就是一条可查的记录,而不是黑匣子。

血泪经验是这么来的:早些年接过一个渠道,回调验签一直过不去,我把参数打印出来跟文档对比了一下午,最后发现是对方文档里写的字段名和实际推送的差了一个下划线。从那以后我每次接新渠道,第一步都是先写一个只打印原始报文、不做任何处理的回调入口,把真实报文抓下来再写验签逻辑,绝不照着文档闭眼写。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询