简介:面向C#开发者的微信支付与微信企业付款实战代码包,聚焦商户端支付集成与企业向个人转账两类场景,适合已具备一定.NET基础、需要快速跑通支付流程的中级开发者参考。包内共93个文件,以47个cs源码文件为核心,配合13个aspx页面、8个dll依赖库及5个config配置文件,另有少量js脚本与项目解决方案文件,压缩包约2.13MB,整体按SDK、业务层与示例页面分层组织。内容覆盖JSAPI支付、刷卡支付、扫码支付等接入方式,并演示企业付款到零钱、订单退款、订单与退款查询、账单下载等接口的调用链路,附带的SDK封装了签名验证、请求发送与结果处理等环节,便于对照调试。已有1578人学习下载,可作为理解微信支付接口参数与业务流程的参考样例。
1. 从一笔“提现到零钱”说起:这个 C# Demo 到底解决什么问题
做过支付对接的同学大概都有过这种体验:普通扫码支付、JSAPI 支付跑通得挺顺,一到“企业给个人打钱”就卡壳——商户号要单独开通权限、证书要单独下载、金额单位是分不是元、收款人姓名要加密传输,随便一个参数对不上,接口就返回一个看不懂的错误码。这个 C# Demo 就是冲着这个痛点来的:它把微信支付里的普通支付、企业付款到个人、企业付款到零钱这几条链路,用一套可以直接跑起来的 C# 代码串了起来,适合正在做提现、佣金结算、红包发放、退款返现这类功能的开发者拿来对照改造。下面我按“能干什么 → 怎么跑 → 核心代码怎么读 → 怎么改”的顺序,把这个 Demo 拆开讲一遍。
2. 项目定位与技术栈:为什么是 C# 而不是拼凑脚本
2.1 这个 Demo 覆盖的三条支付链路
从标题就能看出,它不是一个单点示例,而是把微信支付体系里三条容易混淆的链路放在了一起。第一条是普通微信支付,也就是用户付钱给商户,常见形态是扫码支付和 JSAPI 支付;第二条是企业付款到个人,商户号主动把钱打给某个微信用户;第三条是企业付款到零钱,本质上是第二条的一个具体落地形态,钱最终进到用户的微信零钱账户里。这三条链路在签名方式、证书使用、金额单位上都有差异,Demo 把它们并列放在一个工程里,最大的价值是让你能横向对比:哪些参数是共用的,哪些是这条链路独有的。
很多新手会把“企业付款到零钱”和“普通退款”搞混。退款是把用户已经付过的钱退回去,走的是退款接口,原路返回;而企业付款到零钱是商户主动发起的一笔新支出,收款方可以是任何符合条件的微信用户,不要求他之前在你这里消费过。这个区别决定了它们在证书、权限、风控上的要求完全不同,Demo 把两者分开实现,正好帮你把概念理清楚。
2.2 技术栈与运行环境
这个 Demo 是纯 C# 实现,没有绑定特定框架,控制台程序或者 ASP.NET 项目里都能直接引用。核心依赖是 .NET 自带的加密库和 HTTP 请求能力,签名用 RSA,请求走 HTTPS,数据格式是 XML——微信支付早期接口体系基本都以 XML 为主,这一点在代码里会反复出现。运行环境上,你需要一个能跑 .NET 的机器,Windows 上装好对应版本的 .NET SDK 即可,Linux 下用 dotnet 命令行也能编译运行。
证书是绕不开的一环。企业付款类接口要求使用 API 证书,也就是商户平台下载的 apiclient_cert.p12 文件,加载时需要商户号作为证书密码。Demo 里会体现证书的加载和签名过程,这部分是新手最容易翻车的地方,后面单独讲。整体技术栈不复杂,但每一个环节都卡得很死,参数错一位就通不过。
2.3 目录结构与模块划分
一个结构清晰的支付 Demo,通常会按“配置、签名、请求、业务”四层来组织。下面是我按这个 Demo 的定位整理出的典型目录形态,你拿到源码后可以对照着看:
WeChatPayDemo/ ├── Config/ │ └── PayConfig.cs # 商户号、AppId、密钥、证书路径等配置 ├── Common/ │ ├── SignHelper.cs # 签名与验签 │ ├── CertHelper.cs # p12 证书加载 │ └── XmlHelper.cs # XML 与对象互转 ├── Service/ │ ├── WxPayService.cs # 普通支付下单、查询 │ └── TransferService.cs # 企业付款到个人/零钱 └── Program.cs # 入口,演示各链路调用这种划分的好处是配置和业务分离,签名逻辑集中在一处。你二次开发时,改配置只动 Config,加新接口只动 Service,不会牵一发动全身。如果拿到的源码目录名略有不同,按这个职责去对应即可,核心是找到签名和证书这两个工具类,它们是所有链路的公共底座。
3. 跑起来:从配置到第一笔请求的完整步骤
3.1 准备商户号、AppId 与 API 密钥
跑通之前,先把四样东西备齐:商户号、AppId、API 密钥、API 证书。商户号和 AppId 在商户平台和公众平台分别能看到,API 密钥是你在商户平台自己设置的 32 位字符串,用于部分接口的签名;API 证书则是下载下来的 p12 文件,企业付款类接口必须用它。这四样缺一不可,而且 API 密钥和证书密码不是一回事,别混。
配置通常集中写在一个类里,Demo 里对应 PayConfig。下面是一个典型的配置写法,字段名按你实际源码调整:
public static class PayConfig { // 商户号,企业付款接口的必填项 public const string MchId = "1900000000"; // 绑定商户号的 AppId public const string AppId = "wxd000000000000000"; // API 密钥,32 位,用于 MD5/HMAC 类签名 public const string ApiKey = "your32lengthapikey00000000000000"; // p12 证书的绝对路径,企业付款必须 public const string CertPath = @"C:\certs\apiclient_cert.p12"; // 证书密码,通常就是商户号 public const string CertPassword = MchId; }这里每个字段都有明确用途:MchId 参与几乎所有请求的公共参数;AppId 决定这笔交易归属哪个应用;ApiKey 用于生成签名;CertPath 和 CertPassword 只在需要证书的接口里用到。把证书密码直接设成商户号是微信的约定,但代码里最好保留独立字段,方便以后换证书时不用改逻辑。
3.2 加载 p12 证书并完成签名
企业付款到零钱这类接口,请求必须带证书,签名也必须用证书私钥做 RSA 签名。加载证书的常见做法是从文件读入字节,用商户号作为密码构造 X509Certificate2,再取出私钥参与签名。下面这段是证书加载和签名的核心:
// 从 p12 文件加载证书,密码为商户号 var cert = new X509Certificate2( PayConfig.CertPath, PayConfig.CertPassword, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.Exportable); // 用证书私钥对“待签名字符串”做 SHA256 签名 using var rsa = cert.GetRSAPrivateKey(); var data = Encoding.UTF8.GetBytes(signSource); var signature = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var signBase64 = Convert.ToBase64String(signature);逻辑上分两步:先拿到证书对象,再用它的私钥对拼接好的待签名字符串签名。signSource 是把参与签名的参数按字典序拼成 key=value&key=value 的形式,最后拼上 key=API密钥,这一步顺序错了签名必错。X509KeyStorageFlags 里加 Exportable 是为了某些环境下能正常导出私钥,MachineKeySet 则避免用户态权限问题。签名结果做 Base64 后放进请求的 sign 字段。
3.3 组装 XML 请求并发送
微信支付早期接口用 XML 传参,请求体是一段 XML 字符串,公共参数包括 appid、mch_id、nonce_str、sign,业务参数按接口不同而不同。组装时先把所有参数放进字典,生成签名,再把签名塞回字典,最后序列化成 XML。发送用 HttpClient 以 POST 方式提交到对应接口地址,企业付款到零钱的地址和普通支付不同,别用错。
var dict = new Dictionary<string, string> { ["mch_appid"] = PayConfig.AppId, ["mchid"] = PayConfig.MchId, ["nonce_str"] = Guid.NewGuid().ToString("N"), ["partner_trade_no"] = "T" + DateTime.Now.ToString("yyyyMMddHHmmss"), ["openid"] = "oUserOpenIdxxxxxxxxxxxx", ["check_name"] = "NO_CHECK", // 不校验真实姓名 ["amount"] = "100", // 单位:分 ["desc"] = "佣金提现", ["spbill_create_ip"] = "127.0.0.1" }; dict["sign"] = SignHelper.Sign(dict, PayConfig.ApiKey); // 生成签名 var xml = XmlHelper.ToXml(dict); // 转 XML var resp = await httpClient.PostAsync(url, new StringContent(xml, Encoding.UTF8, "text/xml"));参数里几个点值得注意:partner_trade_no 是商户侧唯一单号,重复会报错;amount 单位是分,100 表示 1 元;check_name 设为 NO_CHECK 表示不校验收款人真实姓名,设为 FORCE_CHECK 则必须传 re_user_name。spbill_create_ip 填服务器出口 IP 即可。请求发出后,返回同样是 XML,解析出 return_code、result_code 和 err_code 才能判断成败。
4. 核心代码拆解:签名、请求与结果处理
4.1 签名算法的实现细节
签名是整个支付链路的地基,错一个字符全盘皆输。微信的签名规则是:剔除空值和 sign 字段,按参数名 ASCII 码从小到大排序,拼成 key=value&key=value 的形式,最后拼接 &key=API密钥,再做 MD5 或 HMAC-SHA256,转大写。企业付款类接口用的是 MD5 或 HMAC-SHA256,具体看接口文档,Demo 里通常两种都实现。下面是一个 MD5 版本的签名实现:
public static string Sign(Dictionary<string, string> dict, string apiKey) { // 1. 过滤空值和 sign 本身,按 key 字典序排序 var sorted = dict .Where(kv => !string.IsNullOrEmpty(kv.Value) && kv.Key != "sign") .OrderBy(kv => kv.Key, StringComparer.Ordinal) .ToList(); // 2. 拼成 key=value&key=value 形式 var sb = new StringBuilder(); foreach (var kv in sorted) sb.Append($"{kv.Key}={kv.Value}&"); // 3. 末尾拼上 API 密钥,做 MD5 并转大写 sb.Append($"key={apiKey}"); using var md5 = MD5.Create(); var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); return BitConverter.ToString(hash).Replace("-", "").ToUpper(); }这段代码的关键在排序和拼接。OrderBy 用 Ordinal 保证按 ASCII 码比较,不能用默认的区域性排序,否则大小写字母顺序会变。空值必须剔除,因为微信服务端也是这么算的,你多拼一个空参数签名就对不上。最后 MD5 结果转大写十六进制,这是微信要求的格式。如果接口要求 HMAC-SHA256,把 MD5 那段换成 HMACSHA256 即可,密钥作为 HMAC 的 key。
4.2 企业付款到零钱的请求封装
把签名和请求串起来,就是一个完整的付款方法。下面这段封装了从参数组装到结果解析的全过程,是 Demo 里最值得抄的部分:
public async Task<TransferResult> PayToWalletAsync(string openId, int amountFen, string desc) { var dict = new Dictionary<string, string> { ["mch_appid"] = PayConfig.AppId, ["mchid"] = PayConfig.MchId, ["nonce_str"] = Guid.NewGuid().ToString("N"), ["partner_trade_no"] = "T" + DateTime.Now.ToString("yyyyMMddHHmmssfff"), ["openid"] = openId, ["check_name"] = "NO_CHECK", ["amount"] = amountFen.ToString(), ["desc"] = desc, ["spbill_create_ip"] = "127.0.0.1" }; dict["sign"] = SignHelper.Sign(dict, PayConfig.ApiKey); var xml = XmlHelper.ToXml(dict); var handler = new HttpClientHandler(); handler.ClientCertificates.Add(CertHelper.Load()); // 挂载 p12 证书 using var client = new HttpClient(handler); var content = new StringContent(xml, Encoding.UTF8, "text/xml"); var respXml = await client.PostAsync(TransferUrl, content).Result.Content.ReadAsStringAsync(); var resp = XmlHelper.FromXml(respXml); // 解析为字典 return new TransferResult { Success = resp["return_code"] == "SUCCESS" && resp["result_code"] == "SUCCESS", ErrCode = resp.GetValueOrDefault("err_code"), ErrMsg = resp.GetValueOrDefault("err_code_des"), PaymentNo = resp.GetValueOrDefault("payment_no") }; }这段代码有几个设计点。第一,HttpClientHandler 上挂了证书,这是企业付款接口的硬性要求,不挂证书服务端直接拒绝。第二,partner_trade_no 用时间戳加毫秒,降低重复概率,但生产环境最好用业务单号,保证幂等。第三,结果判断要同时看 return_code 和 result_code,前者是通信层,后者是业务层,只有两个都 SUCCESS 才算真正成功。第四,payment_no 是微信侧的付款单号,对账时用得上,务必落库。
4.3 返回结果的解析与错误码处理
微信返回的 XML 里字段很多,解析成字典后按需取。常见的错误码有:SYSTEMERROR 表示系统繁忙,可以用同一单号重试;AMOUNT_LIMIT 表示金额超限;NOTENOUGH 表示商户余额不足;V2_ACCOUNT_SIMPLE_BAN 表示账户异常。这些错误码不是让你背,而是要在代码里做分支处理。比如 SYSTEMERROR 可以重试,NOTENOUGH 重试也没用,得去充值。Demo 里通常只做成功失败判断,二次开发时建议把错误码映射成业务提示,别把原始错误直接抛给用户。
switch (result.ErrCode) { case "SYSTEMERROR": // 系统繁忙,用原单号重试,避免重复付款 return await RetryWithSameNoAsync(partnerTradeNo); case "NOTENOUGH": return Fail("商户余额不足,请充值后重试"); case "AMOUNT_LIMIT": return Fail("金额超出限制"); default: return Fail(result.ErrMsg ?? "付款失败"); }这里要强调幂等:重试必须用同一个 partner_trade_no,微信会根据这个单号去重,不会重复打款。如果你重试时换了新单号,就可能出现一笔业务打两次钱的事故,这是血泪经验,务必记住。
5. 避坑与排查:那些让请求失败的小细节
5.1 签名总是不对
现象是返回签名错误,但参数看着都对。原因通常是排序用了区域性比较、空值没剔除、或者拼接时多了一个 &。解决方法是把待签名字符串打印出来,和微信文档里的示例逐字符比对,重点看排序是否按 ASCII、末尾是否只拼一次 key。另外注意 API 密钥前后不能有空格,从商户平台复制时容易带上。
5.2 证书加载报错
现象是加载 p12 时抛异常,提示密码错误或权限不足。原因是证书密码不是商户号,或者运行账户没有读取私钥的权限。解决方法是确认密码就是商户号,Linux 下注意文件权限,Windows 下用 MachineKeySet 避免用户态限制。如果还是不行,把证书导入系统证书存储再按指纹加载。
5.3 金额单位搞错
现象是付款金额和预期差 100 倍。原因是微信金额单位是分,你传了元。解决方法是在业务层统一用分做单位,展示时再除以 100,别在中间层来回转换。这个坑几乎每个新手都踩过,代码里加个注释提醒自己。
5.4 重复付款
现象是同一笔业务打出去两次钱。原因是重试时换了新单号,或者网络超时后没查单就重发。解决方法是 partner_trade_no 用业务唯一单号,超时先调查询接口确认状态,确认未成功再重试,且重试必须用原单号。
5.5 收款人 openid 不对
现象是返回 openid 错误或用户不存在。原因是 openid 是相对于 AppId 的,你用 A 应用的 openid 去 B 应用付款必然失败。解决方法是确保 openid 和 mch_appid 对应同一个应用,跨应用场景要先做账号打通。
6. 二次开发:把这套 Demo 改成能上生产的模块
拿到 Demo 跑通只是第一步,真正要落地,得把它改造成能扛住生产流量的模块。我一般会做三件事:把配置外置到配置文件或环境变量,别硬编码在类里;把签名、证书加载、请求发送抽成独立服务,用依赖注入管理;把付款结果落库并加对账任务。下面是一个改造后的服务接口示例:
public interface ITransferService { Task<TransferResult> PayToWalletAsync(TransferRequest req); Task<TransferResult> QueryAsync(string partnerTradeNo); } public class TransferRequest { public string OpenId { get; set; } public int AmountFen { get; set; } public string BizNo { get; set; } // 业务唯一单号,直接作 partner_trade_no public string Desc { get; set; } }改造的关键是把 partner_trade_no 和业务单号绑定,这样重试、查询、对账都能对上。再配一个定时任务,把当天所有付款单拿去和微信账单比对,发现差异及时告警。验证方法很简单:先用 1 分钱跑通全链路,再模拟超时和重复请求,确认幂等生效。下面这张表是我改造时常用的检查项:
| 检查项 | 目的 | 通过标准 |
|---|---|---|
| 配置外置 | 避免硬编码泄露 | 密钥不在源码中 |
| 单号幂等 | 防重复付款 | 同单号重试不重复扣款 |
| 结果落库 | 支持对账 | 每笔有状态记录 |
| 错误码映射 | 友好提示 | 用户看到可读信息 |
| 对账任务 | 发现差异 | 日终账单一致 |
从那以后我每次接支付类需求,都会先把“单号幂等”和“结果落库”这两件事写进设计里,再动手写业务代码,因为支付这块没有后悔药,钱出去了就很难追。希望这套拆解能帮到你,把 Demo 跑起来只是开始,改成能上生产的模块才算真正掌握。
本文还有配套的精品资源,点击获取