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,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();
}
}