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,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