找回密码
 立即注册
首页 业界区 业界 AspNetCore开发笔记:WebApi项目集成企业微信和公众号 ...

AspNetCore开发笔记:WebApi项目集成企业微信和公众号

习和璧 2026-1-22 00:15:00
前言

很久没写文章了,现在有了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
微信配置信息

需要准备这些配置信息:
企业微信:
  1. public class WechatWorkOptions {
  2.     public string CorpId { get; set; } = string.Empty;
  3.     // 应用ID
  4.     public int AgentId { get; set; }
  5.     // 应用密钥
  6.     public string Secret { get; set; } = string.Empty;
  7.     // 回调 Token
  8.     public string CallbackToken { get; set; } = string.Empty;
  9.     // 回调 EncodingAESKey
  10.     public string CallbackEncodingAESKey { get; set; } = string.Empty;
  11. }
复制代码
公众号:
  1. public class WechatApiClientOptions {
  2.     public string AppId { get; set; } = string.Empty;
  3.     public string AppSecret { get; set; } = string.Empty;
  4.     public string CallbackToken { get; set; } = string.Empty;
  5.     public string CallbackEncodingAESKey { get; set; } = string.Empty;
  6. }
复制代码
注册服务
  1. // 企业微信
  2. builder.Services.AddSingleton<WechatWorkClient>(sp => {
  3.     var options = sp.GetRequiredService<IOptions<WechatWorkOptions>>().Value;
  4.     return WechatWorkClientBuilder.Create(options).Build();
  5. });
  6. // 公众号
  7. builder.Services.AddSingleton<WechatApiClient>(sp => {
  8.     var options = sp.GetRequiredService<IOptions<WechatMpOptions>>().Value;
  9.     return WechatApiClientBuilder.Create(options).Build();
  10. });
复制代码
准备工作就搞定了。
管理token

微信的接口都需要用 AccessToken 才能调用,但微信又不想开发者每次都去请求获取token,所以只能获取一次然后自己保存了。
C# 可以用 IMemoryCache 组件,很方便的管理这些临时存储的数据;Django框架也有内置的cache机制,其他语言框架可以用Redis这类NoSQL数据库来存储。扯远了,本文还是介绍C#的。
我用一个 WechatWorkTokenService 服务来管理企业微信的token(公众号、小程序这种也是同理)
  1. public class WechatWorkTokenService(
  2.     WechatWorkClient client,
  3.     IMemoryCache cache,
  4.     IOptions<WechatWorkOptions> options
  5. ) : IWechatWorkTokenService {
  6.     private const string CacheKey = "WechatWorkAccessToken";
  7.     // 用于并发控制,防止瞬间高并发导致多次请求 Token 接口
  8.     private static readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1, 1);
  9.     /// <summary>
  10.     /// 获取 AccessToken
  11.     /// </summary>
  12.     public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken = default) {
  13.         // 1. 尝试从缓存获取
  14.         if (cache.TryGetValue(CacheKey, out string? accessToken) && !string.IsNullOrEmpty(accessToken)) {
  15.             return accessToken;
  16.         }
  17.         // 2. 缓存未命中,加锁请求
  18.         await Semaphore.WaitAsync(cancellationToken);
  19.         try {
  20.             // 双重检查,防止排队等待的线程再次请求
  21.             if (cache.TryGetValue(CacheKey, out accessToken) && !string.IsNullOrEmpty(accessToken)) {
  22.                 return accessToken;
  23.             }
  24.             // 3. 调用接口获取 Token
  25.             var request = new CgibinGetTokenRequest();
  26.             var response = await client.ExecuteCgibinGetTokenAsync(request, cancellationToken);
  27.             if (!response.IsSuccessful()) {
  28.                 throw new Exception($"获取 AccessToken 失败: {response.ErrorMessage} (Code: {response.ErrorCode})");
  29.             }
  30.             accessToken = response.AccessToken;
  31.             // 4. 设置缓存
  32.             // 提前 5 分钟过期,确保在过期前刷新
  33.             // 如果 ExpiresIn 小于 300 秒,则设为一半时间
  34.             var expirySeconds = response.ExpiresIn > 300 ? response.ExpiresIn - 300 : response.ExpiresIn / 2;
  35.             var cacheEntryOptions = new MemoryCacheEntryOptions()
  36.                 .SetAbsoluteExpiration(TimeSpan.FromSeconds(expirySeconds));
  37.             cache.Set(CacheKey, accessToken, cacheEntryOptions);
  38.             return accessToken;
  39.         }
  40.         finally {
  41.             Semaphore.Release();
  42.         }
  43.     }
  44. }
复制代码
企业微信

企业微信的限制比较少,可以主动给用户发信息,所以可以把接收和发送信息分开,例如调用LLM处理回复的时候,会比较慢,可以把回复放到异步任务队列里去实现。
验证回调

直接上接口代码。
在配置企业微信应用URL的时候,微信服务器会发送一个GET请求到配置的URL进行验证,后端程序需要验证签名,解密后把内容复读给微信服务器。
下面这个接口就实现了这个验证方法。
这样实现之后填写 https://example.com/api/wechat/work/callback 这个地址就好了。
  1. [ApiController]
  2. [AllowAnonymous]
  3. [Route("api/wechat/work/callback")]
  4. public class WechatWorkController(
  5.     WechatWorkClient client,
  6.     IBackgroundTaskQueue queue,
  7.     ILogger<WechatWorkController> logger
  8. ) : ControllerBase {
  9.     /// <summary>
  10.     /// 回调验证 (GET)
  11.     /// </summary>
  12.     [HttpGet]
  13.     public IActionResult Echo(
  14.         [FromQuery(Name = "msg_signature")] string msgSignature,
  15.         [FromQuery(Name = "timestamp")] string timestamp,
  16.         [FromQuery(Name = "nonce")] string nonce,
  17.         [FromQuery(Name = "echostr")] string echoStr
  18.     ) {
  19.         // 验证签名
  20.         var verifyResult = client.VerifyEventSignatureForEcho(
  21.             timestamp, nonce, echoStr, msgSignature, out string? replyEcho
  22.         );
  23.         if (verifyResult.Result) {
  24.             logger.LogInformation("Echo verification successful. ReplyEcho: {ReplyEcho}", replyEcho);
  25.             return Content(replyEcho ?? string.Empty);
  26.         }
  27.         logger.LogWarning("Echo verification failed. Error: {Error}", verifyResult.Error?.Message);
  28.         return BadRequest($"Verify signature failed: {verifyResult.Error?.Message}");
  29.     }
  30. }
复制代码
接收信息

接收信息和上面的验证都是一个URL,区别是接收信息时,微信服务器会向URL发POST请求。
代码里有详细注释了,应该不用解释太多。
  1. /// <summary>
  2. /// 接收消息 (POST)
  3. /// </summary>
  4. [HttpPost]
  5. public async Task<IActionResult> Callback(
  6.     [FromQuery(Name = "msg_signature")] string msgSignature,
  7.     [FromQuery(Name = "timestamp")] string timestamp,
  8.     [FromQuery(Name = "nonce")] string nonce
  9. ) {
  10.     // 必须读取原始 Request Body 流,而不能使用 [FromBody] 绑定
  11.     // 原因:
  12.     // 1. 微信签名验证依赖于原始请求体,任何空格、换行符的差异都会导致签名校验失败
  13.     // 2. 推送内容通常是加密的 XML,需要先获取原始字符串传给 SDK 进行解密
  14.     using var reader = new StreamReader(Request.Body);
  15.     var xml = await reader.ReadToEndAsync();
  16.     logger.LogDebug("Callback Body (Length: {Length}): {Xml}", xml.Length, xml);
  17.     // 1. 验证签名
  18.     // 虽然 DeserializeEventFromXml 内部可能会包含解密过程,但显式验证签名是更安全的做法
  19.     var verifyResult = client.VerifyEventSignatureFromXml(timestamp, nonce, xml, msgSignature);
  20.     if (!verifyResult.Result) {
  21.         logger.LogWarning("Callback signature verification failed. Error: {Error}", verifyResult.Error?.Message);
  22.         return BadRequest($"Verify signature failed: {verifyResult.Error?.Message}");
  23.     }
  24.     // 2. 使用 SKIT 库提供的扩展方法自动解密并反序列化
  25.     // 注意:需要在 WechatWorkClientOptions 中配置 PushToken 和 PushEncodingAESKey
  26.     WechatWorkEvent wechatEvent;
  27.     try {
  28.         wechatEvent = client.DeserializeEventFromXml(xml);
  29.         logger.LogInformation("Callback deserialized successfully. MessageType: {MessageType}, FromUser: {FromUser}, ToUser: {ToUser}", wechatEvent.MessageType, wechatEvent.FromUserName, wechatEvent.ToUserName);
  30.     } catch (Exception ex) {
  31.         // 反序列化失败(通常是因为签名验证失败或解密失败)
  32.         logger.LogError(ex, "Callback deserialization failed.");
  33.         return BadRequest($"Deserialization failed: {ex.Message}");
  34.     }
  35.     // 处理逻辑
  36.     if (string.Equals(wechatEvent.MessageType, "TEXT", StringComparison.OrdinalIgnoreCase)) {
  37.         // 再次反序列化为具体的文本消息事件以获取 Content
  38.         var textEvent = client.DeserializeEventFromXml<TextMessageEvent>(xml);
  39.         if (textEvent != null && !string.IsNullOrEmpty(textEvent.Content) &&
  40.             !string.IsNullOrEmpty(textEvent.FromUserName)) {
  41.             logger.LogInformation("Processing TEXT message from {FromUser}: {Content}", textEvent.FromUserName, textEvent.Content);
  42.             await ProcessTextMessageAsync(textEvent.FromUserName, textEvent.Content);
  43.         }
  44.     }
  45.     else if (string.Equals(wechatEvent.MessageType, "IMAGE", StringComparison.OrdinalIgnoreCase)) {
  46.         var imageEvent = client.DeserializeEventFromXml<ImageMessageEvent>(xml);
  47.         if (imageEvent != null && !string.IsNullOrEmpty(imageEvent.MediaId) &&
  48.             !string.IsNullOrEmpty(imageEvent.FromUserName)) {
  49.             logger.LogInformation("Processing IMAGE message from {FromUser}: {MediaId}", imageEvent.FromUserName, imageEvent.MediaId);
  50.             await ProcessImageMessageAsync(imageEvent.FromUserName, imageEvent.MediaId);
  51.         }
  52.     }
  53.     else {
  54.         logger.LogInformation("Ignored message type: {MessageType}", wechatEvent.MessageType);
  55.     }
  56.     return Ok("success");
  57. }
复制代码
异步处理信息

因为企业微信可以主动给用户发信息,所以可以把接收和发送信息分开,例如调用LLM处理回复的时候,会比较慢,可以把回复放到异步任务队列里去实现。
文本信息

纯文本处理起来还是比较简单的。
  1. /// <summary>
  2. /// 异步处理文本消息
  3. /// </summary>
  4. private async Task ProcessTextMessageAsync(string toUser, string content) {
  5.     await queue.QueueBackgroundWorkItemAsync(async (serviceProvider, token) => {
  6.         // 在后台任务中解析 Scoped 服务
  7.         var chatBot = serviceProvider.GetRequiredService<IChatBotService>();
  8.         var logger = serviceProvider.GetRequiredService<ILogger<WechatWorkController>>();
  9.         try {
  10.             logger.LogInformation("Processing background task for user {ToUser}", toUser);
  11.             // 1. 调用 ChatBot 获取回复
  12.             string reply = await chatBot.ProcessMessageAsync(content);
  13.             // 2. 发送回复
  14.             var accessToken = await _tokenService.GetAccessTokenAsync();
  15.             var request = new CgibinMessageSendRequest {
  16.                 AccessToken = accessToken,
  17.                 AgentId = _agentId,
  18.                 ToUserIdList = [toUser],
  19.                 MessageType = "text",
  20.                 MessageContentAsText = new CgibinMessageSendRequest.Types.TextMessage {
  21.                     Content = content
  22.                 }
  23.             };
  24.             var response = await _client.ExecuteCgibinMessageSendAsync(request);
  25.             if (!response.IsSuccessful()){
  26.                 throw new Exception($"发送企业微信消息失败: {response.ErrorMessage} (Code: {response.ErrorCode})");
  27.             }
  28.             logger.LogInformation("Reply sent to {ToUser}: {ReplyContent}", toUser, reply);
  29.         } catch (Exception ex) {
  30.             logger.LogError(ex, "Failed to process message for {ToUser}", toUser);
  31.         }
  32.     });
  33. }
复制代码
图片信息

图片麻烦一点,微信不会直接把图片数据发来,而是搞了个 mediaId,要我们手动去下载。
C# 这里还是方便的,直接把图片下载放到内存里交给第三方服务处理(如OCR),然后再把结果发出来。
  1. /// <summary>
  2. /// 异步处理图片消息
  3. /// </summary>
  4. private async Task ProcessImageMessageAsync(string toUser, string mediaId) {
  5.     await queue.QueueBackgroundWorkItemAsync(async (serviceProvider, token) => {
  6.         var chatBot = serviceProvider.GetRequiredService<IChatBotService>();
  7.         var wechatService = serviceProvider.GetRequiredService<IWechatWorkService>();
  8.         var tokenService = serviceProvider.GetRequiredService<IWechatWorkTokenService>();
  9.         var logger = serviceProvider.GetRequiredService<ILogger<WechatWorkController>>();
  10.         var wechatClient = serviceProvider.GetRequiredService<WechatWorkClient>();
  11.         try {
  12.             logger.LogInformation("Processing background image task for user {ToUser}", toUser);
  13.             // 1. Download Image
  14.             var accessToken = await tokenService.GetAccessTokenAsync(token);
  15.             var request = new CgibinMediaGetRequest {
  16.                 AccessToken = accessToken,
  17.                 MediaId = mediaId
  18.             };
  19.             var resp = await wechatClient.ExecuteCgibinMediaGetAsync(request, cancellationToken: token);
  20.             if (!resp.IsSuccessful()) {
  21.                 logger.LogError("Failed to download image: {Error}", resp.ErrorMessage);
  22.                 await wechatService.SendTextMessageAsync(toUser, "抱歉,无法获取图片内容。");
  23.                 return;
  24.             }
  25.             var bytes = resp.GetRawBytes();
  26.             var mimeType = "image/jpeg";
  27.             if (bytes.Length > 0 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) {
  28.                 mimeType = "image/png";
  29.             }
  30.             var items = new ChatMessageContentItemCollection {
  31.                 new ImageContent(bytes, mimeType)
  32.             };
  33.             // 2. Call ChatBot
  34.             var chatMessage = new ChatMessageContent(AuthorRole.User, items);
  35.             var reply = await chatBot.ProcessMessageAsync(chatMessage);
  36.             // 3. Send Reply
  37.             await wechatService.SendTextMessageAsync(toUser, reply);
  38.             logger.LogInformation("Reply sent to {ToUser}", toUser);
  39.         } catch (Exception ex) {
  40.             logger.LogError(ex, "Failed to process image message for {ToUser}", toUser);
  41.         }
  42.     });
  43. }
复制代码
公众号

好,企业微信搞定了。接下来看看公众号。
公众号和企业微信不一样,无法主动发信息,所以在收到用户信息时,要返回XML格式的相应,作为回复内容,5秒内必须回复。
验证回调这里就不重复了,和企业微信是一样的。
  1. /// <summary>
  2. /// 接收消息 (POST)
  3. /// </summary>
  4. [HttpPost]
  5. public async Task<IActionResult> Callback(
  6.     [FromQuery(Name = "msg_signature")] string? msgSignature,
  7.     [FromQuery(Name = "signature")] string? signature,
  8.     [FromQuery(Name = "timestamp")] string timestamp,
  9.     [FromQuery(Name = "nonce")] string nonce,
  10.     [FromQuery(Name = "encrypt_type")] string? encryptType
  11. ) {
  12.     using var reader = new StreamReader(Request.Body);
  13.     var xml = await reader.ReadToEndAsync();
  14.     _logger.LogDebug("Callback Body (Length: {Length}): {Xml}", xml.Length, xml);
  15.     // 1. 验证签名
  16.     // 如果是安全模式 (encryptType == "aes"),使用 VerifyEventSignatureFromXml (需要 msg_signature)
  17.     // 如果是明文模式,SDK 内部 DeserializeEventFromXml 也会做一些校验,但通常明文模式签名校验使用 signature (VerifyEventSignatureForEcho logic)
  18.     // 这里主要处理安全模式,因为明文模式下通常不需要复杂的解密验证
  19.     if (string.Equals(encryptType, "aes", StringComparison.OrdinalIgnoreCase)) {
  20.         if (string.IsNullOrEmpty(msgSignature)) {
  21.             return BadRequest("msg_signature is required for aes encryption");
  22.         }
  23.         var verifyResult = _client.VerifyEventSignatureFromXml(timestamp, nonce, xml, msgSignature);
  24.         if (!verifyResult.Result) {
  25.             _logger.LogWarning("Callback signature verification failed. Error: {Error}", verifyResult.Error?.Message);
  26.             return BadRequest($"Verify signature failed: {verifyResult.Error?.Message}");
  27.         }
  28.     }
  29.     else {
  30.         // 明文模式,可以使用 signature 验证 (可选)
  31.         // var verifyResult = _client.VerifyEventSignatureForEcho(timestamp, nonce, signature);
  32.     }
  33.     // 2. 使用 SKIT 库自动解密并反序列化
  34.     WechatApiEvent wechatEvent;
  35.     try {
  36.         wechatEvent = _client.DeserializeEventFromXml(xml);
  37.         _logger.LogInformation("Callback deserialized successfully. MessageType: {MessageType}, FromUser: {FromUser}, ToUser: {ToUser}",
  38.                                wechatEvent.MessageType, wechatEvent.FromUserName, wechatEvent.ToUserName);
  39.     } catch (Exception ex) {
  40.         _logger.LogError(ex, "Callback deserialization failed.");
  41.         return BadRequest($"Deserialization failed: {ex.Message}");
  42.     }
  43.     switch (wechatEvent.MessageType?.ToLower()) {
  44.         case "text":
  45.             var textEvent = _client.DeserializeEventFromXml<TextMessageEvent>(xml);
  46.             if (!string.IsNullOrEmpty(textEvent.Content) &&
  47.                 !string.IsNullOrEmpty(textEvent.FromUserName)) {
  48.                 _logger.LogInformation("Processing TEXT message from {FromUser}: {Content}", textEvent.FromUserName, textEvent.Content);
  49.                 var isSafetyMode = string.Equals(encryptType, "aes", StringComparison.OrdinalIgnoreCase);
  50.                 var textReply = new TextMessageReply {
  51.                     ToUserName = textEvent.FromUserName,
  52.                     FromUserName = textEvent.ToUserName,
  53.                     MessageType = "text",
  54.                     Content = "这里是回复给用户的内容",
  55.                     CreateTimestamp = DateTimeOffset.Now.ToUnixTimeSeconds()
  56.                 };
  57.                 var replyXml = _client.SerializeEventToXml(textReply, isSafetyMode);
  58.                 return Content(replyXml, "application/xml");
  59.             }
  60.             break;
  61.         default:
  62.             _logger.LogInformation("Ignored message type: {MessageType}", wechatEvent.MessageType);
  63.             break;
  64.     }
  65.     return Ok("success");
  66. }
复制代码
可以看到代码里判断是 text 类型后,构造了 TextMessageReply 类型的数据,然后调用 SKIT.FlurlHttpClient.Wechat 库提供的 XML 序列化方法。
这个库封装了直接序列化被动回复事件的扩展方法,默认会序列化为安全模式。
接入登录

微信登录和大部分第三方单点认证流程差不多,已经写过好多次了。
不再赘述这个流程,感兴趣的同学可以看这篇文章: Django+Taro项目实现企业微信登录
本次我没有接入登录,而是用了另一种方式实现微信和平台用户的关联,就是平台上生成一个key,让用户在微信发送,感觉还挺有意思的,另辟蹊径。
所以这里搬运一下我之前做的单点认证项目里的代码吧,详情可以看这篇文章: IdentityServerLite项目和近期的开源计划
  1. /// <summary>
  2. /// 企业微信登录 - 使用回调的 code 登录
  3. /// </summary>
  4. /// <param name="code"></param>
  5. /// <param name="state">一些让微信转发传给后端的参数,这里是单点认证项目的session_id</param>
  6. [HttpGet("wecom/login")]
  7. public async Task<IActionResult> WecomLogin(string code, string? state = null) {
  8.     logger.LogInformation("企业微信登录,code: {code}, state: {state}, crop: {cropTag}", code, state, cropTag);
  9.     if (string.IsNullOrWhiteSpace(state)) {
  10.         return BadRequest(new ApiResponse { Message = "企业微信登录的 state 为空,无法获取 session" });
  11.     }
  12.     var session = await authService.GetSession(state);
  13.     if (session == null) {
  14.         return NotFound(new ApiResponse { Message = $"session {state} 不存在!" });
  15.     }
  16.     var userInfo = await wecomService.GetUserInfo(code);
  17.     if (userInfo == null) {
  18.         return BadRequest(new ApiResponse { Message = "获取 userinfo 错误!" });
  19.     }
  20.     if (userInfo.Errcode != 0) {
  21.         return BadRequest(new ApiResponse { Message = $"获取用户信息失败,企微错误信息: {userInfo.Errmsg}" });
  22.     }
  23.     var wechatUser = await wecomService.GetUser(userInfo.Userid);
  24.     if (wechatUser == null) {
  25.         return BadRequest(new ApiResponse { Message = "获取 user 错误!" });
  26.     }
  27.     var user = await userRepo.Where(a => a.PhoneNumber == wechatUser.Userid).FirstAsync();
  28.     // 用户不存在的话,自动创建用户
  29.     if (user == null) {
  30.         user = await accountService.CreateUser(
  31.             await accountService.GenerateUsername(wechatUser.Name),
  32.             wechatUser.Userid,
  33.             wechatUser.Name
  34.         );
  35.         logger.LogInformation("用户 {Phone} 不存在,已创建新用户 {UserId}",
  36.                               wechatUser.Userid, user.Id);
  37.         // return NotFound(new ApiResponse { Message = $"用户 {wechatUser.Userid} 不存在!" });
  38.     }
  39.     try {
  40.         var url = await authService.LoginSessionAndGetUri(session, user, true);
  41.         logger.LogInformation("企业微信登录成功,跳转到链接: {url}", url);
  42.         return Redirect(url);
  43.     }
  44.     catch (Exception ex) {
  45.         ex.ToExceptionless().Submit();
  46.         return Problem($"企业微信登录失败: LoginSessionAndGetUri 失败 - {ex.Message}");
  47.     }
  48. }
复制代码
小结


大概就是这些了,很繁琐,不过还挺好用的,这些代码写完后几乎是一次就对接通过,想起来以前反复调试的经历,感叹:日子也是好起来了呀!
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

2026-1-25 07:50:12

举报

您需要登录后才可以回帖 登录 | 立即注册