接了十几单带支付的小程序之后,我总结出一个规律:支付功能翻车从不翻在「发起支付」,全都翻在「支付之后」。签名报错、回调掉单、退款状态没人管——这三件事覆盖了 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 选钥匙、全链路幂等——这套结构跑在多个生产小程序里,零掉单。
觉得有用点个关注,后续会写「支付对账单自动核对」和「桌面端收银台与小程序支付打通」的实战篇;具体项目问题欢迎评论区或私信交流。