)
简介面向 .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 TaskHttpResponseMessage 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_noxxx就会验签失败二是 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/jsapiV3 服务商的 JSAPI 下单地址和普通商户不一样。普通模式是/v3/pay/transactions/jsapi服务商模式多了一个 partner 段变成/v3/pay/partner/transactions/jsapi。如果照着普通商户文档拼 URL微信会返回 404 或者校验商户参数不匹配。用源码里的 PayCommon 模型来拼请求体结构比较清楚。public async TaskJsapiPayResult 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.ReadFromJsonAsyncJsapiPayResult(); 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_noSP2025031800001定位本系统订单transaction_id420000123456微信支付订单号退款时要用trade_stateSUCCESS只有 SUCCESS 才落支付成功success_time2025-03-18T10:30:0008:00实际支付成功时间amount.payer_total100用户支付金额单位分3.3 支付回写落库如何保证幂等与不丢单回写处理最怕两种错微信重复通知导致订单状态错乱业务处理到一半系统崩溃导致订单已经支付但权益没发。源码里的 PayService 用的是“先查订单再更新 事务 成功即返回”的三段式处理。public async TaskCallbackResponse HandlePayCallback(NotifyModel notify) { var plainJson DecryptNotifyResource( notify.Resource.Ciphertext, notify.Resource.Nonce, notify.Resource.AssociatedData); var pay JsonSerializer.DeserializePayTransaction(plainJson); if (pay.TradeState ! SUCCESS) return CallbackResponse.Success(); using var tran await _sugar.Ado.UseTranAsync(); var order await _sugar.QueryableOrder().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 TaskProfitSharingResult 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 ListProfitReceiver { 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.ReadFromJsonAsyncProfitSharingResult(); }这个请求体里最容易写错的是 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 TaskRefundResponse 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.ReadFromJsonAsyncRefundResponse(); }退款请求的同步响应里通常 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.GetStringAesGcm 抛异常nonce 或 associated_data 与 resource 不一致直接取 resource 原始字段不要 URL 解码请求返回 401URL 路径带了 query 或空 body 时签名串拼接错误空 body 时签名串仍然是方法\n路径\n时间\n随机串\n\n不能用空字符串代替5.3 上线前必做的一笔 1 分钱全链路验证我一直保持一个习惯每次接入新商户或者换子商户都在测试环境用 1 分钱把整条链路跑一遍而不是只测下单。具体顺序是先真实支付一笔订单等支付回写然后发起分账确认个人 openid 和子商户都到账再发起退款确认退款回调能更新状态。跑的时候把订单号、授权头、回调原文都记录到独立日志表里方便排查。把这套流程固化成自动化脚本或者测试用例以后升级证书、修改分账比例时只跑一遍就知道链路有没有被改坏。本文还有配套的精品资源点击获取