
接手过一个“小程序里调起微信支付V3后台C#”的需求第一反应是这玩意儿网上资料怎么这么碎。V2的帖子一抓一大把换到V3突然多了APIv3密钥、RSA签名、平台证书、微信支付证书光是名词就能把人绕晕。更头疼的是官方文档的示例代码基本是Java、PHP、NodeC#的要么是半成品要么是基于老版本SDK。这篇文章把我从零到上线整个过程的思路、代码、踩坑记录都整理出来代码可以直接抄但更重要的是搞懂每一步为什么这么做不然换个场景照样翻车。1. 微信支付V3和V2的差异先搞懂再动手1.1 为什么要从V2迁到V3很多团队还在用V2因为资料多、惯性大。但微信官方对V3的态度已经很明确了新能力只给V3V2的很多接口也在逐步收紧。从实际体验来说V3的API设计确实更规范所有接口基于RESTful风格请求和响应统一使用JSON签名换成了更安全的RSA-SHA256而不是V2的MD5或者HMAC-SHA256。还有一个点很关键V3的敏感信息比如金额、商户号、用户openid这些默认就是加密传输的而V2主要是靠签名来保证请求没被篡改。V3在安全上更进一步增加了平台证书验签和敏感信息加密这对做支付系统来说是一个实打实的提升。但V3也有它的学习成本首先是APIv3密钥和证书的概念要搞清其次回调通知里敏感信息单独用APIv3密钥做AES-256-GCM加解密这个流程V2里完全没有。C#那边还得处理RSA的私钥格式问题.NET里默认的PEM解析对PKCS#8、PKCS#1支持还得绕一下。1.2 几个必须区分清楚的名词这里我吃过亏先把概念理干净。四个东西别搞混商户API证书商户私钥用来给请求签名也用来解密回调里的敏感信息。在商户平台下载的是一个压缩包里面包含证书文件和私钥文件。平台证书平台公钥用来验证回调通知是不是微信发的。这个证书不是固定不变的微信会定期轮换所以不能只信任一个。APIv3密钥在商户平台里手动设置的32字节密钥用于回调通知中的敏感信息解密。商户号微信支付商户的唯一标识在商户平台里查看。实际开发中最容易犯的错就是拿商户API证书去当平台证书验签或者把APIv3密钥当成签名密钥用。这两类用途完全不同后面代码部分会具体细说。1.3 C#项目适不适合直接上v3我的结论是适合但要做好两个准备。第一官方没有面向C#的官方SDK至少截至发文时还没有完全体大多数代码需要自己写HTTP请求、自己拼签名、自己解析证书第二.NET框架版本如果较老比如.NET Framework 4.5RSA的PEM加解密得额外处理建议直接用.NET Core/.NET 6省掉很多麻烦。如果项目只能跑在.NET Framework上也别灰心用BouncyCastle库可以解析PKCS#8和PKCS#1私钥就是代码稍微绕一点。下文代码以.NET 6为基准但会标注哪些地方在Framework下需要替换。2. 小程序端要什么后端给什么2.1 小程序调起支付的完整链路小程序调起微信支付在客户端其实就一个APIwx.requestPayment。它需要五个参数timeStamp、nonceStr、package、signType、paySign。这五个参数不是小程序自己拼的必须由后端先生成前端拿回来直接用。完整流程就是用户在C#后端请求下单接口自己写的业务接口后端拿到订单号、商品描述、金额、用户的openid。后端拿着这些信息调用微信支付的JSAPI下单接口APPID、商户号、openid这些必须在后端不能暴露在小程序端。微信返回prepay_id预支付交易会话标识。后端拿着prepay_id生成小程序支付需要的五个参数返回给前端。小程序拿到参数调wx.requestPayment用户输密码确认。微信服务器异步通知后端支付结果回调通知后端验签、解密、更新订单状态。这里有个顺序问题经常被搞错是先调下单接口拿到prepay_id再生成paySign不是反过来。paySign里面参与签名的就有prepay_id所以顺序乱一步整个流程就跑不通。2.2 后端返回给前端的字段设计后端返回的JSON结构一般是{ code: 0, data: { timeStamp: 1737172800, nonceStr: 随机字符串, package: prepay_idwx1234567890abcdef, signType: RSA, paySign: base64编码的签名值 } }注意timeStamp是字符串不是数字有些前端同学会直接拿数字塞进去小程序那边可能没问题但最好统一转成字符串。package字段的值必须是prepay_idxxx的格式不要把prepay_id单独丢出来。signType在V3下固定是RSA不是MD5或HMAC-SHA256。前端拿到这五个参数后wx.requestPayment({ timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.package, signType: RSA, paySign: res.data.paySign, success: function (res) { // 用户支付成功等待后端回调确认最终结果 }, fail: function (err) { console.error(支付失败, err); } });值得提醒的是小程序端success回调不代表订单真的支付成功了只能代表用户完成了支付流程的确认。最终以服务端收到微信回调为准客户端的回调只能用来做页面跳转之类的用户交互。3. C#后端核心签名、统一下单、生成支付参数3.1 初始化读取证书和密钥先把配置文件准备好建议放appsettings.json{ WechatPay: { AppId: 你的小程序AppId, MchId: 你的商户号, ApiV3Key: 在商户平台设置的APIv3密钥, MerchantPrivateKeyPath: 证书目录/apiclient_key.pem, MerchantSerialNo: 商户API证书序列号, NotifyUrl: https://你的域名/api/wechatpay/notify } }这里有一个坑MerchantPrivateKeyPath不是apiclient_cert.pem而是apiclient_key.pem。商户平台下载的证书压缩包里有两个文件一个是证书.pem一个是私钥_key.pem加签用的是私钥文件。我第一次就搞反了折腾了很久后来才发现指定的是私钥不是证书。读取私钥的代码internal static RSA LoadPrivateKey(string pemPath) { var pemText File.ReadAllText(pemPath); // 只取-----BEGIN PRIVATE KEY-----和-----END PRIVATE KEY-----中间的部分 // 注意微信下载的apiclient_key.pem可能是PKCS#8也可能是PKCS#1格式 var rsa RSA.Create(); rsa.ImportFromPem(pemText.ToCharArray()); return rsa; }ImportFromPem是.NET 5内置的直接支持PKCS#8和PKCS#1两种格式。如果你的项目还在.NET Framework那就要用BouncyCastle// .NET Framework下的替代方案 var pemReader new PemReader(new StringReader(pemText)); var keyPair (AsymmetricCipherKeyPair)pemReader.ReadObject(); var rsaParams DotNetUtilities.ToRSAParameters((RsaPrivateCipherKeyPair)keyPair); var rsa RSA.Create(); rsa.ImportParameters(rsaParams);还有个容易忽略的细节商户API证书序列号在证书文件里可以直接看到但不是apiclient_cert.pem文件名里那串而是证书本身的序列号。查看方法openssl x509 -in apiclient_cert.pem -noout -serial命令行跑一下就能看到。这个序列号在加签名时要放到HTTP请求头里写错一个字母都是401。3.2 构造签名V3的签名逻辑不算复杂但拼接规则容易踩坑。你需要把请求方法、请求路径、请求时间戳、随机字符串、请求体这五个部分拼成一个待签名字符串。具体规则HTTP方法\n 请求路径\n 时间戳\n 随机字符串\n 请求体\n注意请求体如果是GET请求或者参数在URL上那就是空字符串如果是POST请求就是JSON字符串本身不能格式化也不能有额外的空格换行。这个细节非常隐蔽我见过有人因为把JSON序列化后缩进了一下签名就失败了排查了半天。C#签名代码public static string Sign(string method, string urlPath, string body, long timestamp, string nonceStr, RSA privateKey) { var message ${method}\n{urlPath}\n{timestamp}\n{nonceStr}\n{body}\n; var data Encoding.UTF8.GetBytes(message); var signedData privateKey.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.PKCS1); return Convert.ToBase64String(signedData); }签名后字符串是Base64编码直接放进HTTP请求头的Authorization字段里。Authorization的完整格式是WECHATPAY2-SHA256-RSA2048 mchidxxx,nonce_strxxx,signaturexxx,timestampxxx,serial_noxxx这串格式必须和官方文档一模一样一个引号、一个等号都不能少。写代码时最好用string.Format或$插值别手抖拼错。3.3 统一下单接口调用微信支付V3的JSAPI下单接口路径是/v3/pay/transactions/jsapi请求方法是POST请求体JSON{ appid: 小程序AppId, mchid: 商户号, description: 商品描述, out_trade_no: 商户订单号, notify_url: https://你的域名/api/wechatpay/notify, amount: { total: 100, currency: CNY }, payer: { openid: 用户在小程序下的openid } }total单位是分不是元。100表示1元1000表示10元。这个换算错一次就等着退款吧很多新手在这翻车。在C#后端调用这个接口可以简单封装public class WechatPayClient { private readonly HttpClient _httpClient; private readonly WechatPayOptions _options; private readonly RSA _privateKey; public async Taskstring CreateJsapiOrderAsync(OrderRequest request) { var urlPath /v3/pay/transactions/jsapi; var timestamp DateTimeOffset.Now.ToUnixTimeSeconds(); var nonceStr Guid.NewGuid().ToString(N); var body JsonSerializer.Serialize(new { appid _options.AppId, mchid _options.MchId, description request.Description, out_trade_no request.OutTradeNo, notify_url _options.NotifyUrl, amount new { total request.TotalFee, currency CNY }, payer new { openid request.OpenId } }); var sign SignatureHelper.Sign(POST, urlPath, body, timestamp, nonceStr, _privateKey); var authHeader $WECHATPAY2-SHA256-RSA2048 mchid\{_options.MchId}\,nonce_str\{nonceStr}\,signature\{sign}\,timestamp\{timestamp}\,serial_no\{_options.MerchantSerialNo}\; _httpClient.DefaultRequestHeaders.Remove(Authorization); _httpClient.DefaultRequestHeaders.TryAddWithoutValidation(Authorization, authHeader); _httpClient.DefaultRequestHeaders.Accept.Add( new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue(application/json)); var response await _httpClient.PostAsync( $https://api.mch.weixin.qq.com{urlPath}, new StringContent(body, Encoding.UTF8, application/json)); var content await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new Exception($微信统一下单失败: {content}); } using var doc JsonDocument.Parse(content); return doc.RootElement.GetProperty(prepay_id).GetString(); } }返回的prepay_id是要用来生成小程序支付参数的别把它直接暴露给前端必须再签名一次。3.4 生成小程序端paySign拿到prepay_id之后后端还需要生成第二个签名这个签名才是给小程序wx.requestPayment用的。参与签名的参数按固定顺序拼接appid\n timestamp\n nonceStr\n package\n这里的package是prepay_idxxx。注意这个签名的拼接顺序和上一节的HTTP请求签名不一样别搞混。public static string BuildPaySign(string appId, string timeStamp, string nonceStr, string prepayId, RSA privateKey) { var package $prepay_id{prepayId}; var message ${appId}\n{timeStamp}\n{nonceStr}\n{package}\n; var data Encoding.UTF8.GetBytes(message); var signedData privateKey.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.PKCS1); return Convert.ToBase64String(signedData); }完整返回参数var paySign BuildPaySign(appId, timestampStr, nonceStr, prepayId, privateKey); return new { timeStamp timestampStr, nonceStr nonceStr, package $prepay_id{prepayId}, signType RSA, paySign paySign };这里再次提醒timeStamp是字符串不是数字。前端把数字传进wx.requestPayment也会成功但一致性不好最好统一字符串。4. 证书与敏感信息加解密最容易出问题的环节4.1 平台证书的获取与轮换机制回调通知验签需要用到平台证书。平台证书不是唯一的微信会定期轮换所以不能把一个证书写死就完事。获取平台证书的接口是GET /v3/certificates返回的是一组证书列表每个证书有serial_no、effective_time、expire_time、encrypt_certificate。其中encrypt_certificate里的证书内容是加密的需要用APIv3密钥解密才能拿到真正的证书明文。实际开发中如果你引入了官方SDK的Java/PHP版本他们的SDK会自动处理证书轮换。但C#没有官方SDK就得自己实现证书下载和更新逻辑。有一个简单做法定时任务每天拉一次证书列表解密后保存到本地或者缓存更新时不要删除旧证书最好保留最近三个。因为微信通知到达服务器时使用的证书可能是几天前签发的如果你只存最新的可能会因为证书不匹配导致验签失败。获取平台证书并解密的完整代码private async TaskListWechatPlatformCert DownloadPlatformCertsAsync() { var urlPath /v3/certificates; var timestamp DateTimeOffset.Now.ToUnixTimeSeconds(); var nonceStr Guid.NewGuid().ToString(N); // GET请求body为空字符串 var sign SignatureHelper.Sign(GET, urlPath, , timestamp, nonceStr, _privateKey); // 拼Authorization发起GET请求... // 拿到响应后注意返回的结构 }响应的JSON结构是{ data: [ { serial_no: 证书序列号, effective_time: 生效时间, expire_time: 过期时间, encrypt_certificate: { algorithm: AEAD_AES_256_GCM, nonce: 随机数, associated_data: 附加数据, ciphertext: 加密后的证书内容 } } ] }4.2 AES-256-GCM解密APIv3密钥加密内容平台证书的密文用APIv3密钥解密。C#里System.Security.Cryptography自带AES-GCM但只在.NET Core 3.0可用。public static string DecryptApiV3(string ciphertext, string nonce, string associatedData, string apiV3Key) { var keyBytes Encoding.UTF8.GetBytes(apiV3Key); var nonceBytes Encoding.UTF8.GetBytes(nonce); var associatedDataBytes Encoding.UTF8.GetBytes(associatedData); var ciphertextBytes Convert.FromBase64String(ciphertext); using var aes new AesGcm(keyBytes, 16); // tagSizeInBytes16 var plaintextBytes new byte[ciphertextBytes.Length - 16]; var tagBytes new byte[16]; Array.Copy(ciphertextBytes, 0, plaintextBytes, 0, ciphertextBytes.Length - 16); Array.Copy(ciphertextBytes, ciphertextBytes.Length - 16, tagBytes, 0, 16); aes.Decrypt(nonceBytes, ciphertextBytes.AsSpan(0, ciphertextBytes.Length - 16), tagBytes, plaintextBytes, associatedDataBytes); return Encoding.UTF8.GetString(plaintextBytes); }解密后的内容就是平台证书的PEM格式文本。先别急着入库还要校验一下证书链微信平台证书是可以自签名的但要看Subject的CN字段是否是微信支付平台证书以及证书是否在有效期内。如果你的项目是.NET Framework没有AesGcm类可以用BouncyCastle的GcmBlockCipher来实现但代码会稍微长一些。强烈建议直接用.NET 6省这个事。4.3 回调通知里的敏感信息解密平台证书的加解密只是第一步真正让新手头痛的是回调通知body里的resource字段是加密的这个里面才是订单号、金额、交易状态等核心数据。微信回调通知的完整结构{ id: 事件ID, create_time: 2024-01-18T10:00:0008:00, event_type: TRANSACTION.SUCCESS, resource_type: encrypt-resource, resource: { algorithm: AEAD_AES_256_GCM, ciphertext: 加密内容, nonce: 随机数, associated_data: 附加数据, original_type: transaction } }resource.ciphertext里面的明文是JSON{ appid: 小程序AppId, mchid: 商户号, out_trade_no: 商户订单号, transaction_id: 微信支付订单号, trade_type: JSAPI, trade_state: SUCCESS, success_time: ..., amount: { total: 100, payer_total: 100, currency: CNY }, payer: { openid: ... } }解密过程和解平台证书一模一样都是AES-256-GCM用的都是APIv3密钥。唯一的区别是associated_data字段不同平台证书那里是certificate回调通知这里是transaction。如果你把associated_data传错了解密出来的就是乱码。5. 回调通知验签这一步做不好订单状态会乱5.1 验签的必要性可能有人会问微信回调通知的URL我写在商户平台了别人知道了又能怎样实际上回调URL并不是什么秘密如果不验签任何人都可以伪造一个通知POST到你的接口说你有一笔订单支付成功了。如果你的逻辑是“收到回调就把订单标记为已支付”订单状态还能改吗当然能改但很可能造成资金损失、发货错乱。所以验签是非做不可的。验签原理微信服务器使用平台证书对应的私钥对通知内容签名我们用平台证书里的公钥验证签名是否匹配。5.2 验签的完整流程验签的数据拼接格式时间戳\n 随机字符串\n 请求体\n请求体就是原始POST body的JSON字符串不要做任何格式化处理直接拿原始字节。签名在HTTP请求头里微信的回调请求头长这样Wechatpay-Serial: 平台证书序列号 Wechatpay-Signature: 签名值 Wechatpay-Timestamp: 时间戳 Wechatpay-Nonce: 随机字符串从请求头取这四个值然后用序列号找到对应的平台证书用该证书里的公钥验签public static bool VerifyWechatpaySignature(string timestamp, string nonce, string body, string signature, RSA platformPublicKey) { var message ${timestamp}\n{nonce}\n{body}\n; var data Encoding.UTF8.GetBytes(message); var signatureBytes Convert.FromBase64String(signature); return platformPublicKey.VerifyData(data, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.PKCS1); }这里有个常见误区很多人以为回调验签用的是商户API证书的私钥或公钥甚至有人用商户私钥去验签。正确的是用微信平台证书的公钥。所以处理回调前你必须有平台证书不能在回调逻辑里先拉证书再验签——那样会有竞态万一证书拉取失败回调就全丢了。要在服务启动时就把平台证书准备好了。5.3 验签通过后的解密与订单处理验签通过后才能进入解密流程。解密resource得到明文然后判断trade_state是否为SUCCESS再对比out_trade_no和amount.total是否与原订单一致。这里我建议做三重校验订单存在性out_trade_no在数据库里能查到对应的订单。金额一致性回调里的amount.total和订单表中的应付金额一致。不一致就是异常需要告警。订单状态幂等再判断一次订单当前状态如果已经是已支付说明是重复通知直接返回成功不要重复处理。处理完成后一定要给微信返回200 OK并携带一个JSON响应体{code: SUCCESS, message: 成功}。微信收到这个响应后认为通知送达成功不会再补发。如果返回非200微信会按照一定间隔重试重试次数多了会被认为回调异常。注意不要先返回成功再异步处理更新订单如果异步处理挂了订单状态就永远卡在中间状态。我见过有人图快收到通知就返回成功然后丢进消息队列结果队列消费失败客户钱付了订单却显示未支付。要在回调里同步处理完才返回成功。6. 实战踩坑记录C#调微信支付V3的坑远不止签名6.1 签名失败的几种原因签名失败是出现频率最高的报错基本都会卡在这里。除了上文提到的换行符问题我整理了三条第一请求路径必须和实际请求完全一致。如果你请求的是https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi那么参与签名的路径就是/v3/pay/transactions/jsapi。不要把https://api.mch.weixin.qq.com前缀带进去不要多加斜杠。第二JSON序列化顺序不影响签名但空格会影响。有次我把请求体用JsonSerializer序列化后为了日志好看格式化了一下再传给微信结果微信那边的body和参与签名的body不一致报签名错误。后来改过来请求发什么原始字节签名就拼什么原始字符串。第三时间戳不能用错单位。DateTimeOffset.Now.ToUnixTimeSeconds()得到的是秒级但有人不小心用了DateTime.Now.Ticks或者毫秒级时间戳微信那边校验不过。6.2 证书相关报错CERTIFICATE_NOT_VERIFIED这个错误一般是证书序列号或证书方式不对。检查serial_no是不是商户API证书序列号。INVALID_REQUEST参数格式不对仔细看返回里的detail字段会告诉你具体哪个字段有问题。PRIVATE_KEY_MISMATCH通常是证书和私钥不匹配下载压缩包时证书和私钥是一一对应的别混搭。还有一类比较隐蔽商户证书过期。商户API证书有效期一般是两年到期后商户平台的接口会全部报证书错误。如果你线上支付突然批量失败先看看证书是不是过期了这个从日志里看特别容易误判成代码问题。6.3 小程序端报错requestPayment:fail小程序端最常见的报错是requestPayment:fail invalid parameter。这个一般不是前端问题是后端给的数据格式错了timeStamp传了数字、package少了prepay_id前缀或者paySign是空串。还有一种情况openid和当前小程序的登录用户不匹配。后端下单用的openid和实际发起支付的小程序用户不是同一个就会报ORDER_PAID_ERROR或者OPENID_MISMATCH。排查思路很直接看看后端拿openid的来源是不是当前会话的登录态而不是从别的地方硬塞。6.4 关于日志和排查的建议支付系统最重要的习惯就是完整记录日志。我给每个和微信支付的交互都打了日志格式类似[WechatPay] 统一下单 request methodPOST path/v3/pay/transactions/jsapi [WechatPay] 统一下单 response status200 body{...} [WechatPay] 回调通知 headers serialxxx signaturexxx timestampxxx noncexxx [WechatPay] 回调通知 bodyxxx不要嫌日志多。支付报错靠的就是这些原始请求响应才能定位。特别是回调通知body、headers、验签结果缺一样都很难排查。还有不要把完整银行卡号、openid等敏感信息打日志这是红线。订单号、金额这些可以打但用户敏感信息要脱敏。6.5 测试阶段的几件小事测试环境建议直接用微信沙箱或者小额真实支付。小额真实支付比如1分钱和沙箱各有优劣沙箱不产生真实资金但有些商户配置和沙箱不一致真实小额支付能完整走链路但要处理好退款。退款接口也要测试V3的退款API是POST /v3/refund/domestic/refunds要注意退款金额不能大于订单金额。测试的时候把退款流程也跑通不然用户申请退款时才发现代码没写好就晚了。另外测试小程序有微信开发者工具在工具里可以模拟支付但模拟支付不会触发真实回调所以回调逻辑还是要靠真机测试。上线前用真机完整走一遍下单、拉起支付、输密码、回调、查单全链路确认。7. 上线前最后的自查清单整理一份自查清单按这个顺序过一遍基本能避开大部分支付上线上的常见问题商户平台上配置的支付回调地址和代码里的NotifyUrl是否一致。不一致就直接收不到回调。商户API证书是否有效私钥文件是否只存在于服务器不要放到前端代码或Git仓库里。APIv3密钥是否设置为强随机密钥不要和其他环境共用。平台证书是否由定时任务自动更新还是写死了一个证书。回调接口是否验证了签名、解密、金额、订单状态。用户取消支付时订单状态是否有兜底逻辑。用户取消后订单是变成关闭状态还是保持待支付要约定好。退款流程是否测试过。日志脱敏是否做好。我在实际项目中还见过一个坑多个小程序AppId复用同一个商户号。这种情况下统一下单时appid必须传对且每个AppId都要在商户平台完成绑定不然报APPID_MCHID_NOT_MATCH。支付这个东西功能本身不难难在流程的严谨性和细节的完整性。代码写好只是第一步把各种异常流程想清楚才是真正考验功力的地方。希望这篇文章能帮你省掉几个晚上的排查时间。如果看完之后还有什么问题先按日志和签名排查一遍大部分问题都出在那几行字符串拼接上。