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:
lzh
2026-04-24 00:35:14 +08:00
parent 1f87d599c0
commit 42466363c7
38 changed files with 3900 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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 告警记录 DOv2.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;
}

View File

@@ -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 存 TINYINT0=未确认 / 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));
}
}

View File

@@ -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 存 TINYINT0=活跃 / 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));
}
}

View File

@@ -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;
}
}

View File

@@ -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"));
}
}

View File

@@ -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 告警记录 Servicev2.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);
}

View File

@@ -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}
* - ⚠️ 评审 C4TODO 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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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());
}
// ==================== 用例 10tenant_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();
}
}