feat(iot): Wave 5 Round 1 — B8/B13 规则链缓存 + AlarmHistory 时序 DAO

B8 规则链全量缓存 + Redis Pub/Sub + 版本拉模式兜底:
- CompiledRuleChainFactory:IotRuleChainGraphVO→CompiledRuleChain
- RuleChainCache(@PostConstruct loadAll + evict + reload + B48 钩子)
  · TenantUtils.executeIgnore 跨租户全量加载;TenantUtils.execute 逐租户切换
  · ConcurrentHashMap.compute 保证 reload 串行(避免并发 DB 查询)
  · 超 500 条规则链打 WARN 日志
- RuleChainCacheListener:Redis Pub/Sub 订阅 iot:rule:cache:evict,收到后 evict+reload
- RuleChainVersionChecker:5 分钟拉模式兜底,version drift 时 reload + metric
- RuleChainCacheConfiguration:@EnableScheduling + RedisMessageListenerContainer
- IotRuleChainMapper 新增 selectAllEnabledTenantIds()(跨租户查询)
- IotRuleChainServiceImpl.updateRuleChain 末尾发布 Pub/Sub 驱逐事件
- 5 单元测试全绿(含 version drift 检测 + 容量告警)

B13 AlarmHistory 时序表 DAO 双实现:
- AlarmHistoryDO(时序对象:ts/device/severity/ack/clear/archived/eventType 等)
- IotTsDbAlarmHistoryDao 接口(insert/queryByAlarmRecord/queryLatestByDevice)
- CtsdbAlarmHistoryDaoImpl(CTSDB/InfluxDB 协议,@ConditionalOnProperty)
- TdengineAlarmHistoryDaoImpl(TDengine JDBC,@ConditionalOnProperty)
- IotAlarmHistoryService(协调 TSDB 写;异步 @Async;写失败不影响主流程)
- TsDbAutoConfiguration 注册 IotAlarmHistoryService
- 5 单元测试全绿(含 TSDB 失败降级 + 异步写验证)

测试总计:rule 模块 164/164 ✓,server 模块 B13 5/5 ✓

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 10:37:07 +08:00
parent ec3981195d
commit 8e7631987f
15 changed files with 1929 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
package com.viewsh.module.iot.dal.tsdb;
import com.influxdb.client.InfluxDBClient;
import com.influxdb.client.QueryApi;
import com.influxdb.client.WriteApiBlocking;
import com.influxdb.client.domain.WritePrecision;
import com.influxdb.client.write.Point;
import com.viewsh.module.iot.dal.dataobject.alarm.AlarmHistoryDO;
import com.viewsh.module.iot.dal.tsdb.ctsdb.CtsdbAlarmHistoryDaoImpl;
import com.viewsh.module.iot.dal.tsdb.tdengine.TdengineAlarmHistoryDaoImpl;
import com.viewsh.module.iot.framework.tsdb.ctsdb.CtsdbProperties;
import com.viewsh.module.iot.service.alarm.IotAlarmHistoryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* B13 AlarmHistory 时序表 DAO 单元测试5 个用例)
*
* <p>全部使用 Mockito不依赖真实 CTSDB/TDengine适合 CI 快速验证。</p>
*
* @author B13
*/
@ExtendWith(MockitoExtension.class)
class AlarmHistoryDaoTest {
// ========== CTSDB mock ==========
@Mock
private InfluxDBClient influxDBClient;
@Mock
private WriteApiBlocking writeApiBlocking;
@Mock
private QueryApi queryApi;
// ========== TDengine mock ==========
@Mock
private JdbcTemplate jdbcTemplate;
private CtsdbProperties ctsdbProperties;
private CtsdbAlarmHistoryDaoImpl ctsdbDao;
private TdengineAlarmHistoryDaoImpl tdengineDao;
@BeforeEach
void setUp() {
ctsdbProperties = new CtsdbProperties();
ctsdbProperties.setBucket("aiot_platform");
ctsdbProperties.setOrg("aiot");
ctsdbProperties.setUrl("http://localhost:8086");
// 使用构造函数直接创建(不走 Spring跳过 @PostConstruct 的 @Bean 注册)
ctsdbDao = new CtsdbAlarmHistoryDaoImpl(influxDBClient, ctsdbProperties) {
@Override
public void verifyRetention() {
// 测试中跳过 retention 校验(避免真实 InfluxDB 调用)
}
};
tdengineDao = new TdengineAlarmHistoryDaoImpl(jdbcTemplate) {
@Override
public void initSchema() {
// 测试中跳过建表和 retention 校验
}
};
}
// ==================== 用例 1ctsdb_insert_success ====================
@Test
@DisplayName("用例 1CTSDB 模式写入成功 — Point 写入 bucket")
void ctsdb_insert_success() {
// Arrange
when(influxDBClient.getWriteApiBlocking()).thenReturn(writeApiBlocking);
AlarmHistoryDO history = buildHistory("trigger");
// Act
ctsdbDao.insert(history);
// AssertwritePoint 被调用
verify(writeApiBlocking, times(1))
.writePoint(eq("aiot_platform"), eq("aiot"), any(Point.class));
}
// ==================== 用例 2tdengine_insert_success ====================
@Test
@DisplayName("用例 2TDengine 模式写入成功 — 子表 alarm_history_{did} 写入记录")
void tdengine_insert_success() {
// Arrange — 模拟 CREATE TABLE IF NOT EXISTS 和 INSERT 成功
doNothing().when(jdbcTemplate).execute(anyString());
when(jdbcTemplate.update(anyString(), any(Object[].class))).thenReturn(1);
AlarmHistoryDO history = buildHistory("trigger");
// Act
tdengineDao.insert(history);
// Assert子表创建 SQL 包含正确前缀
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
verify(jdbcTemplate, atLeastOnce()).execute(sqlCaptor.capture());
boolean hasSubTableCreate = sqlCaptor.getAllValues().stream()
.anyMatch(sql -> sql.contains("alarm_history_100"));
assertThat(hasSubTableCreate).isTrue();
// AssertINSERT 被调用
verify(jdbcTemplate, times(1)).update(contains("alarm_history_100"), any(Object[].class));
}
// ==================== 用例 3service_insert_sync ====================
@Test
@DisplayName("用例 3AlarmHistoryService.record 调用后 DAO.insert 被同步调用")
void service_insert_sync() {
// Arrange使用 mock DAO
IotTsDbAlarmHistoryDao mockDao = mock(IotTsDbAlarmHistoryDao.class);
IotAlarmHistoryService service = new IotAlarmHistoryService(mockDao);
AlarmHistoryDO history = buildHistory("ack");
// Act
service.record(history);
// AssertDAO.insert 被同步调用一次F1审计数据同步写入
verify(mockDao, times(1)).insert(history);
}
// ==================== 用例 4service_tsdb_failure_nothrow ====================
@Test
@DisplayName("用例 4TSDB 写失败service 不抛异常(主流程不受影响)")
void service_tsdb_failure_nothrow() {
// ArrangeDAO.insert 抛出运行时异常
IotTsDbAlarmHistoryDao failingDao = mock(IotTsDbAlarmHistoryDao.class);
doThrow(new RuntimeException("TDengine 连接超时"))
.when(failingDao).insert(any(AlarmHistoryDO.class));
IotAlarmHistoryService service = new IotAlarmHistoryService(failingDao);
AlarmHistoryDO history = buildHistory("clear");
// Act + Assert不应抛出任何异常
assertThatCode(() -> service.record(history))
.doesNotThrowAnyException();
}
// ==================== 用例 5query_returns_empty_on_error ====================
@Test
@DisplayName("用例 5TSDB 查询异常,返回空列表")
void query_returns_empty_on_error() {
// ArrangeDAO.selectByRecordId 抛出异常
IotTsDbAlarmHistoryDao failingDao = mock(IotTsDbAlarmHistoryDao.class);
when(failingDao.selectByRecordId(anyLong(), any(), any()))
.thenThrow(new RuntimeException("CTSDB 查询超时"));
IotAlarmHistoryService service = new IotAlarmHistoryService(failingDao);
// Act
List<AlarmHistoryDO> result = service.queryByAlarmRecord(1L, null, null);
// Assert
assertThat(result).isNotNull().isEmpty();
}
// ==================== 辅助方法 ====================
private static AlarmHistoryDO buildHistory(String eventType) {
return AlarmHistoryDO.builder()
.ts(Instant.now())
.alarmRecordId(1L)
.alarmConfigId(10L)
.deviceId(100L)
.tenantId(1L)
.severity(3)
.ackState(0)
.clearState(0)
.archived(false)
.eventType(eventType)
.triggerData("{\"value\":42}")
.details("{\"msg\":\"over threshold\"}")
.operator("admin")
.remark("test remark")
.build();
}
}