C#/.NET三渠道支付整合:统一抽象、回调幂等与对账
简介面向.NET开发者的多渠道支付整合资料包围绕C#接入微信、支付宝与银联支付展开覆盖JSAPI、Native、H5、APP、网页/扫码及快捷支付等场景重点解决预支付下单、异步回调验签、查询退款与统一接口封装等工程问题。压缩包约52.91MB文件总数显示为0且类型明细暂无数据实际内容以统一支付API层组织三方渠道交互逻辑便于将不同支付方式收敛为一致调用出口。已有1495人学习适合需要搭建多支付通道、梳理回调与错误处理的中级开发者参考。读者可从中获得多渠道SDK接入与商户配置思路、回调验签和异常处理框架、沙箱测试与合规注意事项以及可复用的支付服务分层设计用于项目对照排查和二次开发。1. 从一笔“支付成功但订单没发货”说起凌晨一点运维在群里贴了张截图用户订单状态还是「待支付」可渠道后台明明白白显示扣款成功。这种事故在同时接入微信、支付宝、银联的项目里几乎必然出现一次根因往往不是渠道有问题而是三条渠道的回调被三个人用三种写法处理谁都没做幂等谁都没记原始报文出事后连日志都翻不出来。这套 C#/.NET 支付整合方案要解决的就是这件事把三个渠道的下单、签名、回调、退款、对账收进一套统一抽象让业务代码只回答「这笔钱该不该发货」不用再关心签名串怎么拼、证书怎么轮换。它适合正在做支付模块毕设或课程设计的学生也适合要给既有系统补支付能力、又不想把三套 SDK 各抄一遍的开发者。下面按需求清单、总体架构、渠道实现、避坑排查、验收演示的顺序把这份方案完整拆开。2. 需求拆解与技术选型先把场景列全再定统一抽象支付模块最容易出的问题不是代码写不出来而是需求没列全就开始写。第一版只做了扫码下单上线两周后运营要退款财务要对账App 端要唤起支付于是三条渠道各自被改了三遍代码开始分叉。我一般会先把场景表格列出来再决定抽象层的边界。2.1 支付场景清单六个动作决定接口粒度把「用户要付钱」拆成可枚举的动作每一项都对应渠道的一个接口这样才能判断哪些能统一、哪些必须分开。场景触发方是否同步返回结果关键入参扫码下单PC 收银台商户后台是返回二维码串商户订单号、金额、标题、回调地址手机浏览器支付用户浏览器否跳转后靠回调同上 客户端 IPApp 唤起支付移动端否靠回调 客户端轮询同上 渠道需要的附加参数订单查询商户后台/定时任务是商户订单号退款申请运营后台是受理结果原订单号、退款单号、退款金额对账文件下载定时任务是账单日期表格里有一列很关键是否同步返回结果。扫码下单和查询是同步语义可以返回明确结果手机浏览器和 App 支付是异步语义接口只能返回「已受理」真实结果必须等回调。抽象层如果按渠道切就会出现「微信版退款」和「支付宝版退款」两套方法名按场景切六个方法就能覆盖三条渠道差异全部下沉到适配层内部。还有两个容易被漏掉的场景一是关单用户下单后放弃支付订单不能一直挂着占用库存需要主动调用渠道的关闭订单接口二是退款查询退款是异步的渠道受理不等于到账财务口径要的是「退款成功」而不是「退款受理」。这两个场景在毕设里经常被省略答辩时一问就露馅。2.2 三种接入方式对比为什么选 HTTP 直连自己封装确定场景后是选型。三条渠道在国内 .NET 生态里的支持程度并不一致常见三条路维度官方 SDKHTTP 直连 自封装聚合网关依赖体积中部分包依赖较重无额外依赖用 HttpClient中可控性受限于 SDK 暴露的方法完全可控报文可完整落库低报文被网关吞掉排错难度中异常信息被包装过低原始请求响应都在手上高出问题只能提工单升级成本跟随 SDK 版本节奏自行按文档调整字段跟随服务商适合场景快速验证需要长期维护的业务系统多商户分账等复杂诉求我一般选中间那条。原因不是官方 SDK 不好而是三家的示例和文档更新节奏不同遇到跨平台或依赖冲突时改 SDK 不如改自己那两百行签名代码。聚合网关看似省事但它是典型的黑匣子——签名失败你只能看到「参数错误」回调延迟你只能等对账口径还多一层转换。提示不管选哪条路证书和密钥的管理方式要提前定死。私钥放配置文件是最常见的翻车点至少在开发阶段就用环境变量或本地密钥文件并且把路径写进配置项。2.3 统一支付抽象一个接口定下三条渠道的契约抽象层的核心是一个接口加一组数据传输对象。接口只放场景方法不放渠道特性。// 三条渠道共同遵守的契约渠道差异全部收敛在实现类内部 public interface IPaymentChannel { string ChannelCode { get; } // WECHAT / ALIPAY / UNIONPAY // 下单同步场景返回二维码或跳转参数异步场景返回受理结果 TaskPrepayResult PrepayAsync(PrepayRequest request, CancellationToken ct default); // 主动查询用于回调丢失时的补偿 TaskQueryResult QueryAsync(string outTradeNo, CancellationToken ct default); // 退款申请只代表渠道受理不代表到账 TaskRefundResult RefundAsync(RefundRequest request, CancellationToken ct default); // 解析并验签回调返回统一结构验签失败抛 PaymentVerifyException TaskNotifyResult ParseNotifyAsync(NotifyContext context, CancellationToken ct default); }参数对象里有两个约定必须写死在注释里否则后面一定有人改错。第一金额统一用int表示「分」不做decimal也不允许出现「元」第二商户订单号由自己生成时间戳 业务前缀 随机位渠道订单号只作为附属字段存下来绝不用它做主键。NotifyContext里放原始请求体、请求头和查询串不要在解析前做任何反序列化——微信和银联的验签都依赖原始报文字节一旦被中间件重新序列化签名必然对不上这个问题后面还会专门讲。PrepayResult做成一个带Scene字段的容器扫码场景填QrCode浏览器场景填RedirectUrl或表单 HTMLApp 场景填渠道返回的参数字典。调用方按Scene分支处理而不是按ChannelCode分支这样新增第四条渠道时业务层一行不用改。3. 总体架构设计五层结构与四张表撑起一条支付链路抽象接口解决的是「怎么调」架构解决的是「谁来调、结果存哪、出错谁兜底」。支付链路的特殊性在于它是典型的「外部系统驱动内部状态」所以分层不能按常规的 Controller-Service-Dao 三段切必须把渠道适配单独拎出来。3.1 五层结构与依赖方向从上到下依次是收银台接口层、支付应用层、渠道适配层、基础设施层、存储层。依赖方向严格单向向下适配层不许反向调用应用层这一点决定了后面能不能单独对某条渠道做单元测试。层职责关键类型接口层暴露下单/查询/退款 API 和回调入口PayController、NotifyController应用层编排建单、调渠道、改状态、写流水、发事件PaymentAppService适配层签名、组包、解析、验签WechatPayChannel、AlipayChannel、UnionPayChannel基础设施HttpClient、签名器、配置、日志ISignatureProvider、IPayHttpClient存储层订单、流水、回调日志、退款单四张表 仓储依赖注入的注册方式决定了适配层能不能被无痛替换// 每个渠道注册为 IPaymentChannel 的具名实现工厂按 ChannelCode 取 builder.Services.AddSingletonIPaymentChannel, WechatPayChannel(); builder.Services.AddSingletonIPaymentChannel, AlipayChannel(); builder.Services.AddSingletonIPaymentChannel, UnionPayChannel(); builder.Services.AddSingletonPaymentChannelFactory(); // 渠道 HTTP 调用统一走命名客户端便于单独配置超时和代理策略 builder.Services.AddHttpClient(wechat-pay, c c.Timeout TimeSpan.FromSeconds(10)); builder.Services.AddHttpClient(alipay, c c.Timeout TimeSpan.FromSeconds(15));PaymentChannelFactory构造时注入IEnumerableIPaymentChannel按ChannelCode建字典找不到就抛异常而不是返回 null——支付链路上静默失败比崩溃危险得多。HttpClient 一定要用工厂而不是new支付接口的超时和重试策略与业务接口完全不同下单可以重试一次退款绝对不能自动重试必须靠查询确认。3.2 数据表设计订单、流水、回调、退款四张表四张表各自的职责要分清最常见的错误是把渠道流水和业务订单揉成一张表结果一个订单多次支付尝试时历史被覆盖。-- 业务订单一笔业务只对应一条状态由支付结果驱动 CREATE TABLE pay_order ( id BIGINT PRIMARY KEY, out_trade_no VARCHAR(64) NOT NULL, -- 商户订单号全局唯一 channel VARCHAR(16) NOT NULL, -- WECHAT / ALIPAY / UNIONPAY amount_fen INT NOT NULL, -- 单位分 subject VARCHAR(128) NOT NULL, status VARCHAR(16) NOT NULL, -- INIT/PAYING/PAID/CLOSED/REFUNDING/REFUNDED channel_trade_no VARCHAR(64) NULL, -- 渠道订单号回调后回填 expire_at DATETIME NOT NULL, created_at DATETIME NOT NULL, paid_at DATETIME NULL, UNIQUE KEY uk_out_trade_no (out_trade_no) ); -- 渠道流水每次调用渠道都记一条用于排查和模拟重放 CREATE TABLE pay_trade ( id BIGINT PRIMARY KEY, out_trade_no VARCHAR(64) NOT NULL, channel VARCHAR(16) NOT NULL, action VARCHAR(16) NOT NULL, -- PREPAY/QUERY/REFUND/CLOSE request_body TEXT NULL, response_body TEXT NULL, success TINYINT NOT NULL, created_at DATETIME NOT NULL ); -- 回调日志原始报文必须先落库再验签这是唯一的后悔药 CREATE TABLE pay_notify_log ( id BIGINT PRIMARY KEY, channel VARCHAR(16) NOT NULL, raw_body TEXT NOT NULL, headers TEXT NULL, verified TINYINT NOT NULL DEFAULT 0, handled TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL );pay_refund表结构与pay_order类似关键字段是refund_no自己生成唯一索引、out_trade_no、refund_amount_fen、refund_status。退款金额要单独存不要用原订单金额反推部分退款场景下这个字段是唯一依据。pay_notify_log这张表我建议无论项目大小都建。支付回调是外部驱动的异步事件出了问题你手里只有两样东西能救命原始报文和验签结果。没有它排查就是纯靠猜。3.3 状态机与幂等用条件更新把重复回调挡在门外状态流转规则要先写死再写代码INIT → PAYING → PAID → REFUNDING → REFUNDEDPAYING可以转CLOSED超时未支付或主动关单PAID是终态之一不允许回退。非法流转一律拒绝并记日志。真正的核心是幂等。渠道的回调是「至少一次」语义同一笔支付可能推三次五次网络抖动、你返回超时、渠道重试都会触发重复通知。最可靠的做法不是查一次再更新有并发窗口而是让数据库来判// 只有当前状态是 PAYING 才能落到 PAID影响行数为 0 说明已经处理过或状态非法 var affected await connection.ExecuteAsync( UPDATE pay_order SET status PAID, channel_trade_no tradeNo, paid_at now WHERE out_trade_no outTradeNo AND status PAYING, new { tradeNo, now DateTime.UtcNow, outTradeNo }); if (affected 0) { // 已经处理过直接返回渠道要求的成功应答让渠道停止重推 return NotifyReply.Success(channel); } // 影响行数为 1才继续走发货、发消息、写流水这段代码的价值在于把「判断」和「更新」合成一个原子操作。如果先SELECT status再UPDATE两个并发回调会同时读到PAYING然后都去发货这就是最常见的重复发货事故。发货动作本身也要幂等通常用一个out_trade_no唯一索引的发货记录表兜底属于第二道保险。4. 三个渠道的落地实现签名、下单、回调、退款三条渠道的技术差异集中在两处签名算法和报文格式。微信是 RSA AES-GCM 双层结构支付宝是参数排序 RSA2银联是证书签名 表单跳转。适配层的任务是把这些差异全部吃进去对外只吐出PrepayResult和NotifyResult。4.1 微信支付 V3签名串、平台证书与回调解密V3 版本的签名规则很明确但细节极容易错。签名串由五行组成用换行符连接最后一行也要有换行// 构造微信 V3 请求头。注意url 必须包含 query 且与真实请求完全一致 public string BuildAuthorization(string method, string url, string body) { var timestamp DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); var nonce Guid.NewGuid().ToString(N); // 32 位随机串 // 签名串固定五行方法 \n URL \n 时间戳 \n 随机串 \n 报文主体 \n var message ${method}\n{url}\n{timestamp}\n{nonce}\n{body}\n; using var rsa RSA.Create(); rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(_opt.MchPrivateKey), out _); var signature Convert.ToBase64String( rsa.SignData(Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); return $WECHATPAY2-SHA256-RSA2048 mchid\{_opt.MchId}\, $nonce_str\{nonce}\,signature\{signature}\, $timestamp\{timestamp}\,serial_no\{_opt.MchSerialNo}\; }几个参数必须说清楚url是带域名之后的路径加查询串比如/v3/pay/transactions/native如果带?参数就必须原样带上顺序不能变body是最终发出去的原始 JSON 字符串不是对象——如果你先序列化一次签了名HttpClient 又序列化一次两次结果哪怕只差一个空格签名都会失败。我的做法是签名和发送共用同一个string body变量绝不传对象进 HttpClient。回调方向要处理两件事验签和解密。验签用的是微信平台证书的公钥签名串是「时间戳\n随机串\n报文主体\n」三行注意这里没有 URL 和方法。解密用的是 APIv3 密钥对resource节点里的ciphertext做 AES-256-GCM 解密nonce是 12 字节associated_data要作为附加认证数据传进去。// AES-256-GCM 解密回调 resource密文和 nonce 都是 Base64 public string DecryptResource(string ciphertext, string nonce, string associatedData) { var key Encoding.UTF8.GetBytes(_opt.ApiV3Key); // 必须 32 字节 var data Convert.FromBase64String(ciphertext); var iv Encoding.UTF8.GetBytes(nonce); var aad Encoding.UTF8.GetBytes(associatedData ?? ); var tag new byte[16]; var cipher new byte[data.Length - 16]; Buffer.BlockCopy(data, 0, cipher, 0, cipher.Length); Buffer.BlockCopy(data, cipher.Length, tag, 0, 16); using var aes new AesGcm(key); var plain new byte[cipher.Length]; aes.Decrypt(iv, cipher, tag, plain, aad); // 认证失败会抛异常 return Encoding.UTF8.GetString(plain); }平台证书不要每次回调都去下载那会让回调响应变慢渠道判定超时后重推形成恶性循环。通常的做法是启动时拉一次、缓存到内存并按证书序列号建字典配一个定时任务定期刷新。证书切换期间新旧证书会并存字典比单个字段更能扛住这个窗口。4.2 支付宝参数排序、RSA2 验签与三个必校验字段支付宝的签名逻辑是「参数排序后拼接」。所有非空参数按 key 的字典序升序排列用kv形式以连接然后对整串做 SHA256withRSA。这里有个高频错误拼接时用的是参数的原始值不能先做 URL 编码编码只发生在传输阶段。// 待签名串剔除 sign 与空值按 key 的序数排序后拼接 var signContent string.Join(, parameters .Where(p p.Key ! sign !string.IsNullOrEmpty(p.Value)) .OrderBy(p p.Key, StringComparer.Ordinal) .Select(p ${p.Key}{p.Value})); using var rsa RSA.Create(); rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(_opt.AppPrivateKey), out _); var sign Convert.ToBase64String( rsa.SignData(Encoding.UTF8.GetBytes(signContent), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));异步通知的验签是反向操作把通知里的参数同样剔除sign和sign_type排序拼接用支付宝公钥验签。这里必须用支付宝公钥不是应用公钥两者搞混是验签失败的头号原因。验签通过之后还不能直接发货要依次校验三个字段app_id是不是自己的应用、seller_id是不是自己的收款账号、total_amount是不是与订单金额一致注意单位是元要转成分再比。少校验任何一个都可能被伪造通知刷单。支付宝的应答格式很朴素处理成功返回纯文本success其他任何内容都会被判定为失败并触发重推。所以异常分支千万别返回 JSON 或 HTML 错误页宁可返回failure让它重试。4.3 银联表单跳转与后台通知的两段式银联网关类产品的交互模式和前两家不同它是「前台表单跳转 后台通知」两段式。下单接口返回的不是二维码也不是 URL而是一组需要 POST 到网关的字段服务端生成一个自动提交的表单页面浏览器加载后自动跳转。// 生成自动提交表单字段顺序按渠道规范拼接值做 UrlEncode签名用商户证书私钥 public static string BuildAutoSubmitForm(string gateway, IDictionarystring, string fields) { var sb new StringBuilder(); sb.Append($form id\pay\ action\{gateway}\ method\post\); foreach (var kv in fields) { // HtmlEncode 防止值里出现引号破坏标签结构 sb.Append($input type\hidden\ name\{kv.Key}\ value\{HtmlEncode(kv.Value)}\ /); } sb.Append(/formscriptdocument.getElementById(pay).submit();/script); return sb.ToString(); }字段里最关键的是签名。银联的签名串按渠道文档规定的字段顺序拼接用商户证书私钥做 SHA256withRSA签名值再 Base64 后放进签名字段。字段顺序不能靠字典序猜必须按文档来这也是为什么适配层要把组包逻辑单独抽一个方法——顺序写错了报错信息只会告诉你「验签失败」不会有任何提示。后台通知的处理逻辑和前两家一致先用银联公钥证书验签再校验商户号、订单号和金额最后判断应答码是否为成功。成功时返回渠道要求的成功应答报文让银联停止重推。有个细节要注意前台跳转回来的页面不能用来改订单状态用户关掉浏览器、网络中断都会导致前台不回来唯一可信的支付结果是后台通知加上主动查询。4.4 统一回调入口验签、落库、幂等、应答四步不能乱三条渠道的回调地址不同、报文格式不同、应答格式也不同但处理流程可以完全统一成四步。我在接口层只留一个入口用路由参数区分渠道[HttpPost(/api/pay/notify/{channel})] public async TaskIActionResult Notify(string channel) { // 第一步读原始报文不解析、不反序列化 using var reader new StreamReader(Request.Body, Encoding.UTF8); var raw await reader.ReadToEndAsync(); // 第二步先落库再验签验签失败也要留下证据 var logId await _notifyLog.SaveAsync(channel, raw, Request.Headers); // 第三步交给对应渠道适配器验签并解析成统一结构 var ctx new NotifyContext(raw, Request.Headers, Request.QueryString.Value); var result await _factory.Get(channel).ParseNotifyAsync(ctx); // 第四步幂等处理业务最后按渠道要求应答 await _paymentApp.HandlePaidAsync(result); return _factory.Get(channel).BuildNotifyReply(true); }顺序不能调换。先落库是因为验签失败时要能回放报文先验签再落库的话攻击者构造的垃圾请求会把日志表刷满但你也没法区分哪些是真实回调。应答格式按渠道区分微信返回 HTTP 200 加{code:SUCCESS}支付宝返回纯文本success银联返回约定的成功报文。业务处理抛异常时不要捕获后返回成功那样渠道会认为通知已送达永远不会再推——正确做法是返回失败应答让它按退避策略重试同时自己起一个补偿任务兜底。5. 避坑与排查支付联调里最容易翻车的五个点支付模块的调试成本很高因为大部分错误不会给你明确提示。下面五条是这类项目里出现频率最高的每条都按现象、原因、解决来写。5.1 签名与验签类问题现象一微信下单一直返回 401提示签名错误但本地用同样的参数能签出来。原因是签名用的 URL 和实际发出的 URL 不一致。最常见的是带了查询参数却没拼进签名串或者 HttpClient 对路径做了规范化比如把//压成/、把参数重新排序。还有一种隐蔽情况签名时传的是对象发送时又序列化一次两次 JSON 的字段顺序或空格不同。解决办法是把「最终发出的字节」当成唯一真相。组包方法直接返回string body签名用它发送也用它中间不做任何转换。调试时把签名串打印出来注意脱敏逐字符对比换行符——Windows 下的\r\n和\n会导致莫名其妙的失败这类玄学问题我踩过不止一次。现象二支付宝异步通知验签时好时坏重推几次偶尔能成功。八成是公钥用错了。应用私钥签名、支付宝公钥验签这两个不是一对。另一成原因是对参数值做了 URL 解码后再拼接而通知里的值本身可能含有、%这类字符解码后签名串就变了。解决方式是验签前不做任何值处理直接取原始字符串拼同时把sign_type排除掉RSA2 时代它不参与签名。验签失败时把拼好的待签串和收到的签名一起打进日志下次出问题十秒就能定位。5.2 回调与状态类问题现象三用户收到货了仓库说发了两遍。重复回调没有幂等。渠道的通知是至少一次语义你返回超时它就再推一次两次请求可能并发到达。解决方式就是第 3 章那段条件更新UPDATE ... WHERE statusPAYING看影响行数决定是否继续。再配一个发货记录表的唯一索引作为第二道保险。这套组合下来重复发货的概率基本为零。现象四订单一直停在待支付渠道后台明明已扣款。只依赖异步通知没有补偿机制。通知会丢用户网络断了、你服务重启了、回调地址配错了环境、防火墙拦了。任何一种都会让订单永远挂着。解决方式是加主动查询补偿下单后写一条待确认记录定时任务按 1 分钟、3 分钟、5 分钟、10 分钟的退避节奏去渠道查查到已支付就走同一套幂等逻辑落库超过订单有效期还没查到就调关单接口。这个任务上线后支付相关的客诉会掉一大截。5.3 金额与配置类问题现象五金额差了 100 倍测试环境一切正常生产第一笔就出事。单位不统一。微信和银联以「分」为单位传整数支付宝以「元」为单位传字符串且必须两位小数。如果内部用decimal存元、出口各自转换总会有人漏转或者多转一次。解决方式是在系统边界上做硬约束数据库存amount_fen INT接口入参叫amountFen适配层出口各自转换——转微信直接ToString()转支付宝用(fen / 100m).ToString(0.00)。回调回来再转回分做比对比对不一致直接拒绝并告警。配置同理商户号、密钥、证书路径按环境分文件用IOptionsT强类型绑定并在启动时校验非空别等到第一笔支付才发现生产环境还在读沙箱配置。6. 验收与演示用一套清单把三个渠道跑通支付模块的验收不能靠「我点了能付」要按用例走。我一般会准备一张验收表三条渠道逐项打勾全部通过才算交付。验收项通过标准关注点正常下单支付订单状态变 PAID发货记录唯一回调应答格式正确重复回调连推三次只发货一次幂等生效无异常日志金额篡改回调金额与订单不符时拒绝拒绝并告警验签失败伪造通知被拒报文已落库返回失败应答超时未支付到期自动关单状态变 CLOSED关单接口被调用全额退款退款单状态最终为成功不是「受理」部分退款两次部分退款金额累计正确金额字段独立存储回调丢失补偿手动停掉回调入口定时任务能补上状态退避节奏合理演示时按这个顺序走先展示数据库四张表的初始状态下一单扫码支付展示pay_order状态变化和pay_notify_log里的原始报文然后用工具把同一条回调重放三次展示发货记录仍然只有一条接着演示退款和退款查询最后把回调入口的配置临时改错触发一次主动查询补偿。整个过程不到十分钟但把「正常流程」和「异常兜底」都覆盖到了比念十页 PPT 有用。答辩时最容易被问的两个问题是「回调重复怎么办」和「通知丢了你怎么办」答案就在第 3 章和第 5 章里能直接指着代码说清楚基本就稳了。从那以后我每次接支付都强制先建pay_notify_log表、先写幂等的那条UPDATE再动手写签名——顺序反过来后面补的成本是现在的三倍。希望帮到你。本文还有配套的精品资源点击获取