跳到主要内容WebApi 项目集成企业微信和公众号 | 极客日志C#WeChatAI
WebApi 项目集成企业微信和公众号
本文介绍了在 WebApi 项目中集成企业微信和微信公众号的技术方案。主要内容包括:配置依赖库(SKIT.FlurlHttpClient.Wechat),注册服务,使用 IMemoryCache 管理 AccessToken,实现回调验证与消息接收(文本/图片),通过异步任务队列处理耗时操作(如调用 LLM),以及简单的用户关联登录流程。代码基于 C# 实现。
CoderByte3 浏览 前置工作
依赖库
其中:
- 企业微信:SKIT.FlurlHttpClient.Wechat.Work
- 公众号:SKIT.FlurlHttpClient.Wechat.Api
微信配置信息
需要准备这些配置信息:
企业微信:
public class WechatWorkOptions {
public string CorpId { get; set; } = string.Empty;
public int AgentId { get; set; }
public string Secret { get; set; } = string.Empty;
public string CallbackToken { get; set; } = string.Empty;
public string CallbackEncodingAESKey { get; set; } = string.Empty;
}
公众号:
public class WechatApiClientOptions {
public string AppId { get; set; } = string.Empty;
public string AppSecret { get; set; } = .Empty;
CallbackToken { ; ; } = .Empty;
CallbackEncodingAESKey { ; ; } = .Empty;
}
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- RSA密钥对生成器
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
- Mermaid 预览与可视化编辑
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown 转 HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
- HTML 转 Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
string
public
string
get
set
string
public
string
get
set
string
注册服务
builder.Services.AddSingleton<WechatWorkClient>(sp => {
var options = sp.GetRequiredService<IOptions<WechatWorkOptions>>().Value;
return WechatWorkClientBuilder.Create(options).Build();
});
builder.Services.AddSingleton<WechatApiClient>(sp => {
var options = sp.GetRequiredService<IOptions<WechatMpOptions>>().Value;
return WechatApiClientBuilder.Create(options).Build();
});
管理 token
微信的接口都需要用 AccessToken 才能调用,但微信又不想开发者每次都去请求获取 token,所以只能获取一次然后自己保存了。
C# 可以用 IMemoryCache 组件,很方便的管理这些临时存储的数据;其他语言框架可以用 Redis 这类 NoSQL 数据库来存储。本文重点介绍 C# 的实现。
我用一个 WechatWorkTokenService 服务来管理企业微信的 token(公众号、小程序这种也是同理)。
public class WechatWorkTokenService(
WechatWorkClient client,
IMemoryCache cache,
IOptions<WechatWorkOptions> options
) : IWechatWorkTokenService {
private const string CacheKey = "WechatWorkAccessToken";
private static readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1, 1);
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken = default) {
if (cache.TryGetValue(CacheKey, out string? accessToken) && !string.IsNullOrEmpty(accessToken)) {
return accessToken;
}
await Semaphore.WaitAsync(cancellationToken);
try {
if (cache.TryGetValue(CacheKey, out accessToken) && !string.IsNullOrEmpty(accessToken)) {
return accessToken;
}
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;
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,
ILogger<WechatWorkController> logger
) : ControllerBase {
[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 请求。
代码里有详细注释了,应该不用解释太多。
[HttpPost]
public async Task<IActionResult> Callback(
[FromQuery(Name = "msg_signature")] string msgSignature,
[FromQuery(Name = "timestamp")] string timestamp,
[FromQuery(Name = "nonce")] string nonce
) {
using var reader = new StreamReader(Request.Body);
var xml = await reader.ReadToEndAsync();
logger.LogDebug("Callback Body (Length: {Length}): {Xml}", xml.Length, xml);
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}");
}
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)) {
var textEvent = client.DeserializeEventFromXml<TextMessageEvent>(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.DeserializeEventFromXml<ImageMessageEvent>(xml);
if (imageEvent != null && !string.IsNullOrEmpty(imageEvent.MediaId) && !string.IsNullOrEmpty(imageEvent.FromUserName)) {
logger.LogInformation("Processing IMAGE message from {FromUser}: {MediaId}", textEvent.FromUserName, imageEvent.MediaId);
await ProcessImageMessageAsync(textEvent.FromUserName, imageEvent.MediaId);
}
} else {
logger.LogInformation("Ignored message type: {MessageType}", wechatEvent.MessageType);
}
return Ok("success");
}
异步处理信息
因为企业微信可以主动给用户发信息,所以可以把接收和发送信息分开,例如调用 LLM 处理回复的时候,会比较慢,可以把回复放到异步任务队列里去实现。
文本信息
private async Task ProcessTextMessageAsync(string toUser, string content) {
await queue.QueueBackgroundWorkItemAsync(async (serviceProvider, token) => {
var chatBot = serviceProvider.GetRequiredService<IChatBotService>();
var logger = serviceProvider.GetRequiredService<ILogger<WechatWorkController>>();
try {
logger.LogInformation("Processing background task for user {ToUser}", toUser);
string reply = await chatBot.ProcessMessageAsync(content);
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),然后再把结果发出来。
private async Task ProcessImageMessageAsync(string toUser, string mediaId) {
await queue.QueueBackgroundWorkItemAsync(async (serviceProvider, token) => {
var chatBot = serviceProvider.GetRequiredService<IChatBotService>();
var wechatService = serviceProvider.GetRequiredService<IWechatWorkService>();
var tokenService = serviceProvider.GetRequiredService<IWechatWorkTokenService>();
var logger = serviceProvider.GetRequiredService<ILogger<WechatWorkController>>();
var wechatClient = serviceProvider.GetRequiredService<WechatWorkClient>();
try {
logger.LogInformation("Processing background image task for user {ToUser}", toUser);
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)
};
var chatMessage = new ChatMessageContent(AuthorRole.User, items);
var reply = await chatBot.ProcessMessageAsync(chatMessage);
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 秒内必须回复。
验证回调这里就不重复了,和企业微信是一样的。
[HttpPost]
public async Task<IActionResult> 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);
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 {
}
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.DeserializeEventFromXml<TextMessageEvent>(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 序列化方法。
这个库封装了直接序列化被动回复事件的扩展方法,默认会序列化为安全模式。
接入登录
微信登录和大部分第三方单点认证流程差不多,已经写过好多次了。
不再赘述这个流程。
本次我没有接入登录,而是用了另一种方式实现微信和平台用户的关联,就是平台上生成一个 key,让用户在微信发送,感觉还挺有意思的,另辟蹊径。
所以这里搬运一下我之前做的单点认证项目里的代码吧。
[HttpGet("wecom/login")]
public async Task<IActionResult> 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);
}
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}");
}
}