首页 / 资讯中心 / 文章详情

C# .NET对接拉卡拉支付接口:签名、回调幂等与T+1对账实战

C# .NET对接拉卡拉支付接口:签名、回调幂等与T+1对账实战 ★ FEATURED ARTICLE
简介面向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# 业务系统、需要把支付通道接进去的一线开发者从拆包选型一路讲到签名、下单、回调、排错和 T1 轧账。新手照着抄能跑通熟手可以重点看第 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 { // 统一下单返回二维码内容或跳转地址 TaskCreateOrderResult CreateAsync(CreateOrderInput input, CancellationToken ct default); // 主动查询回调丢了靠它兜底 TaskQueryOrderResult QueryAsync(string outTradeNo, CancellationToken ct default); // 退款 TaskRefundResult RefundAsync(RefundInput input, CancellationToken ct default); // 验证异步通知的签名返回解析后的字段 bool TryParseNotify(IDictionarystring, 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不是当前区域文化拼成k1v1k2v2...knvn末尾不加末尾追加key商户密钥有些网关是直接拼密钥不加key一定以包里的文档为准对整串做 MD5 取 32 位大写或者做 SHA256withRSA 再 Base64。第 2 步是最容易翻车的地方。C# 的OrderBy(x x.Key)默认走Comparerstring.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(IEnumerableKeyValuePairstring, 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(); // 形如 a1b2 } /// summaryMD5 模式源串 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(); } /// summaryRSA 模式SHA256 PKCS#1 v1.5输出 Base64/summary public static string RsaSha256(string source, string privateKeyPem) { using var rsa RSA.Create(); rsa.ImportFromPem(privateKeyPem); // .NET 5Framework 需用证书或第三方库导入 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参数看着多余实际很关键。接口包文档里如果写的是charsetGBK那你必须用 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这种带千分位的形态。字符集。签名用的字节集和传输用的字节集必须一致。如果charsetUTF-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 ILoggerGatewayClient _logger; public GatewayClient(HttpClient http, IOptionsGatewayOptions opt, ILoggerGatewayClient logger) { _http http; _opt opt.Value; _logger logger; } public async TaskCreateOrderResult CreateAsync(CreateOrderInput input, CancellationToken ct default) { var p new Dictionarystring, 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.DeserializeCreateOrderResult(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 TaskIActionResult 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重复通知或者订单不存在/状态异常。这两种情况都直接返回成功不要抛异常让网关继续重试。发货、加积分这些副作用放在事务提交之后用领域事件或者简单的“本地消息表 定时任务”来保证最终一致。兜底查询交给一个定时任务扫描statusPENDING且创建时间超过 2 分钟但不到 24 小时的订单逐笔调QueryAsync。频率别太高我一般用 1 分钟一轮、每轮限流 50 笔加指数退避。这个任务跑起来之后“用户付了钱但订单没变状态”的客诉会少掉九成。5. 排查清单上线前最容易翻车的五类现场5.1 签名类算出来和网关对不上现象一所有订单都返回签名错误但把参数贴进官方 Demo 里算结果又一样。原因多半在待签名串的可见性上——你以为自己拼的是a1b2实际拼出来的可能是a1b2或者中间夹了一个你没注意到的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 statusPENDING并检查影响行数或者给out_trade_no加唯一索引让数据库来兜底问题立刻消失。血泪经验是任何涉及钱的写操作都不要用“先查后改”要么用条件更新要么用行锁。金额单位的问题也归到这一类。现象是“订单金额 1 元实际扣了 100 元”或者反过来。原因通常是某一段链路里把“分”当“元”用了而且因为100 * 100 10000这种巧合测试时用小额0.01 元反而没暴露出来。我的习惯是所有变量名一律带单位后缀amountFen、amountYuan看到不带后缀的amount就当成代码异味处理。6. 对账与上线验证用 T1 对账单把账轧平接口跑通只能说明“能付钱”能对上账才说明“能长期跑”。支付通道的 T1 对账单一般是一个按行分隔的文本文件字段包含商户订单号、通道流水号、交易金额、手续费、交易状态、交易时间。我一般先做一张字段映射表把对账单字段和本地订单表一一对上再写轧账逻辑对账单字段本地字段校验规则商户订单号out_trade_no必须能查到查不到记为「长款」通道流水号channel_trade_no与回调写入的一致交易金额分amount_fen严格相等不做容差手续费分fee_fen仅记录参与利润核算交易状态statusSUCCESS对应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而我们的站点强制跳 httpsPOST 请求被 301 成了 GET参数全丢。从那以后我每次接新通道都强制走一遍「回调地址可达性验证 报文原始日志开关 对账任务空跑三天」这三步哪怕需求再急也不跳。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站