ARTICLE DETAIL

资讯详情

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

netCore接入微信支付V3服务商模式:下单、分账与退款全流程解析

netCore接入微信支付V3服务商模式:下单、分账与退款全流程解析 简介这是一套基于 .NET Core 开发的微信支付服务端源码适合需要对接微信支付 V3、服务商模式、分账及退款等场景的 .NET 开发者。资源覆盖普通支付、微信V3支付、服务商模式支付与回写、分账给个人、分账给子商户、V3退款等关键环节并且保留了 sln、csproj 工程文件可直接在 Visual Studio 中打开编译。整个压缩包共696个文件大小34.16MB其中70个cs文件为业务源码383个dll文件为依赖库或编译输出其余json、xml、config、txt等文件承担配置参数、接口说明与运行日志等角色。从内容预览看解决方案按 WechatPay、PayCommon、PayService、SugarHelper 等模块划分支付服务、公共逻辑和数据访问层次清晰便于二次开发和定位问题。目前已有1369人学习下载是一份能帮助开发者理清微信支付V3接入与分账回写流程的参考实现。1. 这是哪条路netCore 项目接 V3 服务商模式真正要打通的不只是下单很多 netCore 项目第一次接微信支付的 V3 服务商模式卡住的往往不是“下单成功”而是支付成功之后那一堆事钱先进了服务商账户怎么分给特约商户和推广员用户要退款钱怎么原路退回去每一笔支付、分账、退款的结果又怎么通过回写可靠地落进自己的订单库。服务商模式和直连模式最大的区别就在这里——直连模式下商户收了钱就是自己的服务商模式下你是平台得替入驻的商户做清结算。这篇文章面向的是在做入驻式电商、家政、聚合支付这类业务的开发者我按自己实际接线的顺序把证书准备、JSAPI 下单、支付回写、服务商分账、退款到踩坑排查整条链路写清楚照着做能少走几天弯路。2. 接入前置证书、密钥与基础请求封装2.1 先凑齐这几样商户号、AppID、证书序列号与平台证书接 V3 服务商模式之前手头至少要有下面这六样东西缺一样后面都会卡住配置项来源用途服务商商户号 sp_mchid服务商入驻申请时分配请求体里的 sp_mchid签名用的 mchid特约商户号 sub_mchid特约商户签约进件后分配每一笔订单归属哪个商户服务商 AppID服务商的开放平台/公众平台应用sp_appid拉起支付时用商户 API 证书序列号商户平台“账户中心 - API 安全”签名头里的 serial_no商户 API 私钥 apiclient_key.pem申请 API 证书时下载生成 Authorization 签名APIv3 密钥在 API 安全里手动设置回调报文解密、分账接收方加密有一个最常见的误解是把 apiclient_cert.pem商户证书当成平台证书来用。商户证书是你的身份凭证平台证书是微信支付服务器的公钥证书用来验签和加密敏感字段。平台证书可以通过调用/v3/certificates下载也可以直接在商户平台下载。下载下来是 PEM 字符串后面所有“平台证书公钥”的地方用的都是它。另外注意服务商模式和直连模式是两套商户号体系。如果你只是拿一个商户号去调服务商接口微信会直接报“商户号与接口权限不匹配”。我一般建议在项目配置里把 sp_mchid 和 sub_mchid 分开存放别图省事复用同一个字段后面做分账和退款时很多 bug 都是因为这两个 ID 混用。2.2 用 HttpClient 做带 Authorization 头的基础请求微信支付 V3 的每一个业务接口都要求自定义签名头Authorization这个头的格式是固定的WECHATPAY2-SHA256-RSA2048 mchid1900000001,nonce_str随机串,timestamp时间戳,serial_no证书序列号,signature签名值签名串的构造顺序是HTTP方法\n URL路径[带查询参数]\n 时间戳\n 随机串\n 请求体\n这里的 URL 是实际请求的路径和查询参数比如https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi如果带了查询参数得用 RFC3986 编码后的完整路径。请求体就是原始字符串POST 有 body 就放 JSON 字符串GET 没有 body 就放空字符串。netCore 里我用 RSA 导入 PEM 私钥来签名public static string BuildAuthorization(string method, string url, string body, string mchid, string serialNo, string privateKeyPem) { var timestamp DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var nonce Guid.NewGuid().ToString(N); var message ${method}\n{url}\n{timestamp}\n{nonce}\n{body}\n; using var rsa RSA.Create(); rsa.ImportFromPem(privateKeyPem); var data Encoding.UTF8.GetBytes(message); var signedData rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var signature Convert.ToBase64String(signedData); return $WECHATPAY2-SHA256-RSA2048 mchid\{mchid}\,nonce_str\{nonce}\,timestamp\{timestamp}\,serial_no\{serialNo}\,signature\{signature}\; }逻辑说明ImportFromPem是 .NET 5 自带的方法直接接收apiclient_key.pem的完整字符串即可如果项目在 .NET Core 3.1 上需要手动把 PEM 的BEGIN/END头去掉再ImportRSAPrivateKey。签名用的是商户私钥不是平台证书私钥这个区分很重要搞反了验签时微信端会报“签名错误”。调用业务接口时我在HttpClient之上包了一层只维护一个方法传入 method、url、body 和商户配置返回HttpResponseMessagepublic async Taskstring RequestAsync(HttpMethod method, string url, string body) { var auth BuildAuthorization(method.Method, url, body, _options.SpMchId, _options.SerialNo, _options.PrivateKey); using var request new HttpRequestMessage(method, url); request.Headers.Add(Authorization, auth); request.Headers.Add(Accept, application/json); request.Headers.Add(User-Agent, netcore-wechatpay-v3/1.0); if (body ! null) { request.Content new StringContent(body, Encoding.UTF8, application/json); } var response await _httpClient.SendAsync(request); var result await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { // 记录 requestId、错误码与错误信息方便排查 throw new WechatPayException(response.StatusCode, result); } return result; }参数说明header 里的Accept必须带application/jsonUser-Agent官方要求带项目标识不是可选项。WechatPayException是我自定义的异常类型把微信返回的code、message、detail都丢进去方便在调用层直接看失败原因。很多人在这一步误以为请求失败是 JSON 序列化问题其实多半是签名串里的 URL 写成https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi?带了个空问号或者查询参数没有编码这属于典型踩坑。2.3 敏感信息加解密工具类AES-256-GCM 解密与 RSA 公钥加密V3 的回调报文里核心数据放在resource字段内用 APIv3 密钥做 AES-256-GCM 加密分账时接收方信息里的 account 字段如果是 openid也要求用平台证书公钥做 RSA 加密后再传输。这两个加解密是接完下单之后迟早要碰到的建议在项目里直接写成静态工具类public static class WechatCrypto { public static string AesGcmDecrypt(string apiV3Key, string nonce, string ciphertext, string associatedData) { var keyBytes Encoding.UTF8.GetBytes(apiV3Key); var nonceBytes Encoding.UTF8.GetBytes(nonce); var cipherBytes Convert.FromBase64String(ciphertext); var associatedBytes string.IsNullOrEmpty(associatedData) ? Array.Emptybyte() : Encoding.UTF8.GetBytes(associatedData); // 密文最后 16 字节是 GCM 认证标签 var tagBytes cipherBytes[^16..]; var dataBytes cipherBytes[..^16]; using var aes new AesGcm(keyBytes, 16); var plainBytes new byte[dataBytes.Length]; aes.Decrypt(nonceBytes, dataBytes, tagBytes, plainBytes, associatedBytes); return Encoding.UTF8.GetString(plainBytes); } public static string RsaEncryptWithPlatformCert(string publicKeyPem, string plainText) { using var rsa RSA.Create(); rsa.ImportFromPem(publicKeyPem); var data Encoding.UTF8.GetBytes(plainText); // 微信支付要求 PKCS1 填充不要用 OAEP var encrypted rsa.Encrypt(data, RSAEncryptionPadding.Pkcs1); return Convert.ToBase64String(encrypted); } }逻辑说明AES-256-GCM 的 key 固定是 APIv3 密钥本身注意不是商户 API 私钥也不是证书密钥。密文从 Base64 解码后分成两部分最后 16 字节是认证标签前面是真正的密文。解密时如果回调报文里associated_data字段是 null在 C# 里要传空数组传null会直接抛运行时异常。RSA 加密这块微信支付的文档明确规定用 PKCS1 填充很多从 Java 转过来的同学习惯性用 OAEP加密后微信端解不开会报“分账接收方信息解密失败”。工具类写完基础层就齐了。接下来可以开始真正的业务接口请求。3. 服务商模式 JSAPI 下单请求体组装与预支付 ID3.1 服务商单子和直连单子差在哪sp_mchid、sub_mchid 与 payer服务商模式 JSAPI 下单接口路径和直连模式一样都是/v3/pay/transactions/jsapi但请求体里替换了好几个字段字段含义服务商模式取值sp_appid服务商应用 AppID服务商公众平台/开放平台 AppIDsp_mchid服务商商户号服务商自己sub_mchid特约商户号进件商户sub_appid特约商户绑定的 AppID有则填没有就不填description商品描述长度有限制不能带特殊符号out_trade_no商户订单号自己生成全局唯一notify_url回写地址公网可访问的 HTTPSamount.total金额单位是分整数payer.sub_openid用户在商户公众号/小程序下的 openid用户身份标识最重要的一对关系是sub_appid和payer.sub_openid。如果特约商户有自己的开放平台应用则sub_appid填特约商户的 AppIDpayer.sub_openid填用户在该 AppID 下的 openid如果特约商户没有绑定 AppID就用服务商的sp_appid去获取 openid此时请求体里可以不传sub_appid但payer.sub_openid必须是用户在服务商 AppID 下的 openid。这里最容易报的错是“appid 与 openid 不匹配”。我做方案时一般直接约定入驻商户如果没有自己的应用统一用平台 AppID 收集 openid这样分账和退款时接收方 openid 也都是同一个体系下的避免一个用户在多个 AppID 下有多套 openid 的混乱局面。3.2 构造签名并调用下单接口拿到 prepay_id下单的完整代码我封装成一个方法参数用 DTO 传入避免控制器里堆一堆局部变量public async Taskstring CreateJsapiOrderAsync(SpmCreateOrderDto dto) { var url https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi; var amount new { total dto.TotalFee, currency CNY }; var payer new { sub_openid dto.SubOpenId }; var bodyObj new { sp_appid _options.SpAppId, sp_mchid _options.SpMchId, sub_mchid dto.SubMchId, sub_appid string.IsNullOrEmpty(dto.SubAppId) ? null : dto.SubAppId, description dto.Description, out_trade_no dto.OutTradeNo, notify_url dto.NotifyUrl, amount, payer }; var body JsonSerializer.Serialize(bodyObj, _jsonOptions); var response await RequestAsync(HttpMethod.Post, url, body); var json JsonDocument.Parse(response); return json.RootElement.GetProperty(prepay_id).GetString(); }逻辑说明金额total的单位是分订单金额 1 元就传 100这行写错会造成实际收款和订单对不上。out_trade_no建议用“日期 业务单号 随机后缀”方式生成保证在服务商维度下全局唯一不要用自增 ID容易被撞。notify_url必须是 HTTPS微信支付会回调这个地址把支付结果回写给你这个地址不要带签名参数或动态 token回调时没有上下文。参数说明_jsonOptions是所有 JSON 序列化共用的配置要设置为忽略null字段。sub_appid为空时不传该字段微信端校验逻辑是“要么不传传了就必须有效”传个空字符串过去会直接报参数错误。响应里拿到的是字符串prepay_id这个值本身不能直接给前端用还要做第 3.3 节里的二次签名。3.3 拉起支付由 prepay_id 构造小程序支付参数的二次签名微信支付从后端到前端的最后一步是把prepay_id拼到package参数里再用商户私钥做一次签名生成给小程序端wx.requestPayment的五个字段。这个签名串和前面 Authorization 的签名串完全不是一回事appId\n timeStamp\n nonceStr\n packageprepay_idxxx\npublic PaySignDto BuildPaySign(string prepayId) { var appId _options.SpAppId; var timeStamp DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var nonceStr Guid.NewGuid().ToString(N); var packageValue $prepay_id{prepayId}; var message ${appId}\n{timeStamp}\n{nonceStr}\n{packageValue}\n; using var rsa RSA.Create(); rsa.ImportFromPem(_options.PrivateKey); var signedData rsa.SignData(Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return new PaySignDto { AppId appId, TimeStamp timeStamp, NonceStr nonceStr, Package packageValue, SignType RSA, PaySign Convert.ToBase64String(signedData) }; }逻辑说明timeStamp必须是字符串形式的秒级时间戳不是DateTime序列化出来的格式更不是毫秒级。这个签名用到的私钥还是商户 API 私钥不是平台证书私钥。AppId传的是发起拉起支付时对应的 AppID如果第 3.1 节里你传了sub_appid小程序端拉起支付用的 AppID 应当是特约商户的sub_appid而不是服务商的sp_appid否则前端会报“支付验证签名失败”。参数说明SignType固定RSA小程序端wx.requestPayment认这个值。很多人会在这里顺手把 2.x 版的老签名方式MD5搬过来V3 完全没有这个分支直接用 RSA。生成完的PaySign是 Base64 字符串不需要再 URL 编码直接放进 JSON 返回给前端。4. 支付回写验签、解密与应用层幂等4.1 回写报文长什么样为什么不能只验平台证书支付成功后微信支付会向notify_url发一个 POST 请求这个请求的响应只能是 200 且 body 必须返回{code:SUCCESS}否则微信会按策略重试。回写请求的 header 里有四个关键字段Header含义Wechatpay-Serial平台证书序列号Wechatpay-Timestamp签名时间戳Wechatpay-Nonce随机串Wechatpay-Signature签名值验签用的签名串是时间戳\n 随机串\n 请求体原文\n请求体原文指的是从 body 里读出来的完整 JSON 字符串一个字节都不能少。验签用的公钥是平台证书公钥不是商户证书公钥。我见过很多项目在回调里只信任微信服务器的固定 IP、或者只验一个 “来源是不是微信” 的字段实际上最可靠的做法是完整走一遍签名验证用Wechatpay-Serial找到对应平台证书用证书公钥验Wechatpay-Signature验完再判断事件类型和业务字段。一个小坑Wechatpay-Timestamp和服务器当前时间差超过 5 分钟的回调理论上应当直接拒绝防止回放攻击。实际生产环境里服务器时间漂移的情况不多但这一条校验成本极低建议加上。4.2 解密 resource 字段真正拿到支付订单数据回写 body 的结构如下{ id: 回调通知ID, event_type: TRANSACTION.SUCCESS, resource: { algorithm: AEAD_AES_256_GCM, ciphertext: BASE64密文, associated_data: transaction, nonce: 随机串 } }解密后JSON 里才有out_trade_no、transaction_id、trade_state、amount等真正有用的字段。完整的解密处理public async TaskWechatPayNotifyMessage ParseNotifyAsync(HttpRequest request) { request.EnableBuffering(); var body await new StreamReader(request.Body, Encoding.UTF8).ReadToEndAsync(); request.Body.Position 0; // 1. 验签省略此处代码见 4.3 过滤器内实现 // 2. 解出明文 var notifyJson JsonDocument.Parse(body); var resource notifyJson.RootElement.GetProperty(resource); var algorithm resource.GetProperty(algorithm).GetString(); var ciphertext resource.GetProperty(ciphertext).GetString(); var nonce resource.GetProperty(nonce).GetString(); var associatedData resource.TryGetProperty(associated_data, out var ad) ? ad.GetString() : null; if (algorithm ! AEAD_AES_256_GCM) { throw new WechatPayException(不支持的加密算法: algorithm); } var plaintext WechatCrypto.AesGcmDecrypt(_options.ApiV3Key, nonce, ciphertext, associatedData); // 3. 反序列化成业务对象 return JsonSerializer.DeserializeWechatPayNotifyMessage(plaintext); }逻辑说明EnableBuffering()是必须的netCore 里 Request.Body 默认是一次性流不开启缓冲的话读完 body 后控制器再读就是空流。associated_data字段在微信回传的 JSON 里存在但个别历史报文可能没有反序列化时用TryGetProperty兜底。解密失败最常见的原因是 APIv3 密钥配错这个密钥是在商户平台手动设置的 32 字节字符串不是 API 证书的私钥也不是证书密码三者放一个配置里最容易拿混。参数说明WechatPayNotifyMessage里建议把trade_state、out_trade_no、transaction_id、amount.total都做成强类型字段后续做状态机判断时用枚举而不是裸字符串能减少很多拼写错误引起的线上问题。4.3 用过滤器统一处理回写解密避免每个控制器重复支付回写涉及验签、解密、日志、幂等判断如果每个业务控制器都写一遍很容易出现某个接口忘了验签或漏了日志。netCore 的过滤器机制正好适合做这件事我把这部分逻辑收敛成一个全局过滤器只做“验签 解密 存上下文”具体业务动作放在控制器里public class WechatPayNotifyFilter : IAsyncActionFilter { private readonly IWechatPayService _service; private readonly ILoggerWechatPayNotifyFilter _logger; public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var request context.HttpContext.Request; request.EnableBuffering(); var body await new StreamReader(request.Body, Encoding.UTF8).ReadToEndAsync(); request.Body.Position 0; var timestamp request.Headers[Wechatpay-Timestamp].ToString(); var nonce request.Headers[Wechatpay-Nonce].ToString(); var signature request.Headers[Wechatpay-Signature].ToString(); var serial request.Headers[Wechatpay-Serial].ToString(); if (!_service.VerifyNotifySignature(timestamp, nonce, body, signature, serial, out var error)) { context.Result new JsonResult(new { code FAIL, message 验签失败 }) { StatusCode 403 }; _logger.LogWarning(微信支付回写验签失败: {Error}, error); return; } var notify ParseNotifyBody(body); context.HttpContext.Items[WechatPayNotify] notify; await next(); } }逻辑说明Items是 HttpContext 里的一个字典适合在过滤器和控制器之间传临时数据不用额外定义缓存或服务。验签失败时直接返回非 200 响应微信会稍后重试这比返回 200 但业务不处理更安全——至少不会把失败的回调当成成功的吞掉。EnableBuffering()和Position 0配合保证控制器里再读 body 时不会拿到空流。参数说明日志里只需要记录验签失败的错误原因和 body 摘要不要把完整密文打出来密文里包含用户支付隐私信息。解密成功后控制器里从HttpContext.Items[WechatPayNotify]取出消息先查自己的支付单是否已经是SUCCESS如果是则直接返回{code:SUCCESS}这就完成了幂等。这个动作特别重要微信支付回调在极端情况下会重试多次没有幂等保护订单状态会被反复改回已支付。5. 服务商分账与退款接口参数顺序与避坑排查5.1 分账请求先解冻再按方分配接收方信息要加密在服务商模式下用户支付的钱默认会冻结在特约商户账户里要先把部分或全部金额解冻才能把利润分给服务商、商户或其他接收方。分账接口是/v3/profitsharing/orders核心请求体如下public async Taskstring CreateProfitSharingOrderAsync(ProfitSharingDto dto) { var url https://api.mch.weixin.qq.com/v3/profitsharing/orders; var receivers new object[] { new { type PERSONAL_OPENID, // 接收方类型商户号 MERCHANT_ID、个人 openid PERSONAL_OPENID account await _wechatCrypto.RsaEncryptAsync(dto.ReceiverOpenId), amount dto.ReceiverAmount, description dto.Description } }; var bodyObj new { appid _options.SpAppId, sub_mchid dto.SubMchId, transaction_id dto.TransactionId, out_order_no dto.ProfitSharingOutOrderNo, receivers, unfreeze_amount dto.UnfreezeAmount }; var body JsonSerializer.Serialize(bodyObj, _jsonOptions); return await RequestAsync(HttpMethod.Post, url, body); }逻辑说明transaction_id是支付回写里解出来的微信支付订单号不是自己的业务单号。out_order_no是分账订单号要自己生成并保存后续查询分账结果和接收方结果都要靠它。unfreeze_amount是解冻金额单位是分——意思是这次要从冻结资金里解开多少钱解冻的钱加上分出去的钱不能超过订单的可分账金额。接收方数组里每个元素都要有type、account、amount、description其中account如果是用户 openid必须用平台证书公钥加密后传密文明文传会被拒绝。参数说明分账不是即时生效的请求发出后通常需要等几秒微信会通过/v3/profitsharing/notify回调下发PROFITSHARING.SUCCESS事件。很多入门方案只发分账请求、不监听分账回调结果对账时发现订单状态和分账状态对不上这是后面最容易翻车的地方。5.2 退款请求原路退回退款金额不能超过可退金额退款接口路径是/v3/refund/domestic/refunds服务商模式下必须在请求体里带上sub_mchidpublic async Taskstring CreateRefundAsync(RefundDto dto) { var url https://api.mch.weixin.qq.com/v3/refund/domestic/refunds; var bodyObj new { sub_mchid dto.SubMchId, out_trade_no dto.OutTradeNo, out_refund_no dto.OutRefundNo, refund_desc dto.RefundDesc, refund_amount new { amount dto.RefundFee, currency CNY }, notify_url dto.NotifyUrl }; var body JsonSerializer.Serialize(bodyObj, _jsonOptions); return await RequestAsync(HttpMethod.Post, url, body); }逻辑说明out_refund_no是退款单号后台要唯一保存微信回调退款结果时靠它定位是哪一笔退款。refund_amount.amount是退款金额单位还是分。这里有一个边界条件退款金额不能超过该订单的“可退金额”可退金额 原订单金额 - 已退款金额 - 已冻结金额。如果用户支付后做了分账未解冻的钱是不能直接退的得先解除冻结否则接口报“订单金额不足”。notify_url在退款接口里是可选的但强烈建议传。不传的话微信默认使用商户平台配置的回调地址生产环境多个项目混在一起时会出现“A 系统下单、B 系统收到退款回调”的事故。传了之后退款结果会回写到你自己定义的地址上。5.3 五个高频坑排查现象、原因、解决下面这五条是我实际接服务商模式项目时踩过或者帮人排查过的典型问题按“现象 → 原因 → 解决”的格式记录。坑 1下单报“appid 与 openid 不匹配”现象请求/v3/pay/transactions/jsapi返回APPID_OPENID_MISMATCH。原因payer.sub_openid不是在sp_appid或sub_appid对应应用下获取的 openid。服务商模式里 AppID 和 openid 的归属必须一一对应不能拿服务商 AppID 去查特约商户应用下的 openid。解决先确认前端wx.login用的是哪个 AppID再按照那个 AppID 写sp_appid/sub_appid。我一般落地时会在请求里记录前端传来的 appId后端再比对配置不一致直接拒绝下单早报错比晚对账好。坑 2验签一直失败连回调报文都进不来现象过滤器里VerifyNotifySignature反复返回 false日志只有一句“验签失败”。原因最常见的是签名串里的 URL 或 body 和发起请求时不一致。比如 GET 请求没有使用 RFC3986 编码的查询参数或者回调验签时把 body 读出来之后没复位流导致验签用的 body 是空串。解决验签前先确定你读到的 body 和微信发出来的原始 body 完全一致在开发环境打印一次原始 body 和签名串做对照。回调验签时所有 header 字段都转成字符串再拼接不要用对象序列化避免类型转换造成差异。坑 3解密 resource 一直报 AEAD 解密失败现象AesGcmDecrypt抛CryptographicException提示 authentication tag 不匹配。原因APIv3 密钥设置错了或者把nonce、associated_data用错。还有一个隐蔽点是密文 Base64 解码后没有拆分 tag直接整个密文传给Decrypt。解决确认配置里的 ApiV3Key 是在商户平台手动设置的 32 字节密钥不是 32 位盐之类的随机串。密文解码后先取最后 16 字节做 tag剩余的做密文主体两者分开传。associated_data为空时传空数组传null会炸。坑 4分账时报“分账金额与可分账金额不一致”现象请求/v3/profitsharing/orders返回NOT_ENOUGH或者金额校验失败。原因unfreeze_amount与receivers里各金额合计计算错误。微信支付要求分账金额总和加上解冻金额不能超过订单实际可分配金额且部分订单被退款或售后后可分账金额会变小。解决分账前先调用查询接口/v3/profitsharing/orders/{out_order_no}或先查订单当前状态拿到真实“可分账金额”再组装请求。把分账业务拆成“先查询、后分账”两步不要直接拿订单原始金额算。手续费由微信自动扣走不属于可分配范围。坑 5退款回调一直不到现象退款申请返回成功但等很久收不到REFUND.SUCCESS回调。原因退款结果通知走的是微信支付退款回调配置而不是下单时那个notify_url。服务商模式下特约商户如果没有单独配置退款回调地址退款结果可能发到默认地址去甚至不对外回传。解决退款请求里显式传notify_url并在配置中为每个特约商户维护独立的退款回调地址。如果还是收不到用/v3/refund/domestic/refunds/{out_refund_no}主动查询退款状态把主动查询和回调两条路都做上退款对账才不会漏。6. 进阶分账回写与退款异步通知的落地顺序把支付、分账、退款都接通之后真正考验工程能力的是怎么把这些异步通知按正确的顺序落到自己的订单状态机里。我接手这类项目时第一件事永远不是看下单代码而是看回调入口能不能重复执行。以分账为例微信会推PROFITSHARING.SUCCESS事件退款会推REFUND.SUCCESS事件它们的到达顺序并没有严格保证——有可能退款先到分账成功通知后到甚至分账和退款的回调各自重试多次。我一般要求业务系统里落一张“支付事件流水表”用out_trade_no event_type 微信通知ID做唯一约束回调进来先尝试插入流水插入冲突就直接返回成功杜绝重复处理。业务状态机的推进顺序是支付成功先落TRADE_SUCCESS分账成功只更新分账单状态退款成功只更新退款单状态不要在一个回调里同时改三个域的状态否则一旦某次回调重试会把另一笔操作的中间状态覆盖掉。退款和分账的先后关系也要想清楚。如果用户申请退款时订单已经分账需要先调用分账回退接口退回分账金额再做退款否则退款会因为余额不足失败。我在方案里通常把“用户申请退款”这个动作设计成异步任务内部按“查询分账状态 → 分账回退 → 发起退款 → 落退款流水”的顺序执行每一步失败都记录原因并允许人工重试。主动查询和回调通知是互补关系回调没到不能断定没有发生补偿轮询兜底是必须的。netCore 里做一个简单的定时任务每五分钟扫一次“支付成功但分账未成功”的订单主动调查询接口补齐状态这一层兜底能省下很多半夜起来对账的精力。说实话微信支付 V3 服务商模式的接口本身并不复杂复杂的是服务商、特约商户、接收方这三层关系下金额、状态、回调交错出来的边界情况。按我自己的习惯每个接口都只做一件事回调入口只做验签、解密和状态落库不在回调里直接发起新的分账或退款请求这样回调重试的副作用就会被唯一约束挡在外面。把这套幂等和顺序控制做到位再复杂的清结算场景也能扛住希望帮到你。本文还有配套的精品资源点击获取
返回列表