微信支付 v3 三个最容易出事的环节:签名、回调、退款(.NET 实战记录)

3 阅读3分钟

接了十几单带支付的小程序之后,我总结出一个规律:支付功能翻车从不翻在「发起支付」,全都翻在「支付之后」。签名报错、回调掉单、退款状态没人管——这三件事覆盖了 90% 的线上事故。这篇按 .NET WebAPI 的实现顺序讲,代码可直接抄。

一、签名:先分清钥匙,再谈代码

v3 接入手里有三种密钥材料,混用是所有 SIGN_ERROR 的总根源:

  • 商户 API 私钥(apiclient_key.pem):你请求微信时签名用;
  • 商户证书序列号:Authorization 头里的 serial_no,告诉微信用哪把公钥验你;
  • APIv3 密钥(32 字节):解密回调密文用,不参与任何签名。

一句话记住方向:你签微信验,用商户私钥;微信签你验,用平台公钥。

签名串规则:HTTP方法\nURL路径\n时间戳\n随机串\n请求体\n——body 后那个换行不能少,GET 也要留。

string BuildAuthorization(string method, string canonicalUrl, string jsonBody)
{
    var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
    var nonce = Guid.NewGuid().ToString("N");
    var message = $"{method}\n{canonicalUrl}\n{timestamp}\n{nonce}\n{jsonBody}\n";

    using var rsa = RSA.Create();
    rsa.ImportFromPem(File.ReadAllText("apiclient_key.pem"));
    var sig = rsa.SignData(Encoding.UTF8.GetBytes(message),
        HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);

    return $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_mchid}\"," +
           $"nonce_str=\"{nonce}\"," +
           $"signature=\"{Convert.ToBase64String(sig)}\"," +
           $"timestamp=\"{timestamp}\"," +
           $"serial_no=\"{_merchantSerial}\"";
}

SIGN_ERROR 排查顺序(按命中率):时钟偏差 > 5 分钟(先校 NTP)→ serial_no 填错(要商户证书序列号)→ 签名串缺尾部换行 / URL 带了域名 → 密钥材料拿错。

顺带一个小程序端的坑:wx.requestPayment 的 paySign 也是商户私钥签(串:appId\ntimeStamp\nnonceStr\nprepay_id=xxx\n),signType 传 "RSA",别用 v2 的 MD5 习惯。

二、回调:三条铁律根治掉单

回调是唯一「微信主动找你」的环节,掉单、重复发货全出在这里。

铁律一:先落库,再应答。 四个头(Wechatpay-Timestamp/Nonce/Signature/Serial)+ 原始 body 先存表,验签解密落库完成后才应答:

[HttpPost("pay/notify")]
public async Task<IActionResult> Notify()
{
    var raw = await SaveRawRequestAsync();
    if (!VerifySignature(raw))
        return StatusCode(401, new { code = "FAIL" });

    var json = DecryptResource(raw);
    await UpsertOrderPaidAsync(json);      // out_trade_no 唯一索引幂等
    return Ok(new { code = "SUCCESS" });
}

成功 200 + {"code":"SUCCESS"};失败返回 4xx/5xx,微信按 15s → 6h 衰减重试。先 return 200 再慢慢处理 = 掉单的标准姿势,因为业务异常时微信已经认为你收到了。

铁律二:按 Wechatpay-Serial 动态选公钥。 平台证书会轮换,写死一把迟早翻车——按 serial 缓存,未命中走下载平台证书接口补拉:

bool VerifySignature(RawNotify raw)
{
    var message = $"{raw.Timestamp}\n{raw.Nonce}\n{raw.Body}\n";
    using var rsa = RSA.Create();
    rsa.ImportFromPem(GetPlatformPublicKey(raw.Serial));
    return rsa.VerifyData(Encoding.UTF8.GetBytes(message),
        Convert.FromBase64String(raw.Signature),
        HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}

铁律三:AES-256-GCM 解密,末 16 字节是 tag:

byte[] DecryptResource(string nonce, string ciphertext, string aad = "transaction")
{
    var key   = Encoding.UTF8.GetBytes(_apiV3Key);      // 32 字节
    var ct    = Convert.FromBase64String(ciphertext);
    var tag   = ct[^16..];
    var data  = ct[..^16];
    var plain = new byte[data.Length];
    using var gcm = new AesGcm(key, 16);
    gcm.Decrypt(Encoding.UTF8.GetBytes(nonce), data,
                Encoding.UTF8.GetBytes(aad), plain, tag);
    return plain;
}

幂等靠 out_trade_no 唯一索引:重复通知撞索引直接 SUCCESS,发货逻辑放幂等层后面,永不重复执行。

三、退款:受理 ≠ 到账

POST /v3/refund/domestic/refunds 同步返回成功只代表微信受理了,真实结果走异步通知(REFUND.SUCCESS / ABNORMAL / CLOSED)。退款回调与支付回调共用验签解密,按 event_type 分流即可。

要点:out_refund_no 商户侧唯一(建议订单号-R序号);金额单位分,refund ≤ total − 已退;余额不足进 ABNORMAL,靠通知触发人工介入;退款原因会展示给用户,写人话别写内部单号。

四、小结

支付系统的难点从来不是「把请求发出去」,而是把每一个异步状态都想成可能重复、可能迟到、可能不来。先落库、再应答、按 serial 选钥匙、全链路幂等——这套结构跑在多个生产小程序里,零掉单。

觉得有用点个关注,后续会写「支付对账单自动核对」和「桌面端收银台与小程序支付打通」的实战篇;具体项目问题欢迎评论区或私信交流。