返回文章列表文章索引阅读全文
STARBLOG / 阅读手记
Asp-Net-Core开发笔记

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.

曦远 (DealiAxy)约 55 分钟
目录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 许可协议。转载请注明出处!

相关文章

讨论

共 0 条

良言一句三冬暖,恶语伤人六月寒。

加载评论中…