feat(iot): Wave 4 Round 1 — B12/B4/B5 告警状态机 + 规则引擎 SPI
主会话 Opus:
- B12 iot_alarm_record 正交状态机(ack_state + clear_state + archived)
* V2.0.4__iot_alarm_record.sql:主表 + iot_alarm_propagation 关联表
* 评审 C1 正交三字段(替代线性 4 枚举,表达"已清除未确认")
* 评审 C2 联合 UK (device_id, alarm_config_id, tenant_id, deleted)
* 评审 C3 传播关联表(替代 propagated_to JSON 查询)
* Service 5 方法:triggerAlarm / ackAlarm / unackAlarm / clearAlarm / archiveAlarm
* 幂等 upsert(trigger_count++)+ 归档后禁止修改
* 13 单元测试全绿
* TODO B14 分布式锁 / B15 传播 / B16 通知
Sonnet subagent B4:TriggerProvider SPI + 5 内置触发器
* spi/TriggerProvider + TriggerProviderManager(@Component + getType 索引,fail-fast 重复 type)
* trigger/DeviceState / DeviceProperty / DeviceEvent / DeviceService / Timer(Spring TaskScheduler)
* 评审 A3 落地:禁 ServiceLoader / @SPI
* 44 单元测试全绿
Sonnet subagent B5:ConditionEvaluator SPI + 3 条件 + 统一模板变量
* spi/ConditionEvaluator + condition/Manager
* condition/Expression(Aviator + LRU(256) 编译缓存)
* condition/TimeRange(跨午夜支持)
* condition/DeviceState(Redis 查询,空值按 offline)
* template/TemplateResolver:\${namespace.key},拒绝 \$[...] 旧语法(评审 B5)
* TODO B44 完整 8 层 Aviator 沙箱
* 50 单元测试全绿(TemplateResolver 16 + 条件 3x ≈ 34)
测试汇总:rule 136 全绿 / server 13 新增全绿
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet (subagent) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package com.viewsh.module.iot.dal.dataobject.alarm;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.viewsh.framework.tenant.core.db.TenantBaseDO;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 告警沿资产层级传播关联表(评审 C3)
|
||||
* <p>
|
||||
* 替代 v1 {@code propagated_to} JSON 字段的高频查询。
|
||||
* 由 B15 告警传播任务写入;本卡只建表 + DO 骨架。
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@TableName("iot_alarm_propagation")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotAlarmPropagationDO extends TenantBaseDO {
|
||||
|
||||
/** 告警记录 ID(联合主键) */
|
||||
private Long alarmRecordId;
|
||||
|
||||
/** 资产类型:SUBSYSTEM / FLOOR / BUILDING(联合主键) */
|
||||
private String assetType;
|
||||
|
||||
/** 资产 ID(联合主键) */
|
||||
private Long assetId;
|
||||
|
||||
/** 资产名称冗余 */
|
||||
private String assetName;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.viewsh.module.iot.dal.dataobject.alarm;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.viewsh.framework.tenant.core.db.TenantBaseDO;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* IoT 告警记录 DO(v2.0 正交状态机)
|
||||
* <p>
|
||||
* 评审修正:
|
||||
* - C1:{@code ack_state + clear_state + archived} 三字段正交,替代线性 4 枚举
|
||||
* - C2:联合 UK {@code (device_id, alarm_config_id, tenant_id, deleted)},不使用 MD5 哈希
|
||||
* <p>
|
||||
* 合法状态组合(Service 层强制):
|
||||
* <pre>
|
||||
* (ack_state, clear_state, archived)
|
||||
* (0, 0, 0) ACTIVE 活跃未确认 — 刚触发
|
||||
* (1, 0, 0) ACTIVE 活跃已确认 — 值班处理中
|
||||
* (0, 1, 0) CLEARED 已清除未确认 — 自动恢复,待审计
|
||||
* (1, 1, 0) CLEARED 已清除已确认 — 完整处理链
|
||||
* (*, *, 1) ARCHIVED — 归档不可修改
|
||||
* </pre>
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@TableName(value = "iot_alarm_record", autoResultMap = true)
|
||||
@KeySequence("iot_alarm_record_seq")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotAlarmRecordDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 告警配置 ID */
|
||||
private Long alarmConfigId;
|
||||
|
||||
/** 告警名称(冗余 config.name,避免反查) */
|
||||
private String alarmName;
|
||||
|
||||
/**
|
||||
* 严重度 1-5 对应 CRITICAL / MAJOR / MINOR / WARNING / INFO
|
||||
* <p>
|
||||
* {@link com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmSeverity}
|
||||
*/
|
||||
private Integer severity;
|
||||
|
||||
/**
|
||||
* 确认状态 0=未确认 1=已确认
|
||||
* <p>
|
||||
* {@link com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmAckState}
|
||||
*/
|
||||
private Integer ackState;
|
||||
|
||||
/**
|
||||
* 清除状态 0=活跃 1=已清除
|
||||
* <p>
|
||||
* {@link com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmClearState}
|
||||
*/
|
||||
private Integer clearState;
|
||||
|
||||
/** 归档 0=未归档 1=已归档(归档后不可修改) */
|
||||
private Integer archived;
|
||||
|
||||
/** 设备编号 */
|
||||
private Long deviceId;
|
||||
|
||||
/** 产品编号(冗余) */
|
||||
private Long productId;
|
||||
|
||||
/** 归属子系统 ID(评审 R2) */
|
||||
private Long subsystemId;
|
||||
|
||||
/** 触发此告警的 v2 规则链 ID */
|
||||
private Long ruleChainId;
|
||||
|
||||
/** 触发此告警的 v1 场景规则 ID(灰度期兼容) */
|
||||
private Long sceneRuleId;
|
||||
|
||||
/** 首次触发时间 */
|
||||
private LocalDateTime startTs;
|
||||
|
||||
/** 最近触发时间(累积触发时更新) */
|
||||
private LocalDateTime endTs;
|
||||
|
||||
/** 清除时间 */
|
||||
private LocalDateTime clearTs;
|
||||
|
||||
/** 确认时间 */
|
||||
private LocalDateTime ackTs;
|
||||
|
||||
/** 归档时间 */
|
||||
private LocalDateTime archiveTs;
|
||||
|
||||
/** 告警详情(可累积) */
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
private JsonNode details;
|
||||
|
||||
/** 持续触发次数 */
|
||||
private Integer triggerCount;
|
||||
|
||||
/** 处理备注 */
|
||||
private String processRemark;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.viewsh.module.iot.dal.dataobject.alarm.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 告警确认状态(评审 C1 正交三字段之一)
|
||||
* <p>
|
||||
* DB 存 TINYINT:0=未确认 / 1=已确认
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AlarmAckState {
|
||||
|
||||
/** 未确认(新触发告警默认态) */
|
||||
UNACK(0, "未确认"),
|
||||
/** 已确认(人工或规则确认过) */
|
||||
ACK(1, "已确认");
|
||||
|
||||
private final Integer code;
|
||||
private final String desc;
|
||||
|
||||
public static AlarmAckState of(Integer code) {
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
return Arrays.stream(values())
|
||||
.filter(s -> s.code.equals(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("unknown AlarmAckState code: " + code));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.viewsh.module.iot.dal.dataobject.alarm.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 告警清除状态(评审 C1 正交三字段之一)
|
||||
* <p>
|
||||
* DB 存 TINYINT:0=活跃 / 1=已清除
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AlarmClearState {
|
||||
|
||||
/** 活跃(告警持续中) */
|
||||
ACTIVE(0, "活跃"),
|
||||
/** 已清除(规则自动或手动清除) */
|
||||
CLEARED(1, "已清除");
|
||||
|
||||
private final Integer code;
|
||||
private final String desc;
|
||||
|
||||
public static AlarmClearState of(Integer code) {
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
return Arrays.stream(values())
|
||||
.filter(s -> s.code.equals(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("unknown AlarmClearState code: " + code));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.viewsh.module.iot.dal.dataobject.alarm.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 告警严重度(1-5)
|
||||
* <p>
|
||||
* DB 存 TINYINT;与 ITU-T X.733 / ThingsBoard Severity 一致,数值越小越严重。
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AlarmSeverity {
|
||||
|
||||
/** 严重:P0 立即响应 */
|
||||
CRITICAL(1, "严重"),
|
||||
/** 重要:P1 */
|
||||
MAJOR(2, "重要"),
|
||||
/** 次要:P2 */
|
||||
MINOR(3, "次要"),
|
||||
/** 警告:P3 */
|
||||
WARNING(4, "警告"),
|
||||
/** 提示:P4 */
|
||||
INFO(5, "提示");
|
||||
|
||||
private final Integer code;
|
||||
private final String desc;
|
||||
|
||||
public static AlarmSeverity of(Integer code) {
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
return Arrays.stream(values())
|
||||
.filter(s -> s.code.equals(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("unknown AlarmSeverity code: " + code));
|
||||
}
|
||||
|
||||
public static boolean isValid(Integer code) {
|
||||
return code != null && code >= 1 && code <= 5;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.viewsh.module.iot.dal.mysql.alarm;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.viewsh.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* IoT 告警记录 Mapper
|
||||
* <p>
|
||||
* 状态迁移 CAS 语义由 Service 层组合 {@link #selectById}/{@link #updateById} 实现,
|
||||
* 防止 Mockito 对 default method 的半 mock 行为。
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@Mapper
|
||||
public interface IotAlarmRecordMapper extends BaseMapperX<IotAlarmRecordDO> {
|
||||
|
||||
/**
|
||||
* 查活跃(未归档)告警记录:按 UK (device_id, alarm_config_id, tenant_id) 取最新。
|
||||
* <p>
|
||||
* 用于 triggerAlarm 的幂等 upsert;仅返回 archived=0 的记录,
|
||||
* 已归档则视为新告警触发(UK 含 deleted 列,archived 本身不在 UK)。
|
||||
*/
|
||||
default IotAlarmRecordDO selectActiveByDeviceAndConfig(Long deviceId, Long alarmConfigId, Long tenantId) {
|
||||
return selectOne(new LambdaQueryWrapper<IotAlarmRecordDO>()
|
||||
.eq(IotAlarmRecordDO::getDeviceId, deviceId)
|
||||
.eq(IotAlarmRecordDO::getAlarmConfigId, alarmConfigId)
|
||||
.eq(IotAlarmRecordDO::getTenantId, tenantId)
|
||||
.eq(IotAlarmRecordDO::getArchived, 0)
|
||||
.orderByDesc(IotAlarmRecordDO::getId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.viewsh.module.iot.service.alarm;
|
||||
|
||||
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
|
||||
import com.viewsh.module.iot.service.alarm.dto.AlarmStateTransitionRequest;
|
||||
import com.viewsh.module.iot.service.alarm.dto.AlarmTriggerRequest;
|
||||
|
||||
/**
|
||||
* IoT 告警记录 Service(v2.0 正交状态机)
|
||||
* <p>
|
||||
* 评审修正:
|
||||
* - C1 正交三字段 (ack_state, clear_state, archived)
|
||||
* - C2 联合 UK 幂等 upsert
|
||||
* - C4 竞态保护(SET NX PX + Lua 解锁)由 <b>B14</b> 叠加到本 Service 的方法上
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
public interface IotAlarmRecordService {
|
||||
|
||||
/**
|
||||
* 触发告警(规则引擎 AlarmTriggerAction 调用)
|
||||
* <p>
|
||||
* 幂等:同一 (device_id, alarm_config_id, tenant_id) 活跃记录已存在 → trigger_count++ + 更新 end_ts;
|
||||
* 否则插入新记录 (0,0,0) + trigger_count=1。
|
||||
*
|
||||
* @return 告警记录 ID(新建或既有)
|
||||
*/
|
||||
Long triggerAlarm(AlarmTriggerRequest request);
|
||||
|
||||
/**
|
||||
* 确认告警(UNACK → ACK)
|
||||
* <p>
|
||||
* 对 ack_state=0 的记录置为 1;归档后拒绝。幂等:ack_state=1 调用返回同一 id,不抛错。
|
||||
*/
|
||||
void ackAlarm(AlarmStateTransitionRequest request);
|
||||
|
||||
/**
|
||||
* 撤销确认(ACK → UNACK)
|
||||
* <p>
|
||||
* 归档后拒绝。幂等:ack_state=0 调用返回同一 id,不抛错。
|
||||
*/
|
||||
void unackAlarm(AlarmStateTransitionRequest request);
|
||||
|
||||
/**
|
||||
* 清除告警(ACTIVE → CLEARED)
|
||||
* <p>
|
||||
* 归档后拒绝。幂等:clear_state=1 调用返回同一 id,不抛错。
|
||||
*/
|
||||
void clearAlarm(AlarmStateTransitionRequest request);
|
||||
|
||||
/**
|
||||
* 归档告警(最终关闭)
|
||||
* <p>
|
||||
* 非幂等:已归档再调用抛 ALARM_ALREADY_ARCHIVED。
|
||||
*/
|
||||
void archiveAlarm(AlarmStateTransitionRequest request);
|
||||
|
||||
/**
|
||||
* 按 ID 查询
|
||||
*/
|
||||
IotAlarmRecordDO getAlarm(Long id);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.viewsh.module.iot.service.alarm;
|
||||
|
||||
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
|
||||
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
|
||||
import com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmSeverity;
|
||||
import com.viewsh.module.iot.dal.mysql.alarm.IotAlarmRecordMapper;
|
||||
import com.viewsh.module.iot.service.alarm.dto.AlarmStateTransitionRequest;
|
||||
import com.viewsh.module.iot.service.alarm.dto.AlarmTriggerRequest;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* IoT 告警记录 Service 实现(v2.0 正交状态机)
|
||||
* <p>
|
||||
* 本卡职责:建表 / 枚举 / DO / Service 状态机核心 + 幂等 upsert。
|
||||
* <b>不实现</b>:分布式锁(B14)、告警传播(B15)、通知(B16)、
|
||||
* 告警条件结构化存储(评审 E4)、Redis 实时计数(B14/O1)。
|
||||
* <p>
|
||||
* Known Pitfalls 落地:
|
||||
* - ⚠️ 评审 C1:正交三字段替代线性 4 枚举({@link IotAlarmRecordDO})
|
||||
* - ⚠️ 评审 C2:联合 UK 幂等 upsert({@link IotAlarmRecordMapper#selectActiveByDeviceAndConfig})
|
||||
* - ⚠️ 评审 C4:TODO B14 加分布式锁(triggerAlarm 当前仅 CAS)
|
||||
* - ⚠️ 归档后不可修改:5 个状态迁移 API 都先校验 archived=0
|
||||
* - ⚠️ tenant_id:基于 {@link TenantContextHolder}
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Validated
|
||||
public class IotAlarmRecordServiceImpl implements IotAlarmRecordService {
|
||||
|
||||
@Resource
|
||||
private IotAlarmRecordMapper alarmRecordMapper;
|
||||
|
||||
// ==================== 触发(幂等 upsert) ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long triggerAlarm(AlarmTriggerRequest request) {
|
||||
// 1. 参数校验
|
||||
if (request == null
|
||||
|| request.getDeviceId() == null
|
||||
|| request.getAlarmConfigId() == null
|
||||
|| request.getSeverity() == null) {
|
||||
throw exception(ALARM_TRIGGER_REQUIRED_FIELD);
|
||||
}
|
||||
if (!AlarmSeverity.isValid(request.getSeverity())) {
|
||||
throw exception(ALARM_SEVERITY_INVALID);
|
||||
}
|
||||
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
// 2. 幂等 upsert(评审 C2)
|
||||
// TODO B14:此处应套分布式锁 SET NX PX + Lua,防并发 trigger 造成双写
|
||||
IotAlarmRecordDO existing = alarmRecordMapper.selectActiveByDeviceAndConfig(
|
||||
request.getDeviceId(), request.getAlarmConfigId(), tenantId);
|
||||
|
||||
if (existing != null) {
|
||||
// 已存在活跃记录 → trigger_count++ + end_ts 更新 + details 覆盖
|
||||
IotAlarmRecordDO update = new IotAlarmRecordDO();
|
||||
update.setId(existing.getId());
|
||||
update.setEndTs(now);
|
||||
int oldCount = existing.getTriggerCount() == null ? 1 : existing.getTriggerCount();
|
||||
update.setTriggerCount(oldCount + 1);
|
||||
if (request.getDetails() != null) {
|
||||
update.setDetails(request.getDetails());
|
||||
}
|
||||
alarmRecordMapper.updateById(update);
|
||||
log.debug("[triggerAlarm] 累积触发 id={} count={}", existing.getId(), oldCount + 1);
|
||||
return existing.getId();
|
||||
}
|
||||
|
||||
// 3. 新建(0, 0, 0)
|
||||
IotAlarmRecordDO record = IotAlarmRecordDO.builder()
|
||||
.alarmConfigId(request.getAlarmConfigId())
|
||||
.alarmName(request.getAlarmName())
|
||||
.severity(request.getSeverity())
|
||||
.ackState(0)
|
||||
.clearState(0)
|
||||
.archived(0)
|
||||
.deviceId(request.getDeviceId())
|
||||
.productId(request.getProductId())
|
||||
.subsystemId(request.getSubsystemId())
|
||||
.ruleChainId(request.getRuleChainId())
|
||||
.sceneRuleId(request.getSceneRuleId())
|
||||
.startTs(now)
|
||||
.endTs(now)
|
||||
.details(request.getDetails())
|
||||
.triggerCount(1)
|
||||
.build();
|
||||
alarmRecordMapper.insert(record);
|
||||
log.info("[triggerAlarm] 新建告警 id={} device={} config={}",
|
||||
record.getId(), request.getDeviceId(), request.getAlarmConfigId());
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
// ==================== 状态迁移(CAS by Service 层) ====================
|
||||
|
||||
@Override
|
||||
public void ackAlarm(AlarmStateTransitionRequest request) {
|
||||
IotAlarmRecordDO alarm = requireActiveAlarm(request.getAlarmId());
|
||||
if (Integer.valueOf(1).equals(alarm.getAckState())) {
|
||||
log.debug("[ackAlarm] 告警 id={} 已确认,幂等跳过", alarm.getId());
|
||||
return;
|
||||
}
|
||||
IotAlarmRecordDO update = new IotAlarmRecordDO();
|
||||
update.setId(alarm.getId());
|
||||
update.setAckState(1);
|
||||
update.setAckTs(LocalDateTime.now());
|
||||
update.setUpdater(request.getOperator());
|
||||
if (request.getRemark() != null && !request.getRemark().isBlank()) {
|
||||
update.setProcessRemark(request.getRemark());
|
||||
}
|
||||
alarmRecordMapper.updateById(update);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unackAlarm(AlarmStateTransitionRequest request) {
|
||||
IotAlarmRecordDO alarm = requireActiveAlarm(request.getAlarmId());
|
||||
if (Integer.valueOf(0).equals(alarm.getAckState())) {
|
||||
log.debug("[unackAlarm] 告警 id={} 未确认,幂等跳过", alarm.getId());
|
||||
return;
|
||||
}
|
||||
IotAlarmRecordDO update = new IotAlarmRecordDO();
|
||||
update.setId(alarm.getId());
|
||||
update.setAckState(0);
|
||||
update.setAckTs(null);
|
||||
update.setUpdater(request.getOperator());
|
||||
if (request.getRemark() != null && !request.getRemark().isBlank()) {
|
||||
update.setProcessRemark(request.getRemark());
|
||||
}
|
||||
alarmRecordMapper.updateById(update);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearAlarm(AlarmStateTransitionRequest request) {
|
||||
IotAlarmRecordDO alarm = requireActiveAlarm(request.getAlarmId());
|
||||
if (Integer.valueOf(1).equals(alarm.getClearState())) {
|
||||
log.debug("[clearAlarm] 告警 id={} 已清除,幂等跳过", alarm.getId());
|
||||
return;
|
||||
}
|
||||
IotAlarmRecordDO update = new IotAlarmRecordDO();
|
||||
update.setId(alarm.getId());
|
||||
update.setClearState(1);
|
||||
update.setClearTs(LocalDateTime.now());
|
||||
update.setUpdater(request.getOperator());
|
||||
if (request.getRemark() != null && !request.getRemark().isBlank()) {
|
||||
update.setProcessRemark(request.getRemark());
|
||||
}
|
||||
alarmRecordMapper.updateById(update);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void archiveAlarm(AlarmStateTransitionRequest request) {
|
||||
IotAlarmRecordDO alarm = getAlarmOrThrow(request.getAlarmId());
|
||||
if (Integer.valueOf(1).equals(alarm.getArchived())) {
|
||||
// 归档非幂等:已归档再调用抛业务异常
|
||||
throw exception(ALARM_ALREADY_ARCHIVED);
|
||||
}
|
||||
IotAlarmRecordDO update = new IotAlarmRecordDO();
|
||||
update.setId(alarm.getId());
|
||||
update.setArchived(1);
|
||||
update.setArchiveTs(LocalDateTime.now());
|
||||
update.setUpdater(request.getOperator());
|
||||
if (request.getRemark() != null && !request.getRemark().isBlank()) {
|
||||
update.setProcessRemark(request.getRemark());
|
||||
}
|
||||
alarmRecordMapper.updateById(update);
|
||||
}
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
@Override
|
||||
public IotAlarmRecordDO getAlarm(Long id) {
|
||||
return alarmRecordMapper.selectById(id);
|
||||
}
|
||||
|
||||
// ==================== 内部工具 ====================
|
||||
|
||||
private IotAlarmRecordDO getAlarmOrThrow(Long id) {
|
||||
IotAlarmRecordDO alarm = alarmRecordMapper.selectById(id);
|
||||
if (alarm == null) {
|
||||
throw exception(ALARM_RECORD_NOT_EXISTS);
|
||||
}
|
||||
return alarm;
|
||||
}
|
||||
|
||||
/** 要求告警存在且未归档(归档后禁止 ack/unack/clear) */
|
||||
private IotAlarmRecordDO requireActiveAlarm(Long id) {
|
||||
IotAlarmRecordDO alarm = getAlarmOrThrow(id);
|
||||
if (Integer.valueOf(1).equals(alarm.getArchived())) {
|
||||
throw exception(ALARM_ALREADY_ARCHIVED);
|
||||
}
|
||||
return alarm;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.viewsh.module.iot.service.alarm.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 告警状态迁移请求(ack / clear / archive / unack 公用)
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AlarmStateTransitionRequest {
|
||||
|
||||
/** 告警记录 ID */
|
||||
private Long alarmId;
|
||||
|
||||
/** 操作人 */
|
||||
private String operator;
|
||||
|
||||
/** 处理备注 */
|
||||
private String remark;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.viewsh.module.iot.service.alarm.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 触发告警请求(规则引擎 AlarmTriggerAction 调用)
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AlarmTriggerRequest {
|
||||
|
||||
/** 设备 ID(必填) */
|
||||
private Long deviceId;
|
||||
|
||||
/** 告警配置 ID(必填) */
|
||||
private Long alarmConfigId;
|
||||
|
||||
/** 严重度 1-5(必填) */
|
||||
private Integer severity;
|
||||
|
||||
/** 告警名称(冗余,便于查询) */
|
||||
private String alarmName;
|
||||
|
||||
/** 产品 ID(冗余,可选) */
|
||||
private Long productId;
|
||||
|
||||
/** 所属子系统 ID(可选) */
|
||||
private Long subsystemId;
|
||||
|
||||
/** v2 规则链 ID(可选) */
|
||||
private Long ruleChainId;
|
||||
|
||||
/** v1 场景规则 ID(可选,灰度期兼容) */
|
||||
private Long sceneRuleId;
|
||||
|
||||
/** 触发详情(JSON) */
|
||||
private JsonNode details;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.viewsh.module.iot.service.alarm;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.viewsh.framework.common.exception.ServiceException;
|
||||
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
|
||||
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
|
||||
import com.viewsh.module.iot.dal.mysql.alarm.IotAlarmRecordMapper;
|
||||
import com.viewsh.module.iot.service.alarm.dto.AlarmStateTransitionRequest;
|
||||
import com.viewsh.module.iot.service.alarm.dto.AlarmTriggerRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link IotAlarmRecordServiceImpl} 单元测试(覆盖任务卡 B12 §6.1 的 10 个用例)。
|
||||
*
|
||||
* @author B12
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IotAlarmRecordServiceImplTest {
|
||||
|
||||
@InjectMocks
|
||||
private IotAlarmRecordServiceImpl alarmService;
|
||||
|
||||
@Mock
|
||||
private IotAlarmRecordMapper alarmRecordMapper;
|
||||
|
||||
private MockedStatic<TenantContextHolder> tenantMock;
|
||||
|
||||
private static final Long TENANT_ID = 1L;
|
||||
private static final Long DEVICE_ID = 100L;
|
||||
private static final Long CONFIG_ID = 200L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantMock = mockStatic(TenantContextHolder.class);
|
||||
tenantMock.when(TenantContextHolder::getTenantId).thenReturn(TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
tenantMock.close();
|
||||
}
|
||||
|
||||
// ==================== 用例 1:首次触发 → 插入 (0,0,0) ====================
|
||||
|
||||
@Test
|
||||
void testTriggerAlarm_new() {
|
||||
when(alarmRecordMapper.selectActiveByDeviceAndConfig(DEVICE_ID, CONFIG_ID, TENANT_ID))
|
||||
.thenReturn(null);
|
||||
when(alarmRecordMapper.insert(any(IotAlarmRecordDO.class))).thenAnswer(inv -> {
|
||||
IotAlarmRecordDO d = inv.getArgument(0);
|
||||
d.setId(555L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
Long id = alarmService.triggerAlarm(buildTriggerReq(3));
|
||||
|
||||
assertEquals(555L, id);
|
||||
ArgumentCaptor<IotAlarmRecordDO> captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class);
|
||||
verify(alarmRecordMapper).insert(captor.capture());
|
||||
IotAlarmRecordDO inserted = captor.getValue();
|
||||
assertEquals(0, inserted.getAckState());
|
||||
assertEquals(0, inserted.getClearState());
|
||||
assertEquals(0, inserted.getArchived());
|
||||
assertEquals(1, inserted.getTriggerCount());
|
||||
assertNotNull(inserted.getStartTs());
|
||||
assertNotNull(inserted.getEndTs());
|
||||
}
|
||||
|
||||
// ==================== 用例 2:已有活跃告警再触发 → trigger_count++ ====================
|
||||
|
||||
@Test
|
||||
void testTriggerAlarm_existing() {
|
||||
IotAlarmRecordDO existing = IotAlarmRecordDO.builder()
|
||||
.id(555L).deviceId(DEVICE_ID).alarmConfigId(CONFIG_ID)
|
||||
.ackState(0).clearState(0).archived(0).triggerCount(3)
|
||||
.build();
|
||||
when(alarmRecordMapper.selectActiveByDeviceAndConfig(DEVICE_ID, CONFIG_ID, TENANT_ID))
|
||||
.thenReturn(existing);
|
||||
|
||||
Long id = alarmService.triggerAlarm(buildTriggerReq(3));
|
||||
|
||||
assertEquals(555L, id);
|
||||
verify(alarmRecordMapper, never()).insert(any(IotAlarmRecordDO.class));
|
||||
ArgumentCaptor<IotAlarmRecordDO> captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class);
|
||||
verify(alarmRecordMapper).updateById(captor.capture());
|
||||
assertEquals(4, captor.getValue().getTriggerCount());
|
||||
assertNotNull(captor.getValue().getEndTs());
|
||||
}
|
||||
|
||||
// ==================== 用例 2b:非法 severity → 抛错 ====================
|
||||
|
||||
@Test
|
||||
void testTriggerAlarm_invalidSeverity() {
|
||||
AlarmTriggerRequest req = buildTriggerReq(9);
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> alarmService.triggerAlarm(req));
|
||||
assertEquals(ALARM_SEVERITY_INVALID.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ==================== 用例 3:活跃告警确认 → (0→1, 0, 0) ====================
|
||||
|
||||
@Test
|
||||
void testAckAlarm() {
|
||||
IotAlarmRecordDO active = activeAlarm(10L);
|
||||
when(alarmRecordMapper.selectById(10L)).thenReturn(active);
|
||||
|
||||
alarmService.ackAlarm(AlarmStateTransitionRequest.builder()
|
||||
.alarmId(10L).operator("admin").remark("处理中").build());
|
||||
|
||||
ArgumentCaptor<IotAlarmRecordDO> captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class);
|
||||
verify(alarmRecordMapper).updateById(captor.capture());
|
||||
assertEquals(1, captor.getValue().getAckState());
|
||||
assertNotNull(captor.getValue().getAckTs());
|
||||
assertEquals("admin", captor.getValue().getUpdater());
|
||||
assertEquals("处理中", captor.getValue().getProcessRemark());
|
||||
}
|
||||
|
||||
// ==================== 用例 4:活跃告警清除 → (*, 0→1, 0) ====================
|
||||
|
||||
@Test
|
||||
void testClearAlarm_fromActive() {
|
||||
IotAlarmRecordDO active = activeAlarm(11L);
|
||||
when(alarmRecordMapper.selectById(11L)).thenReturn(active);
|
||||
|
||||
alarmService.clearAlarm(AlarmStateTransitionRequest.builder()
|
||||
.alarmId(11L).operator("system").build());
|
||||
|
||||
ArgumentCaptor<IotAlarmRecordDO> captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class);
|
||||
verify(alarmRecordMapper).updateById(captor.capture());
|
||||
assertEquals(1, captor.getValue().getClearState());
|
||||
assertNotNull(captor.getValue().getClearTs());
|
||||
}
|
||||
|
||||
// ==================== 用例 5:先自动清除再人工确认(评审 C1 关键场景) ==================
|
||||
|
||||
@Test
|
||||
void testClearAlarm_thenAck() {
|
||||
// 已清除未确认:(0, 1, 0)
|
||||
IotAlarmRecordDO cleared = IotAlarmRecordDO.builder()
|
||||
.id(12L).ackState(0).clearState(1).archived(0).triggerCount(5)
|
||||
.build();
|
||||
when(alarmRecordMapper.selectById(12L)).thenReturn(cleared);
|
||||
|
||||
alarmService.ackAlarm(AlarmStateTransitionRequest.builder().alarmId(12L).operator("qa").build());
|
||||
|
||||
ArgumentCaptor<IotAlarmRecordDO> captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class);
|
||||
verify(alarmRecordMapper).updateById(captor.capture());
|
||||
// 关键:ack_state=1 但 clear_state 未动(Service 只改目标字段)
|
||||
assertEquals(1, captor.getValue().getAckState());
|
||||
assertNull(captor.getValue().getClearState(), "clearState 不应被触碰,保持原值 1");
|
||||
}
|
||||
|
||||
// ==================== 用例 6:先确认再清除 → (1, 0→1, 0) ====================
|
||||
|
||||
@Test
|
||||
void testAckAlarm_thenClear() {
|
||||
IotAlarmRecordDO acked = IotAlarmRecordDO.builder()
|
||||
.id(13L).ackState(1).clearState(0).archived(0).triggerCount(2)
|
||||
.build();
|
||||
when(alarmRecordMapper.selectById(13L)).thenReturn(acked);
|
||||
|
||||
alarmService.clearAlarm(AlarmStateTransitionRequest.builder().alarmId(13L).build());
|
||||
|
||||
ArgumentCaptor<IotAlarmRecordDO> captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class);
|
||||
verify(alarmRecordMapper).updateById(captor.capture());
|
||||
assertEquals(1, captor.getValue().getClearState());
|
||||
assertNull(captor.getValue().getAckState(), "ackState 不应被触碰,保持原值 1");
|
||||
}
|
||||
|
||||
// ==================== 用例 7:归档 → (*, *, 0→1) ====================
|
||||
|
||||
@Test
|
||||
void testArchiveAlarm() {
|
||||
IotAlarmRecordDO active = activeAlarm(14L);
|
||||
when(alarmRecordMapper.selectById(14L)).thenReturn(active);
|
||||
|
||||
alarmService.archiveAlarm(AlarmStateTransitionRequest.builder().alarmId(14L).build());
|
||||
|
||||
ArgumentCaptor<IotAlarmRecordDO> captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class);
|
||||
verify(alarmRecordMapper).updateById(captor.capture());
|
||||
assertEquals(1, captor.getValue().getArchived());
|
||||
assertNotNull(captor.getValue().getArchiveTs());
|
||||
}
|
||||
|
||||
// ==================== 用例 8:归档后尝试确认 → throws ALARM_ALREADY_ARCHIVED ====================
|
||||
|
||||
@Test
|
||||
void testArchiveAlarm_thenAck_rejected() {
|
||||
IotAlarmRecordDO archived = IotAlarmRecordDO.builder()
|
||||
.id(15L).ackState(1).clearState(1).archived(1).triggerCount(10)
|
||||
.build();
|
||||
when(alarmRecordMapper.selectById(15L)).thenReturn(archived);
|
||||
|
||||
AlarmStateTransitionRequest req = AlarmStateTransitionRequest.builder().alarmId(15L).build();
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> alarmService.ackAlarm(req));
|
||||
assertEquals(ALARM_ALREADY_ARCHIVED.getCode(), ex.getCode());
|
||||
|
||||
// 归档再归档也拒绝
|
||||
ex = assertThrows(ServiceException.class, () -> alarmService.archiveAlarm(req));
|
||||
assertEquals(ALARM_ALREADY_ARCHIVED.getCode(), ex.getCode());
|
||||
verify(alarmRecordMapper, never()).updateById(any(IotAlarmRecordDO.class));
|
||||
}
|
||||
|
||||
// ==================== 用例 9:撤销确认 → (1→0, *, 0) ====================
|
||||
|
||||
@Test
|
||||
void testUnackAlarm() {
|
||||
IotAlarmRecordDO acked = IotAlarmRecordDO.builder()
|
||||
.id(16L).ackState(1).clearState(0).archived(0).triggerCount(1)
|
||||
.build();
|
||||
when(alarmRecordMapper.selectById(16L)).thenReturn(acked);
|
||||
|
||||
alarmService.unackAlarm(AlarmStateTransitionRequest.builder()
|
||||
.alarmId(16L).operator("admin").remark("误操作").build());
|
||||
|
||||
ArgumentCaptor<IotAlarmRecordDO> captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class);
|
||||
verify(alarmRecordMapper).updateById(captor.capture());
|
||||
assertEquals(0, captor.getValue().getAckState());
|
||||
assertNull(captor.getValue().getAckTs(), "unack 应清 ackTs");
|
||||
assertEquals("误操作", captor.getValue().getProcessRemark());
|
||||
}
|
||||
|
||||
// ==================== 用例 10:tenant_id 过滤传递到 Mapper(租户隔离) ====================
|
||||
|
||||
@Test
|
||||
void testTenantIsolation_triggerPassesTenantId() {
|
||||
when(alarmRecordMapper.selectActiveByDeviceAndConfig(DEVICE_ID, CONFIG_ID, TENANT_ID)).thenReturn(null);
|
||||
when(alarmRecordMapper.insert(any(IotAlarmRecordDO.class))).thenAnswer(inv -> {
|
||||
((IotAlarmRecordDO) inv.getArgument(0)).setId(999L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
alarmService.triggerAlarm(buildTriggerReq(3));
|
||||
|
||||
// 验证 Service 用 TenantContextHolder 传给 Mapper
|
||||
verify(alarmRecordMapper).selectActiveByDeviceAndConfig(eq(DEVICE_ID), eq(CONFIG_ID), eq(TENANT_ID));
|
||||
}
|
||||
|
||||
// ==================== 附加:幂等 ack / clear ====================
|
||||
|
||||
@Test
|
||||
void testAckAlarm_idempotent() {
|
||||
IotAlarmRecordDO acked = IotAlarmRecordDO.builder()
|
||||
.id(17L).ackState(1).clearState(0).archived(0).triggerCount(1).build();
|
||||
when(alarmRecordMapper.selectById(17L)).thenReturn(acked);
|
||||
|
||||
alarmService.ackAlarm(AlarmStateTransitionRequest.builder().alarmId(17L).build());
|
||||
|
||||
// 已确认 → no-op
|
||||
verify(alarmRecordMapper, never()).updateById(any(IotAlarmRecordDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAlarmNotFound() {
|
||||
when(alarmRecordMapper.selectById(999L)).thenReturn(null);
|
||||
|
||||
AlarmStateTransitionRequest req = AlarmStateTransitionRequest.builder().alarmId(999L).build();
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> alarmService.ackAlarm(req));
|
||||
assertEquals(ALARM_RECORD_NOT_EXISTS.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
|
||||
private AlarmTriggerRequest buildTriggerReq(int severity) {
|
||||
ObjectNode details = JsonNodeFactory.instance.objectNode().put("temperature", 45.5);
|
||||
return AlarmTriggerRequest.builder()
|
||||
.deviceId(DEVICE_ID)
|
||||
.alarmConfigId(CONFIG_ID)
|
||||
.alarmName("高温告警")
|
||||
.severity(severity)
|
||||
.subsystemId(9L)
|
||||
.ruleChainId(8L)
|
||||
.details(details)
|
||||
.build();
|
||||
}
|
||||
|
||||
private IotAlarmRecordDO activeAlarm(Long id) {
|
||||
return IotAlarmRecordDO.builder()
|
||||
.id(id).deviceId(DEVICE_ID).alarmConfigId(CONFIG_ID)
|
||||
.ackState(0).clearState(0).archived(0).triggerCount(1)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user