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个测试
This commit is contained in:
2026-06-14 15:31:14 +08:00
parent 6c6db59ba9
commit 1870171e45
12 changed files with 1703 additions and 10 deletions
+122
View File
@@ -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);
@@ -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<Page<AlertRule>> 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<AlertRule> ruleDetail(@PathVariable Long id) {
AlertRule rule = ruleService.getById(id);
return rule != null ? R.ok(rule) : R.fail(404, "规则不存在");
}
@Operation(summary = "创建报警规则")
@PostMapping("/rule")
public R<AlertRule> createRule(@RequestBody AlertRule rule) {
return R.ok(ruleService.create(rule));
}
@Operation(summary = "更新报警规则")
@PutMapping("/rule/{id}")
public R<String> 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<String> deleteRule(@PathVariable Long id) {
return ruleService.delete(id) ? R.ok("删除成功") : R.fail("删除失败");
}
@Operation(summary = "启用/禁用规则")
@PutMapping("/rule/{id}/toggle")
public R<String> toggleRule(@PathVariable Long id, @RequestParam boolean enabled) {
return ruleService.toggleEnabled(id, enabled) ? R.ok("操作成功") : R.fail("操作失败");
}
@Operation(summary = "查询所有启用规则")
@GetMapping("/rule/enabled")
public R<List<AlertRule>> enabledRules() {
return R.ok(ruleService.findAllEnabled());
}
@Operation(summary = "规则等级统计")
@GetMapping("/rule/stats")
public R<List<Map<String, Object>>> ruleStats() {
return R.ok(ruleService.countByLevel());
}
// ==================== 规则引擎 - 指标评估 ====================
@Operation(summary = "提交指标数据触发规则评估")
@PostMapping("/evaluate")
public R<List<AlertRecord>> 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<Page<AlertRecord>> 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<Map<String, Object>> recordDetail(@PathVariable Long id) {
Map<String, Object> detail = centerService.getDetail(id);
return detail != null ? R.ok(detail) : R.fail(404, "报警记录不存在");
}
@Operation(summary = "查询活跃报警")
@GetMapping("/record/active")
public R<List<AlertRecord>> activeAlerts() {
return R.ok(centerService.findActive());
}
@Operation(summary = "查询待确认报警")
@GetMapping("/record/pending")
public R<List<AlertRecord>> pendingAlerts() {
return R.ok(centerService.findPendingConfirmation());
}
@Operation(summary = "确认报警")
@PostMapping("/record/{id}/confirm")
public R<String> confirm(@PathVariable Long id, @RequestParam Long userId) {
return centerService.confirm(id, userId) ? R.ok("已确认") : R.fail("确认失败(状态不允许)");
}
@Operation(summary = "派单(指派处理人)")
@PostMapping("/record/{id}/dispatch")
public R<String> 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<String> 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<String> completeHandle(@PathVariable Long id, @RequestBody Map<String, String> body) {
String result = body.getOrDefault("result", "");
return centerService.completeHandle(id, result) ? R.ok("处理完成") : R.fail("操作失败(状态不允许)");
}
@Operation(summary = "归档报警")
@PostMapping("/record/{id}/archive")
public R<String> archive(@PathVariable Long id, @RequestBody Map<String, String> body) {
String reason = body.getOrDefault("reason", "");
return centerService.archive(id, reason) ? R.ok("已归档") : R.fail("归档失败(状态不允许)");
}
@Operation(summary = "批量确认报警")
@PostMapping("/record/batch-confirm")
public R<Map<String, Object>> batchConfirm(@RequestBody Map<String, Object> body) {
@SuppressWarnings("unchecked")
List<Number> ids = (List<Number>) body.get("ids");
Long userId = ((Number) body.get("userId")).longValue();
List<Long> 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<Map<String, Object>> batchArchive(@RequestBody Map<String, Object> body) {
@SuppressWarnings("unchecked")
List<Number> ids = (List<Number>) body.get("ids");
String reason = (String) body.getOrDefault("reason", "");
List<Long> 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<List<AlertNotification>> notifications(@PathVariable Long id) {
return R.ok(centerService.getNotifications(id));
}
// ==================== 统计看板 ====================
@Operation(summary = "报警统计看板")
@GetMapping("/dashboard")
public R<Map<String, Object>> dashboard(@RequestParam(defaultValue = "week") String period) {
return R.ok(centerService.getDashboard(period));
}
@Operation(summary = "今日报警概要")
@GetMapping("/today-summary")
public R<Map<String, Object>> todaySummary() {
return R.ok(centerService.getTodaySummary());
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<AlertNotification> {
/**
* 按报警记录ID查询通知记录
*/
@Select("SELECT * FROM prod_alert_notification WHERE alert_record_id = #{recordId} ORDER BY created_time")
List<AlertNotification> 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<Map<String, Object>> statisticsByChannel(@Param("startTime") String startTime);
}
@@ -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<AlertRecord> {}
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<AlertRecord> {
/**
* 按状态统计报警数量
*/
@Select("SELECT status, COUNT(*) as count FROM prod_alert_record WHERE deleted = 0 GROUP BY status")
List<Map<String, Object>> countByStatus();
/**
* 按等级统计报警数量
*/
@Select("SELECT alert_level, COUNT(*) as count FROM prod_alert_record WHERE deleted = 0 " +
"AND created_time >= #{startTime} GROUP BY alert_level")
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> todayStatistics();
/**
* 查询活跃报警(未归档)
*/
@Select("SELECT * FROM prod_alert_record WHERE deleted = 0 AND status < 5 ORDER BY created_time DESC")
List<AlertRecord> 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<AlertRecord> 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);
}
@@ -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<AlertRule> {
/**
* 根据指标键查询启用的规则
*/
@Select("SELECT * FROM prod_alert_rule WHERE metric_key = #{metricKey} AND enabled = 1 AND deleted = 0")
List<AlertRule> findEnabledByMetricKey(@Param("metricKey") String metricKey);
/**
* 查询所有启用的规则
*/
@Select("SELECT * FROM prod_alert_rule WHERE enabled = 1 AND deleted = 0 ORDER BY priority DESC")
List<AlertRule> findAllEnabled();
/**
* 统计各等级规则数量
*/
@Select("SELECT alert_level, COUNT(*) as count FROM prod_alert_rule WHERE enabled = 1 AND deleted = 0 GROUP BY alert_level")
List<Map<String, Object>> countByLevel();
}
@@ -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<AlertRecord> page(int current, int size, String level, String area,
Integer status, String deviceSn,
String startDate, String endDate) {
LambdaQueryWrapper<AlertRecord> 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<String, Object> getDetail(Long id) {
AlertRecord record = recordMapper.selectById(id);
if (record == null) return null;
Map<String, Object> 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<AlertRecord> findActive() {
return recordMapper.findActive();
}
/**
* 查询待确认报警
*/
public List<AlertRecord> 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<Long> ids, Long userId) {
int count = 0;
for (Long id : ids) {
count += recordMapper.confirm(id, userId);
}
return count;
}
/**
* 批量归档
*/
@Transactional
public int batchArchive(List<Long> ids, String reason) {
int count = 0;
for (Long id : ids) {
count += recordMapper.archive(id, reason);
}
return count;
}
// ==================== 统计看板 ====================
/**
* 获取统计看板数据
*/
public Map<String, Object> getDashboard(String period) {
Map<String, Object> 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<String, Object> getTodaySummary() {
Map<String, Object> summary = new LinkedHashMap<>();
List<Map<String, Object>> todayStats = recordMapper.todayStatistics();
long totalToday = 0;
long activeCount = 0;
long confirmedCount = 0;
long handledCount = 0;
for (Map<String, Object> 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<AlertNotification> 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<String, Object> buildSummary(String startTime) {
Map<String, Object> summary = new LinkedHashMap<>();
// 总报警数
LambdaQueryWrapper<AlertRecord> totalWrapper = new LambdaQueryWrapper<>();
totalWrapper.ge(AlertRecord::getCreatedTime, LocalDate.parse(startTime, DATE_FMT).atStartOfDay());
long total = recordMapper.selectCount(totalWrapper);
summary.put("total", total);
// 活跃报警数
LambdaQueryWrapper<AlertRecord> activeWrapper = new LambdaQueryWrapper<>();
activeWrapper.lt(AlertRecord::getStatus, 5)
.ge(AlertRecord::getCreatedTime, LocalDate.parse(startTime, DATE_FMT).atStartOfDay());
summary.put("active", recordMapper.selectCount(activeWrapper));
// 紧急报警数
LambdaQueryWrapper<AlertRecord> 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;
};
}
}
@@ -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<String, Long> lastTriggerTime = new ConcurrentHashMap<>();
// ==================== CRUD ====================
/**
* 分页查询规则
*/
public Page<AlertRule> page(int current, int size, String keyword, String alertLevel, Boolean enabled) {
LambdaQueryWrapper<AlertRule> 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<AlertRule> findAllEnabled() {
return ruleMapper.findAllEnabled();
}
/**
* 统计各等级规则数量
*/
public List<Map<String, Object>> countByLevel() {
return ruleMapper.countByLevel();
}
// ==================== 规则评估引擎 ====================
/**
* 评估指标值是否触发报警 (核心引擎)
* 支持AND/OR组合条件的JSON表达式解析
*
* @param deviceSn 设备编号
* @param metricKey 指标键
* @param value 指标值
* @param area 区域
* @return 触发的报警记录列表
*/
@Transactional
public List<AlertRecord> evaluateMetric(String deviceSn, String metricKey, double value, String area) {
List<AlertRule> rules = ruleMapper.findEnabledByMetricKey(metricKey);
List<AlertRecord> 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;
};
}
}
@@ -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);
}
}
@@ -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));
}
}