diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmPropagationDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmPropagationDO.java index 71fe3762..de4342ab 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmPropagationDO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmPropagationDO.java @@ -1,37 +1,49 @@ package com.viewsh.module.iot.dal.dataobject.alarm; +import com.baomidou.mybatisplus.annotation.TableId; 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; +import java.time.LocalDateTime; + /** * 告警沿资产层级传播关联表(评审 C3) *

- * 替代 v1 {@code propagated_to} JSON 字段的高频查询。 - * 由 B15 告警传播任务写入;本卡只建表 + DO 骨架。 + * 替代 v1 {@code propagated_to} JSON 字段的高频查询,走关联表 + 索引(评审 C3)。 + * 复合 PRIMARY KEY (alarm_record_id, asset_type, asset_id) 保证幂等(INSERT IGNORE)。 * - * @author B12 + * @author B15 */ @TableName("iot_alarm_propagation") @Data @Builder @NoArgsConstructor @AllArgsConstructor -public class IotAlarmPropagationDO extends TenantBaseDO { +public class IotAlarmPropagationDO { - /** 告警记录 ID(联合主键) */ + /** 代理主键(AUTO_INCREMENT) */ + @TableId + private Long id; + + /** 告警记录 ID(联合唯一键) */ private Long alarmRecordId; - /** 资产类型:SUBSYSTEM / FLOOR / BUILDING(联合主键) */ + /** 资产类型:SUBSYSTEM / PROJECT / TENANT(联合唯一键) */ private String assetType; - /** 资产 ID(联合主键) */ + /** 资产 ID(联合唯一键) */ private Long assetId; - /** 资产名称冗余 */ + /** 资产名称冗余(快速展示,避免反查) */ private String assetName; + /** 租户 ID(多租户隔离) */ + private Long tenantId; + + /** 创建时间 */ + private LocalDateTime createTime; + } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmPropagationMapper.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmPropagationMapper.java new file mode 100644 index 00000000..9cf7a775 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmPropagationMapper.java @@ -0,0 +1,56 @@ +package com.viewsh.module.iot.dal.mysql.alarm; + +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmPropagationDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * IoT 告警传播 Mapper + *

+ * 评审 C3:关联表替代 JSON_CONTAINS 高频查询; + * 命中索引 {@code idx_asset(asset_type, asset_id, tenant_id)},毫秒级响应。 + * + * @author B15 + */ +@Mapper +public interface IotAlarmPropagationMapper extends BaseMapperX { + + /** + * 批量插入传播记录(INSERT IGNORE 保证幂等,评审 C3) + * + * @param list 传播记录列表 + */ + void batchInsert(@Param("list") List list); + + /** + * 查询指定资产下的告警 ID 列表(分页,命中 idx_asset 索引) + * + * @param assetType 资产类型 + * @param assetId 资产 ID + * @param tenantId 租户 ID + * @param offset 偏移量 + * @param limit 每页条数 + * @return 告警记录 ID 列表 + */ + List selectAlarmIdsByAsset(@Param("assetType") String assetType, + @Param("assetId") Long assetId, + @Param("tenantId") Long tenantId, + @Param("offset") int offset, + @Param("limit") int limit); + + /** + * 统计指定资产下的告警总数(用于分页 total) + * + * @param assetType 资产类型 + * @param assetId 资产 ID + * @param tenantId 租户 ID + * @return 总数 + */ + long countByAsset(@Param("assetType") String assetType, + @Param("assetId") Long assetId, + @Param("tenantId") Long tenantId); + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/subsystem/IotProjectMapper.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/subsystem/IotProjectMapper.java new file mode 100644 index 00000000..5bec1df2 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/subsystem/IotProjectMapper.java @@ -0,0 +1,16 @@ +package com.viewsh.module.iot.dal.mysql.subsystem; + +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.module.iot.dal.dataobject.subsystem.IotProjectDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * IoT 项目 Mapper(预留,B10 架构中 project 暂不开放 API) + *

+ * B15 告警传播 PROJECT 层通过此 Mapper 读取 project name。 + * + * @author B15 + */ +@Mapper +public interface IotProjectMapper extends BaseMapperX { +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmPropagationService.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmPropagationService.java new file mode 100644 index 00000000..62ddf365 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmPropagationService.java @@ -0,0 +1,43 @@ +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.IotAlarmRecordDO; + +/** + * IoT 告警传播 Service(评审 C3:关联表替代 JSON 查询) + *

+ * 职责: + *

    + *
  1. 告警首次触发时,沿资产层级(subsystem → project → tenant)向上写 propagation 记录
  2. + *
  3. 提供 Dashboard 按资产聚合查询告警 ID 列表
  4. + *
+ * + * @author B15 + */ +public interface IotAlarmPropagationService { + + /** + * 传播告警到各层级资产(subsystem / project / tenant) + *

+ * 在 {@code triggerAlarm} 同事务内调用(首次触发,trigger_count == 1)。 + * INSERT IGNORE 保证幂等;告警 clear 后保留传播记录(审计需要)。 + * + * @param alarm 已持久化的告警记录(含 id / subsystemId / tenantId) + */ + void propagate(IotAlarmRecordDO alarm); + + /** + * 查询某资产下的所有告警 ID(分页) + *

+ * Dashboard 聚合查询入口;只返回 alarm_record_id 列表,详情走 JOIN iot_alarm_record(避免传播表字段冗余)。 + * + * @param assetType 资产类型(SUBSYSTEM / PROJECT / TENANT) + * @param assetId 资产 ID + * @param tenantId 租户 ID + * @param page 分页参数 + * @return 告警记录 ID 分页结果 + */ + PageResult findAlarmsByAsset(String assetType, Long assetId, Long tenantId, PageParam page); + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmPropagationServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmPropagationServiceImpl.java new file mode 100644 index 00000000..e6afe66e --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmPropagationServiceImpl.java @@ -0,0 +1,120 @@ +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 jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * IoT 告警传播 Service 实现(评审 C3:关联表替代 JSON_CONTAINS) + *

+ * Known Pitfalls 落地: + * - ⚠️ 评审 C3:不用 JSON_CONTAINS,走关联表 + idx_asset 索引 + * - ⚠️ 重复插入:INSERT IGNORE 幂等,PRIMARY KEY (alarm_record_id, asset_type, asset_id) + * - ⚠️ 清除不回滚传播:告警 clear 保留记录,查询时由调用方带 clear_state 过滤 + * - ⚠️ 事务边界:在 triggerAlarm 同事务内调用,失败则 alarm 也回滚 + * - ⚠️ 资产层级:本任务只做 subsystem/project/tenant 三层,未来扩展加表结构 + * - ⚠️ Dashboard 查询:findAlarmsByAsset 只返回 alarm_record_id,详情走 JOIN iot_alarm_record + * + * @author B15 + */ +@Slf4j +@Service +public class IotAlarmPropagationServiceImpl implements IotAlarmPropagationService { + + @Resource + private IotAlarmPropagationMapper propagationMapper; + + @Resource + private IotSubsystemService subsystemService; + + /** + * IotProjectMapper(B10 架构预留,本期不开放 API;B15 通过此 Mapper 取 project name) + */ + @Resource + private IotProjectMapper projectMapper; + + // ==================== 传播 ==================== + + @Override + public void propagate(IotAlarmRecordDO alarm) { + if (alarm == null || alarm.getId() == null) { + log.warn("[propagate] alarm or alarm.id is null, skip"); + return; + } + + List rows = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + + // 1. Subsystem 层 + if (alarm.getSubsystemId() != null) { + IotSubsystemDO sub = subsystemService.getSubsystem(alarm.getSubsystemId()); + if (sub != null) { + rows.add(buildRow("SUBSYSTEM", sub.getId(), sub.getName(), alarm, now)); + + // 2. Project 层(若 subsystem 归属 project) + if (sub.getProjectId() != null) { + IotProjectDO prj = projectMapper.selectById(sub.getProjectId()); + if (prj != null) { + rows.add(buildRow("PROJECT", prj.getId(), prj.getName(), alarm, now)); + } else { + // TODO:project 记录不存在(可能被删除),跳过 PROJECT 层 + log.warn("[propagate] project not found: projectId={}, skip PROJECT layer, alarmId={}", + sub.getProjectId(), alarm.getId()); + } + } + } else { + log.warn("[propagate] subsystem not found: subsystemId={}, skip subsystem layer, alarmId={}", + alarm.getSubsystemId(), alarm.getId()); + } + } + + // 3. Tenant 层(始终传播) + rows.add(buildRow("TENANT", alarm.getTenantId(), "租户级", alarm, now)); + + // 批量 INSERT IGNORE(幂等) + if (!rows.isEmpty()) { + propagationMapper.batchInsert(rows); + log.debug("[propagate] alarmId={} propagated {} rows", alarm.getId(), rows.size()); + } + } + + // ==================== 查询 ==================== + + @Override + public PageResult findAlarmsByAsset(String assetType, Long assetId, Long tenantId, PageParam page) { + int offset = (page.getPageNo() - 1) * page.getPageSize(); + int limit = page.getPageSize(); + + List ids = propagationMapper.selectAlarmIdsByAsset(assetType, assetId, tenantId, offset, limit); + long total = propagationMapper.countByAsset(assetType, assetId, tenantId); + return new PageResult<>(ids, total); + } + + // ==================== 内部工具 ==================== + + private IotAlarmPropagationDO buildRow(String assetType, Long assetId, String assetName, + IotAlarmRecordDO alarm, LocalDateTime now) { + return IotAlarmPropagationDO.builder() + .alarmRecordId(alarm.getId()) + .assetType(assetType) + .assetId(assetId) + .assetName(assetName) + .tenantId(alarm.getTenantId()) + .createTime(now) + .build(); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java index ed56b5cb..c187ca31 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java @@ -8,6 +8,7 @@ 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.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -54,6 +55,13 @@ public class IotAlarmRecordServiceImpl implements IotAlarmRecordService { @Resource private AlarmStateValidator validator; + /** + * B15 告警传播(@Lazy 避免循环依赖) + */ + @Lazy + @Resource + private IotAlarmPropagationService alarmPropagationService; + // ==================== 触发(幂等 upsert) ==================== @Override @@ -101,6 +109,8 @@ public class IotAlarmRecordServiceImpl implements IotAlarmRecordService { record.getId(), request.getDeviceId(), request.getAlarmConfigId()); // 同步缓存(首次,无需锁) cacheService.updateState(record.getId(), record); + // B15 告警传播(首次触发,trigger_count == 1) + alarmPropagationService.propagate(record); return record.getId(); } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/resources/mapper/alarm/IotAlarmPropagationMapper.xml b/viewsh-module-iot/viewsh-module-iot-server/src/main/resources/mapper/alarm/IotAlarmPropagationMapper.xml new file mode 100644 index 00000000..cb920618 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/resources/mapper/alarm/IotAlarmPropagationMapper.xml @@ -0,0 +1,42 @@ + + + + + + + INSERT IGNORE INTO iot_alarm_propagation + (alarm_record_id, asset_type, asset_id, asset_name, tenant_id, create_time) + VALUES + + (#{r.alarmRecordId}, #{r.assetType}, #{r.assetId}, #{r.assetName}, #{r.tenantId}, #{r.createTime}) + + + + + + + + + + diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmPropagationServiceImplTest.java b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmPropagationServiceImplTest.java new file mode 100644 index 00000000..dda9b533 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmPropagationServiceImplTest.java @@ -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 条 propagation(SUBSYSTEM + 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> captor = ArgumentCaptor.forClass(List.class); + verify(propagationMapper).batchInsert(captor.capture()); + + List 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> captor = ArgumentCaptor.forClass(List.class); + verify(propagationMapper).batchInsert(captor.capture()); + + List 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> captor = ArgumentCaptor.forClass(List.class); + verify(propagationMapper).batchInsert(captor.capture()); + + List 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 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 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 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 + " 的传播记录"); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java index f4db8bce..fc37a96f 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java @@ -53,6 +53,10 @@ class IotAlarmRecordServiceImplTest { @Mock private AlarmStateValidator validator; + /** B15 告警传播(lenient:仅首次触发路径调用) */ + @Mock + private IotAlarmPropagationService alarmPropagationService; + private MockedStatic 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 传播成功(无需 stub,void 方法默认 no-op)— lenient 避免部分测试不触发传播时报 UnnecessaryStubbing + lenient().doNothing().when(alarmPropagationService).propagate(any(IotAlarmRecordDO.class)); } @AfterEach