
目录前言前置工作依赖库微信配置信息注册服务管理token企业微信验证回调接收信息异步处理信息文本信息图片信息公众号接入登录小结引用链接前言很久没写文章了现在有了AI其实已经不怎么需要写文章反正不懂就问AI嘛。不过AI总是有盲区的就比如国内的微信开发。微信的文档是公认的烂而且经常悄咪咪改接口又不更新文档所以AI对微信开发的API其实不怎么熟悉经常给出一些错误的回复。本文记录一下最近我使用 C# WebApi 项目接入企业微信和公众号的过程主要是用到自动回复功能。前置工作依赖库我用到了SKIT.FlurlHttpClient.Wechat这个系列的库https://github.com/fudiwei/DotNetCore.SKIT.FlurlHttpClient.Wechat原本想直接用 Flurl 对接的毕竟现在手里有了锤子AI看啥都是钉子啥都想造轮子。不过搜了一下我的收藏夹发现有这个项目封装了微信的大部分接口那还要啥自行车直接用就完事儿了。其中• 企业微信SKIT.FlurlHttpClient.Wechat.Work• 公众号SKIT.FlurlHttpClient.Wechat.Api微信配置信息需要准备这些配置信息企业微信public class WechatWorkOptions { public string CorpId { get; set; } string.Empty; // 应用ID public int AgentId { get; set; } // 应用密钥 public string Secret { get; set; } string.Empty; // 回调 Token public string CallbackToken { get; set; } string.Empty; // 回调 EncodingAESKey public string CallbackEncodingAESKey { get; set; } string.Empty; }公众号public class WechatApiClientOptions { public string AppId { get; set; } string.Empty; public string AppSecret { get; set; } string.Empty; public string CallbackToken { get; set; } string.Empty; public string CallbackEncodingAESKey { get; set; } string.Empty; }注册服务// 企业微信 builder.Services.AddSingletonWechatWorkClient(sp { var options sp.GetRequiredServiceIOptionsWechatWorkOptions().Value; return WechatWorkClientBuilder.Create(options).Build(); }); // 公众号 builder.Services.AddSingletonWechatApiClient(sp { var options sp.GetRequiredServiceIOptionsWechatMpOptions().Value; return WechatApiClientBuilder.Create(options).Build(); });准备工作就搞定了。管理token微信的接口都需要用 AccessToken 才能调用但微信又不想开发者每次都去请求获取token所以只能获取一次然后自己保存了。C# 可以用 IMemoryCache 组件很方便的管理这些临时存储的数据Django框架也有内置的cache机制其他语言框架可以用Redis这类NoSQL数据库来存储。扯远了本文还是介绍C#的。我用一个WechatWorkTokenService服务来管理企业微信的token公众号、小程序这种也是同理public class WechatWorkTokenService( WechatWorkClient client, IMemoryCache cache, IOptionsWechatWorkOptions options ) : IWechatWorkTokenService { private const string CacheKey WechatWorkAccessToken; // 用于并发控制防止瞬间高并发导致多次请求 Token 接口 private static readonly SemaphoreSlim Semaphore new SemaphoreSlim(1, 1); /// summary /// 获取 AccessToken /// /summary public async Taskstring GetAccessTokenAsync(CancellationToken cancellationToken default) { // 1. 尝试从缓存获取 if (cache.TryGetValue(CacheKey, out string? accessToken) !string.IsNullOrEmpty(accessToken)) { return accessToken; } // 2. 缓存未命中加锁请求 await Semaphore.WaitAsync(cancellationToken); try { // 双重检查防止排队等待的线程再次请求 if (cache.TryGetValue(CacheKey, out accessToken) !string.IsNullOrEmpty(accessToken)) { return accessToken; } // 3. 调用接口获取 Token var request new CgibinGetTokenRequest(); var response await client.ExecuteCgibinGetTokenAsync(request, cancellationToken); if (!response.IsSuccessful()) { throw new Exception($获取 AccessToken 失败: {response.ErrorMessage} (Code: {response.ErrorCode})); } accessToken response.AccessToken; // 4. 设置缓存 // 提前 5 分钟过期确保在过期前刷新 // 如果 ExpiresIn 小于 300 秒则设为一半时间 var expirySeconds response.ExpiresIn 300 ? response.ExpiresIn - 300 : response.ExpiresIn / 2; var cacheEntryOptions new MemoryCacheEntryOptions() .SetAbsoluteExpiration(TimeSpan.FromSeconds(expirySeconds)); cache.Set(CacheKey, accessToken, cacheEntryOptions); return accessToken; } finally { Semaphore.Release(); } } }企业微信企业微信的限制比较少可以主动给用户发信息所以可以把接收和发送信息分开例如调用LLM处理回复的时候会比较慢可以把回复放到异步任务队列里去实现。验证回调直接上接口代码。在配置企业微信应用URL的时候微信服务器会发送一个GET请求到配置的URL进行验证后端程序需要验证签名解密后把内容复读给微信服务器。下面这个接口就实现了这个验证方法。这样实现之后填写https://example.com/api/wechat/work/callback这个地址就好了。[ApiController] [AllowAnonymous] [Route(api/wechat/work/callback)] public class WechatWorkController( WechatWorkClient client, IBackgroundTaskQueue queue, ILoggerWechatWorkController logger ) : ControllerBase { /// summary /// 回调验证 (GET) /// /summary [HttpGet] public IActionResult Echo( [FromQuery(Name msg_signature)] string msgSignature, [FromQuery(Name timestamp)] string timestamp, [FromQuery(Name nonce)] string nonce, [FromQuery(Name echostr)] string echoStr ) { // 验证签名 var verifyResult client.VerifyEventSignatureForEcho( timestamp, nonce, echoStr, msgSignature, out string? replyEcho ); if (verifyResult.Result) { logger.LogInformation(Echo verification successful. ReplyEcho: {ReplyEcho}, replyEcho); return Content(replyEcho ?? string.Empty); } logger.LogWarning(Echo verification failed. Error: {Error}, verifyResult.Error?.Message); return BadRequest($Verify signature failed: {verifyResult.Error?.Message}); } }接收信息接收信息和上面的验证都是一个URL区别是接收信息时微信服务器会向URL发POST请求。代码里有详细注释了应该不用解释太多。/// summary /// 接收消息 (POST) /// /summary [HttpPost] public async TaskIActionResult Callback( [FromQuery(Name msg_signature)] string msgSignature, [FromQuery(Name timestamp)] string timestamp, [FromQuery(Name nonce)] string nonce ) { // 必须读取原始 Request Body 流而不能使用 [FromBody] 绑定 // 原因 // 1. 微信签名验证依赖于原始请求体任何空格、换行符的差异都会导致签名校验失败 // 2. 推送内容通常是加密的 XML需要先获取原始字符串传给 SDK 进行解密 using var reader new StreamReader(Request.Body); var xml await reader.ReadToEndAsync(); logger.LogDebug(Callback Body (Length: {Length}): {Xml}, xml.Length, xml); // 1. 验证签名 // 虽然 DeserializeEventFromXml 内部可能会包含解密过程但显式验证签名是更安全的做法 var verifyResult client.VerifyEventSignatureFromXml(timestamp, nonce, xml, msgSignature); if (!verifyResult.Result) { logger.LogWarning(Callback signature verification failed. Error: {Error}, verifyResult.Error?.Message); return BadRequest($Verify signature failed: {verifyResult.Error?.Message}); } // 2. 使用 SKIT 库提供的扩展方法自动解密并反序列化 // 注意需要在 WechatWorkClientOptions 中配置 PushToken 和 PushEncodingAESKey WechatWorkEvent wechatEvent; try { wechatEvent client.DeserializeEventFromXml(xml); logger.LogInformation(Callback deserialized successfully. MessageType: {MessageType}, FromUser: {FromUser}, ToUser: {ToUser}, wechatEvent.MessageType, wechatEvent.FromUserName, wechatEvent.ToUserName); } catch (Exception ex) { // 反序列化失败通常是因为签名验证失败或解密失败 logger.LogError(ex, Callback deserialization failed.); return BadRequest($Deserialization failed: {ex.Message}); } // 处理逻辑 if (string.Equals(wechatEvent.MessageType, TEXT, StringComparison.OrdinalIgnoreCase)) { // 再次反序列化为具体的文本消息事件以获取 Content var textEvent client.DeserializeEventFromXmlTextMessageEvent(xml); if (textEvent ! null !string.IsNullOrEmpty(textEvent.Content) !string.IsNullOrEmpty(textEvent.FromUserName)) { logger.LogInformation(Processing TEXT message from {FromUser}: {Content}, textEvent.FromUserName, textEvent.Content); await ProcessTextMessageAsync(textEvent.FromUserName, textEvent.Content); } } else if (string.Equals(wechatEvent.MessageType, IMAGE, StringComparison.OrdinalIgnoreCase)) { var imageEvent client.DeserializeEventFromXmlImageMessageEvent(xml); if (imageEvent ! null !string.IsNullOrEmpty(imageEvent.MediaId) !string.IsNullOrEmpty(imageEvent.FromUserName)) { logger.LogInformation(Processing IMAGE message from {FromUser}: {MediaId}, imageEvent.FromUserName, imageEvent.MediaId); await ProcessImageMessageAsync(imageEvent.FromUserName, imageEvent.MediaId); } } else { logger.LogInformation(Ignored message type: {MessageType}, wechatEvent.MessageType); } return Ok(success); }异步处理信息因为企业微信可以主动给用户发信息所以可以把接收和发送信息分开例如调用LLM处理回复的时候会比较慢可以把回复放到异步任务队列里去实现。文本信息纯文本处理起来还是比较简单的。/// summary /// 异步处理文本消息 /// /summary private async Task ProcessTextMessageAsync(string toUser, string content) { await queue.QueueBackgroundWorkItemAsync(async (serviceProvider, token) { // 在后台任务中解析 Scoped 服务 var chatBot serviceProvider.GetRequiredServiceIChatBotService(); var logger serviceProvider.GetRequiredServiceILoggerWechatWorkController(); try { logger.LogInformation(Processing background task for user {ToUser}, toUser); // 1. 调用 ChatBot 获取回复 string reply await chatBot.ProcessMessageAsync(content); // 2. 发送回复 var accessToken await _tokenService.GetAccessTokenAsync(); var request new CgibinMessageSendRequest { AccessToken accessToken, AgentId _agentId, ToUserIdList [toUser], MessageType text, MessageContentAsText new CgibinMessageSendRequest.Types.TextMessage { Content content } }; var response await _client.ExecuteCgibinMessageSendAsync(request); if (!response.IsSuccessful()){ throw new Exception($发送企业微信消息失败: {response.ErrorMessage} (Code: {response.ErrorCode})); } logger.LogInformation(Reply sent to {ToUser}: {ReplyContent}, toUser, reply); } catch (Exception ex) { logger.LogError(ex, Failed to process message for {ToUser}, toUser); } }); }图片信息图片麻烦一点微信不会直接把图片数据发来而是搞了个 mediaId要我们手动去下载。C# 这里还是方便的直接把图片下载放到内存里交给第三方服务处理如OCR然后再把结果发出来。/// summary /// 异步处理图片消息 /// /summary private async Task ProcessImageMessageAsync(string toUser, string mediaId) { await queue.QueueBackgroundWorkItemAsync(async (serviceProvider, token) { var chatBot serviceProvider.GetRequiredServiceIChatBotService(); var wechatService serviceProvider.GetRequiredServiceIWechatWorkService(); var tokenService serviceProvider.GetRequiredServiceIWechatWorkTokenService(); var logger serviceProvider.GetRequiredServiceILoggerWechatWorkController(); var wechatClient serviceProvider.GetRequiredServiceWechatWorkClient(); try { logger.LogInformation(Processing background image task for user {ToUser}, toUser); // 1. Download Image var accessToken await tokenService.GetAccessTokenAsync(token); var request new CgibinMediaGetRequest { AccessToken accessToken, MediaId mediaId }; var resp await wechatClient.ExecuteCgibinMediaGetAsync(request, cancellationToken: token); if (!resp.IsSuccessful()) { logger.LogError(Failed to download image: {Error}, resp.ErrorMessage); await wechatService.SendTextMessageAsync(toUser, 抱歉无法获取图片内容。); return; } var bytes resp.GetRawBytes(); var mimeType image/jpeg; if (bytes.Length 0 bytes[0] 0x89 bytes[1] 0x50 bytes[2] 0x4E bytes[3] 0x47) { mimeType image/png; } var items new ChatMessageContentItemCollection { new ImageContent(bytes, mimeType) }; // 2. Call ChatBot var chatMessage new ChatMessageContent(AuthorRole.User, items); var reply await chatBot.ProcessMessageAsync(chatMessage); // 3. Send Reply await wechatService.SendTextMessageAsync(toUser, reply); logger.LogInformation(Reply sent to {ToUser}, toUser); } catch (Exception ex) { logger.LogError(ex, Failed to process image message for {ToUser}, toUser); } }); }公众号好企业微信搞定了。接下来看看公众号。公众号和企业微信不一样无法主动发信息所以在收到用户信息时要返回XML格式的相应作为回复内容5秒内必须回复。验证回调这里就不重复了和企业微信是一样的。/// summary /// 接收消息 (POST) /// /summary [HttpPost] public async TaskIActionResult Callback( [FromQuery(Name msg_signature)] string? msgSignature, [FromQuery(Name signature)] string? signature, [FromQuery(Name timestamp)] string timestamp, [FromQuery(Name nonce)] string nonce, [FromQuery(Name encrypt_type)] string? encryptType ) { using var reader new StreamReader(Request.Body); var xml await reader.ReadToEndAsync(); _logger.LogDebug(Callback Body (Length: {Length}): {Xml}, xml.Length, xml); // 1. 验证签名 // 如果是安全模式 (encryptType aes)使用 VerifyEventSignatureFromXml (需要 msg_signature) // 如果是明文模式SDK 内部 DeserializeEventFromXml 也会做一些校验但通常明文模式签名校验使用 signature (VerifyEventSignatureForEcho logic) // 这里主要处理安全模式因为明文模式下通常不需要复杂的解密验证 if (string.Equals(encryptType, aes, StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrEmpty(msgSignature)) { return BadRequest(msg_signature is required for aes encryption); } var verifyResult _client.VerifyEventSignatureFromXml(timestamp, nonce, xml, msgSignature); if (!verifyResult.Result) { _logger.LogWarning(Callback signature verification failed. Error: {Error}, verifyResult.Error?.Message); return BadRequest($Verify signature failed: {verifyResult.Error?.Message}); } } else { // 明文模式可以使用 signature 验证 (可选) // var verifyResult _client.VerifyEventSignatureForEcho(timestamp, nonce, signature); } // 2. 使用 SKIT 库自动解密并反序列化 WechatApiEvent wechatEvent; try { wechatEvent _client.DeserializeEventFromXml(xml); _logger.LogInformation(Callback deserialized successfully. MessageType: {MessageType}, FromUser: {FromUser}, ToUser: {ToUser}, wechatEvent.MessageType, wechatEvent.FromUserName, wechatEvent.ToUserName); } catch (Exception ex) { _logger.LogError(ex, Callback deserialization failed.); return BadRequest($Deserialization failed: {ex.Message}); } switch (wechatEvent.MessageType?.ToLower()) { case text: var textEvent _client.DeserializeEventFromXmlTextMessageEvent(xml); if (!string.IsNullOrEmpty(textEvent.Content) !string.IsNullOrEmpty(textEvent.FromUserName)) { _logger.LogInformation(Processing TEXT message from {FromUser}: {Content}, textEvent.FromUserName, textEvent.Content); var isSafetyMode string.Equals(encryptType, aes, StringComparison.OrdinalIgnoreCase); var textReply new TextMessageReply { ToUserName textEvent.FromUserName, FromUserName textEvent.ToUserName, MessageType text, Content 这里是回复给用户的内容, CreateTimestamp DateTimeOffset.Now.ToUnixTimeSeconds() }; var replyXml _client.SerializeEventToXml(textReply, isSafetyMode); return Content(replyXml, application/xml); } break; default: _logger.LogInformation(Ignored message type: {MessageType}, wechatEvent.MessageType); break; } return Ok(success); }可以看到代码里判断是 text 类型后构造了 TextMessageReply 类型的数据然后调用 SKIT.FlurlHttpClient.Wechat 库提供的 XML 序列化方法。这个库封装了直接序列化被动回复事件的扩展方法默认会序列化为安全模式。接入登录微信登录和大部分第三方单点认证流程差不多已经写过好多次了。不再赘述这个流程感兴趣的同学可以看这篇文章: DjangoTaro项目实现企业微信登录[1]本次我没有接入登录而是用了另一种方式实现微信和平台用户的关联就是平台上生成一个key让用户在微信发送感觉还挺有意思的另辟蹊径。所以这里搬运一下我之前做的单点认证项目里的代码吧详情可以看这篇文章: IdentityServerLite项目和近期的开源计划[2]/// summary /// 企业微信登录 - 使用回调的 code 登录 /// /summary /// param namecode/param /// param namestate一些让微信转发传给后端的参数这里是单点认证项目的session_id/param [HttpGet(wecom/login)] public async TaskIActionResult WecomLogin(string code, string? state null) { logger.LogInformation(企业微信登录code: {code}, state: {state}, crop: {cropTag}, code, state, cropTag); if (string.IsNullOrWhiteSpace(state)) { return BadRequest(new ApiResponse { Message 企业微信登录的 state 为空无法获取 session }); } var session await authService.GetSession(state); if (session null) { return NotFound(new ApiResponse { Message $session {state} 不存在 }); } var userInfo await wecomService.GetUserInfo(code); if (userInfo null) { return BadRequest(new ApiResponse { Message 获取 userinfo 错误 }); } if (userInfo.Errcode ! 0) { return BadRequest(new ApiResponse { Message $获取用户信息失败企微错误信息: {userInfo.Errmsg} }); } var wechatUser await wecomService.GetUser(userInfo.Userid); if (wechatUser null) { return BadRequest(new ApiResponse { Message 获取 user 错误 }); } var user await userRepo.Where(a a.PhoneNumber wechatUser.Userid).FirstAsync(); // 用户不存在的话自动创建用户 if (user null) { user await accountService.CreateUser( await accountService.GenerateUsername(wechatUser.Name), wechatUser.Userid, wechatUser.Name ); logger.LogInformation(用户 {Phone} 不存在已创建新用户 {UserId}, wechatUser.Userid, user.Id); // return NotFound(new ApiResponse { Message $用户 {wechatUser.Userid} 不存在 }); } try { var url await authService.LoginSessionAndGetUri(session, user, true); logger.LogInformation(企业微信登录成功跳转到链接: {url}, url); return Redirect(url); } catch (Exception ex) { ex.ToExceptionless().Submit(); return Problem($企业微信登录失败: LoginSessionAndGetUri 失败 - {ex.Message}); } }小结大概就是这些了很繁琐不过还挺好用的这些代码写完后几乎是一次就对接通过想起来以前反复调试的经历感叹日子也是好起来了呀另一点感叹Semantic Kernel真好用我用了太久langchain应该早点上semantic kernel的。引用链接[1]DjangoTaro项目实现企业微信登录:https://blog.deali.cn/p/django-taro-wechat-work-login[2]IdentityServerLite项目和近期的开源计划:https://blog.deali.cn/p/almost-abandoned-project-open-source引入地址