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` 频道消息格式未破坏下游兼容。