简介:面向 .NET Core 开发者的微信支付 V3 服务商模式源码包,覆盖普通支付、服务商模式支付、分账给个人、退款、支付回写等业务,适合平台型电商、多商户系统或需要接入微信支付分账能力的项目,阅读者需具备 C# 基础。资源共 696 个文件,压缩包约 34.16 MB,含 383 个动态链接库、70 个 C# 源文件、45 个 JSON 配置文件和多个 Config、PDB、XML 文档,并附带解决方案与多个工程文件,便于直接编译与二次开发。已有 1367 人学习下载。通过源码可以系统了解从下单、支付回写到分账给个人、服务商模式分账给子商户、V3 退款的全流程实现,掌握请求签名、回调验签、商户与子商户关系处理等关键细节,减少对接中的重复踩坑,能作为实际项目落地或重构支付模块时的可靠参考,尤其适合需要在生产环境快速集成支付能力的团队。
1. 从一次“分账给个人”的需求说起:netCore 为什么要直接上 V3 服务商模式
如果你是做平台型交易系统的,迟早会遇到一个场景:用户支付后,平台要按比例把钱分给子商户,还要从平台利润里拿一部分给推广用户个人。这个需求在微信支付里并不是“加一个字段”就能完成的,它要求商户号必须是服务商模式,并且支付、回写、分账、退款全部走 V3 API。我拆过一套 netCore 的微信支付源码,它把这个链路完整串了起来,既包含普通商户的 V3 支付,也包含服务商模式下的下单支付、支付回写、退款、分账给个人、分账给子商户。源码里 PayCommon、PayService、WechatPay、SugarHelper 四个工程分层清晰,很多细节值得抄作业。适合已经在微信商户平台开通服务商关系、想在 .NET Core 里少踩坑的人。
2. 项目结构拆解:PayCommon、PayService、WechatPay 和 SugarHelper 的职责边界
源码包虽然只给了 csproj 的缓存文件,但从 WechatPay.csproj、PayCommon.csproj、PayService.csproj、SugarHelper.csproj 这堆 AssemblyReference.cache 里能看出当时是同时维护支付网关、公共模型、业务服务和数据库封装四个工程。我在实际项目里也倾向这么拆,而不是把所有微信支付代码写进一个 Web API 的 Controller 里。
2.1 四个工程各管哪一段
先说分工,直接看下面的表,后面所有代码都围绕这个边界展开。
| 工程名 | 职责 | 常见内容 |
|---|---|---|
| PayCommon | 契约层 | 请求/响应 DTO、订单状态枚举、支付常量、回调公共模型 |
| WechatPay | 网关层 | HttpClient 工厂、签名拦截器、AES-GCM 解密、API 方法封装 |
| PayService | 服务层 | 下单编排、支付回写、分账、退款、结果通知处理 |
| SugarHelper | 数据层 | SqlSugar 的初始化、仓储基类、事务包装 |
PayService 是唯一允许同时引用 WechatPay 和 SugarHelper 的工程。WechatPay 不该知道订单表里有个 status 字段,PayCommon 也不放任何业务逻辑。这样做的理由很直接:微信支付 API 升级或者换支付渠道时,只动 WechatPay;数据库从 SqlSugar 换 EF Core 时,只动 SugarHelper,业务层不用跟着大改。我看清楚源码结构后第一件事就是复制这层边界,然后再去抠支付细节。
2.2 服务商模式的私钥与证书初始化
支付相关配置集中在 WechatPay 里,初始化时最需要注意的是服务商模式下要使用服务商自己的商户号、API 证书序列号和 API 私钥,而不是子商户的。这套源码里用 WxPayClient 统一创建 HttpClient,构造函数大概是这样的。
public class WxPayClient { private readonly HttpClient _httpClient; public WxPayClient(string mchId, string serialNo, string privateKeyPath, string apiV3Key) { var rsa = RSA.Create(); rsa.ImportFromPem(File.ReadAllText(privateKeyPath).ToCharArray()); var handler = new WxPaySignHandler(rsa, mchId, serialNo); _httpClient = new HttpClient(handler); _httpClient.BaseAddress = new Uri("https://api.mch.weixin.qq.com"); _httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); } }参数 mchId 填服务商商户号时,后面所有跟订单相关的接口都会在请求体里带 sub_mchid 指定子商户;填普通商户号时,就等价于普通商户模式。serialNo 是商户 API 证书序列号,不是 pem 证书文件里的序列号,这个很多第一次接入的人会看错。privateKeyPath 指向 apiclient_key.pem,它是 PKCS#8 格式,ImportFromPem可以直接读。如果还在用 netCore 3.1,需要手动转成 DER 再导入。
提示:apiV3Key 不要和商户 API 密钥混用,它是回调报文解密用的对称密钥,长度 32 字节,在商户平台设置后不会完整回显。
2.3 签名拦截器与请求 header 拼装
V3 的认证头是 WECHATPAY2-SHA256-RSA2048,签名串固定为“请求方法 + URL 路径 + 时间戳 + 随机串 + 请求体”用换行连接。我在源码里看到的做法是用 DelegatingHandler 统一处理,这样所有 API 方法不必重复写签名逻辑。
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var body = request.Content == null ? "" : await request.Content.ReadAsStringAsync(); var path = request.RequestUri.PathAndQuery.Split('?')[0]; var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var nonce = Guid.NewGuid().ToString("N"); var message = $"{request.Method.Method}\n{path}\n{timestamp}\n{nonce}\n{body}\n"; var signature = _rsa.SignData( Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); request.Headers.Add("Authorization", $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_mchId}\",nonce_str=\"{nonce}\",timestamp=\"{timestamp}\",serial_no=\"{_serialNo}\",signature=\"{Convert.ToBase64String(signature)}\""); return await base.SendAsync(request, cancellationToken); }这个拦截器里最值得注意的两点:一是 path 必须去掉域名和 query,比如带?out_no=xxx就会验签失败;二是 body 必须在使用ReadAsStringAsync之后再传给 base 继续发送,否则有些 HttpContent 只能读一次。很多 401 错误都是因为这两处没处理好。另外微信要求每个请求的 nonce_str 都不相同,用Guid.NewGuid().ToString("N")去掉横线就可以。服务商模式下的 mchid 和 serial_no 都用服务商商户的,签名证书和发起支付的商户号必须一致,子商户号只出现在请求体里,这是服务商模式和普通商户之间最本质的差异。
3. 服务商模式统一下单与支付回写:从 JSAPI 下单到验签落库
这一章开始进入业务主线。支付回写不是只有回调接口里改一个订单状态,它至少包含服务商 JSAPI 下单、回调报文验签、解密、幂等落库四个步骤。源码里把这四步分散在 WechatPay 和 PayService 两个工程中,原因是下单和验签属于 API 能力,而落库属于业务规则。
3.1 服务商 JSAPI 下单:/v3/pay/partner/transactions/jsapi
V3 服务商的 JSAPI 下单地址和普通商户不一样。普通模式是/v3/pay/transactions/jsapi,服务商模式多了一个 partner 段,变成/v3/pay/partner/transactions/jsapi。如果照着普通商户文档拼 URL,微信会返回 404 或者校验商户参数不匹配。用源码里的 PayCommon 模型来拼请求体,结构比较清楚。
public async Task<JsapiPayResult> JSAPIPay(OrderInfo order, string spOpenId) { var request = new JsapiOrderRequest { sp_appid = _config.SpAppId, sp_mchid = _config.SpMchId, sub_mchid = order.SubMchId, description = order.GoodsName, out_trade_no = order.OrderNo, notify_url = _config.NotifyUrl, amount = new PayAmount { total = order.TotalFee, currency = "CNY" }, payer = new Payer { sp_openid = spOpenId } }; var resp = await _wxClient.PostAsJsonAsync("/v3/pay/partner/transactions/jsapi", request); var result = await resp.Content.ReadFromJsonAsync<JsapiPayResult>(); return result; }这里的 total 单位是分,如果从数据库读出来的是 decimal 元,要乘 100 再取整。payer 里传的是服务商应用下的用户 openid;如果用户是从子商户自己的小程序进来的,需要改为 sub_appid + sub_openid 组合,但 sp_appid 还是要传服务商的,不能只传一个。out_trade_no 这个字段在同一商户号下是唯一的,但服务商模式下订单是挂在服务商号下的,所以建议订单号生成规则里带上子商户标识,否则不同子商户的两个订单可能撞单。
3.2 回调报文结构与验签:先把请求头验证通过了再谈解密
支付成功后微信回调 notify_url,请求头里带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce和平台证书序列号。我一般先验签再解密,验签数据用“时间戳 + 换行 + 随机串 + 换行 + 原始报文 + 换行”拼接,公钥来自微信支付平台证书。
public bool VerifyWechatpaySign(string timestamp, string nonce, string body, string certSerial, string signature) { var publicKey = _platformCertManager.GetCertificate(certSerial).GetRSAPublicKey(); var data = $"{timestamp}\n{nonce}\n{body}\n"; return publicKey.VerifyData( Encoding.UTF8.GetBytes(data), Convert.FromBase64String(signature), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); }这里有个容易被忽略的点:平台证书会定期轮换,certSerial 每次回调可能不同。源码里的 _platformCertManager 会按序列号缓存公钥,并在调接口时通过/v3/certificates主动拉取新证书。如果把这些证书写死在配置里,某天微信换了证书,线上会突然大量回调验签失败,而且排查起来非常隐蔽。
验签通过后,body 里的 resource 还是加密的,需要先用 APIv3 key 做 AES-256-GCM 解密。
public string DecryptNotifyResource(string ciphertext, string nonce, string associatedData) { var keyBytes = Encoding.UTF8.GetBytes(_apiV3Key); var nonceBytes = Encoding.UTF8.GetBytes(nonce); var adBytes = Encoding.UTF8.GetBytes(associatedData); var cipherBytes = Convert.FromBase64String(ciphertext); var plainBytes = new byte[cipherBytes.Length - 16]; using var aes = new AesGcm(keyBytes, 16); aes.Decrypt( nonceBytes, cipherBytes.AsSpan(0, cipherBytes.Length - 16).ToArray(), cipherBytes.AsSpan(cipherBytes.Length - 16).ToArray(), plainBytes, adBytes); return Encoding.UTF8.GetString(plainBytes); }注意 cipherBytes 的最后 16 字节是 GCM 认证标签,不能参与解密,要把它单独切出来作为 tag。nonce 和 associatedData 都来自回调 body 的 resource 对象,直接拿字符串,不要做 Base64 解码。AesGcm 在 netCore 3.0 以后是内置的,如果是 netCore 2.1,需要引入 System.Security.Cryptography.Algorithms 包。解密后得到的 JSON 关键字段可以对照下面的表。
| 字段 | 示例 | 用途 |
|---|---|---|
| out_trade_no | "SP2025031800001" | 定位本系统订单 |
| transaction_id | "420000123456" | 微信支付订单号,退款时要用 |
| trade_state | "SUCCESS" | 只有 SUCCESS 才落支付成功 |
| success_time | "2025-03-18T10:30:00+08:00" | 实际支付成功时间 |
| amount.payer_total | 100 | 用户支付金额,单位分 |
3.3 支付回写落库:如何保证幂等与不丢单
回写处理最怕两种错:微信重复通知导致订单状态错乱;业务处理到一半系统崩溃,导致订单已经支付但权益没发。源码里的 PayService 用的是“先查订单再更新 + 事务 + 成功即返回”的三段式处理。
public async Task<CallbackResponse> HandlePayCallback(NotifyModel notify) { var plainJson = DecryptNotifyResource( notify.Resource.Ciphertext, notify.Resource.Nonce, notify.Resource.AssociatedData); var pay = JsonSerializer.Deserialize<PayTransaction>(plainJson); if (pay.TradeState != "SUCCESS") return CallbackResponse.Success(); using var tran = await _sugar.Ado.UseTranAsync(); var order = await _sugar.Queryable<Order>().FirstAsync(x => x.OrderNo == pay.OutTradeNo); if (order.Status == (int)OrderStatus.Paid) { return CallbackResponse.Success(); } order.Status = (int)OrderStatus.Paid; order.TransactionId = pay.TransactionId; order.PayTime = pay.SuccessTime; await _sugar.Updateable(order).ExecuteCommandAsync(); await _sugar.Insertable(new PayLog { OrderNo = pay.OutTradeNo, TransactionId = pay.TransactionId, PayAmount = pay.Amount.PayerTotal, RawData = plainJson }).ExecuteCommandAsync(); await tran.CommitAsync(); return CallbackResponse.Success(); }这段逻辑的关键点在“已经 Paid 就直接返回成功”,这样重复通知不会引发二次发券。微信的成功回执不是 HTTP 200 就完事,响应 body 要求是微信指定的 JSON 结构,一般返回{"code":"SUCCESS","message":"成功"}。如果业务异常,要故意抛异常或返回非 200,微信才会按 15 秒间隔重试。回写事务里不要做耗时太长的操作,比如同步给个人分账、推送消息,这些要放到事务提交后由消息队列或后台任务处理,否则微信在 5 秒内超时重试,反而造成大量重复请求。
4. 分账与退款:给个人分账、给子商户分账、V3 退款与失败处理
分账和退款在服务商模式下是紧密相连的两件事。支付完成后,如果想分账给个人,又需要退款,顺序就不能乱:先做分账,再做退款时要把已分金额回退,否则微信会提示订单已分账不能退款。这套源码把分账和退款都封装在 PayService 的 ProfitService 和 RefundService 里,下面按调用顺序拆开。
4.1 分账前先搞清接收方类型和限制
分账接收方不是随便填个 openid 或者商户号就能分。微信要求接收方类型必须在约定范围内,且对于个人 openid 接收方,需要提前在商户平台或通过分账接收方接口添加并验证,否则调用请求分账时会报NO_AUTH或者RECEIVER_NOT_EXIST。在服务商模式下,常见接收方类型如下。
| 接收方类型 | account 传什么 | 服务商模式下的典型用途 |
|---|---|---|
| MERCHANT_ID | 子商户号 | 把订单金额结算给实际供货方 |
| PERSONAL_OPENID | 服务商应用下的用户 openid | 给推广人员个人发奖励 |
| PERSONAL_SUB_OPENID | 子商户应用下的用户 openid | 子商户自己识别用户时使用 |
分账给个人与分账给子商户的比例没有固定硬编码,但要遵守微信支付规则:单笔分账接收方最多 50 个,每个接收方金额必须是正整数,且所有接收方金额之和不能超过订单可分金额。给个人 openid 分账时,openid 必须属于该分账订单使用的 appid 下的用户,否则系统会回推INVALID_OPENID。
4.2 服务商模式请求分账:/v3/profitsharing/orders 的组装
服务商分账的请求地址是 POST/v3/profitsharing/orders,请求体里必须同时带上子商户号 sub_mchid、服务商应用 id appid、原支付订单的 transaction_id。源码里创建分账单时,把两个接收方放在同一批请求里:一个分给子商户,一个分给个人 openid。
public async Task<ProfitSharingResult> CreateProfitSharing(ProfitCreateRequest model) { var req = new ProfitSharingOrder { appid = _config.SpAppId, sub_mchid = model.SubMchId, transaction_id = model.TransactionId, out_order_no = model.ProfitNo, receivers = new List<ProfitReceiver> { new ProfitReceiver { type = "MERCHANT_ID", account = model.SubMchId, amount = model.SettleAmount, description = "子商户结算" }, new ProfitReceiver { type = "PERSONAL_OPENID", account = model.SpOpenId, amount = model.InviterAmount, description = "推广奖励" } }, unfreeze_unsplit = true }; var resp = await _wxClient.PostAsJsonAsync("/v3/profitsharing/orders", req); return await resp.Content.ReadFromJsonAsync<ProfitSharingResult>(); }这个请求体里最容易写错的是 appid。在服务商分账中,appid 指服务商应用ID,而不是子商户应用ID,因为分账出资方是服务商商户号,资金从服务商商户号里划出。unfreeze_unsplit 参数表示分账完成后是否自动解冻剩余资金。如果这次分账不是全部金额,且后续还要继续分,就设为 false;如果一次分完,设为 true 可以省去再调一次解冻接口。description 字段不能为空,也不要填纯数字,否则接口会校验失败。
提示:分账给个人是接口开通后才有权限,如果测试环境报“分账接收方类型未开”,先到商户平台查看分账功能权限,而不是反复重试。
4.3 分账回写和失败后的重试机制
分账请求接口只表示微信已受理,分账结果通过异步通知返回,事件类型对应分账通知。源码里 PayService 对分账通知的处理比较简单,就是拿解密后的 result 判断。
var json = JsonDocument.Parse(plainText); if (json.RootElement.GetProperty("result").GetString() == "SUCCESS") { await _profitRepository.MarkSuccessAsync( json.RootElement.GetProperty("out_order_no").GetString(), json.RootElement.GetProperty("order_id").GetString()); }分账通知的 result 可能有 SUCCESS、FAILED、FINISHED 等状态。SUCCESS 表示这笔分账行为成功,但整个分账单可能还有后续分账;FINISHED 表示所有分账都完成。如果只判断 SUCCESS 就置为终态,后续任务可能提前触发。更稳的处理是维护一张 profit_order 表,记录每笔分账是否全部完成,然后由定时任务扫描 out_order_no 去调查询分账结果接口补状态。微信会自动重试通知,但超过一定次数后不再通知,这时必须靠主动查询兜底。
4.4 V3 退款接口与退款回调处理
退款不能用支付回调的事务里同步做,要单独建退款单。V3 的退款接口路径是/v3/refund/domestic/refunds,服务商模式代子商户退款时,请求体要加 sub_mchid。源码里 RefundService 的创建退款方法类似下面。
public async Task<RefundResponse> CreateRefund(RefundApplyDto dto) { var req = new RefundRequest { out_trade_no = dto.OrderNo, out_refund_no = dto.RefundNo, sub_mchid = dto.SubMchId, amount = new RefundAmount { refund = dto.RefundFee, total = dto.TotalFee, currency = "CNY" }, notify_url = _config.RefundNotifyUrl }; var resp = await _wxClient.PostAsJsonAsync("/v3/refund/domestic/refunds", req); return await resp.Content.ReadFromJsonAsync<RefundResponse>(); }退款请求的同步响应里通常 refund_status 是 PROCESSING,真正的结果在退款回调里告知。退款金额 refund 不能大于原订单 total,且如果订单已经做过部分退款,再次退款的剩余金额要重新计算。退款回调的 resource 解密方式和支付回调一样,只是字段前缀不同。以下表格列出退款状态和应对动作。
| refund_status | 含义 | 处理动作 |
|---|---|---|
| SUCCESS | 退款已成功 | 更新退款单和订单剩余可退金额 |
| CLOSED | 退款关闭 | 如果已扣款要原路退回,标记退款关闭 |
| ABNORMAL | 退款异常需要人工 | 告警并进入退款核查流程 |
| PROCESSING | 退款处理中 | 等待下一条通知,不重复发起 |
如果订单先做了分账再发起退款,微信会校验分账状态,常见报错是“存在未回退的分账订单”。这时候需要先调分账回退接口/v3/profitsharing/orders/return把已分给个人的部分退回,再重新发起退款。源码里把分账回退与退款封装成了两个独立 service,顺序由业务流程控制,不要在退款接口里自动调回退,否则会把正常的“部分分账”状态搞乱。
5. 调试、排错与上线前检查:从日志里快速定位签名和回调问题
5.1 先用控制台工具看签名头
所有 V3 接口的第一道坎都是 401。最常见的原因不是签名算法不会写,而是签名串拼接和文档不一致。我一般会在开发环境写一个几十行的控制台工具,把签名串原样打出来,再和微信支付签名工具的结果对比。
var method = "POST"; var urlPath = "/v3/pay/partner/transactions/jsapi"; var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var nonce = Guid.NewGuid().ToString("N"); var body = "{\"sp_appid\":\"wx123\",\"sp_mchid\":\"16xxx\",\"sub_mchid\":\"19xxx\"}"; var message = $"{method}\n{urlPath}\n{timestamp}\n{nonce}\n{body}\n"; var signature = Convert.ToBase64String( rsa.SignData(Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); Console.WriteLine($"Authorization: WECHATPAY2-SHA256-RSA2048 mchid=\"{mchId}\",nonce_str=\"{nonce}\",timestamp=\"{timestamp}\",serial_no=\"{serialNo}\",signature=\"{signature}\"");如果确认签名串格式没变,再看 serial_no 是否为 API 证书序列号,以及商户号是否一致。服务商模式下如果误用子商户的证书给服务商接口签名,微信会提示商户号与证书不匹配。
5.2 回调验签失败和乱码的排查清单
支付回调和退款回调经常在本地能通、部署到服务器后报验签失败。多数原因是服务器时间不准,或者平台证书没更新。以下是实际排查顺序。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 回调验签失败 | 微信支付平台证书过期或被替换 | 定时刷新平台证书,不要写死在配置里 |
| 解密后中文乱码 | 解码字节没有使用 UTF-8 | 解密后统一用 Encoding.UTF8.GetString |
| AesGcm 抛异常 | nonce 或 associated_data 与 resource 不一致 | 直接取 resource 原始字段,不要 URL 解码 |
| 请求返回 401 | URL 路径带了 query 或空 body 时签名串拼接错误 | 空 body 时签名串仍然是方法\n路径\n时间\n随机串\n\n,不能用空字符串代替 |
5.3 上线前必做的一笔 1 分钱全链路验证
我一直保持一个习惯:每次接入新商户或者换子商户,都在测试环境用 1 分钱把整条链路跑一遍,而不是只测下单。具体顺序是先真实支付一笔订单,等支付回写;然后发起分账,确认个人 openid 和子商户都到账;再发起退款,确认退款回调能更新状态。跑的时候把订单号、授权头、回调原文都记录到独立日志表里,方便排查。把这套流程固化成自动化脚本或者测试用例,以后升级证书、修改分账比例时只跑一遍就知道链路有没有被改坏。
本文还有配套的精品资源,点击获取