
简介netCore接入微信支付V3服务商模式的完整源码方案面向.NET Core开发者和需要实现微信支付、分账、退款等功能的团队。资源覆盖普通支付、V3支付、服务商模式支付、支付回写、退款以及分账给个人/子商户等核心场景按公共组件、支付服务、接口应用等模块划分工程结构清晰便于直接移植或二次开发。压缩包共696个文件约34.16MB以dll、cs源码、pdb调试文件、json配置为主同时包含sln解决方案与csproj工程文件适合在Visual Studio中整体打开调试。已有1367人学习下载说明该方案具备较强的参考价值。通过源码可快速梳理微信支付V3的接入流程、服务商模式下的分账逻辑以及回调处理机制尤其适合需要处理平台型商户资金分账的开发者参考。1. netCore 接入微信支付 V3 服务商模式最先遇到坑的是字段名在 .NET 后端里对接微信支付 V3 服务商模式大多数团队并不是倒在签名算法上而是死在“以为和普通商户模式一样”这个预设上。V3 服务商模式的统一下单请求体里服务商叫sp_appid、sp_mchid子商户叫sub_mchid回调通知里返回的又是 AES-256-GCM 密文必须用 APIv3 密钥解密才能看到订单号。本文把这套链路拆成配置、下单、分账、退款、支付回写和投诉回调六段用 .NET 6/8 能直接落地的代码讲清楚每一步参数怎么设、失败看哪里。适合正在做多商户平台、聚合支付或 SaaS 订单系统的后端工程师也适合被分账和退款折腾过一轮的人。2. 配置服务商模式证书、密钥、平台证书与签名封装2.1 六个关键配置项决定你能否调通支付接口服务商模式本质上是“一个服务商主体替多个子商户发起交易”。鉴权主体始终是服务商商户号所以商户私钥、商户证书序列号、APIv3 密钥这三件套来自服务商而每一笔交易具体落在哪个子商户由请求体里的sub_mchid决定。这意味着sub_mchid不能固定在配置里必须从业务订单上取否则整个平台只有一家子商户能收款。常用配置结构如下{ WechatPay: { AppId: wx1234567890abcdef, MchId: 1900009191, ApiV3Key: 32位英数字符串, MerchantPrivateKey: -----BEGIN PRIVATE KEY-----\n..., MerchantSerialNo: 6F8E9F..., PlatformCertificatePath: cert/wx_platform_cert.pem } }ApiV3Key和MerchantPrivateKey是接口签名和回调解密的关键。ApiV3Key是 32 字节字符串在商户平台“API 安全”里自行设置MerchantPrivateKey是下载 API 证书时一起拿到的apiclient_key.pem。PlatformCertificatePath是微信平台证书回调验签时使用后面第 5 章会详细讲。下面这张表把三个主体的归属关系列清楚避免配置时填反配置项所属主体实际用途AppId服务商应用请求体里的sp_appidMchId PrivateKey SerialNo服务商商户请求签名、接口鉴权SubMchId子商户请求体里的sub_mchid不固定ApiV3Key服务商商户回调密文解密的对称密钥2.2 每个请求都要做一次 RSA-SHA256 签名这是整个 V3 体系里最不能写错的环节。签名内容不是对参数按字典序拼接而是把 method、url、timestamp、nonce、body 五个部分用换行符连接。body 是原始 JSON 字符串序列化后再格式化会导致验签失败url 不包含域名只需要路径和查询字符串。完整签名方法如下private string Sign(string method, string url, string body, string timestamp, string nonce, string privateKey) { var message ${method}\n{url}\n{timestamp}\n{nonce}\n{body}\n; using var rsa RSA.Create(); rsa.ImportFromPem(privateKey); var data Encoding.UTF8.GetBytes(message); var signed rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(signed); }调用方需要把签名结果放进Authorization头var url /v3/pay/transactions/jsapi; var body JsonSerializer.Serialize(request, _jsonOptions); var timestamp DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var nonce Guid.NewGuid().ToString(N); var signature Sign(POST, url, body, timestamp, nonce, options.MerchantPrivateKey); client.DefaultRequestHeaders.TryAddWithoutValidation(Authorization, $WECHATPAY2-SHA256-RSA2048 mchid\{options.MchId}\, $nonce_str\{nonce}\,timestamp\{timestamp}\, $serial_no\{options.MerchantSerialNo}\,signature\{signature}\);serial_no是商户 API 证书序列号不是微信平台证书的序列号两个不要混用。如果更换了 API 证书旧序列号会在请求时直接返回“证书序列号不匹配”。签名用的body必须和 HttpClient 实际发送的 body 完全一致很多排查半天的签名问题都出在这里。2.3 把公共逻辑收敛到一个 DelegatingHandler 里下单、退款、分账、查单都要生成同样的签名头不要在业务代码里到处复制。常见做法是实现一个DelegatingHandler配合AddHttpClient注册让所有请求自动带签名。这样做还有一个额外好处如果以后要对接微信支付的“平台公钥模式”或切换证书只需要改动 handler 内部业务层无感。public class WechatPayAuthHandler : DelegatingHandler { private readonly IOptionsWechatPayOptions _options; public WechatPayAuthHandler(IOptionsWechatPayOptions options) { _options options; } protected override async TaskHttpResponseMessage SendAsync(HttpRequestMessage request, CancellationToken ct) { var body request.Content null ? : await request.Content.ReadAsStringAsync(ct); var timestamp DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var nonce Guid.NewGuid().ToString(N); var url request.RequestUri!.PathAndQuery; var message ${request.Method.Method}\n{url}\n{timestamp}\n{nonce}\n{body}\n; using var rsa RSA.Create(); rsa.ImportFromPem(_options.Value.MerchantPrivateKey); var signed rsa.SignData(Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); request.Headers.TryAddWithoutValidation(Authorization, $WECHATPAY2-SHA256-RSA2048 mchid\{_options.Value.MchId}\, $nonce_str\{nonce}\,timestamp\{timestamp}\, $serial_no\{_options.Value.MerchantSerialNo}\, $signature\{Convert.ToBase64String(signed)}\); return await base.SendAsync(request, ct); } }注意request.RequestUri.PathAndQuery拿到的就是不带域名的路径正好符合签名要求。Get 请求没有 body 时签名字符串里 body 位置是空字符串也就是最后会连续出现两个换行符这是正常的不要省略。3. 统一下单JSAPI 和 Native 的服务商字段差异3.1 服务商模式请求体的字段层级统一下单接口是POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi服务商模式的请求体与直连模式明显不同。直连模式写appid mchid payer.openid服务商模式则要求服务商和子商户成对出现支付者标识也要用sub_openid{ sp_appid: wx1234567890abcdef, sp_mchid: 1900009191, sub_mchid: 1900000109, description: 测试商品-001, out_trade_no: 20240101001, notify_url: https://api.example.com/pay/notify, amount: { total: 100, currency: CNY }, payer: { sub_openid: oUpF8uMuAJO_M2pxb1Q9zNjWeS6o } }out_trade_no是商户侧订单号在服务商模式下只需要在服务商主体内唯一不需要跨子商户唯一。total单位是分100 表示 1 元支付回写时回调里返回的amount.total也是分和下单值直接比对即可。notify_url是支付结果通知地址同一个地址会被所有子商户订单复用。如果子商户有自己的小程序或公众号可以额外传sub_appid此时payer里的sub_openid必须是该子商户应用下的用户标识。如果子商户没有独立应用就直接用服务商 AppID 下的openid填到payer.sub_openid两种方式由微信平台根据sub_appid是否存在来判断不能混填。3.2 NetCore 创建订单并生成前端调起参数后端创建订单的核心代码不需要太多关键是把下单和二次签名分开。下单拿到的是prepay_id小程序端并不能直接用这个值还要用 appId、timeStamp、nonceStr、package 再签一次public async Taskstring CreateJsapiOrderAsync(string subMchid, string openid, int totalFee, string outTradeNo) { var url /v3/pay/transactions/jsapi; var request new { sp_appid _options.AppId, sp_mchid _options.MchId, sub_mchid subMchid, description 测试商品, out_trade_no outTradeNo, notify_url _options.NotifyUrl, amount new { total totalFee, currency CNY }, payer new { sub_openid openid } }; var body JsonSerializer.Serialize(request); var resp await _client.PostAsync(url, new StringContent(body, Encoding.UTF8, application/json)); var json await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) { throw new WechatPayException((int)resp.StatusCode, json); } return JsonSerializer.DeserializeJsapiOrderResult(json).PrepayId; }拿到prepay_id后拼接二次签名数据var payParams new { appId _options.AppId, timeStamp DateTimeOffset.Now.ToUnixTimeSeconds().ToString(), nonceStr Guid.NewGuid().ToString(N), package $prepay_id{prepayId}, signType RSA }; var signMessage ${payParams.appId}\n{payParams.timeStamp}\n{payParams.nonceStr}\n{payParams.package}\n; var paySign Sign(signMessage); // 复用 2.2 节的 RSA SHA256 签名这里参与签名的字符串顺序是固定的package必须是prepay_idxxx这种完整格式最后同样要有一个换行符收尾。前端wx.requestPayment接收这五个参数时字段名首字母大小写也有要求后端序列化时建议显式指定 JSON 命名策略为 camelCase否则小程序端会报payment parameters error。3.3 Native 支付只需返回一个 code_urlNative 支付适合扫码场景请求地址改为/v3/pay/transactions/native请求体不需要payer其他字段与 JSAPI 基本一样。微信返回code_url后端直接把它交给前端生成二维码即可。服务商模式下 Native 支付同样带上sp_appid、sp_mchid、sub_mchid注意子商户没有自己的应用时二维码打开后的支付页会显示服务商主体名称用户可能看不出是哪个子商户这种体验问题要在进件时提前设计好。Native 下单失败时最常见的返回是APPID_MCHID_NOT_MATCH表示sp_appid和sp_mchid不是绑定关系。服务商应用的 AppID 必须事先在服务商商户平台完成关联这个动作不是调用 API 能完成的需要登录商户平台手动操作。4. 分账与退款先加接收方再谈金额拆分4.1 添加分账接收方是分账的前置条件很多团队第一次调分账接口时收到NO_AUTH或RECEIVER_NOT_EXIST原因就是漏了“添加分账接收方”。分账接收方不会自动跟随子商户进件生成必须显式调用接口绑定。服务商模式下常见接收方是子商户号或其他合作商户号。var request new { appid _options.AppId, type MERCHANT_ID, account 1900000109, name 某某有限公司, relation_type SERVICE_PROVIDER }; var resp await _client.PostAsJsonAsync(/v3/profitsharing/receivers/add, request);type参数决定account怎么填常见三种取值如下type 参数account 含义使用场景MERCHANT_ID商户号服务商给子商户分账PERSONAL_OPENID个人微信 openid给用户个人分账PERSONAL_SUB_OPENID子商户应用下个人 openid用户身份基于子商户 apprelation_type在服务商分账场景一般填SERVICE_PROVIDER表示服务商与接收方是服务关系。如果接收方是子商户自己account填子商户号如果是其他服务商下的商户需要额外关注签约关系不是随便一个商户号都能收钱。4.2 创建分账订单的请求参数分账接口是POST /v3/profitsharing/orders必须在支付成功后才能发起。请求体里既有appid又有sub_mchid这容易让人误以为要写sp_appid实际微信文档要求这里直接用服务商 AppID。{ appid: wx1234567890abcdef, sub_mchid: 1900000109, transaction_id: 4200001569202007028285849528, out_order_no: P20240101001, receivers: [ { type: MERCHANT_ID, account: 86693852, amount: 20, description: 分给合作方 } ] }transaction_id是微信支付订单号out_order_no是商户侧分账单号需要保证唯一。receivers数组最多支持 50 个接收方所有接收方金额之和不能超过订单总金额单位同样是分。分账比例上限受商户号风控等级影响没有固定百分比接口会直接提示。分账是异步操作提交成功后不能立刻认为钱已到账要主动查询结果或接收分账回调。查询接口是GET /v3/profitsharing/orders/{out_order_no}?sub_mchid1900000109transaction_id4200001569202007028285849528返回的status字段常见取值有PROCESSING、FINISHED、CLOSED。第三方分账系统通常会把查询动作封装成一个定时任务分账单处于PROCESSING时每 10 秒查一次最多查 3 分钟超时后标记为异常并人工介入。4.3 退款接口与分账回退退款入口是POST /v3/refund/domestic/refunds服务商模式的请求体只要求sub_mchid不需要sp_appid。{ sub_mchid: 1900000109, out_trade_no: 20240101001, out_refund_no: R20240101001, amount: { refund: 100, total: 100, currency: CNY }, notify_url: https://api.example.com/refund/notify }refund是本次退款金额total必须是原订单支付总额不是退款后的剩余金额。这两者很容易写反一旦refund大于total会直接报错。如果订单已经做过部分退款再次退款时total依然是原始金额。分账后的订单要全额退款情况会复杂一些。微信要求先把已分账的金额回退到商户再走退款流程否则原路退款会因为资金不足而失败。回退接口是var request new { sub_mchid 1900000109, out_order_no P20240101001, out_return_no R20240101001, return_mchid 86693852, amount 20, description 分账回退 }; await _client.PostAsJsonAsync(/v3/profitsharing/return-orders, request);return_mchid是收款方商户号也就是原分账请求里接收方的account。分账回退也有异步回调回退成功后原路退回的资金才能用于退款。做多商户平台时建议把“退款前检查分账单状态”写成统一校验方法发现订单已分账就先触发回退再进入退款队列。5. 支付回写回调验签、解密和状态更新5.1 验签前先检查时间戳和证书序列号微信支付结果通知会 POST 到下单时的notify_url请求头里带Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial、Wechatpay-Signature四个字段。不能直接跳过验签就解析 body否则任何人伪造一个通知都能把订单改成已支付。验签的基本逻辑是先用Wechatpay-Serial找到对应微信平台证书然后用该证书公钥对timestamp \n nonce \n body \n做 RSA-SHA256 验签。这里用的是微信平台证书不是商户 API 证书。时间戳防重放是一个容易忽略的点。建议先判断Wechatpay-Timestamp与当前时间差是否在 5 分钟内超过就直接拒绝防止攻击者录制旧请求重放。平台证书需要定期更新常见做法是每天凌晨调用GET /v3/certificates拉取最新证书并缓存证书字段本身是密文需要先解密再保存。5.2 用 AesGcm 解密 resource 数据验签通过后body 里的resource字段才是真正有用的数据。微信使用 AEAD_AES_256_GCM 算法加密解密需要 APIv3 密钥和支付请求签名无关。密文结构上最后的 16 字节是 GCM 认证标签需要和真正的密文分开传参。public string DecryptResource(string ciphertext, string nonce, string associatedData) { var cipherData Convert.FromBase64String(ciphertext); var tag cipherData[^16..]; var encrypted cipherData[..^16]; var plaintext new byte[encrypted.Length]; using var aes new AesGcm(Encoding.UTF8.GetBytes(_options.ApiV3Key), 16); var associatedBytes string.IsNullOrEmpty(associatedData) ? null : Encoding.UTF8.GetBytes(associatedData); aes.Decrypt( Encoding.UTF8.GetBytes(nonce), encrypted, tag, plaintext, associatedBytes); return Encoding.UTF8.GetString(plaintext); }associatedData对应通知 body 里resource.associated_data有些情况下这个字段是空字符串解密时传null即可。解密后的 JSON 就是支付结果包含out_trade_no、transaction_id、amount.total、sub_mchid、payer.sub_openid等字段。5.3 支付回写的状态机与幂等更新支付回写是最容易出并发问题的地方。微信支付结果通知是异步的、可能重复的同一笔订单在极端情况下会收到多次通知业务侧必须以“状态更新”的幂等性为前提来设计。推荐做法是先查询订单判断是否已经从待支付变成已支付如果是就直接返回成功如果不是校验金额和订单号再用条件更新语句把状态从待支付改成已支付。条件更新的好处是天然防并发两个线程同时收到回调时只有一条 SQL 能更新成功。var result JsonSerializer.DeserializePayResult(plaintext); var order await _orderRepo.GetByOutTradeNoAsync(result.OutTradeNo); if (order null) { return Ok(new { code FAIL, message 订单不存在 }); } if (order.Status OrderStatus.Paid) { return Ok(new { code SUCCESS, message 成功 }); } if (order.TotalFee ! result.Amount.Total) { return Ok(new { code FAIL, message 金额不一致 }); } var updated await _orderRepo.MarkPaidAsync( order.Id, result.TransactionId, OrderStatus.Paid, // 目标状态 OrderStatus.Created // 期望的当前状态 ); if (!updated) { return Ok(new { code FAIL, message 回调重复或状态异常 }); } // 在这里触发发货、积分、虚拟权益等后续动作 await _orderService.AfterPaidAsync(order.Id);MarkPaidAsync对应 SQL 大致是UPDATE orders SET status TargetStatus, transaction_id TransactionId, paid_at Now WHERE id Id AND status CurrentStatus;这里要特别强调如果数据库更新失败或者后续业务处理抛异常回调接口必须返回非 2xx 状态码或 JSON 中code为FAIL微信才会按间隔重试。但如果订单已经处于已支付状态永远要返回成功否则微信会不断重试造成日志刷屏。6. 投诉回调与联调验证技巧6.1 投诉回调与支付回调共用验签管线微信支付投诉回调的接入位置是POST /v3/merchant-service/complaints-notifications当用户对订单发起投诉时微信会向这个地址推送通知。投诉回调的通知格式和支付结果通知一样都是resource字段加密所以验签和密文解密逻辑完全可以复用同一套管线。区别只在解密后的业务字段投诉通知返回的是complaint_id、out_trade_no、complaint_state、complaint_description等。接入时的关键是把out_trade_no关联回本地订单再通知客服系统。一个商户号下可能有多个子商户订单投诉回调里没有sub_mchid字段所以只能通过out_trade_no反查本地订单再找到对应的子商户。如果订单号生成规则里没有带子商户标识这一步就需要联合索引来查建议订单表给out_trade_no建唯一索引。6.2 联调中最常见的四个问题第一个是“验签失败”。多数原因不是代码逻辑而是平台证书和实际证书不匹配。回调头里的Wechatpay-Serial对应的是微信平台证书序列号如果代码里用的是商户 API 证书序列号验签永远失败。第二个是“AES 解密报错”。AesGcm解密报Authentication tag mismatch基本可以确定ApiV3Key配错了或者密文被截断过。还有一个隐蔽问题有些 .NET 版本中AesGcm构造函数的tagSizeInBytes参数必须显式传 16否则默认值和微信不匹配。第三个是“下单成功但回调收不到”。先看notify_url是否公网可访问微信不会重试 4xx 响应如果 SDK 或代码里对回调请求先做了权限校验返回 401 或 403微信会直接放弃。回调地址必须是 HTTPS并且证书链完整。第四个是“金额不一致导致回写失败”。这种情况通常不是程序 bug而是订单金额在支付前被修改过。比如用户下单 100 元支付回调回来发现订单已经被改成 80 元校验就会失败。建议把金额校验结果单独记录下来而不是简单地把订单标记为异常方便运营后台核对。联调时可以用一个统一入口把所有微信通知打到同一个 Action用通知类型区分处理逻辑这样支付回调、退款回调、分账回调和投诉回调的验签解密只写一遍后面新增通知类型时只需要增加一个分支方法。本文还有配套的精品资源点击获取