From a21b72f90c8838e21a04b71faa82f1847b496200 Mon Sep 17 00:00:00 2001 From: XuJiacheng Date: Fri, 27 Feb 2026 14:17:13 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E9=A1=B9=E7=9B=AE=20?= =?UTF-8?q?README=20=E6=96=87=E6=A1=A3=EF=BC=8C=E8=AF=A6=E7=BB=86=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E9=A1=B9=E7=9B=AE=E6=A6=82=E8=BF=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Models/TimerClass.cs | 625 +++++++++++++++++++++++++------------------ README.md | 357 ++++++++++++++++++++++++ 2 files changed, 718 insertions(+), 264 deletions(-) create mode 100644 README.md diff --git a/Models/TimerClass.cs b/Models/TimerClass.cs index 76f5133..d95253b 100644 --- a/Models/TimerClass.cs +++ b/Models/TimerClass.cs @@ -1,453 +1,519 @@ -using AutoNotificatPhone.Controllers; +using AutoNotificatPhone.Controllers; using Common; -using System.Threading; -using NLog; -using System.Diagnostics; -using Npgsql; using Microsoft.Extensions.Configuration; +using NLog; +using Npgsql; +using System.Diagnostics; namespace AutoNotificatPhone.Models { + /// + /// 定时后台服务: + /// 1) 每分钟固定时刻执行巡检 + /// 2) 执行整点/定时通知 + /// 3) 执行 Redis 指标告警 + /// 4) 执行 PostgreSQL 心跳检查告警 + /// public class TimerClass : BackgroundService { - // 日志记录器 - public static Logger logger = LogManager.GetCurrentClassLogger(); - // 通知接收手机号码 + // NLog 记录器 + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + // 告警通知接收手机号 private static readonly string Mobile1 = "13509214696"; private static readonly string Mobile2 = "16620970520"; - // 记录已执行任务的时间点 - private Dictionary _executedTasks = []; - // 消息控制器实例 - private readonly CallAndMsgController _callAndMsgController = new(); - private readonly IConfiguration _configuration; - // Kafka数据库连接失败累计次数 - private int _kafkaDbConnectionAlertCount = 0; + // 每日定时任务触发小时(北京时间) + private static readonly HashSet DailyTaskHours = [10, 15, 22]; + + // 每分钟在第 30 秒触发一次巡检 + private const int RunSecond = 30; + // 主循环异常后的重试等待时间(秒) + private const int RetryDelaySeconds = 10; + + // 默认短信/电话任务过期时间(秒) + private const int SmsDeadlineSeconds = 1800; + private const int CallDeadlineSeconds = 900; + // 扩展短信/电话任务过期时间(秒) + private const int ExtendedSmsDeadlineSeconds = 3600; + private const int ExtendedCallDeadlineSeconds = 1800; + + // Kafka 心跳超时阈值(分钟) + private const int KafkaStaleMinutes = 5; + // 数据库连接失败累计到 N 次才触发一次告警,避免告警风暴 + private const int KafkaDbAlertTriggerCount = 8; + + // 接收包“低值”判定阈值 + private const int RecvPackageLowThreshold = 70000; + + // 用于防止同一整点任务重复执行 + private readonly Dictionary _executedTasks = new(); + // 复用 API 控制器发送短信/电话任务 + private readonly CallAndMsgController _callAndMsgController = new(); + // 从 appsettings 读取 Postgres 配置 + private readonly IConfiguration _configuration; + + // Kafka 数据库连接失败计数器 + private int _kafkaDbConnectionAlertCount; + + /// + /// 构造函数,注入配置对象。 + /// + /// 应用配置(用于读取 Postgres 节点) public TimerClass(IConfiguration configuration) { _configuration = configuration; } /// - /// 后台服务主执行方法 + /// 后台服务主循环。 /// + /// 服务取消令牌 protected override async Task ExecuteAsync(CancellationToken cancellationToken) { - await Task.Factory.StartNew(async () => + while (!cancellationToken.IsCancellationRequested) { - while (!cancellationToken.IsCancellationRequested) + try { - try - { - // 计算下次执行时间(每分钟的30秒) - var now = DateTime.UtcNow; - var nextRunTime = CalculateNextRunTime(now); - var delayTime = nextRunTime - now; + // 等待到下一次固定执行时刻(每分钟第 RunSecond 秒) + await DelayUntilNextRunAsync(cancellationToken); - // 等待到下一个执行点 - await Task.Delay(delayTime, cancellationToken); + // 检查电话机进程在线状态并写日志 + LogPhoneStatus(CheckPhoneIsOnline()); - // 检查电话机状态并记录日志 - LogPhoneStatus(CheckPhoneIsOnline()); + // 执行整点/每日通知任务 + RunHourlyNotificationTask(); - // 执行定时任务 - await RegularlySendActiverecords(); - - // 执行系统检查任务 - await CheckCpuThresholdAsync(); - await CheckRcuOnlineAsync(); - await CheckTotalSendPackageAsync(); - await CheckTotalRecvPackageAsync(); - await CheckKafkaHeartbeatAsync(); - } - catch (TaskCanceledException) - { - // 任务被取消时的处理 - logger.Error("任务被取消"); - break; - } - catch (Exception ex) - { - // 异常处理 - logger.Error($"主循环发生错误: {ex.Message}"); - await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); - } + // 执行各项系统检查任务 + CheckCpuThreshold(); + CheckRcuOnline(); + CheckTotalSendPackage(); + CheckTotalRecvPackage(); + await CheckKafkaHeartbeatAsync(); } - }, TaskCreationOptions.LongRunning); + catch (TaskCanceledException) + { + // 服务停止时会进入这里 + Logger.Error("任务被取消"); + break; + } + catch (Exception ex) + { + // 主循环兜底异常,稍后重试 + Logger.Error($"主循环发生错误: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(RetryDelaySeconds), cancellationToken); + } + } } /// - /// 计算下次执行时间(每分钟的30秒) + /// 等待到下一次固定执行时间。 /// - private DateTime CalculateNextRunTime(DateTime now) + /// 取消令牌 + private async Task DelayUntilNextRunAsync(CancellationToken cancellationToken) { - var nextRunTime = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 30); - return now.Second >= 30 ? nextRunTime.AddMinutes(1) : nextRunTime; + var now = DateTime.UtcNow; + var nextRunTime = CalculateNextRunTime(now); + var delayTime = nextRunTime - now; + // 到点前阻塞等待 + await Task.Delay(delayTime, cancellationToken); } /// - /// 记录电话机状态日志 + /// 计算下一次执行时间点(每分钟第 RunSecond 秒)。 /// - private void LogPhoneStatus(bool isOnline) + /// 当前 UTC 时间 + /// 下一次执行时间 + private static DateTime CalculateNextRunTime(DateTime now) { - logger.Error(isOnline ? "电话机在线,开始判断!+++++str+++++" : "电话机不在线,下面内容可能不会执行!+++++err+++++"); + var nextRunTime = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, RunSecond); + // 如果当前秒已过触发点,则顺延到下一分钟 + return now.Second >= RunSecond ? nextRunTime.AddMinutes(1) : nextRunTime; } /// - /// 检查电话机进程是否在线 + /// 输出电话机在线状态日志。 /// - private bool CheckPhoneIsOnline() + /// 是否在线 + private static void LogPhoneStatus(bool isOnline) + { + Logger.Error(isOnline + ? "电话机在线,开始判断!+++++str+++++" + : "电话机不在线,下面内容可能不会执行!+++++err+++++"); + } + + /// + /// 通过本机进程名判断电话机程序是否运行。 + /// + /// 在线返回 true,否则 false + private static bool CheckPhoneIsOnline() { try { - // 通过进程名检查电话机是否运行 + // 约定进程名为 Telephone return Process.GetProcessesByName("Telephone").Length > 0; } catch (Exception ex) { - logger.Error($"电话机进程检查失败: {ex.Message}"); + Logger.Error($"电话机进程检查失败: {ex.Message}"); return false; } } /// - /// 定时发送活动记录(整点任务) + /// 整点通知任务调度(北京时间): + /// - 非整点直接返回 + /// - 同一整点只执行一次 + /// - 10/15/22 点执行每日任务,其余整点执行整点短信 /// - private async Task RegularlySendActiverecords() + private void RunHourlyNotificationTask() { - // 转换为北京时间 + // 当前北京时间 var beijingTime = DateTime.UtcNow.AddHours(8); - //logger.Error($"每日任务检查 - 当前北京时间: {beijingTime:yyyy-MM-dd HH:mm:ss}"); - // 检查是否整点 + // 仅整点触发 if (beijingTime.Minute != 0) { - //logger.Error($"不满足整点任务触发条件 - 当前时间: {beijingTime:HH:mm}"); return; } - // 创建整点时间键 + // 当前整点键,用于去重 var hourlyKey = new DateTime(beijingTime.Year, beijingTime.Month, beijingTime.Day, beijingTime.Hour, 0, 0); - - // 检查任务是否已执行 if (_executedTasks.ContainsKey(hourlyKey)) { - //logger.Error($"跳过已执行的整点任务 - 时间点: {hourlyKey:yyyy-MM-dd HH:mm}"); + // 避免重复执行 return; } - logger.Error($"准备执行整点短信任务 - 时间点: {hourlyKey:yyyy-MM-dd HH:mm}"); + Logger.Error($"准备执行整点短信任务 - 时间点: {hourlyKey:yyyy-MM-dd HH:mm}"); - // 判断执行每日任务还是整点短信 - if (beijingTime.Hour is 10 or 15 or 22) + // 每日固定时点执行“每日任务”,否则执行“整点短信” + if (DailyTaskHours.Contains(beijingTime.Hour)) { - await ExecuteDailyTask(beijingTime); + ExecuteDailyTask(beijingTime); } else { - await SendHourlySms(beijingTime); + SendHourlySms(beijingTime); } - // 标记任务已执行 + // 标记当前整点已执行 _executedTasks[hourlyKey] = true; - - // 清理过期任务记录 + // 清理历史日期记录 CleanupOldTasks(beijingTime); - - //logger.Error($"整点任务执行完成 - 时间点: {hourlyKey:yyyy-MM-dd HH:mm}"); } /// - /// 清理过期任务记录 + /// 清理前一天及更早的整点执行记录。 /// + /// 当前时间(北京时间) private void CleanupOldTasks(DateTime currentTime) { - // 找出所有过期的任务键 - var keysToRemove = _executedTasks.Keys.Where(k => k.Date < currentTime.Date).ToList(); + var keysToRemove = _executedTasks.Keys.Where(key => key.Date < currentTime.Date).ToList(); foreach (var key in keysToRemove) { _executedTasks.Remove(key); - //logger.Error($"清理过期任务记录: {key:yyyy-MM-dd HH:mm}"); } } /// - /// 发送整点短信 + /// 发送整点短信。 /// - private async Task SendHourlySms(DateTime beijingTime) + /// 当前北京时间 + private void SendHourlySms(DateTime beijingTime) { try { - // 准备短信内容 - long currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - string dateTimeStr = $"{beijingTime.Month}月{beijingTime.Day}日{beijingTime.Hour}点"; - string smsContent = $"[BLV运维提示] 整点系统状态报告。当前时间:{dateTimeStr}"; + // 生成展示时间文本 + var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var dateTimeStr = $"{beijingTime.Month}月{beijingTime.Day}日{beijingTime.Hour}点"; + var smsContent = $"[BLV运维提示] 整点系统状态报告。当前时间:{dateTimeStr}"; - // 创建短信请求 + // 仅发送短信到 Mobile1 var request = CreateSmsRequest( type: "2", - deadline: currentTimestamp + 1800, + deadline: currentTimestamp + SmsDeadlineSeconds, phone: Mobile1, caller: "整点报告", - content: smsContent - ); + content: smsContent); - // 发送短信并记录结果 - var result = _callAndMsgController.SendToPhone(request); - /*logger.Error(result.isok - ? $"整点短信已发送:{dateTimeStr}" - : $"发送整点短信失败:{result.message}");*/ + // 投递短信任务 + _callAndMsgController.SendToPhone(request); } catch (Exception ex) { - logger.Error($"发送整点短信时出错:{ex.Message}"); + Logger.Error($"发送整点短信时出错:{ex.Message}"); } - await Task.CompletedTask; } /// - /// 执行每日定时任务(10点、15点、22点) + /// 执行每日定时通知(短信 + 电话)。 /// - private async Task ExecuteDailyTask(DateTime beijingTime) + /// 当前北京时间 + private void ExecuteDailyTask(DateTime beijingTime) { try { - // 准备消息内容 - long currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - string dateTimeStr = $"{beijingTime.Month}月{beijingTime.Day}日{beijingTime.Hour}点"; - string smsContent = $"[BLV运维提示] 每日定时通知。当前时间为:{dateTimeStr}"; - string callContent = $"BLV运维提示 每日定时通知 当前时间为 {dateTimeStr}"; + var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var dateTimeStr = $"{beijingTime.Month}月{beijingTime.Day}日{beijingTime.Hour}点"; + var smsContent = $"[BLV运维提示] 每日定时通知。当前时间为:{dateTimeStr}"; + var callContent = $"BLV运维提示 每日定时通知 当前时间为 {dateTimeStr}"; - // 创建短信和电话请求 - var smsRequest1 = CreateSmsRequest("2", currentTimestamp + 1800, Mobile1, "每日定时通知", smsContent); - var smsRequest2 = CreateSmsRequest("2", currentTimestamp + 1800, Mobile2, "每日定时通知", smsContent); - var callRequest = CreateSmsRequest("1", currentTimestamp + 900, Mobile1, "每日定时通知", callContent); + // 两路短信 + 一路电话 + var smsRequest1 = CreateSmsRequest("2", currentTimestamp + SmsDeadlineSeconds, Mobile1, "每日定时通知", smsContent); + var smsRequest2 = CreateSmsRequest("2", currentTimestamp + SmsDeadlineSeconds, Mobile2, "每日定时通知", smsContent); + var callRequest = CreateSmsRequest("1", currentTimestamp + CallDeadlineSeconds, Mobile1, "每日定时通知", callContent); - // 发送通知 + // 执行发送并检查结果 var smsResult1 = _callAndMsgController.SendToPhone(smsRequest1); var smsResult2 = _callAndMsgController.SendToPhone(smsRequest2); var callResult = _callAndMsgController.SendToPhone(callRequest); - // 记录发送结果 - if (smsResult1.isok && smsResult2.isok && callResult.isok) + if (!smsResult1.isok || !smsResult2.isok || !callResult.isok) { - //logger.Error($"每日定时通知已发送:{dateTimeStr}"); - } - else - { - logger.Error($"发送每日定时通知失败:短信1={smsResult1.message} 短信2={smsResult2.message} 电话={callResult.message}"); + Logger.Error($"发送每日定时通知失败:短信1={smsResult1.message} 短信2={smsResult2.message} 电话={callResult.message}"); } } catch (Exception ex) { - logger.Error($"执行每日任务时出错:{ex.Message}"); + Logger.Error($"执行每日任务时出错:{ex.Message}"); } - await Task.CompletedTask; } /// - /// 检查CPU使用率阈值 + /// CPU 阈值检查: + /// 1) 先判断监控程序是否失联 + /// 2) 再判断 CPU 指标是否超过阈值 /// - private async Task CheckCpuThresholdAsync() + private void CheckCpuThreshold() { try { - // 检查监控程序是否在线 - string detectTimeString = CSRedisCacheHelper.redis1.Get("UDPPackage_DetectTime"); + // 监控程序最后上报时间(来自 Redis) + var detectTimeString = CSRedisCacheHelper.redis1.Get("UDPPackage_DetectTime"); if (!string.IsNullOrEmpty(detectTimeString) && - DateTime.TryParse(detectTimeString, out DateTime detectTime) && + DateTime.TryParse(detectTimeString, out var detectTime) && (DateTime.UtcNow - detectTime).TotalMinutes > 10) { - // 监控程序无法访问警报 + // 超过 10 分钟未更新,触发监控失联告警 ExecuteMonitorUnavailableAlert(detectTime); return; } - // 获取CPU使用率数据 - string cpuMax = CSRedisCacheHelper.redis1.Get("UDPPackage_CPUMax"); - string cpuAvg = CSRedisCacheHelper.redis1.Get("UDPPackage_CPUAvg"); - List cpuMaxValues = ParseCpuValues(cpuMax); - List cpuAvgValues = ParseCpuValues(cpuAvg); + // 拉取 CPU 指标 + var cpuMax = CSRedisCacheHelper.redis1.Get("UDPPackage_CPUMax"); + var cpuAvg = CSRedisCacheHelper.redis1.Get("UDPPackage_CPUAvg"); - // 记录CPU使用率 - //logger.Error($"RCU服务器的CPU使用率-AVG:{cpuAvg},MAX:{cpuMax}"); + var cpuMaxValues = ParseCsvToIntList(cpuMax); + var cpuAvgValues = ParseCsvToIntList(cpuAvg); - // 检查是否超过阈值 - if (CheckThreshold(cpuAvgValues, 80, 6))//(CheckThreshold(cpuMaxValues, 90, 6) || CheckThreshold(cpuAvgValues, 85, 6)) + // 规则:平均 CPU >= 80 的点达到 6 个触发告警 + if (CheckThreshold(cpuAvgValues, threshold: 80, requiredCount: 6)) { - // 触发CPU警报 - ExecuteCpuAlert(cpuMaxValues, ParseCpuValues(CSRedisCacheHelper.redis1.Get("UDPPackage_CPUMin")), cpuAvgValues); + var cpuMinValues = ParseCsvToIntList(CSRedisCacheHelper.redis1.Get("UDPPackage_CPUMin")); + ExecuteCpuAlert(cpuMaxValues, cpuMinValues, cpuAvgValues); } } catch (Exception ex) { - logger.Error($"CPU阈值检查错误: {ex.Message}"); + Logger.Error($"CPU阈值检查错误: {ex.Message}"); } - await Task.CompletedTask; } /// - /// 检查RCU在线数量 + /// 检查 RCU 在线数量。 /// - private async Task CheckRcuOnlineAsync() + private void CheckRcuOnline() { - await CheckRedisValueAsync("RCUOnLine", 8, 0.8, ExecuteRcuOnlineAlert, "RCU主机的在线数量"); + CheckRedisValue( + redisKey: "RCUOnLine", + baselineCount: 8, + thresholdRatio: 0.8, + alertAction: ExecuteRcuOnlineAlert, + logPrefix: "RCU主机的在线数量"); } /// - /// 检查总发送包数量 + /// 检查 RCU 总发送包数量。 /// - private async Task CheckTotalSendPackageAsync() + private void CheckTotalSendPackage() { - await CheckRedisValueAsync("UDPPackage_TotalSendPackage", 8, 0.6, ExecuteTotalSendPackageAlert, "RCU主机的通讯数量"); + CheckRedisValue( + redisKey: "UDPPackage_TotalSendPackage", + baselineCount: 8, + thresholdRatio: 0.6, + alertAction: ExecuteTotalSendPackageAlert, + logPrefix: "RCU主机的通讯数量"); } + /// - /// 检查总接收包数量 + /// 检查 RCU 总接收包数量。 + /// 除了通用阈值逻辑外,还增加“最后 3 个值都低于固定阈值”的快速告警。 /// - private async Task CheckTotalRecvPackageAsync() + private void CheckTotalRecvPackage() { try { - // 从Redis获取值 - string valueString = CSRedisCacheHelper.redis1.Get("UDPPackage_TotalRecvPackage"); - if (string.IsNullOrEmpty(valueString)) return; - - // 解析值 - List values = ParseCpuValues(valueString); - if (values == null || values.Count < 10) return; - - // 检查最后3个值是否都小于70000 - if (values.Count >= 3 && values[^3] < 70000 && values[^2] < 70000 && values[^1] < 70000) + // 获取接收包时序数据 + var valueString = CSRedisCacheHelper.redis1.Get("UDPPackage_TotalRecvPackage"); + if (string.IsNullOrEmpty(valueString)) + { + return; + } + + var values = ParseCsvToIntList(valueString); + if (values.Count < 10) + { + // 数据点不足,无法按规则判定 + return; + } + + // 特殊规则:最后 3 个点都很低,立即告警 + if (values.Count >= 3 && values[^3] < RecvPackageLowThreshold && values[^2] < RecvPackageLowThreshold && values[^1] < RecvPackageLowThreshold) { - // 触发警报 ExecuteTotalRecvPackageAlert([values[^3], values[^2], values[^1]]); + return; } - else - { - // 原有的阈值检查逻辑 - await CheckRedisValueAsync("UDPPackage_TotalRecvPackage", 8, 0.75, ExecuteTotalRecvPackageAlert, "RCU主机的通讯数量"); - } + + // 回退到通用阈值规则 + CheckRedisValue( + redisKey: "UDPPackage_TotalRecvPackage", + baselineCount: 8, + thresholdRatio: 0.75, + alertAction: ExecuteTotalRecvPackageAlert, + logPrefix: "RCU主机的通讯数量"); } catch (Exception ex) { - logger.Error($"总接收包数量检查错误: {ex.Message}"); + Logger.Error($"总接收包数量检查错误: {ex.Message}"); } - await Task.CompletedTask; } /// - /// 通用Redis值检查方法 + /// 通用 Redis 时序指标检查: + /// - 以前 baselineCount 个点的平均值作为基线 + /// - 计算阈值(平均值 * thresholdRatio) + /// - 若后续两个点都低于阈值则触发告警 /// - private async Task CheckRedisValueAsync(string redisKey, int baselineCount, double thresholdRatio, Action> alertAction, string logPrefix) + /// Redis 键 + /// 基线样本数量 + /// 阈值比例 + /// 告警动作 + /// 日志前缀 + private void CheckRedisValue(string redisKey, int baselineCount, double thresholdRatio, Action> alertAction, string logPrefix) { try { - // 从Redis获取值 - string valueString = CSRedisCacheHelper.redis1.Get(redisKey); - if (string.IsNullOrEmpty(valueString)) return; + // 从 Redis 读取 CSV 字符串 + var valueString = CSRedisCacheHelper.redis1.Get(redisKey); + if (string.IsNullOrEmpty(valueString)) + { + return; + } - // 解析值 - List values = ParseCpuValues(valueString); - if (values == null || values.Count < 10) return; + var values = ParseCsvToIntList(valueString); + if (values.Count < 10) + { + return; + } - // 计算平均值和阈值 - double average = values.Take(baselineCount).Average(); - double threshold = average * thresholdRatio; - //logger.Error($"{logPrefix}-AVG:{average},ALL:{valueString}"); + // 计算阈值 + var average = values.Take(baselineCount).Average(); + var threshold = average * thresholdRatio; - // 检查最后两个值是否低于阈值 + // 后续两个点均低于阈值才触发 if (values[baselineCount] < threshold && values[baselineCount + 1] < threshold) { - // 触发警报 alertAction(values); } } catch (Exception ex) { - logger.Error($"{logPrefix}检查错误: {ex.Message}"); + Logger.Error($"{logPrefix}检查错误: {ex.Message}"); } - await Task.CompletedTask; } /// - /// 执行RCU在线数量警报 + /// RCU 在线数量告警。 /// private void ExecuteRcuOnlineAlert(List values) { SendAlert( smsContent: $"[BLV运维提示] RCU主机在线数量低于正常值,请立即检查。数据:{string.Join(",", values)}", callContent: "BLV运维提示 RCU主机在线数量低于正常值 请立即检查", - alertType: "RCU-在线数量警报" - ); + alertType: "RCU-在线数量警报"); } /// - /// 执行总发送包数量警报 + /// RCU 发送数量告警。 /// private void ExecuteTotalSendPackageAlert(List values) { SendAlert( smsContent: $"[BLV运维提示] RCU发送数量低于预期值,请立即检查。数据:{string.Join(",", values)}", callContent: "BLV运维提示 RCU发送数量低于预期值 请立即检查", - alertType: "RCU-通讯数量警报" - ); + alertType: "RCU-通讯数量警报"); } + /// - /// 执行总接收包数量警报 + /// RCU 接收数量告警。 /// private void ExecuteTotalRecvPackageAlert(List values) { SendAlert( smsContent: $"[BLV运维提示] RCU接收数量低于预期值,请立即检查。数据:{string.Join(",", values)}", callContent: "BLV运维提示 RCU接收数量低于预期值 请立即检查", - alertType: "RCU-通讯数量警报" - ); + alertType: "RCU-通讯数量警报"); } /// - /// 执行CPU使用率警报 + /// CPU 告警。 /// private void ExecuteCpuAlert(List cpuMax, List cpuMin, List cpuAvg) { - string dataString = $"AVG:{string.Join(",", cpuAvg)},MAX:{string.Join(",", cpuMax)},MIN:{string.Join(",", cpuMin)}"; + // 拼接 CPU 指标明细 + var dataString = $"AVG:{string.Join(",", cpuAvg)},MAX:{string.Join(",", cpuMax)},MIN:{string.Join(",", cpuMin)}"; SendAlert( smsContent: $"[BLV运维提示] RCU服务器的CPU使用率告警。{dataString}", callContent: "BLV运维提示 RCU服务器的CPU使用率告警 请立即检查", - alertType: "RCU-CPU警报" - ); + alertType: "RCU-CPU警报"); } /// - /// 执行监控程序无法访问警报 + /// 监控程序失联告警。 /// + /// 最后检测时间(当前版本仅用于语义传参) private void ExecuteMonitorUnavailableAlert(DateTime detectTime) { SendAlert( smsContent: "[BLV运维提示] RCU服务器的监控程序无法访问,请立即检查。", callContent: "BLV运维提示 RCU服务器的监控程序无法访问 请立即检查", alertType: "RCU-监控程序警报", - extendedDeadline: true - ); + extendedDeadline: true); } /// - /// 检查Kafka入库心跳(PostgreSQL) + /// 检查 PostgreSQL 心跳表,判断 Kafka 入库是否活跃。 /// private async Task CheckKafkaHeartbeatAsync() { try { - string? connectionString = BuildPostgresConnectionString(); + // 从配置构建连接串 + var connectionString = BuildPostgresConnectionString(); if (string.IsNullOrWhiteSpace(connectionString)) { - logger.Error("Postgres配置缺失,无法检查Kafka入库心跳"); + Logger.Error("Postgres配置缺失,无法检查Kafka入库心跳"); + // 配置缺失等价于连接失败告警路径 ExecuteKafkaDbConnectionAlert(); return; } + // 建立数据库连接 await using var connection = new NpgsqlConnection(connectionString); await connection.OpenAsync(); + // 查询最近数据中的最新 write_ts_ms const string sql = @"SELECT write_ts_ms FROM ( SELECT write_ts_ms @@ -457,52 +523,59 @@ namespace AutoNotificatPhone.Models ) AS recent_events ORDER BY write_ts_ms DESC LIMIT 1;"; - await using var command = new NpgsqlCommand(sql, connection); - object? result = await command.ExecuteScalarAsync(); + await using var command = new NpgsqlCommand(sql, connection); + var result = await command.ExecuteScalarAsync(); + + // 空结果按数据库异常路径处理 if (result == null || result == DBNull.Value) { - logger.Error("Kafka入库心跳ts_ms查询结果为空"); + Logger.Error("Kafka入库心跳ts_ms查询结果为空"); ExecuteKafkaDbConnectionAlert(); return; } - if (!long.TryParse(result.ToString(), out long lastTsMs)) + // 解析时间戳(毫秒) + if (!long.TryParse(result.ToString(), out var lastTsMs)) { - logger.Error("Kafka入库心跳ts_ms解析失败"); + Logger.Error("Kafka入库心跳ts_ms解析失败"); ExecuteKafkaDbConnectionAlert(); return; } - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - if (nowMs - lastTsMs > TimeSpan.FromMinutes(5).TotalMilliseconds) + // 按“当前时间 - 最新入库时间”判断是否超时 + var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (nowMs - lastTsMs > TimeSpan.FromMinutes(KafkaStaleMinutes).TotalMilliseconds) { - logger.Error("Kafka入库心跳超过5分钟未更新"); + Logger.Error($"Kafka入库心跳超过{KafkaStaleMinutes}分钟未更新"); ExecuteKafkaInactiveAlert(); } } catch (Exception ex) { - logger.Error($"Kafka入库心跳检查错误: {ex.Message}"); + Logger.Error($"Kafka入库心跳检查错误: {ex.Message}"); ExecuteKafkaDbConnectionAlert(); } - await Task.CompletedTask; } /// - /// 生成PostgreSQL连接字符串 + /// 从配置读取 Postgres 参数并生成连接字符串。 /// + /// 可用连接串;若关键配置缺失则返回 null private string? BuildPostgresConnectionString() { - IConfigurationSection section = _configuration.GetSection("Postgres"); - string? host = section["Host"]; - string? portString = section["Port"]; - string? database = section["Database"]; - string? username = section["User"]; - string? password = section["Password"]; - string? maxConnectionsString = section["MaxConnections"]; - string? idleTimeoutMsString = section["IdleTimeoutMs"]; + // 约定配置节点:Postgres + var section = _configuration.GetSection("Postgres"); + var host = section["Host"]; + var portString = section["Port"]; + var database = section["Database"]; + var username = section["User"]; + var password = section["Password"]; + var maxConnectionsString = section["MaxConnections"]; + var idleTimeoutMsString = section["IdleTimeoutMs"]; + + // 必填项校验 if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(portString) || string.IsNullOrWhiteSpace(database) || @@ -512,11 +585,13 @@ namespace AutoNotificatPhone.Models return null; } - if (!int.TryParse(portString, out int port)) + // 端口格式校验 + if (!int.TryParse(portString, out var port)) { return null; } + // 构建基础连接串 var builder = new NpgsqlConnectionStringBuilder { Host = host, @@ -526,12 +601,14 @@ namespace AutoNotificatPhone.Models Password = password }; - if (int.TryParse(maxConnectionsString, out int maxConnections) && maxConnections > 0) + // 连接池最大连接数(可选) + if (int.TryParse(maxConnectionsString, out var maxConnections) && maxConnections > 0) { builder.MaxPoolSize = maxConnections; } - if (int.TryParse(idleTimeoutMsString, out int idleTimeoutMs) && idleTimeoutMs > 0) + // 空闲连接生命周期(ms -> s) + if (int.TryParse(idleTimeoutMsString, out var idleTimeoutMs) && idleTimeoutMs > 0) { builder.ConnectionIdleLifetime = Math.Max(1, idleTimeoutMs / 1000); } @@ -540,7 +617,7 @@ namespace AutoNotificatPhone.Models } /// - /// 执行Kafka入库失活警报 + /// Kafka 入库停滞告警。 /// private void ExecuteKafkaInactiveAlert() { @@ -548,98 +625,118 @@ namespace AutoNotificatPhone.Models smsContent: "[BLV运维提示] BLS数据库3分钟内入库数据为0。", callContent: "BLV运维提示 BLS数据库3分钟内入库数据为0", alertType: "BLS-数据库入库警报", - extendedDeadline: true - ); + extendedDeadline: true); } /// - /// 执行Kafka入库数据库连接失败警报 + /// Kafka 数据库连接异常告警(带计数节流)。 /// private void ExecuteKafkaDbConnectionAlert() { + // 每次失败计数 +1,累计到阈值再告警 _kafkaDbConnectionAlertCount++; - if (_kafkaDbConnectionAlertCount < 8) + if (_kafkaDbConnectionAlertCount < KafkaDbAlertTriggerCount) { return; } + // 触发一次后清零重新计数 SendAlert( smsContent: "[BLV运维提示] 数据库连接失败!", callContent: "[BLV运维提示] 数据库连接失败", alertType: "BLS-数据库连接警报", - extendedDeadline: true - ); + extendedDeadline: true); _kafkaDbConnectionAlertCount = 0; } /// - /// 解析CPU值字符串为整数列表 + /// 将逗号分隔字符串解析为整型列表;解析失败项按 0 处理。 /// - private List ParseCpuValues(string valueString) + /// CSV 字符串 + /// 整型列表 + private static List ParseCsvToIntList(string valueString) { - return string.IsNullOrEmpty(valueString) - ? new List() - : valueString.Split(',').Select(v => int.TryParse(v, out int result) ? result : 0).ToList(); + if (string.IsNullOrEmpty(valueString)) + { + return []; + } + + return valueString + .Split(',') + .Select(item => int.TryParse(item, out var number) ? number : 0) + .ToList(); } /// - /// 检查值列表是否超过阈值 + /// 判断列表中是否至少有 requiredCount 个值达到 threshold。 /// - private bool CheckThreshold(List values, int threshold, int requiredCount) + /// 待检查值集合 + /// 阈值 + /// 最少命中数量 + /// 满足返回 true + private static bool CheckThreshold(List values, int threshold, int requiredCount) { - return values != null && - values.Count >= requiredCount && - values.Count(v => v >= threshold) >= requiredCount; + return values.Count >= requiredCount && values.Count(v => v >= threshold) >= requiredCount; } - /// - /// 通用警报发送方法 + /// 统一告警发送入口(两条短信 + 一通电话)。 /// + /// 短信内容 + /// 电话播报内容 + /// 告警类型(用于 caller 与日志) + /// 是否使用扩展过期时间 private void SendAlert(string smsContent, string callContent, string alertType, bool extendedDeadline = false) { - // 设置过期时间 - long currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - long smsDeadline = extendedDeadline ? currentTimestamp + 3600 : currentTimestamp + 1800; - long callDeadline = extendedDeadline ? currentTimestamp + 1800 : currentTimestamp + 900; + // 计算任务过期时间 + var nowSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var smsDeadline = nowSeconds + (extendedDeadline ? ExtendedSmsDeadlineSeconds : SmsDeadlineSeconds); + var callDeadline = nowSeconds + (extendedDeadline ? ExtendedCallDeadlineSeconds : CallDeadlineSeconds); - // 创建请求 - var smsRequest = CreateSmsRequest("2", smsDeadline, Mobile1, alertType, smsContent); + // 构建请求:两路短信(Mobile1/2)+ 一路电话(Mobile1) + var smsRequest1 = CreateSmsRequest("2", smsDeadline, Mobile1, alertType, smsContent); var smsRequest2 = CreateSmsRequest("2", smsDeadline, Mobile2, alertType, smsContent); var callRequest = CreateSmsRequest("1", callDeadline, Mobile1, alertType, callContent); - // 发送警报 - var smsResult = _callAndMsgController.SendToPhone(smsRequest); + // 调用 API 投递任务 + var smsResult1 = _callAndMsgController.SendToPhone(smsRequest1); var smsResult2 = _callAndMsgController.SendToPhone(smsRequest2); var callResult = _callAndMsgController.SendToPhone(callRequest); - // 记录结果 - if (smsResult.isok && callResult.isok) + // 任意一路失败都记录错误日志 + if (!smsResult1.isok || !smsResult2.isok || !callResult.isok) { - //logger.Error($"{alertType}警告已发送"); - } - else - { - logger.Error($"发送{alertType}通知失败: 短信={smsResult.message} 电话={callResult.message}"); + Logger.Error($"发送{alertType}通知失败: 短信1={smsResult1.message} 短信2={smsResult2.message} 电话={callResult.message}"); } } /// - /// 创建短信/电话请求对象 + /// 创建短信/电话请求对象。 /// - private SmsRequest CreateSmsRequest(string type, long deadline, string phone, string caller, string content) + /// 1=电话,2=短信 + /// 截止时间(Unix 秒) + /// 目标手机号 + /// 任务标识/来电名称 + /// 内容 + /// SmsRequest 对象 + private static SmsRequest CreateSmsRequest(string type, long deadline, string phone, string caller, string content) { return new SmsRequest { + // 业务类型(1 电话 / 2 短信) Type = type, + // 截止时间 DeadLine = deadline, + // 创建时间 StartingPoint = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + // 目标号码 PhoneNumber = phone, + // 任务标识 CallerName = caller, + // 消息内容 Content = content }; } - } -} \ No newline at end of file +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b3d8cb --- /dev/null +++ b/README.md @@ -0,0 +1,357 @@ +# Web_AutoNotificatPhone_Server_Prod + +## 1. 项目概述 + +这是一个基于 **ASP.NET Core 8 MVC** 的通知与巡检服务,核心职责是: + +1. 提供 HTTP API,接收“电话/短信”通知请求并发布到 Redis 频道。 +2. 运行后台定时任务(`TimerClass`),每分钟做系统状态检查。 +3. 根据 Redis 指标和 PostgreSQL 心跳数据触发自动告警(短信 + 电话)。 + +项目本质上是一个“**告警编排层**”: +- 输入来源:HTTP 请求、Redis 指标、PostgreSQL 心跳表。 +- 输出目标:Redis 发布(供下游呼叫/短信系统消费)。 + +--- + +## 2. 技术栈与依赖 + +- .NET: `net8.0` +- Web: ASP.NET Core MVC + API Controller +- Redis: `CSRedisCore` +- PostgreSQL: `Npgsql` +- Logging: `NLog` + +`AutoNotificatPhone.csproj` 关键包: +- `CSRedisCore` +- `NLog` +- `Npgsql` + +--- + +## 3. 目录与核心文件 + +- `Program.cs`:应用启动、CORS、路由、HostedService 注册。 +- `Controllers/CallAndMsgController.cs`:通知入站 API(电话/短信)。 +- `Models/TimerClass.cs`:后台巡检与告警主流程。 +- `CSRedisCacheHelper.cs`:Redis 连接与基础操作封装。 +- `Models/SmsRequest.cs`:通知请求模型。 +- `Models/ReturnInfo.cs`:统一接口返回结构。 +- `appsettings.json`:日志级别、Postgres 连接配置。 +- `nlog.config`:日志落盘规则。 + +--- + +## 4. 启动流程(程序生命周期) + +### 4.1 Program 启动阶段 + +`Program.cs` 主要行为: + +1. `AddControllersWithViews()`:注册 MVC 和 API 支持。 +2. 注册 CORS 策略 `AllowAnyOrigin`: + - 允许任意源 + - 允许任意方法 + - 允许任意请求头 +3. `AddHostedService()`:注册后台巡检服务。 +4. 管道顺序: + - `UseStaticFiles()` + - `UseRouting()` + - `UseCors("AllowAnyOrigin")` + - `UseAuthorization()` + - `MapControllerRoute(...)` + +### 4.2 后台服务启动 + +应用启动后,`TimerClass` 自动运行: +- 持续循环,直到应用停止。 +- 每分钟固定在第 30 秒执行一次巡检逻辑。 + +--- + +## 5. API 处理流程(CallAndMsgController) + +## 5.1 路由 + +Controller 路由前缀: +- `api/CallAndMsg/{action}` + +核心接口: +- `POST api/CallAndMsg/SendToPhone` + +## 5.2 请求模型(SmsRequest) + +- `PhoneNumber`:目标手机号 +- `CallerName`:任务标识/来电显示名 +- `Content`:消息内容 +- `StartingPoint`:开始时间(Unix 秒) +- `DeadLine`:截止时间(Unix 秒) +- `Type`:`1` 电话,`2` 短信 + +## 5.3 接口执行逻辑 + +`SendToPhone` 内部流程: + +1. 接收 `SmsRequest`。 +2. 组装 `notification` 对象。 +3. 使用固定频道 `MsgAndCall` 发布到 Redis: + - `CSRedisCacheHelper.Publish("MsgAndCall", payload)` +4. 返回 `ReturnInfo`: + - 成功:`status=200` + 根据 `Type` 返回“已加入队列”文案 + - 失败:`status=500` + 异常信息 + +> 说明:`ConvertTimestampToIso` 名称为“转 ISO”,但实际仍返回 Unix 秒。 + +--- + +## 6. Redis 封装(CSRedisCacheHelper) + +## 6.1 连接 + +静态初始化时连接本地 Redis: +- Host: `127.0.0.1` +- Port: `10079` +- 密码:代码中硬编码 +- DB0: `redis` +- DB1: `redis1` + +## 6.2 用途 + +- `DB0`:主要用于发布通知频道(`MsgAndCall`)。 +- `DB1`:主要用于读取巡检指标(CPU、在线数、收发包等)。 + +## 6.3 方法 + +- `Set/Get/Forever/Del/Contains` +- `Publish` + +--- + +## 7. 核心:TimerClass 业务流程详解 + +`TimerClass` 是项目核心,分为 **调度层**、**检查层**、**告警层**。 + +## 7.1 调度层(每分钟触发) + +主循环执行顺序: + +1. 计算下次执行时间(每分钟第 30 秒)。 +2. 检查电话机进程是否在线(进程名 `Telephone`)。 +3. 运行整点任务调度。 +4. 依次执行检查任务: + - CPU 阈值 + - RCU 在线数量 + - 总发送包 + - 总接收包 + - PostgreSQL 心跳 + +出现异常时: +- 记录日志 +- 延迟 10 秒重试 + +## 7.2 整点任务调度 + +方法:`RunHourlyNotificationTask()` + +规则: +- 使用北京时间(UTC+8)。 +- 非整点(`Minute != 0`)不执行。 +- 同一整点只执行一次(`_executedTasks` 去重)。 +- 10/15/22 点执行每日任务,其余整点发整点短信。 + +### 7.2.1 整点短信(SendHourlySms) + +- 给 `Mobile1` 发送一条短信任务。 +- 文案包含当前“月/日/时”。 + +### 7.2.2 每日任务(ExecuteDailyTask) + +- 给 `Mobile1` 和 `Mobile2` 发送短信。 +- 给 `Mobile1` 发送电话任务。 +- 任意发送失败记录错误日志。 + +## 7.3 Redis 指标检查 + +### 7.3.1 CPU 检查(CheckCpuThreshold) + +步骤: +1. 读取 `UDPPackage_DetectTime`: + - 若超过 10 分钟未更新,触发“监控程序无法访问”告警。 +2. 读取: + - `UDPPackage_CPUMax` + - `UDPPackage_CPUAvg` + - `UDPPackage_CPUMin` +3. 若 `CPUAvg` 中满足“>=80 的点数达到 6 个”,触发 CPU 告警。 + +### 7.3.2 通用 Redis 规则(CheckRedisValue) + +用于在线数/发送包/接收包的基线跌落判断: + +1. 读取 CSV 序列。 +2. 取前 `baselineCount` 个点做平均。 +3. 阈值 = 平均值 × `thresholdRatio`。 +4. 若后续两个点都低于阈值,触发告警。 + +具体调用: +- `RCUOnLine`:`baselineCount=8`,`ratio=0.8` +- `UDPPackage_TotalSendPackage`:`baselineCount=8`,`ratio=0.6` +- `UDPPackage_TotalRecvPackage`:`baselineCount=8`,`ratio=0.75` + +### 7.3.3 接收包特殊规则(CheckTotalRecvPackage) + +在通用规则之前,先判断: +- 若最后 3 个值都 `< 70000`,立即触发告警。 + +## 7.4 PostgreSQL 心跳检查 + +方法:`CheckKafkaHeartbeatAsync()` + +目标:判断“Kafka 入库是否活跃”。 + +流程: +1. 从 `appsettings.json` 的 `Postgres` 节生成连接串。 +2. 查询 `heartbeat.heartbeat_events`: + - 先按 `ts_ms desc` 取最近 3000 条 + - 再按 `write_ts_ms desc` 取最新一条 +3. 结果为空或解析失败:走“数据库连接失败”告警路径。 +4. 若 `nowMs - lastTsMs > KafkaStaleMinutes`:触发“入库停滞”告警。 +5. 异常(连接失败/SQL 异常)同样走“数据库连接失败”告警。 + +### 7.4.1 数据库连接失败节流 + +- 字段:`_kafkaDbConnectionAlertCount` +- 逻辑:失败累计到 `KafkaDbAlertTriggerCount=8` 才真正发一次告警,之后计数清零。 + +--- + +## 8. 告警发送机制 + +统一通过 `SendAlert(...)`: + +1. 计算过期时间: + - 普通:短信 1800 秒、电话 900 秒 + - 扩展:短信 3600 秒、电话 1800 秒 +2. 构造 3 条任务: + - 短信:`Mobile1` + - 短信:`Mobile2` + - 电话:`Mobile1` +3. 调用 `CallAndMsgController.SendToPhone` 投递。 +4. 任一失败,记录错误日志。 + +--- + +## 9. 配置说明 + +## 9.1 appsettings.json + +当前包含: + +- `Logging` +- `AllowedHosts` +- `Postgres` + - `Host` + - `Port` + - `Database` + - `User` + - `Password` + - `MaxConnections` + - `IdleTimeoutMs` + +## 9.2 nlog.config + +- 文件落盘目录:`{basedir}/Logs/{shortdate}` +- 文件:`info_*.txt`、`error_*.txt` +- 当前规则: + - `FATAL` 写入 info 文件 + - `Error` 及以上写入 error 文件 + +--- + +## 10. 快速运行 + +## 10.1 开发运行 + +```bash +dotnet restore +dotnet build AutoNotificatPhone.csproj +dotnet run --project AutoNotificatPhone.csproj +``` + +默认开发地址见 `Properties/launchSettings.json`: +- `http://localhost:5080` + +## 10.2 VS Code Tasks + +项目已配置任务: +- `build` +- `publish` +- `watch` + +--- + +## 11. 对外接口示例 + +请求: + +```json +POST /api/CallAndMsg/SendToPhone +Content-Type: application/json + +{ + "PhoneNumber": "13500000000", + "CallerName": "测试任务", + "Content": "测试短信内容", + "StartingPoint": 1760000000, + "DeadLine": 1760001800, + "Type": "2" +} +``` + +成功响应: + +```json +{ + "isok": true, + "message": "短信已加入发送队列", + "status": 200, + "response": "success" +} +``` + +--- + +## 12. 关键数据流(一句话版) + +外部请求/定时巡检 -> 生成 `SmsRequest` -> `CallAndMsgController` -> Redis 频道 `MsgAndCall` -> 下游系统消费执行。 + +--- + +## 13. 当前实现中的注意点 + +1. `CallAndMsgController` 中 `ConvertTimestampToIso` 命名与实现含义不一致(返回 Unix 秒)。 +2. `CSRedisCacheHelper` 中 Redis 密码硬编码在代码,不利于安全与环境切换。 +3. `TimerClass` 的 `KafkaStaleMinutes` 与告警文案“3分钟内入库数据为0”存在语义不一致风险(建议统一)。 +4. 当前 CORS 为完全放开,生产环境建议收敛来源。 + +--- + +## 14. 后续可优化方向(可选) + +1. 将手机号、阈值、执行小时、告警文案全部配置化。 +2. 让 `TimerClass` 通过接口服务发送通知,避免直接 new Controller。 +3. 增加单元测试: + - Redis 阈值计算 + - Kafka 心跳判定 + - 告警节流逻辑 +4. 增加健康检查端点(数据库/Redis 连通性)。 + +--- + +## 15. 维护建议 + +每次修改巡检规则后,至少验证: + +1. `dotnet build` 无编译错误。 +2. Redis 有模拟数据时告警触发正确。 +3. PostgreSQL 心跳超时/失败路径可复现。 +4. `MsgAndCall` 频道消息格式未破坏下游兼容。