摘要:在无人值守的后台服务里,"出了问题有人知道"比"程序不崩"更重要。本文以自动下单客户端的企业微信告警模块为例,从群机器人 Webhook 原理讲起,手把手实现一个支持文本/Markdown 双通道、按类型限流、可 @ 指定成员的告警服务,并给出三个真实业务场景的接入示例。

一、企业微信通知机器人创建步骤

  1. 打开企业微信客户端,进入(或创建一个)用于接收告警的群聊;
  2. 点击群聊右上角 "..." → 群机器人 → 添加机器人;
  3. 填写机器人名称(如"XX客户端告警机器人"),复制Webhook地址,点击"保存";
  4. 添加成功后,复制生成的 Webhook 地址,形如:
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

二、背景与痛点

桌面客户端跑在用户电脑上,通常无人值守。深夜账号掉线、全部账号离线这类异常,如果只是写日志,第二天才发现往往已经错过处理时机:

  • 日志只在出问题的机器上,远程看日志成本高、不及时;
  • 邮箱告警需要申请、配置 SMTP,维护成本重;
  • 短信/电话告警费用高,不适合高频事件。

把关键事件推送到手机上,让运维在微信里直接收到结构化告警,是最低成本、零运维的方案。企业微信群机器人恰好提供了开箱即用的 Webhook 接口——无需申请企业微信应用、无需 AppSecret,拉个群扫码就能用,5 分钟即可接入。

三、企业微信机器人 Webhook 原理

在企业微信群中添加"群机器人"后,会得到一个形如:

https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

的 Webhook 地址。向该地址 POST 一个 JSON 即可发消息,支持的主要消息类型如下:

类型用途典型场景
text纯文本,可指定 mentioned_list(userid)或 mentioned_mobile_list(手机号)@ 成员普通通知、连通性测试
markdownMarkdown 富文本,支持标题、加粗、引用、表格结构化告警、状态汇总
news图文卡片需要跳转链接的推送
file文件消息,需先上传获得 media_id日志、截图

使用中需注意两个关键限制:

  1. 发送频率限制:每个机器人 20 条/分钟,超限返回 errcode: 45009
  2. 只能主动推送:机器人消息不支持被动回复(对告警场景够用)。

发送成功时响应体:

{ "errcode": 0, "errmsg": "ok" }

四、基础封装:文本与 Markdown 两个通道

4.1 文本消息,支持 @ 成员

/// <summary>
/// 发送文本消息
/// </summary>
/// <param name="content">消息内容</param>
/// <param name="mentionedList">@的成员列表(手机号或 userid)</param>
/// <returns>是否发送成功</returns>
public async Task<bool> SendTextMessageAsync(string content, List<string>? mentionedList = null)
{
    try
    {
        using var httpClient = new HttpClient();
        httpClient.Timeout = TimeSpan.FromSeconds(10);

        var payload = new
        {
            msgtype = "text",
            text = new
            {
                content = content,
                mentioned_list = mentionedList ?? new List<string>()
            }
        };

        var json = JsonSerializer.Serialize(payload);
        var contentData = new StringContent(json, Encoding.UTF8, "application/json");
        var response = await httpClient.PostAsync(WebhookUrl, contentData);
        var responseContent = await response.Content.ReadAsStringAsync();

        if (response.IsSuccessStatusCode)
        {
            using var doc = JsonDocument.Parse(responseContent);
            var errcode = doc.RootElement.GetProperty("errcode").GetInt32();

            if (errcode == 0)
            {
                _logger.LogInformation("企业微信消息推送成功");
                return true;
            }
            else
            {
                var errmsg = doc.RootElement.GetProperty("errmsg").GetString();
                _logger.LogError("企业微信消息推送失败: errcode={Errcode}, errmsg={Errmsg}", errcode, errmsg);
                return false;
            }
        }
        else
        {
            _logger.LogError("企业微信消息推送HTTP请求失败: StatusCode={StatusCode}", response.StatusCode);
            return false;
        }
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "企业微信消息推送异常");
        return false;
    }
}

要点:

  • 永远返回 bool 而不是抛异常。通知是旁路逻辑,推送失败不能影响主流程(如下单);
  • mentioned_list 传空数组而不是 null,否则微信侧可能报参数错误;
  • 若按手机号 @,请使用 mentioned_mobile_list 字段,二者互斥。

4.2 Markdown 消息,构建富文本告警

/// <summary>
/// 发送Markdown消息
/// </summary>
/// <param name="content">Markdown内容</param>
/// <returns>是否发送成功</returns>
public async Task<bool> SendMarkdownMessageAsync(string content)
{
    try
    {
        using var httpClient = new HttpClient();
        httpClient.Timeout = TimeSpan.FromSeconds(10);

        var payload = new
        {
            msgtype = "markdown",
            markdown = new
            {
                content = content
            }
        };

        var json = JsonSerializer.Serialize(payload);
        var contentData = new StringContent(json, Encoding.UTF8, "application/json");
        var response = await httpClient.PostAsync(WebhookUrl, contentData);
        var responseContent = await response.Content.ReadAsStringAsync();

        if (response.IsSuccessStatusCode)
        {
            using var doc = JsonDocument.Parse(responseContent);
            var errcode = doc.RootElement.GetProperty("errcode").GetInt32();

            if (errcode == 0)
            {
                _logger.LogInformation("企业微信Markdown消息推送成功");
                return true;
            }
            else
            {
                var errmsg = doc.RootElement.GetProperty("errmsg").GetString();
                _logger.LogError("企业微信Markdown消息推送失败: errcode={Errcode}, errmsg={Errmsg}", errcode, errmsg);
                return false;
            }
        }
        else
        {
            _logger.LogError("企业微信Markdown消息推送HTTP请求失败: StatusCode={StatusCode}", response.StatusCode);
            return false;
        }
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "企业微信Markdown消息推送异常");
        return false;
    }
}

content 中可以组合企业微信支持的 Markdown 语法,例如:

## 采购账号全部离线告警
> <font color="warning">⚠️ 检测到所有账号在 5 分钟内无可用状态</font>

- **告警时间**:2026-08-21 03:12:45
- **账号数量**:8
- **当前状态**:全部离线

---
> 请及时登录账号,避免漏单

五、告警层:限流是灵魂

直接暴露 SendTextMessageAsync 给业务方有个问题:高频异常会刷屏。比如账号反复掉线重登,每分钟可能触发 10 条告警,不仅骚扰,还容易撞上 20 条/分钟的接口上限,导致真正致命的告警被挤掉。

项目的解法是按 alertType 做时间窗口限流

/// <summary>
/// 发送异常告警消息(带推送频率限制)
/// </summary>
/// <param name="alertType">告警类型(用于频率限制key)</param>
/// <param name="title">告警标题</param>
/// <param name="message">告警消息</param>
/// <param name="details">详细信息</param>
/// <returns>是否发送成功(如果被限流则返回true表示跳过)</returns>
public async Task<bool> SendAlertAsync(string alertType, string title, string message, string? details = null)
{
    // 检查推送频率限制
    var now = DateTime.Now;
    if (_lastPushTime.TryGetValue(alertType, out var lastTime))
    {
        if (now - lastTime < MinPushInterval)
        {
            _logger.LogInformation("告警推送被限流: AlertType={AlertType}, 距离上次推送仅 {Seconds} 秒",
                alertType, (int)(now - lastTime).TotalSeconds);
            return true;
        }
    }

    var timestamp = now.ToString("yyyy-MM-dd HH:mm:ss");
    var markdownContent = $"## ⚠️ {title}\n\n" +
                         $"> **时间**: {timestamp}\n" +
                         $"> **描述**: {message}\n";

    if (!string.IsNullOrEmpty(details))
    {
        markdownContent += $"> **详情**: {details}\n";
    }

    markdownContent += "\n---\n";

    var success = await SendMarkdownMessageAsync(markdownContent);

    if (success)
    {
        _lastPushTime[alertType] = now;
    }

    return success;
}

配套字段定义:

// 防止重复推送的时间间隔(秒)
private static readonly Dictionary<string, DateTime> _lastPushTime = new();
private static readonly TimeSpan MinPushInterval = TimeSpan.FromMinutes(5);

设计细节解析

  1. 限流键是 alertType 而非全局"AllAccountsOffline"(全部离线)和 "LoginTimeout_10086"(某账号掉线)互不干扰,一个账号反复掉线不会淹没全局告警。更精细的做法是 $"LoginTimeout_{AccountId}",把粒度直接打到账号级。

  2. 被限流时返回 true。语义是"本次告警按预期被合并/跳过,不算失败",避免业务侧看到 false 误以为推送通道故障,再触发补偿逻辑,形成告警风暴。

  3. _lastPushTime 用普通 Dictionary 而非 ConcurrentDictionary。告警场景低频(分钟级),单线程执行下无需加锁;如果告警频率很高,换成 ConcurrentDictionary 即可,成本为零。

  4. 失败不记时间。只有发送成功才更新 _lastPushTime,网络抖动导致的单次失败不会白白消耗限流窗口。

六、接入业务场景

项目中有三个典型接入点,覆盖"验证、主动通知、异常告警"三类用法。

6.1 设置页连通性测试

UI 层点"测试推送"按钮时,走文本通道验证 Webhook 可用:

var testMessage = $"✅ 企业微信通知测试成功!\n" +
                  $"📌 项目:采购下单客户端\n" +
                  $"⏰ 发送时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}";

var success = await _notificationService.SendTextMessageAsync(testMessage);

if (success)
{
    messageService?.SendMessage("企业微信测试推送成功",
        severity: SpeedyOrderPlacement.Core.Models.InfoBarSeverity.Success);
}

6.2 账号掉线超时告警(按账号维度限流)

TaobaoCookieRefreshService 中,自动登录超时即触发告警:

// 自动登录超时(> 1 分钟),说明需要人工介入
var alertMessage = $"采购账号 [{target.DisplayName}] (ID: {target.AccountId}) " +
                   $"自动登录耗时超过一分钟(已耗时 {elapsedSeconds} 秒),需要人工介入";

await _notificationService.SendAlertAsync(
    alertType: $"LoginTimeout_{target.AccountId}",     // 每个账号独立限流
    title: "采购账号掉线超时告警",
    message: alertMessage,
    details: $"登录开始时间: {loginStartTime:HH:mm:ss}, " +
             $"超时时间: {_autoLoginTimeout.TotalSeconds}秒");

6.3 全局离线告警(单例限流)

PurchaseOrderPollingService 中检测到所有账号连续 5 分钟无可用时触发,5 分钟内只推一次:

var alertMessage = $"轮询出现长时间无可用采购账号!所有账号均未登录。" +
                   $"总账号数: {totalCount}, 已登录: {loggedInCount}, " +
                   $"未登录: {notLoggedInCount}, 已禁用: {disabledCount}, " +
                   $"持续时间: {(int)elapsed.TotalMinutes} 分钟";

await _notificationService.SendAlertAsync(
    alertType: "AllAccountsOffline",                   // 全局事件固定类型名
    title: "采购账号全部离线告警",
    message: alertMessage,
    details: $"告警开始时间: {_noAvailableAccountStartTime.Value:HH:mm:ss}, " +
             $"当前时间: {now:HH:mm:ss}");

两个场景正好演示了限流键的设计差异:账号级事件用 {类型}_{账号Id} 做键,全局事件用固定类型名做键

七、依赖注入与配置

服务通过构造函数注入 ILogger,在模块中注册为单例:

services.AddSingleton<WeChatWorkNotificationService>();

Webhook 地址建议从 AppSettings / 配置文件读取,不要把 key 硬编码进代码,否则换群就得改代码重新发布:

{
  "WeChatWork": {
    "WebhookUrl": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx"
  }
}

八、可继续深化的方向

  1. 图片告警:先调 webhook/upload_media 上传图片拿到 media_id,再发 image 消息——非常适合"滑块出现时自动截图推送";
  2. 告警分级SendAlertAsync 增加 level 参数,error/warning/info 对应不同限流窗口(如 error 5 分钟、warning 30 分钟);
  3. 失败重试errcode == 45009(限流)时指数退避重试,45033(webhook 被封禁)时直接停服上报,避免无效重试;
  4. 消息队列化:高频事件先入 Channel,后台消费者按批合并推送,进一步降低触发频率;
  5. 多通道兼容:把"限流 + 组装 + 发送"抽象成接口,同一套逻辑可复用于钉钉、飞书、Telegram Bot 等 Webhook 渠道。

九、总结

一套可靠的企业微信告警,核心其实不在 Webhook 本身(本质只是一个 POST),而在旁路设计限流策略

设计原则实现手段收益
推送失败不影响主流程返回 bool 不抛异常告警是旁路,不能拖垮业务
重要的必达按 alertType 分类限流全局异常不会被账号级异常淹没
重复的不刷屏时间窗口限流(5 分钟)不骚扰、不撞接口上限
限流窗口不被浪费仅发送成功后更新时间戳网络抖动不消耗告警配额

这套模式稍作改造即可复用到钉钉、飞书、Telegram Bot 等任意 Webhook 类推送渠道,建议直接沉淀为团队通用的通知组件。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐