简介:面向C#开发者的微信支付与微信企业付款实战代码包,聚焦商户端支付集成与企业向个人转账两类场景,适合已有一定.NET基础、需要快速跑通支付链路的中初级开发者参考。压缩包共93个文件、约2.13MB,以47个cs源码文件为核心,配合13个aspx页面、8个dll依赖库及若干config配置、js脚本与项目解决方案文件,构成可直接编译调试的Web示例工程。内容覆盖JSAPI支付、刷卡支付、扫码支付等接入方式,并延伸至企业付款到零钱、订单退款、订单与退款状态查询、账单下载等环节,SDK封装了预支付会话获取、签名验证、请求发送与结果处理等关键步骤。已有1578人学习下载,读者可据此对照梳理支付流程、签名规则与回调处理思路,减少从零搭建时的试错成本。
1. 从一笔“提现到零钱”说起:这个 C# Demo 到底解决什么问题
做过微信生态内资金流转的开发者大概都遇到过这种需求:用户在自家 App 里攒了积分、佣金或者分销收益,想直接提到微信零钱里花掉。这时候普通的下单支付接口就不够用了,得走企业付款到零钱这条链路。这个 C# Demo 干的就是这件事——把微信支付里的普通支付、企业付款到个人、企业付款到零钱这几条最容易混淆的接口,用一套可运行的代码串起来,让你看清楚每一笔钱从商户号出发到用户零钱落地,中间到底签了什么名、传了什么参数、返回了什么结果。适合正在接微信转账能力、被证书和签名折腾过的 C# 后端,也适合想搞明白企业付款和普通支付差别的二次开发者。
2. 技术栈与目录结构:先看清这套 Demo 的骨架
拿到一个支付类 Demo,我一般不会急着点运行,而是先把目录翻一遍。支付代码最怕的就是“能跑但不知道为什么能跑”,等换到自己项目里,证书路径、商户号、回调地址一改就全线崩。所以这一章先把技术选型和工程结构讲透,后面读代码才有参照系。
2.1 技术选型:为什么是 .NET + 原生 HttpClient
这套 Demo 用的是 C# 语言,运行在 .NET 平台上,常见做法是 .NET Framework 4.6 以上或者 .NET Core / .NET 6+ 都能跑,具体取决于你拿到的工程文件是.csproj的老式格式还是 SDK 风格。它没有引入第三方微信 SDK,而是直接用HttpClient发 HTTPS 请求、用System.Security.Cryptography自己做签名。这个选择对二次开发其实是好事——依赖少,出问题好定位,不会出现“SDK 内部报错但看不到请求体”的黑匣子情况。
微信支付的接口本质上就是几个 HTTPS 端点,请求体是 XML(老版 v2 接口)或 JSON(新版 v3 接口),签名规则写在文档里。Demo 里企业付款到零钱走的是 v2 的transfers接口,普通支付走的是统一下单unifiedorder,两者都用 MD5 或 HMAC-SHA256 做签名。用原生 HttpClient 意味着签名、组包、验签这些环节全部暴露在你眼前,改起来心里有底。
工程里通常还会依赖System.Security.Cryptography.X509Certificates来加载 API 证书,因为企业付款到零钱这个接口强制要求双向证书校验——这是它和普通支付最大的区别,普通支付只要商户号和密钥,企业付款必须带证书。
2.2 目录结构:几个关键文件各管什么
一个典型的工程结构大概长这样,不同人整理习惯略有差异,但核心文件跑不掉:
WeChatPayDemo/ ├── Program.cs // 入口,演示各接口调用 ├── Config/ │ └── WxPayConfig.cs // 商户号、AppId、密钥、证书路径集中管理 ├── Core/ │ ├── WxPayApi.cs // 统一下单、企业付款等接口封装 │ ├── WxPayData.cs // 参数字典 + 签名 + XML 序列化 │ └── HttpService.cs // 带证书的 HttpClient 封装 ├── Model/ │ ├── UnifiedOrderRequest.cs │ └── TransferRequest.cs ├── cert/ │ ├── apiclient_cert.p12 // API 证书(需自行下载) │ └── apiclient_key.pem └── README.mdWxPayConfig.cs是所有敏感信息的集中地,商户号MchId、应用AppId、API 密钥Key、证书路径CertPath、证书密码CertPassword都在这里。我一般会把它改成从环境变量或配置中心读取,绝不硬编码进仓库——这是血泪经验,密钥一旦进了 Git 历史,清理起来非常麻烦。
WxPayData.cs是整套代码的心脏,它内部维护一个字典,负责把参数拼成待签名字符串、计算签名、再序列化成 XML。WxPayApi.cs则是业务层,把统一下单、企业付款这些动作封装成方法,调用方只需要传业务参数。HttpService.cs单独抽出来是因为企业付款需要挂客户端证书,普通请求不需要,分开封装避免互相污染。
2.3 配置项清单:跑之前必须填对的几个值
在动手运行前,把下面这张表里的值核对一遍,缺一个都跑不通:
| 配置项 | 含义 | 从哪来 |
|---|---|---|
| AppId | 公众号/小程序/商户应用 ID | 微信商户平台或公众平台 |
| MchId | 商户号 | 商户平台账户中心 |
| Key | API 密钥(32 位) | 商户平台 API 安全里自行设置 |
| CertPath | apiclient_cert.p12 路径 | 商户平台 API 安全里下载 |
| CertPassword | 证书密码,默认是商户号 | 下载证书时提示 |
| NotifyUrl | 支付结果回调地址 | 你自己的公网可访问地址 |
注意:企业付款到零钱的证书密码默认就是商户号本身,很多人第一次跑报“证书加载失败”,八成是密码填错或者 p12 文件放错目录。另外NotifyUrl必须是公网 HTTPS 地址,本地调试可以用内网穿透工具临时映射一个,但别把穿透地址写死进代码。
3. 核心链路拆解:签名、下单、企业付款三段代码
这一章是整篇的重点。支付类代码的难点从来不在业务逻辑,而在签名和证书这两块“玄学”区域。我把 Demo 里最关键的三个环节拆开讲:参数签名怎么算、统一下单怎么发、企业付款到零钱怎么带证书请求。每段代码后面都会说清楚参数含义和容易翻车的地方。
3.1 签名模块:WxPayData 如何把字典变成签名字符串
微信 v2 接口的签名规则说穿了就一句话:把所有非空参数按 key 的 ASCII 码升序排列,拼成key=value&key=value的形式,末尾加上&key=你的API密钥,然后做 MD5 或 HMAC-SHA256,最后转大写。听起来简单,但“非空”“升序”“末尾加 key”这三个细节任何一个出错,签名就对不上。
public class WxPayData { private readonly SortedDictionary<string, object> _values = new SortedDictionary<string, object>(StringComparer.Ordinal); // 添加参数,sign 字段不参与签名 public void SetValue(string key, object value) { if (key == "sign") return; _values[key] = value; } // 生成待签名字符串 public string MakeSignString(string apiKey) { var sb = new StringBuilder(); foreach (var kv in _values) { if (kv.Value == null || kv.Value.ToString() == "") continue; sb.Append(kv.Key).Append("=").Append(kv.Value).Append("&"); } sb.Append("key=").Append(apiKey); return sb.ToString(); } // MD5 签名并转大写 public string MakeSign(string apiKey) { var raw = MakeSignString(apiKey); using var md5 = MD5.Create(); var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(raw)); var hex = BitConverter.ToString(bytes).Replace("-", ""); return hex.ToUpperInvariant(); } }这段代码有几个点值得说。第一,用SortedDictionary配合StringComparer.Ordinal保证按 ASCII 升序,别用默认的字符串比较器,否则大小写字母顺序会错。第二,SetValue里直接跳过sign字段,避免自己把自己签进去。第三,MakeSignString里过滤了空值,因为微信要求空参数不参与签名,但很多人传了空字符串进去导致签名不一致。
参数说明:apiKey就是商户平台设置的 32 位 API 密钥,不是 AppSecret,这两个经常被搞混。MakeSign返回的是 32 位大写十六进制字符串,直接塞回请求参数的sign字段即可。
我踩过的坑是:某次参数值里带了中文,编码用了Encoding.Default而不是 UTF-8,本地测试没问题,上线后签名全错。从那以后我所有签名入口都强制Encoding.UTF8,不留余地。
3.2 统一下单:普通支付请求怎么组包和发送
普通支付(比如 JSAPI 支付)走的是https://api.mch.weixin.qq.com/pay/unifiedorder,请求体是 XML,不需要证书。Demo 里把它封装成一个方法,调用方传业务参数,内部完成签名、组 XML、发请求、解析返回。
public async Task<WxPayData> UnifiedOrderAsync(WxPayData input, string apiKey) { // 1. 补全公共参数 input.SetValue("appid", WxPayConfig.AppId); input.SetValue("mch_id", WxPayConfig.MchId); input.SetValue("nonce_str", Guid.NewGuid().ToString("N")); input.SetValue("notify_url", WxPayConfig.NotifyUrl); // 2. 计算签名 var sign = input.MakeSign(apiKey); input.SetValue("sign", sign); // 3. 序列化为 XML 并发送 var xml = input.ToXml(); using var client = new HttpClient(); var content = new StringContent(xml, Encoding.UTF8, "application/xml"); var resp = await client.PostAsync( "https://api.mch.weixin.qq.com/pay/unifiedorder", content); var respXml = await resp.Content.ReadAsStringAsync(); // 4. 解析返回并验签 var result = WxPayData.FromXml(respXml); if (!result.CheckSign(apiKey)) throw new Exception("返回签名校验失败"); return result; }逻辑说明:第一步补全appid、mch_id、nonce_str、notify_url这些每个请求都要带的公共参数,nonce_str用 32 位随机串防止重放。第二步算签名,注意签名必须在所有业务参数都塞进去之后算,顺序错了签名必错。第三步序列化成 XML,Content-Type必须是application/xml,用text/xml有些环境会出问题。第四步解析返回后一定要验签,别拿到return_code=SUCCESS就信了,返回体也可能被篡改。
参数说明:input里至少要包含body(商品描述)、out_trade_no(商户订单号)、total_fee(金额,单位分)、trade_type(如 JSAPI 还要带openid)。total_fee是整数分,别传元,这是新手最常见的金额错误。
3.3 企业付款到零钱:带证书的双向校验请求
企业付款到零钱走的是https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers,和统一下单最大的区别是必须挂客户端证书做双向 TLS 校验。Demo 里HttpService专门处理这件事。
public class HttpService { // 创建带客户端证书的 HttpClientHandler public static HttpClientHandler CreateCertHandler( string certPath, string certPassword, string mchId) { var handler = new HttpClientHandler(); var cert = new X509Certificate2( certPath, certPassword, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet); handler.ClientCertificates.Add(cert); return handler; } public static async Task<string> PostWithCertAsync( string url, string xml, string certPath, string certPassword, string mchId) { var handler = CreateCertHandler(certPath, certPassword, mchId); using var client = new HttpClient(handler); var content = new StringContent(xml, Encoding.UTF8, "application/xml"); var resp = await client.PostAsync(url, content); return await resp.Content.ReadAsStringAsync(); } }逻辑说明:X509Certificate2加载 p12 证书时,X509KeyStorageFlags的选择很关键。在 IIS 或 Windows 服务里跑,用MachineKeySet避免用户配置文件权限问题;在容器或 Linux 上跑,PersistKeySet更稳。证书加载成功后挂到ClientCertificates集合,HttpClient 发请求时会自动完成双向握手。
参数说明:certPath是 p12 文件绝对路径,certPassword默认等于商户号,mchId在部分场景用于校验。企业付款请求体里必须带partner_trade_no(商户付款单号,唯一)、openid(收款用户)、amount(金额,分)、desc(付款描述)、check_name(是否校验真实姓名,NO_CHECK或FORCE_CHECK)。check_name选FORCE_CHECK时还要传re_user_name,否则会报“参数错误”。
这里有个容易忽略的点:企业付款到零钱的金额有下限,单笔最低 0.3 元(30 分),低于这个数接口直接拒绝。另外付款单号partner_trade_no必须全局唯一,重复提交会返回“单号已存在”,做重试逻辑时要用同一个单号,别每次重试都生成新的。
4. 避坑与排查:支付联调里最容易翻车的五件事
支付联调的痛苦程度,做过的人都懂。下面这五条是我从实际项目里攒下来的,每条都按“现象 → 原因 → 解决”写,照着排查能省不少时间。
4.1 签名一直报错,但参数看着都对
现象:请求返回签名错误,把参数打印出来逐字核对,和文档示例一模一样。
原因:八成是编码问题或者参数顺序问题。微信要求 UTF-8 编码,且签名串必须按 ASCII 升序。如果参数值里有中文、空格或特殊字符,用错编码就会导致 MD5 结果不同。还有一种情况是SortedDictionary用了默认比较器,大小写字母排序和 ASCII 不一致。
解决:签名前统一Encoding.UTF8,排序用StringComparer.Ordinal。调试时把待签名字符串完整打印出来,和微信官方签名工具算出来的结果对比,一眼就能看出差异。
4.2 企业付款报“证书错误”或握手失败
现象:统一下单正常,一切到企业付款就抛异常,提示证书加载失败或 TLS 握手失败。
原因:p12 证书路径不对、密码填错,或者X509KeyStorageFlags在当前运行环境不适用。在 IIS 下用UserKeySet可能因为应用池账户没权限而失败。
解决:先确认 p12 文件存在且密码是商户号;Windows 服务/IIS 用MachineKeySet,Linux/容器用PersistKeySet;实在不行把证书导入系统证书存储再按指纹加载。另外确认服务器时间准确,TLS 握手对时间敏感。
4.3 付款成功但用户没收到钱
现象:接口返回result_code=SUCCESS,但用户零钱没到账。
原因:企业付款到零钱不是实时到账,微信侧有处理延迟,通常几分钟内到账。还有一种情况是用户微信账户异常(未实名、被限制),资金会退回商户号。
解决:不要以接口返回作为最终到账依据,要查付款查询接口gettransferinfo确认状态,或者等异步通知。对账时以商户号资金流水为准,别只看业务库。
4.4 金额传错单位,多付了十倍
现象:测试时付了 1 元,结果用户收到 10 元。
原因:微信金额单位是分,代码里传了元。total_fee=1表示 1 分,不是 1 元。
解决:所有金额在入口处统一转成整数分,用decimal算完再*100取整,别用double避免精度问题。我一般会在参数封装层加一个AmountHelper.ToFen(decimal yuan)强制转换,杜绝手写。
4.5 回调地址收不到通知
现象:支付成功但NotifyUrl一直没收到请求。
原因:回调地址不是公网可访问、不是 HTTPS、或者返回内容不符合微信要求(必须返回<xml><return_code>SUCCESS</return_code></xml>)。
解决:先用工具确认地址公网可达;回调处理里无论业务成功与否都先返回 SUCCESS,业务逻辑异步处理;本地调试用内网穿透临时映射,但别把穿透域名写死进生产配置。
5. 二次开发改造:从 Demo 到能上生产的几个动作
Demo 能跑通只是起点,真要接到自己项目里,还得做几件事。这一章讲我怎么把这套代码改造成生产可用的样子,包括配置外置、异常重试、对账补偿,最后用一个具体技巧收尾。
5.1 配置外置与密钥管理
Demo 里WxPayConfig大概率是静态类硬编码,生产环境必须改。我一般把它换成IOptions<WxPayOptions>注入,值从配置中心或环境变量读。证书文件不放仓库,部署时挂载到固定路径,路径也走配置。API 密钥和证书密码用密钥管理服务托管,代码里只拿引用。这样换商户号、轮换密钥都不用改代码重新发版。
5.2 付款重试与幂等设计
企业付款最怕重复付款。改造时我会给每笔付款生成唯一的partner_trade_no存库,重试时复用同一个单号。微信对重复单号会返回“单号已存在”,这时候去查gettransferinfo拿真实状态,而不是盲目再发一次。重试策略用指数退避,最多三次,超过就进人工处理队列。下面是个简化的幂等封装思路:
public async Task<TransferResult> TransferWithRetryAsync(TransferRequest req) { // 单号在业务层生成并持久化,重试复用 var existing = await _repo.GetByTradeNoAsync(req.PartnerTradeNo); if (existing != null && existing.Status == "SUCCESS") return TransferResult.AlreadyDone(existing); for (int i = 0; i < 3; i++) { try { var resp = await _wxPay.TransferAsync(req); if (resp.IsSuccess) return TransferResult.Ok(resp); if (resp.IsDuplicate) return await QueryAndSyncAsync(req.PartnerTradeNo); } catch (HttpRequestException) when (i < 2) { await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i))); } } return TransferResult.NeedManualReview(req.PartnerTradeNo); }这段代码的关键是“先查后发”和“重复单号转查询”。PartnerTradeNo由业务层生成并落库,重试时从库里取,保证幂等。IsDuplicate判断返回的错误码,命中就走查询同步,而不是再发一次。
5.3 对账与补偿任务
再稳的接口也会有漏网之鱼,所以我会加一个定时对账任务:每天拉取商户平台的资金流水,和业务库里的付款记录比对,找出“业务成功但微信失败”和“微信成功但业务未更新”两类差异,前者触发退款或重试,后者补状态。对账文件从商户平台下载,解析后按partner_trade_no关联。这个任务跑起来之后,支付相关的客诉能降一大截。
5.4 一个具体技巧:用沙箱环境先验签名
微信支付有沙箱环境,虽然企业付款到零钱在沙箱里支持有限,但签名和统一下单流程可以完整验证。我的习惯是:任何签名逻辑改动,先在沙箱跑通统一下单,确认签名无误,再切到生产环境测企业付款。这样能把“签名错”和“证书错”两类问题分开定位,不至于一上来就混在一起排查。
改造这套 Demo 的过程中,我最大的教训是:支付代码里任何“看起来能跑”的捷径,最后都会在某个深夜变成线上事故。从那以后我每次改签名或证书相关代码,都强制走一遍沙箱验证加对账比对,绝不跳过。希望这套拆解能帮到你,少走几个弯路。
本文还有配套的精品资源,点击获取