C#微信支付封装源码解析:签名机制与Native/App支付实现
2026/9/13 21:28:21 网站建设 项目流程

简介:一套基于C#语言、面向.NET平台的微信支付封装源码,覆盖二维码扫码支付、APP内发起支付等高频场景,目标是让.NET开发者避开繁琐的官方接口对接流程,直接通过封装好的类与方法完成接入,适合正在做商城、预约系统或移动端应用的工程师参考。压缩包共151个文件,其中71个cs源码是核心,包含支付请求、回调处理、签名工具等逻辑;15个xml用于存放配置与注释,14个dll提供运行依赖,10个nuspec与10个nupkg支撑NuGet包管理,另有cshtml示例视图、config配置文件、样式脚本等,整体仅3.04MB,结构紧凑、层级清晰。目前已有3202人参与学习。翻阅源码可完整看到扫码支付与APP支付的实现思路,包括统一下单、回调验签、订单查询、证书与异步通知等关键环节;配套的配置文件与示例页面也能减少重复搭建成本,帮助开发者快速迁移到实际项目,遇到支付签名错误、回调地址配置等问题时也能更快定位原因。

1. 当扫码支付和 APP 支付挤在同一个支付服务里,封装源码要怎么拆

一个电商项目同时要支持 PC 端扫码支付和 App 内唤起微信支付,两个入口共用同一个订单中心,但微信官方文档把 Native 和 App 的参数、签名时序拆成两套说明。官方 Demo 的问题也很明显:请求发送、响应解析、证书加载全堆在几个类里,业务代码被 XML 字符串塞满,想加一个字段都要翻半天。这套 C# 源码包的思路明确:WxPayAPI.cs 收敛统一下单、签名、回调验签;WeixinExecutor.cs 做交易类型分发;Global.asax 和 Web.config 负责初始化和多环境切换。适合正在接手微信支付的 .NET 工程师,也适合准备把支付代码收敛到服务层的老项目。下面按一次真实支付的流转链路拆开讲。

2. 微信支付接口调用链与 WxPayAPI 的签名封装原理

2.1 一个支付请求从入口到微信接口的完整流转路径

微信支付的每次业务请求都要经历“业务参数组装 → 按字典序拼接 → 加签 → 请求微信 API → 解析 XML/JSON 响应 → 验签”这六个环节。这个源码包把前四个环节收进 WxPayAPI.cs,后两个环节按场景拆到不同方法。文件列表里的 Global.asax 和 Web.config 不参与协议,但决定了运行时配置从哪来,这是很多 .NET 开发者忽略的部分:支付网关地址、证书路径、API Key 都应该在应用启动时加载,而不是散落在业务方法里。

从调用链看,Native 扫码支付和 App 支付共用统一下单接口,区别只在最终请求参数中 trade_type 和 product_id 不同。统一下单成功后会返回 prepay_id,Native 流程直接从响应里取 code_url 生成二维码;App 流程则需要对 prepay_id 做二次签名,再把签名参数返回给客户端。WeixinExecutor.cs 在这里充当路由层,它根据请求体里的支付场景决定走哪条分支。

这里我一般会强调:不能把微信支付请求和普通 HTTP 请求混在一起。微信支付对参数顺序、编码和空值过滤有严格要求,任何额外的字段都会导致签名不一致。因此封装源码使用 SortedDictionary 而不是 Dictionary 来保存请求参数,这是第一个值得留意的实现细节。

2.2 参数签名:MD5 与 HMAC-SHA256 的差异

微信支付 API v2 的签名规则是所有请求参数(sign 本身除外)值不为空的键值对,按参数名 ASCII 码升序排列,拼接成key1=value1&key2=value2的形式,末尾追加&key=商户API密钥,对拼接结果做摘要并转大写。

当前微信支付商户平台主要支持 MD5 和 HMAC-SHA256 两种摘要算法。MD5 输出 32 位大写字符,HMAC-SHA256 输出 64 位大写字符。两者混用的风险在于:统一下单时用 HMAC-SHA256 签名,但回调通知验签时使用了默认 MD5,结果就是回调一直验签失败。这个源码包的解决方案是在配置项里维护一份 SignType,所有签名和验签入口都读取同一个配置。

// 微信支付请求参数签名生成 public static string MakeSign(SortedDictionary<string, object> parameters, string apiKey, string signType) { var sb = new StringBuilder(); foreach (var param in parameters) { string value = param.Value?.ToString(); if (param.Key == "sign" || string.IsNullOrEmpty(value)) { continue; // 过滤空值与 sign 字段 } sb.Append($"{param.Key}={value}&"); } sb.Append("key=").Append(apiKey); // 末尾拼接商户 API Key byte[] contentBytes = Encoding.UTF8.GetBytes(sb.ToString()); if (signType == "HMAC-SHA256") { using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiKey))) { byte[] hash = hmac.ComputeHash(contentBytes); return BitConverter.ToString(hash).Replace("-", "").ToUpper(); } } using (var md5 = MD5.Create()) { byte[] hash = md5.ComputeHash(contentBytes); return BitConverter.ToString(hash).Replace("-", "").ToUpper(); } }

这段代码的逻辑是:先用 SortedDictionary 保证参数名升序,接着跳过 sign 本身和值为空的字段,最终拼出待签名字符串。HMAC-SHA256 分支需要额外注意:HMAC 的密钥也是 apiKey,因此我在 using 块里重复读取 apiKey 字节,避免外部对密钥做不必要的编码转换。MD5 分支则是微信支付 v2 最常见的默认算法。

使用这段代码时有一个典型的误用:很多同学会先把参数放进 Dictionary,再手动调用 OrderBy 排序,最后转换为 string 拼接——这没问题,但一旦漏掉值为 null 的字段,签名结果就和你自己在微信商户平台调试工具里得到的值不一致。直接把参数放进 SortedDictionary 并在循环里过滤,能少犯这类错误。

2.3 统一下单参数对照与选型说明

统一下单是 Native、App、JSAPI、MWEB 四种支付方式共同的第一步,因此封装源码里的 UnifiedOrder 方法只接收一个 WxPayData 对象,不把每种支付方式单独开方法。这里给出该源码包中最常用的字段说明。

协议参数名对应封装属性/字段必填值说明
appidAppId微信开放平台或公众号的 AppID
mch_idMchId微信支付商户号
out_trade_noOutTradeNo商户订单号,建议字母数字组合,长度不超过 32
bodyBody商品描述,会展示在用户账单
total_feeTotalFee支付金额,单位为分,不能带小数点
spbill_create_ipSpbillCreateIp用户终端 IP,App 支付填客户端公网 IP;Native 填服务器出口 IP
notify_urlNotifyUrl接收支付结果回调的地址,必须公网可访问
trade_typeTradeTypeNATIVE、APP、JSAPI、MWEB 之一
product_idProductIdNative 支付必填,用于扫码时映射商品
openidOpenIdJSAPI 支付必填

上表里最容易忽略的是 total_fee 的单位。接口收的是“分”,但业务库常用“元”存储,封装源码在构建 WxPayData 时没有自动做单位换算,因为自动换算会导致调用方对精度失控。我习惯在业务 Service 层统一把元转换为分后再传给 Executor,这样支付模块保持无状态,也方便单元测试。

另外,spbill_create_ip这个参数在 App 支付时不能填服务器的内网 IP,否则部分风控策略会直接拦截。Native 支付则可以填服务器公网 IP,因为扫码请求实际由微信服务器发起,终端 IP 对微信不可见。封装源码把该字段设计成必填,就是为了逼调用方显式传入 IP,而不是包一层默认值。

3. WxPayAPI 中统一下单、二维码扫码支付与 APP 支付的实现

3.1 Native 扫码支付:code_url 的生成与二维码渲染解耦

Native 支付的完整流程是:服务端调用统一下单,trade_type 为 NATIVE,product_id 为商品标识;微信返回 code_url,这是一个专门用于生成二维码的链接;用户扫码后微信内部把 code_url 转换成一笔授权支付请求。封装源码在 WxPayAPI.UnifiedOrder 返回的 WxPayData 里直接暴露 GetValue("code_url") 方法,把二维码渲染交给前端或服务端图形库,不在支付模块里引入二维码库。

这样设计的好处是支付 API 和 UI 层彻底分离。如果项目用的是 Vue/React,前端拿 code_url 调用 qrcode.js 即可;如果服务端需要直接输出图片,常见做法是在 Controller 里用 QRCoder 把 code_url 转成 Bitmap,再以 image/png 响应。封装源码不掺和这一步,避免为了支付引入一个只服务二维码的依赖。

示例代码给出一个 Native 下单方法的完整写法:

public WxPayData NativeOrder(string orderNo, string body, int totalFee, string productId, string notifyUrl, string ip) { var req = new WxPayData(); req.SetValue("out_trade_no", orderNo); req.SetValue("body", body); req.SetValue("total_fee", totalFee); req.SetValue("product_id", productId); req.SetValue("notify_url", notifyUrl); req.SetValue("spbill_create_ip", ip); req.SetValue("trade_type", "NATIVE"); return WxPayAPI.UnifiedOrder(req); }

注意这里没有单独传 appid 和 mch_id,因为 WxPayAPI.UnifiedOrder 内部会从全局支付配置读取。这样封装既方便普通场景,也不会让每次调用都被一堆固定参数填满。当你需要同时服务多个商户号时,再给方法增加一个 config 参数即可,源码包预留了这样的扩展面。

拿到 code_url 后,如果扫码后一直提示“找不到商品”,优先检查 product_id 是否为空。很多 .NET 项目在数据库里存的是 long 型商品主键,序列化成 JSON 后变成字符串,前端再传回来时可能被截断,导致 product_id 和统一下单时不一致。建议 product_id 统一用稳定字符串,不要依赖数据库自增主键。

3.2 App 支付:prepay_id 生成后进行第二次签名

App 支付里“统一下单 → 取 prepay_id → 生成客户端参数”这三步是连续动作。拿到 prepay_id 后不能直接返回给客户端,因为客户端调起微信 SDK 时还需要 appid、partnerid、prepayid、package、timestamp、noncestr 以及由这些参数生成的 sign。顺序错了或者参数名大小写错了,客户端就会报签名错误。

源码包里提供的方法通常命名为 GetAppPayParams,内部封装第二次签名逻辑。下面是我基于该源码整理的一个版本:

public Dictionary<string, string> BuildAppPayParams(WxPayData unifiedOrderResult, string appId, string mchId, string apiKey) { string prepayId = unifiedOrderResult.GetValue("prepay_id").ToString(); var parameters = new SortedDictionary<string, object> { ["appid"] = appId, ["partnerid"] = mchId, ["prepayid"] = prepayId, ["package"] = "Sign=WXPay", ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ["noncestr"] = Guid.NewGuid().ToString("N") }; string sign = MakeSign(parameters, apiKey, "MD5"); parameters.Add("sign", sign); return parameters.ToDictionary(kv => kv.Key, kv => kv.Value.ToString()); }

这段代码有几个关键点:timestamp 使用的是 Unix 秒级时间戳,C# 开发者最容易写成DateTime.Now.ToString("yyyyMMddHHmmss"),微信 SDK 要求的是秒级时间戳,格式错误会直接导致调不起支付页面。noncestr 是随机字符串,这里用 Guid 去掉连字符后的 32 位,能满足长度要求;但要注意 Guid 的随机性足够但牺牲一点性能,高频调用可以用 RNGCryptoServiceProvider 生成随机字符串。package 固定为 Sign=WXPay,不能改成其他值。

签名算法在这里统一使用 MD5,如果统一下单时用的 HMAC-SHA256,这里需要把参数扩展到包含sign_type=HMAC-SHA256后再计算。这块必须根据商户平台实际设置调整,不能想当然。最后返回 Dictionary<string, string> 而不是 JObject,是因为 Unity、iOS、安卓的对接层分别有自己的 JSON 解析方式,服务端只提供扁平字典,由客户端自己序列化成各自需要的数据结构,反而减少跨端格式冲突。

3.3 支付结果回调:验签、金额核对与投诉回调分流

支付回调是风险最高的一环,微信服务器在订单支付完成后多次 POST 通知到 notify_url。封装源码处理的第一个动作是读取原始 XML 报文并验签,验签通过后再反序列化业务字段。第二个动作是核对金额和订单状态:除了 out_trade_no 必须存在,还要把微信返回的 total_fee 和本地订单金额做精确比较,这一点官方文档没有强制要求,但业务上不做就是资损漏洞。

验签示例:

public bool VerifyNotify(string xml, string apiKey, string signType) { var data = WxPayData.FromXml(xml); string receivedSign = data.GetValue("sign")?.ToString(); data.Remove("sign"); string calculatedSign = MakeSign(data.ToSortedDictionary(), apiKey, signType); return string.Equals(receivedSign, calculatedSign, StringComparison.OrdinalIgnoreCase); }

这个方法的执行顺序是:先把 XML 解析成 WxPayData,取出 sign 字段后立刻从集合中移除;再用剩余字段计算签名,最后统一转大写比较。比较时使用 OrdinalIgnoreCase 而不是 ToUpper,可以避免某些语言环境下的土耳其文化问题,虽然微信返回的是大写,防御性写法并不多余。

验签通过后,封装源码返回给微信服务器的响应必须是纯文本SUCCESS;如果业务处理失败,必须返回FAIL或非 SUCCESS 文本,微信会按一定周期重试。这里有个源码包没有直接处理的边界:数据库更新成功但响应超时,微信会立刻重试,导致回调幂等逻辑必须存在。我一般会在 Executor 里加入“订单状态检查”或“唯一键去重表”,确保同一笔订单被重复通知时不会二次退款或二次发货。

注意:验证回调签名和业务处理必须放在支持重试的流程里,生产环境对同一通知至少保证一次幂等处理。

此外,微信支付投诉回调是另一种独立的回调,它和支付结果回调共用同一个通知入口时,接口字段完全不一样。投诉回调中出现的字段是 complaint_id、complaint_time、amount 等,事件类型由 msg_type 区分。源码包里没有单独实现投诉处理器,扩展时建议在 WeixinExecutor 中增加一个专门的方法,按 msg_type 分发到支付通知、退款通知、投诉通知三个方向,避免一个 Handler 里堆满 if/else。

4. WeixinExecutor 支付分发、Global.asax 初始化与 Web.config 多环境切换

4.1 WeixinExecutor 如何把支付类型映射到业务处理器

这个源码包里的 WeixinExecutor.cs 是所有支付入口的门面。它没有直接继承某个接口,而是通过一个简单的策略映射把 NATIVE、APP 两个支付场景分发到 WxPayAPI 的不同方法。如果你扩展出 JSAPI 或 MWEB 支付,只需要在 Execute 方法里增加分支,并补上各自特有的参数。

我建议把 Executor 设计成尽量只依赖两个方法:一个负责组装统一下单数据,另一个负责处理支付结果。这样业务层调用时不需要知道 WxPayAPI 的底层细节,也让单元测试可以 mock 整个 Executor。示例代码演示这种分发方式:

public WxPayData Execute(PayRequest request, PaymentConfig config) { var order = new WxPayData(); order.SetValue("out_trade_no", request.OrderNo); order.SetValue("body", request.Body); order.SetValue("total_fee", request.TotalFee); order.SetValue("notify_url", config.NotifyUrl); order.SetValue("spbill_create_ip", request.Ip); switch (request.TradeType) { case "NATIVE": order.SetValue("trade_type", "NATIVE"); order.SetValue("product_id", request.ProductId); var nativeResult = WxPayAPI.UnifiedOrder(order); return BuildNativeResult(nativeResult); case "APP": order.SetValue("trade_type", "APP"); var appUnified = WxPayAPI.UnifiedOrder(order); return BuildAppResult(appUnified, config); default: throw new NotSupportedException($"不支持的支付类型:{request.TradeType}"); } }

这里有几个值得注意的细节:PayRequest 是入参 DTO,PaymentConfig 是全局配置对象;Execute 方法本身不读取 Web.config,所有外部配置都通过参数传递。这样做的原因是单元测试时可以传入一个内存配置,而不必依赖 ConfigurationManager。还有,total_fee 在这个 DTO 里已经由元转换成 int 类型,避免在 Executor 内部做金额单位换算,减少隐藏 bug。

这个分发方法也承担了错误边界职责:微信方返回异常时,统一抛出自定义异常类型,上层 Controller 捕获后返回标准化 JSON 给前端。源码包没有强制约定异常处理,但我在改造项目时一般会增加一个 Result 包装返回对象,把微信错误代码、错误描述和业务错误分离,方便运营人员直接定位是配置问题还是参数问题。

4.2 Global.asax 中预加载支付配置的意义

Global.asax 里的 Application_Start 是这个源码包初始化的核心。微信支付客户端证书、API Key、商户号等信息如果在每次请求时从配置文件读取,配置中心一旦更新就会影响所有在途请求。推荐做法是在应用启动时把配置放到静态类里做一次性缓存,后面所有代码从缓存读取。

public class MvcApplication : HttpApplication { protected void Application_Start() { AreaRegistration.RegisterAllAreas(); FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); PaymentConfig.Initialize(ConfigurationManager.AppSettings); } protected void Application_BeginRequest(object sender, EventArgs e) { CallContext.LogicalSetData("trace_id", Guid.NewGuid().ToString("N")); } }

Application_Start 里执行初始化方法,把 Web.config 中 appSettings 下 WxAppId、WxMchId、WxApiKey、WxCertPath、WxCertPassword 等键加载进 PaymentConfig 的静态属性。CallContext 在 BeginRequest 中写入一个 trace_id,后续日志可以通过 HttpContext.Current?.Items 或 AsyncLocal 继续传递这个请求标识。实际项目如果用的是 .NET Framework 4.x,CallContext 在异步代码中可能沿逻辑上下文传播,比 ThreadStatic 更稳定。

Certificate 的加载:Native 和 App 支付通常不需要客户端证书,退款和企业付款到零钱接口才需要 apiclient_cert.p12。所以 Application_Start 里不要无条件加载证书,否则证书过期或路径不存在时,整个应用启动失败。正确做法是让 PaymentConfig 暴露一个 Lazy 类型的属性,需要双向认证的接口第一次调用时才真正加载证书文件。

4.3 Web.config 与 Web.Release.config 的配置隔离策略

源码包里同时存在 Web.config、Web.Debug.config 和 Web.Release.config,这是 Visual Studio 默认的配置转换机制。Web.config 放本地调试用的测试商户号,Web.Release.config 在发布时对指定 key 做替换。转换文件只在发布时生效,不会影响运行时的编译结果。

配置键Debug 值示例Release 值示例转换方式
WxAppIdwx-test-appidwx-prod-appid替换
WxMchId19000001091900000111替换
WxApiKeytest-api-keyprod-api-key替换
WxNotifyUrlhttps://localhost:44301/notifyhttps://api.example.com/wxpay/notify替换
WxCertPathC:\certs\test\apiclient_cert.p12D:\certs\prod\apiclient_cert.p12替换

Web.Release.config 里的转换片段常见写法如下:

<appSettings> <add key="WxAppId" value="wx-prod-appid" xdt:Transform="SetAttributes" xdt:Locator="Match(key)" /> </appSettings>

这个 transform 的语义是:在发布 Release 时,找到 appSettings 里 key 为 WxAppId 的 add 节点,替换 value 属性。Match(key) 是定位条件,SetAttributes 是动作,两者必须同时出现,否则会被替换成空值。这里有一个容易忽略的坑:如果 Web.config 中的某个配置项被加密或者在父级 configSource 中定义,transform 会失效;支付类配置尽量保持明文,放在独立的配置节中,通过权限控制保护。

还要注意 Web.Release.config 无法直接删除 appSettings 里已存在的 key,只能修改属性。想彻底清空某个密钥,可以用 xdt:Transform="Remove" xdt:Locator="Match(key)",但发布后该 key 不存在又会让 ConfigurationManager.AppSettings["key"] 返回 null,PaymentConfig.Initialize 里需要做 null 检查并抛出包含可读信息的异常,否则上线后只能看到空引用错误,排错成本会高很多。

5. 微信支付签名不一致的定位技巧与本地模拟回调验证

5.1 签名错误的三步定位方法

签名问题是微信支付集成中最常见也最难直接看出原因的报错,返回码SIGNERROR签名错误时,我一般按三步处理。第一步是开源码包里的原始报文日志,把 WxPayAPI 发送请求前组装好的 XML 原样记录;第二步是把报文中的参数复制到微信商户平台“签名校验工具”中手动比对,注意工具要求的是 URL 解码后的键值对;第三步是检查公钥和密钥的配置来源,很多项目里 Web.config 同时存在旧商户号和新商户号配置,Executor 读取的是某一份,支付平台却用另一份。

封装源码的 MakeSign 里有一个日志输出空位,常见做法是加一个#if DEBUG分支,把拼接后的 signContent 随请求报文一起输出。上线时这些敏感信息要打码,密钥日志不落盘。

string signContent = sb.ToString(); System.Diagnostics.Debug.WriteLine($"[WxPay] signContent={signContent}");

这段调试代码只会在 DEBUG 编译下执行,因为 Debug.WriteLine 本身在 Release 下会被 JIT 忽略,但仍不建议长期保留。更稳妥的方案是用条件编译器常量或 ILogger 注入,在测试环境把签名原串写到独立日志文件,生产环境只记录签名串的哈希值。

5.2 本地模拟回调:用 Postman 验证 Notify 接口

不需要等真实订单,也可以在本地完整验证回调逻辑。先在统一下单请求里拿到 code_url,再找到统一下单响应的 XML 结构,把 prepay_id、return_code、result_code 等字段替换成测试订单对应的值,构造一条模拟支付通知。直接复制官方文档中的示例报文会导致验签失败,因为示例报文的签名是用官方密钥生成的,必须重新用本地配置的密钥和随机 nonce_str 算一次签名。

模拟回调用 Postman 或 curl 发送 POST 请求,Content-Type 设置为 application/xml。注意本地联调时 notify_url 不能是 localhost,微信服务器访问不到;测试环境可以借助内网穿透工具,把本机端口暴露到公网。发送后观察返回值,成功返回 SUCCESS,失败返回 FAIL。通过这种方式,可以在开发阶段就把验签、金额核对、幂等逻辑全部跑通,而不是每次都要等真实支付完成。

本质是:熟悉这套源码包的接口封装后,自动化和回归测试的收益会非常大。微信支付本身是外部依赖,不能保证测试环境随时可用,因此我始终会在支付模块里留一个“模拟回调”的开关,仅限测试环境打开,杜绝线上误用。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询