简介这份 ASP.NET 微信支付 JSAPI 调用示例面向需要在微信内置浏览器中接入支付的 C# 开发者与中小项目团队重点解决 JSAPI 支付配置繁琐、旧版 Demo 代码冗长的问题。压缩包共 122 个文件、约 2.36MB包含 44 个 cs 源码、24 个 aspx 页面、11 个 dll 依赖、9 个 config 配置以及 js、txt、日志与证书等辅助文件覆盖支付页面、商品页、退款与退款查询等模块。已有 1073 人学习下载说明其在同类接入场景中具有一定参考价值。示例基于官方支付 v3 版本整理代码相对简洁且无需额外引入 JS作者实践表明配通周期可控制在十分钟左右借助完整页面结构与配置样例读者可对照梳理微信浏览器内的支付调用链路、退款处理和常见排错思路减少从旧 Demo 迁移的时间成本。1. 从一笔「待支付」订单说起ASP.NET 接 JSAPI 支付到底要写多少代码后台订单列表里显示着一笔「待支付」用户却截图说钱已经扣了。翻日志才发现统一下单成功了prepay_id 也拿到了前端收银台也弹出并输完密码了唯独回调地址返回 500重试十几次全被 ASP.NET 的异常页拦截。这类问题不看链路根本查不出来和 C# 语法一点关系都没有。这份 ASP.NET 微信支付 JSAPI 调用 Demo把 C# 侧的整条链路固定成一套可对照的骨架网页授权拿 openid、JSAPI 下单换 prepay_id、二次签名交给前端调起、回调验签解密、幂等落库。适合已经能写 ASP.NET MVC / Web API、但第一次对接微信支付 v3 的人也适合接手半成品项目、需要一份参照物来逐段比对的人。2. 把链路摆平从 openid 到 prepay_id 的六个环节与参数表JSAPI 支付最容易让人误判的地方是它看起来像「前端弹个收银台」这么简单。实际上服务端要完成的事情有六件前端只负责最后一件。把这条链路拆成环节之后每个环节都有自己的输入、输出和典型报错出问题时你只要判断「断在第几个环节」即可不用从头到尾读日志。2.1 六个环节与各自最容易失败的位置先看一张表这是我对着 Demo 的调用顺序整理出来的也是排查时的对照表。环节执行方关键输入产出失败时的典型表现1 网页授权前端跳转 服务端换票appid、redirect_uri、scopesnsapi_basecode回调地址不带 code或 code 用过一次就失效2 换 openid服务端code、appid、AppSecretopenid40125 类错误多半是 AppSecret 用错或 appid 不匹配3 统一下单服务端openid、金额、订单号、notify_urlprepay_id400 参数错误、401 签名错误4 二次签名服务端prepay_id、时间戳、随机串paySign前端报「支付验证签名失败」5 调起收银台前端上面四个参数 paySign用户输密码收银台不弹、白屏、提示系统繁忙6 异步通知微信侧回调加密报文订单终态回调一直重试、订单永远待支付环节 1 和 2 是很多人忽略的一步JSAPI 支付必须带 payer.openid而这个 openid 必须是「和下单 appid 同一主体下」的 openid。公众号的 openid 和小程序的 openid 不是一回事同一用户在两个 appid 下拿到的值完全不同。我见过最典型的翻车是页面在公众号里跑用的却是小程序那套 appid 和密钥openid 拿到了一下单就报「openid 与 appid 不匹配」。环节 3 是整条链路的分水岭。这一步成功只代表「微信那边登记了一笔待支付订单」不代表用户付了钱。很多人在这里就开始改业务状态结果用户关掉收银台订单变成「已支付但没付钱」对账时只能人工处理。环节 6 才是唯一可信的支付结果来源。前端get_brand_wcpay_request:ok只是「用户在收银台上点了确认」网络抖动、用户中途退出、微信侧扣款失败都会让它和真实结果不一致。所以服务端的判断顺序永远是收到回调 → 验签 → 解密 → 核对金额 → 幂等更新 → 返回 2xx。2.2 统一下单的字段表与一段能直接跑的调用代码下单接口是POST /v3/pay/transactions/jsapi请求体是 JSON。下面这几个字段是最容易设错的我按「改了会怎样」的方式列出来。字段取值要点设错的后果appid与拿 openid 的那个应用一致openid 不匹配下单直接失败mchid商户号纯数字字符串与证书不匹配时签名校验不过out_trade_no6~32 位字母数字和 _-*description商品描述别塞特殊字符部分字符会被判为非法参数notify_url必须 https不带 query不能是内网地址回调永远收不到amount.total单位是分整数写成元金额差 100 倍payer.openid用户在当前 appid 下的 openid报 openid 与 appid 不匹配对应代码如下我把它放在一个服务类里控制器只负责传参// 统一下单只负责拿到 prepay_id不代表用户已付款 public async Taskstring CreateJsapiOrderAsync(string openid, string outTradeNo, int totalFen, string description) { var body new { appid _options.AppId, // 必须与获取 openid 的应用一致 mchid _options.MchId, // 商户号字符串形式 description description, // 商品描述尽量短且无特殊字符 out_trade_no outTradeNo, // 商户订单号全局唯一 notify_url _options.NotifyUrl, // https、无 query、公网可达 amount new { total totalFen, currency CNY }, // 单位分 payer new { openid openid } }; var json JsonSerializer.Serialize(body); using var req new HttpRequestMessage(HttpMethod.Post, /v3/pay/transactions/jsapi); req.Content new StringContent(json, Encoding.UTF8, application/json); // 注意 pathWithQuery 参与签名必须和实际请求路径逐字符一致 req.Headers.TryAddWithoutValidation(Authorization, BuildAuthorization(POST, /v3/pay/transactions/jsapi, json)); using var resp await _http.SendAsync(req); var text await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($下单失败 {(int)resp.StatusCode}: {text}); using var doc JsonDocument.Parse(text); return doc.RootElement.GetProperty(prepay_id).GetString()!; }代码里有两处细节值得单独说。第一totalFen我在方法签名上就写成「分」而不是在方法体内做换算这样调用方一眼就知道单位避免出现「传了 9.9 结果变成 9 分」这种血泪事故。第二BuildAuthorization的第二个参数是「带 query 的路径」如果你的接口带查询串必须把?a1b2原样拼进签名串不能先 URL 编码再拼也不能只传路径部分。HttpClient不要在每个方法里new用IHttpClientFactory注入超时设 10 秒左右。下单接口本身很快超过 10 秒基本是网络或 DNS 问题继续等只会把线程池占满。2.3 签名串APIv3 的换行位置少一个都不行APIv3 的请求签名串是五行最后一行后面也要有一个换行符HTTP方法\n URL带query\n 时间戳秒\n 随机串\n 请求体\n拼接完成后用商户 API 私钥做 SHA256withRSABase64 编码后放进Authorization头。这里的「血泪经验」是绝大多数 401 签名错误都不是密钥不对而是拼接差了一个字符。// 请求签名注意结尾的 \n少了它必报 401 private string BuildAuthorization(string method, string pathWithQuery, string body) { var timestamp DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); // 秒级误差别超 5 分钟 var nonce Guid.NewGuid().ToString(N); // 32 位随机串 var message ${method}\n{pathWithQuery}\n{timestamp}\n{nonce}\n{body}\n; var signature Sign(message); return $WECHATPAY2-SHA256-RSA2048 mchid\{_options.MchId}\, $nonce_str\{nonce}\,signature\{signature}\, $timestamp\{timestamp}\,serial_no\{_options.MerchantSerialNo}\; } private string Sign(string message) { using var rsa RSA.Create(); rsa.ImportFromPem(_options.MerchantPrivateKeyPem); // 也支持 ImportFromPkcs12 读 .p12 var sig rsa.SignData(Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(sig); }serial_no是商户 API 证书的序列号不是证书内容也不是 APIv3 密钥。序列号在证书详情里能看到格式是一串十六进制字符串。这里每次调用都新建RSA实例在 Demo 里没问题生产环境建议在启动时导入一次并缓存否则高并发下会有明显的 CPU 开销。还有一个容易混淆的点APIv3 没有「API 密钥参与签名」这回事。APIv3 密钥只用来解密回调报文不参与请求签名。把 APIv3 密钥当签名密钥用是最常见的一类误用。3. 拆开 Demo 的代码骨架配置、服务类与前端调起怎么分层拿到一份支付 Demo最忌讳的做法是「先跑通再说」。跑通之后你会得到一堆散落在控制器里的签名代码、硬编码的商户号和一段直接写在 cshtml 里的 JS改一个环境就全崩。这一章讲的是这份 Demo 的分层方式以及每一层为什么这么切。3.1 配置项与证书文件的落位支付相关的配置分成三类可以进代码仓库的普通配置appid、商户号、回调地址、绝对不能进仓库的密钥材料商户私钥、APIv3 密钥、以及需要定期轮换的平台证书或公钥。这三类要分开管。// appsettings.json —— 下面的值全部是占位符真实值走环境变量或密钥库 { WechatPay: { AppId: wx****************, // 与获取 openid 的应用一致 MchId: 1900000000, // 商户号 MerchantSerialNo: ****************, // 商户 API 证书序列号 MerchantPrivateKeyPath: cert/apiclient_key.pem, ApiV3Key: ************************, // 必须 32 位仅用于解密回调 NotifyUrl: https://your.domain/api/pay/notify, PlatformCertPath: cert/wechatpay_platform.pem } }几个实操上的判断标准私钥文件不要放在wwwroot下哪怕目录名看起来不显眼。放在项目外的相对路径或者直接走系统证书存储。ApiV3Key长度必须是 32 位。设错了不会在启动时报错而是等到回调解密时才抛出非常难查。平台证书会轮换不要把它当成永久文件写死。启动时加载、失败时能重新拉取比「打进镜像」稳得多。证书序列号建议在启动时打印到日志只打序列号不打私钥出问题时第一眼看的就是它和后台配置是否一致。我一般会在启动时做一次自检读私钥、读证书、校验序列号是否与配置一致、校验 APIv3 密钥长度是否为 32。这四个检查加起来不到二十行代码但能提前挡掉后面八成的「莫名其妙的 401」。3.2 服务类怎么切一个下单方法一个签名方法一个通知处理器Demo 里我把它拆成三个职责WechatPayClient只负责发 HTTPS 请求和处理 HTTP 层错误方法签名是「输入业务参数输出 prepay_id 或异常」。WechatPaySigner只负责拼签名串、签名、验签、解密纯计算不碰网络。PayNotifyController只负责读原始报文、调 signer 验签解密、调业务服务落库、返回状态码。这种切法的好处是签名相关的错误可以单独写单元测试。比如「签名串末尾少一个换行」这种 bug用真实网络请求去调试要来回折腾十几分钟写成一个断言只要一秒。// 注册统一注入 HttpClient避免端口耗尽和 DNS 缓存问题 builder.Services.AddHttpClientIWechatPayClient, WechatPayClient(c { c.BaseAddress new Uri(https://api.mch.weixin.qq.com/); c.Timeout TimeSpan.FromSeconds(10); // 超过 10 秒基本是网络问题继续等只会拖垮线程池 }); builder.Services.AddSingletonIWechatPaySigner, WechatPaySigner(); // 内部缓存 RSA 实例订单号的生成规则也放在这一层因为它是幂等的基础。常见的做法是「业务前缀 时间 随机位」好处是能直接从订单号看出业务线和日期排查时不用反查数据库。// 商户订单号6~32 位只允许数字、字母和 _-|* public static string NewOutTradeNo(string prefix) { // .NET 6 用 Random.Shared.NET Framework 下换成 RandomNumberGenerator var tail Random.Shared.Next(100000, 999999); return ${prefix}{DateTime.Now:yyyyMMddHHmmss}{tail}; }注意订单号一旦生成就不能因为「用户重新点了一次支付」而重新生成。正确做法是用同一个out_trade_no再调一次下单微信侧会返回同一笔订单的 prepay_id在有效期内的前提下。如果每次都生成新订单号用户点三次就会产生三笔待支付订单对账时是一团乱麻。3.3 前端调起WeixinJSBridge 和 JSSDK 两条路的取舍前端拿到服务端返回的appId / timeStamp / nonceStr / package / signType / paySign之后调起方式有两种直接WeixinJSBridge.invoke或者引入 JSSDK 用wx.chooseWXPay。前者不需要额外引入脚本后者在部分老版本客户端上更稳但多一次配置注入。Demo 里默认走WeixinJSBridge因为它对 ASP.NET 这种服务端渲染的页面更友好——不需要额外的签名注入流程页面加载完就能用。// 前端只做一件事把服务端二次签名后的参数交给微信绝不在这里算签名 function invokePay(p) { WeixinJSBridge.invoke(getBrandWCPayRequest, { appId: p.appId, timeStamp: p.timeStamp, // 字符串别传数字 nonceStr: p.nonceStr, package: p.package, // 固定格式prepay_idxxx signType: p.signType, // APIv3 是 RSA不是 MD5 paySign: p.paySign }, function (res) { // 这里只做 UI 提示不作为发货依据 if (res.err_msg get_brand_wcpay_request:ok) { location.href /pay/result?outTradeNo encodeURIComponent(p.outTradeNo); } else if (res.err_msg get_brand_wcpay_request:cancel) { // 用户主动取消订单仍是待支付可以引导重新支付 } }); } // 微信内部分老版本需要等桥接对象就绪 if (typeof WeixinJSBridge undefined) { document.addEventListener(WeixinJSBridgeReady, () invokePay(params), false); } else { invokePay(params); }package字段是固定格式prepay_idxxx不是只传 prepay_id 本身这个格式错了会直接提示「参数错误」。signType在 APIv3 下固定是RSA很多从 v2 迁移过来的代码还留着MD5前端就会报「支付验证签名失败」。二次签名的签名串是四行appId\ntimeStamp\nnonceStr\npackage\n同样要带结尾换行用商户私钥签。它和下单请求的签名串不是一回事不能复用。4. 回调才是主战场验签、AES-256-GCM 解密与幂等落库下单成功只是开始回调才是决定这笔钱有没有真正入账的地方。这一章按「读报文 → 验签 → 解密 → 核对 → 落库 → 应答」的顺序拆每一步都给出失败时的判断依据。4.1 验签顺序先验签再解密反过来就是安全漏洞回调请求带四个头Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial。验签串是三行时间戳\n随机串\n原始报文\n用平台证书或平台公钥验注意不是商户私钥。这里有一个必须强调的点读原始报文。如果你在 ASP.NET 里先[FromBody]反序列化再序列化回去字段顺序、空格、转义都会变验签必然失败。正确做法是直接读Request.Body的字符串。[HttpPost(/api/pay/notify)] public async TaskIActionResult Notify() { // 必须读原始字符串不能先绑定模型再序列化 using var reader new StreamReader(Request.Body, Encoding.UTF8); var body await reader.ReadToEndAsync(); 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(); // 1. 验签三段拼串用平台证书/平台公钥验 var message ${timestamp}\n{nonce}\n{body}\n; if (!_signer.Verify(message, signature, serial)) return StatusCode(401); // 验签失败直接拒绝不要继续解密 // 2. 验签通过后再处理 resource见下一节 return await HandleResourceAsync(body); }验签失败的可能原因有三个平台证书过期或轮换了、拼串时用了格式化后的 JSON、系统时间偏差过大。前两个改代码或重新拉证书即可第三个在容器环境里很常见——容器时间没同步时间戳差几分钟签名就校验不过。顺序上还有一个安全考量先验签能挡掉伪造请求。如果先解密再验签攻击者可以用构造的密文触发解密逻辑虽然 AES-GCM 本身有认证标签但把不必要的计算暴露在未认证的输入上不是好习惯。4.2 AES-256-GCM 解密与金额核对验签通过后报文里的resource是一段加密数据字段包括algorithm、ciphertext、nonce、associated_data。解密密钥就是那个 32 位的 APIv3 密钥算法是 AEAD_AES_256_GCM密文末尾 16 字节是认证标签。// AES-256-GCM 解密末尾 16 字节是 tag前面是密文 public static string DecryptGcm(string ciphertextBase64, string nonce, string associatedData, string apiV3Key) { var raw Convert.FromBase64String(ciphertextBase64); var tag raw[^16..]; // 认证标签 var cipher raw[..^16]; // 真正的密文 // .NET 6 自带 AesGcm.NET Framework 需要引入 BouncyCastle 等等价实现 using var aes new AesGcm(Encoding.UTF8.GetBytes(apiV3Key)); var plain new byte[cipher.Length]; aes.Decrypt(Encoding.UTF8.GetBytes(nonce), cipher, tag, plain, Encoding.UTF8.GetBytes(associatedData)); return Encoding.UTF8.GetString(plain); }解密出来的 JSON 里有out_trade_no、transaction_id、trade_state、amount.total、payer.openid等字段。这里要做三件核对缺一不可trade_state必须是SUCCESS其他状态比如NOTPAY、CLOSED不能当成功处理。amount.total必须和你库里这笔订单的应付金额完全一致。金额对不上说明订单被篡改或串单必须走人工核查绝对不能直接落库。mchid和appid必须与配置一致防止串商户。金额这一项是最容易被跳过的但它恰恰是资损的最后一道闸门。我见过一个案例测试环境改了订单金额做压测代码里没做核对结果上线后一笔被改价的订单直接按低价发货。4.3 幂等同一笔订单收到三次通知只能发一次货微信侧的回调是「至少一次」语义同一笔订单收到多次通知是正常现象。而且它重试的触发条件很宽你返回非 2xx、处理超时、连接被重置都会触发下一次重试间隔递增并持续较长时间。幂等的实现有两种常见做法Demo 里用的是数据库条件更新因为最简单也最可靠-- 只有从「未支付」改到「已支付」才算这次处理有效 UPDATE pay_order SET status PAID, transaction_id txid, paid_at now WHERE out_trade_no outTradeNo AND status UNPAID; -- 受影响行数 0说明已经处理过直接返回 2xx不要重复发货配合out_trade_no上的唯一索引和transaction_id上的唯一索引即使两个回调同时到达也只有一个能更新成功。另一个受影响行数为 0直接返回成功即可。还有一个细节回调处理要快。你的业务逻辑发货、发消息、写流水如果超过几秒微信那边可能已经超时并开始重试于是同一笔订单被并发处理两次。稳妥的做法是「回调里只做状态流转和落库重活丢进队列异步做」。应答也有讲究处理成功返回 200 或 204 即可body 不要塞业务数据处理失败不要返回 200 装作成功否则微信不再重试这笔订单就永远卡在中间状态。真正需要「我先收下稍后处理」的场景也应该先落库再返回成功而不是先返回成功再去落库。5. 避坑清单签名、金额、回调五类翻车现场与排查顺序这一章是我自己踩过、也在别人代码里见过的五类问题。每一条都按「现象 → 原因 → 解决」写方便你直接对照日志排查。5.1 签名与证书类的三个坑坑一401 签名错误密钥换了三遍还是错现象下单接口稳定返回 401错误码提示签名错误把私钥重新导出、序列号重新抄了一遍依旧报错。原因90% 是签名串拼接问题。最常见的是结尾少了换行符其次是 URL 部分带 query 时编码方式不一致再次是时间戳用了毫秒。少数情况是serial_no填成了 APIv3 密钥或者证书内容。解决把签名串原样打到日志里生产环境记得脱敏逐字符对比。我的习惯是先把签名串写成一个可断点的字符串变量肉眼确认五个部分和四个换行都在再去怀疑密钥。时间戳统一用秒服务器开 NTP 同步。坑二回调验签一直失败但下单是好的现象下单、支付、前端都正常回调进来验签必失败日志里没有任何异常。原因一是用了商户私钥验签回调要用平台证书或平台公钥二是把请求体先反序列化再序列化格式变了三是平台证书轮换了本地还是旧证书。解决确认验签用的密钥来源确认读的是原始 body然后加一个「证书序列号不匹配时自动重新拉取平台证书」的逻辑。这个自动刷新一定要有否则证书一轮换你的回调就会集体失联。坑三本地跑得好好的一上容器就全挂现象开发机正常部署到容器后所有请求 401重启偶尔好一次。原因容器基础镜像里没有可信根证书HTTPS 请求本身失败或者容器时间没同步时间戳偏差超过限制。解决基础镜像装好 CA 证书包容器启动时校验系统时间。这两条在支付场景里是硬性要求不是「有空再说」的优化项。5.2 业务与回调类的两个坑坑四用户说付了钱订单还是待支付现象用户拿着扣款记录来找后台订单状态没变日志里没有任何回调记录。原因notify_url配置有问题。常见的有用了 http、带了 query 参数、指向内网地址、被网关拦了 POST、被 WAF 挡了、路由大小写不一致导致 404。解决先用外网工具直接 POST 一下你的回调地址确认能通再确认网关和 WAF 放行了这个路径最后检查 ASP.NET 的路由和中间件顺序别让鉴权中间件把回调请求拦在门外。回调接口不应该要求登录态。坑五金额差 100 倍或者同一订单发了两次货现象对账时发现收款金额和订单金额对不上或者用户收到两份权益。原因金额单位混用元 vs 分以及回调没做幂等重试时重复执行业务。解决在方法签名层面统一用「分」命名上带Fen后缀落库用条件更新加唯一索引把「发货」这个动作做成幂等操作用一个独立的发货流水表记录重复请求直接短路返回。这两条做完基本可以杜绝资损类事故。6. 上线前的最后一公里用自建 mock 和小额真实单验边界Demo 跑通不等于能上线。我在上线前一定会做两轮验证一轮是自建的 mock 网关一轮是真实小额下单。mock 网关的作用是把「回调」这条最难复现的路径变成可控输入起一个本地 HTTP 服务按微信的回调报文格式构造请求头部带齐四个字段body 用 APIv3 密钥加密。这样可以批量验证验签失败、时间戳过期、金额不一致、重复通知这四种边界而不用真的去付款。注意 mock 只能验证你自己的处理逻辑验签用的证书得用真实平台证书否则验签这关永远过不了。第二轮是小额真实下单金额设成最低档重点看四件事验证项观察点通过标准回调可达服务端日志是否收到 POST5 秒内出现一条回调记录验签解密解密后的 JSON 字段是否完整out_trade_no、transaction_id、amount 齐全幂等手动重放同一报文三次订单只更新一次发货流水只有一条异常应答故意返回 500 一次微信按间隔重试后续能恢复正常还有一个容易被忽略的点把「支付成功页」和「回调」的结果对齐。用户付完钱跳回结果页时回调可能还没到这时候结果页应该显示「支付结果确认中」并轮询订单状态而不是直接显示「支付成功」。我就吃过这个亏——结果页抢在回调之前把状态改成了已支付后来发现那笔订单实际是支付失败用户白拿了一份权益。从那以后我每次接支付都会强制走一遍「mock 边界 小额真单 手动重放」这三步一步都不跳过。支付这块没有后悔药只有上线前多花的两小时。希望帮到你。本文还有配套的精品资源点击获取