微信服务器会发送一个GET请求到配置的URL进行验证

面这个接口就实现了这个验证方法。

这样实现之后填写  https://example.com/api/wechat/work/callback 这个地址就好了。

[ApiController]
[AllowAnonymous]
[Route("api/wechat/work/callback")]
public class WechatWorkController(
    WechatWorkClient client,
    IBackgroundTaskQueue queue,
    ILogger logger
) : ControllerBase {    /// 
    /// 回调验证 (GET)
    /// 
    [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请求。

代码里有详细注释了,应该不用解释太多。

/// /// 接收消息 (POST)/// [HttpPost]
public async Task 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 cqpryk.com 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(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(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处理回复的时候,会比较慢,可以把回复放到异步任务队列里去实现。

文本信息 #

纯文本处理起来还是比较简单的。

/// /// 异步处理文本消息/// private async Task ProcessTextMessageAsync(string toUser, string content) {
    await queue.QueueBackgroundWorkItemAsync(async (serviceProvider, token) => {        // 在后台任务中解析 Scoped 服务
        var chatBot = serviceProvider.GetRequiredService();
        var logger = serviceProvider.GetRequiredService>();
        try {
            logger.LogInformation("Processing background task for user {ToUser}", toUser);            // 1. 调用 ChatBot 获取回复
            string reply =lnzxyp.com 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),然后再把结果发出来。

/// /// 异步处理图片消息/// private async Task ProcessImageMessageAsync(string toUser, string mediaId) {
    await queue.QueueBackgroundWorkItemAsync(async (serviceProvider, token) => {
        var chatBot = serviceProvider.GetRequiredService();
        var wechatService = serviceProvider.GetRequiredService();
        var tokenService = serviceProvider.GetRequiredService();
        var logger = serviceProvider.GetRequiredService>();
        var wechatClient =ttkqw.com serviceProvider.GetRequiredService();
        try {
            logger.LogInformation("Processing background image task for user {ToUser}", toUser);            // 1. Download Image
            var accessToken zggren.com= 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 ttkq-163.cn 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秒内必须回复。

验证回调这里就不重复了,和企业微信是一样的。

/// /// 接收消息 (POST)/// [HttpPost]
public async Task 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 gzbeimu.com("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(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 =xiqinhb.com "这里是回复给用户的内容",
                    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 序列化方法。

这个库封装了直接序列化被动回复事件的扩展方法,默认会序列化为安全模式。

接入登录 #

微信登录和大部分第三方单点认证流程差不多,已经写过好多次了。

不再赘述这个流程,感兴趣的同学可以看这篇文章:  Django+Taro项目实现企业微信登录

本次我没有接入登录,而是用了另一种方式实现微信和平台用户的关联,就是平台上生成一个key,让用户在微信发送,感觉还挺有意思的,另辟蹊径。

所以这里搬运一下我之前做的单点认证项目里的代码吧,详情可以看这篇文章:  IdentityServerLite项目和近期的开源计划

/// /// 企业微信登录 - 使用回调的 code 登录/// /// /// 一些让微信转发传给后端的参数,这里是单点认证项目的session_id[HttpGet("wecom/login")]
public async Task 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 =ftzhibo.cn 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 zgbdcg.com! livesoccerzb.cn= 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 ==zhiboapp.com.cn null) {
        user = await accountService.CreateUser(
            await accountService.GenerateUsername(wechatUser.Name),
            wechatUser.Userid,zq2026.cn
            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}");


请使用浏览器的分享功能分享到微信等