ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# .NET支付接口对接实战:签名、异步回调、幂等与对账

C# .NET支付接口对接实战:签名、异步回调、幂等与对账

简介:面向C#/.NET开发者,整理了一套拉卡拉支付接口的接入与联调参考包,可用于在线支付、条码支付、交易查询、退款申请及异步回调通知等业务场景,也适合需要理解支付签名、证书验签与异常处理流程的中级开发者查漏补缺。压缩包共78个文件、约5.65MB,以23个cs源码、13个txt报文与说明、9个dll依赖、8个xml配置及证书密钥文件为主,另有WindowsForms示例工程、SDK包和详细使用说明,目录覆盖测试环境参数、请求响应报文、退款查询结果等环节。已有1006人学习,读者可结合示例代码与成功、失败、处理中的报文样本,快速梳理订单创建、参数签名、支付结果回调、退款申请与查询的实现思路,并借助测试环境商户终端号、RSA私钥及cer/crt证书完成本地联调与问题定位,减少对接第三方支付时的反复试错。

1. C# .NET 支付接口不是「调个 URL」,这份压缩包先把边界摊开了

把 C# .NET 支付接口当成一次普通的 HttpClient 调用,是绝大多数翻车现场的开端。这个压缩包里装的是一套完整的对接代码和报文封装,核心解决三件事:请求参数怎么按规则排序拼串、MD5 与 RSA 两条签名路线怎么在托管环境里跑通、同步响应和异步回调怎么保证一次不多一次不少。它适合已经接单但被「签名错误」「通知丢失」「金额对不上」折腾过的 .NET 开发者,也适合第一次给项目加支付模块、想先看清楚协议边界再动手的人。协议本身不复杂,复杂的是那些写在文档角落、不踩一遍不会信的细节。

2. 报文签名:把「签名错误」按在本地解决

签名是整条链路里最容易出错、也最容易被误判的一环。网关返回的永远是一句笼统的「签名验证失败」,不会告诉你是排序错了、编码错了,还是密钥用错了。压缩包里最值钱的部分,其实是那层独立的签名封装——它把签名从网络请求里剥离出来,可以单独跑、单独比对,把问题挡在发请求之前。

2.1 请求报文的三层结构与参数排序规则

支付网关的请求报文基本可以拆成三层。最上面是公共参数:商户号、接口名、版本号、字符集、签名方式、回调地址,这些每次都要带,且值相对固定。中间是业务参数:订单号、金额、商品标题、支付方式、用户标识,这一层因接口而异。最下面是签名本身,由前两层算出来,不参与自身的计算。

签名前的拼串规则几乎各家一致:把所有非空参数按参数名的 ASCII 码升序排列,用key=value的形式以&连接,末尾再拼上商户密钥或走私钥签名。规则简单,坑全在细节里。第一,sign和sign_type两个字段必须剔除,有些实现漏剔sign_type,本地算出来和网关永远对不上。第二,空值要过滤,但0和"0"不能当空值处理,金额为 0 的测试单最容易在这里出问题。第三,排序要用固定的文化无关比较器,StringComparer.Ordinal而不是默认的字符串比较,否则在不同区域设置下大小写混排的参数顺序会漂。

我一般会把拼串和签名做成两个独立方法,中间把拼出来的原文打日志。联调阶段这份原文比什么都管用——拿它去和网关提供的签名工具做逐字符比对,一眼就能看出是排序问题还是密钥问题。上线后这份日志要关掉或降级,因为原文里含密钥。

注意:拼串结果里不要出现 URL 编码后的值。签名针对的是原始值,编码只在最终发请求时做一次,顺序反了就会得到两个完全不同的签名。

2.2 MD5 与 RSA 两条签名路线的选型与实现差异

压缩包里同时给了 MD5 和 RSA 两种签名模式,这不是为了炫技,而是历史包袱和现实需求的折中。MD5 是共享密钥模式,商户和网关各存一份相同的 key,签名就是把拼好的串加上 key 再做一次 MD5,结果转大写十六进制。优点是实现简单、性能好、不需要证书管理;缺点是密钥一旦泄露,任何人都能伪造请求,而且无法做不可否认性证明。RSA 是公私钥模式,商户持私钥签名、网关用公钥验签,密钥泄露的风险面小得多,代价是要管证书、要处理 PEM 格式、要注意哈希算法。

在 .NET 里跑 MD5 很直接,MD5.Create()加ComputeHash就够。但要留意编码:拼串原文用什么编码转字节,决定了最终签名。有的网关要求 UTF-8,有的历史系统还是 GBK。.NET Core 默认不带 GBK 编码页,直接Encoding.GetEncoding("GBK")会抛异常,必须先注册CodePagesEncodingProvider。这个坑我在至少两个项目里见过,报错信息还特别不直观。

RSA 这边,.NET 5 之后有了RSA.ImportFromPem,PKCS#1 和 PKCS#8 都能直接读,代码干净很多。.NET Framework 上没有这个 API,要么用X509Certificate2加载 pfx,要么把 PKCS#8 手工拆成RSAParameters,后者写起来很啰嗦。压缩包里对两种运行时都做了条件编译分支,这一点做得比较扎实。哈希算法上,SHA1withRSA 还在不少老网关上跑,新接入一律建议 SHA256withRSA,签名填充统一用 PKCS#1 v1.5,不要用 PSS,除非文档明确要求。

2.3 在 .NET 里把签名跑通并做自校验

先看拼串这一步。用SortedDictionary加Ordinal比较器,天然保证顺序;过滤逻辑写在遍历里,避免先建副本再删键。

// 拼接待签名字符串:剔除 sign / sign_type,过滤空值,按 ASCII 升序 public static string BuildSignSource(IDictionary<string, string> parameters) { var sb = new StringBuilder(); foreach (var kv in parameters .Where(p => p.Key != "sign" && p.Key != "sign_type") .Where(p => !string.IsNullOrEmpty(p.Value)) .OrderBy(p => p.Key, StringComparer.Ordinal)) { sb.Append(kv.Key).Append('=').Append(kv.Value).Append('&'); } return sb.Length == 0 ? string.Empty : sb.ToString(0, sb.Length - 1); }

OrderBy用了StringComparer.Ordinal,保证不受当前线程区域设置影响;sb.Length - 1去掉末尾多余的&。参数说明:传入的字典必须是原始值,不能是已经 URL 编码过的,否则签出来的结果和网关对不上。

MD5 签名和它的编码依赖:

// .NET Core 下必须先注册编码提供程序,否则 GBK 取不到 static Signer() { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } public static string Md5Sign(string source, string key, string charset) { var enc = Encoding.GetEncoding(charset); // "UTF-8" 或 "GBK" var raw = source + "&key=" + key; // 密钥拼接方式以文档为准 using var md5 = MD5.Create(); var hash = md5.ComputeHash(enc.GetBytes(raw)); var sb = new StringBuilder(hash.Length * 2); foreach (var b in hash) sb.Append(b.ToString("x2")); return sb.ToString().ToUpperInvariant(); // 多数网关要求大写 }

这里的charset必须和报文里声明的_input_charset保持一致。我见过一次线上事故,报文声明 UTF-8,签名却用了系统默认编码,本地测试全过,一到含中文商品名的订单就挂。

RSA 签名:

// SHA256withRSA,PKCS#1 v1.5 填充,.NET 5+ public static string RsaSign(string source, string privateKeyPem, string charset) { using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); // 支持 PKCS#1 / PKCS#8 var data = Encoding.GetEncoding(charset).GetBytes(source); var sig = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(sig); }

ImportFromPem能自动识别BEGIN RSA PRIVATE KEY和BEGIN PRIVATE KEY两种头,省掉手工判断。如果项目还在 .NET Framework 上,这段要换成X509Certificate2加载 pfx 后取GetRSAPrivateKey()。

自校验的做法很简单:本地算完签名后,用同一套规则把参数重新拼一遍原文,再和日志里那份原文做String.Equals逐字节比较;RSA 模式下再用公钥验一次签。这一步花不了几十行代码,但能省掉至少半天的联调扯皮。

3. 下单、查询、退款:三个核心方法在 .NET 里怎么落地

签名通了只是拿到入场券,真正决定这套接口能不能上生产的,是下单、查询、退款这三个方法的工程化程度。压缩包把三个接口抽象成了统一的Execute入口,参数组装和响应解析分离,这个结构值得保留,因为它让「换网关」变成改配置而不是改业务代码。

3.1 统一下单:参数组装、编码与同步响应解析

下单请求要带的东西不少:商户号、订单号、金额、商品标题、回调地址、支付方式、超时时间。金额单位是最容易出错的地方——网关基本都用「分」作为整数传输,业务层用的是「元」的decimal。转换写法看着简单,实际有讲究。

// 元转分:必须用 decimal 运算并显式取整,禁止先转 double public static string YuanToFen(decimal yuan) { if (yuan < 0) throw new ArgumentOutOfRangeException(nameof(yuan)); var fen = decimal.Round(yuan * 100m, 0, MidpointRounding.AwayFromZero); return decimal.ToInt64(fen).ToString(CultureInfo.InvariantCulture); }

用double做这一步,19.99 * 100可能得到1998.9999999999998,截断后变成 1998,少收一分钱。CultureInfo.InvariantCulture是为了防止在某些区域设置下小数点变成逗号。

组装的写法:

var req = new SortedDictionary<string, string>(StringComparer.Ordinal) { ["service"] = "trade.pay", ["partner"] = options.MerchantId, ["_input_charset"] = "UTF-8", ["sign_type"] = "RSA", ["out_trade_no"] = orderNo, ["total_fee"] = YuanToFen(order.Amount), ["subject"] = Truncate(order.Subject, 64), ["notify_url"] = options.NotifyUrl, ["version"] = "1.0" }; req["sign"] = Signer.RsaSign(Signer.BuildSignSource(req), options.PrivateKeyPem, "UTF-8");

两个细节:subject要做长度截断,网关对标题字段通常限制 64 或 128 字节,按字节算不是按字符算,中文一个字三字节;notify_url必须是公网可达的绝对地址,不能带自定义端口之外的怪东西,也不能是内网地址。同步响应的解析则要做防御式处理,先判断返回体是不是 JSON,再判断is_success或ret_code字段,最后才取业务字段,任何一步不满足都抛带原始报文的自定义异常,方便排查。

3.2 订单查询与退款:状态机、金额单位与幂等

查询接口是整个支付链路里的「后悔药」。下单超时、回调丢失、网络中断,最后都得靠查询把状态捞回来。实现上要注意两点:查询结果的状态要和本地订单状态做映射,而不是直接覆盖;查询本身要设置退避重试,但重试次数和间隔要有上限。

// 查询带指数退避,只对网络异常和明确的「处理中」重试 public async Task<PayStatus> QueryWithBackoffAsync(string orderNo, CancellationToken ct) { var delays = new[] { 0, 200, 500, 1000, 2000 }; Exception? last = null; foreach (var d in delays) { if (d > 0) await Task.Delay(d, ct); try { var r = await _gateway.QueryAsync(orderNo, ct); if (r.Code == "PROCESSING") continue; // 明确处理中才继续等 return r.Status; } catch (HttpRequestException ex) { last = ex; } catch (TaskCanceledException ex) when (!ct.IsCancellationRequested) { last = ex; } } throw new PayQueryException($"查询未决,订单 {orderNo}", last); }

注意TaskCanceledException的过滤条件:调用方主动取消不应该被当成超时重试,这个分支写错会导致取消信号被吞掉。

退款比下单更需要幂等。同一个退款请求重复提交,网关侧通常靠「退款单号」去重,但本地也必须先落库再发请求,并用唯一索引挡住重复。退款金额同样用分,且必须校验不超过原订单金额减去已退金额。状态机建议只允许PAID → REFUNDING → REFUNDED单向流转,退款中不允许再次发起退款,否则并发下很容易出现超额退款。

3.3 HttpClient 的连接池、超时与 TLS 配置

很多人在这块翻车,是因为把HttpClient当成了using一次性的对象。每次 new 一个再 dispose,底层 socket 会进入 TIME_WAIT,高并发下端口耗尽,表现是偶发的连接超时。

// 单例或 IHttpClientFactory 管理,禁用每次 new public static HttpClient CreatePayClient(TimeSpan timeout) { var handler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), // 跟上 DNS 变更 MaxConnectionsPerServer = 64, ConnectTimeout = TimeSpan.FromSeconds(5), AutomaticDecompression = DecompressionMethods.None }; return new HttpClient(handler) { Timeout = timeout }; }

PooledConnectionLifetime设成 5 分钟,是为了让连接定期重建,避免 DNS 解析结果变了但连接池还捧着旧 IP。整体超时建议给 10 到 15 秒,连接超时单独给 5 秒,两者分开配比一个笼统的 30 秒有用得多。

还在 .NET Framework 上的项目,TLS 必须显式指定,否则可能协商到已被禁用的旧版本:

ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; ServicePointManager.DefaultConnectionLimit = 64;

DefaultConnectionLimit默认只有 2,不做调整的话,支付高峰期的排队延迟会非常难看。

4. 异步通知与对账:把「掉单」的概率压下去

同步响应只能说明请求发出去了,真正的支付结果在异步通知里。回调没处理好,最常见的结果是用户付了钱、订单还是待支付,客服电话第二天就来了。

4.1 异步通知接收端:验签、幂等与应答体

回调接收端要做四件事,顺序不能乱:验签、校验金额、幂等落库、返回应答。任何一步的顺序错了都会出问题,比如先落库再验签,等于给了伪造请求一个写库的机会。

[HttpPost("pay/notify")] public async Task<IActionResult> NotifyAsync() { var form = await Request.ReadFormAsync(); var dict = form.ToDictionary(k => k.Key, v => v.Value.ToString()); if (!Signer.Verify(dict, _options.GatewayPublicKeyPem, out var reason)) { _logger.LogWarning("回调验签失败: {Reason}", reason); return Content("fail"); } if (!dict.TryGetValue("total_fee", out var fenStr) || !long.TryParse(fenStr, out var fen)) return Content("fail"); // 返回 false 说明已处理过,直接应答成功,不再重复入账 var first = await _orderService.MarkPaidIfAbsentAsync( dict["out_trade_no"], fen, dict.GetValueOrDefault("trade_no")); return Content("success"); }

应答体是纯文本success,不是 JSON,也不是{"code":0}。这个细节文档里常常一笔带过,写错了网关会持续重推,最长能推好几天。验签时用的是网关公钥,注意参数要先剔除sign和sign_type再拼串,规则和发起请求时完全一致。金额校验不能省——验签只能证明请求来自网关,不能证明金额没被篡改过业务含义。

4.2 对账文件下载与差异核对

异步通知再可靠也有丢的时候,T+1 的对账文件是最后的兜底。流程是:按日期请求对账文件地址、下载、校验文件摘要、逐行解析、和本地订单做双向比对。

// 下载并校验摘要,摘要不一致直接放弃本轮对账 public async Task<string> DownloadBillAsync(DateTime billDate, CancellationToken ct) { var meta = await _gateway.GetBillUrlAsync(billDate, "TRADE", ct); var bytes = await _http.GetByteArrayAsync(meta.Url, ct); using var md5 = MD5.Create(); var actual = Convert.ToHexString(md5.ComputeHash(bytes)); if (!actual.Equals(meta.Md5, StringComparison.OrdinalIgnoreCase)) throw new BillTamperedException($"对账文件摘要不符: {billDate:yyyyMMdd}"); return Encoding.UTF8.GetString(bytes); }

比对要双向:本地有、对账文件没有的(可能本地误标成功),对账文件有、本地没有的(典型的掉单)。前者要人工介入,后者可以走自动补单,但补单动作必须记审计日志。文件格式各家不同,CSV 居多,注意有的文件带 BOM、有的字段用制表符分隔、有的金额列本身带千分位。

4.3 日志脱敏与排障留痕

支付日志的价值在于事后能还原「那一刻到底发了什么、收回了什么」,但原始报文里全是敏感信息。

// 只保留可排查的最小信息,敏感字段一律打码 private static readonly string[] SensitiveKeys = { "sign", "key", "card_no", "id_no", "phone", "buyer_id" }; public static string Mask(IDictionary<string, string> dict) { var sb = new StringBuilder(); foreach (var kv in dict.OrderBy(k => k.Key, StringComparer.Ordinal)) { var v = SensitiveKeys.Contains(kv.Key, StringComparer.OrdinalIgnoreCase) ? "***" : kv.Value; sb.Append(kv.Key).Append('=').Append(v).Append(';'); } return sb.ToString(); }

留痕的关键是带上三个东西:请求流水号(本地生成,贯穿全链路)、耗时、网关返回码。有了流水号,一次客诉能在几十秒内定位到具体哪次请求、哪次回调。

5. 避坑排查:四条从联调现场捡回来的经验

下面这四条,每条都在真实联调里出现过不止一次,共同点是报错信息完全不指向根因。

5.1 现象:本地签名校验通过,线上一直报签名错误

现象是本地跑单测签名全绿,一部署到服务器,网关返回签名失败,且只在部分订单上出现。

原因基本锁定在字符集。本地开发机的默认编码和服务器不同,或者代码里Encoding.Default在容器环境下拿到了 UTF-8,而报文声明的是 GBK;另一种情况是商品标题里带了中文或全角符号,编码不一致时签名原文的字节序列就变了。还有一类更隐蔽:反向代理或网关层对请求体做了转码。

解决办法是把字符集从「隐式」改成「显式」。所有编码转换的地方都从配置读charset,绝不依赖Encoding.Default;签名原文在 debug 级别打日志(脱敏后),拿它和网关的签名工具做逐字节比对;上线前用含中文、含全角标点、含 emoji 的三组标题各跑一遍。这三组数据能覆盖九成以上的编码问题。

5.2 现象:回调偶尔收不到,服务端日志里连请求都没有

现象是大部分订单回调正常,个别订单永远等不到通知,重推也没有,网关后台显示已推送。

原因通常有两层。第一层是回调地址的可达性:地址指向的域名解析到了内网,或者防火墙只放行了特定源站,网关侧推送时连接超时。第二层是处理超时:回调处理器里做了耗时操作(比如同步发短信、同步调第三方),网关等待应答的时间通常只有几秒,超时后它认为推送失败,但你的处理其实已经执行了一半,下次重推时又走一遍。

解决办法是把回调处理器做成「最快返回」:验签、落库、返回success,其余动作全部扔进消息队列异步做。回调接口单独配一个不经过鉴权中间件的路由,避免被全局过滤器拦掉。另外在网关后台把重推策略打开,同时本地做定时补偿查询,双保险。

5.3 现象:退款金额差了 100 倍

现象是退款 1 元,实际退了 100 元,或者反过来退成了 0.01 元。

原因几乎都是单位换算。业务层存的是decimal元,传给网关要转成分;如果某处用了(int)(amount * 100)而amount是double,浮点误差会让结果偏移一分;如果某处忘了乘 100,1 元就变成 1 分;如果某处乘了两次 100,就变成 100 元。最坑的是这几种错误在金额较小时不一定触发,测试用例用 0.01 元跑不出来,用 19.99 元才暴露。

解决办法是把单位换算收敛到一个静态方法里,全项目只允许调它,代码评审时看到散落的* 100一律打回。数据库里金额字段统一用整数分存储,展示层再转元。单元测试的用例固定包含 0.01、0.1、19.99、100.00、999999.99 这几个值,覆盖边界和进位。

5.4 现象:并发下单出现重复订单号或重复扣款

现象是压测或大促时,同一个用户短时间内产生两笔相同订单号的支付,或者退款被重复提交。

原因是订单号生成依赖了时间戳加随机数但没做唯一约束,或者业务层做了「先查再插」但没加锁,并发下两个线程同时查到不存在、同时插入。退款那边则是按钮没有防重、接口没有幂等键。

解决办法是三层防护:数据库给订单号建唯一索引,插入冲突直接抛异常由上层重试生成新号;接口层用分布式锁或INSERT ... ON CONFLICT语义保证同一业务单号只处理一次;退款必须带本地生成的退款单号并建唯一索引,重复提交直接返回上一次的结果。压测时专门跑「同一用户 1 秒内 20 次下单」这个场景,能提前把这类问题逼出来。

6. 上线前的一次全链路自检,比任何文档都管用

接口对接完、单测跑绿,很多人就直接上生产了。我的习惯是上线前跑一遍完整的自检清单,把能在本地暴露的问题全部暴露掉,而不是等第一批真实用户来当测试。这一步大概花两小时,能省掉的返工通常以天计。

自检的核心思路是「用最小的真实代价走完全部分支」。准备一个金额为 0.01 元的商品,一个能公网回调的测试地址,一份测试商户证书,然后按下面的顺序走:

检查项验证方法通过标准
签名正确性用网关签名工具比对本地的签名原文逐字符一致
编码边界商品标题带中文、全角标点、emoji下单成功且金额正确
金额换算0.01 / 0.1 / 19.99 / 999999.99 四组网关收到的分值完全一致
同步响应断网、超时、返回非 JSON抛出自定义异常且带原始报文
异步回调手动重放同一份回调三次只入账一次,三次都返回 success
回调验签篡改金额后重放拒绝处理并记录告警
掉单补偿关闭回调地址后支付,等 T+1对账能查出差异并补单
日志脱敏检索日志关键字无明文密钥、无完整卡号手机号

这张表里最容易被跳过的是「掉单补偿」和「日志脱敏」。前者要等到第二天才能验证,很多人就懒得等;后者在出事后才发现日志里躺着一堆敏感信息,删也不是留也不是。我一般会把补偿验证做成一个可手动触发的定时任务,上线当天就手工跑一次,不等 T+1。

再补一个具体技巧:给每个请求生成一个本地流水号,塞进请求的扩展字段(网关一般都允许带out_request_no之类的透传字段),同时写进日志、写进订单表。出问题时用一个流水号就能把「请求 → 网关响应 → 回调 → 对账」四段串起来,比按时间戳大海捞针快一个数量级。

// 流水号贯穿全链路,长度控制在 32 以内,避免超出网关字段限制 public static string NewTraceId() => $"{DateTime.UtcNow:yyyyMMddHHmmssfff}{Random.Shared.Next(1000, 9999)}";

那段经历之后,我每次接新的支付通道,无论文档写得多清楚,都会先把签名和金额这两块单独拎出来写自检脚本跑通,再动业务代码。血泪经验是:文档里没写的那部分,才是真正决定上线顺不顺的部分。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进