资讯详情

资讯详情

.NET老后台接入拉卡拉扫码支付:C#封装、签名回调与幂等对账实战

简介面向 C#/.NET 开发者的拉卡拉支付接口集成示例包聚焦条码支付、交易查询、退款申请与退款查询等典型收单场景帮助有 .NET 基础、需要对接第三方支付的开发者解决签名验签、报文收发与回调通知处理的落地问题。压缩包共 78 个文件、约 5.65MB其中 23 个 cs 源码文件构成 WinForms 客户端与接口封装主体另有 13 个 txt 记录微信、支付宝支付成功、支付中及退款成功、失败的真实报文样例9 个 dll 为依赖库8 个 xml 与 4 个 config 提供配置与工程说明并附 crt、cer、key 等测试环境证书与密钥。目前已有 1006 人学习下载。内容按测试环境参数、报文样例、示例工程三层组织RSA 私钥签名、cer 验签、商户终端号与 appId 等测试凭据齐备配合使用说明可对照报文快速核对字段与返回码减少联调反复试错适合作为支付模块接入与接口调试的参考实现。1. 接手一个 .NET 老后台要接扫码付别人丢来一个 zip去年帮朋友收拾一个跑了七八年的 .NET Framework 后台业务突然要加扫码付和被扫付。运维把 拉卡拉支付接口.zip 丢过来一句话“之前有人接过你看着弄。”解压后没有 README只有一套 C# 类库、两个 Demo 工程和一份字段表 PDF。签名规则我来回对照三遍才对上号第一次下单直接返回“验签失败”查了半天是参数排序用了区域相关的比较器。这套东西的价值很实在它把商户号配置、参数拼串、MD5/RSA 签名、HTTP 提交、回调验签这些零碎活儿封成了能直接 new 出来用的 C# 类省掉从文档逐字翻译成代码的那两天。适合手上已有 .NET 项目、要在短时间里把支付通道接进去的后端不适合想找现成收银台前端的人。2. 项目定位与技术选型这套 C# 封装里到底装了什么先给结论它不是一套完整的支付系统而是一个“通道适配层”的种子工程。真正能复用的只有三块——配置对象、签名工具、HTTP 提交与响应解析剩下的 Demo 页面是给你照着改的样板。想清楚这件事很重要因为很多人解压后第一反应是“怎么就这几个文件”然后试图把它当框架用结果在状态管理和幂等上栽跟头。2.1 解压后的目录骨架不同人打包习惯不一样但这类接口包的结构基本跑不出这个形状LakalaPay/ ├── LakalaPay.Sdk/ # 核心类库唯一需要引用的项目 │ ├── Core/ │ │ ├── PayClient.cs # 对外入口下单、查询、退款、关单 │ │ ├── SignHelper.cs # 拼串、排序、MD5/RSA 签名与验签 │ │ └── HttpHelper.cs # POST/GET 封装超时与编码在这里定 │ ├── Models/ │ │ ├── PayRequest.cs # 下单入参字段和文档一一对应 │ │ ├── PayResponse.cs # 同步返回体 │ │ └── NotifyModel.cs # 异步回调报文的强类型映射 │ └── Config/ │ └── PayConfig.cs # 商户号、终端号、密钥、网关地址 ├── LakalaPay.Demo.Web/ # 下单页 回调接收页 查询页 ├── LakalaPay.Demo.Console/ # 最小可运行示例调通第一个请求靠它 └── docs/ # 字段表、错误码、签名说明目录/文件是否必须说明LakalaPay.Sdk必须引入自己项目或直接把源码拖进现有解决方案Demo.Console建议排查环境问题时的最小复现单元Demo.Web参考回调接收、页面跳转的写法样例docs必须字段长度、必填项、错误码以它为准别信代码注释我一般先把 Sdk 整个目录拷进现有解决方案而不是引 DLL。原因很实际出问题时能单步进签名函数比对着日志猜快十倍。2.2 技术栈与运行时约束这类包的技术选型基本是被“要能塞进老项目”这个需求逼出来的语言与框架C#目标框架通常是 .NET Framework 4.5 以上新的包会做成net45与netstandard2.0双目标好同时兼容老 WebForm 和新写的 .NET Core 服务。如果你的项目是 .NET 6/8直接用 netstandard 那个目标即可。序列化Newtonsoft.Json 占主流老包里也有手写字符串拼接的那种最容易在中文和特殊字符上翻车。网络层老代码用HttpWebRequest新一点用HttpClient。看到ServicePointManager.SecurityProtocol这行就说明作者踩过 TLS 的坑。加解密System.Security.Cryptography里的 MD5 与 RSA不引第三方库。没有 ORM、没有依赖注入容器。作者显然假定你只想 new 一个 client 出来调一下。提示先看 Sdk 的 csproj 里TargetFramework写的是什么。目标框架不匹配会让整个项目引用失败而这个报错信息往往指向别的方向很耽误时间。2.3 三类接入场景决定你要改哪里同一份代码三种用法的改造量差别很大第一种单体老后台加一个支付入口。改动最小把 PayClient 注册成静态单例下单接口塞进原有 Controller回调单独开一个不需要登录鉴权的 Action。半天能跑通。第二种已有订单系统支付只是其中一环。这时候要动的是状态机——订单从“待支付”到“已支付”的流转必须由回调驱动而不是由前端跳转页驱动。前端跳转只是给用户看的不能作为发货依据。第三种多通道并存以后可能还要接别家。那就别直接用 PayClient先抽一层接口把拉卡拉当成一个实现。这一步在第 6 章展开。大部分人是第一种但代码按第二种的严谨度写后面省事。3. 核心链路拆解一次支付请求从下单到回调支付接不接得通就看四件事签名对不对、报文格式对不对、回调验不验得过、金额单位有没有搞错。这一章按调用顺序过一遍代码都是照着包里的结构重写的可以直接对号入座。3.1 参数组装与签名排序、剔空、编码三件事签名函数是整个包里最不能出错的一段。常见做法是取所有非空业务字段按参数名升序拼成k1v1k2v2的形式末尾追加密钥再算 MD5。public static class SignHelper { /// param nameparas业务参数不含 sign 本身/param /// param namekey商户密钥只在服务端持有绝不下发前端/param public static string BuildSign(IDictionarystring, string paras, string key) { // 1. 剔除空值null 和空字符串都不参与签名服务端也是这个规则 // 用 Ordinal 排序避免中文/土耳其语等区域设置影响比较结果 var pairs paras .Where(p !string.IsNullOrEmpty(p.Value) p.Key ! sign) .OrderBy(p p.Key, StringComparer.Ordinal) .Select(p p.Key p.Value); // 2. 拼串后追加密钥注意这里没有等号 var raw string.Join(, pairs) key key; // 3. MD5 取 32 位十六进制大小写按文档要求来多数通道要大写 using (var md5 MD5.Create()) { var hash md5.ComputeHash(Encoding.UTF8.GetBytes(raw)); var sb new StringBuilder(32); foreach (var b in hash) sb.Append(b.ToString(X2)); return sb.ToString(); } } }几个参数必须说清楚。StringComparer.Ordinal不能省OrderBy(p p.Key)走的是当前线程的区域文化在某些服务器上排序结果和 PHP/Java 写的对端不一致签名必然失败而且这种失败只在你部署到那台机器上才出现本地永远复现不了。Encoding.UTF8也要写死别用Encoding.Default老服务器默认 GBK中文商品名一进去签名就歪了。空值剔除的规则必须和对端一致——你剔他不剔字符串长度都不同。最后MD5 结果的大小写、密钥拼接用不用等号这两个细节只有文档说了算不同产品线确实不一样别凭记忆抄别家的。还有一种包用的是 RSA 私钥签名、公钥验签。函数骨架一样只是把 MD5 换成RSACryptoServiceProvider.SignData密钥从字符串变成 PEM 或 PFX 文件。这时候密钥文件的加载路径、密码保护、每次请求是否重复加载都是要盯的点。3.2 统一下单报文、超时、日志三件套签名算好之后组装请求体发出去。这段代码看起来最简单出问题最多。public PayResponse UnifiedOrder(PayRequest req) { var paras req.ToDictionary(); // 业务字段转字典保持字段名与文档一致 paras[sign] SignHelper.BuildSign(paras, _config.Key); var json JsonConvert.SerializeObject(paras, Formatting.None, new JsonSerializerSettings { NullValueHandling NullValueHandling.Ignore }); // 原始报文一定要落日志脱敏后保留出问题唯一能救命的证据 _logger.Info(pay.request outTradeNo{0} body{1}, req.OutTradeNo, Mask(json)); var resp HttpHelper.PostJson(_config.Gateway, json, _config.TimeoutSeconds); var result JsonConvert.DeserializeObjectPayResponse(resp); if (result null || !SignHelper.Verify(result.ToDictionary(), _config.Key)) throw new PayException(响应验签失败可能是网关地址或密钥不匹配); return result; }TimeoutSeconds一般给 10 到 15 秒别设成默认的 100 秒——支付请求卡住时用户早就点第二次了堆在后面的线程只会把线程池拖垮这类玄学问题排查起来非常费劲。日志里Mask是脱敏函数要把密钥、卡号、手机号中间位打码但商户订单号、金额、返回码必须原样保留对账时全靠这几个字段。响应验签这一步很多人省掉觉得“能返回成功码就行”其实网关地址填错、被中间层改写响应时验签是唯一能发现异常的手段。返回体里通常有业务码和渠道流水号OutTradeNo我方订单号和渠道流水号要一起入库查单和退款都要用。3.3 异步回调验签、幂等、应答字符串回调是整条链路里最容易出事的一环因为它不由你控制节奏。[HttpPost] public void Notify() { string body; using (var reader new StreamReader(Request.InputStream, Encoding.UTF8)) body reader.ReadToEnd(); var dict ParseToDictionary(body); // 兼容 form 与 json 两种格式 if (!SignHelper.Verify(dict, _config.Key)) { _logger.Warn(notify sign fail: {0}, body); Response.Write(fail); // 验签失败不能返回成功否则丢单 return; } // 幂等同一笔订单只处理一次网关会因为超时重推 if (!_orderService.TryMarkPaid(dict[out_trade_no], dict[trade_no], dict[amount])) { Response.Write(success); // 已处理过直接应答成功让它别再推 return; } Response.Write(success); // 应答内容以文档为准通常是 success 或 OK }读 body 时用Encoding.UTF8显式指定别用Request.Form一把梭——部分通道发的是 JSONRequest.Form会是空的你会以为网关没推。应答字符串必须和文档完全一致多一个空格对端就可能判为未收到于是反复重推用户被反复通知发货。幂等判断要在业务处理之前做用唯一索引或UPDATE ... WHERE status 待支付这类原子操作别用“先查再改”并发下必然失守。金额也要校验回调里的金额和我方订单金额对不上时宁可报警也不要直接置为已支付。3.4 查询、退款与对账补偿链路同步返回和异步回调都可能丢所以主动查询接口是必需品。public PayResponse Query(string outTradeNo) { var paras new Dictionarystring, string { [out_trade_no] outTradeNo, [timestamp] DateTime.Now.ToString(yyyyMMddHHmmss) }; paras[sign] SignHelper.BuildSign(paras, _config.Key); return Post(/trade/query, paras); }查询接口一般做成定时补偿任务下单后 30 秒内没收到回调的订单每 30 秒查一次最多查 6 次仍未支付就置为超时关闭。退款同理退款是异步的接口返回“受理成功”不等于钱到账必须以退款查询结果为准。日终对账就是拿通道给的账单文件和我方订单表逐笔比对重点看三样金额、状态、渠道流水号差异单落到异常表人工处理。这部分代码包里通常没有得自己写但它是支付模块从“能跑”到“敢上线”的分界线。4. 跑起来环境、配置与最小可运行示例新手最怕的是改完配置一按 F5 就报一堆错。这一章按顺序走一遍从环境到第一个成功的响应。4.1 环境准备与依赖还原先确认三件事Visual Studio 2019 或更高、装了对应版本的 .NET Framework 开发组件、能联网还原 NuGet 包。打开解决方案后如果 Sdk 项目带感叹号多半是目标框架没装如果是 Newtonsoft.Json 找不到引用在包管理器控制台执行Update-Package -reinstall重装一遍最省事。# 包管理器控制台或命令行均可 dotnet restore LakalaPay.sln # 老项目走 nuget nuget restore LakalaPay.sln编译顺序上先单独编 Sdk它不依赖 Demo 里的任何东西。Sdk 过了再编 Console Demo。Web Demo 放到最后它牵扯 IIS Express 和回调地址配置出错面更大。4.2 配置项四个值决定能不能通老项目一般写在web.config或app.config的appSettings里新项目用appsettings.json。不管哪种核心就四项appSettings !-- 商户号通道分配登录商户后台可查 -- add keyLakala:MerchantNo value你的商户号 / !-- 终端号部分产品线必填没有就留空 -- add keyLakala:TerminalNo value你的终端号 / !-- 密钥只在服务端禁止出现在前端代码和日志明文里 -- add keyLakala:Key value你的密钥 / !-- 网关地址一定要区分测试与生产两者不能混用 -- add keyLakala:Gateway valuehttps://测试网关地址/统一入口 / /appSettings配置读取建议做一个小类包一层缺值时直接抛异常别让它默默取到 null 然后拼出一个没有签名的报文那种错误现场极难定位public static PayConfig Load() { var cfg new PayConfig { MerchantNo Require(Lakala:MerchantNo), TerminalNo ConfigurationManager.AppSettings[Lakala:TerminalNo] ?? , Key Require(Lakala:Key), Gateway Require(Lakala:Gateway), }; if (cfg.Gateway.Contains(测试)) cfg.IsSandbox true; // 打个标记防止误发生产 return cfg; }Required那种立即失败的设计是我被坑过之后固定下来的习惯——配置错就该在启动时炸别等到用户点了支付才炸。4.3 最小可运行示例先跑通一笔先把 SDK 跑通再回来看业务接入。static void Main() { var client new PayClient(PayConfig.Load()); var req new PayRequest { OutTradeNo T DateTime.Now.ToString(yyyyMMddHHmmssfff), // 我方订单号全局唯一 Amount 1, // 单位是分1 分钱做测试 Subject 测试商品, AuthCode , // 被扫时传用户付款码主扫留空 NotifyUrl https://你的可访问地址/pay/notify, // 必须公网可达 TradeType SCAN // 交易类型以文档为准 }; var resp client.UnifiedOrder(req); Console.WriteLine(code{0} msg{1} qr{2}, resp.Code, resp.Msg, resp.QrCode); }几个参数值得单独说。Amount单位是分写 1 就是 0.01 元这是资金类接口最常见的翻车点单位搞错就是一百倍事故上线前在代码里加一个断言或注释比事后对账强。OutTradeNo必须唯一且可回溯别用自增 ID 直接拼万一将来分库会撞号。NotifyUrl必须是外网能访问到的地址本地localhost收不到回调——调试时要把它指向一台有公网入口的测试服务器。AuthCode是被扫付款码主扫模式留空。被扫模式下还需要处理付款码的“数字被吞”问题付款码是 18 位左右、以 28 开头的数字串用 Excel 保存会变成科学计数法接口收到时位数都不对了。企业 O2O 接入场景里最常遇到的投诉就是这一类。4.4 本地自测把回调先演练一遍等真实回调是最被动的调试方式。更快的做法是手工构造一份回调报文自己 POST 给自己# 用 curl 模拟网关推送字段和签名按通道规则生成 curl -X POST http://localhost:8080/pay/notify \ -H Content-Type: application/x-www-form-urlencoded; charsetutf-8 \ -d out_trade_noT20240101000000001trade_no4200001234amount1statusSUCCESSsign算好的签名先跑一遍正确签名的确认业务逻辑走通再把 sign 改一个字符确认验签失败时返回的是失败应答而不是成功再故意推两次确认幂等生效、订单状态只变一次。这三种情况在真实环境里一定会发生提前演练一遍比上线后凌晨被电话叫起来强。测试网关通常有沙箱商户号具体申请方式看文档别拿生产商户号试付款码。5. 避坑与排查签名、超时、重复回调、金额四类事故下面几条都是支付接入里高频出现的现场按“现象、原因、解决”组织照着比对能省不少时间。5.1 一直返回验签失败本地却永远复现不了现象部署到测试服务器后所有请求都返回签名错误本地开发机一切正常代码一字未改。原因排序用了文化相关的比较器服务器上线程的区域设置和你本机不同导致拼串顺序不一致另一个常见来源是服务器默认编码不是 UTF-8中文商品名参与签名时字节不同。解决排序显式写StringComparer.Ordinal所有Encoding显式写UTF8正文字节数组自己算一遍长度。再不行就把参与签名的原始拼串脱敏后打到日志里和服务端返回的报文对比一眼能看出差在哪。5.2 请求偶发超时高峰期几乎必挂现象低峰期正常一到大促就大面积超时日志里全是请求挂起。原因HttpWebRequest的默认超时过长或者HttpClient每次请求都new一个连接和端口被耗尽也有 TLS 版本没启用的表现为与服务端握手阶段就失败。解决HttpClient用静态单例Timeout设 10 到 15 秒老代码在程序启动时加一行ServicePointManager.SecurityProtocol SecurityProtocolType.Tls12;超时后不要立刻重试下单改成走主动查询否则容易产生重复订单。5.3 用户被重复发货回调来了一堆现象同一笔订单收到了四五次回调仓库发了两次货客服被迫手工拦截。原因回调接收方在业务处理成功后才应答或者应答的不是文档规定的字符串网关判定失败就持续重推另一种是缺少幂等控制重推进来又被完整处理一遍。解决进回调第一件事是验签第二件事是原子性标记订单状态第三步才做发货等业务动作无论业务是否成功都按协议应答同时用out_trade_no建唯一索引兜底。5.4 金额差一百倍现象用户付了 1 元系统记账 100 元或者反过来。原因一方用分、一方用元中间还夹着小数点的四舍五入decimal转int时直接截断。解决全链路统一用“分”存整数只在展示层除以 100入参和回调都把金额单独打日志对账时先比金额再看状态。5.5 服务器时间漂移导致的时间戳校验失败现象同样的报文昨晚还好好的早上开始报时间戳非法。原因请求参数里带timestamp或expire_time服务器没开 NTP 同步时钟漂了几分钟对端判定超期。解决服务器上开时间同步用w32tm校正代码里的时间统一取一次生成别在一段逻辑里多次调用DateTime.Now。这个坑很隐蔽因为它时有时无跟业务量毫无关系。6. 二次开发把这套接口改成能长期维护的支付层接口包能跑通只是第一步。真正上线后要面对的是多通道、退单、对账、客服查单所以我会在原包外面再包一层。第一件事是抽接口。不要让业务代码里出现new PayClient(...)改成public interface IPaymentChannel { string ChannelName { get; } // 通道标识落库时记录 PayResult Create(PayOrder order); // 下单返回二维码或跳转串 PayResult Query(string outTradeNo); // 主动查询 RefundResult Refund(string outTradeNo, int amountInCent); // 退款单位统一为分 NotifyResult HandleNotify(IDictionarystring, string payload); // 回调验签与解析 }拉卡拉这套封装实现一个LakalaPaymentChannel以后接第二家只需再实现一个。业务层只认接口通道切换变成配置项。改造时注意三个点金额统一在入口转成“分”再往下传避免单位在层间漂移异常全部收敛成PayResult里的错误码别让HttpRequestException冒到业务层通道名和渠道流水号一起入库客服查单时按渠道去对应后台查。第二件事是补状态机。订单状态只允许由回调或查询驱动正向流转固定为“待支付 → 已支付 → 已退款”每次变更写一条流水带上来源回调/查询/人工。我一般会给状态变更加一个版本号做乐观锁这样并发回调进来只会有一个成功。第三件事是对账兜底。每天凌晨拉一次通道账单按out_trade_no与本地订单比对生成三张表本地有通道无可能漏推、通道有本地无可能串单、金额不一致。差异单不能自动改状态必须人工确认这条规则吃过亏——有次自动修正把一笔真实退款改回了已支付。第四件事是日志的可检索性。支付日志我固定打这几个字段outTradeNo、channel、stagerequest/response/notify/query、costMs、code一行一条 JSON。出问题时按订单号一搜就是完整链路不用再翻四五个日志文件拼时间线。最后说个我自己踩过的教训第一次接支付时我把“前端跳转回来的页面”当成了支付成功结果用户在跳转途中关掉浏览器订单一直挂着第二天对账才发现十几笔异常。从那以后我每次接新通道第一版代码里一定先写主动查询和幂等两条再写下单顺序不能反。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →