AspNetCore Development Notes: Integrating WeCom and Official Accounts in a WebApi Project
WeChat's developer docs are ridiculously awful, so I just used the SKIT library. Async message handling, in-memory image downloads, Official Account passive replies done within 5 seconds. Tsk tsk.
目录15 个章节
Introduction
It's been a long time since I wrote an article. Now that we have AI, there's not really much need to write articles anyway—if you don't get something, just ask AI.
But AI always has blind spots, like WeChat development in China.
WeChat's documentation is notoriously terrible, and they often quietly change APIs without updating the docs, so AI isn't really familiar with WeChat development APIs and often gives incorrect answers.
This article records my recent process of integrating WeCom and WeChat Official Accounts into a C# WebApi project, mainly using the auto-reply feature.
Prerequisites
Dependencies
I used the SKIT.FlurlHttpClient.Wechat series of libraries: https://github.com/fudiwei/DotNetCore.SKIT.FlurlHttpClient.Wechat
I originally wanted to integrate with Flurl directly. After all, now that I have a hammer (AI), everything looks like a nail, and I want to reinvent every wheel.
But after searching my bookmarks, I found this project that wraps most of WeChat's APIs. Who needs a bicycle? Just use it and you're done.
Among them:
- WeCom: SKIT.FlurlHttpClient.Wechat.Work
- Official Account: SKIT.FlurlHttpClient.Wechat.Api
WeChat Configuration
You need to prepare the following configuration:
WeCom:
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;
}
Official Account:
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;
}
Registering Services
// 企业微信
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();
});
That's the prep work done.
Managing Tokens
WeChat APIs all require an AccessToken to call, but WeChat doesn't want developers to request a token every time, so you can only fetch it once and store it yourself.
In C# you can use the IMemoryCache component, which makes it convenient to manage this kind of temporary data. The Django framework also has a built-in cache mechanism, and other language frameworks can use NoSQL databases like Redis for storage. Anyway, that's a tangent—this article is still about C#.
I use a WechatWorkTokenService to manage WeCom tokens (the same idea applies to Official Accounts, Mini Programs, and the like)
public class WechatWorkTokenService(
WechatWorkClient client,
IMemoryCache cache,
IOptions<WechatWorkOptions> options
) : IWechatWorkTokenService {
private const string CacheKey = "WechatWorkAccessToken";
// 用于并发控制,防止瞬间高并发导致多次请求 Token 接口
private static readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1, 1);
/// <summary>
/// 获取 AccessToken
/// </summary>
public async Task<string> 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();
}
}
}
WeCom
WeCom has fewer restrictions and can proactively send messages to users, so you can separate receiving and sending. For example, when calling an LLM to generate a reply, which can be slow, you can put the reply into an async task queue.
Callback Verification
Here's the endpoint code.
When configuring the WeCom app URL, the WeChat server will send a GET request to the configured URL for verification. The backend needs to verify the signature, decrypt it, and echo the content back to the WeChat server.
The following endpoint implements this verification.
After implementing this, just fill in the address 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 {
/// <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}");
}
}
Receiving Messages
Receiving messages uses the same URL as the verification above. The difference is that when receiving messages, the WeChat server sends a POST request to the URL.
The code has detailed comments, so there shouldn't be much need for explanation.
/// <summary>
/// 接收消息 (POST)
/// </summary>
[HttpPost]
public async Task<IActionResult> 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.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}", imageEvent.FromUserName, imageEvent.MediaId);
await ProcessImageMessageAsync(imageEvent.FromUserName, imageEvent.MediaId);
}
}
else {
logger.LogInformation("Ignored message type: {MessageType}", wechatEvent.MessageType);
}
return Ok("success");
}
Asynchronous Message Processing
Because WeChat Work can proactively send messages to users, you can separate receiving and sending. For example, when calling an LLM to process a reply, it can be relatively slow, so you can put the reply into an asynchronous task queue.
Text Messages
Processing plain text is still relatively simple.
/// <summary>
/// 异步处理文本消息
/// </summary>
private async Task ProcessTextMessageAsync(string toUser, string content) {
await queue.QueueBackgroundWorkItemAsync(async (serviceProvider, token) => {
// 在后台任务中解析 Scoped 服务
var chatBot = serviceProvider.GetRequiredService<IChatBotService>();
var logger = serviceProvider.GetRequiredService<ILogger<WechatWorkController>>();
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);
}
});
}
Image Messages
Images are a bit more troublesome. WeChat doesn't send the image data directly; instead it gives you a mediaId that we have to download ourselves.
C# is still convenient here—you can download the image into memory and hand it off to a third-party service (such as OCR), then send the result back.
/// <summary>
/// 异步处理图片消息
/// </summary>
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);
// 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);
}
});
}
Official Account
Alright, WeCom is done. Next, let's look at Official Accounts.
Official Accounts are different from WeCom: they cannot send messages proactively, so when a user message is received, you must return an XML-formatted response as the reply, and you must reply within 5 seconds.
I won't repeat the callback verification here; it's the same as with WeCom.
/// <summary>
/// 接收消息 (POST)
/// </summary>
[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);
// 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.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");
}
As you can see, after the code determines the type is text, it constructs a TextMessageReply object, then calls the XML serialization method provided by the SKIT.FlurlHttpClient.Wechat library.
This library wraps an extension method that serializes passive reply events directly, and by default it serializes to safe mode.
Integrating Login
WeChat login is similar to most third-party SSO flows; I've written about it many times already.
I won't go over this flow again. Interested readers can check out this article: Implementing WeCom Login in a Django+Taro Project
This time I didn't integrate login. Instead, I used another approach to associate WeChat users with platform users: generate a key on the platform and have the user send it in WeChat. I found it pretty interesting—taking a different path.
So I'll just copy over some code from a previous SSO project of mine. For details, see this article: The IdentityServerLite Project and Recent Open-Source Plans
/// <summary>
/// 企业微信登录 - 使用回调的 code 登录
/// </summary>
/// <param name="code"></param>
/// <param name="state">一些让微信转发传给后端的参数,这里是单点认证项目的session_id</param>
[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);
// 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}");
}
}
Recap
That's about it. It's quite tedious, but pretty useful. After writing this code, the integration almost went through on the first try. Thinking back to the days of debugging over and over, I have to say: life has gotten better! 😄
Another thought: Semantic Kernel is really great. I've used LangChain for too long—I should have switched to Semantic Kernel sooner.
_EOF_
AspNetCore Development Notes: Integrating WeCom and Official Accounts in a WebApi Project
- 本文作者:
- 程序设计实验室
- 版权声明:
- 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
相关文章
Asp-Net-Core开发笔记:EFCore统一实体和属性命名风格
EFCore居然没有内置命名转换?得自己写正则或用插件,真是折腾。插件一行代码搞定,但为什么EFCore不直接支持?命名风格转换,要么自己写代码,要么依赖第三方,真费劲。
2023-10-10 · Asp-Net-Core开发笔记 · 约 1 分钟
Asp-Net-Core开发笔记:实现动态审计日志功能
审计日志不仅仅是一种遵循最佳实践的手段,更是确保数据安全、提高系统透明度、促进责任归属明晰的关键工具。
2024-03-21 · Asp-Net-Core开发笔记 · 约 1 分钟
Asp-Net-Core开发笔记:Oracle调优
Supported Connection String Attributes To obtain an OracleConnection object in a .NET stored procedure that represents the implicit database connection, set the ConnectionString property of the Oracle
2023-03-05 · Asp-Net-Core开发笔记 · 约 1 分钟
Asp-Net-Core开发笔记:在docker部署时遇到一个小坑
哦吼 之前刚说了尝试了使用docker来部署AspNetCore应用,结果这才刚上班就遇到问题了= =… 我这项目用的数据库是Oracle,之前直接运行没啥问题,但放在docker里运行就报了这个错误: ORA-01882: timezone region not found 看这错误信息是在说时区不对,分别在服务器和docker里看一下时间: [root@localhost ~]# date -
2022-02-16 · Asp-Net-Core开发笔记 · 约 1 分钟
讨论
共 0 条加载评论中…