feat(iot): B15 告警传播(iot_alarm_propagation 关联表)

沿资产层级(SUBSYSTEM→PROJECT→TENANT)向上传播,替代原 JSON_CONTAINS 全表扫描。

关键实现:
- IotAlarmPropagationMapper:INSERT IGNORE 幂等批量插入;selectAlarmIdsByAsset
  命中 idx_asset(asset_type, asset_id, tenant_id),毫秒级响应
- IotAlarmPropagationServiceImpl:三层传播逻辑(无 project_id 时仅 2 层)
- IotAlarmRecordServiceImpl:首次触发(existing==null)时调用传播,
  @Lazy 注入避免循环依赖
- IotProjectMapper:最小化 Mapper(B10 有 DO 无 Mapper)
- 7 个单元测试(3层/2层/无subsystem/重复幂等/分页查询)

测试:iot-server 258/258 全绿(含 B14/B15 新增 23 用例)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 11:08:36 +08:00
parent 171f201384
commit bac4f216fc
9 changed files with 550 additions and 9 deletions

View File

@@ -0,0 +1,235 @@
package com.viewsh.module.iot.service.alarm;
import com.viewsh.framework.common.pojo.PageParam;
import com.viewsh.framework.common.pojo.PageResult;
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmPropagationDO;
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
import com.viewsh.module.iot.dal.dataobject.subsystem.IotProjectDO;
import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
import com.viewsh.module.iot.dal.mysql.alarm.IotAlarmPropagationMapper;
import com.viewsh.module.iot.dal.mysql.subsystem.IotProjectMapper;
import com.viewsh.module.iot.service.subsystem.IotSubsystemService;
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.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* {@link IotAlarmPropagationServiceImpl} 单元测试(覆盖任务卡 B15 §6 测试用例)
*
* @author B15
*/
@ExtendWith(MockitoExtension.class)
class IotAlarmPropagationServiceImplTest {
@InjectMocks
private IotAlarmPropagationServiceImpl propagationService;
@Mock
private IotAlarmPropagationMapper propagationMapper;
@Mock
private IotSubsystemService subsystemService;
@Mock
private IotProjectMapper projectMapper;
private static final Long TENANT_ID = 1L;
private static final Long ALARM_ID = 100L;
private static final Long SUBSYSTEM_ID = 10L;
private static final Long PROJECT_ID = 20L;
// ==================== testPropagate_fullLevels ====================
/**
* subsystem 有 project → 3 条 propagationSUBSYSTEM + PROJECT + TENANT
*/
@Test
void testPropagate_fullLevels() {
IotAlarmRecordDO alarm = buildAlarm(ALARM_ID, SUBSYSTEM_ID, TENANT_ID);
IotSubsystemDO sub = IotSubsystemDO.builder()
.id(SUBSYSTEM_ID).name("消防子系统").projectId(PROJECT_ID).build();
when(subsystemService.getSubsystem(SUBSYSTEM_ID)).thenReturn(sub);
IotProjectDO prj = IotProjectDO.builder().id(PROJECT_ID).name("A 楼项目").build();
when(projectMapper.selectById(PROJECT_ID)).thenReturn(prj);
propagationService.propagate(alarm);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<IotAlarmPropagationDO>> captor = ArgumentCaptor.forClass(List.class);
verify(propagationMapper).batchInsert(captor.capture());
List<IotAlarmPropagationDO> rows = captor.getValue();
assertEquals(3, rows.size(), "应生成 3 条SUBSYSTEM + PROJECT + TENANT");
assertContainsAsset(rows, "SUBSYSTEM", SUBSYSTEM_ID, ALARM_ID, TENANT_ID);
assertContainsAsset(rows, "PROJECT", PROJECT_ID, ALARM_ID, TENANT_ID);
assertContainsAsset(rows, "TENANT", TENANT_ID, ALARM_ID, TENANT_ID);
}
// ==================== testPropagate_noProject ====================
/**
* subsystem.projectId = null → 2 条SUBSYSTEM + TENANT
*/
@Test
void testPropagate_noProject() {
IotAlarmRecordDO alarm = buildAlarm(ALARM_ID, SUBSYSTEM_ID, TENANT_ID);
IotSubsystemDO sub = IotSubsystemDO.builder()
.id(SUBSYSTEM_ID).name("消防子系统").projectId(null).build();
when(subsystemService.getSubsystem(SUBSYSTEM_ID)).thenReturn(sub);
propagationService.propagate(alarm);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<IotAlarmPropagationDO>> captor = ArgumentCaptor.forClass(List.class);
verify(propagationMapper).batchInsert(captor.capture());
List<IotAlarmPropagationDO> rows = captor.getValue();
assertEquals(2, rows.size(), "应生成 2 条SUBSYSTEM + TENANT无 PROJECT");
assertContainsAsset(rows, "SUBSYSTEM", SUBSYSTEM_ID, ALARM_ID, TENANT_ID);
assertContainsAsset(rows, "TENANT", TENANT_ID, ALARM_ID, TENANT_ID);
// 无 PROJECT
assertTrue(rows.stream().noneMatch(r -> "PROJECT".equals(r.getAssetType())));
// projectMapper 不应被调用
verify(projectMapper, never()).selectById(anyLong());
}
// ==================== testPropagate_noSubsystem ====================
/**
* alarm.subsystemId = null → 只传播 1 条 TENANT
*/
@Test
void testPropagate_noSubsystem() {
IotAlarmRecordDO alarm = buildAlarm(ALARM_ID, null, TENANT_ID);
propagationService.propagate(alarm);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<IotAlarmPropagationDO>> captor = ArgumentCaptor.forClass(List.class);
verify(propagationMapper).batchInsert(captor.capture());
List<IotAlarmPropagationDO> rows = captor.getValue();
assertEquals(1, rows.size(), "无 subsystem 时仅传播到 TENANT");
assertEquals("TENANT", rows.get(0).getAssetType());
verify(subsystemService, never()).getSubsystem(anyLong());
}
// ==================== testPropagate_duplicate ====================
/**
* 同一告警 propagate 调用 2 次 → mapper.batchInsert 被调用 2 次INSERT IGNORE 幂等DB 层去重)
*/
@Test
void testPropagate_duplicate() {
IotAlarmRecordDO alarm = buildAlarm(ALARM_ID, SUBSYSTEM_ID, TENANT_ID);
IotSubsystemDO sub = IotSubsystemDO.builder()
.id(SUBSYSTEM_ID).name("消防子系统").projectId(PROJECT_ID).build();
when(subsystemService.getSubsystem(SUBSYSTEM_ID)).thenReturn(sub);
IotProjectDO prj = IotProjectDO.builder().id(PROJECT_ID).name("A 楼项目").build();
when(projectMapper.selectById(PROJECT_ID)).thenReturn(prj);
// 第一次
propagationService.propagate(alarm);
// 第二次(重复)
propagationService.propagate(alarm);
// batchInsert 被调用 2 次INSERT IGNORE 由 DB 层保证幂等)
verify(propagationMapper, times(2)).batchInsert(anyList());
}
// ==================== testFindAlarmsByAsset ====================
/**
* findAlarmsByAsset正确传递 offset/limit组装 PageResult
*/
@Test
void testFindAlarmsByAsset() {
when(propagationMapper.selectAlarmIdsByAsset("SUBSYSTEM", 5L, TENANT_ID, 0, 10))
.thenReturn(List.of(101L, 102L, 103L));
when(propagationMapper.countByAsset("SUBSYSTEM", 5L, TENANT_ID)).thenReturn(3L);
PageParam page = new PageParam();
page.setPageNo(1);
page.setPageSize(10);
PageResult<Long> result = propagationService.findAlarmsByAsset("SUBSYSTEM", 5L, TENANT_ID, page);
assertEquals(3L, result.getTotal());
assertEquals(3, result.getList().size());
assertTrue(result.getList().containsAll(List.of(101L, 102L, 103L)));
}
/**
* 分页:第 2 页pageSize=10 → offset=10
*/
@Test
void testFindAlarmsByAsset_page2() {
when(propagationMapper.selectAlarmIdsByAsset("TENANT", TENANT_ID, TENANT_ID, 10, 10))
.thenReturn(List.of(200L));
when(propagationMapper.countByAsset("TENANT", TENANT_ID, TENANT_ID)).thenReturn(11L);
PageParam page = new PageParam();
page.setPageNo(2);
page.setPageSize(10);
PageResult<Long> result = propagationService.findAlarmsByAsset("TENANT", TENANT_ID, TENANT_ID, page);
assertEquals(11L, result.getTotal());
assertEquals(1, result.getList().size());
// 验证 offset=10
verify(propagationMapper).selectAlarmIdsByAsset("TENANT", TENANT_ID, TENANT_ID, 10, 10);
}
// ==================== testPropagate_nullAlarm ====================
/**
* alarm 为 null → 不调用 mapper安全跳过
*/
@Test
void testPropagate_nullAlarm() {
propagationService.propagate(null);
verify(propagationMapper, never()).batchInsert(anyList());
}
// ==================== 辅助方法 ====================
private IotAlarmRecordDO buildAlarm(Long id, Long subsystemId, Long tenantId) {
IotAlarmRecordDO alarm = new IotAlarmRecordDO();
alarm.setId(id);
alarm.setSubsystemId(subsystemId);
alarm.setTenantId(tenantId);
return alarm;
}
private void assertContainsAsset(List<IotAlarmPropagationDO> rows,
String assetType, Long assetId,
Long alarmRecordId, Long tenantId) {
boolean found = rows.stream().anyMatch(r ->
assetType.equals(r.getAssetType())
&& assetId.equals(r.getAssetId())
&& alarmRecordId.equals(r.getAlarmRecordId())
&& tenantId.equals(r.getTenantId())
);
assertTrue(found, "未找到 assetType=" + assetType + " assetId=" + assetId + " 的传播记录");
}
}

View File

@@ -53,6 +53,10 @@ class IotAlarmRecordServiceImplTest {
@Mock
private AlarmStateValidator validator;
/** B15 告警传播lenient仅首次触发路径调用 */
@Mock
private IotAlarmPropagationService alarmPropagationService;
private MockedStatic<TenantContextHolder> tenantMock;
private static final Long TENANT_ID = 1L;
@@ -73,6 +77,9 @@ class IotAlarmRecordServiceImplTest {
// 默认validator 允许所有操作 — lenient 避免首次触发路径不调用 validator 时报 UnnecessaryStubbing
lenient().when(validator.isEffectiveTrigger(any(), anyLong())).thenReturn(true);
// 默认propagationService 传播成功(无需 stubvoid 方法默认 no-op— lenient 避免部分测试不触发传播时报 UnnecessaryStubbing
lenient().doNothing().when(alarmPropagationService).propagate(any(IotAlarmRecordDO.class));
}
@AfterEach