ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C#实现微信支付宝扫码支付:回调验签与订单一致性处理

C#实现微信支付宝扫码支付:回调验签与订单一致性处理 简介面向使用C#进行Windows应用开发的程序员这份微信与支付宝扫码支付源码包无需域名即可运行只需填入公众号、商户号、API密钥、支付宝APPID及验签密钥文件配合订单编号和支付金额即可完成对接。很适合需要快速集成扫码支付或系统学习双平台支付接口调用的开发者。压缩包共包含141个文件约5.51MB以C#源文件.cs、动态链接库.dll、可执行程序.exe为主另有配置文件.config与密钥文件.pem结构清晰便于直接查看和二次开发。已有2118人学习/下载。功能覆盖微信Native支付二维码生成、扫微信付款码直接支付、按订单号查询支付状态、关闭未支付订单以及支付宝扫码支付二维码生成、扫付款码支付、订单查询、撤销订单并完成退款等完整闭环可帮助开发者快速上手企业级扫码支付场景。1. 扫码支付集成为什么总在回调上翻车先看懂这套 C# 源码的价值做电商、外卖、线下门店收银系统绕不开微信和支付宝的扫码支付。很多团队第一次接支付习惯去官方文档里复制一段下单代码跑通支付请求就以为完事了结果真正上线才发现钱扣了、订单状态没更新、对账对不上。这套 C# 微信、支付宝扫码支付源码核心不是演示怎么发起支付而是把「下单、回调、查单、关单、退款」这条完整链路给你串起来解决的是支付集成里最容易出问题的回调处理和状态一致性。适合的人群很明确手里有 .NET 项目、需要在自己系统里接入扫码支付的开发者或者公司准备自研支付模块、想参考一套完整实现的技术负责人。看这套源码收获最大的是两件事一是微信和支付宝在签名、回调验签、异步通知字段上的差异到底怎么处理二是扫码支付用户扫你的码和付款码支付你扫用户的码两种模式在代码上怎么复用。接下来我会结合这类源码最常见的项目结构把关键模块拆开讲从环境准备一直讲到上线前必须检查的坑。2. 微信与支付宝扫码支付的差异点参数结构、签名算法、异步通知2.1 微信 Native 下单与支付宝预下单同一个“扫码支付”两套完全不同的协议微信扫码支付在官方术语里叫 Native 支付核心接口是 unifiedorder统一下单下单成功后返回一个 code_url你用这个地址生成二维码用户扫码后微信内部完成支付。支付宝对应的是 precreate统一收单线下预创建返回的是二维码字符串 qr_code两者在业务语义上几乎一致但报文结构完全不同。微信的报文是 XML用 MD5 或 HMAC-SHA256 签名所有业务参数按字典序拼接成键值对再加上 key 做签名。支付宝的报文是 JSON用 RSA2SHA256WithRSA签名支付宝公钥验签、应用私钥加签。这套源码里最值得看的第一个点就是签名和验签的封装它决定了你能不能顺利通过联调。// 微信统一下单参数组装省略了部分冗余字段 var wxParams new SortedDictionarystring, string { [appid] wxConfig.AppId, [mch_id] wxConfig.MchId, [out_trade_no] orderNo, [body] subject, [total_fee] ((int)(amount * 100)).ToString(), [spbill_create_ip] ip, [notify_url] wxConfig.NotifyUrl, [trade_type] NATIVE, [product_id] orderNo }; wxParams[sign] WxPaySigner.Sign(wxParams, wxConfig.ApiKey); var xml WxPaySigner.BuildXml(wxParams);这段代码里两个关键点金额必须转成分为单位的整数微信不接受小数金额SignedDictionary 用 SortedDictionary 是为了后续签名时直接拼接避免手工排序出错。如果你在别人的源码里看到用 Dictionary 拼接后手动排序那就要多留个心眼顺序错了签名必挂。支付宝的下单参数完全是另一套风格var aliParams new Dictionarystring, string { [out_trade_no] orderNo, [total_amount] amount.ToString(0.00), [subject] subject, [qr_code_timeout_express] 2h, [timeout_express] 2h }; var body AlipayClient.Execute(alipay.trade.precreate, aliParams);支付宝的金额是元为单位的字符串保留两位小数微信则是分。第一次接的人最容易在这两个之间换算出问题比如 0.01 元在微信里是 1在支付宝里是 0.01互相对照时如果没做归一化对账就会差一分钱。2.2 异步通知验签与回调处理为什么订单状态会在回调这里丢支付成功后微信和支付宝都会往 notify_url 发异步通知。微信的通知是 XML包含 result_code、return_code、out_trade_no、transaction_id、total_fee 等字段支付宝的通知是表单格式的键值对包含 trade_status、out_trade_no、trade_no、total_amount。回调处理的核心是两步验签 更新订单状态。微信验签是把所有非空字段按字典序拼接然后去掉 sign 字段本身计算签名后与通知里的 sign 对比。// 微信回调验签核心逻辑 public static bool VerifyNotify(SortedDictionarystring, string dict, string sign, string apiKey) { var sb new StringBuilder(); foreach (var kv in dict) { if (!string.IsNullOrEmpty(kv.Value) kv.Key ! sign) { sb.Append(${kv.Key}{kv.Value}); } } sb.Append(key).Append(apiKey); var calc Md5(sb.ToString()).ToUpper(); return calc sign.ToUpper(); }这里有三个回调解析的坑源码里通常会做但新手容易忽略第一验签时不能用原始 XML 字符串直接拼必须用解析后的键值对重新拼接否则会因为 CDATA 或属性顺序不一致验签失败第二微信的 result_code 和 return_code 都要校验return_code 是通信标识result_code 才是业务结果只判断 SUCCESS 会漏掉部分失败通知第三验签通过后必须给微信返回 XML 文本「success」否则微信会按频率重试通知重试机制会持续几天不做幂等处理会导致订单状态反复更新。支付宝的验签逻辑更简单但也更容易出错需要从表单参数里取 sign 和 sign_type然后按支付宝公钥验签验签通过后判断 trade_status只有 TRADE_SUCCESS 或 TRADE_FINISHED 才算支付成功TRADE_CLOSED 是交易关闭。// 支付宝回调验签使用 RSA2 public static bool VerifyAlipayNotify(NameValueCollection form, string alipayPublicKey) { var sign form[sign]; var dict new Dictionarystring, string(); foreach (string key in form.AllKeys) { if (key ! sign key ! sign_type !string.IsNullOrEmpty(form[key])) { dict[key] form[key]; } } var content string.Join(, dict.OrderBy(kv kv.Key) .Select(kv ${kv.Key}{kv.Value})); return AlipayRSA.Verify(content, sign, alipayPublicKey); }回调处理里最常见的翻车现场是验签通过后直接更新订单状态但没有做订单状态幂等校验。微信重试通知时如果上一次更新已经成功这次再进来会把已支付订单改成已支付看起来没毛病但如果你在回调里加了短信通知、积分发放、库存扣减这些副作用操作重试就会导致重复触发用户的积分翻倍、库存多扣。正确做法是先查订单状态判断当前状态是否已经是已支付是则直接返回成功不再执行后续逻辑。2.3 支付结果查询与关单给回调丢失上的最后一道保险回调通知在极端情况下会丢比如服务器重启、网络分区、回调 URL 被防火墙拦截。所以正规的支付源码一定会提供主动查单接口微信是 orderquery支付宝是 alipay.trade.query。查单的典型触发场景是前端轮询订单状态超过 30 秒没收到回调结果后端主动向支付平台查一次。源码里一般会封装统一的查单入口传入支付渠道和订单号返回平台侧的真实订单状态再同步到本地订单表。public PayQueryResult QueryPayment(string channel, string orderNo) { if (channel wechat) { // 调用微信 orderquery 接口 var result WxPayClient.OrderQuery(orderNo); if (result.TradeState SUCCESS) { return PayQueryResult.Success(result.TransactionId, result.TotalFee); } return PayQueryResult.Pending; } else { // 调用支付宝 trade.query var result AlipayClient.TradeQuery(orderNo); if (result.TradeStatus TRADE_SUCCESS || result.TradeStatus TRADE_FINISHED) { return PayQueryResult.Success(result.TradeNo, result.TotalAmount); } return PayQueryResult.Pending; } }查单接口配合一个简单的定时任务就能在回调丢失后自动补偿。常见做法是本地订单表加一个 pay_status 字段和一个 notify_count 字段每 5 分钟扫一次「已下单未支付且未超时」的订单调用查单接口如果平台侧已支付就对本地订单做补单处理。这套源码里如果能找到这个补偿模块说明作者是真的上线跑过而不是只会调接口。3. 把 C# 扫码支付源码落到本地项目环境准备、目录结构、最小可运行示例3.1 准备微信支付商户号和支付宝开放平台应用先有证再写码接扫码支付之前需要先准备两套凭证。微信支付需要商户号mch_id、AppID一般是公众号或小程序的 AppID、API 密钥APIv2 key或 APIv3 证书。支付宝需要应用 AppID、应用私钥、支付宝公钥这些在支付宝开放平台的「开发设置」里可以生成。这里有个容易踩的坑微信支付的 API 密钥是 32 位字符串自己设置的不是微信发给你的很多第一次接入的人把「商户平台登录密码」当成 API 密钥去签名结果自然是签名错误。源码里一般会有一个配置文件存放这些敏感信息比如 appsettings.json 或 Config.cs。开发环境建议用弱密钥方便联调生产环境必须用独立的商户号和密钥并且定期轮换。你还得配置支付回调地址的域名微信和支付宝都要求回调地址必须是备案过的域名不能用 IP 直连联调时可以先用内网穿透工具把本机地址映射到公网不然收不到异步通知。这是一个典型的支付配置结构源码里通常长这样public class PayConfig { public string WxAppId { get; set; } public string WxMchId { get; set; } public string WxApiKey { get; set; } public string WxNotifyUrl { get; set; } public string AliAppId { get; set; } public string AliPrivateKey { get; set; } public string AliPublicKey { get; set; } public string AliNotifyUrl { get; set; } }注意支付宝的应用私钥放在服务端任何人不能把它下发到前端支付宝公钥用于验证支付宝发来的通知不是应用公钥。源码里如果混淆了这两者验签会一直失败这个坑在联调阶段非常折磨人。3.2 创建支付订单的完整代码从订单表写入到二维码生成本地项目里实现扫码支付建议按这个流程走客户端请求下单接口服务端生成业务订单并写入本地数据库然后调用支付平台预下单接口拿到二维码字符串返回给前端展示二维码用户扫码完成支付。下面这套代码是「预下单 保存本地订单 返回二维码」的组合写法参考了很多线上项目的惯用姿势[HttpPost(api/pay/scan)] public async TaskIActionResult CreateScanPay([FromBody] PayRequest req) { var orderNo GenerateOrderNo(); // 业务订单号全局唯一 var amount req.Amount; // 单位元decimal 类型 // 1. 保存本地订单状态为待支付 var order new PayOrder { OrderNo orderNo, Channel req.Channel, // wechat / alipay Amount amount, Status WAIT_PAY, CreatedAt DateTime.Now }; _db.PayOrders.Add(order); await _db.SaveChangesAsync(); // 2. 调用各自渠道的预下单接口 string qrCode ; if (req.Channel wechat) { qrCode _wxPayService.PreCreate(orderNo, amount, req.Subject); } else { qrCode _aliPayService.PreCreate(orderNo, amount, req.Subject); } // 3. 返回二维码内容前端用 QrCode 组件渲染 return Ok(new { order_no orderNo, qr_code qrCode }); }预下单接口返回的 qr_code 或 code_url 本质是一个 URL 字符串前端拿到后生成二维码图片即可。微信返回的 code_url 有效期一般是 2 小时支付宝的 qr_code 里带了二维码本身的超时时间像上面的参数设了 2 小时qr_code_timeout_express超时后二维码扫了会提示交易过期。这里有一个值得注意的边界微信的 NATIVE 下单其实还有一个 trade_typeMWEB 的 H5 支付变种返回的是 redirect_url 不是 code_url很多源码把 H5 支付和 Native 支付混在一个服务类里参数判断没做干净就会出问题。3.3 本地跑通的最小命令ASP.NET Core 项目启动与联调自测拿到源码后先在本地把服务跑起来。源码如果是 .NET 6/8 的项目先确认 SDK 安装正常然后进入项目目录执行还原和启动命令dotnet restore dotnet build dotnet run --project src/PayDemo.Api启动后服务默认监听 5000 端口用浏览器访问http://localhost:5000/swagger可以看到接口列表。下单接口、回调接口、查单接口分别对应三个地址/api/pay/scan、/api/pay/notify/{channel}、/api/pay/query/{orderNo}。联调时有几个实用技巧第一回调接口可以先不用真实微信回调用 Postman 手动构造一个 XML 通知发给回调地址验签部分单独用官方签名工具生成的签名验证第二微信和支付宝都提供了沙箱环境支付宝的沙箱在开放平台里可以开启微信的沙箱需要单独申请测试商户号第三如果本地没有公网地址内网穿透工具是必要的把 5000 端口映射出去后把映射后的域名填到 notify_url 里手机真机扫码就能收到回调。4. 微信与支付宝回调联调本地环境怎么捅破“收不到回调”这层窗户纸4.1 扫码支付联调必须准备好的工具清单联调阶段最容易让人崩溃的不是签名错误而是「钱付了回调就是不进来」。常见的原因是本地服务没有公网地址、回调响应格式不对、支付平台验签后处理失败返回了非 success 文本。在开始联调前先把工具备齐工具用途备注内网穿透工具把本地 ASP.NET Core 服务映射到公网免费版域名会变联调用够Postman手动构造回调报文、测试查单接口微信 XML、支付宝表单格式分别构造微信支付商户平台查看订单状态、手动发起查单退款联调阶段不要开真实支付支付宝开放平台沙箱生成沙箱订单和回调沙箱账号与真实账号隔离数据库工具直接查看 pay_order 表状态排查订单更新异常回调调通的标准是在数据库里能看到订单状态从 WAIT_PAY 变成 PAID同时商户平台里能查到对应的交易号。如果只看到支付平台扣款成功但本地状态没变问题几乎必然出在回调链路。4.2 回调收不到的三种场景与定位方法先看第一种微信或支付宝通知发出后本地完全没有请求日志。这种要先确认 notify_url 填的是不是能被公网访问的地址别用 localhost 或 127.0.0.1。其次检查支付平台侧配置的 notify_url 是否和代码里设置的一致微信的 notify_url 参数在统一下单里传支付宝的可以在代码里传也可以在开放平台后台配置优先级容易搞混。最后很多内网穿透工具免费版有外网访问次数或流量限制回调包被丢弃也正常换付费稳定通道解决。第二种回调进来了但代码里验签抛异常。这种情况日志里会有验签失败的记录最常见的原因是微信的签名算法不一致。比如下单时用 HMAC-SHA256验签时按 MD5 算签名必挂。微信在统一下单参数里有 sign_type 字段通知里也会带 sign_type源码里必须支持两种算法并且和发起时保持一致。支付宝验签失败绝大多数原因是应用私钥和支付宝公钥不匹配或者换了一次密钥对后代码里还是旧公钥。第三种验签通过但业务处理里有异常导致返回了非 success。比如更新订单状态时数据库连接超时代码抛异常后默认走 500 错误返回微信收不到 success 就继续重试。源码里一般会在 catch 里把异常信息记到日志并返回固定的错误文本。这里要特别提醒如果订单更新失败不要返回 success否则微信会认为通知成功不再重试这笔订单就永远丢失了。try { var success _orderService.MarkAsPaid(outTradeNo, transactionId, totalFee); if (success) { return Content(success, text/plain); } return Content(fail, text/plain); } catch (Exception ex) { _logger.LogError(ex, 回调处理失败); return Content(fail, text/plain); }这段代码的边界在于MarkAsPaid 内部必须包含幂等判断已支付的订单直接返回 true不要去重复扣库存、发积分否则重试和被攻击重复调用时业务会被打穿。4.3 回调、查单、订单状态机怎么对齐少踩坑源码设计里订单状态一般不会只有「未支付 / 已支付」两个值至少还会有「已关闭」「已退款」「支付中」这几个。回调进来改状态查单回来也改状态两边必须走同一个状态更新方法不能在回调里直接写 UPDATE 语句在查单里又写一套不同的逻辑否则后到的状态会覆盖先到的导致已退款订单被误改成已支付。一个完整的支付状态机流转是这样的本地状态触发条件下一个状态WAIT_PAY下单成功WAIT_PAYWAIT_PAY回调/查单确认支付成功PAIDWAIT_PAY超过有效期且未支付CLOSEDPAID发起退款且退款成功REFUNDEDREFUNDED再次收到支付成功通知不改变状态状态机里最关键的规则是PAID 状态只能由 WAIT_PAY 或 PAYING 流转过去其余状态一律拒绝流转CLOSED 状态的订单不允许再被支付成功回调改活。很多翻车事故就是因为状态更新没有条件判断WHERE pay_status WAIT_PAY 这一条没加。5. 避坑指南支付源码集成路上的 5 个典型踩坑记录5.1 微信支付金额单位搞错total_fee 传了元导致支付金额差 100 倍现象下单接口返回成功二维码也能扫支付金额比订单金额大了 100 倍或小了 100 倍。原因微信 total_fee 单位是分源码里如果直接把接口入参的 decimal 金额传进去0.01 元就变成了 1 分实际支付时平台按 1 分处理和订单库里 0.01 元对比永远对不上。解决在预下单接口入口统一做金额转换定义一个 AmountFen 属性由数据库里的元换算后赋值后续所有逻辑都用分不要在下单、回调、查单时各转一次。支付宝则是反过来total_amount 用元保留两位小数转成字符串时不能用amount.ToString()默认格式要显式ToString(0.00)否则0.1会被截断为0.1但0.10才能通过支付宝参数校验。5.2 回调 URL 没有备案域名异步通知永远不到现象本地跑通了支付微信商户平台显示交易成功但本地服务一点日志都没有。原因微信和支付宝的回调对公网地址有限制必须使用已备案的域名用 IP 地址或未备案域名时平台可能不发起通知或在通知时被拦截。解决联调时用内网穿透工具把本地端口映射到公网域名生产环境必须走备案域名并配置 HTTPS。证书过期也会导致回调投递失败支付平台的服务器会校验证书证书失效后通知请求直接被拒绝这个坑在半年报错一次时最难排查。5.3 微信回调验签成功但返回 success 后订单还是没更新现象回调里日志显示验签成功业务代码也执行了但数据库订单状态还是 WAIT_PAY。原因业务代码里更新订单用的订单号不是 out_trade_no而是自己业务表的流水号如果回调里用平台的交易号去查本地订单查不到就跳过了更新。解决本地订单表一定要建一个「商户订单号 out_trade_no」的索引回调进来先用 out_trade_no 查订单查到后校验金额是否一致金额不一致直接告警不要更新状态。微信和支付宝的回调报文里都能拿到 out_trade_no 和总金额这两个字段必须和本地订单完全匹配才能确认支付。5.4 支付宝回调验签总是失败排查半天发现公钥填错现象支付宝沙箱回调签名一直校验失败换了几次密钥都不行。原因支付宝开放平台里有「应用公钥」和「支付宝公钥」两个概念代码验签用的是支付宝公钥而不少人把应用公钥复制到了验签配置里。解决登录开放平台在「开发设置」-「接口加签方式」里查看支付宝公钥复制以-----BEGIN PUBLIC KEY-----开头的完整字符串。每次重置密钥后要同时更新私钥和支付宝公钥只改其中一个验签必挂。这里还有一个高级坑支付宝公钥会随平台升级轮换线上突然验签失败时先去开放平台确认公钥是否变更。5.5 乱用单例导致多商户配置串号现象项目对接了多个微信商户号A 商户的订单回调验签失败B 商户的订单却正常。原因支付配置被设计成了全局静态单例后初始化的配置覆盖了先初始化的或者多商户配置用字典存储时 key 设计不当。解决支付配置不要用静态字段保存建议用作用域级别的配置服务每个请求根据渠道和商户号动态取配置多租户场景下单笔订单要落库保存发起时的 appid、mch_id 和商户私钥标识回调验签时从订单记录取配置而不是从全局静态类取。6. 上线前把这几件事做掉预下单幂等、查单补偿、退款与对账验证6.1 预下单接口加幂等控制避免重复下单生成多个二维码线上经常有用户快速点击两次「立即支付」如果预下单接口没有幂等控制会产生两笔本地订单一个用户付了其中一笔另一笔变成永不支付的死角。常见做法是以用户 ID 业务单号为维度做去重同一个业务单号只允许生成一个本地支付订单如果已存在则直接返回已有二维码。[HttpPost(api/pay/scan)] public async TaskIActionResult CreateScanPay([FromBody] PayRequest req) { var existing _db.PayOrders.FirstOrDefault(o o.BizOrderId req.BizOrderId o.Status WAIT_PAY); if (existing ! null) { return Ok(new { order_no existing.OrderNo, qr_code existing.QrCode }); } // 后续创建新订单流程 }这段去重逻辑在并发下会有竞争条件两个请求同时查不到已有订单就会各建一单。更稳妥的做法是给 biz_order_id 字段建唯一索引插入失败时捕捉唯一键冲突异常再查一次。很多成熟源码里会用一个 Redis 分布式锁包住下单逻辑这对高并发场景更友好小流量项目直接依赖数据库唯一索引也够用。6.2 定时查单补偿把回调丢失的订单捞回来即使回调链路稳定也不能完全依赖异步通知。线上建议加一个 5 分钟间隔的补偿任务扫描「已创建超过 1 分钟且仍未支付成功且未关闭」的订单调用查单接口确认平台侧状态。补偿逻辑有两点讲究一是查单频率不能太高微信和支付宝对查单接口有频率限制全表扫会导致大量无效请求二是查单结果要和回调结果互斥不能同一个订单回调刚更新完查单又用旧状态把它覆盖回去。public async Task CompensatePendingOrders() { var pendingOrders _db.PayOrders .Where(o o.Status WAIT_PAY o.CreatedAt DateTime.Now.AddMinutes(-1) o.CreatedAt DateTime.Now.AddHours(-2)) .Take(50) .ToList(); foreach (var order in pendingOrders) { var queryResult await _payService.QueryPayment(order.Channel, order.OrderNo); if (queryResult.Status PAID) { _orderService.MarkAsPaidWithNoNotify(order.OrderNo, queryResult.TransactionId, queryResult.Amount); } } }补偿任务里调用 MarkAsPaidWithNoNotify 和回调处理的 MarkAsPaid 走同一个状态更新方法只是入口不同。这里要特别留意补偿查单发现已支付时不需要再发通知给客户端前端轮询订单查询接口时会自然感知到状态变化。6.3 退款接口和对账扫码支付闭环的最后一公里扫码支付上线后退款是大概率要用到的能力。微信退款用 secapi_pay/refund支付宝用 alipay.trade.refund两个接口都需要验签和证书。退款的关键点是退款单号和原订单号要对应退款金额不能超过原订单金额已经退款的订单不能重复退。对账这件事建议做日结任务每天凌晨拉取微信和支付宝的账单文件逐笔比对平台侧交易和本地订单流水差异数据输出到告警表。微信账单可以下载对账单文件日期粒度支付宝可以在开放平台下载账单。对账脚本的落点不是「能不能跑通」而是差异数据怎么处理本地已支付但平台没有这笔交易优先查是不是回调伪造平台已支付但本地没有订单查是不是回调丢失且补偿查单没有覆盖到。最后说一个我自己的习惯接支付源码先改回调验签再改状态机最后才碰下单接口。因为回调是整个链路的命门验签和幂等靠谱了下单再怎么出问题都能通过查单捞回来。希望这些经验和踩坑记录帮你在接微信、支付宝扫码支付时少走弯路。本文还有配套的精品资源点击获取
返回列表