
简介基于 ASP.NETC#实现的微信支付 JSAPI 接口调用示例面向需要在微信公众号或微信内置浏览器中完成网页支付的 .NET 开发者。项目采用官方支付 V3 版接口省去额外引入 JS 文件的繁琐步骤配置项集中适合初次接入微信支付、希望快速跑通下单与回调流程的中级开发者参考。压缩包共 122 个文件约 2.36MB以 44 个 cs 源码、24 个 aspx 页面、11 个 dll 依赖库和 9 个 config 配置文件为主另含少量 js 脚本、p12 证书与说明文本代码与配置分离便于按模块查阅。内容覆盖商品展示、JSAPI 下单支付、退款申请与退款查询等页面及对应后台逻辑可对照梳理统一下单参数组装、签名生成、支付结果校验等关键环节也能在替换商户号与证书后直接本地联调。目前已有 1073 人学习下载。1. 从一次「签名没错但一直报签名错误」说起「签名串拼错一个换行接口就回你一句 SIGN_ERROR日志里连个提示都没有。」这是我见过的一个 C# 小组接 JSAPI 支付时最常撞的墙。这套 ASP.NET 微信支付接口调用 Demo把公众号、小程序内调起支付的整条链路拆成了可直接运行的 C# 代码统一下单拿 prepay_id、服务端二次签名、前端调起、支付结果通知解密验签一直做到退款与查单兜底。它适合手上已经有一个 ASP.NETMVC 或 WebAPI 都行项目、需要在微信内置浏览器或小程序里收款的开发者也适合已经接了但总在签名、回调、金额单位上反复出错的人。下面按我实际拆包的顺序讲配置怎么填、参数怎么改、哪一步最容易翻车。2. 先看清链路JSAPI 支付在 ASP.NET 里被拆成哪几段2.1 从点击「立即支付」到订单变已支付中间经过五次握手很多人以为 JSAPI 支付就是「服务端下一个单前端调一个 API」实际中间有五次明确的交互任何一次的参数对不上后面的全废。第一次是前端把商品信息和用户标识送到自己的 ASP.NET 接口第二次是服务端拿 AppID、商户号、openid、金额去调统一下单换回一个 prepay_id第三次是服务端用 prepay_id 加上时间戳、随机串做二次签名把五个参数吐给前端第四次是前端在微信环境里调起收银台用户输密码第五次是微信把支付结果推到 notify_url服务端解密验签后改单。这五段里第一段和第四段在前端第二、三、五段在服务端。Demo 的价值就在于把二、三、五这三段写成了独立方法你可以只取其中一段塞进现有项目不必整包搬。我一般会先把第五段回调跑通再回头调第二段因为回调能打通说明密钥、证书、公网地址这一整套环境都没问题了剩下的只是参数拼装。反过来先调下单出了问题你分不清是签名错、密钥错还是网络不通纯属给自己找麻烦。还有一点常被忽略JSAPI 支付的 openid 必须和下单时用的 AppID 属于同一个主体。公众号网页授权拿到的 openid不能拿去给小程序的下单接口用小程序 wx.login 换的 openid 也不能给公众号用。Demo 里把 AppID 和 openid 的来源写在同一个配置对象里就是为了逼你在这件事上想清楚。2.2 Demo 的目录结构与每个文件负责哪一段拆包之后先别急着改配置按文件职责过一遍后面定位问题会快很多。这类 Demo 通常是四层结构配置层、签名工具层、接口调用层、控制器层。配置层放的是商户号、AppID、证书路径、APIv3 密钥这类东西签名工具层是一堆静态方法负责拼签名串、算 RSA 签名、验签、AES 解密接口调用层封装 HttpClient负责发请求和把响应转成对象控制器层就是对外的三个入口下单、回调、查单。我习惯先看签名工具层因为 90% 的报错都出在这儿。重点看三个方法BuildSignMessage拼串、Sign私钥签名、Verify公钥验签。如果这三个方法写对了剩下的就是填参数。另外注意 Demo 里有没有把「请求体字符串」缓存下来复用——签名的 body 和实际 POST 出去的 body 必须是同一份字节如果两次分别序列化字段顺序或空值处理稍有差别就会签名失败这是新手最容易踩的坑之一。配置文件一般是一个 appsettings 节点或者一个 static 配置类。看的时候盯住四个值MchId商户号纯数字字符串、AppId应用 IDwx 开头、SerialNo商户 API 证书序列号40 位十六进制大写、ApiV3Key32 位。这四个值任何一个对不上表现都是「签名错误」或「解密失败」而报错信息不会告诉你具体是哪个错了。2.3 商户号、AppID、APIv3 密钥、证书序列号四个必填项从哪来这四个值的来源完全不同混了就没法排查。商户号在商户平台首页能看到注意它是字符串不是整数前导零不能丢。AppID 在公众号或小程序后台的「开发-开发设置」里必须和商户号完成过绑定商户平台-产品中心-AppID 账号管理。APIv3 密钥在商户平台「账户中心-API 安全」里自己设置32 位大小写字母加数字设置之后平台不再展示只能重设——重设会让线上所有旧密钥立刻失效这事我干过一次凌晨改完整个服务重启血泪经验。证书序列号最容易搞错。商户平台上有两种序列号一个是「商户 API 证书」的序列号用在请求头的serial_no里标识你用哪把私钥签的名另一个是「微信支付平台证书」的序列号用在响应头Wechatpay-Serial里标识微信用哪把公钥签的名。请求签名用前者响应验签认后者两者搞反直接 401。现在还有一种「微信支付公钥」模式用固定的公钥 ID 替代平台证书序列号配置项少一个下载证书的步骤Demo 如果同时支持两种模式优先选公钥模式省掉证书轮换的麻烦。配置项建议不要硬编码在代码里也别直接写进 appsettings.json 提交到仓库。我一般的做法是放环境变量或者用户机密开发环境用dotnet user-secrets生产环境用配置中心代码里只读 key。私钥文件apiclient_key.pem放在 Web 根目录之外别让静态文件中间件能访问到。3. 统一下单与签名C# 侧最容易写错的两段代码3.1 请求签名串的五行拼接规则APIv3 的请求签名串是五行每行以换行符结尾最后一行后面也要有一个换行。顺序固定HTTP 方法、URL含 query string不含域名、时间戳秒级、随机串、请求体。写法上没有捷径就是字符串拼接但有几个细节决定了成败换行必须是\n而不是\r\nURL 必须和实际请求的路径完全一致多一个斜杠都算错GET 请求没有请求体但第五行仍然要保留也就是结尾是两个换行。// 构造 APIv3 请求签名串五行各带一个 \n最后一行后也要有 \n public static string BuildSignMessage(string method, string url, string timestamp, string nonce, string body) { // method 必须大写url 是 /v3/... 这种相对路径body 为 GET 时传空串 return ${method}\n{url}\n{timestamp}\n{nonce}\n{body}\n; } // 用商户 API 私钥做 SHA256withRSA 签名输出 Base64 public static string Sign(string message, string privateKeyPem) { using var rsa RSA.Create(); rsa.ImportFromPem(privateKeyPem); // .NET 5 直接吃 PEM省掉 BouncyCastle var data Encoding.UTF8.GetBytes(message); var sig rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(sig); }BuildSignMessage的五个参数里body是最危险的。如果你先序列化一次拿去签名再序列化一次拿去发送两次结果的字段顺序、空格、null 处理都可能不同签名必然失败。正确做法是序列化一次把结果存进变量签名和发送都用这一个字符串。Sign里用的是 PKCS#1 填充这是 APIv3 规定的换成 PSS 会直接报签名错误。私钥必须是 PKCS#8 格式-----BEGIN PRIVATE KEY-----如果你手上是 PKCS#1BEGIN RSA PRIVATE KEY.NET 的ImportFromPem也能读但换成别的语言或工具链就未必了统一转成 PKCS#8 更稳妥。拼好签名之后组装 Authorization 头格式是WECHATPAY2-SHA256-RSA2048加五个键值对键值对之间用逗号分隔值用双引号包起来逗号后面不能有空格。这个头拼错的表现也是 401而且错误信息同样是「签名错误」所以建议把拼好的完整头打进日志签名值可以截断出问题时一眼能看出是格式问题还是密钥问题。3.2 用 HttpClient 发起 JSAPI 下单并处理响应统一下单的路径是/v3/pay/transactions/jsapiPOSTBody 是 JSON。必填字段有六个appid、mchid、description、out_trade_no、notify_url、amount再加一个payer.openid。金额单位是分整数这一点后面还会说。// 统一下单注意 body 只序列化一次签名和发送共用同一个字符串 public async Taskstring JsapiPrepayAsync(string openid, string outTradeNo, int totalFeeFen, string description) { var payload new { appid _cfg.AppId, mchid _cfg.MchId, description description, // 商品描述用户账单里能看到别写特殊字符 out_trade_no outTradeNo, // 商户侧单号6-32 位同一商户下唯一 notify_url _cfg.NotifyUrl, // 必须是公网可访问的 https 地址 amount new { total totalFeeFen, currency CNY }, payer new { openid openid } }; var body JsonSerializer.Serialize(payload, new JsonSerializerOptions { Encoder JavaScriptEncoder.UnsafeRelaxedJsonEscaping // 中文描述不被转义成 \uXXXX }); var url /v3/pay/transactions/jsapi; var timestamp DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); var nonce Guid.NewGuid().ToString(N); // 32 位随机串别带横线 var message BuildSignMessage(POST, url, timestamp, nonce, body); var signature Sign(message, _cfg.PrivateKeyPem); using var req new HttpRequestMessage(HttpMethod.Post, https://api.mch.weixin.qq.com url); req.Content new StringContent(body, Encoding.UTF8, application/json); req.Headers.TryAddWithoutValidation(Authorization, $WECHATPAY2-SHA256-RSA2048 mchid\{_cfg.MchId}\,nonce_str\{nonce}\, $signature\{signature}\,timestamp\{timestamp}\,serial_no\{_cfg.SerialNo}\); req.Headers.TryAddWithoutValidation(Accept, application/json); var resp await _http.SendAsync(req); var respBody await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new Exception($下单失败 {(int)resp.StatusCode}: {respBody}); using var doc JsonDocument.Parse(respBody); return doc.RootElement.GetProperty(prepay_id).GetString()!; }这段代码里有三个参数值得单独说。out_trade_no我一般用「业务前缀 日期 自增序列」长度控制在 32 位以内别用纯时间戳高并发下会撞。notify_url必须是 https且不能带自定义端口用 443路径里可以带参数但没必要因为回调报文里已经有完整的商户单号。description会显示在用户的微信账单里超过一定长度会被截断别放用户昵称这类可能含特殊字符的内容。另外建议给 HttpClient 配一个超时比如 10 秒和至少一次重试但重试要小心下单接口不是幂等的同一个out_trade_no重复提交微信会返回「订单已存在」而不是报错这时应该走查单接口拿原来的 prepay_id而不是换个单号重新下。这个逻辑 Demo 里一般没写得自己补。3.3 前端调起参数 paySign 的二次签名拿到 prepay_id 之后还没完前端调起还需要五个参数appId、timeStamp、nonceStr、package、signType以及第六个paySign。package的格式固定是prepay_id加上那串 IDsignType固定传RSA注意是老版的MD5就错了。签名串又是四行每行一个\n包括最后一行appId、timeStamp、nonceStr、package。注意这里的appId和timeStamp必须和返回给前端的值完全一致前端如果把时间戳转成了数字再传字符串形态变了就验不过。// 二次签名给前端调起收银台用签名串四行最后一行后也要有 \n public object BuildPayParams(string prepayId) { var appId _cfg.AppId; var timeStamp DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); var nonceStr Guid.NewGuid().ToString(N); var package $prepay_id{prepayId}; var message ${appId}\n{timeStamp}\n{nonceStr}\n{package}\n; var paySign Sign(message, _cfg.PrivateKeyPem); return new { appId, timeStamp, nonceStr, package, signType RSA, paySign }; }前端侧公众号 H5 用WeixinJSBridge.invoke(getBrandWCPayRequest, params, callback)小程序用wx.requestPayment(params)参数名基本一致小程序的appId可以省略SDK 会自己补。回调里判断成功不要看err_msg里的字符串公众号看err_msg get_brand_wcpay_request:ok小程序看res.errMsg requestPayment:ok。但这只是前端的「用户点了确认」真正算数的还是服务端的回调通知前端回调只能用来做「支付中」的界面提示不能用来发货。4. 支付结果通知把「已支付」这个状态做实4.1 回调报文的验签与 AES-256-GCM 解密回调进来的是一个 POSTBody 是 JSON里面有id、event_type、resource三块。resource里有algorithm、ciphertext、associated_data、nonce和original_type。要拿到订单信息得先用 APIv3 密钥做 AES-256-GCM 解密。密钥就是配置里那个 32 位字符串UTF-8 编码后正好 32 字节不用再做哈希。验签是第一步不能跳。请求头里有Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial四个值验签串是「时间戳 \n 随机串 \n 原始 Body \n」用平台证书或微信支付公钥验。原始 Body 必须是未经过任何反序列化再序列化的字节流所以在 ASP.NET 里读 Body 要用EnableBuffering()加StreamReader读完记得把 Position 归零。// 回调处理先验签再解密最后落库三步顺序不能换 [HttpPost(/pay/notify)] public async TaskIActionResult Notify() { Request.EnableBuffering(); using var reader new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true); var body await reader.ReadToEndAsync(); Request.Body.Position 0; var ts Request.Headers[Wechatpay-Timestamp].ToString(); var nonce Request.Headers[Wechatpay-Nonce].ToString(); var sig Request.Headers[Wechatpay-Signature].ToString(); // 验签串时间戳\n随机串\n报文主体\n用平台证书公钥验 if (!Verify(${ts}\n{nonce}\n{body}\n, sig, _cfg.PlatformCertPem)) return BadRequest(); // 验签不过直接拒别往下走 using var doc JsonDocument.Parse(body); var res doc.RootElement.GetProperty(resource); var plain AesGcmDecrypt( _cfg.ApiV3Key, // 32 字节 res.GetProperty(nonce).GetString()!, res.GetProperty(associated_data).GetString()!, res.GetProperty(ciphertext).GetString()!); var order JsonSerializer.DeserializeNotifyOrder(plain)!; // 校验金额、商户号再走幂等落库 return Ok(new { code SUCCESS, message 成功 }); }AesGcmDecrypt里要注意ciphertext是 Base64解码后最后 16 字节是认证标签tag前面的才是密文两者要拆开传给AesGcm.Decrypt。.NET 6 的AesGcm构造函数只接受密钥.NET 8 起多了一个 tagSize 参数跨版本迁移时要留意。associated_data可能是空串此时传null或空数组别传一个空字符串的字节数组否则解密会失败。最后返回给微信的响应体必须是{code:SUCCESS,message:成功}这种形态HTTP 状态码 200 或 204。返回别的内容微信会认为你没收到按策略重试最多重试若干次间隔逐次拉长。这意味着你的回调处理必须幂等同一个商户单号重复进来只应该更新一次订单状态第二次直接返回成功。我一般会在更新语句上加WHERE status UNPAID这样的条件用受影响行数判断是不是第一次处理。4.2 幂等、金额校验与主动查单兜底回调不可靠这件事要有心理准备网络抖动、你服务重启、防火墙规则变更都会让通知丢掉。所以生产环境一定要有一个定时查单任务把「创建超过 5 分钟还没支付」的订单捞出来调查单接口确认状态。查单是 GET路径是/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid{mchid}返回体里trade_state为SUCCESS就是已支付。金额校验是另一道保险。回调解密出来的amount.total必须和你数据库里的订单金额完全相等单位都是分。不相等就别发货先挂起人工核查。这一步看起来多余但它能挡住「用户改价」「重复下单」这类问题。同理mchid和appid也要比对防止串单。幂等的实现方式有两种一种是在订单表上加唯一约束靠数据库报错来兜另一种是先SELECT ... FOR UPDATE再判断状态。前者简单粗暴后者更可控。不管哪种都要保证「更新订单状态」和「发货/加余额」在同一个事务里否则会出现订单已支付但没发货、或者重复发货的情况。这块是业务逻辑Demo 里通常只给个空方法得自己按业务补。4.3 退款接口与退款回调的差异点退款是另一个接口POST /v3/refund/domestic/refundsBody 里要有out_trade_no或transaction_id、out_refund_no商户退款单号自己生成唯一、reason、notify_url以及amount里的refund退款金额分、total原订单金额分、currency。注意total是必填的很多人只填refund会报参数错误。退款也有回调但它的结构和支付回调不一样event_type是REFUND.SUCCESS之类的值解密出来的报文里是退款单信息而不是订单信息字段名是out_refund_no、refund_id、refund_status。如果你把支付回调和退款回调复用同一个解密方法记得在业务分支里按event_type分流别一股脑当成支付成功处理。退款是异步的接口返回status: PROCESSING不代表钱已经退了最终结果以退款回调或退款查单为准。退款查单是GET /v3/refund/domestic/refunds/{out_refund_no}。另外退款金额不能超过原订单金额部分退款可以多次但累计不能超。这些边界条件在测试环境一定要跑一遍尤其是「退过一次之后再退第二次」这种场景。5. 排查清单JSAPI 支付上线前最容易翻车的五个地方5.1 签名与调起类问题现象一接口返回 401报文里写「签名错误」但你把签名串打印出来逐字符比对也看不出问题。原因通常有三个换行符用了\r\nWindows 下用字符串拼接很容易带进去、请求体序列化了两次导致签名的 body 和发送的 body 不一致、私钥格式不对PKCS#1 当 PKCS#8 用或者中间多了空格、少了结尾换行。解决方式是把签名串用Convert.ToBase64String(Encoding.UTF8.GetBytes(message))打出来对照肉眼看不出来就打印每个字符的 ASCII 码第 13 号字符\r出现就是换行问题请求体只序列化一次存变量私钥用openssl pkcs8 -topk8 -inform PEM -in old.pem -outform PEM -nocrypt统一转一遍。现象二前端调起收银台弹窗一闪而过提示「支付验证签名失败」。这是二次签名的签名串拼错了不是请求签名。常见错法是package值写成了裸的 prepay_id 而漏了prepay_id前缀或者签名串最后一行没加\n或者signType传了MD5、HMAC-SHA256。解决方式是把四行签名串原样打日志逐行核对特别是最后那个看不见的换行。现象三前端调起成功、用户也扣款了但服务端订单一直是未支付。先看回调有没有进来。没进来就去查notify_url是不是 https、是不是公网可达用手机 4G 访问一下那个地址看能不能通、有没有被 WAF 拦掉 POST。进来了但处理失败多半是解密报错associated_data传了空字符串的字节数组而不是 null、APIv3 密钥长度不是 32 字节、或者ciphertext没有把最后 16 字节的 tag 拆出来。解决方式是把解密异常单独 catch 出来打日志别和业务异常混在一起。5.2 环境与参数类问题现象四金额差了 100 倍1 元的商品扣了 100 元或者 1 分。amount.total的单位是分不是元。数据库里如果存的是decimal元传之前必须(int)(amount * 100)并且四舍五入别直接强转浮点误差会让你少收一分钱。反过来回调回来的是分落库时要除 100 转回元用decimal别用double。现象五报「appid 和 mchid 不匹配」或「openid 无效」。AppID 和商户号没有在商户平台完成绑定或者绑定的是另一个 AppIDopenid 的来源 AppID 和下单用的 AppID 不是同一个。解决方式是在商户平台「产品中心-AppID 账号管理」里确认绑定关系然后在代码里把 openid 和 AppID 的对应关系做成显式传参别用全局配置兜底兜底最容易串。提示这五类问题的报错信息高度相似都可能是「签名错误」四个字。建议在签名工具层就把方法、URL、时间戳、随机串、body、签名结果打进一条结构化日志出问题时按这条日志复现比盲猜快十倍。6. 上线前的沙箱演练一套自检流程和三条经验真正让我少熬几个通宵的不是某个巧妙的代码而是一张上线前必须逐项打勾的自检表。每次接入新的商户号或新环境我都按这张表从下往上过一遍顺序不能反——从回调往上验比从下单往下试效率高得多。自检项怎么验不通过的表现私钥与序列号匹配用私钥签一段固定字符串用商户平台下载的证书公钥验签401 签名错误APIv3 密钥长度Encoding.UTF8.GetByteCount(key) 32回调解密抛异常notify_url 可达手机 4G 网络直接 POST 一次看服务端日志回调永远收不到回调验签用沙箱环境的真实回调报文跑一遍打印验签结果验签失败但解密成功金额单位下单 1 分钱看收银台显示显示 1 元或 0.01 分幂等同一个回调报文连续 POST 三次订单被重复发货沙箱环境的好处是可以随便造数据坏处是它和生产的证书、密钥不是一套所以千万不要把沙箱的配置带到生产也别在生产上跑「退 1 分钱」这种测试单。我一般的做法是开发环境接沙箱预发布环境接生产但用一个额度极小的专用商户号生产环境只做配置切换代码一行不改。还有三条是踩出来的。第一签名工具类写成静态无状态方法别在里面读配置对象配置从外面传进去这样单元测试能直接构造用例不用起整个 Web 主机。第二所有对外请求都过一层自己的封装统一加超时、统一打日志、统一把异常转成业务错误码别让HttpRequestException直接冒到控制器。第三回调接口单独开一个路由别和业务接口共用鉴权中间件微信的请求带不了你的 Token被中间件拦掉的表现就是「回调一直重试」。从那以后我每次接新的支付通道都会先把「回调能不能通」这一项单独拎出来做用一条真实的 1 分钱订单从支付跑到退款全链路走完再回头写业务代码。这个习惯救过我不止一次——有一次是防火墙只放行了 80 端口有一次是反向代理把 POST body 吞了。希望帮到你。本文还有配套的精品资源点击获取