简介:这是一套基于 .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 mchid="1900000001",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 和商户配置,返回HttpResponseMessage:
public async Task<string> 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/json,User-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.Empty<byte>() : 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 下单:请求体组装与预支付 ID
3.1 服务商单子和直连单子差在哪:sp_mchid、sub_mchid 与 payer
服务商模式 JSAPI 下单,接口路径和直连模式一样都是/v3/pay/transactions/jsapi,但请求体里替换了好几个字段:
| 字段 | 含义 | 服务商模式取值 |
|---|---|---|
| sp_appid | 服务商应用 AppID | 服务商公众平台/开放平台 AppID |
| sp_mchid | 服务商商户号 | 服务商自己 |
| sub_mchid | 特约商户号 | 进件商户 |
| sub_appid | 特约商户绑定的 AppID | 有则填,没有就不填 |
| description | 商品描述 | 长度有限制,不能带特殊符号 |
| out_trade_no | 商户订单号 | 自己生成,全局唯一 |
| notify_url | 回写地址 | 公网可访问的 HTTPS |
| amount.total | 金额 | 单位是分,整数 |
| payer.sub_openid | 用户在商户公众号/小程序下的 openid | 用户身份标识 |
最重要的一对关系是sub_appid和payer.sub_openid。如果特约商户有自己的开放平台应用,则sub_appid填特约商户的 AppID,payer.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 Task<string> 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 package=prepay_id=xxx\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 Task<WechatPayNotifyMessage> 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.Deserialize<WechatPayNotifyMessage>(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 ILogger<WechatPayNotifyFilter> _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 Task<string> 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_mchid:
public async Task<string> 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 服务商模式的接口本身并不复杂,复杂的是服务商、特约商户、接收方这三层关系下,金额、状态、回调交错出来的边界情况。按我自己的习惯,每个接口都只做一件事,回调入口只做验签、解密和状态落库,不在回调里直接发起新的分账或退款请求,这样回调重试的副作用就会被唯一约束挡在外面。把这套幂等和顺序控制做到位,再复杂的清结算场景也能扛住,希望帮到你。
本文还有配套的精品资源,点击获取