Files
IOT_API/IOT_API/Services/AlertNotifyWorker.cs
T

231 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Model.Entity.Inspection;
using ORM;
using Service.Implement;
using Service.Interface;
using SqlSugar;
namespace WebAPI.Services
{
/// <summary>
/// 告警通知后台 Worker:消费 AlertNotifyBus 队列,按通知规则分发到飞书/钉钉/企微渠道
/// 流程:加载告警 → 匹配启用规则(按级别) → 静默期判断 → 直连推送(重试3次)或写跨网发件箱 → 记推送日志
/// </summary>
public class AlertNotifyWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AlertNotifyWorker> _logger;
/// <summary>
/// 直连推送最大尝试次数(含首次)
/// </summary>
private const int MaxAttempts = 3;
public AlertNotifyWorker(IServiceScopeFactory scopeFactory, ILogger<AlertNotifyWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("告警通知 Worker 已启动");
try
{
await foreach (var evt in AlertNotifyBus.Reader.ReadAllAsync(stoppingToken))
{
try
{
await ProcessEventAsync(evt, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "处理告警通知事件失败, AlertId={AlertId}", evt.AlertId);
}
}
}
catch (OperationCanceledException)
{
// 正常停机
}
}
/// <summary>
/// 处理单个告警通知事件
/// </summary>
private async Task ProcessEventAsync(AlertNotifyEvent evt, CancellationToken ct)
{
var db = SqlSugarContext.DbContext;
// 1. 加载告警消息
var alert = await db.Queryable<AlertMessageEntity>()
.Where(x => x.Id == evt.AlertId && x.IsDel == 0)
.FirstAsync();
if (alert == null) return;
// 2. 匹配启用的通知规则(按告警级别)
var rules = await db.Queryable<AlertNotifyRuleEntity>()
.Where(x => x.IsDel == 0 && x.IsEnabled && x.AlertLevel == alert.AlertLevel)
.ToListAsync();
if (rules.Count == 0) return;
// 3. 每个事件独立创建作用域(Worker 是单例,服务是 Scoped
using var scope = _scopeFactory.CreateScope();
var notifyService = scope.ServiceProvider.GetRequiredService<IAlertNotifyService>();
var notifiers = scope.ServiceProvider.GetServices<IAlertNotifier>().ToList();
foreach (var rule in rules)
{
var channel = await db.Queryable<AlertNotifyChannelEntity>()
.Where(x => x.Id == rule.ChannelId && x.IsDel == 0 && x.IsEnabled)
.FirstAsync();
if (channel == null) continue;
// 4. 静默期判断:合并触发的重复告警在静默期内不重复推送(新告警必推)
if (!evt.IsNewAlert && rule.SilenceMinutes > 0)
{
var silenceStart = DateTime.Now.AddMinutes(-rule.SilenceMinutes);
bool recentlyPushed = await db.Queryable<AlertNotifyLogEntity>()
.AnyAsync(x => x.AlertId == alert.Id && x.ChannelId == channel.Id
&& x.Success && x.NotifyTime >= silenceStart);
if (recentlyPushed) continue;
}
// 5. 组装消息与 @ 列表(规则接收人 + 当班值班人电话)
var message = BuildMessage(alert);
var atMobiles = new List<string>();
if (!string.IsNullOrWhiteSpace(rule.Receivers))
{
// 接收人常带 @ 前缀(如 "@18872509102"),但企微 mentioned_mobile_list / 钉钉 atMobiles 只认纯手机号,
// 带 @ 的非法值无法匹配群成员导致 @ 不生效,这里统一去掉前导 @ 与空白
atMobiles.AddRange(rule.Receivers.Split(',', '', ';', '')
.Select(s => s.Trim().TrimStart('@').Trim()).Where(s => s.Length > 0));
}
if (rule.NotifyDutyPerson)
{
var duty = await notifyService.GetDutyByDateAsync(alert.LastAlertTime.Date);
if (duty != null && !string.IsNullOrWhiteSpace(duty.Phone))
{
atMobiles.Add(duty.Phone.Trim());
message.Content += $"\n值班人: {duty.PersonName}";
}
}
atMobiles = atMobiles.Distinct().ToList();
// 飞书渠道:at 标签只认 open_id,不认手机号——把接收人里的手机号转成 open_id
// "all" 与 ou_ 开头的原样保留)。转换在内网主站完成,直连与跨网(Outbox)两条路径都拿到 open_idAlertBridge 无需改。
if (channel.ChannelType == NotifyChannelTypeEnum.Feishu && atMobiles.Count > 0)
{
var feishuContact = scope.ServiceProvider.GetRequiredService<IFeishuContactService>();
var openIds = new List<string>();
foreach (var at in atMobiles)
{
if (at.Equals("all", StringComparison.OrdinalIgnoreCase) || at.StartsWith("ou_"))
{
openIds.Add(at);
}
else
{
var openId = await feishuContact.GetOpenIdByMobileAsync(at);
if (!string.IsNullOrEmpty(openId)) openIds.Add(openId);
else _logger.LogWarning("飞书接收人 {Receiver} 未换取到 open_id,已跳过 @(检查飞书应用权限与通讯录范围)", at);
}
}
atMobiles = openIds.Distinct().ToList();
}
// 关键:@ 列表必须回填到 message——直连模式的 Notifier 是从 message.AtMobiles 读取的。
// 此前只把 atMobiles 传给了 Outbox 分支,直连 SendAsync(message) 的 message.AtMobiles 始终为空,导致企微/钉钉不 @ 任何人。
message.AtMobiles = atMobiles;
// 6. 按渠道推送模式分发
if (channel.PushMode == NotifyPushModeEnum.Outbox)
{
// 跨网模式:写发件箱,由跳板机 AlertBridge 拉取推送并回写结果
await notifyService.EnqueueOutboxAsync(alert.Id, channel.Id, message, atMobiles);
_logger.LogInformation("告警 {AlertId} 已转入跨网发件箱(渠道 {Channel}", alert.Id, channel.Name);
continue;
}
// 直连模式:查找对应 Notifier,失败重试
var notifier = notifiers.FirstOrDefault(n => n.ChannelType == channel.ChannelType);
if (notifier == null)
{
await notifyService.WriteLogAsync(alert.Id, channel.Id, message.Title,
false, $"暂不支持的渠道类型: {channel.ChannelType}", 0);
continue;
}
NotifyResult result = NotifyResult.Fail("未执行");
int attempt = 0;
while (attempt < MaxAttempts && !ct.IsCancellationRequested)
{
attempt++;
result = await notifier.SendAsync(message, channel.WebhookUrl, channel.Secret, ct);
if (result.Success) break;
if (attempt < MaxAttempts)
{
await Task.Delay(TimeSpan.FromSeconds(attempt * 2), ct); // 2s/4s 退避
}
}
await notifyService.WriteLogAsync(alert.Id, channel.Id,
message.Title + " " + Truncate(message.Content, 800),
result.Success, result.ErrorMsg, attempt - 1);
if (!result.Success)
{
_logger.LogWarning("告警 {AlertId} 推送渠道 {Channel} 失败: {Error}", alert.Id, channel.Name, result.ErrorMsg);
}
}
}
/// <summary>
/// 按内置模板构建通知消息(标题 + markdown 正文;后续可由通知模板模块接管)
/// </summary>
private static AlertNotifyMessage BuildMessage(AlertMessageEntity alert)
{
string levelLabel = AlertLevelMap.ToLabel(alert.AlertLevel);
var content = new System.Text.StringBuilder();
content.AppendLine($"- 设备: {alert.DeviceName}({alert.DeviceCode})");
if (!string.IsNullOrWhiteSpace(alert.DeviceType)) content.AppendLine($"- 类型: {alert.DeviceType}");
if (!string.IsNullOrWhiteSpace(alert.Metric)) content.AppendLine($"- 测点: {alert.Metric}");
if (alert.CurrentValue.HasValue) content.AppendLine($"- 当前值: {alert.CurrentValue.Value}");
if (alert.ThresholdValue.HasValue) content.AppendLine($"- 阈值: {alert.ThresholdValue.Value}");
content.AppendLine($"- 告警类型: {TypeLabel(alert.AlertType)}");
content.AppendLine($"- 来源: {SourceLabel(alert.Source)}");
content.AppendLine($"- 内容: {alert.Content}");
if (alert.TriggerCount > 1) content.AppendLine($"- 累计触发: {alert.TriggerCount} 次");
content.Append($"- 时间: {alert.LastAlertTime:yyyy-MM-dd HH:mm:ss}");
return new AlertNotifyMessage
{
Title = $"【{levelLabel}】设备告警 - {alert.DeviceName ?? alert.DeviceCode}",
Content = content.ToString()
};
}
private static string TypeLabel(AlertTypeEnum type) => type switch
{
AlertTypeEnum.ThresholdExceed => "阈值超限",
AlertTypeEnum.DeviceOffline => "设备离线",
AlertTypeEnum.CommFault => "通讯故障",
AlertTypeEnum.PatrolAbnormal => "巡检异常",
AlertTypeEnum.Custom => "自定义",
_ => "其它"
};
private static string SourceLabel(AlertSourceEnum source) => source switch
{
AlertSourceEnum.RuleEngine => "规则引擎",
AlertSourceEnum.AutoPatrol => "自动巡检",
AlertSourceEnum.Dashboard => "数据看板",
AlertSourceEnum.Collection => "数据采集",
AlertSourceEnum.Manual => "手动上报",
_ => "其它"
};
private static string Truncate(string s, int max) =>
string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
}
}