接手过几个企业微信自建应用的项目,每次新人对接时几乎都会卡在同一个地方:管理后台保存“接收消息服务器”配置时提示URL验证失败,或者明明验证过了,消息却一直推送不进来。这个环节看着只是填几个参数,背后实际上是一条完整的加解密链路。我这次用.NET把整条链路拆开来讲,从域名验证的原理、AES加解密的细节,到消息接收、业务分发、被动回复的时序问题,一次性说清楚。适合正在做企业微信应用接入的.NET开发者,也适合被回调验证折磨过的朋友对照排查。1. 搞清企业微信应用接入的完整链路后再动手1.1 创建自建应用需要准备的三件套在企业微信管理后台创建一个自建应用后,你会拿到三个关键参数,很多人一开始分不清它们各自的用途:CorpId(企业ID):当前企业的唯一标识,回调解密出来的明文字段尾部会携带它,用来确认消息确实是发给本企业的。AgentId(应用ID):自建应用的唯一编号,业务上用来区分消息来自哪个应用。Secret(应用密钥):调用企业微信服务端API获取access_token时使用,注意它不参与消息回调的加解密。也就是说,回调验证和消息解密只依赖Token和EncodingAESKey,Secret是另一条线。我在项目中见过有人把Secret也填进加解密逻辑里,结果越搞越乱。1.2 两种“域名验证”要分清楚标题里说的“域名验证”在企业微信里其实对应两种不同的操作:接收消息服务器的回调URL验证:这是配置“接收消息服务器”时触发的验证。企业微信向你的URL发起GET请求,带msg_signature、timestamp、nonce、echostr四个参数,你需要验证签名、解密echostr、再原样返回明文。本文重点讲的就是这个。网页应用的可信域名校验:如果你的应用要使用OAuth网页授权或JS-SDK,需要在应用详情里配置可信域名,然后下载一个校验文件放到域名根目录,保证通过https://yourdomain/文件名.txt能访问到。这属于域名归属验证,和回调加解密没有关系。把这两件事分开,后面看文档就不会绕晕。1.3 从保存配置到收到第一条消息的完整时序我先把整条链路在脑子里过一遍,再往下看代码会清晰很多:在管理后台配置接收消息服务器,填上公网可访问的URL、自定义Token、后台生成的EncodingAESKey。点击保存,企业微信立即向URL发起GET验证请求。你的服务校验签名、解密echostr,把解密后的明文内容作为响应返回。验证通过,配置保存成功。用户给应用发消息或触发事件,企业微信向同一URL发起POST请求,请求体是包含<Encrypt>节点的XML。你读取<Encrypt>内容,验签、解密得到明文XML。处理业务,需要回复时构造明文回复XML,加密后包一层响应XML返回;不需要回复时直接返回字符串success。看到没有,GET和POST走的是同一个URL,但一个是验证、一个是收消息。很多坑都出在把这两个入口混在一起处理。2. 回调URL验证(GET请求):先验签名再解密EchoStr2.1 验证请求的构造方式和安全设计企业微信发起的GET验证请求,参数是这样的:参数名含义msg_signature签名串,基于token、timestamp、nonce、echostr计算timestamp时间戳,秒级nonce随机串echostr加密的随机字符串,需要解密后原样返回为什么企业微信要用“验签+解密”两步来做验证?我的理解是这样的:单纯验证URL可达并没有意义,它要确认三件事。第一,你确实持有Token,这样能算出正确的签名;第二,你确实持有EncodingAESKey,因为echostr是加密的,只有解密成功并返回正确明文才算真正掌控了这把钥匙;第三,URL背后确实是你的服务,而不是随便一个能响应请求的静态页面。这里的签名参数名是msg_signature,不是微信公众平台那套的signature,容易踩坑的就是拿老代码直接改。2.2 签名校验的算法细节与代码签名规则并不复杂:把token、timestamp、nonce、echostr四个字符串按字典序排序,拼接成一个字符串,做SHA1哈希,结果转小写十六进制字符串,与msg_signature比对。需要注意“字典序”三个字,C#里用StringComparer.Ordinal来排序,而不是默认的CurrentCulture,以免某些环境下排序结果不一致。using System.Security.Cryptography; using System.Text; public static class WeComSign { public static string BuildSignature(string token, string timestamp, string nonce, string encrypt) { var array = new[] { token, timestamp, nonce, encrypt }; Array.Sort(array, StringComparer.Ordinal); var raw = string.Concat(array); var hash = SHA1.HashData(Encoding.UTF8.GetBytes(raw)); return Convert.ToHexString(hash).ToLowerInvariant(); } public static bool VerifySignature(string msgSignature, string token, string timestamp, string nonce, string encrypt) { if (string.IsNullOrEmpty(msgSignature) || string.IsNullOrEmpty(encrypt)) { return false; } var signature = BuildSignature(token, timestamp, nonce, encrypt); return string.Equals(signature, msgSignature, StringComparison.OrdinalIgnoreCase); } }SHA1.HashData是.NET 5及以上才有的静态方法,如果你的项目还在.NET Framework,那就手动创建SHA1.Create()再调用ComputeHash。Convert.ToHexString同理,也可以换成BitConverter.ToString(hash).Replace("-", "").ToLower()。2.3 解密EchoStr的返回要求验签通过后,需要解密echostr。注意解密结果的结构是:16字节随机字符串 + 4字节消息长度(网络字节序) + 真正的EchoStr明文 + 企业CorpId你要返回的是中间的“真正的EchoStr明文”,不是把整个解密结果返回,也不是把echostr做Base64解码就返回。我见过一个失败案例,对方直接把Convert.FromBase64String(echostr)转字符串返回,后台一直提示URL验证失败,本质上就是没搞懂这层封装。下面是一个完整的GET验证处理代码,直接放在ASP.NET Core控制器里即可:[ApiController] [Route("wecom")] public class WeComCallbackController : ControllerBase { private readonly ILogger<WeComCallbackController> _logger; // 配置项:与管理后台保持一致 private const string Token = "your-token"; private const string EncodingAesKey = "your-encoding-aes-key"; private const string CorpId = "your-corp-id"; public WeComCallbackController(ILogger<WeComCallbackController> logger) { _logger = logger; } [HttpGet("callback")] public IActionResult Verify( [FromQuery] string msg_signature, [FromQuery] string timestamp, [FromQuery] string nonce, [FromQuery] string echostr) { if (!WeComSign.VerifySignature(msg_signature, Token, timestamp, nonce, echostr)) { _logger.LogWarning("URL验证签名失败"); return Content("signature fail", "text/plain"); } try { var (echo, corpId) = WeComCrypto.Decrypt(echostr, EncodingAesKey); if (corpId != CorpId) { _logger.LogWarning("URL验证时CorpId不匹配,解密得到: {CorpId}", corpId); return Content("corpid fail", "text/plain"); } return Content(echo, "text/plain"); } catch (Exception ex) { _logger.LogError(ex, "echostr解密失败"); return Content("decrypt fail", "text/plain"); } } }有一点要特别提醒:响应的实体就是纯文本,不要加多余的空格、换行或JSON包装。Content(echo, "text/plain")在ASP.NET Core里默认就是UTF-8无BOM输出,符合要求。3. .NET下企业微信加解密模块的完整实现3.1 企业微信的密文包格式拆解解密方法写起来不复杂,但前提是理解企业微信的封装格式。它的设计很像一个自定义信封:原始明文 = 16字节随机字符串 + 4字节消息长度(大端序) + 消息体 + 企业CorpId然后使用EncodingAESKey做AES-256-CBC加密,具体参数是:密钥:EncodingAESKey按Base64解码后的32字节。IV:取密钥的前16字节。模式与填充:CBC模式 + PKCS7填充。输出:加密后的字节数组做Base64编码,得到字符串。这里有几个为什么值得说一下。加入16字节随机串,保证相同内容每次加密出来的密文不同,防止重放和模式分析。加入4字节消息长度,解密后能准确截取消息体。末尾拼CorpId,相当于收件人校验,防止别人的报文拿到你这里还能解出“看似合理”的内容。3.2 解密方法:从Base64到明文XMLusing System.Security.Cryptography; using System.Text; public static class WeComCrypto { public static (string Msg, string CorpId) Decrypt(string encrypt, string encodingAesKey) { if (string.IsNullOrEmpty(encrypt)) { throw new ArgumentException("密文不能为空", nameof(encrypt)); } var key = Convert.FromBase64String(encodingAesKey); if (key.Length != 32) { throw new CryptographicException("EncodingAESKey解码后长度必须为32字节"); } var iv = new byte[16]; Array.Copy(key, 0, iv, 0, 16); using var aes = Aes.Create(); aes.Key = key; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; byte[] plainBytes; using (var input = new MemoryStream(Convert.FromBase64String(encrypt))) using (var decryptor = aes.CreateDecryptor()) using (var cryptoStream = new CryptoStream(input, decryptor, CryptoStreamMode.Read)) using (var output = new MemoryStream()) { cryptoStream.CopyTo(output); plainBytes = output.ToArray(); } // 前16字节是随机串,这里不校验 var msgLen = (plainBytes[16] << 24) | (plainBytes[17] << 16) | (plainBytes[18] << 8) | plainBytes[19]; if (msgLen <= 0 || 20 + msgLen > plainBytes.Length) { throw new CryptographicException("消息长度字段非法"); } var msg = Encoding.UTF8.GetString(plainBytes, 20, msgLen); var corpId = Encoding.UTF8.GetString(plainBytes, 20 + msgLen, plainBytes.Length - 20 - msgLen); return (msg, corpId); } }长度字段我特意用手动移位的方式读取,没有用BitConverter,因为企业微信这里明确是网络字节序(大端),而BitConverter在常见平台上默认是小端,直接转会出错。手动移位在任何平台都稳。解密之后一定要判断CorpId是否等于自己的企业ID。这个校验能挡住不少“密文能解开但内容不属于当前企业”的脏数据。3.3 加密方法:构造被动回复密文被动回复的加密过程正好反过来:构造好明文回复XML后,按同样的格式拼接、AES加密、Base64输出。public static string Encrypt(string msg, string encodingAesKey, string corpId) { if (string.IsNullOrEmpty(msg)) { throw new ArgumentException("消息不能为空", nameof(msg)); } var key = Convert.FromBase64String(encodingAesKey); if (key.Length != 32) { throw new CryptographicException("EncodingAESKey解码后长度必须为32字节"); } var iv = new byte[16]; Array.Copy(key, 0, iv, 0, 16); var random = RandomNumberGenerator.GetBytes(16); var msgBytes = Encoding.UTF8.GetBytes(msg); var lenBytes = new byte[4]; lenBytes[0] = (byte)((msgBytes.Length >> 24) & 0xFF); lenBytes[1] = (byte)((msgBytes.Length >> 16) & 0xFF); lenBytes[2] = (byte)((msgBytes.Length >> 8) & 0xFF); lenBytes[3] = (byte)(msgBytes.Length & 0xFF); var corpIdBytes = Encoding.UTF8.GetBytes(corpId); var plainBytes = new byte[16 + 4 + msgBytes.Length + corpIdBytes.Length]; Buffer.BlockCopy(random, 0, plainBytes, 0, 16); Buffer.BlockCopy(lenBytes, 0, plainBytes, 16, 4); Buffer.BlockCopy(msgBytes, 0, plainBytes, 20, msgBytes.Length); Buffer.BlockCopy(corpIdBytes, 0, plainBytes, 20 + msgBytes.Length, corpIdBytes.Length); using var aes = Aes.Create(); aes.Key = key; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; using var encryptor = aes.CreateEncryptor(); var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); return Convert.ToBase64String(cipherBytes); }随机串用RandomNumberGenerator.GetBytes(16)生成,这是加密安全的随机数来源,不要用Guid或者Random凑数。Random这类伪随机源在加密场景里属于不安全用法。4. 消息处理链路:验签、解包、识别、被动回复4.1 POST消息入口的验签与解包用户发消息或触发事件后,企业微信会向同一个URL发起POST请求。请求体是一个XML,核心内容在<Encrypt>节点里:<xml> <ToUserName><![CDATA[corpid]]></ToUserName> <AgentID>1000002</AgentID> <Encrypt><![CDATA[密文BASE64]]></Encrypt> </xml>URL上的参数依然有msg_signature、timestamp、nonce,签名计算时参与的四个字符串是token、timestamp、nonce、XML里的Encrypt内容。我给出一个完整的处理入口:[HttpPost("callback")] public async Task<IActionResult> Receive( [FromQuery] string msg_signature, [FromQuery] string timestamp, [FromQuery] string nonce) { string body; using (var reader = new StreamReader(Request.Body, Encoding.UTF8)) { body = await reader.ReadToEndAsync(); } var doc = XDocument.Parse(body); var encrypt = doc.Root?.Element("Encrypt")?.Value; if (string.IsNullOrEmpty(encrypt) || !WeComSign.VerifySignature(msg_signature, Token, timestamp, nonce, encrypt)) { _logger.LogWarning("POST消息验签失败"); return Content("signature fail", "text/plain"); } string plainXml; try { var (msg, corpId) = WeComCrypto.Decrypt(encrypt, EncodingAesKey); if (corpId != CorpId) { _logger.LogWarning("消息CorpId不匹配"); return Content("corpid fail", "text/plain"); } plainXml = msg; } catch (Exception ex) { _logger.LogError(ex, "消息解密失败"); return Content("decrypt fail", "text/plain"); } _logger.LogInformation("收到明文消息: {PlainXml}", plainXml); var replyXml = ProcessMessage(plainXml); if (string.IsNullOrEmpty(replyXml)) { return Content("success", "text/plain"); } var encryptedReply = WeComCrypto.Encrypt(replyXml, EncodingAesKey, CorpId); var timeStamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); var nonceOut = Guid.NewGuid().ToString("N"); var responseXml = BuildEncryptedResponse(encryptedReply, timeStamp, nonceOut); return Content(responseXml, "text/xml"); } private static string BuildEncryptedResponse(string encrypt, string timeStamp, string nonce) { var signature = WeComSign.BuildSignature(Token, timeStamp, nonce, encrypt); return $@"<xml> <Encrypt><![CDATA[{encrypt}]]></Encrypt> <MsgSignature><![CDATA[{signature}]]></MsgSignature> <TimeStamp>{timeStamp}</TimeStamp> <Nonce><![CDATA[{nonce}]]></Nonce> </xml>"; }这个入口的ProcessMessage返回的是明文回复XML,如果不回复则返回null。响应加密XML里的TimeStamp和Nonce可以自己生成,但签名必须用同一组值来计算。4.2 明文XML的消息类型与分发策略解密后的明文XML,结构是企业微信定义好的。下面是我在项目里见过的主要类型:MsgType含义关键字段text文本消息Contentimage图片消息PicUrl, MediaIdvoice语音消息MediaId, Format, Recognitionvideo视频消息MediaId, ThumbMediaIdlocation位置消息Location_X, Location_Y, Scale, Labellink链接消息Title, Description, Urlevent事件消息Event, EventKey事件消息里比较常见的是:subscribe(用户关注)、enter_agent(用户进入应用)、click(点击应用菜单)、location(上报地理位置)。事件消息一般不需要回复,直接返回success即可。业务分发我建议用清晰的switch结构,避免一堆if嵌套:private string? ProcessMessage(string plainXml) { var root = XDocument.Parse(plainXml).Root; var msgType = root.Element("MsgType")?.Value; var fromUser = root.Element("FromUserName")?.Value; var toUser = root.Element("ToUserName")?.Value; switch (msgType) { case "text": var content = root.Element("Content")?.Value; _logger.LogInformation("收到文本消息: {Content}", content); return BuildTextReply(fromUser, toUser, $"你发送的是:{content}"); case "event": var eventName = root.Element("Event")?.Value; var eventKey = root.Element("EventKey")?.Value; _logger.LogInformation("收到事件: {Event}, Key: {EventKey}", eventName, eventKey); if (eventName == "enter_agent") { return BuildTextReply(fromUser, toUser, "欢迎进入应用"); } return null; default: _logger.LogInformation("未处理的消息类型: {MsgType}", msgType); return null; } } private static string BuildTextReply(string fromUser, string toUser, string content) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); return $@"<xml> <ToUserName><![CDATA[{fromUser}]]></ToUserName> <FromUserName><![CDATA[{toUser}]]></FromUserName> <CreateTime>{timestamp}</CreateTime> <MsgType><![CDATA[text]]></MsgType> <Content><![CDATA[{content}]]></Content> </xml>"; }注意被动回复明文XML里的ToUserName要填发送者,FromUserName要填企业的CorpId,和接收时正好相反。很多人第一次写这里会把两个节点填反,结果消息发不出去。4.3 被动回复的时序要求与success策略被动回复有一个硬性限制:必须在5秒内响应。这里说的响应,是指你的服务向企业微信服务器返回完整内容,而不是业务处理完成。如果你的业务逻辑比较重,比如要查数据库、调三方接口,5秒经常不够。这时候有两个选择:先返回success,让企业微信认为处理结束,然后自己另起一个后台任务继续处理,处理完通过“发送应用消息”的API主动推给用户。想办法优化处理链路,保证5秒内能构造出回复。我个人的经验是:能用success就用success,被动回复适合轻量场景。千万不要为了凑回复而阻塞请求线程,一旦超时,企业微信会重试多次,最终可能导致同一条消息被重复处理。谈到重试,就一定要提幂等。企业微信因超时或网络问题会对同一消息重试,普通消息里有MsgId字段,事件消息没有MsgId,只能根据FromUserName加CreateTime加Event来拼一个业务幂等键。我在项目里用数据库唯一索引做去重,入库前先查一次,简单有效。**5. 接入过程中我踩过的坑和调试建议5.1 回调验证总是失败的三个真实原因我在实际项目里排查过三种典型的验证失败,基本覆盖了90%的场景。第一种是返回内容错误。最常见的是把解密后的完整内容返回了,或者返回的内容带了换行、空格、BOM。企业微信比对的是echostr解密后的字符串,多一个字符都不行。第二种是签名参数名搞错。老项目从微信公众平台迁移过来时容易把msg_signature写成signature,或者签名计算的四个元素里混入了AgentId。签名计算的输入必须是token、timestamp、nonce、echostr(或Encrypt)这四个,一个都不能多。第三种是密钥不一致。管理后台点“随机生成”得到的EncodingAESKey,和代码里配置的不一致,或者复制时多了一个空格。Convert.FromBase64String在遇到非法字符时直接抛异常,日志里会非常明显。还有一种相对隐蔽的情况:服务部署在反向代理后面,代理把query string丢弃了或者改了URL大小写。这种会让验签永远失败,排查时除了看应用日志,还要看网关日志。5.2 消息解密报Padding异常的排查如果你遇到CryptographicException: Padding is invalid and cannot be removed,多半不是算法写错,而是数据本身有问题。我按频率排了一下:EncodingAESKey配置错了,或者后台重置过密钥但代码没同步。密文在传输中被截断,比如网关对body大小有限制,把XML截断了一半。CorpId和加密时不一致。解密出来的CorpId如果乱码,说明整个明文结构都被破坏,自然也会报Padding异常。某些框架自动修改了请求体。我遇到过请求经过某个日志中间件后,body流被读走但没有复位,导致控制器读到空body,解析不出Encrypt。排查这类问题,第一步永远是打原始报文日志。把URL参数、请求体、解密用的CorpId、EncodingAESKey长度都打出来,先确认数据完整,再去怀疑算法。还有一个小细节:Base64字符串里的+在URL参数里会被解码成空格,但在POST的XML body里不会。如果在GET验证时你在网关日志里看到密文里的+变成了空格,那只是日志显示问题,ASP.NET Core的模型绑定会自动URL解码还原,不需要手动处理。5.3 本地调试的实用手段本地调试企业微信回调,最麻烦的是公网回调地址。我建议在项目早期就用内网穿透工具把本地服务临时暴露到公网,或者直接部署到一台测试服务器,验证整个链路后再迁回正式环境。内网穿透时要注意URL路径和正式环境保持一致,避免传参差异。第二个建议是做一个原始报文记录开关。在回调入口把msg_signature、timestamp、nonce、body全文记录到日志或数据库,排查问题时可以完整还原企业微信到底发了什么。这个开关在正式环境建议默认关闭,需要排查时再打开,避免日志量过大。第三个建议是先把加解密模块单独验证,再接入业务逻辑。用企业微信后台或文档里提供的示例数据,跑一遍Encrypt和Decrypt,确保和你对应用例的结果一致。这里建议多做几组不同长度、包含中文和特殊字符的样本,比如中文、XML转义字符、Emoji,都能正常往返才算稳。还有一点务必注意:Token和EncodingAESKey属于敏感配置,不要写死在代码里,用环境变量或配置中心管理,日志里也要做脱敏。我在一个项目里见过有人为了方便把EncodingAESKey直接打在启动日志里,后来整个密钥被迫重置,这个教训挺深刻的。
接手过几个企业微信自建应用的项目,每次新人对接时几乎都会卡在同一个地方:管理后台保存“接收消息服务器”配置时提示URL验证失败,或者明明验证过了,消息却一直推送不进来。这个环节看着只是填几个参数,背后实际上是一条完整的加解密链路。我这次用.NET把整条链路拆开来讲,从域名验证的原理、AES加解密的细节,到消息接收、业务分发、被动回复的时序问题,一次性说清楚。适合正在做企业微信应用接入的.NET开发者,也适合被回调验证折磨过的朋友对照排查。1. 搞清企业微信应用接入的完整链路后再动手1.1 创建自建应用需要准备的三件套在企业微信管理后台创建一个自建应用后,你会拿到三个关键参数,很多人一开始分不清它们各自的用途:CorpId(企业ID):当前企业的唯一标识,回调解密出来的明文字段尾部会携带它,用来确认消息确实是发给本企业的。AgentId(应用ID):自建应用的唯一编号,业务上用来区分消息来自哪个应用。Secret(应用密钥):调用企业微信服务端API获取access_token时使用,注意它不参与消息回调的加解密。也就是说,回调验证和消息解密只依赖Token和EncodingAESKey,Secret是另一条线。我在项目中见过有人把Secret也填进加解密逻辑里,结果越搞越乱。1.2 两种“域名验证”要分清楚标题里说的“域名验证”在企业微信里其实对应两种不同的操作:接收消息服务器的回调URL验证:这是配置“接收消息服务器”时触发的验证。企业微信向你的URL发起GET请求,带msg_signature、timestamp、nonce、echostr四个参数,你需要验证签名、解密echostr、再原样返回明文。本文重点讲的就是这个。网页应用的可信域名校验:如果你的应用要使用OAuth网页授权或JS-SDK,需要在应用详情里配置可信域名,然后下载一个校验文件放到域名根目录,保证通过https://yourdomain/文件名.txt能访问到。这属于域名归属验证,和回调加解密没有关系。把这两件事分开,后面看文档就不会绕晕。1.3 从保存配置到收到第一条消息的完整时序我先把整条链路在脑子里过一遍,再往下看代码会清晰很多:在管理后台配置接收消息服务器,填上公网可访问的URL、自定义Token、后台生成的EncodingAESKey。点击保存,企业微信立即向URL发起GET验证请求。你的服务校验签名、解密echostr,把解密后的明文内容作为响应返回。验证通过,配置保存成功。用户给应用发消息或触发事件,企业微信向同一URL发起POST请求,请求体是包含<Encrypt>节点的XML。你读取<Encrypt>内容,验签、解密得到明文XML。处理业务,需要回复时构造明文回复XML,加密后包一层响应XML返回;不需要回复时直接返回字符串success。看到没有,GET和POST走的是同一个URL,但一个是验证、一个是收消息。很多坑都出在把这两个入口混在一起处理。2. 回调URL验证(GET请求):先验签名再解密EchoStr2.1 验证请求的构造方式和安全设计企业微信发起的GET验证请求,参数是这样的:参数名含义msg_signature签名串,基于token、timestamp、nonce、echostr计算timestamp时间戳,秒级nonce随机串echostr加密的随机字符串,需要解密后原样返回为什么企业微信要用“验签+解密”两步来做验证?我的理解是这样的:单纯验证URL可达并没有意义,它要确认三件事。第一,你确实持有Token,这样能算出正确的签名;第二,你确实持有EncodingAESKey,因为echostr是加密的,只有解密成功并返回正确明文才算真正掌控了这把钥匙;第三,URL背后确实是你的服务,而不是随便一个能响应请求的静态页面。这里的签名参数名是msg_signature,不是微信公众平台那套的signature,容易踩坑的就是拿老代码直接改。2.2 签名校验的算法细节与代码签名规则并不复杂:把token、timestamp、nonce、echostr四个字符串按字典序排序,拼接成一个字符串,做SHA1哈希,结果转小写十六进制字符串,与msg_signature比对。需要注意“字典序”三个字,C#里用StringComparer.Ordinal来排序,而不是默认的CurrentCulture,以免某些环境下排序结果不一致。using System.Security.Cryptography; using System.Text; public static class WeComSign { public static string BuildSignature(string token, string timestamp, string nonce, string encrypt) { var array = new[] { token, timestamp, nonce, encrypt }; Array.Sort(array, StringComparer.Ordinal); var raw = string.Concat(array); var hash = SHA1.HashData(Encoding.UTF8.GetBytes(raw)); return Convert.ToHexString(hash).ToLowerInvariant(); } public static bool VerifySignature(string msgSignature, string token, string timestamp, string nonce, string encrypt) { if (string.IsNullOrEmpty(msgSignature) || string.IsNullOrEmpty(encrypt)) { return false; } var signature = BuildSignature(token, timestamp, nonce, encrypt); return string.Equals(signature, msgSignature, StringComparison.OrdinalIgnoreCase); } }SHA1.HashData是.NET 5及以上才有的静态方法,如果你的项目还在.NET Framework,那就手动创建SHA1.Create()再调用ComputeHash。Convert.ToHexString同理,也可以换成BitConverter.ToString(hash).Replace("-", "").ToLower()。2.3 解密EchoStr的返回要求验签通过后,需要解密echostr。注意解密结果的结构是:16字节随机字符串 + 4字节消息长度(网络字节序) + 真正的EchoStr明文 + 企业CorpId你要返回的是中间的“真正的EchoStr明文”,不是把整个解密结果返回,也不是把echostr做Base64解码就返回。我见过一个失败案例,对方直接把Convert.FromBase64String(echostr)转字符串返回,后台一直提示URL验证失败,本质上就是没搞懂这层封装。下面是一个完整的GET验证处理代码,直接放在ASP.NET Core控制器里即可:[ApiController] [Route("wecom")] public class WeComCallbackController : ControllerBase { private readonly ILogger<WeComCallbackController> _logger; // 配置项:与管理后台保持一致 private const string Token = "your-token"; private const string EncodingAesKey = "your-encoding-aes-key"; private const string CorpId = "your-corp-id"; public WeComCallbackController(ILogger<WeComCallbackController> logger) { _logger = logger; } [HttpGet("callback")] public IActionResult Verify( [FromQuery] string msg_signature, [FromQuery] string timestamp, [FromQuery] string nonce, [FromQuery] string echostr) { if (!WeComSign.VerifySignature(msg_signature, Token, timestamp, nonce, echostr)) { _logger.LogWarning("URL验证签名失败"); return Content("signature fail", "text/plain"); } try { var (echo, corpId) = WeComCrypto.Decrypt(echostr, EncodingAesKey); if (corpId != CorpId) { _logger.LogWarning("URL验证时CorpId不匹配,解密得到: {CorpId}", corpId); return Content("corpid fail", "text/plain"); } return Content(echo, "text/plain"); } catch (Exception ex) { _logger.LogError(ex, "echostr解密失败"); return Content("decrypt fail", "text/plain"); } } }有一点要特别提醒:响应的实体就是纯文本,不要加多余的空格、换行或JSON包装。Content(echo, "text/plain")在ASP.NET Core里默认就是UTF-8无BOM输出,符合要求。3. .NET下企业微信加解密模块的完整实现3.1 企业微信的密文包格式拆解解密方法写起来不复杂,但前提是理解企业微信的封装格式。它的设计很像一个自定义信封:原始明文 = 16字节随机字符串 + 4字节消息长度(大端序) + 消息体 + 企业CorpId然后使用EncodingAESKey做AES-256-CBC加密,具体参数是:密钥:EncodingAESKey按Base64解码后的32字节。IV:取密钥的前16字节。模式与填充:CBC模式 + PKCS7填充。输出:加密后的字节数组做Base64编码,得到字符串。这里有几个为什么值得说一下。加入16字节随机串,保证相同内容每次加密出来的密文不同,防止重放和模式分析。加入4字节消息长度,解密后能准确截取消息体。末尾拼CorpId,相当于收件人校验,防止别人的报文拿到你这里还能解出“看似合理”的内容。3.2 解密方法:从Base64到明文XMLusing System.Security.Cryptography; using System.Text; public static class WeComCrypto { public static (string Msg, string CorpId) Decrypt(string encrypt, string encodingAesKey) { if (string.IsNullOrEmpty(encrypt)) { throw new ArgumentException("密文不能为空", nameof(encrypt)); } var key = Convert.FromBase64String(encodingAesKey); if (key.Length != 32) { throw new CryptographicException("EncodingAESKey解码后长度必须为32字节"); } var iv = new byte[16]; Array.Copy(key, 0, iv, 0, 16); using var aes = Aes.Create(); aes.Key = key; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; byte[] plainBytes; using (var input = new MemoryStream(Convert.FromBase64String(encrypt))) using (var decryptor = aes.CreateDecryptor()) using (var cryptoStream = new CryptoStream(input, decryptor, CryptoStreamMode.Read)) using (var output = new MemoryStream()) { cryptoStream.CopyTo(output); plainBytes = output.ToArray(); } // 前16字节是随机串,这里不校验 var msgLen = (plainBytes[16] << 24) | (plainBytes[17] << 16) | (plainBytes[18] << 8) | plainBytes[19]; if (msgLen <= 0 || 20 + msgLen > plainBytes.Length) { throw new CryptographicException("消息长度字段非法"); } var msg = Encoding.UTF8.GetString(plainBytes, 20, msgLen); var corpId = Encoding.UTF8.GetString(plainBytes, 20 + msgLen, plainBytes.Length - 20 - msgLen); return (msg, corpId); } }长度字段我特意用手动移位的方式读取,没有用BitConverter,因为企业微信这里明确是网络字节序(大端),而BitConverter在常见平台上默认是小端,直接转会出错。手动移位在任何平台都稳。解密之后一定要判断CorpId是否等于自己的企业ID。这个校验能挡住不少“密文能解开但内容不属于当前企业”的脏数据。3.3 加密方法:构造被动回复密文被动回复的加密过程正好反过来:构造好明文回复XML后,按同样的格式拼接、AES加密、Base64输出。public static string Encrypt(string msg, string encodingAesKey, string corpId) { if (string.IsNullOrEmpty(msg)) { throw new ArgumentException("消息不能为空", nameof(msg)); } var key = Convert.FromBase64String(encodingAesKey); if (key.Length != 32) { throw new CryptographicException("EncodingAESKey解码后长度必须为32字节"); } var iv = new byte[16]; Array.Copy(key, 0, iv, 0, 16); var random = RandomNumberGenerator.GetBytes(16); var msgBytes = Encoding.UTF8.GetBytes(msg); var lenBytes = new byte[4]; lenBytes[0] = (byte)((msgBytes.Length >> 24) & 0xFF); lenBytes[1] = (byte)((msgBytes.Length >> 16) & 0xFF); lenBytes[2] = (byte)((msgBytes.Length >> 8) & 0xFF); lenBytes[3] = (byte)(msgBytes.Length & 0xFF); var corpIdBytes = Encoding.UTF8.GetBytes(corpId); var plainBytes = new byte[16 + 4 + msgBytes.Length + corpIdBytes.Length]; Buffer.BlockCopy(random, 0, plainBytes, 0, 16); Buffer.BlockCopy(lenBytes, 0, plainBytes, 16, 4); Buffer.BlockCopy(msgBytes, 0, plainBytes, 20, msgBytes.Length); Buffer.BlockCopy(corpIdBytes, 0, plainBytes, 20 + msgBytes.Length, corpIdBytes.Length); using var aes = Aes.Create(); aes.Key = key; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; using var encryptor = aes.CreateEncryptor(); var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); return Convert.ToBase64String(cipherBytes); }随机串用RandomNumberGenerator.GetBytes(16)生成,这是加密安全的随机数来源,不要用Guid或者Random凑数。Random这类伪随机源在加密场景里属于不安全用法。4. 消息处理链路:验签、解包、识别、被动回复4.1 POST消息入口的验签与解包用户发消息或触发事件后,企业微信会向同一个URL发起POST请求。请求体是一个XML,核心内容在<Encrypt>节点里:<xml> <ToUserName><![CDATA[corpid]]></ToUserName> <AgentID>1000002</AgentID> <Encrypt><![CDATA[密文BASE64]]></Encrypt> </xml>URL上的参数依然有msg_signature、timestamp、nonce,签名计算时参与的四个字符串是token、timestamp、nonce、XML里的Encrypt内容。我给出一个完整的处理入口:[HttpPost("callback")] public async Task<IActionResult> Receive( [FromQuery] string msg_signature, [FromQuery] string timestamp, [FromQuery] string nonce) { string body; using (var reader = new StreamReader(Request.Body, Encoding.UTF8)) { body = await reader.ReadToEndAsync(); } var doc = XDocument.Parse(body); var encrypt = doc.Root?.Element("Encrypt")?.Value; if (string.IsNullOrEmpty(encrypt) || !WeComSign.VerifySignature(msg_signature, Token, timestamp, nonce, encrypt)) { _logger.LogWarning("POST消息验签失败"); return Content("signature fail", "text/plain"); } string plainXml; try { var (msg, corpId) = WeComCrypto.Decrypt(encrypt, EncodingAesKey); if (corpId != CorpId) { _logger.LogWarning("消息CorpId不匹配"); return Content("corpid fail", "text/plain"); } plainXml = msg; } catch (Exception ex) { _logger.LogError(ex, "消息解密失败"); return Content("decrypt fail", "text/plain"); } _logger.LogInformation("收到明文消息: {PlainXml}", plainXml); var replyXml = ProcessMessage(plainXml); if (string.IsNullOrEmpty(replyXml)) { return Content("success", "text/plain"); } var encryptedReply = WeComCrypto.Encrypt(replyXml, EncodingAesKey, CorpId); var timeStamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); var nonceOut = Guid.NewGuid().ToString("N"); var responseXml = BuildEncryptedResponse(encryptedReply, timeStamp, nonceOut); return Content(responseXml, "text/xml"); } private static string BuildEncryptedResponse(string encrypt, string timeStamp, string nonce) { var signature = WeComSign.BuildSignature(Token, timeStamp, nonce, encrypt); return $@"<xml> <Encrypt><![CDATA[{encrypt}]]></Encrypt> <MsgSignature><![CDATA[{signature}]]></MsgSignature> <TimeStamp>{timeStamp}</TimeStamp> <Nonce><![CDATA[{nonce}]]></Nonce> </xml>"; }这个入口的ProcessMessage返回的是明文回复XML,如果不回复则返回null。响应加密XML里的TimeStamp和Nonce可以自己生成,但签名必须用同一组值来计算。4.2 明文XML的消息类型与分发策略解密后的明文XML,结构是企业微信定义好的。下面是我在项目里见过的主要类型:MsgType含义关键字段text文本消息Contentimage图片消息PicUrl, MediaIdvoice语音消息MediaId, Format, Recognitionvideo视频消息MediaId, ThumbMediaIdlocation位置消息Location_X, Location_Y, Scale, Labellink链接消息Title, Description, Urlevent事件消息Event, EventKey事件消息里比较常见的是:subscribe(用户关注)、enter_agent(用户进入应用)、click(点击应用菜单)、location(上报地理位置)。事件消息一般不需要回复,直接返回success即可。业务分发我建议用清晰的switch结构,避免一堆if嵌套:private string? ProcessMessage(string plainXml) { var root = XDocument.Parse(plainXml).Root; var msgType = root.Element("MsgType")?.Value; var fromUser = root.Element("FromUserName")?.Value; var toUser = root.Element("ToUserName")?.Value; switch (msgType) { case "text": var content = root.Element("Content")?.Value; _logger.LogInformation("收到文本消息: {Content}", content); return BuildTextReply(fromUser, toUser, $"你发送的是:{content}"); case "event": var eventName = root.Element("Event")?.Value; var eventKey = root.Element("EventKey")?.Value; _logger.LogInformation("收到事件: {Event}, Key: {EventKey}", eventName, eventKey); if (eventName == "enter_agent") { return BuildTextReply(fromUser, toUser, "欢迎进入应用"); } return null; default: _logger.LogInformation("未处理的消息类型: {MsgType}", msgType); return null; } } private static string BuildTextReply(string fromUser, string toUser, string content) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); return $@"<xml> <ToUserName><![CDATA[{fromUser}]]></ToUserName> <FromUserName><![CDATA[{toUser}]]></FromUserName> <CreateTime>{timestamp}</CreateTime> <MsgType><![CDATA[text]]></MsgType> <Content><![CDATA[{content}]]></Content> </xml>"; }注意被动回复明文XML里的ToUserName要填发送者,FromUserName要填企业的CorpId,和接收时正好相反。很多人第一次写这里会把两个节点填反,结果消息发不出去。4.3 被动回复的时序要求与success策略被动回复有一个硬性限制:必须在5秒内响应。这里说的响应,是指你的服务向企业微信服务器返回完整内容,而不是业务处理完成。如果你的业务逻辑比较重,比如要查数据库、调三方接口,5秒经常不够。这时候有两个选择:先返回success,让企业微信认为处理结束,然后自己另起一个后台任务继续处理,处理完通过“发送应用消息”的API主动推给用户。想办法优化处理链路,保证5秒内能构造出回复。我个人的经验是:能用success就用success,被动回复适合轻量场景。千万不要为了凑回复而阻塞请求线程,一旦超时,企业微信会重试多次,最终可能导致同一条消息被重复处理。谈到重试,就一定要提幂等。企业微信因超时或网络问题会对同一消息重试,普通消息里有MsgId字段,事件消息没有MsgId,只能根据FromUserName加CreateTime加Event来拼一个业务幂等键。我在项目里用数据库唯一索引做去重,入库前先查一次,简单有效。**5. 接入过程中我踩过的坑和调试建议5.1 回调验证总是失败的三个真实原因我在实际项目里排查过三种典型的验证失败,基本覆盖了90%的场景。第一种是返回内容错误。最常见的是把解密后的完整内容返回了,或者返回的内容带了换行、空格、BOM。企业微信比对的是echostr解密后的字符串,多一个字符都不行。第二种是签名参数名搞错。老项目从微信公众平台迁移过来时容易把msg_signature写成signature,或者签名计算的四个元素里混入了AgentId。签名计算的输入必须是token、timestamp、nonce、echostr(或Encrypt)这四个,一个都不能多。第三种是密钥不一致。管理后台点“随机生成”得到的EncodingAESKey,和代码里配置的不一致,或者复制时多了一个空格。Convert.FromBase64String在遇到非法字符时直接抛异常,日志里会非常明显。还有一种相对隐蔽的情况:服务部署在反向代理后面,代理把query string丢弃了或者改了URL大小写。这种会让验签永远失败,排查时除了看应用日志,还要看网关日志。5.2 消息解密报Padding异常的排查如果你遇到CryptographicException: Padding is invalid and cannot be removed,多半不是算法写错,而是数据本身有问题。我按频率排了一下:EncodingAESKey配置错了,或者后台重置过密钥但代码没同步。密文在传输中被截断,比如网关对body大小有限制,把XML截断了一半。CorpId和加密时不一致。解密出来的CorpId如果乱码,说明整个明文结构都被破坏,自然也会报Padding异常。某些框架自动修改了请求体。我遇到过请求经过某个日志中间件后,body流被读走但没有复位,导致控制器读到空body,解析不出Encrypt。排查这类问题,第一步永远是打原始报文日志。把URL参数、请求体、解密用的CorpId、EncodingAESKey长度都打出来,先确认数据完整,再去怀疑算法。还有一个小细节:Base64字符串里的+在URL参数里会被解码成空格,但在POST的XML body里不会。如果在GET验证时你在网关日志里看到密文里的+变成了空格,那只是日志显示问题,ASP.NET Core的模型绑定会自动URL解码还原,不需要手动处理。5.3 本地调试的实用手段本地调试企业微信回调,最麻烦的是公网回调地址。我建议在项目早期就用内网穿透工具把本地服务临时暴露到公网,或者直接部署到一台测试服务器,验证整个链路后再迁回正式环境。内网穿透时要注意URL路径和正式环境保持一致,避免传参差异。第二个建议是做一个原始报文记录开关。在回调入口把msg_signature、timestamp、nonce、body全文记录到日志或数据库,排查问题时可以完整还原企业微信到底发了什么。这个开关在正式环境建议默认关闭,需要排查时再打开,避免日志量过大。第三个建议是先把加解密模块单独验证,再接入业务逻辑。用企业微信后台或文档里提供的示例数据,跑一遍Encrypt和Decrypt,确保和你对应用例的结果一致。这里建议多做几组不同长度、包含中文和特殊字符的样本,比如中文、XML转义字符、Emoji,都能正常往返才算稳。还有一点务必注意:Token和EncodingAESKey属于敏感配置,不要写死在代码里,用环境变量或配置中心管理,日志里也要做脱敏。我在一个项目里见过有人为了方便把EncodingAESKey直接打在启动日志里,后来整个密钥被迫重置,这个教训挺深刻的。