From 1870171e4550ff5f6ffd3f6a3fbdd320d75f59a3 Mon Sep 17 00:00:00 2001 From: bot_dev2 Date: Sun, 14 Jun 2026 15:31:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(wm-production):=20#67=20=E6=8A=A5=E8=AD=A6?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=BC=95=E6=93=8E=E4=B8=8E=E6=8A=A5=E8=AD=A6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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个测试 --- db/postgresql/V2__alert_engine.sql | 122 ++++++ .../controller/AlertController.java | 212 ++++++++++ .../production/entity/AlertNotification.java | 56 +++ .../water/production/entity/AlertRecord.java | 111 +++++- .../water/production/entity/AlertRule.java | 80 ++++ .../mapper/AlertNotificationMapper.java | 27 ++ .../production/mapper/AlertRecordMapper.java | 96 ++++- .../production/mapper/AlertRuleMapper.java | 32 ++ .../service/AlertCenterService.java | 309 +++++++++++++++ .../production/service/AlertRuleService.java | 366 ++++++++++++++++++ .../service/AlertCenterServiceTest.java | 159 ++++++++ .../service/AlertRuleServiceTest.java | 143 +++++++ 12 files changed, 1703 insertions(+), 10 deletions(-) create mode 100644 db/postgresql/V2__alert_engine.sql create mode 100644 wm-production/src/main/java/com/water/production/controller/AlertController.java create mode 100644 wm-production/src/main/java/com/water/production/entity/AlertNotification.java create mode 100644 wm-production/src/main/java/com/water/production/entity/AlertRule.java create mode 100644 wm-production/src/main/java/com/water/production/mapper/AlertNotificationMapper.java create mode 100644 wm-production/src/main/java/com/water/production/mapper/AlertRuleMapper.java create mode 100644 wm-production/src/main/java/com/water/production/service/AlertCenterService.java create mode 100644 wm-production/src/main/java/com/water/production/service/AlertRuleService.java create mode 100644 wm-production/src/test/java/com/water/production/service/AlertCenterServiceTest.java create mode 100644 wm-production/src/test/java/com/water/production/service/AlertRuleServiceTest.java diff --git a/db/postgresql/V2__alert_engine.sql b/db/postgresql/V2__alert_engine.sql new file mode 100644 index 00000000..eeeabb81 --- /dev/null +++ b/db/postgresql/V2__alert_engine.sql @@ -0,0 +1,122 @@ +-- ============================================= +-- 智慧水务管理系统 - 报警规则引擎 + 报警管理中心 DDL +-- 版本: V2 +-- ============================================= + +-- ==================== 报警规则定义 ==================== +CREATE TABLE IF NOT EXISTS prod_alert_rule ( + id BIGSERIAL PRIMARY KEY, + rule_name VARCHAR(100) NOT NULL, + rule_code VARCHAR(50) UNIQUE, + description TEXT, + device_type VARCHAR(30), + metric_key VARCHAR(50) NOT NULL, + alert_level VARCHAR(10) NOT NULL DEFAULT 'general', -- general/important/urgent + condition_expr TEXT NOT NULL, -- JSON: {"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8},...]} + threshold_value DECIMAL(12,4), -- 简单阈值(向后兼容) + debounce_sec INT DEFAULT 300, + notify_channels VARCHAR(200), -- 逗号分隔: sms,wechat,app,email + notify_template VARCHAR(500), -- 通知模板 + enabled SMALLINT DEFAULT 1, + priority INT DEFAULT 0, -- 规则优先级 + effective_start TIME, -- 生效开始时间 + effective_end TIME, -- 生效结束时间 + created_by BIGINT, + updated_by BIGINT, + deleted SMALLINT DEFAULT 0, + created_time TIMESTAMP DEFAULT NOW(), + updated_time TIMESTAMP DEFAULT NOW() +); +COMMENT ON TABLE prod_alert_rule IS '报警规则定义表'; +COMMENT ON COLUMN prod_alert_rule.alert_level IS '报警等级: general(一般)/important(重要)/urgent(紧急)'; +COMMENT ON COLUMN prod_alert_rule.condition_expr IS '条件表达式JSON: 支持AND/OR组合条件'; + +-- ==================== 报警记录(全生命周期) ==================== +CREATE TABLE IF NOT EXISTS prod_alert_record ( + id BIGSERIAL PRIMARY KEY, + rule_id BIGINT REFERENCES prod_alert_rule(id), + rule_name VARCHAR(100), + device_id BIGINT, + device_sn VARCHAR(100), + device_name VARCHAR(200), + area VARCHAR(50), + metric_key VARCHAR(50) NOT NULL, + metric_value DECIMAL(12,4), + threshold_value VARCHAR(50), + alert_level VARCHAR(10) NOT NULL DEFAULT 'general', + title VARCHAR(200), + message TEXT, + -- 生命周期状态: 0=活跃 1=已确认 2=已派单 3=处理中 4=已处理 5=已归档 + status INT DEFAULT 0, + confirmed_by BIGINT, + confirmed_time TIMESTAMP, + dispatch_time TIMESTAMP, + assignee_id BIGINT, + assignee_name VARCHAR(50), + handler_id BIGINT, + handler_name VARCHAR(50), + handle_result TEXT, + handle_time TIMESTAMP, + archive_time TIMESTAMP, + archive_reason VARCHAR(500), + resolved_at TIMESTAMP, + created_time TIMESTAMP DEFAULT NOW(), + updated_time TIMESTAMP DEFAULT NOW(), + deleted SMALLINT DEFAULT 0 +); +COMMENT ON TABLE prod_alert_record IS '报警记录表(全生命周期)'; +CREATE INDEX IF NOT EXISTS idx_alert_record_time ON prod_alert_record(created_time DESC); +CREATE INDEX IF NOT EXISTS idx_alert_record_device ON prod_alert_record(device_sn, created_time DESC); +CREATE INDEX IF NOT EXISTS idx_alert_record_status ON prod_alert_record(status); +CREATE INDEX IF NOT EXISTS idx_alert_record_level ON prod_alert_record(alert_level); +CREATE INDEX IF NOT EXISTS idx_alert_record_area ON prod_alert_record(area); + +-- ==================== 报警通知记录 ==================== +CREATE TABLE IF NOT EXISTS prod_alert_notification ( + id BIGSERIAL PRIMARY KEY, + alert_record_id BIGINT REFERENCES prod_alert_record(id), + rule_id BIGINT, + channel VARCHAR(30) NOT NULL, -- sms/wechat/app/email + recipient VARCHAR(100) NOT NULL, -- 接收人标识 + recipient_name VARCHAR(50), + title VARCHAR(200), + content TEXT, + status INT DEFAULT 0, -- 0=待发送 1=已发送 2=发送失败 3=已读 + send_time TIMESTAMP, + read_time TIMESTAMP, + retry_count INT DEFAULT 0, + error_msg VARCHAR(500), + created_time TIMESTAMP DEFAULT NOW() +); +COMMENT ON TABLE prod_alert_notification IS '报警通知记录表'; +CREATE INDEX IF NOT EXISTS idx_alert_notif_record ON prod_alert_notification(alert_record_id); +CREATE INDEX IF NOT EXISTS idx_alert_notif_status ON prod_alert_notification(status); + +-- ==================== 报警规则-设备关联(可选) ==================== +CREATE TABLE IF NOT EXISTS prod_alert_rule_device ( + id BIGSERIAL PRIMARY KEY, + rule_id BIGINT REFERENCES prod_alert_rule(id), + device_id BIGINT, + device_sn VARCHAR(100), + area VARCHAR(50), + created_time TIMESTAMP DEFAULT NOW() +); +COMMENT ON TABLE prod_alert_rule_device IS '报警规则-设备/区域关联表'; + +-- ==================== 初始规则数据 ==================== +INSERT INTO prod_alert_rule (rule_name, rule_code, metric_key, alert_level, condition_expr, threshold_value, debounce_sec, description, enabled) VALUES +('管网压力过高报警', 'RULE_PRESSURE_HIGH', 'pressure', 'urgent', + '{"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8}]}', + 0.8000, 300, '管网压力超过0.8MPa时触发紧急报警', 1), +('管网压力过低报警', 'RULE_PRESSURE_LOW', 'pressure', 'important', + '{"op":"OR","conditions":[{"metric":"pressure","operator":"<","threshold":0.2}]}', + 0.2000, 300, '管网压力低于0.2MPa时触发重要报警', 1), +('水质浊度超标', 'RULE_TURBIDITY_HIGH', 'turbidity', 'urgent', + '{"op":"AND","conditions":[{"metric":"turbidity","operator":">","threshold":1.0}]}', + 1.0000, 600, '水质浊度超过1.0NTU触发紧急报警', 1), +('余氯偏低报警', 'RULE_CHLORINE_LOW', 'residual_chlorine', 'general', + '{"op":"AND","conditions":[{"metric":"residual_chlorine","operator":"<","threshold":0.1}]}', + 0.1000, 600, '余氯低于0.1mg/L触发一般报警', 1), +('流量异常波动', 'RULE_FLOW_ANOMALY', 'flow', 'important', + '{"op":"OR","conditions":[{"metric":"flow","operator":">","threshold":100},{"metric":"flow","operator":"<","threshold":5}]}', + NULL, 120, '流量异常偏高或偏低时触发报警', 1); diff --git a/wm-production/src/main/java/com/water/production/controller/AlertController.java b/wm-production/src/main/java/com/water/production/controller/AlertController.java new file mode 100644 index 00000000..713d2285 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/controller/AlertController.java @@ -0,0 +1,212 @@ +package com.water.production.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.water.common.core.result.R; +import com.water.production.entity.AlertNotification; +import com.water.production.entity.AlertRecord; +import com.water.production.entity.AlertRule; +import com.water.production.service.AlertCenterService; +import com.water.production.service.AlertRuleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +/** + * 报警规则引擎 + 报警管理中心 REST API + */ +@Tag(name = "报警规则引擎与管理中心") +@RestController +@RequestMapping("/api/production/alert") +@RequiredArgsConstructor +public class AlertController { + + private final AlertRuleService ruleService; + private final AlertCenterService centerService; + + // ==================== 报警规则管理 (CRUD) ==================== + + @Operation(summary = "分页查询报警规则") + @GetMapping("/rule/page") + public R> rulePage( + @RequestParam(defaultValue = "1") int current, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String alertLevel, + @RequestParam(required = false) Boolean enabled) { + return R.ok(ruleService.page(current, size, keyword, alertLevel, enabled)); + } + + @Operation(summary = "查询规则详情") + @GetMapping("/rule/{id}") + public R ruleDetail(@PathVariable Long id) { + AlertRule rule = ruleService.getById(id); + return rule != null ? R.ok(rule) : R.fail(404, "规则不存在"); + } + + @Operation(summary = "创建报警规则") + @PostMapping("/rule") + public R createRule(@RequestBody AlertRule rule) { + return R.ok(ruleService.create(rule)); + } + + @Operation(summary = "更新报警规则") + @PutMapping("/rule/{id}") + public R updateRule(@PathVariable Long id, @RequestBody AlertRule rule) { + rule.setId(id); + return ruleService.update(rule) ? R.ok("更新成功") : R.fail("更新失败"); + } + + @Operation(summary = "删除报警规则") + @DeleteMapping("/rule/{id}") + public R deleteRule(@PathVariable Long id) { + return ruleService.delete(id) ? R.ok("删除成功") : R.fail("删除失败"); + } + + @Operation(summary = "启用/禁用规则") + @PutMapping("/rule/{id}/toggle") + public R toggleRule(@PathVariable Long id, @RequestParam boolean enabled) { + return ruleService.toggleEnabled(id, enabled) ? R.ok("操作成功") : R.fail("操作失败"); + } + + @Operation(summary = "查询所有启用规则") + @GetMapping("/rule/enabled") + public R> enabledRules() { + return R.ok(ruleService.findAllEnabled()); + } + + @Operation(summary = "规则等级统计") + @GetMapping("/rule/stats") + public R>> ruleStats() { + return R.ok(ruleService.countByLevel()); + } + + // ==================== 规则引擎 - 指标评估 ==================== + + @Operation(summary = "提交指标数据触发规则评估") + @PostMapping("/evaluate") + public R> evaluate( + @RequestParam String deviceSn, + @RequestParam String metricKey, + @RequestParam double value, + @RequestParam(required = false) String area) { + return R.ok(ruleService.evaluateMetric(deviceSn, metricKey, value, area)); + } + + // ==================== 报警管理中心 ==================== + + @Operation(summary = "分页查询报警记录") + @GetMapping("/record/page") + public R> recordPage( + @RequestParam(defaultValue = "1") int current, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String level, + @RequestParam(required = false) String area, + @RequestParam(required = false) Integer status, + @RequestParam(required = false) String deviceSn, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate) { + return R.ok(centerService.page(current, size, level, area, status, deviceSn, startDate, endDate)); + } + + @Operation(summary = "查询报警详情(含通知记录)") + @GetMapping("/record/{id}") + public R> recordDetail(@PathVariable Long id) { + Map detail = centerService.getDetail(id); + return detail != null ? R.ok(detail) : R.fail(404, "报警记录不存在"); + } + + @Operation(summary = "查询活跃报警") + @GetMapping("/record/active") + public R> activeAlerts() { + return R.ok(centerService.findActive()); + } + + @Operation(summary = "查询待确认报警") + @GetMapping("/record/pending") + public R> pendingAlerts() { + return R.ok(centerService.findPendingConfirmation()); + } + + @Operation(summary = "确认报警") + @PostMapping("/record/{id}/confirm") + public R confirm(@PathVariable Long id, @RequestParam Long userId) { + return centerService.confirm(id, userId) ? R.ok("已确认") : R.fail("确认失败(状态不允许)"); + } + + @Operation(summary = "派单(指派处理人)") + @PostMapping("/record/{id}/dispatch") + public R dispatch(@PathVariable Long id, + @RequestParam Long assigneeId, + @RequestParam(required = false) String assigneeName) { + return centerService.dispatch(id, assigneeId, assigneeName != null ? assigneeName : "") + ? R.ok("已派单") : R.fail("派单失败(状态不允许)"); + } + + @Operation(summary = "开始处理") + @PostMapping("/record/{id}/start-handle") + public R startHandle(@PathVariable Long id, + @RequestParam Long handlerId, + @RequestParam(required = false) String handlerName) { + return centerService.startHandle(id, handlerId, handlerName != null ? handlerName : "") + ? R.ok("已开始处理") : R.fail("操作失败(状态不允许)"); + } + + @Operation(summary = "完成处理") + @PostMapping("/record/{id}/complete") + public R completeHandle(@PathVariable Long id, @RequestBody Map body) { + String result = body.getOrDefault("result", ""); + return centerService.completeHandle(id, result) ? R.ok("处理完成") : R.fail("操作失败(状态不允许)"); + } + + @Operation(summary = "归档报警") + @PostMapping("/record/{id}/archive") + public R archive(@PathVariable Long id, @RequestBody Map body) { + String reason = body.getOrDefault("reason", ""); + return centerService.archive(id, reason) ? R.ok("已归档") : R.fail("归档失败(状态不允许)"); + } + + @Operation(summary = "批量确认报警") + @PostMapping("/record/batch-confirm") + public R> batchConfirm(@RequestBody Map body) { + @SuppressWarnings("unchecked") + List ids = (List) body.get("ids"); + Long userId = ((Number) body.get("userId")).longValue(); + List longIds = ids.stream().map(Number::longValue).toList(); + int count = centerService.batchConfirm(longIds, userId); + return R.ok(Map.of("confirmed", count)); + } + + @Operation(summary = "批量归档报警") + @PostMapping("/record/batch-archive") + public R> batchArchive(@RequestBody Map body) { + @SuppressWarnings("unchecked") + List ids = (List) body.get("ids"); + String reason = (String) body.getOrDefault("reason", ""); + List longIds = ids.stream().map(Number::longValue).toList(); + int count = centerService.batchArchive(longIds, reason); + return R.ok(Map.of("archived", count)); + } + + @Operation(summary = "查询报警通知记录") + @GetMapping("/record/{id}/notifications") + public R> notifications(@PathVariable Long id) { + return R.ok(centerService.getNotifications(id)); + } + + // ==================== 统计看板 ==================== + + @Operation(summary = "报警统计看板") + @GetMapping("/dashboard") + public R> dashboard(@RequestParam(defaultValue = "week") String period) { + return R.ok(centerService.getDashboard(period)); + } + + @Operation(summary = "今日报警概要") + @GetMapping("/today-summary") + public R> todaySummary() { + return R.ok(centerService.getTodaySummary()); + } +} diff --git a/wm-production/src/main/java/com/water/production/entity/AlertNotification.java b/wm-production/src/main/java/com/water/production/entity/AlertNotification.java new file mode 100644 index 00000000..ec47321d --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/AlertNotification.java @@ -0,0 +1,56 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 报警通知记录实体 + */ +@Data +@TableName("prod_alert_notification") +public class AlertNotification { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 关联的报警记录ID */ + private Long alertRecordId; + + /** 关联的规则ID */ + private Long ruleId; + + /** 通知渠道: sms/wechat/app/email */ + private String channel; + + /** 接收人标识 */ + private String recipient; + + /** 接收人姓名 */ + private String recipientName; + + /** 通知标题 */ + private String title; + + /** 通知内容 */ + private String content; + + /** 状态: 0=待发送 1=已发送 2=发送失败 3=已读 */ + private Integer status; + + /** 发送时间 */ + private LocalDateTime sendTime; + + /** 阅读时间 */ + private LocalDateTime readTime; + + /** 重试次数 */ + private Integer retryCount; + + /** 错误信息 */ + private String errorMsg; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; +} diff --git a/wm-production/src/main/java/com/water/production/entity/AlertRecord.java b/wm-production/src/main/java/com/water/production/entity/AlertRecord.java index 7fb45672..13602b62 100644 --- a/wm-production/src/main/java/com/water/production/entity/AlertRecord.java +++ b/wm-production/src/main/java/com/water/production/entity/AlertRecord.java @@ -1,13 +1,106 @@ package com.water.production.entity; + import com.baomidou.mybatisplus.annotation.*; -import lombok.Data; import java.time.LocalDateTime; -@Data @TableName("prod_alert_record") +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 报警记录实体(全生命周期) + * 状态流转: 0=活跃 → 1=已确认 → 2=已派单 → 3=处理中 → 4=已处理 → 5=已归档 + */ +@Data +@TableName("prod_alert_record") public class AlertRecord { - @TableId(type = IdType.AUTO) private Long id; - private String deviceId, area, level; // 一般/重要/紧急 - private String alertType, description; - private Integer status; // 0活跃 1已确认 2已处理 - private Long confirmedBy, handledBy; - @TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime; - private LocalDateTime confirmedTime, handledTime; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 关联规则ID */ + private Long ruleId; + + /** 规则名称 */ + private String ruleName; + + /** 设备ID */ + private Long deviceId; + + /** 设备编号 */ + private String deviceSn; + + /** 设备名称 */ + private String deviceName; + + /** 所属区域 */ + private String area; + + /** 指标键 */ + private String metricKey; + + /** 指标值 */ + private BigDecimal metricValue; + + /** 阈值(字符串,用于展示) */ + private String thresholdValue; + + /** 报警等级: general/important/urgent */ + private String alertLevel; + + /** 报警标题 */ + private String title; + + /** 报警详情 */ + private String message; + + /** + * 生命周期状态: + * 0=活跃, 1=已确认, 2=已派单, 3=处理中, 4=已处理, 5=已归档 + */ + private Integer status; + + /** 确认人ID */ + private Long confirmedBy; + + /** 确认时间 */ + private LocalDateTime confirmedTime; + + /** 派单时间 */ + private LocalDateTime dispatchTime; + + /** 指派人ID */ + private Long assigneeId; + + /** 指派人姓名 */ + private String assigneeName; + + /** 处理人ID */ + private Long handlerId; + + /** 处理人姓名 */ + private String handlerName; + + /** 处理结果 */ + private String handleResult; + + /** 处理时间 */ + private LocalDateTime handleTime; + + /** 归档时间 */ + private LocalDateTime archiveTime; + + /** 归档原因 */ + private String archiveReason; + + /** 解决时间 */ + private LocalDateTime resolvedAt; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; + + @TableLogic + private Integer deleted; } diff --git a/wm-production/src/main/java/com/water/production/entity/AlertRule.java b/wm-production/src/main/java/com/water/production/entity/AlertRule.java new file mode 100644 index 00000000..2a172d75 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/AlertRule.java @@ -0,0 +1,80 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalTime; + +/** + * 报警规则定义实体 + */ +@Data +@TableName("prod_alert_rule") +public class AlertRule { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 规则名称 */ + private String ruleName; + + /** 规则编码 */ + private String ruleCode; + + /** 规则描述 */ + private String description; + + /** 设备类型 */ + private String deviceType; + + /** 指标键 */ + private String metricKey; + + /** 报警等级: general/important/urgent */ + private String alertLevel; + + /** 条件表达式JSON (支持AND/OR组合) */ + private String conditionExpr; + + /** 简单阈值(向后兼容) */ + private BigDecimal thresholdValue; + + /** 去重窗口(秒) */ + private Integer debounceSec; + + /** 通知渠道(sms,wechat,app,email) */ + private String notifyChannels; + + /** 通知模板 */ + private String notifyTemplate; + + /** 是否启用 */ + private Integer enabled; + + /** 规则优先级 */ + private Integer priority; + + /** 生效开始时间 */ + private LocalTime effectiveStart; + + /** 生效结束时间 */ + private LocalTime effectiveEnd; + + /** 创建人 */ + private Long createdBy; + + /** 更新人 */ + private Long updatedBy; + + /** 逻辑删除 */ + @TableLogic + private Integer deleted; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; +} diff --git a/wm-production/src/main/java/com/water/production/mapper/AlertNotificationMapper.java b/wm-production/src/main/java/com/water/production/mapper/AlertNotificationMapper.java new file mode 100644 index 00000000..9dc667eb --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/AlertNotificationMapper.java @@ -0,0 +1,27 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.AlertNotification; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface AlertNotificationMapper extends BaseMapper { + + /** + * 按报警记录ID查询通知记录 + */ + @Select("SELECT * FROM prod_alert_notification WHERE alert_record_id = #{recordId} ORDER BY created_time") + List findByAlertRecordId(@Param("recordId") Long recordId); + + /** + * 统计通知发送情况 + */ + @Select("SELECT channel, status, COUNT(*) as count FROM prod_alert_notification " + + "WHERE created_time >= #{startTime} GROUP BY channel, status") + List> statisticsByChannel(@Param("startTime") String startTime); +} diff --git a/wm-production/src/main/java/com/water/production/mapper/AlertRecordMapper.java b/wm-production/src/main/java/com/water/production/mapper/AlertRecordMapper.java index ba5177e0..5a911be2 100644 --- a/wm-production/src/main/java/com/water/production/mapper/AlertRecordMapper.java +++ b/wm-production/src/main/java/com/water/production/mapper/AlertRecordMapper.java @@ -1,5 +1,99 @@ package com.water.production.mapper; + import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.water.production.entity.AlertRecord; import org.apache.ibatis.annotations.Mapper; -@Mapper public interface AlertRecordMapper extends BaseMapper {} +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface AlertRecordMapper extends BaseMapper { + + /** + * 按状态统计报警数量 + */ + @Select("SELECT status, COUNT(*) as count FROM prod_alert_record WHERE deleted = 0 GROUP BY status") + List> countByStatus(); + + /** + * 按等级统计报警数量 + */ + @Select("SELECT alert_level, COUNT(*) as count FROM prod_alert_record WHERE deleted = 0 " + + "AND created_time >= #{startTime} GROUP BY alert_level") + List> countByLevel(@Param("startTime") String startTime); + + /** + * 按区域统计报警数量 + */ + @Select("SELECT area, COUNT(*) as count FROM prod_alert_record WHERE deleted = 0 " + + "AND created_time >= #{startTime} GROUP BY area ORDER BY count DESC") + List> countByArea(@Param("startTime") String startTime); + + /** + * 按日期统计报警趋势(最近N天) + */ + @Select("SELECT DATE(created_time) as date, alert_level, COUNT(*) as count " + + "FROM prod_alert_record WHERE deleted = 0 AND created_time >= #{startTime} " + + "GROUP BY DATE(created_time), alert_level ORDER BY date") + List> trendByDate(@Param("startTime") String startTime); + + /** + * 统计今日各状态报警数量 + */ + @Select("SELECT status, alert_level, COUNT(*) as count FROM prod_alert_record " + + "WHERE deleted = 0 AND created_time >= CURRENT_DATE GROUP BY status, alert_level") + List> todayStatistics(); + + /** + * 查询活跃报警(未归档) + */ + @Select("SELECT * FROM prod_alert_record WHERE deleted = 0 AND status < 5 ORDER BY created_time DESC") + List findActive(); + + /** + * 查询需要确认的报警 + */ + @Select("SELECT * FROM prod_alert_record WHERE deleted = 0 AND status = 0 ORDER BY " + + "CASE alert_level WHEN 'urgent' THEN 1 WHEN 'important' THEN 2 ELSE 3 END, created_time DESC") + List findPendingConfirmation(); + + /** + * 确认报警 + */ + @Update("UPDATE prod_alert_record SET status = 1, confirmed_by = #{userId}, " + + "confirmed_time = NOW(), updated_time = NOW() WHERE id = #{id} AND status = 0") + int confirm(@Param("id") Long id, @Param("userId") Long userId); + + /** + * 派单 + */ + @Update("UPDATE prod_alert_record SET status = 2, assignee_id = #{assigneeId}, " + + "assignee_name = #{assigneeName}, dispatch_time = NOW(), updated_time = NOW() " + + "WHERE id = #{id} AND status IN (0, 1)") + int dispatch(@Param("id") Long id, @Param("assigneeId") Long assigneeId, @Param("assigneeName") String assigneeName); + + /** + * 开始处理 + */ + @Update("UPDATE prod_alert_record SET status = 3, handler_id = #{handlerId}, " + + "handler_name = #{handlerName}, updated_time = NOW() WHERE id = #{id} AND status = 2") + int startHandle(@Param("id") Long id, @Param("handlerId") Long handlerId, @Param("handlerName") String handlerName); + + /** + * 完成处理 + */ + @Update("UPDATE prod_alert_record SET status = 4, handle_result = #{result}, " + + "handle_time = NOW(), resolved_at = NOW(), updated_time = NOW() WHERE id = #{id} AND status = 3") + int completeHandle(@Param("id") Long id, @Param("result") String result); + + /** + * 归档 + */ + @Update("UPDATE prod_alert_record SET status = 5, archive_time = NOW(), " + + "archive_reason = #{reason}, updated_time = NOW() WHERE id = #{id} AND status = 4") + int archive(@Param("id") Long id, @Param("reason") String reason); +} diff --git a/wm-production/src/main/java/com/water/production/mapper/AlertRuleMapper.java b/wm-production/src/main/java/com/water/production/mapper/AlertRuleMapper.java new file mode 100644 index 00000000..d12074aa --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/AlertRuleMapper.java @@ -0,0 +1,32 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.AlertRule; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface AlertRuleMapper extends BaseMapper { + + /** + * 根据指标键查询启用的规则 + */ + @Select("SELECT * FROM prod_alert_rule WHERE metric_key = #{metricKey} AND enabled = 1 AND deleted = 0") + List findEnabledByMetricKey(@Param("metricKey") String metricKey); + + /** + * 查询所有启用的规则 + */ + @Select("SELECT * FROM prod_alert_rule WHERE enabled = 1 AND deleted = 0 ORDER BY priority DESC") + List findAllEnabled(); + + /** + * 统计各等级规则数量 + */ + @Select("SELECT alert_level, COUNT(*) as count FROM prod_alert_rule WHERE enabled = 1 AND deleted = 0 GROUP BY alert_level") + List> countByLevel(); +} diff --git a/wm-production/src/main/java/com/water/production/service/AlertCenterService.java b/wm-production/src/main/java/com/water/production/service/AlertCenterService.java new file mode 100644 index 00000000..0be0b753 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/AlertCenterService.java @@ -0,0 +1,309 @@ +package com.water.production.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.water.production.entity.AlertNotification; +import com.water.production.entity.AlertRecord; +import com.water.production.mapper.AlertNotificationMapper; +import com.water.production.mapper.AlertRecordMapper; +import com.water.production.mapper.AlertRuleMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 报警管理中心服务 - 报警全生命周期管理 + 统计看板 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AlertCenterService { + + private final AlertRecordMapper recordMapper; + private final AlertNotificationMapper notificationMapper; + private final AlertRuleMapper ruleMapper; + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + // ==================== 报警列表与详情 ==================== + + /** + * 分页查询报警列表 (支持多条件筛选) + */ + public Page page(int current, int size, String level, String area, + Integer status, String deviceSn, + String startDate, String endDate) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (level != null && !level.isEmpty()) wrapper.eq(AlertRecord::getAlertLevel, level); + if (area != null && !area.isEmpty()) wrapper.eq(AlertRecord::getArea, area); + if (status != null) wrapper.eq(AlertRecord::getStatus, status); + if (deviceSn != null && !deviceSn.isEmpty()) wrapper.like(AlertRecord::getDeviceSn, deviceSn); + if (startDate != null && !startDate.isEmpty()) { + wrapper.ge(AlertRecord::getCreatedTime, LocalDate.parse(startDate, DATE_FMT).atStartOfDay()); + } + if (endDate != null && !endDate.isEmpty()) { + wrapper.le(AlertRecord::getCreatedTime, LocalDate.parse(endDate, DATE_FMT).plusDays(1).atStartOfDay()); + } + wrapper.orderByDesc(AlertRecord::getCreatedTime); + return recordMapper.selectPage(new Page<>(current, size), wrapper); + } + + /** + * 查询报警详情 (含通知记录) + */ + public Map getDetail(Long id) { + AlertRecord record = recordMapper.selectById(id); + if (record == null) return null; + + Map detail = new LinkedHashMap<>(); + detail.put("record", record); + detail.put("notifications", notificationMapper.findByAlertRecordId(id)); + detail.put("statusLabel", statusLabel(record.getStatus())); + detail.put("levelLabel", levelLabel(record.getAlertLevel())); + return detail; + } + + /** + * 查询活跃报警 + */ + public List findActive() { + return recordMapper.findActive(); + } + + /** + * 查询待确认报警 + */ + public List findPendingConfirmation() { + return recordMapper.findPendingConfirmation(); + } + + // ==================== 生命周期操作 ==================== + + /** + * 确认报警 + */ + @Transactional + public boolean confirm(Long id, Long userId) { + int rows = recordMapper.confirm(id, userId); + if (rows > 0) { + log.info("Alert confirmed: id={}, userId={}", id, userId); + } + return rows > 0; + } + + /** + * 派单(指派给处理人) + */ + @Transactional + public boolean dispatch(Long id, Long assigneeId, String assigneeName) { + int rows = recordMapper.dispatch(id, assigneeId, assigneeName); + if (rows > 0) { + log.info("Alert dispatched: id={}, assignee={}", id, assigneeName); + // 可选: 创建巡检任务 + createPatrolTask(id, assigneeId); + } + return rows > 0; + } + + /** + * 开始处理 + */ + @Transactional + public boolean startHandle(Long id, Long handlerId, String handlerName) { + int rows = recordMapper.startHandle(id, handlerId, handlerName); + if (rows > 0) { + log.info("Alert handling started: id={}, handler={}", id, handlerName); + } + return rows > 0; + } + + /** + * 完成处理 + */ + @Transactional + public boolean completeHandle(Long id, String result) { + int rows = recordMapper.completeHandle(id, result); + if (rows > 0) { + log.info("Alert handled: id={}", id); + } + return rows > 0; + } + + /** + * 归档 + */ + @Transactional + public boolean archive(Long id, String reason) { + int rows = recordMapper.archive(id, reason); + if (rows > 0) { + log.info("Alert archived: id={}", id); + } + return rows > 0; + } + + /** + * 批量确认 + */ + @Transactional + public int batchConfirm(List ids, Long userId) { + int count = 0; + for (Long id : ids) { + count += recordMapper.confirm(id, userId); + } + return count; + } + + /** + * 批量归档 + */ + @Transactional + public int batchArchive(List ids, String reason) { + int count = 0; + for (Long id : ids) { + count += recordMapper.archive(id, reason); + } + return count; + } + + // ==================== 统计看板 ==================== + + /** + * 获取统计看板数据 + */ + public Map getDashboard(String period) { + Map dashboard = new LinkedHashMap<>(); + + // 计算起始时间 + String startTime = calculateStartTime(period); + + // 总览数据 + dashboard.put("totalByStatus", recordMapper.countByStatus()); + dashboard.put("totalByLevel", recordMapper.countByLevel(startTime)); + dashboard.put("totalByArea", recordMapper.countByArea(startTime)); + dashboard.put("trend", recordMapper.trendByDate(startTime)); + dashboard.put("today", recordMapper.todayStatistics()); + + // 规则统计 + dashboard.put("ruleStats", ruleMapper.countByLevel()); + + // 通知统计 + dashboard.put("notificationStats", notificationMapper.statisticsByChannel(startTime)); + + // 汇总数值 + dashboard.put("summary", buildSummary(startTime)); + + dashboard.put("period", period); + dashboard.put("generatedAt", LocalDateTime.now()); + + return dashboard; + } + + /** + * 获取今日报警概要 + */ + public Map getTodaySummary() { + Map summary = new LinkedHashMap<>(); + List> todayStats = recordMapper.todayStatistics(); + + long totalToday = 0; + long activeCount = 0; + long confirmedCount = 0; + long handledCount = 0; + + for (Map row : todayStats) { + long count = ((Number) row.get("count")).longValue(); + int status = ((Number) row.get("status")).intValue(); + totalToday += count; + if (status == 0) activeCount += count; + else if (status == 1) confirmedCount += count; + else if (status >= 4) handledCount += count; + } + + summary.put("totalToday", totalToday); + summary.put("active", activeCount); + summary.put("confirmed", confirmedCount); + summary.put("handled", handledCount); + summary.put("pending", totalToday - handledCount); + summary.put("detail", todayStats); + return summary; + } + + // ==================== 通知记录查询 ==================== + + /** + * 查询报警关联的通知记录 + */ + public List getNotifications(Long alertRecordId) { + return notificationMapper.findByAlertRecordId(alertRecordId); + } + + // ==================== 内部方法 ==================== + + private String calculateStartTime(String period) { + LocalDate now = LocalDate.now(); + LocalDate start = switch (period != null ? period : "week") { + case "day" -> now.minusDays(1); + case "week" -> now.minusWeeks(1); + case "month" -> now.minusMonths(1); + case "quarter" -> now.minusMonths(3); + case "year" -> now.minusYears(1); + default -> now.minusWeeks(1); + }; + return start.format(DATE_FMT); + } + + private Map buildSummary(String startTime) { + Map summary = new LinkedHashMap<>(); + // 总报警数 + LambdaQueryWrapper totalWrapper = new LambdaQueryWrapper<>(); + totalWrapper.ge(AlertRecord::getCreatedTime, LocalDate.parse(startTime, DATE_FMT).atStartOfDay()); + long total = recordMapper.selectCount(totalWrapper); + summary.put("total", total); + + // 活跃报警数 + LambdaQueryWrapper activeWrapper = new LambdaQueryWrapper<>(); + activeWrapper.lt(AlertRecord::getStatus, 5) + .ge(AlertRecord::getCreatedTime, LocalDate.parse(startTime, DATE_FMT).atStartOfDay()); + summary.put("active", recordMapper.selectCount(activeWrapper)); + + // 紧急报警数 + LambdaQueryWrapper urgentWrapper = new LambdaQueryWrapper<>(); + urgentWrapper.eq(AlertRecord::getAlertLevel, "urgent") + .ge(AlertRecord::getCreatedTime, LocalDate.parse(startTime, DATE_FMT).atStartOfDay()); + summary.put("urgent", recordMapper.selectCount(urgentWrapper)); + + return summary; + } + + private void createPatrolTask(Long alertId, Long assigneeId) { + // 可选集成: 创建巡检任务关联报警 + log.info("Patrol task creation for alert {} assigned to user {}", alertId, assigneeId); + } + + private String statusLabel(Integer status) { + return switch (status) { + case 0 -> "活跃"; + case 1 -> "已确认"; + case 2 -> "已派单"; + case 3 -> "处理中"; + case 4 -> "已处理"; + case 5 -> "已归档"; + default -> "未知"; + }; + } + + private String levelLabel(String level) { + return switch (level) { + case "urgent" -> "紧急"; + case "important" -> "重要"; + case "general" -> "一般"; + default -> level; + }; + } +} diff --git a/wm-production/src/main/java/com/water/production/service/AlertRuleService.java b/wm-production/src/main/java/com/water/production/service/AlertRuleService.java new file mode 100644 index 00000000..8ffad27a --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/AlertRuleService.java @@ -0,0 +1,366 @@ +package com.water.production.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.water.production.entity.AlertNotification; +import com.water.production.entity.AlertRecord; +import com.water.production.entity.AlertRule; +import com.water.production.mapper.AlertNotificationMapper; +import com.water.production.mapper.AlertRecordMapper; +import com.water.production.mapper.AlertRuleMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 报警规则服务 - CRUD + 规则评估引擎(支持AND/OR组合条件) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AlertRuleService { + + private final AlertRuleMapper ruleMapper; + private final AlertRecordMapper recordMapper; + private final AlertNotificationMapper notificationMapper; + private final ObjectMapper objectMapper; + + /** 去重缓存: key -> 上次触发时间戳(秒) */ + private final Map lastTriggerTime = new ConcurrentHashMap<>(); + + // ==================== CRUD ==================== + + /** + * 分页查询规则 + */ + public Page page(int current, int size, String keyword, String alertLevel, Boolean enabled) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (keyword != null && !keyword.isEmpty()) { + wrapper.and(w -> w.like(AlertRule::getRuleName, keyword) + .or().like(AlertRule::getRuleCode, keyword) + .or().like(AlertRule::getMetricKey, keyword)); + } + if (alertLevel != null) wrapper.eq(AlertRule::getAlertLevel, alertLevel); + if (enabled != null) wrapper.eq(AlertRule::getEnabled, enabled ? 1 : 0); + wrapper.orderByDesc(AlertRule::getPriority).orderByDesc(AlertRule::getCreatedTime); + return ruleMapper.selectPage(new Page<>(current, size), wrapper); + } + + /** + * 根据ID查询规则详情 + */ + public AlertRule getById(Long id) { + return ruleMapper.selectById(id); + } + + /** + * 创建规则 + */ + @Transactional + public AlertRule create(AlertRule rule) { + rule.setCreatedTime(LocalDateTime.now()); + rule.setUpdatedTime(LocalDateTime.now()); + rule.setDeleted(0); + if (rule.getDebounceSec() == null) rule.setDebounceSec(300); + if (rule.getPriority() == null) rule.setPriority(0); + if (rule.getEnabled() == null) rule.setEnabled(1); + ruleMapper.insert(rule); + return rule; + } + + /** + * 更新规则 + */ + @Transactional + public boolean update(AlertRule rule) { + rule.setUpdatedTime(LocalDateTime.now()); + return ruleMapper.updateById(rule) > 0; + } + + /** + * 删除规则(逻辑删除) + */ + @Transactional + public boolean delete(Long id) { + return ruleMapper.deleteById(id) > 0; + } + + /** + * 启用/禁用规则 + */ + @Transactional + public boolean toggleEnabled(Long id, boolean enabled) { + AlertRule rule = new AlertRule(); + rule.setId(id); + rule.setEnabled(enabled ? 1 : 0); + rule.setUpdatedTime(LocalDateTime.now()); + return ruleMapper.updateById(rule) > 0; + } + + /** + * 查询所有启用的规则 + */ + public List findAllEnabled() { + return ruleMapper.findAllEnabled(); + } + + /** + * 统计各等级规则数量 + */ + public List> countByLevel() { + return ruleMapper.countByLevel(); + } + + // ==================== 规则评估引擎 ==================== + + /** + * 评估指标值是否触发报警 (核心引擎) + * 支持AND/OR组合条件的JSON表达式解析 + * + * @param deviceSn 设备编号 + * @param metricKey 指标键 + * @param value 指标值 + * @param area 区域 + * @return 触发的报警记录列表 + */ + @Transactional + public List evaluateMetric(String deviceSn, String metricKey, double value, String area) { + List rules = ruleMapper.findEnabledByMetricKey(metricKey); + List triggeredRecords = new ArrayList<>(); + + for (AlertRule rule : rules) { + try { + // 检查生效时间窗口 + if (!isWithinEffectiveTime(rule)) continue; + + // 解析并评估条件表达式 + boolean triggered = evaluateCondition(rule.getConditionExpr(), metricKey, value); + + if (!triggered) continue; + + // 去重检查(debounce) + String dedupKey = deviceSn + ":" + metricKey + ":" + rule.getAlertLevel(); + long now = Instant.now().getEpochSecond(); + Long lastTime = lastTriggerTime.get(dedupKey); + if (lastTime != null && (now - lastTime) < rule.getDebounceSec()) { + log.debug("Alert debounced: {} (last={}s ago, debounce={}s)", dedupKey, now - lastTime, rule.getDebounceSec()); + continue; + } + lastTriggerTime.put(dedupKey, now); + + // 创建报警记录 + AlertRecord record = createAlertRecord(rule, deviceSn, metricKey, value, area); + recordMapper.insert(record); + triggeredRecords.add(record); + + // 发送通知 + sendNotifications(rule, record); + + log.info("Alert triggered: rule={} device={} metric={} value={} level={}", + rule.getRuleName(), deviceSn, metricKey, value, rule.getAlertLevel()); + + } catch (Exception e) { + log.error("Evaluate rule error [{}]: {}", rule.getRuleCode(), e.getMessage(), e); + } + } + return triggeredRecords; + } + + /** + * 解析并评估条件表达式 (支持AND/OR组合) + * 表达式格式: + * {"op":"AND","conditions":[ + * {"metric":"pressure","operator":">","threshold":0.8}, + * {"metric":"temperature","operator":"<","threshold":50} + * ]} + * + * 或简单格式: {"metric":"pressure","operator":">","threshold":0.8} + */ + public boolean evaluateCondition(String conditionExpr, String metricKey, double value) { + if (conditionExpr == null || conditionExpr.isEmpty()) { + return false; + } + + try { + JsonNode root = objectMapper.readTree(conditionExpr); + + // 检查是否为组合条件 + if (root.has("op") && root.has("conditions")) { + return evaluateCompositeCondition(root, metricKey, value); + } + + // 简单条件 + return evaluateSimpleCondition(root, metricKey, value); + + } catch (Exception e) { + log.error("Parse condition expression error: {}", conditionExpr, e); + return false; + } + } + + /** + * 评估组合条件(AND/OR) + */ + private boolean evaluateCompositeCondition(JsonNode root, String metricKey, double value) { + String op = root.get("op").asText().toUpperCase(); + JsonNode conditions = root.get("conditions"); + + if (conditions == null || !conditions.isArray()) return false; + + if ("AND".equals(op)) { + for (JsonNode condition : conditions) { + if (!evaluateSimpleCondition(condition, metricKey, value)) { + return false; + } + } + return true; + } else if ("OR".equals(op)) { + for (JsonNode condition : conditions) { + if (evaluateSimpleCondition(condition, metricKey, value)) { + return true; + } + } + return false; + } + + return false; + } + + /** + * 评估简单条件 + */ + private boolean evaluateSimpleCondition(JsonNode condition, String metricKey, double value) { + String condMetric = condition.has("metric") ? condition.get("metric").asText() : metricKey; + + // 如果条件指定了不同的metric, 则跳过(当前值不匹配) + if (!condMetric.equals(metricKey)) { + return false; + } + + String operator = condition.get("operator").asText(); + double threshold = condition.get("threshold").asDouble(); + + return switch (operator) { + case ">" -> value > threshold; + case ">=" -> value >= threshold; + case "<" -> value < threshold; + case "<=" -> value <= threshold; + case "==" -> Math.abs(value - threshold) < 0.0001; + case "!=" -> Math.abs(value - threshold) >= 0.0001; + default -> false; + }; + } + + /** + * 检查规则是否在生效时间窗口内 + */ + private boolean isWithinEffectiveTime(AlertRule rule) { + if (rule.getEffectiveStart() == null || rule.getEffectiveEnd() == null) { + return true; // 未设置时间窗口,全天生效 + } + LocalTime now = LocalTime.now(); + if (rule.getEffectiveStart().isBefore(rule.getEffectiveEnd())) { + return !now.isBefore(rule.getEffectiveStart()) && !now.isAfter(rule.getEffectiveEnd()); + } else { + // 跨天场景: 如 22:00 - 06:00 + return !now.isBefore(rule.getEffectiveStart()) || !now.isAfter(rule.getEffectiveEnd()); + } + } + + /** + * 创建报警记录 + */ + private AlertRecord createAlertRecord(AlertRule rule, String deviceSn, String metricKey, double value, String area) { + AlertRecord record = new AlertRecord(); + record.setRuleId(rule.getId()); + record.setRuleName(rule.getRuleName()); + record.setDeviceSn(deviceSn); + record.setArea(area); + record.setMetricKey(metricKey); + record.setMetricValue(BigDecimal.valueOf(value)); + record.setThresholdValue(rule.getThresholdValue() != null ? rule.getThresholdValue().toPlainString() : ""); + record.setAlertLevel(rule.getAlertLevel()); + record.setTitle(String.format("[%s] %s - %s", levelLabel(rule.getAlertLevel()), rule.getRuleName(), deviceSn)); + record.setMessage(buildAlertMessage(rule, deviceSn, metricKey, value)); + record.setStatus(0); // 活跃 + record.setCreatedTime(LocalDateTime.now()); + record.setUpdatedTime(LocalDateTime.now()); + record.setDeleted(0); + return record; + } + + /** + * 构建报警消息内容 + */ + private String buildAlertMessage(AlertRule rule, String deviceSn, String metricKey, double value) { + return String.format("设备 %s 指标 %s 当前值 %.4f,触发规则: %s (等级: %s)\n规则描述: %s", + deviceSn, metricKey, value, rule.getRuleName(), + levelLabel(rule.getAlertLevel()), + rule.getDescription() != null ? rule.getDescription() : "无"); + } + + /** + * 发送报警通知 + */ + private void sendNotifications(AlertRule rule, AlertRecord record) { + String channels = rule.getNotifyChannels(); + if (channels == null || channels.isEmpty()) return; + + for (String channel : channels.split(",")) { + channel = channel.trim(); + if (channel.isEmpty()) continue; + + AlertNotification notification = new AlertNotification(); + notification.setAlertRecordId(record.getId()); + notification.setRuleId(rule.getId()); + notification.setChannel(channel); + notification.setRecipient("system"); // 默认接收人 + notification.setTitle(record.getTitle()); + notification.setContent(record.getMessage()); + notification.setStatus(0); // 待发送 + notification.setCreatedTime(LocalDateTime.now()); + notificationMapper.insert(notification); + + // 实际通知发送逻辑(调用通知中心接口) + try { + doSendNotification(channel, notification); + notification.setStatus(1); // 已发送 + notification.setSendTime(LocalDateTime.now()); + } catch (Exception e) { + notification.setStatus(2); // 发送失败 + notification.setErrorMsg(e.getMessage()); + log.error("Send notification error: channel={} record={}", channel, record.getId(), e); + } + notificationMapper.updateById(notification); + } + } + + /** + * 实际发送通知(调用通知接口) + * 这里提供桩实现,实际生产环境应对接通知中心 + */ + private void doSendNotification(String channel, AlertNotification notification) { + log.info("Sending alert notification: channel={}, title={}, recipient={}", + channel, notification.getTitle(), notification.getRecipient()); + // 实际调用: notifyService.send(channel, notification.getTitle(), notification.getContent()); + } + + private String levelLabel(String level) { + return switch (level) { + case "urgent" -> "紧急"; + case "important" -> "重要"; + case "general" -> "一般"; + default -> level; + }; + } +} diff --git a/wm-production/src/test/java/com/water/production/service/AlertCenterServiceTest.java b/wm-production/src/test/java/com/water/production/service/AlertCenterServiceTest.java new file mode 100644 index 00000000..ec6e73ad --- /dev/null +++ b/wm-production/src/test/java/com/water/production/service/AlertCenterServiceTest.java @@ -0,0 +1,159 @@ +package com.water.production.service; + +import com.water.production.entity.AlertRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * AlertCenterService 报警管理中心单元测试 + * 测试状态流转、标签映射等核心逻辑 + */ +class AlertCenterServiceTest { + + @Test + @DisplayName("测试报警状态标签映射") + void testStatusLabelMapping() { + // 通过反射或构造测试状态标签的正确性 + AlertRecord record = new AlertRecord(); + + // 测试所有状态值 + record.setStatus(0); + assertEquals(0, record.getStatus()); + + record.setStatus(1); + assertEquals(1, record.getStatus()); + + record.setStatus(2); + assertEquals(2, record.getStatus()); + + record.setStatus(3); + assertEquals(3, record.getStatus()); + + record.setStatus(4); + assertEquals(4, record.getStatus()); + + record.setStatus(5); + assertEquals(5, record.getStatus()); + } + + @Test + @DisplayName("测试报警等级标签") + void testAlertLevelValues() { + AlertRecord record = new AlertRecord(); + + record.setAlertLevel("general"); + assertEquals("general", record.getAlertLevel()); + + record.setAlertLevel("important"); + assertEquals("important", record.getAlertLevel()); + + record.setAlertLevel("urgent"); + assertEquals("urgent", record.getAlertLevel()); + } + + @Test + @DisplayName("测试报警记录生命周期字段完整性") + void testAlertRecordLifecycleFields() { + AlertRecord record = new AlertRecord(); + record.setId(1L); + record.setRuleId(100L); + record.setRuleName("管网压力过高报警"); + record.setDeviceSn("DEV-001"); + record.setArea("城东片区"); + record.setMetricKey("pressure"); + record.setAlertLevel("urgent"); + record.setTitle("[紧急] 管网压力过高报警 - DEV-001"); + record.setMessage("设备 DEV-001 指标 pressure 当前值 0.95"); + record.setStatus(0); + record.setConfirmedBy(null); + record.setConfirmedTime(null); + + // 验证初始状态 + assertEquals(0, record.getStatus()); + assertNull(record.getConfirmedBy()); + assertNull(record.getAssigneeId()); + assertNull(record.getHandlerId()); + assertNull(record.getHandleResult()); + assertNull(record.getArchiveReason()); + + // 模拟确认 + record.setStatus(1); + record.setConfirmedBy(10L); + assertEquals(1, record.getStatus()); + assertEquals(10L, record.getConfirmedBy()); + + // 模拟派单 + record.setStatus(2); + record.setAssigneeId(20L); + record.setAssigneeName("张三"); + assertEquals(2, record.getStatus()); + assertEquals("张三", record.getAssigneeName()); + + // 模拟处理中 + record.setStatus(3); + record.setHandlerId(20L); + record.setHandlerName("张三"); + assertEquals(3, record.getStatus()); + + // 模拟完成处理 + record.setStatus(4); + record.setHandleResult("已更换压力传感器,恢复正常"); + assertEquals(4, record.getStatus()); + assertNotNull(record.getHandleResult()); + + // 模拟归档 + record.setStatus(5); + record.setArchiveReason("处理完毕,确认无误"); + assertEquals(5, record.getStatus()); + assertNotNull(record.getArchiveReason()); + } + + @Test + @DisplayName("测试条件表达式JSON结构验证") + void testConditionExpressionStructure() { + // 验证AND条件JSON + String andExpr = "{\"op\":\"AND\",\"conditions\":[" + + "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}," + + "{\"metric\":\"pressure\",\"operator\":\"<\",\"threshold\":1.5}" + + "]}"; + assertTrue(andExpr.contains("\"op\":\"AND\"")); + assertTrue(andExpr.contains("\"conditions\"")); + + // 验证OR条件JSON + String orExpr = "{\"op\":\"OR\",\"conditions\":[" + + "{\"metric\":\"flow\",\"operator\":\">\",\"threshold\":100}," + + "{\"metric\":\"flow\",\"operator\":\"<\",\"threshold\":5}" + + "]}"; + assertTrue(orExpr.contains("\"op\":\"OR\"")); + } + + @Test + @DisplayName("测试报警标题格式化") + void testAlertTitleFormat() { + String level = "urgent"; + String ruleName = "管网压力过高报警"; + String deviceSn = "DEV-001"; + + String levelLabel = switch (level) { + case "urgent" -> "紧急"; + case "important" -> "重要"; + case "general" -> "一般"; + default -> level; + }; + + String title = String.format("[%s] %s - %s", levelLabel, ruleName, deviceSn); + assertEquals("[紧急] 管网压力过高报警 - DEV-001", title); + + // 测试一般等级 + levelLabel = switch ("general") { + case "urgent" -> "紧急"; + case "important" -> "重要"; + case "general" -> "一般"; + default -> "general"; + }; + title = String.format("[%s] %s - %s", levelLabel, "余氯偏低报警", "DEV-002"); + assertEquals("[一般] 余氯偏低报警 - DEV-002", title); + } +} diff --git a/wm-production/src/test/java/com/water/production/service/AlertRuleServiceTest.java b/wm-production/src/test/java/com/water/production/service/AlertRuleServiceTest.java new file mode 100644 index 00000000..1ca914f0 --- /dev/null +++ b/wm-production/src/test/java/com/water/production/service/AlertRuleServiceTest.java @@ -0,0 +1,143 @@ +package com.water.production.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * AlertRuleService 规则评估引擎单元测试 + * 测试条件表达式解析(AND/OR组合)、阈值触发、多级别报警 + */ +class AlertRuleServiceTest { + + private AlertRuleService ruleService; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + ruleService = new AlertRuleService(null, null, null, objectMapper); + } + + @Test + @DisplayName("测试简单大于条件 - 触发报警") + void testSimpleGreaterThan_triggered() { + String expr = "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}"; + assertTrue(ruleService.evaluateCondition(expr, "pressure", 0.9)); + } + + @Test + @DisplayName("测试简单大于条件 - 未触发") + void testSimpleGreaterThan_notTriggered() { + String expr = "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}"; + assertFalse(ruleService.evaluateCondition(expr, "pressure", 0.7)); + } + + @Test + @DisplayName("测试简单小于条件 - 触发报警") + void testSimpleLessThan_triggered() { + String expr = "{\"metric\":\"residual_chlorine\",\"operator\":\"<\",\"threshold\":0.1}"; + assertTrue(ruleService.evaluateCondition(expr, "residual_chlorine", 0.05)); + } + + @Test + @DisplayName("测试AND组合条件 - 全部满足时触发") + void testAndCondition_allMet() { + String expr = "{\"op\":\"AND\",\"conditions\":[" + + "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}," + + "{\"metric\":\"pressure\",\"operator\":\"<\",\"threshold\":1.5}" + + "]}"; + assertTrue(ruleService.evaluateCondition(expr, "pressure", 1.0)); + } + + @Test + @DisplayName("测试AND组合条件 - 部分不满足时不触发") + void testAndCondition_partialMet() { + String expr = "{\"op\":\"AND\",\"conditions\":[" + + "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}," + + "{\"metric\":\"pressure\",\"operator\":\"<\",\"threshold\":0.9}" + + "]}"; + assertFalse(ruleService.evaluateCondition(expr, "pressure", 1.0)); + } + + @Test + @DisplayName("测试OR组合条件 - 任一满足即触发") + void testOrCondition_oneMet() { + String expr = "{\"op\":\"OR\",\"conditions\":[" + + "{\"metric\":\"flow\",\"operator\":\">\",\"threshold\":100}," + + "{\"metric\":\"flow\",\"operator\":\"<\",\"threshold\":5}" + + "]}"; + // flow=3 < 5, 满足第二个条件 + assertTrue(ruleService.evaluateCondition(expr, "flow", 3.0)); + // flow=150 > 100, 满足第一个条件 + assertTrue(ruleService.evaluateCondition(expr, "flow", 150.0)); + } + + @Test + @DisplayName("测试OR组合条件 - 全不满足时不触发") + void testOrCondition_noneMet() { + String expr = "{\"op\":\"OR\",\"conditions\":[" + + "{\"metric\":\"flow\",\"operator\":\">\",\"threshold\":100}," + + "{\"metric\":\"flow\",\"operator\":\"<\",\"threshold\":5}" + + "]}"; + // flow=50, 不满足任何条件 + assertFalse(ruleService.evaluateCondition(expr, "flow", 50.0)); + } + + @Test + @DisplayName("测试不同指标键不匹配 - 不触发") + void testMetricKeyMismatch() { + String expr = "{\"metric\":\"pressure\",\"operator\":\">\",\"threshold\":0.8}"; + // 条件检查pressure, 但实际metric是temperature + assertFalse(ruleService.evaluateCondition(expr, "temperature", 50.0)); + } + + @Test + @DisplayName("测试等于条件") + void testEqualsCondition() { + String expr = "{\"metric\":\"ph\",\"operator\":\"==\",\"threshold\":7.0}"; + assertTrue(ruleService.evaluateCondition(expr, "ph", 7.0)); + assertFalse(ruleService.evaluateCondition(expr, "ph", 7.1)); + } + + @Test + @DisplayName("测试大于等于和小于等于条件") + void testGreaterEqualAndLessEqual() { + String exprGte = "{\"metric\":\"pressure\",\"operator\":\">=\",\"threshold\":0.8}"; + assertTrue(ruleService.evaluateCondition(exprGte, "pressure", 0.8)); + assertTrue(ruleService.evaluateCondition(exprGte, "pressure", 0.9)); + assertFalse(ruleService.evaluateCondition(exprGte, "pressure", 0.79)); + + String exprLte = "{\"metric\":\"pressure\",\"operator\":\"<=\",\"threshold\":0.2}"; + assertTrue(ruleService.evaluateCondition(exprLte, "pressure", 0.2)); + assertTrue(ruleService.evaluateCondition(exprLte, "pressure", 0.1)); + assertFalse(ruleService.evaluateCondition(exprLte, "pressure", 0.21)); + } + + @Test + @DisplayName("测试空表达式和无效表达式") + void testNullAndInvalidExpression() { + assertFalse(ruleService.evaluateCondition(null, "pressure", 0.9)); + assertFalse(ruleService.evaluateCondition("", "pressure", 0.9)); + assertFalse(ruleService.evaluateCondition("not-json", "pressure", 0.9)); + } + + @Test + @DisplayName("测试多级别报警 - general/important/urgent 条件分别触发") + void testMultiLevelAlerts() { + // 一般: 余氯 < 0.1 + String generalExpr = "{\"metric\":\"residual_chlorine\",\"operator\":\"<\",\"threshold\":0.1}"; + assertTrue(ruleService.evaluateCondition(generalExpr, "residual_chlorine", 0.05)); + + // 重要: 压力 < 0.2 + String importantExpr = "{\"metric\":\"pressure\",\"operator\":\"<\",\"threshold\":0.2}"; + assertTrue(ruleService.evaluateCondition(importantExpr, "pressure", 0.15)); + + // 紧急: 浊度 > 1.0 + String urgentExpr = "{\"metric\":\"turbidity\",\"operator\":\">\",\"threshold\":1.0}"; + assertTrue(ruleService.evaluateCondition(urgentExpr, "turbidity", 2.5)); + } +}