简介:本资源面向.NET Core开发者,聚焦微信支付V3服务商模式下的完整支付链路实现,涵盖普通支付、支付回写、退款、分账给个人、服务商模式支付与回写、服务商分账给子商户以及V3支付退款等核心场景,适合需要快速落地微信支付与分账业务的中高级开发者参考。压缩包共696个文件,约34.16MB,以383个dll、70个cs源码、62个pdb调试文件为主,辅以json配置、xml文档、csproj工程文件与sln解决方案,整体为可直接编译运行的源码工程结构。目前已有1369人学习下载,说明该方案在实际项目中具备一定参考价值。读者可从中获取各支付场景的接口调用示例、分账逻辑组织方式与回写处理思路,便于对照自身业务进行改造与排错,减少从零搭建微信支付模块的时间成本。
1. 服务商模式下的 .NET Core 微信支付 V3:一条绕不开的接入路径
如果你正在做一个多商户 SaaS 平台,或者给连锁品牌做收银中台,大概率会遇到这个场景:平台自己不是收款方,钱要直接进各个子商户的账户,平台只拿分账抽成。这时候普通商户直连模式就不够用了,必须走服务商模式。而微信支付 V3 接口相比老 V2 版本,签名机制、证书体系、回调解密全部换了一套,很多团队第一次接的时候会在验签和回调解密上卡好几天。
这篇笔记围绕 .NET Core 环境下接入微信支付 V3 服务商模式,把支付下单、分账、退款、支付回写这条完整链路拆开讲。适合两类人:一是刚拿到服务商资质、准备从零接入的团队;二是已经接了普通商户模式,想迁移到服务商模式的开发者。核心不是讲微信支付有多复杂,而是把每一步的代码、参数、踩坑点摆出来,让你照着能跑通。
2. 服务商模式的前置准备:证书、密钥与商户号关系
2.1 服务商模式到底和普通模式差在哪
普通商户模式下,你用自己的商户号下单,钱进自己账户。服务商模式下,你有两个身份:服务商商户号(sp_mchid)和子商户号(sub_mchid)。下单时接口里要同时传这两个 ID,微信会把钱结算到子商户账户,服务商通过分账接口抽取佣金。
这个区别直接影响了几个关键环节。第一,签名用的私钥是服务商的,不是子商户的。第二,回调通知里的商户号字段是子商户的,验签时要用服务商的平台证书。第三,退款和分账的权限校验走的是服务商维度,子商户不需要单独配置 API 密钥。很多人在第一步就搞混了,用子商户的密钥去签名,结果一直报签名错误。
另一个容易忽略的点是子商户的绑定关系。子商户必须先通过服务商平台完成进件审核,拿到 sub_mchid 之后才能下单。进件流程不在代码层面,但如果你在测试环境用了一个没审核通过的 sub_mchid,下单接口会直接返回PARAM_ERROR,错误信息里不会明确告诉你子商户没绑定,只会说参数不对。这个坑后面会细说。
2.2 在 .NET Core 项目里配置证书和密钥
微信支付 V3 需要三类密钥材料:服务商 API 私钥(apiclient_key.pem)、服务商证书序列号、微信支付平台证书。前两个在服务商商户平台下载,平台证书需要通过接口动态获取或者用工具下载。
我一般会在项目里建一个WeChatPayOptions配置类,从appsettings.json读取路径和序列号,不把证书内容硬编码进代码。
public class WeChatPayOptions { public string SpMchId { get; set; } // 服务商商户号 public string AppId { get; set; } // 服务商绑定的 AppId public string PrivateKeyPath { get; set; } // apiclient_key.pem 路径 public string MerchantSerialNo { get; set; } // 服务商证书序列号 public string ApiV3Key { get; set; } // APIv3 密钥,用于回调解密 public string PlatformCertPath { get; set; } // 微信平台证书路径 }SpMchId和AppId必须匹配,服务商模式下 AppId 是服务商自己申请的那个,不是子商户的。ApiV3Key是在商户平台手动设置的 32 位字符串,回调解密和敏感信息加密都用它。MerchantSerialNo是证书序列号,不是证书内容,在商户平台证书管理页面能看到。
加载私钥时用X509Certificate2或者直接读 PEM 文件。.NET Core 里推荐用RSA.Create()配合ImportFromPem,比老式的X509Certificate2更干净。
public RSA LoadPrivateKey(string path) { var rsa = RSA.Create(); var pem = File.ReadAllText(path); rsa.ImportFromPem(pem); // .NET 5+ 支持,直接读 PEM 格式 return rsa; }如果你用的是 .NET Core 3.1,ImportFromPem不存在,需要用BouncyCastle或者手动解析 PEM 的 Base64 内容再调ImportRSAPrivateKey。这是版本差异带来的第一个坑,后面避坑章节会展开。
平台证书的获取有两种方式:一是用微信提供的工具下载,二是调/v3/certificates接口动态获取。生产环境建议动态获取并缓存,因为平台证书会定期轮换。缓存时注意记录证书的serial_no,验签时要根据回调头里的Wechatpay-Serial找到对应的证书。
3. 用 .NET Core 实现 V3 服务商下单与签名
3.1 V3 签名机制的拆解与构造方法
V3 的签名和 V2 的 MD5 完全不是一回事。它要求你把 HTTP 方法、URL 路径、时间戳、随机串、请求体拼成一个字符串,然后用 SHA256withRSA 签名,最后放到Authorization头里。拼串格式是:
HTTP方法\nURL路径\n时间戳\n随机串\n请求体\n注意 URL 路径要带 query string,比如/v3/pay/partner/transactions/jsapi不带参数,但查单接口/v3/pay/partner/transactions/out-trade-no/{out_trade_no}?sp_mchid=xxx就要把 query 拼进去。请求体如果是 GET 请求,留空但换行符不能少。
签名串构造代码:
public string BuildSignatureString(string method, string urlPath, long timestamp, string nonceStr, string body) { // 每行末尾都要有 \n,包括最后一行 return $"{method}\n{urlPath}\n{timestamp}\n{nonceStr}\n{body}\n"; }时间戳是秒级 Unix 时间戳,不是毫秒。随机串用Guid.NewGuid().ToString("N")就行,32 位以内。签名结果用 Base64 编码,然后拼成:
WECHATPAY2-SHA256-RSA2048 mchid="服务商商户号",nonce_str="随机串",timestamp="时间戳",serial_no="证书序列号",signature="签名值"这里mchid填服务商商户号,不是子商户号。serial_no是服务商证书序列号。这两个字段填错会直接返回 401。
3.2 服务商 JSAPI 下单的完整请求构造
服务商模式下单接口是/v3/pay/partner/transactions/jsapi。请求体里必须包含sp_appid、sp_mchid、sub_mchid、description、out_trade_no、notify_url、amount、payer。
public async Task<string> CreatePartnerOrderAsync(string subMchId, string openId, string outTradeNo, int totalFee) { var urlPath = "/v3/pay/partner/transactions/jsapi"; var body = new { sp_appid = _options.AppId, sp_mchid = _options.SpMchId, sub_mchid = subMchId, description = "测试商品", out_trade_no = outTradeNo, notify_url = "https://yourdomain.com/api/wxpay/notify", amount = new { total = totalFee, currency = "CNY" }, payer = new { openid = openId } }; var json = JsonSerializer.Serialize(body); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var nonce = Guid.NewGuid().ToString("N"); var signStr = BuildSignatureString("POST", urlPath, timestamp, nonce, json); var signature = SignWithRsa(signStr, _privateKey); var auth = $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_options.SpMchId}\"," + $"nonce_str=\"{nonce}\",timestamp=\"{timestamp}\"," + $"serial_no=\"{_options.MerchantSerialNo}\",signature=\"{signature}\""; // 用 HttpClient 发送请求,带上 Authorization 和 Accept // ... }totalFee单位是分,不是元。notify_url必须是 HTTPS,不能带参数。openid是用户在服务商 AppId 下的 openid,不是子商户 AppId 的。如果子商户有自己的 AppId,需要走sub_appid字段,但 openid 对应的主体也要跟着变。这个细节在跨主体场景下特别容易翻车。
返回结果里会拿到prepay_id,然后需要再签一次名生成小程序或 JSAPI 调起支付的参数。调起支付的签名串格式是:
appId\ntimeStamp\nnonceStr\npackage\n注意这里的appId是服务商的 AppId,package是prepay_id=xxx。签名用同一个私钥,但拼串格式和接口签名不同,别搞混。
3.3 支付回写的验签与解密处理
支付成功后微信会异步通知到notify_url。回调请求头里有Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial。验签流程是:用平台证书公钥对时间戳\n随机串\n请求体\n做验签。
public bool VerifyNotify(string timestamp, string nonce, string body, string signature, string serialNo) { var message = $"{timestamp}\n{nonce}\n{body}\n"; var cert = GetPlatformCert(serialNo); // 根据 serialNo 找证书 using var rsa = cert.GetRSAPublicKey(); var data = Encoding.UTF8.GetBytes(message); var sig = Convert.FromBase64String(signature); return rsa.VerifyData(data, sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); }验签通过后,回调体是 AES-256-GCM 加密的,需要用ApiV3Key解密。解密时注意nonce和associated_data从resource对象里取,不是请求头的 nonce。
public string DecryptResource(string ciphertext, string nonce, string associatedData, string apiV3Key) { var key = Encoding.UTF8.GetBytes(apiV3Key); var cipherBytes = Convert.FromBase64String(ciphertext); var nonceBytes = Encoding.UTF8.GetBytes(nonce); var tag = cipherBytes[^16..]; // 最后 16 字节是 auth tag var data = cipherBytes[..^16]; using var aes = new AesGcm(key); var plain = new byte[data.Length]; aes.Decrypt(nonceBytes, data, tag, plain, Encoding.UTF8.GetBytes(associatedData)); return Encoding.UTF8.GetString(plain); }解密后的 JSON 里out_trade_no、transaction_id、trade_state是核心字段。处理完业务逻辑后必须返回{"code":"SUCCESS","message":"成功"},否则微信会按策略重试。重试间隔是 15s、15s、30s、3m、10m、20m、30m、30m、30m、60m、3h、3h、3h、6h、6h、6h,最多 24 小时。所以回调处理一定要做幂等,用out_trade_no或transaction_id做唯一键。
4. 分账与退款的接口调用与状态机
4.1 分账接口的调用时机与参数配置
分账不是下单就能调的,必须等支付成功且订单进入可分账状态。微信规定,支付成功后订单有 180 天的分账窗口,但实际业务里一般支付回调处理完就发起分账。分账接口是/v3/profitsharing/orders。
请求体关键字段:appid、transaction_id、out_order_no、receivers、unfreeze_unsplit。receivers是一个数组,每个元素包含type、account、amount、description。type可以是MERCHANT_ID或PERSONAL_OPENID,分别对应分给商户或分给个人。
var body = new { appid = _options.AppId, transaction_id = transactionId, out_order_no = $"profit_{outTradeNo}", receivers = new[] { new { type = "MERCHANT_ID", account = subMchId, amount = 100, description = "分账给子商户" }, new { type = "MERCHANT_ID", account = _options.SpMchId, amount = 20, description = "平台佣金" } }, unfreeze_unsplit = true // 分账后剩余金额解冻 };amount单位是分,所有 receiver 的 amount 之和不能超过订单总金额。out_order_no是服务商侧的分账单号,必须唯一。unfreeze_unsplit设为 true 表示分账完成后剩余资金解冻给子商户,如果设为 false,剩余资金会继续冻结,需要再调解冻接口。
分账接口返回status字段,常见值有PROCESSING、FINISHED、CLOSED。PROCESSING表示微信还在处理,需要等分账回调或者主动查单。分账回调的event_type是PROFITSHARING.ORDER.FINISHED,处理逻辑和支付回调类似,验签解密后更新分账状态。
4.2 退款接口在服务商模式下的差异
退款接口是/v3/refund/domestic/refunds。服务商模式下,请求体里要传sub_mchid,而不是sp_mchid。退款金额不能超过订单剩余可退金额,部分退款时要注意累计退款金额。
var body = new { sub_mchid = subMchId, out_trade_no = outTradeNo, out_refund_no = $"refund_{outTradeNo}_{DateTime.Now.Ticks}", reason = "用户申请退款", notify_url = "https://yourdomain.com/api/wxpay/refund-notify", amount = new { refund = refundFee, total = totalFee, currency = "CNY" } };out_refund_no必须唯一,重复提交同一个退款单号微信会返回原退款结果,不会重复退款。退款回调的event_type是REFUND.SUCCESS或REFUND.ABNORMAL。退款成功后如果原订单有分账,微信会自动从分账方扣回对应比例,但前提是分账方账户余额充足。如果分账方余额不足,退款会失败,这个坑在分账后立即退款的场景里特别常见。
退款状态机比支付复杂,有SUCCESS、CLOSED、PROCESSING、ABNORMAL四种。ABNORMAL表示退款异常,通常是收款方账户问题,需要人工介入。PROCESSING状态要等回调或者主动查单,不能直接当失败处理。
4.3 分账与退款的组合场景处理
实际业务里最常见的组合是:用户支付 100 元,平台分账 20 元给服务商,80 元给子商户,然后用户申请全额退款。这时候微信会先从子商户扣 80 元,再从服务商扣 20 元。如果服务商账户余额不足,退款会卡在ABNORMAL。
处理这种场景,我一般会在退款前先查分账状态,确认分账已完成。如果分账还在PROCESSING,先等分账回调再发起退款。另外,退款金额要按分账比例回滚,不能只退子商户那部分。微信的退款接口会自动处理分账回滚,但前提是分账方余额充足。
还有一个细节:分账接口有 30 天的分账窗口限制,超过 30 天的订单不能再分账。如果业务需要长周期分账,要在支付成功后尽快发起,或者用unfreeze_unsplit=false先冻结资金,后续再分账。
5. 避坑与排查:签名、回调、分账的 5 个血泪教训
5.1 签名一直报 401 但不知道哪里错了
现象:调接口返回 401,错误信息是SIGN_ERROR,但检查了私钥、序列号、拼串格式都没问题。
原因:最常见的是 URL 路径拼串时漏了 query string,或者 GET 请求的 body 留空但没加换行符。另一个高频原因是时间戳用了毫秒,微信要求秒级。还有一个隐蔽原因是Authorization头里的mchid填了子商户号,服务商模式下必须填服务商商户号。
解决:把拼串内容打印出来,逐行对比。时间戳用DateTimeOffset.UtcNow.ToUnixTimeSeconds(),不要用ToUnixTimeMilliseconds()。GET 请求的 body 传空字符串,但拼串时\n不能省。mchid字段确认是sp_mchid。
5.2 回调验签失败但平台证书是对的
现象:支付回调进来,验签返回 false,但平台证书是从微信官方渠道下载的。
原因:回调请求体在验签前被读取了一次,导致流位置变了,第二次读取时拿到空字符串。ASP.NET Core 里Request.Body默认只能读一次,如果中间件里读过,控制器里再读就是空的。
解决:在验签前用EnableBuffering()开启缓冲,或者用StreamReader读取后把Position重置为 0。更稳妥的做法是在中间件里一次性读完 body,存到HttpContext.Items里,后续直接用。
Request.EnableBuffering(); using var reader = new StreamReader(Request.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(); Request.Body.Position = 0; // 重置位置,方便后续读取5.3 分账接口返回 PARAM_ERROR 但参数看起来都对
现象:分账请求返回PARAM_ERROR,提示receiver相关字段有问题。
原因:receivers数组里某个account填的是子商户号,但type写成了PERSONAL_OPENID。或者amount之和超过了订单总金额。还有一种情况是分账接收方没有在服务商平台添加分账关系,微信要求先调/v3/profitsharing/receivers/add添加接收方。
解决:检查每个 receiver 的type和account是否匹配。MERCHANT_ID对应商户号,PERSONAL_OPENID对应 openid。分账前先调添加接收方接口,确保关系已建立。金额之和用代码校验一遍,别靠肉眼。
5.4 退款成功但分账方余额被扣成负数
现象:退款回调显示成功,但服务商账户余额变成负数,后续分账接口全部失败。
原因:分账后立即退款,微信从分账方扣回资金时,如果分账方余额不足,会先扣成负数,然后限制后续分账和退款操作。
解决:退款前先查分账方余额,确保足够覆盖退款金额。如果余额不足,先让分账方充值,或者调整分账比例,留足退款缓冲。生产环境建议在分账时预留 10% 到 20% 的退款保证金,不要全部分完。
5.5 回调处理超时导致微信重复通知
现象:回调处理逻辑里查了数据库、调了其他服务,耗时超过 5 秒,微信判定超时,开始重试,导致同一笔订单被处理多次。
原因:微信回调的超时时间是 5 秒,超过就会重试。如果业务逻辑里有慢查询或者外部接口调用,很容易超时。
解决:回调里只做验签、解密、落库,把耗时操作放到异步队列里处理。落库时用out_trade_no做唯一索引,重复插入直接忽略。返回给微信的响应要快,不要等业务逻辑全部跑完。
6. 用在线调试工具验证签名与回调的实操技巧
微信支付有个在线调试网站,可以模拟请求和验签。我一般用它来验证签名串构造是否正确。把拼串内容、私钥、序列号填进去,看生成的签名和代码里的是否一致。如果在线工具能通过,代码里报 401,那问题一定在 HTTP 请求构造上,比如 header 拼写、Content-Type 设置。
另一个技巧是用curl手动发一次请求,把Authorization头完整打印出来,和代码里生成的对比。curl命令里注意-d的 JSON 不要有换行和多余空格,否则签名串会变。
curl -X POST https://api.mch.weixin.qq.com/v3/pay/partner/transactions/jsapi \ -H "Authorization: WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\",nonce_str=\"abc123\",timestamp=\"1700000000\",serial_no=\"ABC123\",signature=\"xxx\"" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"sp_appid":"wx123","sp_mchid":"1900000001","sub_mchid":"1900000002","description":"test","out_trade_no":"test001","notify_url":"https://yourdomain.com/notify","amount":{"total":100,"currency":"CNY"},"payer":{"openid":"o123"}}'回调验证可以用微信提供的回调测试工具,模拟加密回调体,看你的解密逻辑能不能正确还原。我习惯在本地写一个单元测试,把真实的回调体、nonce、associated_data 存成测试用例,每次改代码跑一遍,确保解密和验签逻辑没被改坏。
最后一个习惯:所有和微信支付相关的配置项,在启动时做一次校验。私钥文件是否存在、序列号是否为空、ApiV3Key 是否 32 位,这些检查放在Startup或Program里,启动就报错,别等到调接口才发现。这个习惯帮我省了很多次线上排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取