简介:面向 netCore 开发者的微信支付 V3 服务商模式集成源码包,内容覆盖普通支付、微信 V3 支付、服务商模式支付、分账给个人、服务商模式分账给子商户、退款及支付回写等核心场景。无论是普通商户直接对接,还是平台型项目需要管理二级商户资金,均能找到可直接参考的 C# 实现,覆盖服务商模式特级商户进件后的支付链路。包内共 696 个文件,以 C# 源码、dll 运行库为主,辅以 json/config 接口配置、xml 文档、csproj 工程文件及少量 exe 辅助工具,压缩包整体仅 34.16MB,结构按 PayCommon、PayService、WechatPay 等模块组织,含解决方案与日志文件,便于定位调试和按需裁剪。已有 1367 人学习下载,适合具备 .NET Core 基础、正在搭建支付服务或需要对接微信支付分账/退款逻辑的开发者,尤其是涉及平台与子商户资金结算的项目。通过源码可快速理清 V3 接口签名、服务商与子商户结算、分账到个人零钱等关键流程,并参考支付回写与退款处理的实际实现,减少联调踩坑,缩短接入周期。
1. netCore 接入微信支付 V3:服务商模式才是分账和退款的正确入口
微信支付 V3 的服务商模式,接入时最坑的不是下单接口,而是从第一行代码就选错了体系。普通商户和服务商虽然表面上都是 AppId + 商户号 + APIv3 密钥,但下单、回调、退款、分账的接口路径和请求参数完全是两套,混着用就会不断收到 "sub_mchid 不匹配""请求参数错误"。这套 netCore 源码把两套都拆开了:普通支付、微信 V3 支付、服务商模式支付、支付回写、退款、V3 支付退款、分账给个人、服务商模式分账给子商户,全部落在 SugarHelper、PayCommon、PayService、WechatPay 四个工程里。适合正在把老项目从 V2 往 V3 迁的人,也适合做电商平台需要给个人和子商户做分账结算的从业者,照着改配置就能跑通。
2. V3 与服务商模式的选型:从四个工程看配置落位
接入微信支付 V3 之前,先别急着写代码,把工程结构认清楚。这套源码的四件套分得很清晰:SugarHelper 是对 SQLSugar ORM 的封装,负责订单表、退款表、分账流水表的读写;PayCommon 放支付公共的接口模型、配置实体和加密工具;PayService 是核心业务层,下单、回调、退款、分账都在这一层实现;WechatPay 是 API 入口工程,暴露下单、前端唤起支付、回调接收等接口。这种划分唯一的目的是把微信支付 SDK 的依赖关在 PayCommon 和 PayService 里,Web 层不需要知道证书和密钥长什么样。
2.1 V2 到 V3:签名、证书、密钥三个层面的变化
如果你是从 V2 迁过来的,最容易踩的坑是用 V2 的思维写 V3。V2 用 MD5/HMAC-SHA256 签名,请求里带商户证书;V3 改成用商户私钥做 RSA-SHA256 签名,请求头必须带Authorization: WECHATPAY2-SHA256-RSA2048,同时验签用的是微信平台证书而不是商户证书。这意味着你手里至少要准备四样东西:商户号、AppId、32 位 APIv3 密钥、商户 API 证书私钥。平台证书不是商户证书,它由微信服务器动态签发,拿到后要缓存下来做验签和回调解密。
还有一个经常被忽略的细节:V3 的回调报文里,订单数据是被 AES-256-GCM 加密过的,密钥就是 APIv3 密钥。也就是说 APIv3 密钥不只用于签名,还用于解密回调内容。源码里 PayCommon 的配置实体一般是这样组织的:
public class WechatPayOptions { public string AppId { get; set; } // 服务商模式下为 sp_appid public string MerchantId { get; set; } // 普通模式为商户号,服务商模式为 sp_mchid public string SubMerchantId { get; set; } // 服务商模式下子商户号 public string ApiV3Key { get; set; } // 32 位 APIv3 密钥,用于回调 AES-256-GCM 解密 public string MerchantCertificateSerial { get; set; } public string MerchantPrivateKey { get; set; } // 商户私钥 PEM 内容 public string PlatformCertificate { get; set; } // 微信平台证书 PEM 内容 }这里的ApiV3Key是整个接入中最敏感的参数,泄露了等于回调内容和退款接口都能被伪造。我一般会放到环境变量或配置中心的加密项里,不进代码仓库。MerchantPrivateKey是下载 API 证书时生成的apiclient_key.pem文件内容,必须连BEGIN PRIVATE KEY的 PEM 头一起存,很多签名失败的问题都是因为只粘贴了中间一段导致的。
2.2 普通商户与服务商模式:一张表看清参数差异
很多人分不清普通模式和服务商模式,核心差异在于:普通模式是自己收款自己分账,服务商模式是平台替子商户收款,款项进子商户的账户,再由服务商发起分账。两种模式的接口路径不同,请求体里字段名也不一样。
| 维度 | 普通商户模式 | 服务商模式 |
|---|---|---|
| 下单接口路径 | /v3/pay/transactions/jsapi | /v3/pay/partner/transactions/jsapi |
| 请求中的商户号 | mchid | sp_mchid+sub_mchid |
| 小程序 AppId | appid | sp_appid(子商户的小程序用sub_appid) |
| 用户身份参数 | payer.openid | payer.sub_openid |
| 分账接收方 | 个人 openid(PERSONAL_OPENID) | 子商户(MERCHANT_ID)或个人(PERSONAL_SUB_OPENID) |
| 回调 resource 中带 aid | 无 | 带sub_mchid,需要据此路由到子商户 |
这个表格值得贴在工位上。服务商模式下,用户实际是在子商户的小程序里付钱,但微信支付体系里支付请求由服务商发起,所以 openid 那一栏要传sub_openid。如果传了普通openid,微信会返回PARAM_ERROR或SUBMCH_NOT_EXIST。这类报错在接入初期出现频率非常高,不是你代码写得不对,是字段体系从一开始就用错了。
2.3 HttpClient 与证书加载:回调验签的前提
证书加载和 HttpClient 的配置是 V3 接入的第一道坎。微信平台证书需要定期从微信接口拉取,源码里常见的做法是启动时拉取一次放入内存,之后每小时巡检一次,发现Wechatpay-Serial变了就更新。直接写死平台证书文件的做法在证书到期那天会突然回调验签失败。
services.AddHttpClient("wechat.pay", client => { }) .ConfigurePrimaryHttpMessageHandler(() => { var handler = new HttpClientHandler(); handler.ClientCertificates.Add(LoadMerchantCertificate(merchantPrivateKey)); return handler; }); services.AddSingleton(sp => new PlatformCertificateManager( apiV3Key: options.ApiV3Key, merchantId: options.MerchantId, merchantPrivateKey: options.MerchantPrivateKey, httpClientFactory: sp.GetRequiredService<IHttpClientFactory>()));这里的LoadMerchantCertificate是把apiclient_key.pem转成X509Certificate2。注意 .NET Core 在 Linux 上加载带私钥的 PEM 文件时,不能用 Windows 的老写法直接 new,要用X509Certificate2.CreateFromPemFile(certPath, keyPath),否则会抛CryptographicException。这是 netCore 部署到 Docker 后回调验签的第一步,也是最常见的启动时才知道的坑。
PlatformCertificateManager就是放平台证书的容器,同时暴露一个Verify(signature, message, serialNumber)方法给后续所有回调验签用。平台证书的serialNumber是证书自身的序列号,每次验签要先从微信请求头Wechatpay-Serial里拿序列号,匹配到对应平台证书再验签。这一步不能简化成只验一次,因为微信会不定期自动轮换平台证书。
3. 下单与支付回写:服务商参数表和 AES-256-GCM 解密实战
支付下单看着简单,真正决定成败的是参数透传。服务商模式下的 JSAPI 下单,请求体里至少要出现六组字段:sp_appid、sp_mchid、sub_mchid、description、out_trade_no、payer.sub_openid。缺少任何一个,微信返回的错误信息都是「参数错误」,不会告诉你具体少了哪个,所以最好在代码里写一个参数完整性校验方法,专门在下单前逐字段检查。
3.1 服务商模式 JSAPI 下单:请求体里的关键差异
以下是我在这套源码里常用的服务商模式 JSAPI 下单写法,路径用partner/transactions/jsapi:
public async Task<string> CreateJsapiOrder(OrderInput input) { var request = new CreatePartnerJsapiOrderRequest { SpAppId = options.AppId, // 服务商应用 ID SpMchId = options.MerchantId, // 服务商商户号 SubMchId = input.SubMerchantId, // 子商户号 Description = input.ProductName, OutTradeNo = input.OrderNo, // 商户系统唯一单号 NotifyUrl = "https://api.example.com/wechatpay/notify", Amount = new AmountInfo { Total = input.AmountFen, Currency = "CNY" }, Payer = new PayerInfo { SubOpenId = input.UserOpenId } // 注意是 sub_openid }; var response = await _client.ExecuteAsync(request); if (response.IsSuccessful()) { // 返回 prepay_id,给小程序端调 wx.requestPayment 用 return response.PrepayId; } throw new WechatPayException(response.Error.Code, response.Error.Message); }这里最容易翻车的是Payer.SubOpenId是从子商户的小程序里拿到的 openid。服务商自己的小程序获取到的是user openid,子商户的小程序里才是sub_openid。判断标准很简单:用户在哪个小程序里付钱,就用哪个 openid。下单成功后拿到的prepay_id要组装成小程序端wx.requestPayment需要的 timeStamp、nonceStr、package 和 sign,签名用的是商户私钥,这部分 PayCommon 里一般都会有现成方法。
3.2 支付回写:验签、解密、幂等三步缺一不可
支付回写是支付的最后一公里,也是最不稳定的环节。微信支付的回调通知会重复发送,频率是 15 秒、15 秒、30 秒、3 分钟等递增,最多重试若干次。如果我们的接口在 5 秒内没返回响应或返回非 2xx 状态码,微信就会重试。所以支付回写处理器必须满足两个条件:响应快、幂等。
先看验签和 AES-256-GCM 解密的部分:
public async Task<PayNotifyResult> HandlePaymentNotify(HttpRequest request) { var body = await new StreamReader(request.Body).ReadToEndAsync(); var serial = request.Headers["Wechatpay-Serial"].FirstOrDefault(); var signature = request.Headers["Wechatpay-Signature"].FirstOrDefault(); var timestamp = request.Headers["Wechatpay-Timestamp"].FirstOrDefault(); // 第一步:用平台证书验签,证书按 serial 从缓存中匹配 var cert = _platformCertManager.Get(serial); bool valid = _platformCertManager.Verify(signature, BuildMessage(timestamp, body), cert); if (!valid) { return PayNotifyResult.Fail("签名验证失败"); } // 第二步:解密 resource 里的密文 var resource = ParseResource(body); string decrypted = AesGcmHelper.Decrypt( ciphertext: resource.Ciphertext, nonce: resource.Nonce, associatedData: resource.AssociatedData, key: options.ApiV3Key); // 第三步:反序列化为订单对象,走幂等回写 return await _payService.WriteBackOrder(decrypted); }BuildMessage是基于微信回调验签规则拼串:timestamp + "\n" + nonceStr + "\n" + body + "\n",其中 nonceStr 来自请求头Wechatpay-Nonce。很多第一次接 V3 的人会漏掉结尾的换行符,导致验签永远失败。AES-GCM 解密时,ciphertext在报文里是 Base64 编码,解密前要先Convert.FromBase64String。AesGcmHelper 的 Decrypt 方法底层用的是System.Security.Cryptography.AesGcm,.NET Core 3.1 之后才稳定支持,低于这个版本要引入 BouncyCastle 替代。
public static string Decrypt(string ciphertext, string nonce, string associatedData, string apiV3Key) { var keyBytes = Encoding.UTF8.GetBytes(apiV3Key); var nonceBytes = Encoding.UTF8.GetBytes(nonce); var adBytes = Encoding.UTF8.GetBytes(associatedData); var cipherBytes = Convert.FromBase64String(ciphertext); byte[] plainBytes = new byte[cipherBytes.Length - 16]; byte[] tag = new byte[16]; Array.Copy(cipherBytes, 0, plainBytes, 0, plainBytes.Length); Array.Copy(cipherBytes, plainBytes.Length, tag, 0, 16); using var aes = new AesGcm(keyBytes, 16); aes.Decrypt(nonceBytes, plainBytes, tag, adBytes, plainBytes); return Encoding.UTF8.GetString(plainBytes); }这里的 GCM 认证标签tag是密文追加在末尾的 16 个字节,解密前要手动切出来,这是回调解密最常见的翻车点。解密后的 JSON 里有out_trade_no、transaction_id、trade_state、sub_mchid等字段。服务商模式下,回调 URL 配置在服务商商户号下,一个回调入口收到的是所有子商户的单子,必须用sub_mchid找到对应租户的数据库连接串再写库,否则数据就串了。
3.3 回写逻辑:一次事务解决重复通知
幂等回写的逻辑要放进数据库事务里,用out_trade_no + sub_mchid做唯一约束。下单时订单表先有一行待支付记录,回写时只需要 UPDATE 状态;如果 UPDATE 影响行数为 0,说明订单不存在或者已经被处理过。已经被处理过的时候直接返回成功给微信,告诉它不用再重试了。
public async Task<PayNotifyResult> WriteBackOrder(string decryptedBody) { var data = JsonSerializer.Deserialize<PayCallbackResource>(decryptedBody); if (data.TradeState != "SUCCESS") { return PayNotifyResult.Success(); // 非成功状态不处理,但仍告知微信已收到 } bool updated = await _orderRepo.TryMarkPaid( subMchid: data.SubMchid, outTradeNo: data.OutTradeNo, transactionId: data.TransactionId); if (!updated) { // 可能已回写或订单不存在,直接 ack,避免微信重复打 return PayNotifyResult.Success(); } await _settlementService.Frozen(data.SubMchid, data.OutTradeNo); return PayNotifyResult.Success(); }这里的TryMarkPaid内部用一条UPDATE ... WHERE order_no = @OrderNo AND status = 0的 SQL 保证并发安全。如果更新行数为 0,再查一次订单状态,确认是否已经为已支付状态。这一整套回写逻辑放在 PayService 里,WebApi 层只负责接收并立刻返回,不直接操作数据库,目的是把回调处理和 HTTP 容器解耦,避免异步任务还没跑完,进程就被回收。
4. 退款闭环:V3 支付退款、金额校验与回调幂等的三处关键点
退款是整个支付体系里最容易出金融事故的环节,一旦退了重复金额,线上纠纷很难收场。V3 退款接口本身不复杂,复杂的是金额校验、幂等约束、状态回写三个点。这套源码里的退款功能分成两个入口:普通模式退款和服务商模式退款,服务商模式只是多了sub_mchid参数,核心逻辑几乎相同。
4.1 退款下单:先锁订单再算金额
退款请求的金额单位是分,且有两个金额字段:amount.refund是本次要退的金额,amount.total是原订单支付总金额。微信会拿refund与total做校验,但更可靠的做法是在业务层先查原订单状态再生成退款单。我见过不少项目把total写错,退款单一直处于PROCESSING状态,最后才在商户平台账单里发现金额不对。
public async Task CreateRefund(RefundInput input) { var order = await _orderRepo.GetByOrderNo(input.OrderNo, input.SubMchid); if (order == null || order.Status != OrderStatus.Paid) { throw new BizException("原订单不存在或状态不允许退款"); } int alreadyRefunded = await _refundRepo.SumRefundedFen(input.OrderNo, input.SubMchid); if (input.AmountFen <= 0 || input.AmountFen + alreadyRefunded > order.TotalFen) { throw new BizException("退款金额超过可退余额"); } var refundNo = $"{input.OrderNo}R{DateTime.Now:yyyyMMddHHmmss}"; var request = new CreateRefundDomainRequest { OutTradeNo = order.OrderNo, OutRefundNo = refundNo, SubMchid = input.SubMchid, // 服务商模式必传 NotifyUrl = "https://api.example.com/wechatpay/refund_notify", Amount = new RefundAmountModel { Refund = input.AmountFen, Total = order.TotalFen, Currency = "CNY" } }; await _client.ExecuteAsync(request); await _refundRepo.Insert(new RefundRecord { RefundNo = refundNo, OrderNo = order.OrderNo, SubMchid = input.SubMchid, AmountFen = input.AmountFen, Status = RefundStatus.Processing }); }alreadyRefunded是同一订单累计已退金额,退款入口必须做并发控制,否则两个请求同时进来可能超出可退金额。最稳妥的方式是在数据库里对订单号加行锁,或者用SELECT ... FOR UPDATE,确保同一订单的退款请求串行化。服务商模式下,SubMchid不传或传错,微信会返回REFUND_AMOUNT_ERROR,而且不易排查。
4.2 退款回调:状态机只有三个
退款回调的 event_type 是REFUND.SUCCESS,解密后的报文中refund_status只有三个状态需要处理:SUCCESS、CLOSED、ABNORMAL。SUCCESS 表示退款已经到用户账上,CLOSED 表示退款单关闭,ABNORMAL 表示退款异常需要人工介入。回调处理同样要做幂等,out_refund_no是唯一键,已经落过库的回调直接返回成功。
if (data.RefundStatus == "SUCCESS") { bool updated = await _refundRepo.MarkSuccess( subMchid: data.SubMchid, refundNo: data.OutRefundNo, refundId: data.RefundId); if (updated) { await _orderRepo.MarkRefunded(data.SubMchid, data.OutTradeNo); } }注意这里MarkSuccess和MarkRefunded最好不要做成两个独立事务,中间断电就会出现退款单已成功但订单还是已支付状态的脏数据。常见做法是把两个更新放到同一个 UnitOfWork 里,或者先更新退款单,再通过一条多表关联 SQL 在退款单更新时联动订单状态。
4.3 服务商模式退款通知的落点问题
普通商户的退款回调 URL 直接在商户平台配置,服务商模式的退款回调则在服务商平台配置,但同一个回调地址会收到所有子商户的退款结果。所以退款回调处理和支付回调一样,必须先读sub_mchid,再决定写入哪个租户的数据库。很多服务商在这里翻车,是因为支付回调用的是服务商自己的连接串,结果退款回调也能连上,就忽略了sub_mchid路由,最后子商户的退款账单全记到了服务商头上。
除了sub_mchid,退款回调的success_time字段要原样存下来,这是后续对账的重要时间点。refund_id是微信侧退款单号,要存到退款流水表里,商户平台查单、用户投诉处理时都需要它。源码里这三个字段在 PayService 的退款回调处理中是一条 INSERT 语句同时写入的,建议照搬。
5. 常见问题与排查:证书过期、解密失败、sub_mchid 不匹配四大现场
微信支付 V3 的报错信息设计得不算友好,很多错误码只在微信支付文档里出现一次,出了问题大多数时候要靠日志和请求头排查。以下四个现场是按出现频率排序的,每一条我都踩过,也都定位到了根因。
5.1 平台证书过期导致验签失败
现象:支付回调偶尔正常偶尔失败,失败时日志输出「验签失败」或VerifySignatureError。重新部署一次又好了,但过几天又复发。
原因:微信平台证书会周期性轮换,请求头Wechatpay-Serial携带的是当前调度的最新平台证书序列号。如果内存里缓存的平台证书不是这个序列号对应那本,验签必然失败。间歇性成功是因为微信侧可能同时保留新旧两本证书短暂过渡。
解决:核对请求头里的Wechatpay-Serial与本地平台证书的SerialNumber是否一致。我一般在PlatformCertificateManager里加定时器,每 12 小时拉一次/v3/certificates接口主动刷新证书,同时把serial作为字典键,新旧证书并存。从那以后我再也没有在线上被平台证书坑过。
5.2 回调解密乱码或抛 AuthenticationTagMismatchException
现象:验签通过,但解密时报AuthenticationTagMismatchException,或解出来是乱码字符串。
原因:AES-256-GCM 解密时把ciphertext和tag的顺序搞反了。微信返回的ciphertext是「密文+认证标签」拼接后整体做 Base64,前人代码里如果先Convert.FromBase64String后直接整体喂给AesGcm.Decrypt,就会因为多出 16 字节 tag 而失败。另一个原因是 APIv3 密钥不是 32 字节,或读取时带了换行符。
解决:先 Base64 解码,再按长度切分:总长度减 16 为密文,最后 16 字节为 tag。同时校验Encoding.UTF8.GetBytes(apiV3Key).Length == 32。把这个校验写进配置加载类里,启动时直接抛异常,省得线上跑到半夜才暴露。
5.3 服务商模式退款报 sub_mchid 不匹配
现象:退款接口返回SUBMCH_NOT_EXIST或SUBMCH_MCHID_NOT_MATCH,但商户号、子商户号明明是从配置中心读出来的。
原因:服务商模式退款请求体里的sub_mchid必须是子商户号,不是服务商商户号。还有一种可能是配置里把sub_mchid写成了服务商自己的商户号,微信自然找不到这个子商户身份。
解决:在 PayCommon 配置校验器里加一个断言,服务商模式下SubMerchantId不能等于MerchantId,并且在退款请求发出前打印脱敏后的sub_mchid日志。我通常会在开发环境把SubMerchantId写成test_sub_mchid,保证任何人看到日志第一眼就能发现参数没生效。
5.4 分账给个人一直处于 PROCESSING 或报接收方不存在
现象:普通商户模式分账,接收方类型填PERSONAL_OPENID,请求成功但状态长期停留在PROCESSING;或直接报RECEIVER_NOT_EXIST。
原因:第一,用户需要在「微信支付商户平台-产品中心-分账」里开通分账功能,个人接收方需要在其微信支付账号中完成实名。第二,account字段传成了用户在小程序中的 openid,但分账接收方必须是在分账功能页里添加过的 openid,或者该用户已经授权成为分账接收方。两者对不上就会在分账阶段卡住。
解决:进商户平台的分账接收方管理页,先添加接收方类型为「个人 openid」,再把用户 openid 填进去。如果接收方是小程序用户,要确认和 AppId 对应的是不是同一个开放平台账号下的应用。添加成功后,分账请求会从PROCESSING走到FINISHED,通常几分钟内结算完成。
6. 分账给个人与服务商分账:一单分账从发起到验证完毕
分账之所以是支付对接里最有门槛的一环,是因为它不只牵涉接口参数,还牵涉接收方管理、分账比例限制和状态流转。这套源码在 PayService 里对分账做了统一封装,普通商户分账给个人走PERSONAL_OPENID,服务商模式分账给子商户走MERCHANT_ID,两者都建议独立建表记录。
6.1 分账请求的接收方类型与参数
var request = new CreateProfitSharingOrderRequest { SubMchid = input.SubMerchantId, // 服务商模式分账时需要 AppId = options.AppId, TransactionId = input.TransactionId, OutOrderNo = $"{input.OrderNo}S{DateTime.Now:HHmmss}", Receivers = new[] { new ProfitSharingReceiver { Type = input.ReceiverType, // PERSONAL_OPENID / MERCHANT_ID Account = input.Account, // 个人 openid / 子商户号 Amount = input.AmountFen, Description = input.SplitDescription } } };普通商户分账给个人时,Type = PERSONAL_OPENID,Account是用户在分账发起方 AppId 下的 openid。服务商模式分账给子商户时,Type = MERCHANT_ID,Account是子商户号。两者混用最常见,下单时用了子商户号,分账时却把服务商商户号填进去,结果一直报接收方类型与账号不匹配。分账比例默认不能超过 30%,进件超级商户或申请放开后可以提高,但需要在商户平台单独申请。
6.2 分账验证链路与我的收尾习惯
分账发起后,不要只看接口返回成功就认为万事大吉。我一般会走完验证链路:先在商户平台「分账-分账订单」里看到这笔订单状态为FINISHED,再在「分账-接收方台账」里确认对方到账金额和时间。服务商模式还要额外确认资金来源子商户号是否正确。整个链路里唯一需要盯住的指标就是分账台账里有没有出现ABNORMAL态,出现就立刻查原订单transaction_id对应的支付金额。
如果你的部署环境要求国密 SM2 证书,那支付回调和退款回调解密会走 SM2 算法而不是 AES-256-GCM,平台证书也换成国密版本。判断依据很简单:下载下来的证书文件如果是.cer且配置里出现sm2关键字,就要把 PayCommon 里的解密实现替换掉,AES-256-GCM 的参数nonce、associated_data在国密体系里会被替换为 SM4 的参数。这套源码的 PayCommon 里把加解密都收敛在了一个接口类后面,替换实现不需要动业务层。
从那以后,我每次接入微信支付都会强制走一遍自检清单:配置四项是否齐全、平台证书能否自动刷新、回调处理器是否幂等、分账接收方是否已在平台添加。这套流程帮我挡掉了至少 90% 的线上支付事故,希望也能帮到你。
本文还有配套的精品资源,点击获取