简介:面向C#开发者的微信支付集成参考代码包,覆盖微信支付、微信企业支付到个人及企业付款到零钱等核心场景,并提供可直接调试的ASP.NET WebForm示例项目,适合需要快速接入支付或理解企业转账流程的中高级开发人员。压缩包共93个文件,约2.13MB,以47个cs源码文件、13个aspx页面为主,辅以dll库、config配置、js脚本及sln工程文件,便于直接打开项目并对照接口调用与业务实现。方案涵盖JSAPI支付、刷卡/扫码支付、企业付款到零钱、订单退款与查询、账单下载等关键接口,集成CS_sdk_v3.0.9封装,可帮助开发者理清统一下单、签名验证、回调处理等环节,减少重复排查时间。目前已有1575人学习下载,是一份贴近实战、可运行调试的微信支付DEMO参考。
1. 一个C#微信支付Demo:从下单到企业付款到零钱的完整链路
你接过微信支付,但大概率没有完整做过"用户下单支付→回调验签→企业付款到零钱(转账给个人)"这一整套流程。公众号里能搜到一堆"微信支付接入"的片段,但真正把收款和付款到个人两个方向同时跑通的Demo并不多。这个C#代码DEMO的价值就在这:它把统一下单、支付回调、企业转账到个人(微信企业付款到零钱)从请求构造、签名、证书加载到结果查询全部串了起来,直接对着商户平台配置就能改跑。适合两类人:一是刚在商户平台开通了JSAPI支付和小程序支付,想用C#快速打通收款闭环的开发者;二是已经能收款、但被"企业付款到零钱"的证书和签名规则折磨到想放弃的开发者——后者在这个Demo里能找到完整的可运行代码,而不是网上那种缺一半的伪代码。
2. 项目全貌与技术选型:从API版本到工程目录结构
2.1 玩法概述:收款与付款是两套签名逻辑
很多人第一次接触微信支付会以为"付款给用户"就是"退款接口"的变体,实际上完全不是一回事。退款走的是secapi_pay_refund接口,需要APIv3密钥和平台证书;而"企业付款到零钱"走的是mmpaymkttransfers/promotion/transfers接口,用的是老版v2的MD5签名,但额外要求双向TLS:客户端必须加载商户p12证书。这就造成了一个很尴尬的情况——你刚把v3的RSA验签模式理清,转头发现v2转账要的是另一套规则。这个Demo把所有接口统一到一套配置类下(AppId、MchId、ApiKey、P12证书),签名方法内部区分普通接口和证书接口,调用方不需要关心底层差异,这是项目设计上最实用的一个点。
微信支付的C#实现其实一直有个尴尬:官方SDK更新慢,社区封装版本又杂。这个Demo的做法是不用任何第三方微信支付SDK,直接用HttpClient + XmlDocument + X509Certificate2裸调接口。这样做的最大好处是没有黑匣子——每个请求长什么样、签名怎么拼、证书怎么挂,全部摊开在你面前。缺点是代码量会多一些,但作为二次开发的底子,这种"看得见每一处细节"的方式反而更安全,也更容易迁移到不同的.NET版本。
2.2 工程结构:一眼扫完的配置与调用链
解压Demo后,目录结构大致如下:
WeChatPayDemo/ ├─ Config/ │ └─ WxPayConfig.cs # 微信支付配置(AppId、MchId、ApiKey、证书路径) ├─ Common/ │ ├─ WxPayUtil.cs # 签名、随机数、XML转换、时间戳 │ └─ HttpHelper.cs # HTTP请求封装(支持客户端证书) ├─ Service/ │ ├─ OrderService.cs # 统一下单、查询订单 │ ├─ TransferService.cs # 企业付款到零钱、查询转账 │ └─ NotifyService.cs # 支付回调验签与业务处理 ├─ Model/ │ ├─ OrderInfo.cs # 订单参数(金额单位分,元转分在构造时完成) │ ├─ TransferRequest.cs # 转账请求模型 │ └─ WxPayResult.cs # 接口统一返回模型 ├─ Demo/ │ └─ Program.cs # 控制台入口,演示调用顺序 └─ cert/ └─ apiclient_cert.p12 # 商户证书(从商户平台下载)每个Service类都只暴露一个公开方法,参数用强类型模型传入,返回结果统一包装成WxPayResult:里面包含ReturnCode、ResultCode、ErrCodeDes和原始XML。这种设计很朴素,但足够支撑起后期的业务扩展,比如把OrderService替换成你自己的订单系统,只留微信交互部分。
2.3 三个核心接口的参数差异对比
统一下单、支付回调、企业付款到零钱三者的参数规则差异很大,我把Demo里涉及的接口参数整理成了下表,方便你对照着看:
| 接口 | 请求地址 | 签名方式 | 证书要求 | 金额字段 |
|---|---|---|---|---|
| 统一下单 | /pay/unifiedorder | MD5(ApiKey) | 无(普通HTTPS即可) | total_fee(分) |
| 支付回调 | 由微信POST到NotifyUrl | RSA验签(平台证书公钥) | 无 | total_fee(分) |
| 企业付款到零钱 | /mmpaymkttransfers/promotion/transfers | MD5(ApiKey) | 必须加载p12客户端证书 | amount(分) |
| 查询转账 | /mmpaymkttransfers/gettransferinfo | MD5(ApiKey) | 无(但建议开启服务端IP白名单) | 不需要金额 |
注意上表中"企业付款到零钱"的amount字段文档上标注单位是分,但实际调试时最容易翻车的也正是这里——有人把"元"直接填进去,转了10元却只到了0.1元,后面避坑章节我会专门说。查询转账接口虽然不需要客户端证书,但商户平台的安全设置里通常会要求绑定服务器IP白名单,否则直接返回IP_NOT_ALLOWED。
3. 核心链路拆解:统一下单、支付回调与验签实现
3.1 统一下单:先解决签名,再谈请求
微信支付v2接口的签名规则是"参数名按ASCII字典序排序→拼接键值对→末尾追加key→MD5后转大写"。这个逻辑在所有v2接口里完全一致,所以WxPayUtil.BuildSign是全局复用的基础方法。看OrderService里的统一下单实现:
// Service/OrderService.cs - 统一下单核心代码 public async Task<WxPayResult> UnifiedOrder(OrderInfo order) { // 1. 构造参与签名的参数字典,SortedDictionary会自动按键名ASCII排序 var dict = new SortedDictionary<string, string> { { "appid", _config.AppId }, { "mch_id", _config.MchId }, { "body", order.Body }, // 商品描述,不超过127字符 { "nonce_str", Guid.NewGuid().ToString("N").ToUpper() }, // 随机串,32位以内 { "notify_url", _config.NotifyUrl }, // 回调地址,必须是公网HTTPS { "out_trade_no", order.OutTradeNo }, // 商户订单号,同一商户号下唯一 { "total_fee", order.TotalFee.ToString() }, // 金额单位是分,不是元! { "trade_type", order.TradeType }, // JSAPI / APP / NATIVE { "openid", order.OpenId } // 仅JSAPI必填 }; // 2. 生成MD5签名 string sign = WxPayUtil.BuildSign(dict, _config.ApiKey); dict.Add("sign", sign); // 3. 拼接XML并POST到统一下单地址 string xml = WxPayUtil.BuildXml(dict); string respXml = await _httpHelper.PostXmlAsync( "https://api.mch.weixin.qq.com/pay/unifiedorder", xml); // 4. 解析响应XML,返回code_url或prepay_id var result = WxPayUtil.ParseXml(respXml); var payResult = new WxPayResult { ReturnCode = result["return_code"], ResultCode = result["result_code"], ErrCodeDes = result.ContainsKey("err_code_des") ? result["err_code_des"] : "", PrepayId = result.ContainsKey("prepay_id") ? result["prepay_id"] : "", CodeUrl = result.ContainsKey("code_url") ? result["code_url"] : "" // NATIVE支付用 }; return payResult; }这段代码里有几个容易被忽略的细节:SortedDictionary帮你省去了手动排序的步骤,但必须保证所有参数名与微信文档一致,写错一个字母签名必挂。nonce_str要求32位以内、区分大小写,使用Guid.NewGuid().ToString("N")可以稳定生成32位小写字符串,属于安全用法。total_fee在OrderInfo构造函数里强制做了"元转分"的换算,这样业务层传进来的是"10.5元",到微信这层已经是"1050分",能有效避免金额单位混淆。
如果返回的ReturnCode是FAIL,先不要怀疑签名,大概率是参数漏填或appid与mch_id未绑定。如果ReturnCode是SUCCESS但ResultCode是FAIL,重点看ErrCodeDes,例如APPID_MCH_ID_NOT_MATCH就说明公众号AppId和商户号没有绑定关系。
3.2 支付回调:验签不是可选项,是必选项
支付成功后微信会异步POST一条XML到notify_url,里面包含out_trade_no、transaction_id、total_fee等字段。很多Demo的处理方式是"解析XML拿字段直接改订单状态",但这是极大的安全隐患——任何人都能伪造一条POST到你服务器上,所以NotifyService里的验签环节不能省:
// Service/NotifyService.cs - 支付回调验签与业务处理 public async Task<string> HandleNotify(string requestBody) { // 1. 解析回调XML var dict = WxPayUtil.ParseXml(requestBody); if (dict["return_code"] != "SUCCESS") { return WxPayUtil.BuildFailResponse("FAIL"); // 告诉微信不要重试 } // 2. 从商户平台下载的API证书中加载微信支付平台公钥,用于验签 // 注意:这里是"微信平台证书",不是商户自己的p12证书,两者别混淆 bool isValid = WxPayUtil.VerifyRsaSign(dict, _config.PlatformCertPath); if (!isValid) { return WxPayUtil.BuildFailResponse("SIGN_INVALID"); // 验签失败,记录日志 } // 3. 二次校验金额与商户订单号,防止报文被篡改 string outTradeNo = dict["out_trade_no"]; string totalFee = dict["total_fee"]; // 这里需要查本地数据库,把入参金额与total_fee比较,不一致则拒绝 bool orderMatched = await _orderRepository.CheckAmount(outTradeNo, totalFee); if (!orderMatched) { return WxPayUtil.BuildFailResponse("AMOUNT_MISMATCH"); } // 4. 更新订单状态为已支付,标记第三方支付流水号 await _orderRepository.MarkPaid(outTradeNo, dict["transaction_id"]); // 5. 返回SUCCESS给微信,通知停止重试 return WxPayUtil.BuildSuccessResponse(); }这里的验签逻辑和统一下单完全不同:统一下单是"拿自己的key拼MD5",回调验签是"用微信平台证书的公钥验RSA签名"。如果配错了证书,VerifyRsaSign会一直返回false。我之前在某个项目里踩过这个坑,当时拿着商户p12证书去验签,在本地怎么测都对,部署到服务器就挂——后来才发现回调验签用的是微信支付平台证书,需要在商户平台"API安全-平台证书"处下载,且证书会定期轮换,上线前要确认有效期。
回调返回的字符串也有讲究:微信要求返回<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>,不能多一个空格,不能带BOM头。BuildSuccessResponse内部用XmlDocument保存格式后再输出,能避免手拼字符串引发的编码问题。
3.3 查询订单:给前端轮询兜底
支付结果以回调为准,但有些场景(比如用户支付后进程崩溃,回调解析失败)需要主动查询兜底。OrderService里还实现了一个QueryOrder方法,向/pay/orderquery发送out_trade_no获取订单状态。它的签名方式和统一下单一致,只是XML里少了body、notify_url等字段,多了out_trade_no。这个接口适合放在定时任务里,每分钟把支付中的订单扫一遍,把状态推进到已支付。注意查询接口有频率限制,官方建议间隔不小于15分钟,不然容易触发风控。
4. 企业付款到零钱:转账接口的签名、证书与查询实现
4.1 转账前的三个硬性条件:钱包、权限、证书
真正让我觉得这个Demo值钱的地方在TransferService。企业付款到零钱(商户把资金从商户余额转到用户微信零钱)的接口比收款苛刻得多:
第一,商户号和用户openid必须在同一个公众号下,不能拿公众号A的openid配公众号B绑定的商户号;第二,商户平台需要单独开通"企业付款到零钱"权限,有些商户号开通后还有单笔限额(常见的是单笔上限2万,单日上限按商户号等级);第三,请求必须携带商户p12证书,p12证书从商户平台下载后会有一个密码,默认通常是商户号(MchId)本身,下载后如果改过密码要用改过的。这三个条件任意一个不满足,接口都会返回FAIL,而且错误码各不相同,有些错误码在微信文档里都搜不到确切解释,只能靠经验判断,属实的"玄学"现场。下面把TransferService里的转账实现完整拆开看。
4.2 转账核心方法:从签名到HttpClient挂证书
// Service/TransferService.cs - 企业付款到零钱核心代码 public async Task<WxPayResult> TransferToBalance(TransferRequest req) { // 1. 构造签名参数,字段名与统一下单完全不同 var dict = new SortedDictionary<string, string> { { "mch_appid", _config.AppId }, // 注意是mch_appid不是appid! { "mchid", _config.MchId }, // 注意是mchid不是mch_id! { "nonce_str", Guid.NewGuid().ToString("N").ToUpper() }, { "partner_trade_no", req.PartnerTradeNo }, // 商户转账单号,需唯一 { "openid", req.OpenId }, // 收款用户openid { "check_name", req.CheckName }, // NO_CHECK / OPTION_CHECK / FORCE_CHECK { "re_user_name", req.UserName ?? "" }, // FORCE_CHECK时必须填真实姓名 { "amount", req.Amount.ToString() }, // 金额单位:分 { "desc", req.Desc }, // 转账备注,会展示给用户 { "spbill_create_ip", req.SpbillCreateIp } // 发起转账的服务器IP }; // 2. 与统一下单同款MD5签名 string sign = WxPayUtil.BuildSign(dict, _config.ApiKey); dict.Add("sign", sign); // 3. 核心区别:HttpClient必须挂上p12客户端证书 var handler = new HttpClientHandler(); handler.ClientCertificates.Add(_config.TransferCertificate); // TransferCertificate 由 X509Certificate2 加载 cert/apiclient_cert.p12 // 密码在WxPayConfig中配置,一般为商户号或自定义证书密码 using var client = new HttpClient(handler); client.Timeout = TimeSpan.FromSeconds(30); // 4. 发送XML请求到转账接口 string xml = WxPayUtil.BuildXml(dict); string respXml = await WxPayUtil.PostXmlAsync( client, "https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers", xml); // 5. 解析返回结果并判断 var result = WxPayUtil.ParseXml(respXml); var payResult = new WxPayResult { ReturnCode = result["return_code"], ResultCode = result["result_code"], ErrCodeDes = result.ContainsKey("err_code_des") ? result["err_code_des"] : "", PaymentNo = result.ContainsKey("payment_no") ? result["payment_no"] : "", }; // 注意:ResultCode为SUCCESS只代表"请求受理",不代表"到账成功" return payResult; }这段代码的第一个记忆点是字段名和统一下单不一样。mch_appid、mchid,少一个下划线,多一个appid前缀,拼错直接报PARAM_ERROR。第二个记忆点是check_name字段,如果是FORCE_CHECK,必须传re_user_name且与用户实名一致,否则转账失败,这个字段适合对公转账、报销打款场景;如果是内部测试,用NO_CHECK最省事。第三个记忆点是amount还是分,这已经是第二个接口在金额单位上坑你了,后面改造时建议统一封装。
HttpClientHandler.ClientCertificates.Add这行代码是转账接口的"钥匙"。_config.TransferCertificate在配置中是通过new X509Certificate2(certPath, certPassword)加载的,如果加载时报"密码不正确"或"找不到指定的X509证书",先回到商户平台重新下载p12,或者确认当前服务器Windows/Unix的证书权限是否允许读取文件。
4.3 查询转账结果:区分"受理成功"和"到账成功"
转账接口返回SUCCESS不代表钱已经到用户零钱。资金流转需要时间,网络环境差时甚至会出现"接口超时但后台已转账"的情况,所以Demo里配套实现了查询接口。代码如下:
// Service/TransferService.cs - 查询企业付款到零钱结果 public async Task<TransferStatus> QueryTransfer(string partnerTradeNo) { var dict = new SortedDictionary<string, string> { { "mch_appid", _config.AppId }, { "mchid", _config.MchId }, { "nonce_str", Guid.NewGuid().ToString("N").ToUpper() }, { "partner_trade_no", partnerTradeNo } }; string sign = WxPayUtil.BuildSign(dict, _config.ApiKey); dict.Add("sign", sign); // 普通HTTPS请求即可,不需要客户端证书 string xml = WxPayUtil.BuildXml(dict); string respXml = await _httpHelper.PostXmlAsync( "https://api.mch.weixin.qq.com/mmpaymkttransfers/gettransferinfo", xml); var result = WxPayUtil.ParseXml(respXml); // 关键字段:status -> PROCESSING / SUCCESS / FAILED // PROCESSING 表示转账中,SUCCESS 表示已到账,FAILED 表示转账失败 string status = result.ContainsKey("status") ? result["status"] : ""; string reason = result.ContainsKey("reason") ? result["reason"] : ""; // 失败原因 return new TransferStatus { Status = status, Reason = reason, PaymentTime = result.ContainsKey("payment_time") ? result["payment_time"] : "" }; }建议把"处理中"状态理解成"转账的人已收到指令但还没到账",这时候不能立刻把订单标记为失败,也不能无限制地重复查询。合理做法是把PROCESSING状态的转账单放进一张待查表,每5分钟扫一次,最多查24小时,超过时间的转入人工处理。由于转账单号对应的是真金白银,这种兜底机制在生产环境里必须有——宁可查到FAILED再去退款,也不能把"处理中"当成"已到账"。
5. 避坑指南:调试这个Demo时容易翻车的五个典型坑
5.1 报错"商户号该产品权限未开通":权限不是默认有的
现象:统一下单接口返回return_code=FAIL,err_code_des提示"商户号该产品权限未开通"。
原因:不是每个商户号都默认拥有JSAPI支付权限。你必须在商户平台的"产品中心"里面自助申请开通JSAPI支付、并完成APPID绑定。另一个隐蔽情况是商户号主体和公众号主体不一致,导致绑定失败。
解决:进入商户平台->产品中心->JSAPI支付->产品设置,检查"已关联AppID"是否包含你配置的那个AppId;如果没有,先完成公众号与商户号的关联,再重新跑Demo。如果主体不一致,需要走微信的"授权"流程,把公众号授权给商户号的主体使用,这一步在商户平台有引导。
5.2 回调验签永远失败:平台证书与商户证书分不清
现象:支付成功,微信回调到NotifyUrl,但VerifyRsaSign一直返回false,日志里全是验签失败。
原因:最常见的是拿商户p12证书去验签。回调验签要求的是微信支付平台证书的公钥,这个证书从商户平台"账户中心->API安全->平台证书管理"下载,和商户p12不是同一个文件。还有一个坑是回调请求体的编码问题——某些框架会自动做URL解码或转义,把原始XML改变了一两个字符,导致RSA验签失败。
解决:下载最新的wechatpay_platform.pem文件替换PlatformCertPath;在回调入口处直接接收Request.Body的原始字节流,避免中间层做任何格式化;如果验签还是失败,把收到的XML和证书公钥打印出来做离线比对。
5.3 转账报"openid与mch_appid不匹配":openid有归属
现象:调用TransferToBalance时报openid校验不通过。
原因:每个openid只属于一个AppId。用户在公众号A授权后拿到的openid,不能提供给绑定了公众号B的商户号来转账。项目里如果同时跑着公众号A和公众号B两套系统,最容易出现这个错误。
解决:检查_config.AppId和req.OpenId的来源是否为同一公众号;如果用户是通过小程序支付后就来提现,注意小程序的openid体系和公众号的openid体系是隔离的,必须用小程序对应的AppId发起转账,否则在商户平台上绑定小程序的AppId后再调。
5.4 p12证书加载失败:密码、路径、权限三位一体
现象:运行到TransferToBalance时,new X509Certificate2(certPath, password)抛异常,或者接口返回certificate verify failed。
原因:证书密码错误是第一位,默认密码是商户号,但很多人在商户平台下载时自定义过密码;第二位是相对路径问题,bin目录下运行时./cert/路径找不到文件;第三位是Linux服务器上证书文件权限不足。
解决:统一用绝对路径读取p12,或者把证书复制到项目根目录再写Path.Combine(AppContext.BaseDirectory, "cert", "apiclient_cert.p12"),避免环境不同导致路径失效。加载后用cert.HasPrivateKey判断是否真的含私钥——如果只是挂着证书壳子没有私钥,转账请求必失败。
5.5 金额单位搞混:你以为转了10元,用户收到0.1元
现象:转账接口返回成功,用户反馈"收到的钱不对"。查日志发现amount字段明明填了10,但用户只到账0.1元。
原因:微信所有资金类接口的金额都以分为单位。你填10,微信读到的就是10分,也就是一角钱。这个问题在统一下单时同理——把10元写成10分,订单创建成功但支付页金额变成0.1元,等用户点完支付你才发现,已经晚了。
解决:模型层强制转换。在OrderInfo和TransferRequest的构造函数里写this.AmountInFen = Decimal.ToInt32(decimal.Round(amountYuan * 100)),业务层只传元,模型层负责转分,杜绝散落各处的*100逻辑。同时可以在调用微信接口前断言if (amountInFen <= 0) throw new InvalidOperationException("金额必须大于0,且单位为分")。
6. 从Demo到生产:二次开发的三个改造落点
拿到这个Demo直接跑通只是第一步,想在真实项目里用上,至少要完成三件事。
6.1 把配置外置,支持多商户隔离
WxPayConfig目前是单例,所有接口都读同一组AppId和MchId。实际上很多公司的业务线有多套主体,公众号入口不同,商户号也各自独立。改造时把WxPayConfig改成可配置对象,用IServiceProvider注入,或者做一个WxPayContext字典来存多套配置。调用时按业务线选择对应上下文。签名、证书这些基础方法全部复用,唯一要小心的是回调验签的PlatformCertPath也必须跟着上下文走,不能共用一套平台证书——不同商户号之间证书是不同的。
6.2 给转账单号加上幂等保护
partner_trade_no在微信侧要求唯一,同一个单号重复转账两次会被拒绝,但并发快但你本地数据库还没commit时,两次请求都落到数据库查询"不存在",然后同时调微信,一单成功一单失败。生产环境要这样做:partner_trade_no生成规则统一为"TR" + yyyyMMddHHmmss + 6位随机数,并在数据库字段上加唯一索引;转账前先INSERT这条记录,主键冲突就说明请求重复,直接返回"重复请求"。这个防重逻辑放在TransferService入口处,和纯Demo的区别就在这里。
6.3 完整记录回调与转账日志,别只记result_code
微信支付接口返回的所有字段都应该落库保存,尤其是退款和转账的payment_no、status、reason。我见过不少项目只在出错时记日志,结果对账时缺了一笔转账,查无可查。建议从Demo改造开始就是设计一张wechat_wallet_log表,里面存接口类型、请求报文、响应报文、错误码、耗时(毫秒)、涉事订单号。后续排查问题时,这张表能帮你把"到底微信没收到请求"还是"微信返回了但我们没处理"这类问题从概率变成确定。
从那以后,我每接一个新的支付渠道,都强制自己先走一遍"最小闭环→异常日志→幂等校验→对账兜底"四步流程,这套习惯帮我避开了不少线上故障。希望帮到你。
本文还有配套的精品资源,点击获取