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 查询)
+ *
+ * 职责:
+ *
+ * - 告警首次触发时,沿资产层级(subsystem → project → tenant)向上写 propagation 记录
+ * - 提供 Dashboard 按资产聚合查询告警 ID 列表
+ *
+ *
+ * @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