从零搭建灌装监控系统(十三):报警系统设计,生命周期与通知

报警系统设计:生命周期与通知

这是「从零搭建灌装监控系统」系列第13篇。报警系统不能只写一个 IsAlarm = true。一条报警需要知道什么时候触发、什么时候恢复、是否确认、由谁确认,以及如何通知页面和日志。这篇把报警生命周期拆开,再用服务、数据库和事件把它们串起来。


红灯亮了,但没人知道它为什么亮

早期版本的报警处理很简单:设备状态异常时把一个布尔属性设成 true,界面用红色触发器显示;状态恢复后设回 false。

这种实现演示时很漂亮,现场却马上暴露问题。操作员离开页面再回来,刚才的报警已经没有记录;同一个异常持续十分钟,系统每200ms 都在重复发送提示;报警恢复了,但没人知道它持续了多久。

报警至少包含四个时间点或动作:触发、持续、恢复、确认。确认也不等于恢复,操作员可以先确认"我看到了",设备故障仍然存在。


AlarmRecord:把状态变成记录

csharp 复制代码
public class AlarmRecord
{
    [Column(IsIdentity = true, IsPrimary = true)]
    public long Id { get; set; }

    public AlarmCode Code { get; set; }
    public AlarmSeverity Severity { get; set; }
    public string Title { get; set; } = "";
    public string Message { get; set; } = "";
    public DateTime StartTime { get; set; }
    public DateTime? EndTime { get; set; }
    public bool IsActive { get; set; }
    public double? DurationSeconds { get; set; }
    public bool IsAcknowledged { get; set; }
    public DateTime? AcknowledgedTime { get; set; }
    public string? AcknowledgedUser { get; set; }
}

级别是报警的属性,不是生命周期:

csharp 复制代码
public enum AlarmSeverity
{
    All = 0,
    Info = 1,
    Warning = 2,
    Error = 3,
    Critical = 4
}

编码也要稳定。界面显示文字可以改,代码和数据库里的 AlarmCode 不应该因为换了语言就变化:

csharp 复制代码
public enum AlarmCode
{
    None = 0,
    LowLiquidLevel = 1001,
    Overfill = 1002,
    Leak = 1003,
    LowAirPressure = 2001,
    HighTemperature = 3001,
    SensorFailure = 4001,
    CommunicationError = 5001,
    SystemError = 6001
}

这些是教学用的通用报警码,真实设备的地址和内部编号不应直接写进文章。


触发:同类活动报警只保留一条

报警触发可能来自多个轮询周期。第一件事是检查同一编码是否已经有活动记录:

csharp 复制代码
public static async Task TriggerAlarmAsync(AlarmRecord record)
{
    var exists = await DbProvider.Fsql.Select<AlarmRecord>()
        .Where(x => x.Code == record.Code && x.IsActive)
        .AnyAsync();

    if (exists)
        return;

    record.StartTime = DateTime.Now;
    record.IsActive = true;
    record.IsAcknowledged = false;

    await DbProvider.Fsql.Insert(record).ExecuteAffrowsAsync();
    LogService.Warn($"报警触发:{record.Code} - {record.Message}");
    AlarmTriggered?.Invoke(null, record);
}

这个查询解决了重复弹窗和重复插入,但还存在并发窗口:两个线程可能同时查询,都看到"没有活动报警",接着各自插入。严谨做法是在服务层加异步锁,或者给数据库增加活动报警的唯一约束。现场报警不应该因为一次并发检测就出现两条一模一样的活动记录。


恢复:计算持续时间,不删除记录

报警恢复时不要删除原记录。历史记录的价值就在于它能回答"什么时候发生过":

csharp 复制代码
public static async Task RecoverAlarmAsync(AlarmCode code)
{
    var alarm = await DbProvider.Fsql.Select<AlarmRecord>()
        .Where(x => x.Code == code && x.IsActive)
        .FirstAsync();

    if (alarm == null)
        return;

    var end = DateTime.Now;
    await DbProvider.Fsql.Update<AlarmRecord>()
        .Set(x => x.IsActive, false)
        .Set(x => x.EndTime, end)
        .Set(x => x.DurationSeconds,
             (end - alarm.StartTime).TotalSeconds)
        .Where(x => x.Id == alarm.Id)
        .ExecuteAffrowsAsync();

    alarm.IsActive = false;
    alarm.EndTime = end;
    alarm.DurationSeconds = (end - alarm.StartTime).TotalSeconds;
    LogService.Info($"报警恢复:{code}");
    AlarmRecovered?.Invoke(null, alarm);
}

恢复动作应该是幂等的。没有活动记录时直接返回,再次收到恢复信号不应该报错,也不应该创建一条"恢复记录"。


确认:操作员承认看到了

确认是人为动作,和设备恢复是两条线:

csharp 复制代码
public static async Task<bool> AcknowledgeAlarmAsync(
    long alarmId, string operatorName)
{
    var affected = await DbProvider.Fsql.Update<AlarmRecord>()
        .Set(x => x.IsAcknowledged, true)
        .Set(x => x.AcknowledgedTime, DateTime.Now)
        .Set(x => x.AcknowledgedUser, operatorName)
        .Where(x => x.Id == alarmId && !x.IsAcknowledged)
        .ExecuteAffrowsAsync();

    if (affected == 0)
        return false;

    LogService.Info($"报警确认:{alarmId} by {operatorName}");
    return true;
}

Where(... && !x.IsAcknowledged) 很重要。两个页面同时点确认时,只有第一个更新成功,第二个得到 false。数据库条件本身就是并发保护的一部分。

一个已确认但仍活动的报警,应该继续显示在活动报警列表里,只是颜色、按钮或提示方式可以变化。确认不应该让故障从系统中消失。


事件通知 UI,但服务不依赖 UI

报警服务写数据库和日志,页面通过事件刷新自己的集合:

csharp 复制代码
public static event EventHandler<AlarmRecord>? AlarmTriggered;
public static event EventHandler<AlarmRecord>? AlarmRecovered;

ViewModel 订阅事件:

csharp 复制代码
public AlarmsViewModel()
{
    AlarmService.AlarmTriggered += OnAlarmTriggered;
    AlarmService.AlarmRecovered += OnAlarmRecovered;
    _ = LoadActiveAlarmsAsync();
}

private void OnAlarmTriggered(object? sender, AlarmRecord record)
{
    Application.Current.Dispatcher.Invoke(() =>
    {
        ActiveAlarms.Insert(0, AlarmUIModel.FromAlarmRecord(record));
        ActiveAlarmCount = ActiveAlarms.Count;
    });
}

事件回调可能在后台轮询线程触发,不能直接修改 WPF 的 ObservableCollection。通过 Dispatcher 切回 UI 线程,是这类代码的基本安全线。

服务不应该直接调用 MessageBox.Show 或引用某个页面。否则报警服务无法测试,也无法复用到后台模式。声音、弹窗、灯光和 UI 列表都属于订阅者,各自决定如何响应。


#mermaid-svg-bFkSzT6rmKZjoyTn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bFkSzT6rmKZjoyTn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bFkSzT6rmKZjoyTn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bFkSzT6rmKZjoyTn .error-icon{fill:#552222;}#mermaid-svg-bFkSzT6rmKZjoyTn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bFkSzT6rmKZjoyTn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bFkSzT6rmKZjoyTn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bFkSzT6rmKZjoyTn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bFkSzT6rmKZjoyTn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bFkSzT6rmKZjoyTn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bFkSzT6rmKZjoyTn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bFkSzT6rmKZjoyTn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bFkSzT6rmKZjoyTn .marker.cross{stroke:#333333;}#mermaid-svg-bFkSzT6rmKZjoyTn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bFkSzT6rmKZjoyTn p{margin:0;}#mermaid-svg-bFkSzT6rmKZjoyTn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bFkSzT6rmKZjoyTn .cluster-label text{fill:#333;}#mermaid-svg-bFkSzT6rmKZjoyTn .cluster-label span{color:#333;}#mermaid-svg-bFkSzT6rmKZjoyTn .cluster-label span p{background-color:transparent;}#mermaid-svg-bFkSzT6rmKZjoyTn .label text,#mermaid-svg-bFkSzT6rmKZjoyTn span{fill:#333;color:#333;}#mermaid-svg-bFkSzT6rmKZjoyTn .node rect,#mermaid-svg-bFkSzT6rmKZjoyTn .node circle,#mermaid-svg-bFkSzT6rmKZjoyTn .node ellipse,#mermaid-svg-bFkSzT6rmKZjoyTn .node polygon,#mermaid-svg-bFkSzT6rmKZjoyTn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bFkSzT6rmKZjoyTn .rough-node .label text,#mermaid-svg-bFkSzT6rmKZjoyTn .node .label text,#mermaid-svg-bFkSzT6rmKZjoyTn .image-shape .label,#mermaid-svg-bFkSzT6rmKZjoyTn .icon-shape .label{text-anchor:middle;}#mermaid-svg-bFkSzT6rmKZjoyTn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bFkSzT6rmKZjoyTn .rough-node .label,#mermaid-svg-bFkSzT6rmKZjoyTn .node .label,#mermaid-svg-bFkSzT6rmKZjoyTn .image-shape .label,#mermaid-svg-bFkSzT6rmKZjoyTn .icon-shape .label{text-align:center;}#mermaid-svg-bFkSzT6rmKZjoyTn .node.clickable{cursor:pointer;}#mermaid-svg-bFkSzT6rmKZjoyTn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bFkSzT6rmKZjoyTn .arrowheadPath{fill:#333333;}#mermaid-svg-bFkSzT6rmKZjoyTn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bFkSzT6rmKZjoyTn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bFkSzT6rmKZjoyTn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bFkSzT6rmKZjoyTn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bFkSzT6rmKZjoyTn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bFkSzT6rmKZjoyTn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bFkSzT6rmKZjoyTn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bFkSzT6rmKZjoyTn .cluster text{fill:#333;}#mermaid-svg-bFkSzT6rmKZjoyTn .cluster span{color:#333;}#mermaid-svg-bFkSzT6rmKZjoyTn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bFkSzT6rmKZjoyTn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bFkSzT6rmKZjoyTn rect.text{fill:none;stroke-width:0;}#mermaid-svg-bFkSzT6rmKZjoyTn .icon-shape,#mermaid-svg-bFkSzT6rmKZjoyTn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bFkSzT6rmKZjoyTn .icon-shape p,#mermaid-svg-bFkSzT6rmKZjoyTn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bFkSzT6rmKZjoyTn .icon-shape rect,#mermaid-svg-bFkSzT6rmKZjoyTn .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bFkSzT6rmKZjoyTn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bFkSzT6rmKZjoyTn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bFkSzT6rmKZjoyTn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
轮询读到异常状态
TriggerAlarmAsync
同类活动报警已存在?
直接返回,不重复触发
插入活动记录
写警告日志
触发 AlarmTriggered 事件
Dispatcher 切回 UI 线程
活动报警列表插入一条

活动列表和历史列表是两种查询

活动报警只查 IsActive = true,按开始时间倒序。历史报警则要支持时间和级别筛选,还要分页:

csharp 复制代码
public static async Task<(List<AlarmRecord> Items, long Total)>
    GetAlarmHistoryAsync(int pageIndex, int pageSize,
                         DateTime? start = null,
                         DateTime? end = null,
                         AlarmSeverity severity = AlarmSeverity.All)
{
    var query = DbProvider.Fsql.Select<AlarmRecord>();

    if (start.HasValue)
        query = query.Where(x => x.StartTime >= start.Value);
    if (end.HasValue)
        query = query.Where(x => x.StartTime < end.Value);
    if (severity != AlarmSeverity.All)
        query = query.Where(x => x.Severity == severity);

    var total = await query.CountAsync();
    var items = await query
        .OrderByDescending(x => x.StartTime)
        .Page(pageIndex, pageSize)
        .ToListAsync();

    return (items, total);
}

总数和当前页数据最好使用一致的查询条件,否则分页控件显示的总页数会和实际内容对不上。


从设备状态到报警服务

报警判断应该集中在状态处理层,不要散落在每个控件的属性回调里:

csharp 复制代码
private async Task EvaluateStateAsync(DeviceStates state)
{
    if (state.Volume < Limits.MinimumVolume)
    {
        await AlarmService.TriggerAlarmAsync(
            AlarmFactory.LowLiquidLevel(state.Volume));
    }
    else
    {
        await AlarmService.RecoverAlarmAsync(
            AlarmCode.LowLiquidLevel);
    }

    if (state.Temperature > Limits.MaximumTemperature)
    {
        await AlarmService.TriggerAlarmAsync(
            AlarmFactory.HighTemperature(state.Temperature));
    }
}

触发条件和报警记录创建可以再拆成规则对象,但不要为了追求"通用引擎"把两三个规则包装成十层接口。先保证规则可读、恢复路径明确、日志能定位。


踩坑记录

一次采样直接弹一次窗

轮询是状态采样,报警是状态边沿。必须做活动报警去重。

恢复时删除记录

删除让报警历史无法追溯,也无法计算持续时间。恢复只更新状态和结束信息。

把确认当成恢复

操作员点击确认只表示"我看到了",设备异常仍然存在时,报警必须继续活动。

事件订阅不解除

页面反复导航会重复订阅,最终一次报警触发多次刷新。页面生命周期结束时要解除订阅,或者让订阅者使用统一的生命周期管理。


本篇小结

生命周期 数据变化 典型通知
触发 插入活动记录 列表、声音、灯光
持续 保持活动 周期监视
恢复 写入结束时间和持续时间 消除活动提示
确认 写入操作员和确认时间 记录人工处理

报警数据已经能保存和通知,但系统还需要一个可靠的日志出口。下一篇把 Serilog 接到文件、SQLite、控制台和 UI 四个方向。


下期预告

第14篇:Serilog 四路日志输出

下一篇设计统一日志入口,让同一条日志按级别流向文件、数据库、控制台和界面,而不是在业务代码里到处写文件操作。

相关推荐
miofly3 小时前
GitHub 今日推荐|SCSKiller:游戏着色器预编译消除卡顿
开源·c#·github
周杰伦fans18 小时前
8GB显存下模型量化实战指南
人工智能·后端·c#
samble19 小时前
从零搭建灌装监控系统(十二):配置系统,原子写入与容错
c#·wpf·mvvm·modbus·工业控制
流形填表1 天前
题库去重实战:基于题干指纹的重复题清理
开发语言·c#
开开心心就好1 天前
以图搜图找重复图片,本地工具离线就能用
javascript·智能手机·ffmpeg·c#·ocr·word·音视频
QQ_21696290961 天前
基于C#(Asp.net)电竞陪玩信息管理系统的设计与实现
java·大数据·spring boot·微信小程序·c#·云计算·asp.net
未来之窗软件服务2 天前
C# 截图源码支持自动保存-东方仙盟
c#·仙盟创梦ide·东方仙盟·电脑工具
UIU1142 天前
-7 / 2 在 C 里是 -3,在 Python 里是 -4,谁算错了?
c++·学习·c#
wflynn2 天前
GitHub 今日推荐|REDox:64 位 token 表示结构化数据,内存占用降 70% 支持多格式互转
开源·c#·github