简介:面向C#开发者的微信支付与转账集成示例包,覆盖微信支付、企业支付到个人、企业付款到零钱等核心场景,适合需要快速接入微信支付API的初中级工程师。压缩包共93个文件,以C#源码(47个cs)、ASP.NET页面(13个aspx)、DLL库(8个dll)和配置文件(5个config)为主,整体大小仅2.13MB,目录结构清晰,便于按功能模块查阅。目前已有1574人学习下载。资源基于CS_sdk_v3.0.9封装,提供了WxPayAPI完整项目示例,包含JSAPI支付、刷卡与扫码支付、企业转账到零钱、订单退款及查询、账单下载等接口的调用DEMO,并配有Default.aspx等演示页面和Web.config等运行配置。通过阅读源码可快速理解预支付会话标识获取、签名验证、请求发送与结果处理等关键流程,直接参考或二次改造用于实际业务。
1. C#调微信支付的真相:不是SDK有多难,而是证书和签名的黑匣子
C#写微信支付和企业付款到零钱,最容易翻车的不是HTTP请求,而是证书、签名和回调解密这几个黑匣子。很多开发者跟我一样,装上几个NuGet包,按照文档敲完下单代码,第一次调用就被“请求签名错误”打回来,一查平台证书、证书序列号、APIv3密钥哪个都不敢确认,最后只能拆包看请求日志。真正跑通后才发现,微信支付没有想象中复杂,只要把三样东西理清:商户API私钥、平台证书公钥、APIv3密钥,剩下的就是按格式拼参数、发请求、验回调。
这篇文章是给两类读者准备的:一类要在一周内把支付和转账DEMO跑起来,另一类维护着三年前的老系统,被V2和V3接口切换折腾得想重构。我会用最小可运行的C#代码带你走完微信支付下单、回调验签解密、企业付款到零钱这三个核心流程,每个环节都标注参数含义和容易踩的坑。不讲大而全的SDK封装,只讲能让你的代码真正跑通的最短路径。读过之后你会有能力自己扩展退款、查询账单,而不是继续在网上搜零碎的报错。
2. 微信支付V3的机制:签名、验签与三套API的边界
2.1 请求签名和应答验签:先解决能不能调通的问题
微信支付V3所有接口都要求商户请求带上签名,服务端返回时也会带上微信侧的数字签名,两边的证书完全不是一套。很多第一次接微信支付的人会把“商户API证书”和“平台证书”混在一起,导致下单能通但验签失败,或者反过来。
商户API证书是用来证明你身份的,你在商户平台上传证书CSR生成后,会拿到一串证书文件(pem格式)和证书序号。请求头里serial_no填的就是这个商户API证书的序列号,签名用的私钥也来自这对证书。而平台证书是微信支付自己的证书,用于验证微信返回的数据是否真的来自微信,以及解密微信支付回调里的加密资源。这两个角色必须分开。
V3请求签名的方式是官方的Authorization: WECHATPAY2-SHA256-RSA2048头,签名原文由五个部分组成:
| 部分 | 来源 | 举例 |
|---|---|---|
| HTTP方法 | 请求的method | POST |
| URL | 去掉域名的完整路径 | /v3/pay/transactions/jsapi |
| 时间戳 | 当前Unix秒 | 1737000000 |
| 随机串 | 每次请求生成的nonce | 与请求头一致 |
| 请求体 | 原始body | {"appid":"..."} |
把五部分用换行符拼起来,用商户API私钥做SHA256+RSA2048签名,再把签名结果、商户号、时间戳、nonce、证书序列号拼进Authorization头。注意URL部分是去掉协议和域名、但保留query的路径,比如/v3/pay/transactions/jsapi?tenant=xxx这种情况,签名原文里必须带query。
应答验签是反过来,用微信平台证书公钥对响应体进行验签。微信响应的头里会带Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial,你根据Serial找到对应平台证书,把时间戳、随机串、响应体拼接成和请求一样格式的原文(但这里URL不参与),再验证签名是否符合。验签通过后才说明响应确实来自微信。
还有一个容易被忽略但非常关键的点:每次请求的时间戳和随机串必须唯一且短期有效。时间戳和当前时间相差超过5分钟会被拒绝,nonce重复也会被拒绝。我习惯用Guid.NewGuid().ToString("N")生成32位随机串,足够满足需求。
2.2 三种业务API怎么选:JSAPI、Native、企业付款到零钱
微信支付名下至少有三个高频接口,新手常把几个URL搞混。JSAPI支付是用户在微信内打开的页面里调起支付,必须有用户openid,适合公众号网页、微信小程序;Native支付是扫码后出一个code_url,适合PC端收银台;企业付款到零钱则是从商户余额转钱给微信用户,本质是转账,不走支付回调那套业务流。
| API | 适用场景 | 需要openid | 回调 | 签名方式 |
|---|---|---|---|---|
| JSAPI下单 | 公众号/小程序内支付 | 是 | 支付结果通知 | V3签名 |
| Native下单 | 扫码支付 | 否 | 支付结果通知 | V3签名 |
| 企业付款到零钱(V2) | 商户余额转给用户 | 是 | 无,需主动查 | MD5+V2证书 |
| 商家转账(V3) | 新商户转账场景 | 是 | 有转账回调 | V3签名 |
从2023年起微信支付已停止新商户开通老的“企业付款到零钱”,新增的商户只能使用“商家转账”接口。如果你维护的是老项目,V2企业付款还在运转,那是老权限和旧协议,不建议拿老代码直接套到新商户号上。本篇文章先讲V2老接口的实现,再给V3商家转账方案,这样无论你是接手存量项目还是从头开发都能找到对应写法。
2.3 密钥与证书文件清单:少一个都跑不通
做联调前,先用表格确认你手上的文件是否齐全。很多翻车最后都指向同一个原因:商户平台上下载的apiclient_key.pem没放到服务器上,或APIv3密钥被随手填错一个字符。
| 文件/参数 | 获取位置 | 用途 | 缺了会怎样 |
|---|---|---|---|
| 商户API证书序列号 | 商户平台-账户中心-API安全 | 请求头serial_no | 报证书不存在 |
| apiclient_key.pem | 商户平台下载 | 请求签名私钥 | 签名错误 |
| apiclient_cert.p12 | 商户平台下载 | V2企业付款客户端证书 | V2转账报证书错误 |
| APIv3密钥 | 自己设置,32位 | 解密回调、解密敏感信息 | 回调解密失败 |
| 微信平台证书 | 官方证书接口或商户平台下载 | 响应验签 | 验签失败/安全风险 |
平台证书会定期轮换,生产环境最好实现证书自动更新,DEMO阶段可以在启动时从本地读取。不要写死证书内容在代码里,后面换证书时你会恨自己当初图省事。
3. 下单与回调DEMO:用C#写出第一个微信支付请求
3.1 封装V3签名头,把Authorization加进HttpClient
先做一个最小可用的签名方法。这个类不依赖任何第三方SDK,用.NET自带的RSA即可。下面的代码放在一个名为WxPayV3Signer.cs的文件里。
using System; using System.Security.Cryptography; using System.Text; public static class WxPayV3Signer { public static string BuildAuthorization(string method, string url, string body, string pivateKeyPem, string merchantId, string serialNo) { // 注意:url 是去掉域名但保留query的路径,比如 /v3/pay/transactions/jsapi long timestamp = DateTimeOffset.Now.ToUnixTimeSeconds(); string nonce = Guid.NewGuid().ToString("N"); // 组装签名原文,五部分之间用换行符 string message = $"{method}\n{url}\n{timestamp}\n{nonce}\n{body}\n"; using var rsa = RSA.Create(); rsa.ImportFromPem(pivateKeyPem); // .NET 5+ 可直接读PEM字符串 var data = Encoding.UTF8.GetBytes(message); var signData = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); string signature = Convert.ToBase64String(signData); // 拼Authorization头,注意字段顺序固定 return $"WECHATPAY2-SHA256-RSA2048 mchid=\"{merchantId}\",nonce_str=\"{nonce}\",signature=\"{signature}\",timestamp=\"{timestamp}\",serial_no=\"{serialNo}\""; } }逻辑说明:这个方法每次调用都会生成新的nonce,时间戳用Unix秒,签名原文严格按照微信文档拼接。其中URL部分去掉了https://api.mch.weixin.qq.com前缀,只保留路径和query。如果你用.NET Framework,RSA.ImportFromPem不可用,需要先读PEM文件提取私钥,或者用第三方库,这部分不属于DEMO核心,我一般在生产环境直接读取pem文件然后用证书对象构造RSA,逻辑是一样的。
3.2 JSAPI下单:prepay_id、openid、金额和回调地址
下单前的准备:用户在小程序或公众号里登录后获得openid,这个值由微信OAuth返回。下单请求体需要商户号、appid、描述、订单号、支付金额、回调URL和openid。金额单位是“分”,100表示1元人民币,这个单位坑很多人。
using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; public async Task<string> CreateJsapiOrderAsync(string openid, string outTradeNo, int totalFee, string description) { var requestUrl = "/v3/pay/transactions/jsapi"; var bodyJson = $@"{{ ""appid"": ""{config.Appid}"", ""mchid"": ""{config.MerchantId}"", ""description"": ""{description}"", ""out_trade_no"": ""{outTradeNo}"", ""notify_url"": ""https://yourdomain.com/wxpay/notify"", ""amount"": {{ ""total"": {totalFee}, ""currency"": ""CNY"" }}, ""payer"": {{ ""openid"": ""{openid}"" }} }}"; // 使用上面的签名方法 var auth = WxPayV3Signer.BuildAuthorization("POST", requestUrl, bodyJson, config.PrivateKeyPem, config.MerchantId, config.SerialNo); using var client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(15); client.DefaultRequestHeaders.Add("Authorization", auth); client.DefaultRequestHeaders.Add("User-Agent", "csharp-demo"); var content = new StringContent(bodyJson, Encoding.UTF8, "application/json"); var resp = await client.PostAsync("https://api.mch.weixin.qq.com" + requestUrl, content); var respText = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) { // 返回值里会有 code/message,打印出来定位问题 throw new Exception($"下单失败 {respText}"); } // 解析返回的 prepay_id // 返回json: {"prepay_id":"wx123..."} return ParsePrepayId(respText); }参数说明:totalFee为int类型,必须传分,例如63为0.63元。notify_url必须是公网HTTPS地址,微信服务器无法访问到localhost。下单成功后返回的prepay_id还没有被消费,需要把它包装成调起支付的参数传回前端。
调起支付还需要再次签名,但这次不是接口签名,而是“小程序/公众号JSAPI调起支付”私有签名。需要把appId、timeStamp、nonceStr、package=prepay_id=xxx按appId\ntimeStamp\nnonceStr\npackage=...\n的格式用同一把私钥签名,signType固定为RSA。这段逻辑很多开发者忘了,导致前端拿到prepay_id却始终调不起支付。
3.3 微信支付回调验签与解密:收到真正的成功通知
支付成功后微信会向notify_url发送POST请求,请求体是加密的,请求头里有验签所需信息。很多人在这一步卡住,最常见错误是只解密没验签,或者用商户私钥去验微信的响应。正确顺序是:先用平台证书验证请求头签名,再用APIv3密钥解密请求体。
解密使用的是AEAD_AES_256_GCM算法,需要从APIv3密钥派生出256bit的密钥。.NET没有直接提供简化封装,但可以用AesGcm类实现。
using System.Security.Cryptography; public static string DecryptCallbackResource(string ciphertext, string nonce, string associatedData, string apiv3Key) { // 将Base64的密文转成字节数组 var cipherBytes = Convert.FromBase64String(ciphertext); // 前12字节是GCM的IV,后续字节是密文+认证标签 byte[] iv = cipherBytes.Take(12).ToArray(); byte[] encrypted = cipherBytes.Skip(12).ToArray(); byte[] tag = encrypted.Take(16).ToArray(); byte[] payload = encrypted.Skip(16).ToArray(); using var aesGcm = new AesGcm(Encoding.UTF8.GetBytes(apiv3Key), tag.Length); byte[] result = new byte[payload.Length]; aesGcm.Decrypt(iv, payload, tag, result, associatedData); return Encoding.UTF8.GetString(result); }参数说明:ciphertext来自回调body的resource.ciphertext,nonce是同一个resource里的nonce,associatedData也是resource里的associated_data。APIv3密钥是你自己在商户平台设置的32位字符串,不是证书密码。解密后的json里包含订单号、交易单号、银行类型、支付金额等信息,此时才能更新订单状态为“已支付”。
要特别强调验签。将请求头中的Wechatpay-Timestamp、Wechatpay-Nonce和原始请求体按时间戳\n随机串\n请求体\n拼接,用对应Wechatpay-Serial的平台证书公钥验证Wechatpay-Signature的签名。如果跳过验签,别人伪造通知就可能骗过你的系统。DEMO阶段你可以下载平台证书pem文件放进项目,平台证书可以从微信支付官方证书API接口下载,也可以从商户平台手动下载。
4. 企业付款到零钱:V2老接口与V3商家转账的代码实现
4.1 V2企业付款到零钱:证书、XML与MD5签名
老商户号如果仍开通了“企业付款到零钱”,你会看到接口地址是/mmpaymkttransfers/promotion/transfers,它走HTTPS请求体是XML,要求携带客户端证书。这套接口用的是MD5签名,参数值千万不要手动改格式,否则签名总不过。
// 用HttpClientHandler加载p12客户端证书 var handler = new HttpClientHandler(); handler.ClientCertificates.Add(new X509Certificate2("apiclient_cert.p12", "商户证书密码")); using var httpClient = new HttpClient(handler); // 组装XML参数 string partnerTradeNo = DateTime.Now.ToString("yyyyMMddHHmmss") + new Random().Next(100, 999); string amount = "100"; // 1元,单位分 string openid = "用户openid"; string desc = "报销转账"; string spbillCreateIp = "127.0.0.1"; string xml = $@"<xml> <mch_appid>{config.Appid}</mch_appid> <mchid>{config.MerchantId}</mchid> <partner_trade_no>{partnerTradeNo}</partner_trade_no> <openid>{openid}</openid> <check_name>NO_CHECK</check_name> <amount>{amount}</amount> <desc>{desc}</desc> <spbill_create_ip>{spbillCreateIp}</spbill_create_ip> </xml>"; // 在真实项目里需要对XML节点进行MD5签名并加上sign字段 string sign = BuildMd5Sign(xml, config.V2ApiKey); xml = xml.Replace("</xml>", $"<sign>{sign}</sign></xml>"); var content = new StringContent(xml, Encoding.UTF8, "application/xml"); var resp = await httpClient.PostAsync("https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers", content);逻辑说明:V2接口的签名方式是把所有提交参数按字典序拼接成参数名=值&参数名=值...&key=商户key格式,再对整个字符串MD5。注意是应用层MD5,不是HTTPS证书。这里为控制篇幅用了简化替换,生产代码请写一个XML转字典然后统一签名的函数。check_name字段是校验用户实名,取值NO_CHECK不校验,FORCE_CHECK强制校验真实姓名。如果要校验,请求体里必须加re_user_name,并且该字段按规范要做加密处理。
这里有一个最大的坑:V2企业付款并没有支付结果异步通知,你必须主动调用查询接口来确定转账结果。微信不会因为用户收不到钱就通知你,极容易出现“显示成功但没到账”的情况。所以多发一单钱这种事,多半发生在这种主动查询频率不够高的系统里。
4.2 V3商家转账到零钱:新接口与批次明细的参数设计
新商户号现在用的是“商家转账”接口,URL为/v3/transfer/batches,支持批量最多3000笔。签名方式和微信支付V3完全一致,但请求模型多了一个“批次”和“明细”的两级结构。
public class TransferDetail { public string out_detail_no { get; set; } // 商户明细单号,唯一 public string trans_scene { get; set; } // 转账场景,如“报销”“营销”“佣金” public string transfer_remark { get; set; } // 备注 public string openid { get; set; } public int transfer_amount { get; set; } // 金额,分为单位 public string user_name { get; set; } // 用户姓名,选填,加密后传base64 } var detail = new { out_detail_no = "D20250101001", trans_scene = "佣金结算", transfer_remark = "1月佣金", openid = "用户openid", transfer_amount = 500, user_name = EncryptUserName("张三") // 使用微信支付平台公钥加密 }; var body = new { appid = config.Appid, out_batch_no = "B20250101001", batch_name = "1月佣金批次", batch_remark = "业务广告佣金结算", total_amount = 500, total_num = 1, transfer_detail_list = new[] { detail } }; string json = JsonSerializer.Serialize(body); var auth = WxPayV3Signer.BuildAuthorization("POST", "/v3/transfer/batches", json, config.PrivateKeyPem, config.MerchantId, config.SerialNo); // 发送POST请求,逻辑同3.2,此处省略参数说明:total_amount是所有明细金额的总和,单位为分。批次号和明细单号都可以用时间戳加随机数生成,但必须保证不重复。user_name如果传了,必须用微信支付平台证书中的公钥进行RSA加密,加密后转Base64再放进字段。注意这里的“平台证书”和验证回调用的平台证书是同一组。
V3商家转账支持异步回调,你可在商户平台配置转账回调和退款回调。但很多项目并不依赖回调,而是定时调用查询接口对账。回调只是减少延迟,最终一致性要靠主动查询保证。
4.3 查询与幂等:防止多转一分钱
无论V2还是V3,企业付款都有两个核心原则:金额单位永远是分,转账单号不能复用。V3查询批次用GET /v3/transfer/batches/batch-id/{batch_id},或者用GET /v3/transfer/batches/out-batch-no/{out_batch_no}。建议在数据库中对out_batch_no和out_detail_no建唯一索引,每次发起转账前先检查是否已存在成功记录。
public async Task<bool> IsBatchProcessedAsync(string outBatchNo) { // 先查本地数据库 bool exists = await this.db.ExistsAsync("batch", outBatchNo); if (exists) return true; // 调用微信查询接口,看batch状态 var auth = WxPayV3Signer.BuildAuthorization("GET", $"/v3/transfer/batches/out-batch-no/{outBatchNo}?need_query_detail=true", "", config.PrivateKeyPem, config.MerchantId, config.SerialNo); using var client = new HttpClient(); client.DefaultRequestHeaders.Add("Authorization", auth); var resp = await client.GetStringAsync($"https://api.mch.weixin.qq.com/v3/transfer/batches/out-batch-no/{outBatchNo}?need_query_detail=true"); // 解析响应 var batch = JsonSerializer.Deserialize<BalanceBatch>(resp); return batch.batch_status == "FINISHED"; }真正的幂等不是等微信返回,而是自己在数据库上下功夫。微信侧对同一out_detail_no的重复请求,即使第一次请求超时,第二次也可能返回成功,如果本地没有唯一索引,就会有两笔订单引用同一个单号,最后对账时要崩溃。
5. 微信支付联调避坑:证书、密钥、金额和幂等的5条血泪记录
5.1 坑1:下单接口返回“请求签名错误”,但签名代码看起来没问题
现象:调用支付V3接口始终报签名错误,用商家平台上的签名工具验证签名却又通过。
原因:签名原文里的URL不对。很多开发者直接把Authorization头拼出来后发现联调工具能过,但自己在代码里用的是完整URL比如https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi包含域名。微信官方文档要求签名原文中的URL必须是“去掉域名后保留query的路径”,我在本地测试微信接口时不带query,这个坑不明显,一用带query的退款查询就暴露了。
解决:把完整URL拆成两部分,只取Uri的PathAndQuery传给签名函数。确保BuildAuthorization方法接收的url参数从request.RequestUri.PathAndQuery获取,而不是直接传字符串。
5.2 坑2:回调验签失败,但支付明明成功了
现象:支付成功很多笔,回调却一条都验不过,业务状态全靠手动查库更新。
原因:平台证书和商户证书搞混了。验签必须用微信平台证书的公钥,而你可能会在代码里配置了商户API证书路径,或者把平台证书的序列号写成了商户证书的序列号。
解决:下载微信平台证书保存为wxpay_platform.pem并缓存它的序列号。回调里Wechatpay-Serial是平台证书序列号,用它找到证书做验签。如果回调头里的序列号和你缓存的不一样,很可能是平台证书轮换,需要触发重新下载。
5.3 坑3:金额单位用“元”传到接口,实际支付金额翻了100倍
现象:下单总金额填了1,用户扫码支付后扣款1元,但单据记录里记的却是1分,订单对不上。
原因:微信支付所有金额字段都是整数“分”,金额为0.01元时传1,金额为1元时传100。部分旧代码的入参用decimal类型,直接序列化为1.00,接口解析时取了整数部分。
解决:所有进入微信接口的金额统一转成int型“分”,从业务层到请求响应一直用int。在代码里写一个强制说明,接口入口处加校验,大于等于1且小于3的数字不一定是1元,要提醒是分。如果项目历史遗留用了double,统一在序列化前乘以100再取整。
5.4 坑4:企业付款到零钱提示“姓名不匹配”,但用户反馈确实是本人
现象:调用V2企业付款且选择FORCE_CHECK时,报“校验姓名不一致”,用户姓名三个字和后台实名信息一模一样,还是报错。
原因:微信侧的用户姓名,在V2接口的部分版本里需要做敏感信息加密传输。不加密时该字段会被忽略,但如果请求里有该字段且未加密,接口可能报异常。另外就是编码问题,中文字符串在MD5签名时使用了不同的字符集,导致签名对不上但报错被误判为姓名不匹配。
解决:先用NO_CHECK模式跑通,确认签名和XML正确后再上加密。加密使用RSA公钥加密用户姓名,得到Base64字符串放入re_user_name字段。如果还挺不过,联系商户平台查一下该接口权限是否还允许FORCE_CHECK。
5.5 坑5:支付回调通知处理完后,不返回商户应答
现象:系统已处理完支付结果,但微信平台始终重发通知,每几个小时就收到一条相同的POST。日志显示处理逻辑正常,可请求却一直在重试。
原因:微信回调要求接收方必须在5秒内返回HTTP 200,并按指定XML/JSON格式返回{"code":"SUCCESS"}。很多人处理完逻辑直接返回一个空字符串或404,微信会认为失败继续重试。
解决:在回调处理方法最后,不要返回Ok()然后响应空体,而是返回一个明确的内容。C#里可以返回Content("{\"code\":\"SUCCESS\",\"message\":\"成功\"}", "application/json"),如果处理失败,返回400这样微信才会继续重试。注意如果业务逻辑抛出异常,确保不要返回200,否则会丢失这条通知。
6. 把DEMO变成可复用类库:离线自检与扩展的最后一个技巧
DEMO跑通以后,你想要的不只是一次性代码,而是一个能持续扩展的支付类库。我最后给你一个我每次都先做的技巧:离线自检签名。
写一个没有网络请求的测试类,把固定的method、url、body、私钥、时间戳和nonce都写死,然后生成Authorization头,再拿这个头和你从微信官方签名工具得到的头做对比。这样90%的签名问题都能在发送前发现,而不是等到接口返回错误再猜。
[Fact] public void Authorization_Build_ShouldBeExpected() { var expectedAuth = "WECHATPAY2-SHA256-RSA2048 mchid=\"1900000000\",nonce_str=\"abc123\",signature=\"...\",timestamp=\"1700000000\",serial_no=\"12345\""; var actual = WxPayV3Signer.BuildAuthorization("POST", "/v3/pay/transactions/jsapi", "{\"test\":true}", privateKeyPem, "1900000000", "12345"); Assert.Equal(expectedAuth, actual); }单元测试里不要用随机nonce,把它作为可选参数传入签名方法,这样你和微信工具之间的偏差才能暴露出来。我以前因为换行符问题导致签名错,不是代码看不懂,而是自己生成的原文末尾多了一个空格。写完这个自检,这类问题五分钟定位。
类库结构上建议做三层:签名与证书层只管加密解密,API层管下单、退款、转账的接口调用,业务层管幂等和状态同步。这样以后微信加新接口,你只需要在API层加一个方法。那些把所有逻辑写在一个静态类的项目,升级一次回调版本就要改半天的痛,希望你没经历过。做支付系统的这半年,我最深的教训是“先证明签名正确,再进行网络调用”——现在这个习惯每次都能在我即将翻车前把我拉回来。希望帮到你。
本文还有配套的精品资源,点击获取