ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

.NET Core接入微信支付V3服务商模式:支付、分账、退款与回调验签实战

.NET Core接入微信支付V3服务商模式:支付、分账、退款与回调验签实战 简介面向.NET Core开发者的微信支付集成源码包专注V3版普通支付、服务商模式、分账至个人与子商户、退款及支付回写等高频业务场景适合需要快速接入或改造现有支付模块的中高级后端开发参考。压缩包共696个文件含383个dll依赖库、70个cs核心业务源码、61个pdb调试符号、45个json配置及多个config/项目工程文件整体34.16MB目录按PayCommon、PayService、WechatPay等模块划分便于按需引用。资源已获1369人学习下载说明该类服务商分账需求具有较高参考价值。源码内提供普通支付与V3服务商支付的双链路示例并对支付回写、退款、分账给个人等关键环节作了实现拆分可直接对照实践或抽取复用。1. netCore 接入微信支付 V3 服务商模式先看清这盘棋再动手在 .NET Core 里接入微信支付 V3 服务商模式表面上是发起支付、等回调、退款、分账四条链路实际上真正决定上线成败的是证书签名、回调验签、幂等和账单核对这四件小事。很多团队把统一下单调通就以为完事了结果分账时被关系绑定卡住退款时被金额单位坑到回调里 VS 调试一切正常、一上生产就验签失败。这篇文章适合正在做平台型系统、聚合支付或多商户 SaaS 的 .NET 开发者目标是把 V3 服务商模式下的支付、分账、退款、支付回写整条链路拆开给出可直接抄的代码和参数边界顺带把那些不跑一次根本发现不了的坑提前摆出来。2. 微信支付 V3 服务商模式与直连模式的差异为什么服务商模式值得选2.1 服务商模式与普通商户的边界普通商户模式直连模式里一个商户号对应一个独立经营主体收款直接进自己的商户号。服务商模式则多了一层“平台方”角色服务商申请一个服务商商户号再通过进件为下游商户创建特约商户号子商户。用户付款时钱进了子商户号服务商作为技术服务方可以在交易完成后通过分账把手续费、佣金、平台抽成重新分配。这个结构对平台类系统至关重要——你不需要给每个商户分发证书子商户只需要提供进件资料支付能力统一挂在服务商下面回调也统一打到服务商的地址。netCore 接入时要特别留意一个区别服务商模式的下单接口路径是/v3/pay/partner/transactions/jsapi直连模式则是/v3/pay/transactions/jsapi。虽然两者返回的prepay_id结构和二次签名逻辑几乎一样但请求体里服务商模式必须携带sp_appid、sp_mchid、sub_mchid三元组而直连模式只需要自己的appid和mchid。很多从直连迁移到服务商的代码往往就是漏了这个三元组导致报错“APPID 与商户号不匹配”。2.2 证书、密钥与签名V3 的 APIv3 密钥体系V3 和 V2 最大的变化是证书体系彻底重构。V2 用 API 密钥做 MD5/HMAC-SHA256 签名V3 改用 RSA-SHA256 的商户私钥签名同时引入 APIv3 密钥32 字节专门用于回调报文解密和平台证书解密。如果之前只做过 V2第一次接触 V3 很容易把概念混在一起实际只需要关注四样东西商户 API 证书包含商户证书序列号serial_no和商户私钥apiclient_key.pem下单、退款、分账请求都用它对报文做签名。平台证书微信支付平台的公钥证书回调验签时用它的公钥验证微信的签名序列号在回调请求头的Wechatpay-Serial里。APIv3 密钥32 字节的对称密钥回调resource里的 AES-256-GCM 密文靠它解密。商户号mchid请求签名里必须带的身份标识。在 .NET Core 里加载商户私钥最简单的方式是直接用RSA.ImportFromPem这个方法从 .NET 5 开始内置。如果项目还在 .NET Core 3.1需要自己写 PEM 解析或者引用第三方库。平台证书不是一次申请终身不变微信定期轮换所以平台证书不能本地写死要按serial_no做缓存验签时发现序列号没命中就要调用平台证书下载接口拉取。2.3 从零申请服务商商户号的物料清单接入前先按这个表格清点物料缺一样后面都要回头补物料获取位置用途备注服务商商户号微信支付商户平台请求头和请求体里的sp_mchid进件后才能看到子商户号APIv3 密钥商户平台「API安全」自助生成回调解密、平台证书解密32 字节不要放配置文件明文商户 API 私钥申请 API 证书时生成请求签名、二次签名apiclient_key.pem服务器妥善保管商户证书序列号证书申请成功页Authorization 头的serial_no和私钥成对AppID公众号/小程序/开放平台下单请求的sp_appid/sub_appid需与商户号完成关联绑定回调地址商户平台「产品中心」配置支付、退款、分账结果通知必须是公网 HTTPS且域名备案申请周期上服务商进件一个子商户一般是 1 到 3 个工作日而服务商商户号本身要线下签约。建议在等进件审批时先把这篇的代码骨架跑通用服务商自己的 AppID 和一个小额测试子商户走全流程不然等资质下来再排期联调会拖很久。3. netCore 接入微信支付 V3 的最小可运行骨架HttpClient、证书与签名3.1 用 HttpClient 封装 V3 请求的统一签名层所有 V3 接口的签名规则是一致的构造一个签名串HTTP方法\n请求路径\n时间戳\n随机串\n请求体\n用商户私钥做 SHA256-RSA 签名然后把商户号、随机串、时间戳、证书序列号、签名拼成Authorization头。GET 请求的请求体为空字符串注意路径不含查询参数。下面是一个最小可用的WechatPayV3Clientpublic class WechatPayV3Client { private readonly string _mchId; private readonly string _serialNo; private readonly string _apiV3Key; private readonly RSA _merchantKey; private readonly HttpClient _http; public WechatPayV3Client(string mchId, string serialNo, string privateKeyPemPath, string apiV3Key) { _mchId mchId; _serialNo serialNo; _apiV3Key apiV3Key; _merchantKey RSA.Create(); _merchantKey.ImportFromPem(File.ReadAllText(privateKeyPemPath)); _http new HttpClient { BaseAddress new Uri(https://api.mch.weixin.qq.com) }; } public async Taskstring SendAsync(HttpMethod method, string path, string body ) { var timestamp DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var nonce Guid.NewGuid().ToString(N); var message ${method}\n{path}\n{timestamp}\n{nonce}\n{body}\n; var signature Convert.ToBase64String(_merchantKey.SignData( Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); var auth $WECHATPAY2-SHA256-RSA2048 mchid\{_mchId}\, $nonce_str\{nonce}\,timestamp\{timestamp}\, $serial_no\{_serialNo}\,signature\{signature}\; using var request new HttpRequestMessage(method, path) { Content new StringContent(body, Encoding.UTF8, application/json) }; request.Headers.TryAddWithoutValidation(Authorization, auth); request.Headers.TryAddWithoutValidation(Accept, application/json); var response await _http.SendAsync(request); return await response.Content.ReadAsStringAsync(); } }这段代码是整套支付模块的地基后面所有接口都复用SendAsync。ImportFromPem会直接读取 PEM 格式的私钥换证书时只需要替换文件路径不需要改业务代码。签名里最容易被忽略的是最后的\n少一个换行微信就报验签失败。另外每个请求的随机串和时间戳必须是新鲜的不能复用否则微信会拒绝请求。_http建议在IHttpClientFactory里注册为 Singleton避免频繁建连。3.2 服务商模式统一下单JSAPI/Native的请求体拼装JSAPI 下单用于公众号或小程序内打开微信支付Native 用于 PC 扫码。两者的请求路径和参数基本一致只有payer字段不同。服务商模式下JSAPI 需要传子商户 appid 下的用户sub_openidNative 则不需要。var order new { sp_appid wx1234567890, sp_mchid 1699999999, sub_appid wx0987654321, sub_mchid 1688888888, description XX平台商品订单, out_trade_no 20250101001, notify_url https://api.yoursite.com/pay/notify, amount new { total 1, currency CNY }, payer new { sub_openid oXXXX-xxxx } }; var body JsonSerializer.Serialize(order); var resp await client.SendAsync(HttpMethod.Post, /v3/pay/partner/transactions/jsapi, body);下单返回的正文里有prepay_id这是后续二次签名调起支付的必要参数。total单位是分1就是 0.01 元做测试单特别方便。sub_appid有几种情况如果子商户自己绑定了公众号或小程序就传子商户的如果服务商的 AppID 和子商户号建立了绑定关系sub_appid可以省略但payer里的sub_openid必须与最终使用的 AppID 对应。这个对应关系一旦错前端拉起支付时会提示“支付验证签名失败”。3.3 二次签名调起支付与前端联动拿到prepay_id后服务端还要生成 JSAPI 调起支付所需的参数timeStamp、nonceStr、package、signType、paySign。这里的签名和接口请求签名不同用的是另一套拼接规则按appId\n时间戳\n随机串\npackage\nsignType拼成签名串再同样用商户私钥做 SHA256-RSA 签名。public Dictionarystring, string BuildJsapiPayParams(string appId, string prepayId) { var timeStamp DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var nonceStr Guid.NewGuid().ToString(N); var package prepay_id prepayId; var signMessage ${appId}\n{timeStamp}\n{nonceStr}\n{package}\nRSA\n; var paySign Convert.ToBase64String(_merchantKey.SignData( Encoding.UTF8.GetBytes(signMessage), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); return new Dictionarystring, string { [timeStamp] timeStamp, [nonceStr] nonceStr, [package] package, [signType] RSA, [paySign] paySign }; }这里有个高频误用很多人直接把prepay_id字符串作为package拼进去正确格式是prepay_id prepayId。appId一定是用户实际拉起支付的公众号或小程序的 AppID和服务商 AppID 可能不是一个。前端拿到这五个参数后调用wx.requestPayment失败时可以对比返回的errMsg如果是invalid sign优先检查appId与package是否匹配如果是chooseWXPay:fail大概率是 openid 和 AppID 不匹配。为了安全二次签名参数建议只在后端生成一次并设置短有效期不要缓存复用。4. 分账与退款服务商模式里最容易踩坑的两块硬骨头4.1 服务商分账请求体、比例限制与结果同步分账是服务商模式存在的主要原因也是坑最多的地方。V3 服务商分账的第一步不是直接请求分账而是要先添加分账接收方并建立绑定关系否则接口会报RECEIVER_NOT_MATCH。添加接收方的接口是POST /v3/partner/profitsharing/receivers/addvar addReceiver new { appid wx1234567890, type MERCHANT_ID, account 1688888888, relation_type SERVICE_PROVIDER, name 可选 }; var body JsonSerializer.Serialize(addReceiver); var resp await client.SendAsync(HttpMethod.Post, /v3/partner/profitsharing/receivers/add, body);type有两种常用值MERCHANT_ID表示接收方是商户号可以是子商户也可以是别的服务商PERSONAL_OPENID表示接收方是个人微信用户。relation_type描述两者关系常见值有SERVICE_PROVIDER服务商、DISTRIBUTOR分销商等。服务商给子商户分账接收方填子商户号给个人用户分佣金接收方填用户在子商户 AppID 下的 openid而且个人接收方部分场景需要传name做实名校验不传会报NAME_MISMATCH。分账请求本身用POST /v3/partner/profitsharing/ordersvar profit new { appid wx1234567890, sub_mchid 1688888888, transaction_id 420000123420250101, out_order_no PS20250101001, receivers new[] { new { type MERCHANT_ID, account 1688888888, amount 1, description 平台分成 }, new { type PERSONAL_OPENID, account oXXXX-xxxx, amount 1, description 推广佣金 } }, unfreeze_unsplit true }; var body JsonSerializer.Serialize(profit); var resp await client.SendAsync(HttpMethod.Post, /v3/partner/profitsharing/orders, body);这里有个容易忽略的业务规则分账请求发起前这笔订单必须已经在下单时携带了分账标志或者该商户开启了「分账回退」「自动分账」配置。unfreeze_unsplit决定分账完成后是否解冻剩余资金。默认情况下标记了分账的订单资金是冻结的分账后剩余部分如果设为false会继续冻结直到手动解冻设为true则自动解冻。比例限制通常是单笔订单分账给同一接收方不超过 30%可申请调高。分账结果不是即时生效要以分账回调或查询接口返回的状态为准所以接分账必须同时接分账回调POST /v3/partner/profitsharing/notify不能只发请求不管结果。4.2 退款原路退回的入参与幂等控制退款接口直连和服务商共用同一个地址POST /v3/refund/domestic/refunds区别在于服务商模式的请求体里多了sub_mchid。退款金额单位同样是分但这里的amount有两个字段refund是本次退多少total是这笔订单原本支付的总金额系统会校验refund不能超过total的剩余可退金额。var refund new { sub_mchid 1688888888, out_trade_no 20250101001, out_refund_no RF20250101001, amount new { refund 1, total 1, currency CNY } }; var body JsonSerializer.Serialize(refund); var resp await client.SendAsync(HttpMethod.Post, /v3/refund/domestic/refunds, body);out_refund_no是退款单号它的作用不只是标识更是幂等键。微信支付接口在超时或网络抖动时会出现“请求已处理但响应丢失”的情况这时候重试必须用同一个out_refund_no否则会生成两笔退款。退款成功后资金原路退回但实际到账时间因通道而异不能凭退款接口的同步响应判断“已退到用户钱包”要以退款回调或查单结果为准。退款回调的地址和支付回调可以共用也可以分开但都要支持验签和解密。4.3 分账、退款与账单对账的配合资金链路一旦涉及分账对账就不能只看支付账单还要看分账账单。每个交易日结束后从微信下载交易账单和分账账单和本地订单表、分账流水表做三方比对数据源用途关键字段本地订单表业务实际发生的订单状态out_trade_no、支付状态、退款状态微信交易账单/v3/bill/tradebill核对收款金额与手续费交易类型、订单金额、商户单号分账账单/v3/bill/profitsharingbill核对每笔分账明细分账单号、接收方、分账金额对账脚本 I 一般每天凌晨跑一次先按out_trade_no对齐支付单再按out_order_no对齐分账单。只核对总额一定会在月底翻车比如退款单在账单里显示“原路退回”金额是负数但本地退款表如果没记录退款的渠道手续费差额就永远解释不清。建议在本地把退款和分账各自单独建表字段里带上微信账单里的trade_no这样核销时有据可查。5. 支付回调验签与幂等netCore 接入 V3 的 5 个避坑现场5.1 回调验签与 AES-256-GCM 解密支付回调支付回写是整个链路里真正决定资金可靠性的环节netCore 里最常见的做法是用一个 ActionFilter 统一处理验签和解密业务 Controller 只关心业务逻辑。微信回调请求头里带Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial四个字段验签时用Wechatpay-Serial找到对应平台证书对时间戳\n随机串\n请求体\n做验签。public class WechatPayNotifyFilter : IAsyncActionFilter { public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var request context.HttpContext.Request; var body await new StreamReader(request.Body).ReadToEndAsync(); var headers request.Headers; var timestamp headers[Wechatpay-Timestamp].FirstOrDefault(); var nonce headers[Wechatpay-Nonce].FirstOrDefault(); var signature headers[Wechatpay-Signature].FirstOrDefault(); var serial headers[Wechatpay-Serial].FirstOrDefault(); var message ${timestamp}\n{nonce}\n{body}\n; var valid PlatformCertCache.Verify(serial, message, signature); if (!valid) { context.Result new JsonResult(new { code FAIL, message sign error }); return; } var envelope JsonSerializer.DeserializeNotifyEnvelope(body); var plaintext AesGcmDecrypt(envelope.Resource, _apiV3Key); context.HttpContext.Items[NotifyPlaintext] plaintext; await next(); } }AesGcmDecrypt的实现要注意 .NET 的 AesGcm 类约束public static string AesGcmDecrypt(Resource resource, string apiV3Key) { var key Encoding.UTF8.GetBytes(apiV3Key); var nonce Encoding.UTF8.GetBytes(resource.Nonce); var cipher Convert.FromBase64String(resource.Ciphertext); var tag cipher[^16..]; var cipherBody cipher[..^16]; var plain new byte[cipherBody.Length]; using var aes new AesGcm(key, 16); aes.Decrypt(nonce, cipherBody, tag, plain, Encoding.UTF8.GetBytes(resource.AssociatedData ?? )); return Encoding.UTF8.GetString(plain); }微信 V3 回调的密文格式是 AES-256-GCMtag固定 16 字节并拼在密文末尾AssociatedData取自回调报文里的resource.associated_data可能为空字符串但不能不传。解密后的plaintext就是包含out_trade_no、transaction_id、trade_state等字段的 JSON。注意解密失败时不要直接返回500微信会反复重试通知返回{code:FAIL,message:...}表示告诉微信“这次没处理好继续重试”。5.2 回调处理的幂等与重复通知微信支付的回调不保证只通知一次网络抖动、我们处理超时都会触发重发因此回调处理必须幂等。幂等不能靠内存判断分布式环境下尤其要依赖数据库。我常用的做法是在订单表上建唯一索引回调处理时先按out_trade_no查询发现订单已经是“已支付”状态就直接返回成功不再重复写流水。[HttpPost(notify)] public async TaskIActionResult Notify() { var plaintext HttpContext.Items[NotifyPlaintext] as string; var notify JsonSerializer.DeserializePayNotify(plaintext); var order await _orderRepo.GetByOutTradeNo(notify.OutTradeNo); if (order null) { return Json(new { code FAIL, message 订单不存在 }); } if (order.Status OrderStatus.Paid) { return Json(new { code SUCCESS, message 成功 }); } using var tx _db.Database.BeginTransaction(); order.Status OrderStatus.Paid; order.TransactionId notify.TransactionId; order.PaidAt DateTime.Now; await _db.SaveChangesAsync(); await _payFlowRepo.Insert(new PayFlow { OutTradeNo notify.OutTradeNo, TransactionId notify.TransactionId, Event PAY_SUCCESS, RawData plaintext }); await tx.CommitAsync(); return Json(new { code SUCCESS, message 成功 }); }这里事务里同时更新订单和写入流水避免订单状态变了但流水没记上。如果处理过程中抛异常事务回滚微信下次重试时订单还是待支付不会出现“本地显示未支付但钱已扣”的账务事故。特别注意回调接口不要做耗时的外部调用比如发短信、调用推荐系统这些都放进消息队列异步处理否则响应慢会导致微信频繁重试。5.3 避坑5 个真实翻车现场现象到原因到解决现象 1回调验签永远失败换了证书也没用。原因大多是拿商户证书在验微信签名或者本地平台证书缓存没有按Wechatpay-Serial更新微信换平台证书后旧证书失效。解决写一个平台证书缓存服务定期调用GET /v3/certificates拉取平台证书用 APIv3 密钥解密证书内容后按证书序列号存入内存验签时先查缓存、未命中就重新拉取。现象 2AES-256-GCM 解密抛异常或得到乱码。原因是把整个ciphertext直接丢给AesGcm.Decrypt没有拆分末尾的 16 字节 tag。解决先Convert.FromBase64String取cipher[^16..]作为 tag前面的部分作为密文主体nonce 用回调报文里的resource.nonce关联数据用resource.associated_data三者缺一不可。现象 3分账请求报RECEIVER_NOT_MATCH或NOT_MUST_BE_BOUND。原因是接收方还没添加绑定关系就发起了分账或者接收方类型与账号不匹配例如把个人 openid 填到MERCHANT_ID里。解决先调添加接收方接口确认返回成功后再分账个人接收方必须使用子商户 AppID 下的 openid服务商 AppID 下的 openid 会导致归属错误。现象 4退款报了AMOUNT_INVALID或REFUND_AMOUNT_EXCEEDED。原因是amount.total填成了本次退款金额而不是订单原金额。解决total永远填原订单实付金额refund填本次退款金额且total要包含用户实际支付的分不含优惠券抵扣部分。现象 5支付回调收不到但微信后台显示“通知已发”。原因多半是回调地址没有配置成公网 HTTPS或者服务端防火墙拦截了 POST 请求还有可能是 nginx 把请求体大小限制得太小。解决先用微信支付自带的回调测试工具或查单接口确认微信侧确实发出过通知再看服务端访问日志有没有POST /pay/notify如果 nginx 配置了client_max_body_size回调报文通常几千字节但保险起见调到 1MB。6. 把支付模块做稳验证方法、日志打点与上线前检查6.1 本地联调用主动查询订单代替被动回调回调通知只能发给公网 HTTPS 地址本地开发环境收不到微信回调是最常见的卡点。与其折腾各种代理工具不如在本地联调阶段直接用主动查询订单接口模拟回写。服务商模式查询订单的接口是GET /v3/pay/partner/transactions/out-trade-no/{out_trade_no}?sub_mchidxxxx注意签名时路径只包括/v3/pay/partner/transactions/out-trade-no/{out_trade_no}查询参数不参与签名。var path $/v3/pay/partner/transactions/out-trade-no/20250101001?sub_mchid1688888888; var resp await client.SendAsync(HttpMethod.Get, path); var orderInfo JsonSerializer.DeserializePartnerOrderQueryResult(resp);本地测完下单后直接用这个查询接口轮询订单状态代替“等微信回调”来完成状态机的自测。生产环境的回调处理逻辑可以留一个调试入口在测试商户号上把回调地址临时指到测试服务器用真实回调报文驱动同一套处理代码这样能验到验签、解密、幂等这几层比 Mock 数据可靠得多。6.2 日志打点与账务核对的三张表支付模块的日志不能只靠ILogger随手打必须围绕“这笔单现在到哪一步了”可追溯。我习惯在核心节点打三张表下单流水表、回调流水表、主动查单流水表。每张表至少包含out_trade_no、transaction_id、请求报文摘要、响应报文摘要、状态、触发时间。这样线上出问题时能按订单号把所有环节的原始报文选出来判断是微信没发通知、发了通知我们没收到、还是收到但解密失败。账单核对要用上一个章节提到的交易账单和分账账单写一个定时任务每天拉取按订单号对齐本地数据。第一次跑对账一定会发现差异常见的是微信账单里的订单金额是“分”但本地数据库有的表存了“元”对齐时全部转成分再比较顺手写一条测试用例把这个单位问题固化下来免得后面新同学改坏。6.3 上线前把这三个参数检查好上线前最后检查一遍这六个配置项商户号、AppID、证书序列号、APIv3 密钥、回调地址、平台证书缓存。最容易疏忽的是回调地址在商户平台和代码里各配了一遍两边不一致会出现在测试环境能通、生产环境静默失败的情况。另外.NET的ImportFromPem对 PEM 格式严格换行符如果是 CRLF 也可能解析失败建议读文件后用Replace(\r\n, \n)预处理。我第一次接服务商分账时就是在正式环境漏配了sub_appid用户支付时总是弹“支付验证签名失败”排查了一下午才发现是前端调起支付时使用的 AppID 和下单时Payer里的sub_openid不是同一个开放平台下的。那次之后我给自己定了个规矩支付相关配置全部进配置中心按环境隔离启动时做一次自检把商户号、证书、回调地址打印到日志上线前人工核对一遍。这套习惯帮我后来避免了不少低级事故希望帮到你。本文还有配套的精品资源点击获取
返回列表