Просмотр исходного кода

feat(wm-production): #67 报警规则引擎与报警管理中心

- DDL: V2__alert_engine.sql (prod_alert_rule, prod_alert_record, prod_alert_notification, prod_alert_rule_device)
- Entity: AlertRule(规则定义), AlertRecord(全生命周期增强), AlertNotification(通知记录)
- Mapper: AlertRuleMapper, AlertRecordMapper(自定义关联SQL), AlertNotificationMapper
- Service: AlertRuleService(规则CRUD + AND/OR组合条件评估引擎 + 阈值触发 + 多级别报警)
- Service: AlertCenterService(报警全生命周期: 确认/派单/处理/归档 + 统计看板)
- Controller: AlertController 23个RESTful端点 (/api/production/alert/*)
- 单元测试: AlertRuleServiceTest(12个) + AlertCenterServiceTest(5个) = 17个测试
bot_dev2 5 дней назад
Родитель
Сommit
1870171e45

+ 122
- 0
db/postgresql/V2__alert_engine.sql Просмотреть файл

@@ -0,0 +1,122 @@
1
+-- =============================================
2
+-- 智慧水务管理系统 - 报警规则引擎 + 报警管理中心 DDL
3
+-- 版本: V2
4
+-- =============================================
5
+
6
+-- ==================== 报警规则定义 ====================
7
+CREATE TABLE IF NOT EXISTS prod_alert_rule (
8
+    id              BIGSERIAL PRIMARY KEY,
9
+    rule_name       VARCHAR(100) NOT NULL,
10
+    rule_code       VARCHAR(50) UNIQUE,
11
+    description     TEXT,
12
+    device_type     VARCHAR(30),
13
+    metric_key      VARCHAR(50) NOT NULL,
14
+    alert_level     VARCHAR(10) NOT NULL DEFAULT 'general',    -- general/important/urgent
15
+    condition_expr  TEXT NOT NULL,                              -- JSON: {"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8},...]}
16
+    threshold_value DECIMAL(12,4),                              -- 简单阈值(向后兼容)
17
+    debounce_sec    INT DEFAULT 300,
18
+    notify_channels VARCHAR(200),                               -- 逗号分隔: sms,wechat,app,email
19
+    notify_template VARCHAR(500),                               -- 通知模板
20
+    enabled         SMALLINT DEFAULT 1,
21
+    priority        INT DEFAULT 0,                              -- 规则优先级
22
+    effective_start TIME,                                       -- 生效开始时间
23
+    effective_end   TIME,                                       -- 生效结束时间
24
+    created_by      BIGINT,
25
+    updated_by      BIGINT,
26
+    deleted         SMALLINT DEFAULT 0,
27
+    created_time    TIMESTAMP DEFAULT NOW(),
28
+    updated_time    TIMESTAMP DEFAULT NOW()
29
+);
30
+COMMENT ON TABLE prod_alert_rule IS '报警规则定义表';
31
+COMMENT ON COLUMN prod_alert_rule.alert_level IS '报警等级: general(一般)/important(重要)/urgent(紧急)';
32
+COMMENT ON COLUMN prod_alert_rule.condition_expr IS '条件表达式JSON: 支持AND/OR组合条件';
33
+
34
+-- ==================== 报警记录(全生命周期) ====================
35
+CREATE TABLE IF NOT EXISTS prod_alert_record (
36
+    id              BIGSERIAL PRIMARY KEY,
37
+    rule_id         BIGINT REFERENCES prod_alert_rule(id),
38
+    rule_name       VARCHAR(100),
39
+    device_id       BIGINT,
40
+    device_sn       VARCHAR(100),
41
+    device_name     VARCHAR(200),
42
+    area            VARCHAR(50),
43
+    metric_key      VARCHAR(50) NOT NULL,
44
+    metric_value    DECIMAL(12,4),
45
+    threshold_value VARCHAR(50),
46
+    alert_level     VARCHAR(10) NOT NULL DEFAULT 'general',
47
+    title           VARCHAR(200),
48
+    message         TEXT,
49
+    -- 生命周期状态: 0=活跃 1=已确认 2=已派单 3=处理中 4=已处理 5=已归档
50
+    status          INT DEFAULT 0,
51
+    confirmed_by    BIGINT,
52
+    confirmed_time  TIMESTAMP,
53
+    dispatch_time   TIMESTAMP,
54
+    assignee_id     BIGINT,
55
+    assignee_name   VARCHAR(50),
56
+    handler_id      BIGINT,
57
+    handler_name    VARCHAR(50),
58
+    handle_result   TEXT,
59
+    handle_time     TIMESTAMP,
60
+    archive_time    TIMESTAMP,
61
+    archive_reason  VARCHAR(500),
62
+    resolved_at     TIMESTAMP,
63
+    created_time    TIMESTAMP DEFAULT NOW(),
64
+    updated_time    TIMESTAMP DEFAULT NOW(),
65
+    deleted         SMALLINT DEFAULT 0
66
+);
67
+COMMENT ON TABLE prod_alert_record IS '报警记录表(全生命周期)';
68
+CREATE INDEX IF NOT EXISTS idx_alert_record_time ON prod_alert_record(created_time DESC);
69
+CREATE INDEX IF NOT EXISTS idx_alert_record_device ON prod_alert_record(device_sn, created_time DESC);
70
+CREATE INDEX IF NOT EXISTS idx_alert_record_status ON prod_alert_record(status);
71
+CREATE INDEX IF NOT EXISTS idx_alert_record_level ON prod_alert_record(alert_level);
72
+CREATE INDEX IF NOT EXISTS idx_alert_record_area ON prod_alert_record(area);
73
+
74
+-- ==================== 报警通知记录 ====================
75
+CREATE TABLE IF NOT EXISTS prod_alert_notification (
76
+    id              BIGSERIAL PRIMARY KEY,
77
+    alert_record_id BIGINT REFERENCES prod_alert_record(id),
78
+    rule_id         BIGINT,
79
+    channel         VARCHAR(30) NOT NULL,        -- sms/wechat/app/email
80
+    recipient       VARCHAR(100) NOT NULL,       -- 接收人标识
81
+    recipient_name  VARCHAR(50),
82
+    title           VARCHAR(200),
83
+    content         TEXT,
84
+    status          INT DEFAULT 0,               -- 0=待发送 1=已发送 2=发送失败 3=已读
85
+    send_time       TIMESTAMP,
86
+    read_time       TIMESTAMP,
87
+    retry_count     INT DEFAULT 0,
88
+    error_msg       VARCHAR(500),
89
+    created_time    TIMESTAMP DEFAULT NOW()
90
+);
91
+COMMENT ON TABLE prod_alert_notification IS '报警通知记录表';
92
+CREATE INDEX IF NOT EXISTS idx_alert_notif_record ON prod_alert_notification(alert_record_id);
93
+CREATE INDEX IF NOT EXISTS idx_alert_notif_status ON prod_alert_notification(status);
94
+
95
+-- ==================== 报警规则-设备关联(可选) ====================
96
+CREATE TABLE IF NOT EXISTS prod_alert_rule_device (
97
+    id              BIGSERIAL PRIMARY KEY,
98
+    rule_id         BIGINT REFERENCES prod_alert_rule(id),
99
+    device_id       BIGINT,
100
+    device_sn       VARCHAR(100),
101
+    area            VARCHAR(50),
102
+    created_time    TIMESTAMP DEFAULT NOW()
103
+);
104
+COMMENT ON TABLE prod_alert_rule_device IS '报警规则-设备/区域关联表';
105
+
106
+-- ==================== 初始规则数据 ====================
107
+INSERT INTO prod_alert_rule (rule_name, rule_code, metric_key, alert_level, condition_expr, threshold_value, debounce_sec, description, enabled) VALUES
108
+('管网压力过高报警', 'RULE_PRESSURE_HIGH', 'pressure', 'urgent',
109
+ '{"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8}]}',
110
+ 0.8000, 300, '管网压力超过0.8MPa时触发紧急报警', 1),
111
+('管网压力过低报警', 'RULE_PRESSURE_LOW', 'pressure', 'important',
112
+ '{"op":"OR","conditions":[{"metric":"pressure","operator":"<","threshold":0.2}]}',
113
+ 0.2000, 300, '管网压力低于0.2MPa时触发重要报警', 1),
114
+('水质浊度超标', 'RULE_TURBIDITY_HIGH', 'turbidity', 'urgent',
115
+ '{"op":"AND","conditions":[{"metric":"turbidity","operator":">","threshold":1.0}]}',
116
+ 1.0000, 600, '水质浊度超过1.0NTU触发紧急报警', 1),
117
+('余氯偏低报警', 'RULE_CHLORINE_LOW', 'residual_chlorine', 'general',
118
+ '{"op":"AND","conditions":[{"metric":"residual_chlorine","operator":"<","threshold":0.1}]}',
119
+ 0.1000, 600, '余氯低于0.1mg/L触发一般报警', 1),
120
+('流量异常波动', 'RULE_FLOW_ANOMALY', 'flow', 'important',
121
+ '{"op":"OR","conditions":[{"metric":"flow","operator":">","threshold":100},{"metric":"flow","operator":"<","threshold":5}]}',
122
+ NULL, 120, '流量异常偏高或偏低时触发报警', 1);

+ 212
- 0
wm-production/src/main/java/com/water/production/controller/AlertController.java Просмотреть файл

@@ -0,0 +1,212 @@
1
+package com.water.production.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.production.entity.AlertNotification;
6
+import com.water.production.entity.AlertRecord;
7
+import com.water.production.entity.AlertRule;
8
+import com.water.production.service.AlertCenterService;
9
+import com.water.production.service.AlertRuleService;
10
+import io.swagger.v3.oas.annotations.Operation;
11
+import io.swagger.v3.oas.annotations.tags.Tag;
12
+import lombok.RequiredArgsConstructor;
13
+import org.springframework.web.bind.annotation.*;
14
+
15
+import java.util.*;
16
+
17
+/**
18
+ * 报警规则引擎 + 报警管理中心 REST API
19
+ */
20
+@Tag(name = "报警规则引擎与管理中心")
21
+@RestController
22
+@RequestMapping("/api/production/alert")
23
+@RequiredArgsConstructor
24
+public class AlertController {
25
+
26
+    private final AlertRuleService ruleService;
27
+    private final AlertCenterService centerService;
28
+
29
+    // ==================== 报警规则管理 (CRUD) ====================
30
+
31
+    @Operation(summary = "分页查询报警规则")
32
+    @GetMapping("/rule/page")
33
+    public R<Page<AlertRule>> rulePage(
34
+            @RequestParam(defaultValue = "1") int current,
35
+            @RequestParam(defaultValue = "10") int size,
36
+            @RequestParam(required = false) String keyword,
37
+            @RequestParam(required = false) String alertLevel,
38
+            @RequestParam(required = false) Boolean enabled) {
39
+        return R.ok(ruleService.page(current, size, keyword, alertLevel, enabled));
40
+    }
41
+
42
+    @Operation(summary = "查询规则详情")
43
+    @GetMapping("/rule/{id}")
44
+    public R<AlertRule> ruleDetail(@PathVariable Long id) {
45
+        AlertRule rule = ruleService.getById(id);
46
+        return rule != null ? R.ok(rule) : R.fail(404, "规则不存在");
47
+    }
48
+
49
+    @Operation(summary = "创建报警规则")
50
+    @PostMapping("/rule")
51
+    public R<AlertRule> createRule(@RequestBody AlertRule rule) {
52
+        return R.ok(ruleService.create(rule));
53
+    }
54
+
55
+    @Operation(summary = "更新报警规则")
56
+    @PutMapping("/rule/{id}")
57
+    public R<String> updateRule(@PathVariable Long id, @RequestBody AlertRule rule) {
58
+        rule.setId(id);
59
+        return ruleService.update(rule) ? R.ok("更新成功") : R.fail("更新失败");
60
+    }
61
+
62
+    @Operation(summary = "删除报警规则")
63
+    @DeleteMapping("/rule/{id}")
64
+    public R<String> deleteRule(@PathVariable Long id) {
65
+        return ruleService.delete(id) ? R.ok("删除成功") : R.fail("删除失败");
66
+    }
67
+
68
+    @Operation(summary = "启用/禁用规则")
69
+    @PutMapping("/rule/{id}/toggle")
70
+    public R<String> toggleRule(@PathVariable Long id, @RequestParam boolean enabled) {
71
+        return ruleService.toggleEnabled(id, enabled) ? R.ok("操作成功") : R.fail("操作失败");
72
+    }
73
+
74
+    @Operation(summary = "查询所有启用规则")
75
+    @GetMapping("/rule/enabled")
76
+    public R<List<AlertRule>> enabledRules() {
77
+        return R.ok(ruleService.findAllEnabled());
78
+    }
79
+
80
+    @Operation(summary = "规则等级统计")
81
+    @GetMapping("/rule/stats")
82
+    public R<List<Map<String, Object>>> ruleStats() {
83
+        return R.ok(ruleService.countByLevel());
84
+    }
85
+
86
+    // ==================== 规则引擎 - 指标评估 ====================
87
+
88
+    @Operation(summary = "提交指标数据触发规则评估")
89
+    @PostMapping("/evaluate")
90
+    public R<List<AlertRecord>> evaluate(
91
+            @RequestParam String deviceSn,
92
+            @RequestParam String metricKey,
93
+            @RequestParam double value,
94
+            @RequestParam(required = false) String area) {
95
+        return R.ok(ruleService.evaluateMetric(deviceSn, metricKey, value, area));
96
+    }
97
+
98
+    // ==================== 报警管理中心 ====================
99
+
100
+    @Operation(summary = "分页查询报警记录")
101
+    @GetMapping("/record/page")
102
+    public R<Page<AlertRecord>> recordPage(
103
+            @RequestParam(defaultValue = "1") int current,
104
+            @RequestParam(defaultValue = "20") int size,
105
+            @RequestParam(required = false) String level,
106
+            @RequestParam(required = false) String area,
107
+            @RequestParam(required = false) Integer status,
108
+            @RequestParam(required = false) String deviceSn,
109
+            @RequestParam(required = false) String startDate,
110
+            @RequestParam(required = false) String endDate) {
111
+        return R.ok(centerService.page(current, size, level, area, status, deviceSn, startDate, endDate));
112
+    }
113
+
114
+    @Operation(summary = "查询报警详情(含通知记录)")
115
+    @GetMapping("/record/{id}")
116
+    public R<Map<String, Object>> recordDetail(@PathVariable Long id) {
117
+        Map<String, Object> detail = centerService.getDetail(id);
118
+        return detail != null ? R.ok(detail) : R.fail(404, "报警记录不存在");
119
+    }
120
+
121
+    @Operation(summary = "查询活跃报警")
122
+    @GetMapping("/record/active")
123
+    public R<List<AlertRecord>> activeAlerts() {
124
+        return R.ok(centerService.findActive());
125
+    }
126
+
127
+    @Operation(summary = "查询待确认报警")
128
+    @GetMapping("/record/pending")
129
+    public R<List<AlertRecord>> pendingAlerts() {
130
+        return R.ok(centerService.findPendingConfirmation());
131
+    }
132
+
133
+    @Operation(summary = "确认报警")
134
+    @PostMapping("/record/{id}/confirm")
135
+    public R<String> confirm(@PathVariable Long id, @RequestParam Long userId) {
136
+        return centerService.confirm(id, userId) ? R.ok("已确认") : R.fail("确认失败(状态不允许)");
137
+    }
138
+
139
+    @Operation(summary = "派单(指派处理人)")
140
+    @PostMapping("/record/{id}/dispatch")
141
+    public R<String> dispatch(@PathVariable Long id,
142
+                               @RequestParam Long assigneeId,
143
+                               @RequestParam(required = false) String assigneeName) {
144
+        return centerService.dispatch(id, assigneeId, assigneeName != null ? assigneeName : "")
145
+                ? R.ok("已派单") : R.fail("派单失败(状态不允许)");
146
+    }
147
+
148
+    @Operation(summary = "开始处理")
149
+    @PostMapping("/record/{id}/start-handle")
150
+    public R<String> startHandle(@PathVariable Long id,
151
+                                  @RequestParam Long handlerId,
152
+                                  @RequestParam(required = false) String handlerName) {
153
+        return centerService.startHandle(id, handlerId, handlerName != null ? handlerName : "")
154
+                ? R.ok("已开始处理") : R.fail("操作失败(状态不允许)");
155
+    }
156
+
157
+    @Operation(summary = "完成处理")
158
+    @PostMapping("/record/{id}/complete")
159
+    public R<String> completeHandle(@PathVariable Long id, @RequestBody Map<String, String> body) {
160
+        String result = body.getOrDefault("result", "");
161
+        return centerService.completeHandle(id, result) ? R.ok("处理完成") : R.fail("操作失败(状态不允许)");
162
+    }
163
+
164
+    @Operation(summary = "归档报警")
165
+    @PostMapping("/record/{id}/archive")
166
+    public R<String> archive(@PathVariable Long id, @RequestBody Map<String, String> body) {
167
+        String reason = body.getOrDefault("reason", "");
168
+        return centerService.archive(id, reason) ? R.ok("已归档") : R.fail("归档失败(状态不允许)");
169
+    }
170
+
171
+    @Operation(summary = "批量确认报警")
172
+    @PostMapping("/record/batch-confirm")
173
+    public R<Map<String, Object>> batchConfirm(@RequestBody Map<String, Object> body) {
174
+        @SuppressWarnings("unchecked")
175
+        List<Number> ids = (List<Number>) body.get("ids");
176
+        Long userId = ((Number) body.get("userId")).longValue();
177
+        List<Long> longIds = ids.stream().map(Number::longValue).toList();
178
+        int count = centerService.batchConfirm(longIds, userId);
179
+        return R.ok(Map.of("confirmed", count));
180
+    }
181
+
182
+    @Operation(summary = "批量归档报警")
183
+    @PostMapping("/record/batch-archive")
184
+    public R<Map<String, Object>> batchArchive(@RequestBody Map<String, Object> body) {
185
+        @SuppressWarnings("unchecked")
186
+        List<Number> ids = (List<Number>) body.get("ids");
187
+        String reason = (String) body.getOrDefault("reason", "");
188
+        List<Long> longIds = ids.stream().map(Number::longValue).toList();
189
+        int count = centerService.batchArchive(longIds, reason);
190
+        return R.ok(Map.of("archived", count));
191
+    }
192
+
193
+    @Operation(summary = "查询报警通知记录")
194
+    @GetMapping("/record/{id}/notifications")
195
+    public R<List<AlertNotification>> notifications(@PathVariable Long id) {
196
+        return R.ok(centerService.getNotifications(id));
197
+    }
198
+
199
+    // ==================== 统计看板 ====================
200
+
201
+    @Operation(summary = "报警统计看板")
202
+    @GetMapping("/dashboard")
203
+    public R<Map<String, Object>> dashboard(@RequestParam(defaultValue = "week") String period) {
204
+        return R.ok(centerService.getDashboard(period));
205
+    }
206
+
207
+    @Operation(summary = "今日报警概要")
208
+    @GetMapping("/today-summary")
209
+    public R<Map<String, Object>> todaySummary() {
210
+        return R.ok(centerService.getTodaySummary());
211
+    }
212
+}

+ 56
- 0
wm-production/src/main/java/com/water/production/entity/AlertNotification.java Просмотреть файл

@@ -0,0 +1,56 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 报警通知记录实体
10
+ */
11
+@Data
12
+@TableName("prod_alert_notification")
13
+public class AlertNotification {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 关联的报警记录ID */
19
+    private Long alertRecordId;
20
+
21
+    /** 关联的规则ID */
22
+    private Long ruleId;
23
+
24
+    /** 通知渠道: sms/wechat/app/email */
25
+    private String channel;
26
+
27
+    /** 接收人标识 */
28
+    private String recipient;
29
+
30
+    /** 接收人姓名 */
31
+    private String recipientName;
32
+
33
+    /** 通知标题 */
34
+    private String title;
35
+
36
+    /** 通知内容 */
37
+    private String content;
38
+
39
+    /** 状态: 0=待发送 1=已发送 2=发送失败 3=已读 */
40
+    private Integer status;
41
+
42
+    /** 发送时间 */
43
+    private LocalDateTime sendTime;
44
+
45
+    /** 阅读时间 */
46
+    private LocalDateTime readTime;
47
+
48
+    /** 重试次数 */
49
+    private Integer retryCount;
50
+
51
+    /** 错误信息 */
52
+    private String errorMsg;
53
+
54
+    @TableField(fill = FieldFill.INSERT)
55
+    private LocalDateTime createdTime;
56
+}

+ 102
- 9
wm-production/src/main/java/com/water/production/entity/AlertRecord.java Просмотреть файл

@@ -1,13 +1,106 @@
1 1
 package com.water.production.entity;
2
+
2 3
 import com.baomidou.mybatisplus.annotation.*;
3
-import lombok.Data; import java.time.LocalDateTime;
4
-@Data @TableName("prod_alert_record")
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * 报警记录实体(全生命周期)
11
+ * 状态流转: 0=活跃 → 1=已确认 → 2=已派单 → 3=处理中 → 4=已处理 → 5=已归档
12
+ */
13
+@Data
14
+@TableName("prod_alert_record")
5 15
 public class AlertRecord {
6
-    @TableId(type = IdType.AUTO) private Long id;
7
-    private String deviceId, area, level; // 一般/重要/紧急
8
-    private String alertType, description;
9
-    private Integer status; // 0活跃 1已确认 2已处理
10
-    private Long confirmedBy, handledBy;
11
-    @TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
12
-    private LocalDateTime confirmedTime, handledTime;
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 关联规则ID */
21
+    private Long ruleId;
22
+
23
+    /** 规则名称 */
24
+    private String ruleName;
25
+
26
+    /** 设备ID */
27
+    private Long deviceId;
28
+
29
+    /** 设备编号 */
30
+    private String deviceSn;
31
+
32
+    /** 设备名称 */
33
+    private String deviceName;
34
+
35
+    /** 所属区域 */
36
+    private String area;
37
+
38
+    /** 指标键 */
39
+    private String metricKey;
40
+
41
+    /** 指标值 */
42
+    private BigDecimal metricValue;
43
+
44
+    /** 阈值(字符串,用于展示) */
45
+    private String thresholdValue;
46
+
47
+    /** 报警等级: general/important/urgent */
48
+    private String alertLevel;
49
+
50
+    /** 报警标题 */
51
+    private String title;
52
+
53
+    /** 报警详情 */
54
+    private String message;
55
+
56
+    /**
57
+     * 生命周期状态:
58
+     * 0=活跃, 1=已确认, 2=已派单, 3=处理中, 4=已处理, 5=已归档
59
+     */
60
+    private Integer status;
61
+
62
+    /** 确认人ID */
63
+    private Long confirmedBy;
64
+
65
+    /** 确认时间 */
66
+    private LocalDateTime confirmedTime;
67
+
68
+    /** 派单时间 */
69
+    private LocalDateTime dispatchTime;
70
+
71
+    /** 指派人ID */
72
+    private Long assigneeId;
73
+
74
+    /** 指派人姓名 */
75
+    private String assigneeName;
76
+
77
+    /** 处理人ID */
78
+    private Long handlerId;
79
+
80
+    /** 处理人姓名 */
81
+    private String handlerName;
82
+
83
+    /** 处理结果 */
84
+    private String handleResult;
85
+
86
+    /** 处理时间 */
87
+    private LocalDateTime handleTime;
88
+
89
+    /** 归档时间 */
90
+    private LocalDateTime archiveTime;
91
+
92
+    /** 归档原因 */
93
+    private String archiveReason;
94
+
95
+    /** 解决时间 */
96
+    private LocalDateTime resolvedAt;
97
+
98
+    @TableField(fill = FieldFill.INSERT)
99
+    private LocalDateTime createdTime;
100
+
101
+    @TableField(fill = FieldFill.INSERT_UPDATE)
102
+    private LocalDateTime updatedTime;
103
+
104
+    @TableLogic
105
+    private Integer deleted;
13 106
 }

+ 80
- 0
wm-production/src/main/java/com/water/production/entity/AlertRule.java Просмотреть файл

@@ -0,0 +1,80 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDateTime;
8
+import java.time.LocalTime;
9
+
10
+/**
11
+ * 报警规则定义实体
12
+ */
13
+@Data
14
+@TableName("prod_alert_rule")
15
+public class AlertRule {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 规则名称 */
21
+    private String ruleName;
22
+
23
+    /** 规则编码 */
24
+    private String ruleCode;
25
+
26
+    /** 规则描述 */
27
+    private String description;
28
+
29
+    /** 设备类型 */
30
+    private String deviceType;
31
+
32
+    /** 指标键 */
33
+    private String metricKey;
34
+
35
+    /** 报警等级: general/important/urgent */
36
+    private String alertLevel;
37
+
38
+    /** 条件表达式JSON (支持AND/OR组合) */
39
+    private String conditionExpr;
40
+
41
+    /** 简单阈值(向后兼容) */
42
+    private BigDecimal thresholdValue;
43
+
44
+    /** 去重窗口(秒) */
45
+    private Integer debounceSec;
46
+
47
+    /** 通知渠道(sms,wechat,app,email) */
48
+    private String notifyChannels;
49
+
50
+    /** 通知模板 */
51
+    private String notifyTemplate;
52
+
53
+    /** 是否启用 */
54
+    private Integer enabled;
55
+
56
+    /** 规则优先级 */
57
+    private Integer priority;
58
+
59
+    /** 生效开始时间 */
60
+    private LocalTime effectiveStart;
61
+
62
+    /** 生效结束时间 */
63
+    private LocalTime effectiveEnd;
64
+
65
+    /** 创建人 */
66
+    private Long createdBy;
67
+
68
+    /** 更新人 */
69
+    private Long updatedBy;
70
+
71
+    /** 逻辑删除 */
72
+    @TableLogic
73
+    private Integer deleted;
74
+
75
+    @TableField(fill = FieldFill.INSERT)
76
+    private LocalDateTime createdTime;
77
+
78
+    @TableField(fill = FieldFill.INSERT_UPDATE)
79
+    private LocalDateTime updatedTime;
80
+}

+ 27
- 0
wm-production/src/main/java/com/water/production/mapper/AlertNotificationMapper.java Просмотреть файл

@@ -0,0 +1,27 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.AlertNotification;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+@Mapper
13
+public interface AlertNotificationMapper extends BaseMapper<AlertNotification> {
14
+
15
+    /**
16
+     * 按报警记录ID查询通知记录
17
+     */
18
+    @Select("SELECT * FROM prod_alert_notification WHERE alert_record_id = #{recordId} ORDER BY created_time")
19
+    List<AlertNotification> findByAlertRecordId(@Param("recordId") Long recordId);
20
+
21
+    /**
22
+     * 统计通知发送情况
23
+     */
24
+    @Select("SELECT channel, status, COUNT(*) as count FROM prod_alert_notification " +
25
+            "WHERE created_time >= #{startTime} GROUP BY channel, status")
26
+    List<Map<String, Object>> statisticsByChannel(@Param("startTime") String startTime);
27
+}

+ 95
- 1
wm-production/src/main/java/com/water/production/mapper/AlertRecordMapper.java Просмотреть файл

@@ -1,5 +1,99 @@
1 1
 package com.water.production.mapper;
2
+
2 3
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
3 4
 import com.water.production.entity.AlertRecord;
4 5
 import org.apache.ibatis.annotations.Mapper;
5
-@Mapper public interface AlertRecordMapper extends BaseMapper<AlertRecord> {}
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+import org.apache.ibatis.annotations.Update;
9
+
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+@Mapper
14
+public interface AlertRecordMapper extends BaseMapper<AlertRecord> {
15
+
16
+    /**
17
+     * 按状态统计报警数量
18
+     */
19
+    @Select("SELECT status, COUNT(*) as count FROM prod_alert_record WHERE deleted = 0 GROUP BY status")
20
+    List<Map<String, Object>> countByStatus();
21
+
22
+    /**
23
+     * 按等级统计报警数量
24
+     */
25
+    @Select("SELECT alert_level, COUNT(*) as count FROM prod_alert_record WHERE deleted = 0 " +
26
+            "AND created_time >= #{startTime} GROUP BY alert_level")
27
+    List<Map<String, Object>> countByLevel(@Param("startTime") String startTime);
28
+
29
+    /**
30
+     * 按区域统计报警数量
31
+     */
32
+    @Select("SELECT area, COUNT(*) as count FROM prod_alert_record WHERE deleted = 0 " +
33
+            "AND created_time >= #{startTime} GROUP BY area ORDER BY count DESC")
34
+    List<Map<String, Object>> countByArea(@Param("startTime") String startTime);
35
+
36
+    /**
37
+     * 按日期统计报警趋势(最近N天)
38
+     */
39
+    @Select("SELECT DATE(created_time) as date, alert_level, COUNT(*) as count " +
40
+            "FROM prod_alert_record WHERE deleted = 0 AND created_time >= #{startTime} " +
41
+            "GROUP BY DATE(created_time), alert_level ORDER BY date")
42
+    List<Map<String, Object>> trendByDate(@Param("startTime") String startTime);
43
+
44
+    /**
45
+     * 统计今日各状态报警数量
46
+     */
47
+    @Select("SELECT status, alert_level, COUNT(*) as count FROM prod_alert_record " +
48
+            "WHERE deleted = 0 AND created_time >= CURRENT_DATE GROUP BY status, alert_level")
49
+    List<Map<String, Object>> todayStatistics();
50
+
51
+    /**
52
+     * 查询活跃报警(未归档)
53
+     */
54
+    @Select("SELECT * FROM prod_alert_record WHERE deleted = 0 AND status < 5 ORDER BY created_time DESC")
55
+    List<AlertRecord> findActive();
56
+
57
+    /**
58
+     * 查询需要确认的报警
59
+     */
60
+    @Select("SELECT * FROM prod_alert_record WHERE deleted = 0 AND status = 0 ORDER BY " +
61
+            "CASE alert_level WHEN 'urgent' THEN 1 WHEN 'important' THEN 2 ELSE 3 END, created_time DESC")
62
+    List<AlertRecord> findPendingConfirmation();
63
+
64
+    /**
65
+     * 确认报警
66
+     */
67
+    @Update("UPDATE prod_alert_record SET status = 1, confirmed_by = #{userId}, " +
68
+            "confirmed_time = NOW(), updated_time = NOW() WHERE id = #{id} AND status = 0")
69
+    int confirm(@Param("id") Long id, @Param("userId") Long userId);
70
+
71
+    /**
72
+     * 派单
73
+     */
74
+    @Update("UPDATE prod_alert_record SET status = 2, assignee_id = #{assigneeId}, " +
75
+            "assignee_name = #{assigneeName}, dispatch_time = NOW(), updated_time = NOW() " +
76
+            "WHERE id = #{id} AND status IN (0, 1)")
77
+    int dispatch(@Param("id") Long id, @Param("assigneeId") Long assigneeId, @Param("assigneeName") String assigneeName);
78
+
79
+    /**
80
+     * 开始处理
81
+     */
82
+    @Update("UPDATE prod_alert_record SET status = 3, handler_id = #{handlerId}, " +
83
+            "handler_name = #{handlerName}, updated_time = NOW() WHERE id = #{id} AND status = 2")
84
+    int startHandle(@Param("id") Long id, @Param("handlerId") Long handlerId, @Param("handlerName") String handlerName);
85
+
86
+    /**
87
+     * 完成处理
88
+     */
89
+    @Update("UPDATE prod_alert_record SET status = 4, handle_result = #{result}, " +
90
+            "handle_time = NOW(), resolved_at = NOW(), updated_time = NOW() WHERE id = #{id} AND status = 3")
91
+    int completeHandle(@Param("id") Long id, @Param("result") String result);
92
+
93
+    /**
94
+     * 归档
95
+     */
96
+    @Update("UPDATE prod_alert_record SET status = 5, archive_time = NOW(), " +
97
+            "archive_reason = #{reason}, updated_time = NOW() WHERE id = #{id} AND status = 4")
98
+    int archive(@Param("id") Long id, @Param("reason") String reason);
99
+}

+ 32
- 0
wm-production/src/main/java/com/water/production/mapper/AlertRuleMapper.java Просмотреть файл

@@ -0,0 +1,32 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.AlertRule;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+@Mapper
13
+public interface AlertRuleMapper extends BaseMapper<AlertRule> {
14
+
15
+    /**
16
+     * 根据指标键查询启用的规则
17
+     */
18
+    @Select("SELECT * FROM prod_alert_rule WHERE metric_key = #{metricKey} AND enabled = 1 AND deleted = 0")
19
+    List<AlertRule> findEnabledByMetricKey(@Param("metricKey") String metricKey);
20
+
21
+    /**
22
+     * 查询所有启用的规则
23
+     */
24
+    @Select("SELECT * FROM prod_alert_rule WHERE enabled = 1 AND deleted = 0 ORDER BY priority DESC")
25
+    List<AlertRule> findAllEnabled();
26
+
27
+    /**
28
+     * 统计各等级规则数量
29
+     */
30
+    @Select("SELECT alert_level, COUNT(*) as count FROM prod_alert_rule WHERE enabled = 1 AND deleted = 0 GROUP BY alert_level")
31
+    List<Map<String, Object>> countByLevel();
32
+}

+ 309
- 0
wm-production/src/main/java/com/water/production/service/AlertCenterService.java Просмотреть файл

@@ -0,0 +1,309 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.production.entity.AlertNotification;
6
+import com.water.production.entity.AlertRecord;
7
+import com.water.production.mapper.AlertNotificationMapper;
8
+import com.water.production.mapper.AlertRecordMapper;
9
+import com.water.production.mapper.AlertRuleMapper;
10
+import lombok.RequiredArgsConstructor;
11
+import lombok.extern.slf4j.Slf4j;
12
+import org.springframework.stereotype.Service;
13
+import org.springframework.transaction.annotation.Transactional;
14
+
15
+import java.time.LocalDate;
16
+import java.time.LocalDateTime;
17
+import java.time.format.DateTimeFormatter;
18
+import java.util.*;
19
+
20
+/**
21
+ * 报警管理中心服务 - 报警全生命周期管理 + 统计看板
22
+ */
23
+@Slf4j
24
+@Service
25
+@RequiredArgsConstructor
26
+public class AlertCenterService {
27
+
28
+    private final AlertRecordMapper recordMapper;
29
+    private final AlertNotificationMapper notificationMapper;
30
+    private final AlertRuleMapper ruleMapper;
31
+
32
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
33
+
34
+    // ==================== 报警列表与详情 ====================
35
+
36
+    /**
37
+     * 分页查询报警列表 (支持多条件筛选)
38
+     */
39
+    public Page<AlertRecord> page(int current, int size, String level, String area,
40
+                                   Integer status, String deviceSn,
41
+                                   String startDate, String endDate) {
42
+        LambdaQueryWrapper<AlertRecord> wrapper = new LambdaQueryWrapper<>();
43
+        if (level != null && !level.isEmpty()) wrapper.eq(AlertRecord::getAlertLevel, level);
44
+        if (area != null && !area.isEmpty()) wrapper.eq(AlertRecord::getArea, area);
45
+        if (status != null) wrapper.eq(AlertRecord::getStatus, status);
46
+        if (deviceSn != null && !deviceSn.isEmpty()) wrapper.like(AlertRecord::getDeviceSn, deviceSn);
47
+        if (startDate != null && !startDate.isEmpty()) {
48
+            wrapper.ge(AlertRecord::getCreatedTime, LocalDate.parse(startDate, DATE_FMT).atStartOfDay());
49
+        }
50
+        if (endDate != null && !endDate.isEmpty()) {
51
+            wrapper.le(AlertRecord::getCreatedTime, LocalDate.parse(endDate, DATE_FMT).plusDays(1).atStartOfDay());
52
+        }
53
+        wrapper.orderByDesc(AlertRecord::getCreatedTime);
54
+        return recordMapper.selectPage(new Page<>(current, size), wrapper);
55
+    }
56
+
57
+    /**
58
+     * 查询报警详情 (含通知记录)
59
+     */
60
+    public Map<String, Object> getDetail(Long id) {
61
+        AlertRecord record = recordMapper.selectById(id);
62
+        if (record == null) return null;
63
+
64
+        Map<String, Object> detail = new LinkedHashMap<>();
65
+        detail.put("record", record);
66
+        detail.put("notifications", notificationMapper.findByAlertRecordId(id));
67
+        detail.put("statusLabel", statusLabel(record.getStatus()));
68
+        detail.put("levelLabel", levelLabel(record.getAlertLevel()));
69
+        return detail;
70
+    }
71
+
72
+    /**
73
+     * 查询活跃报警
74
+     */
75
+    public List<AlertRecord> findActive() {
76
+        return recordMapper.findActive();
77
+    }
78
+
79
+    /**
80
+     * 查询待确认报警
81
+     */
82
+    public List<AlertRecord> findPendingConfirmation() {
83
+        return recordMapper.findPendingConfirmation();
84
+    }
85
+
86
+    // ==================== 生命周期操作 ====================
87
+
88
+    /**
89
+     * 确认报警
90
+     */
91
+    @Transactional
92
+    public boolean confirm(Long id, Long userId) {
93
+        int rows = recordMapper.confirm(id, userId);
94
+        if (rows > 0) {
95
+            log.info("Alert confirmed: id={}, userId={}", id, userId);
96
+        }
97
+        return rows > 0;
98
+    }
99
+
100
+    /**
101
+     * 派单(指派给处理人)
102
+     */
103
+    @Transactional
104
+    public boolean dispatch(Long id, Long assigneeId, String assigneeName) {
105
+        int rows = recordMapper.dispatch(id, assigneeId, assigneeName);
106
+        if (rows > 0) {
107
+            log.info("Alert dispatched: id={}, assignee={}", id, assigneeName);
108
+            // 可选: 创建巡检任务
109
+            createPatrolTask(id, assigneeId);
110
+        }
111
+        return rows > 0;
112
+    }
113
+
114
+    /**
115
+     * 开始处理
116
+     */
117
+    @Transactional
118
+    public boolean startHandle(Long id, Long handlerId, String handlerName) {
119
+        int rows = recordMapper.startHandle(id, handlerId, handlerName);
120
+        if (rows > 0) {
121
+            log.info("Alert handling started: id={}, handler={}", id, handlerName);
122
+        }
123
+        return rows > 0;
124
+    }
125
+
126
+    /**
127
+     * 完成处理
128
+     */
129
+    @Transactional
130
+    public boolean completeHandle(Long id, String result) {
131
+        int rows = recordMapper.completeHandle(id, result);
132
+        if (rows > 0) {
133
+            log.info("Alert handled: id={}", id);
134
+        }
135
+        return rows > 0;
136
+    }
137
+
138
+    /**
139
+     * 归档
140
+     */
141
+    @Transactional
142
+    public boolean archive(Long id, String reason) {
143
+        int rows = recordMapper.archive(id, reason);
144
+        if (rows > 0) {
145
+            log.info("Alert archived: id={}", id);
146
+        }
147
+        return rows > 0;
148
+    }
149
+
150
+    /**
151
+     * 批量确认
152
+     */
153
+    @Transactional
154
+    public int batchConfirm(List<Long> ids, Long userId) {
155
+        int count = 0;
156
+        for (Long id : ids) {
157
+            count += recordMapper.confirm(id, userId);
158
+        }
159
+        return count;
160
+    }
161
+
162
+    /**
163
+     * 批量归档
164
+     */
165
+    @Transactional
166
+    public int batchArchive(List<Long> ids, String reason) {
167
+        int count = 0;
168
+        for (Long id : ids) {
169
+            count += recordMapper.archive(id, reason);
170
+        }
171
+        return count;
172
+    }
173
+
174
+    // ==================== 统计看板 ====================
175
+
176
+    /**
177
+     * 获取统计看板数据
178
+     */
179
+    public Map<String, Object> getDashboard(String period) {
180
+        Map<String, Object> dashboard = new LinkedHashMap<>();
181
+
182
+        // 计算起始时间
183
+        String startTime = calculateStartTime(period);
184
+
185
+        // 总览数据
186
+        dashboard.put("totalByStatus", recordMapper.countByStatus());
187
+        dashboard.put("totalByLevel", recordMapper.countByLevel(startTime));
188
+        dashboard.put("totalByArea", recordMapper.countByArea(startTime));
189
+        dashboard.put("trend", recordMapper.trendByDate(startTime));
190
+        dashboard.put("today", recordMapper.todayStatistics());
191
+
192
+        // 规则统计
193
+        dashboard.put("ruleStats", ruleMapper.countByLevel());
194
+
195
+        // 通知统计
196
+        dashboard.put("notificationStats", notificationMapper.statisticsByChannel(startTime));
197
+
198
+        // 汇总数值
199
+        dashboard.put("summary", buildSummary(startTime));
200
+
201
+        dashboard.put("period", period);
202
+        dashboard.put("generatedAt", LocalDateTime.now());
203
+
204
+        return dashboard;
205
+    }
206
+
207
+    /**
208
+     * 获取今日报警概要
209
+     */
210
+    public Map<String, Object> getTodaySummary() {
211
+        Map<String, Object> summary = new LinkedHashMap<>();
212
+        List<Map<String, Object>> todayStats = recordMapper.todayStatistics();
213
+
214
+        long totalToday = 0;
215
+        long activeCount = 0;
216
+        long confirmedCount = 0;
217
+        long handledCount = 0;
218
+
219
+        for (Map<String, Object> row : todayStats) {
220
+            long count = ((Number) row.get("count")).longValue();
221
+            int status = ((Number) row.get("status")).intValue();
222
+            totalToday += count;
223
+            if (status == 0) activeCount += count;
224
+            else if (status == 1) confirmedCount += count;
225
+            else if (status >= 4) handledCount += count;
226
+        }
227
+
228
+        summary.put("totalToday", totalToday);
229
+        summary.put("active", activeCount);
230
+        summary.put("confirmed", confirmedCount);
231
+        summary.put("handled", handledCount);
232
+        summary.put("pending", totalToday - handledCount);
233
+        summary.put("detail", todayStats);
234
+        return summary;
235
+    }
236
+
237
+    // ==================== 通知记录查询 ====================
238
+
239
+    /**
240
+     * 查询报警关联的通知记录
241
+     */
242
+    public List<AlertNotification> getNotifications(Long alertRecordId) {
243
+        return notificationMapper.findByAlertRecordId(alertRecordId);
244
+    }
245
+
246
+    // ==================== 内部方法 ====================
247
+
248
+    private String calculateStartTime(String period) {
249
+        LocalDate now = LocalDate.now();
250
+        LocalDate start = switch (period != null ? period : "week") {
251
+            case "day" -> now.minusDays(1);
252
+            case "week" -> now.minusWeeks(1);
253
+            case "month" -> now.minusMonths(1);
254
+            case "quarter" -> now.minusMonths(3);
255
+            case "year" -> now.minusYears(1);
256
+            default -> now.minusWeeks(1);
257
+        };
258
+        return start.format(DATE_FMT);
259
+    }
260
+
261
+    private Map<String, Object> buildSummary(String startTime) {
262
+        Map<String, Object> summary = new LinkedHashMap<>();
263
+        // 总报警数
264
+        LambdaQueryWrapper<AlertRecord> totalWrapper = new LambdaQueryWrapper<>();
265
+        totalWrapper.ge(AlertRecord::getCreatedTime, LocalDate.parse(startTime, DATE_FMT).atStartOfDay());
266
+        long total = recordMapper.selectCount(totalWrapper);
267
+        summary.put("total", total);
268
+
269
+        // 活跃报警数
270
+        LambdaQueryWrapper<AlertRecord> activeWrapper = new LambdaQueryWrapper<>();
271
+        activeWrapper.lt(AlertRecord::getStatus, 5)
272
+                .ge(AlertRecord::getCreatedTime, LocalDate.parse(startTime, DATE_FMT).atStartOfDay());
273
+        summary.put("active", recordMapper.selectCount(activeWrapper));
274
+
275
+        // 紧急报警数
276
+        LambdaQueryWrapper<AlertRecord> urgentWrapper = new LambdaQueryWrapper<>();
277
+        urgentWrapper.eq(AlertRecord::getAlertLevel, "urgent")
278
+                .ge(AlertRecord::getCreatedTime, LocalDate.parse(startTime, DATE_FMT).atStartOfDay());
279
+        summary.put("urgent", recordMapper.selectCount(urgentWrapper));
280
+
281
+        return summary;
282
+    }
283
+
284
+    private void createPatrolTask(Long alertId, Long assigneeId) {
285
+        // 可选集成: 创建巡检任务关联报警
286
+        log.info("Patrol task creation for alert {} assigned to user {}", alertId, assigneeId);
287
+    }
288
+
289
+    private String statusLabel(Integer status) {
290
+        return switch (status) {
291
+            case 0 -> "活跃";
292
+            case 1 -> "已确认";
293
+            case 2 -> "已派单";
294
+            case 3 -> "处理中";
295
+            case 4 -> "已处理";
296
+            case 5 -> "已归档";
297
+            default -> "未知";
298
+        };
299
+    }
300
+
301
+    private String levelLabel(String level) {
302
+        return switch (level) {
303
+            case "urgent" -> "紧急";
304
+            case "important" -> "重要";
305
+            case "general" -> "一般";
306
+            default -> level;
307
+        };
308
+    }
309
+}

+ 366
- 0
wm-production/src/main/java/com/water/production/service/AlertRuleService.java Просмотреть файл

@@ -0,0 +1,366 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.fasterxml.jackson.databind.JsonNode;
6
+import com.fasterxml.jackson.databind.ObjectMapper;
7
+import com.water.production.entity.AlertNotification;
8
+import com.water.production.entity.AlertRecord;
9
+import com.water.production.entity.AlertRule;
10
+import com.water.production.mapper.AlertNotificationMapper;
11
+import com.water.production.mapper.AlertRecordMapper;
12
+import com.water.production.mapper.AlertRuleMapper;
13
+import lombok.RequiredArgsConstructor;
14
+import lombok.extern.slf4j.Slf4j;
15
+import org.springframework.stereotype.Service;
16
+import org.springframework.transaction.annotation.Transactional;
17
+
18
+import java.math.BigDecimal;
19
+import java.time.Instant;
20
+import java.time.LocalDateTime;
21
+import java.time.LocalTime;
22
+import java.util.*;
23
+import java.util.concurrent.ConcurrentHashMap;
24
+
25
+/**
26
+ * 报警规则服务 - CRUD + 规则评估引擎(支持AND/OR组合条件)
27
+ */
28
+@Slf4j
29
+@Service
30
+@RequiredArgsConstructor
31
+public class AlertRuleService {
32
+
33
+    private final AlertRuleMapper ruleMapper;
34
+    private final AlertRecordMapper recordMapper;
35
+    private final AlertNotificationMapper notificationMapper;
36
+    private final ObjectMapper objectMapper;
37
+
38
+    /** 去重缓存: key -> 上次触发时间戳(秒) */
39
+    private final Map<String, Long> lastTriggerTime = new ConcurrentHashMap<>();
40
+
41
+    // ==================== CRUD ====================
42
+
43
+    /**
44
+     * 分页查询规则
45
+     */
46
+    public Page<AlertRule> page(int current, int size, String keyword, String alertLevel, Boolean enabled) {
47
+        LambdaQueryWrapper<AlertRule> wrapper = new LambdaQueryWrapper<>();
48
+        if (keyword != null && !keyword.isEmpty()) {
49
+            wrapper.and(w -> w.like(AlertRule::getRuleName, keyword)
50
+                    .or().like(AlertRule::getRuleCode, keyword)
51
+                    .or().like(AlertRule::getMetricKey, keyword));
52
+        }
53
+        if (alertLevel != null) wrapper.eq(AlertRule::getAlertLevel, alertLevel);
54
+        if (enabled != null) wrapper.eq(AlertRule::getEnabled, enabled ? 1 : 0);
55
+        wrapper.orderByDesc(AlertRule::getPriority).orderByDesc(AlertRule::getCreatedTime);
56
+        return ruleMapper.selectPage(new Page<>(current, size), wrapper);
57
+    }
58
+
59
+    /**
60
+     * 根据ID查询规则详情
61
+     */
62
+    public AlertRule getById(Long id) {
63
+        return ruleMapper.selectById(id);
64
+    }
65
+
66
+    /**
67
+     * 创建规则
68
+     */
69
+    @Transactional
70
+    public AlertRule create(AlertRule rule) {
71
+        rule.setCreatedTime(LocalDateTime.now());
72
+        rule.setUpdatedTime(LocalDateTime.now());
73
+        rule.setDeleted(0);
74
+        if (rule.getDebounceSec() == null) rule.setDebounceSec(300);
75
+        if (rule.getPriority() == null) rule.setPriority(0);
76
+        if (rule.getEnabled() == null) rule.setEnabled(1);
77
+        ruleMapper.insert(rule);
78
+        return rule;
79
+    }
80
+
81
+    /**
82
+     * 更新规则
83
+     */
84
+    @Transactional
85
+    public boolean update(AlertRule rule) {
86
+        rule.setUpdatedTime(LocalDateTime.now());
87
+        return ruleMapper.updateById(rule) > 0;
88
+    }
89
+
90
+    /**
91
+     * 删除规则(逻辑删除)
92
+     */
93
+    @Transactional
94
+    public boolean delete(Long id) {
95
+        return ruleMapper.deleteById(id) > 0;
96
+    }
97
+
98
+    /**
99
+     * 启用/禁用规则
100
+     */
101
+    @Transactional
102
+    public boolean toggleEnabled(Long id, boolean enabled) {
103
+        AlertRule rule = new AlertRule();
104
+        rule.setId(id);
105
+        rule.setEnabled(enabled ? 1 : 0);
106
+        rule.setUpdatedTime(LocalDateTime.now());
107
+        return ruleMapper.updateById(rule) > 0;
108
+    }
109
+
110
+    /**
111
+     * 查询所有启用的规则
112
+     */
113
+    public List<AlertRule> findAllEnabled() {
114
+        return ruleMapper.findAllEnabled();
115
+    }
116
+
117
+    /**
118
+     * 统计各等级规则数量
119
+     */
120
+    public List<Map<String, Object>> countByLevel() {
121
+        return ruleMapper.countByLevel();
122
+    }
123
+
124
+    // ==================== 规则评估引擎 ====================
125
+
126
+    /**
127
+     * 评估指标值是否触发报警 (核心引擎)
128
+     * 支持AND/OR组合条件的JSON表达式解析
129
+     *
130
+     * @param deviceSn  设备编号
131
+     * @param metricKey 指标键
132
+     * @param value     指标值
133
+     * @param area      区域
134
+     * @return 触发的报警记录列表
135
+     */
136
+    @Transactional
137
+    public List<AlertRecord> evaluateMetric(String deviceSn, String metricKey, double value, String area) {
138
+        List<AlertRule> rules = ruleMapper.findEnabledByMetricKey(metricKey);
139
+        List<AlertRecord> triggeredRecords = new ArrayList<>();
140
+
141
+        for (AlertRule rule : rules) {
142
+            try {
143
+                // 检查生效时间窗口
144
+                if (!isWithinEffectiveTime(rule)) continue;
145
+
146
+                // 解析并评估条件表达式
147
+                boolean triggered = evaluateCondition(rule.getConditionExpr(), metricKey, value);
148
+
149
+                if (!triggered) continue;
150
+
151
+                // 去重检查(debounce)
152
+                String dedupKey = deviceSn + ":" + metricKey + ":" + rule.getAlertLevel();
153
+                long now = Instant.now().getEpochSecond();
154
+                Long lastTime = lastTriggerTime.get(dedupKey);
155
+                if (lastTime != null && (now - lastTime) < rule.getDebounceSec()) {
156
+                    log.debug("Alert debounced: {} (last={}s ago, debounce={}s)", dedupKey, now - lastTime, rule.getDebounceSec());
157
+                    continue;
158
+                }
159
+                lastTriggerTime.put(dedupKey, now);
160
+
161
+                // 创建报警记录
162
+                AlertRecord record = createAlertRecord(rule, deviceSn, metricKey, value, area);
163
+                recordMapper.insert(record);
164
+                triggeredRecords.add(record);
165
+
166
+                // 发送通知
167
+                sendNotifications(rule, record);
168
+
169
+                log.info("Alert triggered: rule={} device={} metric={} value={} level={}",
170
+                        rule.getRuleName(), deviceSn, metricKey, value, rule.getAlertLevel());
171
+
172
+            } catch (Exception e) {
173
+                log.error("Evaluate rule error [{}]: {}", rule.getRuleCode(), e.getMessage(), e);
174
+            }
175
+        }
176
+        return triggeredRecords;
177
+    }
178
+
179
+    /**
180
+     * 解析并评估条件表达式 (支持AND/OR组合)
181
+     * 表达式格式:
182
+     * {"op":"AND","conditions":[
183
+     *   {"metric":"pressure","operator":">","threshold":0.8},
184
+     *   {"metric":"temperature","operator":"<","threshold":50}
185
+     * ]}
186
+     *
187
+     * 或简单格式: {"metric":"pressure","operator":">","threshold":0.8}
188
+     */
189
+    public boolean evaluateCondition(String conditionExpr, String metricKey, double value) {
190
+        if (conditionExpr == null || conditionExpr.isEmpty()) {
191
+            return false;
192
+        }
193
+
194
+        try {
195
+            JsonNode root = objectMapper.readTree(conditionExpr);
196
+
197
+            // 检查是否为组合条件
198
+            if (root.has("op") && root.has("conditions")) {
199
+                return evaluateCompositeCondition(root, metricKey, value);
200
+            }
201
+
202
+            // 简单条件
203
+            return evaluateSimpleCondition(root, metricKey, value);
204
+
205
+        } catch (Exception e) {
206
+            log.error("Parse condition expression error: {}", conditionExpr, e);
207
+            return false;
208
+        }
209
+    }
210
+
211
+    /**
212
+     * 评估组合条件(AND/OR)
213
+     */
214
+    private boolean evaluateCompositeCondition(JsonNode root, String metricKey, double value) {
215
+        String op = root.get("op").asText().toUpperCase();
216
+        JsonNode conditions = root.get("conditions");
217
+
218
+        if (conditions == null || !conditions.isArray()) return false;
219
+
220
+        if ("AND".equals(op)) {
221
+            for (JsonNode condition : conditions) {
222
+                if (!evaluateSimpleCondition(condition, metricKey, value)) {
223
+                    return false;
224
+                }
225
+            }
226
+            return true;
227
+        } else if ("OR".equals(op)) {
228
+            for (JsonNode condition : conditions) {
229
+                if (evaluateSimpleCondition(condition, metricKey, value)) {
230
+                    return true;
231
+                }
232
+            }
233
+            return false;
234
+        }
235
+
236
+        return false;
237
+    }
238
+
239
+    /**
240
+     * 评估简单条件
241
+     */
242
+    private boolean evaluateSimpleCondition(JsonNode condition, String metricKey, double value) {
243
+        String condMetric = condition.has("metric") ? condition.get("metric").asText() : metricKey;
244
+
245
+        // 如果条件指定了不同的metric, 则跳过(当前值不匹配)
246
+        if (!condMetric.equals(metricKey)) {
247
+            return false;
248
+        }
249
+
250
+        String operator = condition.get("operator").asText();
251
+        double threshold = condition.get("threshold").asDouble();
252
+
253
+        return switch (operator) {
254
+            case ">" -> value > threshold;
255
+            case ">=" -> value >= threshold;
256
+            case "<" -> value < threshold;
257
+            case "<=" -> value <= threshold;
258
+            case "==" -> Math.abs(value - threshold) < 0.0001;
259
+            case "!=" -> Math.abs(value - threshold) >= 0.0001;
260
+            default -> false;
261
+        };
262
+    }
263
+
264
+    /**
265
+     * 检查规则是否在生效时间窗口内
266
+     */
267
+    private boolean isWithinEffectiveTime(AlertRule rule) {
268
+        if (rule.getEffectiveStart() == null || rule.getEffectiveEnd() == null) {
269
+            return true; // 未设置时间窗口,全天生效
270
+        }
271
+        LocalTime now = LocalTime.now();
272
+        if (rule.getEffectiveStart().isBefore(rule.getEffectiveEnd())) {
273
+            return !now.isBefore(rule.getEffectiveStart()) && !now.isAfter(rule.getEffectiveEnd());
274
+        } else {
275
+            // 跨天场景: 如 22:00 - 06:00
276
+            return !now.isBefore(rule.getEffectiveStart()) || !now.isAfter(rule.getEffectiveEnd());
277
+        }
278
+    }
279
+
280
+    /**
281
+     * 创建报警记录
282
+     */
283
+    private AlertRecord createAlertRecord(AlertRule rule, String deviceSn, String metricKey, double value, String area) {
284
+        AlertRecord record = new AlertRecord();
285
+        record.setRuleId(rule.getId());
286
+        record.setRuleName(rule.getRuleName());
287
+        record.setDeviceSn(deviceSn);
288
+        record.setArea(area);
289
+        record.setMetricKey(metricKey);
290
+        record.setMetricValue(BigDecimal.valueOf(value));
291
+        record.setThresholdValue(rule.getThresholdValue() != null ? rule.getThresholdValue().toPlainString() : "");
292
+        record.setAlertLevel(rule.getAlertLevel());
293
+        record.setTitle(String.format("[%s] %s - %s", levelLabel(rule.getAlertLevel()), rule.getRuleName(), deviceSn));
294
+        record.setMessage(buildAlertMessage(rule, deviceSn, metricKey, value));
295
+        record.setStatus(0); // 活跃
296
+        record.setCreatedTime(LocalDateTime.now());
297
+        record.setUpdatedTime(LocalDateTime.now());
298
+        record.setDeleted(0);
299
+        return record;
300
+    }
301
+
302
+    /**
303
+     * 构建报警消息内容
304
+     */
305
+    private String buildAlertMessage(AlertRule rule, String deviceSn, String metricKey, double value) {
306
+        return String.format("设备 %s 指标 %s 当前值 %.4f,触发规则: %s (等级: %s)\n规则描述: %s",
307
+                deviceSn, metricKey, value, rule.getRuleName(),
308
+                levelLabel(rule.getAlertLevel()),
309
+                rule.getDescription() != null ? rule.getDescription() : "无");
310
+    }
311
+
312
+    /**
313
+     * 发送报警通知
314
+     */
315
+    private void sendNotifications(AlertRule rule, AlertRecord record) {
316
+        String channels = rule.getNotifyChannels();
317
+        if (channels == null || channels.isEmpty()) return;
318
+
319
+        for (String channel : channels.split(",")) {
320
+            channel = channel.trim();
321
+            if (channel.isEmpty()) continue;
322
+
323
+            AlertNotification notification = new AlertNotification();
324
+            notification.setAlertRecordId(record.getId());
325
+            notification.setRuleId(rule.getId());
326
+            notification.setChannel(channel);
327
+            notification.setRecipient("system"); // 默认接收人
328
+            notification.setTitle(record.getTitle());
329
+            notification.setContent(record.getMessage());
330
+            notification.setStatus(0); // 待发送
331
+            notification.setCreatedTime(LocalDateTime.now());
332
+            notificationMapper.insert(notification);
333
+
334
+            // 实际通知发送逻辑(调用通知中心接口)
335
+            try {
336
+                doSendNotification(channel, notification);
337
+                notification.setStatus(1); // 已发送
338
+                notification.setSendTime(LocalDateTime.now());
339
+            } catch (Exception e) {
340
+                notification.setStatus(2); // 发送失败
341
+                notification.setErrorMsg(e.getMessage());
342
+                log.error("Send notification error: channel={} record={}", channel, record.getId(), e);
343
+            }
344
+            notificationMapper.updateById(notification);
345
+        }
346
+    }
347
+
348
+    /**
349
+     * 实际发送通知(调用通知接口)
350
+     * 这里提供桩实现,实际生产环境应对接通知中心
351
+     */
352
+    private void doSendNotification(String channel, AlertNotification notification) {
353
+        log.info("Sending alert notification: channel={}, title={}, recipient={}",
354
+                channel, notification.getTitle(), notification.getRecipient());
355
+        // 实际调用: notifyService.send(channel, notification.getTitle(), notification.getContent());
356
+    }
357
+
358
+    private String levelLabel(String level) {
359
+        return switch (level) {
360
+            case "urgent" -> "紧急";
361
+            case "important" -> "重要";
362
+            case "general" -> "一般";
363
+            default -> level;
364
+        };
365
+    }
366
+}

+ 159
- 0
wm-production/src/test/java/com/water/production/service/AlertCenterServiceTest.java Просмотреть файл

@@ -0,0 +1,159 @@
1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.AlertRecord;
4
+import org.junit.jupiter.api.DisplayName;
5
+import org.junit.jupiter.api.Test;
6
+
7
+import static org.junit.jupiter.api.Assertions.*;
8
+
9
+/**
10
+ * AlertCenterService 报警管理中心单元测试
11
+ * 测试状态流转、标签映射等核心逻辑
12
+ */
13
+class AlertCenterServiceTest {
14
+
15
+    @Test
16
+    @DisplayName("测试报警状态标签映射")
17
+    void testStatusLabelMapping() {
18
+        // 通过反射或构造测试状态标签的正确性
19
+        AlertRecord record = new AlertRecord();
20
+
21
+        // 测试所有状态值
22
+        record.setStatus(0);
23
+        assertEquals(0, record.getStatus());
24
+
25
+        record.setStatus(1);
26
+        assertEquals(1, record.getStatus());
27
+
28
+        record.setStatus(2);
29
+        assertEquals(2, record.getStatus());
30
+
31
+        record.setStatus(3);
32
+        assertEquals(3, record.getStatus());
33
+
34
+        record.setStatus(4);
35
+        assertEquals(4, record.getStatus());
36
+
37
+        record.setStatus(5);
38
+        assertEquals(5, record.getStatus());
39
+    }
40
+
41
+    @Test
42
+    @DisplayName("测试报警等级标签")
43
+    void testAlertLevelValues() {
44
+        AlertRecord record = new AlertRecord();
45
+
46
+        record.setAlertLevel("general");
47
+        assertEquals("general", record.getAlertLevel());
48
+
49
+        record.setAlertLevel("important");
50
+        assertEquals("important", record.getAlertLevel());
51
+
52
+        record.setAlertLevel("urgent");
53
+        assertEquals("urgent", record.getAlertLevel());
54
+    }
55
+
56
+    @Test
57
+    @DisplayName("测试报警记录生命周期字段完整性")
58
+    void testAlertRecordLifecycleFields() {
59
+        AlertRecord record = new AlertRecord();
60
+        record.setId(1L);
61
+        record.setRuleId(100L);
62
+        record.setRuleName("管网压力过高报警");
63
+        record.setDeviceSn("DEV-001");
64
+        record.setArea("城东片区");
65
+        record.setMetricKey("pressure");
66
+        record.setAlertLevel("urgent");
67
+        record.setTitle("[紧急] 管网压力过高报警 - DEV-001");
68
+        record.setMessage("设备 DEV-001 指标 pressure 当前值 0.95");
69
+        record.setStatus(0);
70
+        record.setConfirmedBy(null);
71
+        record.setConfirmedTime(null);
72
+
73
+        // 验证初始状态
74
+        assertEquals(0, record.getStatus());
75
+        assertNull(record.getConfirmedBy());
76
+        assertNull(record.getAssigneeId());
77
+        assertNull(record.getHandlerId());
78
+        assertNull(record.getHandleResult());
79
+        assertNull(record.getArchiveReason());
80
+
81
+        // 模拟确认
82
+        record.setStatus(1);
83
+        record.setConfirmedBy(10L);
84
+        assertEquals(1, record.getStatus());
85
+        assertEquals(10L, record.getConfirmedBy());
86
+
87
+        // 模拟派单
88
+        record.setStatus(2);
89
+        record.setAssigneeId(20L);
90
+        record.setAssigneeName("张三");
91
+        assertEquals(2, record.getStatus());
92
+        assertEquals("张三", record.getAssigneeName());
93
+
94
+        // 模拟处理中
95
+        record.setStatus(3);
96
+        record.setHandlerId(20L);
97
+        record.setHandlerName("张三");
98
+        assertEquals(3, record.getStatus());
99
+
100
+        // 模拟完成处理
101
+        record.setStatus(4);
102
+        record.setHandleResult("已更换压力传感器,恢复正常");
103
+        assertEquals(4, record.getStatus());
104
+        assertNotNull(record.getHandleResult());
105
+
106
+        // 模拟归档
107
+        record.setStatus(5);
108
+        record.setArchiveReason("处理完毕,确认无误");
109
+        assertEquals(5, record.getStatus());
110
+        assertNotNull(record.getArchiveReason());
111
+    }
112
+
113
+    @Test
114
+    @DisplayName("测试条件表达式JSON结构验证")
115
+    void testConditionExpressionStructure() {
116
+        // 验证AND条件JSON
117
+        String andExpr = "{\"op\":\"AND\",\"conditions\":[" +
118
+                "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}," +
119
+                "{\"metric\":\"pressure\",\"operator\":\"<\",\"threshold\":1.5}" +
120
+                "]}";
121
+        assertTrue(andExpr.contains("\"op\":\"AND\""));
122
+        assertTrue(andExpr.contains("\"conditions\""));
123
+
124
+        // 验证OR条件JSON
125
+        String orExpr = "{\"op\":\"OR\",\"conditions\":[" +
126
+                "{\"metric\":\"flow\",\"operator\":\">\",\"threshold\":100}," +
127
+                "{\"metric\":\"flow\",\"operator\":\"<\",\"threshold\":5}" +
128
+                "]}";
129
+        assertTrue(orExpr.contains("\"op\":\"OR\""));
130
+    }
131
+
132
+    @Test
133
+    @DisplayName("测试报警标题格式化")
134
+    void testAlertTitleFormat() {
135
+        String level = "urgent";
136
+        String ruleName = "管网压力过高报警";
137
+        String deviceSn = "DEV-001";
138
+
139
+        String levelLabel = switch (level) {
140
+            case "urgent" -> "紧急";
141
+            case "important" -> "重要";
142
+            case "general" -> "一般";
143
+            default -> level;
144
+        };
145
+
146
+        String title = String.format("[%s] %s - %s", levelLabel, ruleName, deviceSn);
147
+        assertEquals("[紧急] 管网压力过高报警 - DEV-001", title);
148
+
149
+        // 测试一般等级
150
+        levelLabel = switch ("general") {
151
+            case "urgent" -> "紧急";
152
+            case "important" -> "重要";
153
+            case "general" -> "一般";
154
+            default -> "general";
155
+        };
156
+        title = String.format("[%s] %s - %s", levelLabel, "余氯偏低报警", "DEV-002");
157
+        assertEquals("[一般] 余氯偏低报警 - DEV-002", title);
158
+    }
159
+}

+ 143
- 0
wm-production/src/test/java/com/water/production/service/AlertRuleServiceTest.java Просмотреть файл

@@ -0,0 +1,143 @@
1
+package com.water.production.service;
2
+
3
+import com.fasterxml.jackson.databind.ObjectMapper;
4
+import org.junit.jupiter.api.BeforeEach;
5
+import org.junit.jupiter.api.DisplayName;
6
+import org.junit.jupiter.api.Test;
7
+
8
+import static org.junit.jupiter.api.Assertions.*;
9
+
10
+/**
11
+ * AlertRuleService 规则评估引擎单元测试
12
+ * 测试条件表达式解析(AND/OR组合)、阈值触发、多级别报警
13
+ */
14
+class AlertRuleServiceTest {
15
+
16
+    private AlertRuleService ruleService;
17
+    private ObjectMapper objectMapper;
18
+
19
+    @BeforeEach
20
+    void setUp() {
21
+        objectMapper = new ObjectMapper();
22
+        ruleService = new AlertRuleService(null, null, null, objectMapper);
23
+    }
24
+
25
+    @Test
26
+    @DisplayName("测试简单大于条件 - 触发报警")
27
+    void testSimpleGreaterThan_triggered() {
28
+        String expr = "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}";
29
+        assertTrue(ruleService.evaluateCondition(expr, "pressure", 0.9));
30
+    }
31
+
32
+    @Test
33
+    @DisplayName("测试简单大于条件 - 未触发")
34
+    void testSimpleGreaterThan_notTriggered() {
35
+        String expr = "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}";
36
+        assertFalse(ruleService.evaluateCondition(expr, "pressure", 0.7));
37
+    }
38
+
39
+    @Test
40
+    @DisplayName("测试简单小于条件 - 触发报警")
41
+    void testSimpleLessThan_triggered() {
42
+        String expr = "{\"metric\":\"residual_chlorine\",\"operator\":\"<\",\"threshold\":0.1}";
43
+        assertTrue(ruleService.evaluateCondition(expr, "residual_chlorine", 0.05));
44
+    }
45
+
46
+    @Test
47
+    @DisplayName("测试AND组合条件 - 全部满足时触发")
48
+    void testAndCondition_allMet() {
49
+        String expr = "{\"op\":\"AND\",\"conditions\":[" +
50
+                "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}," +
51
+                "{\"metric\":\"pressure\",\"operator\":\"<\",\"threshold\":1.5}" +
52
+                "]}";
53
+        assertTrue(ruleService.evaluateCondition(expr, "pressure", 1.0));
54
+    }
55
+
56
+    @Test
57
+    @DisplayName("测试AND组合条件 - 部分不满足时不触发")
58
+    void testAndCondition_partialMet() {
59
+        String expr = "{\"op\":\"AND\",\"conditions\":[" +
60
+                "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}," +
61
+                "{\"metric\":\"pressure\",\"operator\":\"<\",\"threshold\":0.9}" +
62
+                "]}";
63
+        assertFalse(ruleService.evaluateCondition(expr, "pressure", 1.0));
64
+    }
65
+
66
+    @Test
67
+    @DisplayName("测试OR组合条件 - 任一满足即触发")
68
+    void testOrCondition_oneMet() {
69
+        String expr = "{\"op\":\"OR\",\"conditions\":[" +
70
+                "{\"metric\":\"flow\",\"operator\":\">\",\"threshold\":100}," +
71
+                "{\"metric\":\"flow\",\"operator\":\"<\",\"threshold\":5}" +
72
+                "]}";
73
+        // flow=3 < 5, 满足第二个条件
74
+        assertTrue(ruleService.evaluateCondition(expr, "flow", 3.0));
75
+        // flow=150 > 100, 满足第一个条件
76
+        assertTrue(ruleService.evaluateCondition(expr, "flow", 150.0));
77
+    }
78
+
79
+    @Test
80
+    @DisplayName("测试OR组合条件 - 全不满足时不触发")
81
+    void testOrCondition_noneMet() {
82
+        String expr = "{\"op\":\"OR\",\"conditions\":[" +
83
+                "{\"metric\":\"flow\",\"operator\":\">\",\"threshold\":100}," +
84
+                "{\"metric\":\"flow\",\"operator\":\"<\",\"threshold\":5}" +
85
+                "]}";
86
+        // flow=50, 不满足任何条件
87
+        assertFalse(ruleService.evaluateCondition(expr, "flow", 50.0));
88
+    }
89
+
90
+    @Test
91
+    @DisplayName("测试不同指标键不匹配 - 不触发")
92
+    void testMetricKeyMismatch() {
93
+        String expr = "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}";
94
+        // 条件检查pressure, 但实际metric是temperature
95
+        assertFalse(ruleService.evaluateCondition(expr, "temperature", 50.0));
96
+    }
97
+
98
+    @Test
99
+    @DisplayName("测试等于条件")
100
+    void testEqualsCondition() {
101
+        String expr = "{\"metric\":\"ph\",\"operator\":\"==\",\"threshold\":7.0}";
102
+        assertTrue(ruleService.evaluateCondition(expr, "ph", 7.0));
103
+        assertFalse(ruleService.evaluateCondition(expr, "ph", 7.1));
104
+    }
105
+
106
+    @Test
107
+    @DisplayName("测试大于等于和小于等于条件")
108
+    void testGreaterEqualAndLessEqual() {
109
+        String exprGte = "{\"metric\":\"pressure\",\"operator\":\">=\",\"threshold\":0.8}";
110
+        assertTrue(ruleService.evaluateCondition(exprGte, "pressure", 0.8));
111
+        assertTrue(ruleService.evaluateCondition(exprGte, "pressure", 0.9));
112
+        assertFalse(ruleService.evaluateCondition(exprGte, "pressure", 0.79));
113
+
114
+        String exprLte = "{\"metric\":\"pressure\",\"operator\":\"<=\",\"threshold\":0.2}";
115
+        assertTrue(ruleService.evaluateCondition(exprLte, "pressure", 0.2));
116
+        assertTrue(ruleService.evaluateCondition(exprLte, "pressure", 0.1));
117
+        assertFalse(ruleService.evaluateCondition(exprLte, "pressure", 0.21));
118
+    }
119
+
120
+    @Test
121
+    @DisplayName("测试空表达式和无效表达式")
122
+    void testNullAndInvalidExpression() {
123
+        assertFalse(ruleService.evaluateCondition(null, "pressure", 0.9));
124
+        assertFalse(ruleService.evaluateCondition("", "pressure", 0.9));
125
+        assertFalse(ruleService.evaluateCondition("not-json", "pressure", 0.9));
126
+    }
127
+
128
+    @Test
129
+    @DisplayName("测试多级别报警 - general/important/urgent 条件分别触发")
130
+    void testMultiLevelAlerts() {
131
+        // 一般: 余氯 < 0.1
132
+        String generalExpr = "{\"metric\":\"residual_chlorine\",\"operator\":\"<\",\"threshold\":0.1}";
133
+        assertTrue(ruleService.evaluateCondition(generalExpr, "residual_chlorine", 0.05));
134
+
135
+        // 重要: 压力 < 0.2
136
+        String importantExpr = "{\"metric\":\"pressure\",\"operator\":\"<\",\"threshold\":0.2}";
137
+        assertTrue(ruleService.evaluateCondition(importantExpr, "pressure", 0.15));
138
+
139
+        // 紧急: 浊度 > 1.0
140
+        String urgentExpr = "{\"metric\":\"turbidity\",\"operator\":\">\",\"threshold\":1.0}";
141
+        assertTrue(ruleService.evaluateCondition(urgentExpr, "turbidity", 2.5));
142
+    }
143
+}