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:
@@ -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)
|
||||
* <p>
|
||||
* 替代 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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* <p>
|
||||
* 评审 C3:关联表替代 JSON_CONTAINS 高频查询;
|
||||
* 命中索引 {@code idx_asset(asset_type, asset_id, tenant_id)},毫秒级响应。
|
||||
*
|
||||
* @author B15
|
||||
*/
|
||||
@Mapper
|
||||
public interface IotAlarmPropagationMapper extends BaseMapperX<IotAlarmPropagationDO> {
|
||||
|
||||
/**
|
||||
* 批量插入传播记录(INSERT IGNORE 保证幂等,评审 C3)
|
||||
*
|
||||
* @param list 传播记录列表
|
||||
*/
|
||||
void batchInsert(@Param("list") List<IotAlarmPropagationDO> list);
|
||||
|
||||
/**
|
||||
* 查询指定资产下的告警 ID 列表(分页,命中 idx_asset 索引)
|
||||
*
|
||||
* @param assetType 资产类型
|
||||
* @param assetId 资产 ID
|
||||
* @param tenantId 租户 ID
|
||||
* @param offset 偏移量
|
||||
* @param limit 每页条数
|
||||
* @return 告警记录 ID 列表
|
||||
*/
|
||||
List<Long> 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);
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
* <p>
|
||||
* B15 告警传播 PROJECT 层通过此 Mapper 读取 project name。
|
||||
*
|
||||
* @author B15
|
||||
*/
|
||||
@Mapper
|
||||
public interface IotProjectMapper extends BaseMapperX<IotProjectDO> {
|
||||
}
|
||||
@@ -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 查询)
|
||||
* <p>
|
||||
* 职责:
|
||||
* <ol>
|
||||
* <li>告警首次触发时,沿资产层级(subsystem → project → tenant)向上写 propagation 记录</li>
|
||||
* <li>提供 Dashboard 按资产聚合查询告警 ID 列表</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author B15
|
||||
*/
|
||||
public interface IotAlarmPropagationService {
|
||||
|
||||
/**
|
||||
* 传播告警到各层级资产(subsystem / project / tenant)
|
||||
* <p>
|
||||
* 在 {@code triggerAlarm} 同事务内调用(首次触发,trigger_count == 1)。
|
||||
* INSERT IGNORE 保证幂等;告警 clear 后保留传播记录(审计需要)。
|
||||
*
|
||||
* @param alarm 已持久化的告警记录(含 id / subsystemId / tenantId)
|
||||
*/
|
||||
void propagate(IotAlarmRecordDO alarm);
|
||||
|
||||
/**
|
||||
* 查询某资产下的所有告警 ID(分页)
|
||||
* <p>
|
||||
* Dashboard 聚合查询入口;只返回 alarm_record_id 列表,详情走 JOIN iot_alarm_record(避免传播表字段冗余)。
|
||||
*
|
||||
* @param assetType 资产类型(SUBSYSTEM / PROJECT / TENANT)
|
||||
* @param assetId 资产 ID
|
||||
* @param tenantId 租户 ID
|
||||
* @param page 分页参数
|
||||
* @return 告警记录 ID 分页结果
|
||||
*/
|
||||
PageResult<Long> findAlarmsByAsset(String assetType, Long assetId, Long tenantId, PageParam page);
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
* <p>
|
||||
* 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<IotAlarmPropagationDO> 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<Long> findAlarmsByAsset(String assetType, Long assetId, Long tenantId, PageParam page) {
|
||||
int offset = (page.getPageNo() - 1) * page.getPageSize();
|
||||
int limit = page.getPageSize();
|
||||
|
||||
List<Long> 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.viewsh.module.iot.dal.mysql.alarm.IotAlarmPropagationMapper">
|
||||
|
||||
<!--
|
||||
batchInsert:INSERT IGNORE 保证幂等(评审 C3)
|
||||
PRIMARY KEY (alarm_record_id, asset_type, asset_id) 阻止重复插入
|
||||
-->
|
||||
<insert id="batchInsert">
|
||||
INSERT IGNORE INTO iot_alarm_propagation
|
||||
(alarm_record_id, asset_type, asset_id, asset_name, tenant_id, create_time)
|
||||
VALUES
|
||||
<foreach collection="list" item="r" separator=",">
|
||||
(#{r.alarmRecordId}, #{r.assetType}, #{r.assetId}, #{r.assetName}, #{r.tenantId}, #{r.createTime})
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<!--
|
||||
selectAlarmIdsByAsset:命中 idx_asset(asset_type, asset_id, tenant_id),毫秒级(评审 C3)
|
||||
-->
|
||||
<select id="selectAlarmIdsByAsset" resultType="java.lang.Long">
|
||||
SELECT alarm_record_id
|
||||
FROM iot_alarm_propagation
|
||||
WHERE asset_type = #{assetType}
|
||||
AND asset_id = #{assetId}
|
||||
AND tenant_id = #{tenantId}
|
||||
ORDER BY alarm_record_id DESC
|
||||
LIMIT #{offset}, #{limit}
|
||||
</select>
|
||||
|
||||
<!-- countByAsset:与 selectAlarmIdsByAsset 相同过滤条件,命中同一索引 -->
|
||||
<select id="countByAsset" resultType="java.lang.Long">
|
||||
SELECT COUNT(*)
|
||||
FROM iot_alarm_propagation
|
||||
WHERE asset_type = #{assetType}
|
||||
AND asset_id = #{assetId}
|
||||
AND tenant_id = #{tenantId}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -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<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 + " 的传播记录");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 传播成功(无需 stub,void 方法默认 no-op)— lenient 避免部分测试不触发传播时报 UnnecessaryStubbing
|
||||
lenient().doNothing().when(alarmPropagationService).propagate(any(IotAlarmRecordDO.class));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
||||
Reference in New Issue
Block a user