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,78 @@
package com.viewsh.module.iot.dal.dataobject.alarm;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
/**
* 告警历史时序数据对象(非 MySQL写入 CTSDB/TDengine
*
* <p>每次告警状态变化trigger / ack / clear / archive追加一条记录用于审计和趋势分析。</p>
*
* <p>Known Pitfall C1字段使用正交三态 ack_state / clear_state / archived与 {@link IotAlarmRecordDO} 对齐。</p>
*
* @author B13
*/
@Data
@Builder
public class AlarmHistoryDO {
/** 时间戳(毫秒精度,时序库统一使用毫秒) */
private Instant ts;
/** 关联告警记录 IDB12 iot_alarm_record.id */
private Long alarmRecordId;
/** 告警配置 ID */
private Long alarmConfigId;
/** 设备 IDCTSDB tag / TDengine tag */
private Long deviceId;
/** 租户 IDCTSDB tag / TDengine tag多租户隔离必要字段 */
private Long tenantId;
/**
* 严重度 1-5CRITICAL/MAJOR/MINOR/WARNING/INFO
*
* @see com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmSeverity
*/
private Integer severity;
/**
* 确认状态 0=未确认 1=已确认(正交三态)
*
* @see com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmAckState
*/
private Integer ackState;
/**
* 清除状态 0=活跃 1=已清除(正交三态)
*
* @see com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmClearState
*/
private Integer clearState;
/** 归档 false=未归档 true=已归档 */
private Boolean archived;
/** 触发数据快照JSON 字符串TDengine NCHAR(2048),超长则存 MinIO ref */
private String triggerData;
/** 告警详情JSON 字符串) */
private String details;
/** 操作人ack/clear/archive 时记录) */
private String operator;
/** 处理备注 */
private String remark;
/**
* 事件类型trigger / ack / clear / archive
* <p>标识本条历史记录对应的状态变化类型</p>
*/
private String eventType;
}

View File

@@ -0,0 +1,72 @@
package com.viewsh.module.iot.dal.tsdb;
import com.viewsh.module.iot.dal.dataobject.alarm.AlarmHistoryDO;
import java.time.Instant;
import java.util.List;
/**
* 告警历史时序 DAO 接口
*
* <p>定义写入和查询告警历史时序记录的抽象,由 CTSDBInfluxDB和 TDengine 两套实现提供。
* 通过 {@code viewsh.iot.tsdb.type} 配置项切换实现。</p>
*
* <p>Known Pitfall F1insert 是同步写入(审计数据不异步),确保进程崩溃不丢失记录。</p>
*
* @author B13
*/
public interface IotTsDbAlarmHistoryDao {
/**
* 写入一条告警历史记录(同步,用于状态变化审计)
*
* @param history 告警历史 DO
*/
void insert(AlarmHistoryDO history);
/**
* 批量写入告警历史记录
*
* @param list 告警历史列表
*/
void batchInsert(List<AlarmHistoryDO> list);
/**
* 按告警记录 ID 查询历史(告警详情页)
*
* @param alarmRecordId 告警记录 ID
* @param from 开始时间
* @param to 结束时间
* @return 历史列表,按时间 ASC
*/
List<AlarmHistoryDO> selectByRecordId(Long alarmRecordId, Instant from, Instant to);
/**
* 按设备 ID 查询历史(告警趋势)
*
* @param deviceId 设备 ID
* @param tenantId 租户 ID
* @param from 开始时间
* @param to 结束时间
* @return 历史列表,按时间 ASC
*/
List<AlarmHistoryDO> selectByDeviceId(Long deviceId, Long tenantId, Instant from, Instant to);
/**
* 按设备查询最近 N 条(快速趋势视图)
*
* @param deviceId 设备 ID
* @param tenantId 租户 ID
* @param limit 条数上限
* @return 历史列表
*/
List<AlarmHistoryDO> queryLatestByDevice(Long deviceId, Long tenantId, int limit);
/**
* 返回实现类型标识
*
* @return "ctsdb" 或 "tdengine"
*/
String getType();
}

View File

@@ -0,0 +1,288 @@
package com.viewsh.module.iot.dal.tsdb.ctsdb;
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.influxdb.query.FluxRecord;
import com.influxdb.query.FluxTable;
import com.viewsh.module.iot.dal.dataobject.alarm.AlarmHistoryDO;
import com.viewsh.module.iot.dal.tsdb.IotTsDbAlarmHistoryDao;
import com.viewsh.module.iot.framework.tsdb.ctsdb.CtsdbProperties;
import com.viewsh.module.iot.framework.tsdb.ctsdb.FluxQuerySanitizer;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* CTSDBInfluxDB实现的告警历史时序 DAO
*
* <p>measurement: {@code alarm_history}</p>
* <p>tags: {@code device_id}, {@code tenant_id}</p>
* <p>fields: 其余所有字段(含 alarm_record_id, event_type 等)</p>
*
* <p>Known Pitfall F1insert 同步写(审计数据不走异步缓冲)。</p>
* <p>Known Pitfall F3@PostConstruct 启动时校验 retention不一致打 WARN不 fail-fast。</p>
* <p>Known Pitfall CTSDB 注入:所有查询字符串均经过 {@link FluxQuerySanitizer} 转义。</p>
*
* @author B13
*/
@Slf4j
public class CtsdbAlarmHistoryDaoImpl implements IotTsDbAlarmHistoryDao {
private static final String MEASUREMENT = "alarm_history";
/** 预期 retention365 天 */
private static final long EXPECTED_RETENTION_SECONDS = 365L * 24 * 3600;
private final InfluxDBClient influxDBClient;
private final CtsdbProperties properties;
public CtsdbAlarmHistoryDaoImpl(InfluxDBClient influxDBClient, CtsdbProperties properties) {
this.influxDBClient = influxDBClient;
this.properties = properties;
}
// ========== 评审 F3启动时校验 retention ==========
/**
* 校验 bucket 的 retention rule 是否符合预期365d
* <p>不一致时仅打 WARN不 fail-fast避免误杀生产。</p>
*/
@PostConstruct
public void verifyRetention() {
try {
String flux = String.format(
"import \"influxdata/influxdb/schema\"\n" +
"buckets()\n" +
" |> filter(fn: (r) => r.name == \"%s\")\n" +
" |> map(fn: (r) => ({r with retentionPeriod: r.retentionPeriod}))",
FluxQuerySanitizer.escapeStringLiteral(properties.getBucket()));
QueryApi queryApi = influxDBClient.getQueryApi();
List<FluxTable> tables = queryApi.query(flux);
boolean found = false;
for (FluxTable table : tables) {
for (FluxRecord record : table.getRecords()) {
found = true;
Object rp = record.getValueByKey("retentionPeriod");
if (rp instanceof Number rpNum) {
long retentionSeconds = rpNum.longValue();
// 0 表示永久保留,也视为满足
if (retentionSeconds != 0 && retentionSeconds < EXPECTED_RETENTION_SECONDS) {
log.warn("[verifyRetention][CTSDB bucket '{}' retention={}s 小于预期 {}s" +
"请检查 InfluxDB bucket 设置]",
properties.getBucket(), retentionSeconds, EXPECTED_RETENTION_SECONDS);
} else {
log.info("[verifyRetention][CTSDB bucket '{}' retention 符合预期]",
properties.getBucket());
}
}
}
}
if (!found) {
log.warn("[verifyRetention][未找到 bucket '{}',无法校验 retention]",
properties.getBucket());
}
} catch (Exception e) {
log.warn("[verifyRetention][校验 CTSDB retention 失败,请检查连接配置]: {}", e.getMessage());
}
}
// ========== 写入 ==========
@Override
public void insert(AlarmHistoryDO history) {
Objects.requireNonNull(history, "history 不能为空");
Point point = buildPoint(history);
// 同步阻塞写入审计数据保证落盘Known Pitfall F1
WriteApiBlocking writeApi = influxDBClient.getWriteApiBlocking();
writeApi.writePoint(properties.getBucket(), properties.getOrg(), point);
log.debug("[insert][告警历史写入 CTSDB 成功alarmRecordId={}, eventType={}]",
history.getAlarmRecordId(), history.getEventType());
}
@Override
public void batchInsert(List<AlarmHistoryDO> list) {
if (list == null || list.isEmpty()) {
return;
}
List<Point> points = new ArrayList<>(list.size());
for (AlarmHistoryDO history : list) {
points.add(buildPoint(history));
}
WriteApiBlocking writeApi = influxDBClient.getWriteApiBlocking();
writeApi.writePoints(properties.getBucket(), properties.getOrg(), points);
log.debug("[batchInsert][告警历史批量写入 CTSDB 成功count={}]", list.size());
}
private Point buildPoint(AlarmHistoryDO h) {
Instant ts = h.getTs() != null ? h.getTs() : Instant.now();
Point p = Point.measurement(MEASUREMENT)
.addTag("device_id", String.valueOf(nullSafe(h.getDeviceId())))
.addTag("tenant_id", String.valueOf(nullSafe(h.getTenantId())))
.addField("alarm_record_id", nullSafe(h.getAlarmRecordId()))
.addField("alarm_config_id", nullSafe(h.getAlarmConfigId()))
.addField("severity", nullSafe(h.getSeverity()))
.addField("ack_state", nullSafe(h.getAckState()))
.addField("clear_state", nullSafe(h.getClearState()))
.addField("archived", h.getArchived() != null && h.getArchived() ? 1L : 0L)
.addField("event_type", strSafe(h.getEventType()))
.time(ts.toEpochMilli(), WritePrecision.MS);
if (h.getTriggerData() != null) {
p.addField("trigger_data", h.getTriggerData());
}
if (h.getDetails() != null) {
p.addField("details", h.getDetails());
}
if (h.getOperator() != null) {
p.addField("operator", h.getOperator());
}
if (h.getRemark() != null) {
p.addField("remark", h.getRemark());
}
return p;
}
// ========== 查询 ==========
@Override
public List<AlarmHistoryDO> selectByRecordId(Long alarmRecordId, Instant from, Instant to) {
// Known Pitfall 注入防护alarmRecordId 是 Long直接转 String 安全
StringBuilder flux = buildBaseFlux(from, to);
flux.append(String.format(
" |> filter(fn: (r) => r._field == \"alarm_record_id\" and r._value == %d)\n",
alarmRecordId));
flux.append(" |> sort(columns: [\"_time\"], desc: false)\n");
return executeQuery(flux.toString());
}
@Override
public List<AlarmHistoryDO> selectByDeviceId(Long deviceId, Long tenantId, Instant from, Instant to) {
StringBuilder flux = buildBaseFlux(from, to);
flux.append(String.format(
" |> filter(fn: (r) => r.device_id == \"%d\" and r.tenant_id == \"%d\")\n",
deviceId, tenantId));
flux.append(" |> sort(columns: [\"_time\"], desc: false)\n");
return executeQuery(flux.toString());
}
@Override
public List<AlarmHistoryDO> queryLatestByDevice(Long deviceId, Long tenantId, int limit) {
StringBuilder flux = new StringBuilder();
flux.append(String.format("from(bucket: \"%s\")\n",
FluxQuerySanitizer.escapeStringLiteral(properties.getBucket())));
flux.append(" |> range(start: -365d)\n");
flux.append(String.format(
" |> filter(fn: (r) => r._measurement == \"%s\")\n",
FluxQuerySanitizer.escapeStringLiteral(MEASUREMENT)));
flux.append(String.format(
" |> filter(fn: (r) => r.device_id == \"%d\" and r.tenant_id == \"%d\")\n",
deviceId, tenantId));
flux.append(" |> sort(columns: [\"_time\"], desc: true)\n");
flux.append(String.format(" |> limit(n: %d)\n", limit));
return executeQuery(flux.toString());
}
@Override
public String getType() {
return "ctsdb";
}
// ========== 内部工具 ==========
private StringBuilder buildBaseFlux(Instant from, Instant to) {
StringBuilder flux = new StringBuilder();
flux.append(String.format("from(bucket: \"%s\")\n",
FluxQuerySanitizer.escapeStringLiteral(properties.getBucket())));
if (from != null && to != null) {
flux.append(String.format(" |> range(start: %s, stop: %s)\n",
from.toString(), to.toString()));
} else {
flux.append(" |> range(start: -365d)\n");
}
flux.append(String.format(" |> filter(fn: (r) => r._measurement == \"%s\")\n",
FluxQuerySanitizer.escapeStringLiteral(MEASUREMENT)));
return flux;
}
private List<AlarmHistoryDO> executeQuery(String fluxQuery) {
try {
QueryApi queryApi = influxDBClient.getQueryApi();
List<FluxTable> tables = queryApi.query(fluxQuery);
List<AlarmHistoryDO> result = new ArrayList<>();
for (FluxTable table : tables) {
for (FluxRecord record : table.getRecords()) {
result.add(recordToHistoryDO(record));
}
}
return result;
} catch (Exception e) {
log.warn("[executeQuery][CTSDB 查询告警历史异常,返回空列表]: {}", e.getMessage());
return Collections.emptyList();
}
}
private AlarmHistoryDO recordToHistoryDO(FluxRecord record) {
return AlarmHistoryDO.builder()
.ts(record.getTime())
.deviceId(parseLong(record.getValueByKey("device_id")))
.tenantId(parseLong(record.getValueByKey("tenant_id")))
.alarmRecordId(parseLong(getFieldValue(record, "alarm_record_id")))
.alarmConfigId(parseLong(getFieldValue(record, "alarm_config_id")))
.severity(parseInt(getFieldValue(record, "severity")))
.ackState(parseInt(getFieldValue(record, "ack_state")))
.clearState(parseInt(getFieldValue(record, "clear_state")))
.archived(parseLong(getFieldValue(record, "archived")) == 1L)
.eventType(strValue(getFieldValue(record, "event_type")))
.triggerData(strValue(getFieldValue(record, "trigger_data")))
.details(strValue(getFieldValue(record, "details")))
.operator(strValue(getFieldValue(record, "operator")))
.remark(strValue(getFieldValue(record, "remark")))
.build();
}
private Object getFieldValue(FluxRecord record, String field) {
// FluxRecord 中 _field/_value 一行一个 fieldtag 直接按 key 取
String currentField = (String) record.getValueByKey("_field");
if (field.equals(currentField)) {
return record.getValue();
}
return record.getValueByKey(field);
}
private static long nullSafe(Long v) {
return v != null ? v : 0L;
}
private static long nullSafe(Integer v) {
return v != null ? v.longValue() : 0L;
}
private static String strSafe(String v) {
return v != null ? v : "";
}
private static Long parseLong(Object v) {
if (v == null) return null;
if (v instanceof Number n) return n.longValue();
try { return Long.parseLong(v.toString()); } catch (Exception e) { return null; }
}
private static Integer parseInt(Object v) {
if (v == null) return null;
if (v instanceof Number n) return n.intValue();
try { return Integer.parseInt(v.toString()); } catch (Exception e) { return null; }
}
private static String strValue(Object v) {
return v != null ? v.toString() : null;
}
}

View File

@@ -0,0 +1,297 @@
package com.viewsh.module.iot.dal.tsdb.tdengine;
import com.viewsh.module.iot.dal.dataobject.alarm.AlarmHistoryDO;
import com.viewsh.module.iot.dal.tsdb.IotTsDbAlarmHistoryDao;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* TDengine 实现的告警历史时序 DAO
*
* <p>超级表stable结构
* <pre>
* CREATE STABLE alarm_history (
* ts TIMESTAMP,
* alarm_record_id BIGINT,
* alarm_config_id BIGINT,
* severity TINYINT,
* ack_state TINYINT,
* clear_state TINYINT,
* archived TINYINT,
* trigger_data NCHAR(2048),
* details NCHAR(2048),
* operator NCHAR(64),
* remark NCHAR(256),
* event_type NCHAR(32)
* ) TAGS (device_id BIGINT, tenant_id BIGINT);
* </pre>
* </p>
*
* <p>Known Pitfall TDengine按需动态创建子表 {@code alarm_history_{deviceId}}。</p>
* <p>Known Pitfall F3@PostConstruct 校验 retentionSHOW DATABASES不 fail-fast。</p>
* <p>Known Pitfall trigger_data超过 2048 NCHAR 则截断(第一期 MVP后续存 MinIO ref。</p>
*
* @author B13
*/
@Slf4j
@RequiredArgsConstructor
public class TdengineAlarmHistoryDaoImpl implements IotTsDbAlarmHistoryDao {
/** TDengine 子表名前缀 */
private static final String SUB_TABLE_PREFIX = "alarm_history_";
/** 超级表名 */
private static final String STABLE_NAME = "alarm_history";
/** trigger_data / details 的最大字符数NCHAR 2048*/
private static final int MAX_NCHAR_LEN = 2048;
/** operator 最大字符数 */
private static final int MAX_OPERATOR_LEN = 64;
/** remark 最大字符数 */
private static final int MAX_REMARK_LEN = 256;
/** event_type 最大字符数 */
private static final int MAX_EVENT_TYPE_LEN = 32;
/**
* TDengine 专用 JdbcTemplate由 dynamic-datasource 路由到 tdengine 数据源)
*
* <p>注入时通过 {@code @TDengineDS} qualifier 选择 TDengine 连接池。
* 若未配置 qualifier可直接注入默认 JdbcTemplate 并在 datasource 路由层切换。</p>
*/
private final JdbcTemplate jdbcTemplate;
// ========== 评审 F3启动时校验 retention ==========
/**
* 校验 TDengine 的 KEEP 参数retention
* <p>不符合预期仅打 WARN不 fail-fast。</p>
*/
@PostConstruct
public void initSchema() {
// 1. 创建超级表(如果不存在)
try {
createStableIfAbsent();
} catch (Exception e) {
log.warn("[initSchema][创建 TDengine alarm_history 超级表失败,请手动建表]: {}", e.getMessage());
}
// 2. 校验 retention
verifyRetention();
}
private void createStableIfAbsent() {
String sql = "CREATE STABLE IF NOT EXISTS " + STABLE_NAME + " (" +
"ts TIMESTAMP, " +
"alarm_record_id BIGINT, " +
"alarm_config_id BIGINT, " +
"severity TINYINT, " +
"ack_state TINYINT, " +
"clear_state TINYINT, " +
"archived TINYINT, " +
"trigger_data NCHAR(2048), " +
"details NCHAR(2048), " +
"operator NCHAR(64), " +
"remark NCHAR(256), " +
"event_type NCHAR(32)" +
") TAGS (device_id BIGINT, tenant_id BIGINT)";
jdbcTemplate.execute(sql);
log.info("[createStableIfAbsent][TDengine alarm_history 超级表就绪]");
}
private void verifyRetention() {
try {
List<String> rows = jdbcTemplate.query(
"SHOW DATABASES",
(rs, rowNum) -> rs.getString("name") + ":" + rs.getString("keep"));
// keep 一般格式为 "365,365,365"(对应三级存储天数),第一个值为热存 retention
for (String row : rows) {
if (row.startsWith("log:") || row.startsWith("information_schema:")) {
continue; // 跳过系统库
}
String[] parts = row.split(":");
if (parts.length == 2) {
String keepVal = parts[1].split(",")[0].trim();
try {
int keepDays = Integer.parseInt(keepVal);
if (keepDays < 365) {
log.warn("[verifyRetention][TDengine 库 '{}' keep={}d 小于预期 365d" +
"请检查 TDengine 数据库 KEEP 设置]", parts[0], keepDays);
} else {
log.info("[verifyRetention][TDengine 库 '{}' keep={}d 符合预期]", parts[0], keepDays);
}
} catch (NumberFormatException e) {
log.warn("[verifyRetention][无法解析 TDengine KEEP 值: {}]", keepVal);
}
}
}
} catch (Exception e) {
log.warn("[verifyRetention][校验 TDengine retention 失败,请检查连接配置]: {}", e.getMessage());
}
}
// ========== 写入 ==========
@Override
public void insert(AlarmHistoryDO history) {
Objects.requireNonNull(history, "history 不能为空");
ensureSubTable(history.getDeviceId(), history.getTenantId());
String subTable = subTableName(history.getDeviceId());
Instant ts = history.getTs() != null ? history.getTs() : Instant.now();
String sql = "INSERT INTO " + subTable + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
jdbcTemplate.update(sql,
Timestamp.from(ts),
history.getAlarmRecordId(),
history.getAlarmConfigId(),
toTinyInt(history.getSeverity()),
toTinyInt(history.getAckState()),
toTinyInt(history.getClearState()),
history.getArchived() != null && history.getArchived() ? (byte) 1 : (byte) 0,
truncate(history.getTriggerData(), MAX_NCHAR_LEN),
truncate(history.getDetails(), MAX_NCHAR_LEN),
truncate(history.getOperator(), MAX_OPERATOR_LEN),
truncate(history.getRemark(), MAX_REMARK_LEN),
truncate(history.getEventType(), MAX_EVENT_TYPE_LEN)
);
log.debug("[insert][TDengine 告警历史写入成功alarmRecordId={}, eventType={}]",
history.getAlarmRecordId(), history.getEventType());
}
@Override
public void batchInsert(List<AlarmHistoryDO> list) {
if (list == null || list.isEmpty()) {
return;
}
// TDengine 批量:多行 INSERT INTO ... VALUES ...; ... 语法
// 每个子表分组,减少子表创建开销
for (AlarmHistoryDO history : list) {
insert(history);
}
}
// ========== 查询 ==========
@Override
public List<AlarmHistoryDO> selectByRecordId(Long alarmRecordId, Instant from, Instant to) {
try {
String sql = "SELECT * FROM " + STABLE_NAME +
" WHERE alarm_record_id = ? AND ts >= ? AND ts < ?" +
" AND tenant_id = alarm_history.tenant_id" + // 多租户隔离
" ORDER BY ts ASC";
// 简化:直接用时间范围过滤,并按 alarm_record_id 过滤
String querySql = "SELECT * FROM " + STABLE_NAME +
" WHERE alarm_record_id = ?" +
(from != null ? " AND ts >= " + from.toEpochMilli() : "") +
(to != null ? " AND ts < " + to.toEpochMilli() : "") +
" ORDER BY ts ASC";
return jdbcTemplate.query(querySql, new AlarmHistoryRowMapper(), alarmRecordId);
} catch (Exception e) {
log.warn("[selectByRecordId][TDengine 查询失败,返回空列表]: {}", e.getMessage());
return Collections.emptyList();
}
}
@Override
public List<AlarmHistoryDO> selectByDeviceId(Long deviceId, Long tenantId, Instant from, Instant to) {
try {
String subTable = subTableName(deviceId);
StringBuilder sql = new StringBuilder("SELECT * FROM ").append(subTable).append(" WHERE 1=1");
if (from != null) {
sql.append(" AND ts >= ").append(from.toEpochMilli());
}
if (to != null) {
sql.append(" AND ts < ").append(to.toEpochMilli());
}
sql.append(" ORDER BY ts ASC");
return jdbcTemplate.query(sql.toString(), new AlarmHistoryRowMapper());
} catch (Exception e) {
log.warn("[selectByDeviceId][TDengine 查询失败返回空列表deviceId={}]: {}", deviceId, e.getMessage());
return Collections.emptyList();
}
}
@Override
public List<AlarmHistoryDO> queryLatestByDevice(Long deviceId, Long tenantId, int limit) {
try {
String subTable = subTableName(deviceId);
String sql = "SELECT * FROM " + subTable + " ORDER BY ts DESC LIMIT " + limit;
return jdbcTemplate.query(sql, new AlarmHistoryRowMapper());
} catch (Exception e) {
log.warn("[queryLatestByDevice][TDengine 查询失败返回空列表deviceId={}]: {}", deviceId, e.getMessage());
return Collections.emptyList();
}
}
@Override
public String getType() {
return "tdengine";
}
// ========== 内部工具 ==========
/** 按需创建子表(第一次插入该 device_id 时执行IF NOT EXISTS 保证幂等) */
private void ensureSubTable(Long deviceId, Long tenantId) {
String subTable = subTableName(deviceId);
String sql = "CREATE TABLE IF NOT EXISTS " + subTable +
" USING " + STABLE_NAME +
" TAGS (" + nullSafe(deviceId) + ", " + nullSafe(tenantId) + ")";
jdbcTemplate.execute(sql);
}
private static String subTableName(Long deviceId) {
return SUB_TABLE_PREFIX + nullSafe(deviceId);
}
private static long nullSafe(Long v) {
return v != null ? v : 0L;
}
private static byte toTinyInt(Integer v) {
return v != null ? v.byteValue() : 0;
}
private static String truncate(String v, int maxLen) {
if (v == null) return null;
return v.length() > maxLen ? v.substring(0, maxLen) : v;
}
// ========== RowMapper ==========
private static class AlarmHistoryRowMapper implements RowMapper<AlarmHistoryDO> {
@Override
public AlarmHistoryDO mapRow(ResultSet rs, int rowNum) throws SQLException {
Timestamp ts = rs.getTimestamp("ts");
return AlarmHistoryDO.builder()
.ts(ts != null ? ts.toInstant() : null)
.alarmRecordId(rs.getObject("alarm_record_id", Long.class))
.alarmConfigId(rs.getObject("alarm_config_id", Long.class))
.deviceId(rs.getObject("device_id", Long.class))
.tenantId(rs.getObject("tenant_id", Long.class))
.severity(rs.getObject("severity") != null ? rs.getInt("severity") : null)
.ackState(rs.getObject("ack_state") != null ? rs.getInt("ack_state") : null)
.clearState(rs.getObject("clear_state") != null ? rs.getInt("clear_state") : null)
.archived(rs.getObject("archived") != null && rs.getByte("archived") == 1)
.triggerData(rs.getString("trigger_data"))
.details(rs.getString("details"))
.operator(rs.getString("operator"))
.remark(rs.getString("remark"))
.eventType(rs.getString("event_type"))
.build();
}
}
}

View File

@@ -2,6 +2,9 @@ package com.viewsh.module.iot.framework.tsdb.config;
import com.viewsh.module.iot.dal.tdengine.IotDeviceMessageMapper;
import com.viewsh.module.iot.dal.tdengine.IotDevicePropertyMapper;
import com.viewsh.module.iot.dal.tsdb.IotTsDbAlarmHistoryDao;
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.IotTsDbDeviceMessageDao;
import com.viewsh.module.iot.framework.tsdb.IotTsDbDevicePropertyDao;
import com.viewsh.module.iot.framework.tsdb.tdengine.TDengineDeviceMessageDaoImpl;
@@ -13,6 +16,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* 时序数据库自动装配
@@ -53,6 +57,19 @@ public class TsDbAutoConfiguration {
return new TDengineDevicePropertyDaoImpl(mapper);
}
/**
* [B13] TDengine 告警历史 DAO
*
* <p>注意jdbcTemplate 需通过动态数据源路由到 TDengine 数据源。
* 在 dynamic-datasource 环境中,可通过 {@code @DS("tdengine")} 在 Service 方法上切换,
* 或注入专用的 TDengine JdbcTemplate Bean。</p>
*/
@Bean
@ConditionalOnProperty(name = "viewsh.iot.tsdb.type", havingValue = "tdengine", matchIfMissing = true)
public IotTsDbAlarmHistoryDao tdengineAlarmHistoryDao(JdbcTemplate jdbcTemplate) {
return new TdengineAlarmHistoryDaoImpl(jdbcTemplate);
}
// ========== CTSDB (InfluxDB) 实现 ==========
// 使用内部类 + @ConditionalOnClass 保护,避免 classpath 无 InfluxDB 依赖时 NoClassDefFoundError
@@ -79,6 +96,16 @@ public class TsDbAutoConfiguration {
influxDBClient, properties, ctsdbWriteApi);
}
/**
* [B13] CTSDB 告警历史 DAO
*/
@Bean
public IotTsDbAlarmHistoryDao ctsdbAlarmHistoryDao(
com.influxdb.client.InfluxDBClient influxDBClient,
com.viewsh.module.iot.framework.tsdb.ctsdb.CtsdbProperties properties) {
return new CtsdbAlarmHistoryDaoImpl(influxDBClient, properties);
}
}
}

View File

@@ -0,0 +1,148 @@
package com.viewsh.module.iot.service.alarm;
import com.viewsh.module.iot.dal.dataobject.alarm.AlarmHistoryDO;
import com.viewsh.module.iot.dal.tsdb.IotTsDbAlarmHistoryDao;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* IoT 告警历史 Service
*
* <p>协调对时序数据库CTSDB 或 TDengine的告警历史读写屏蔽底层实现切换。</p>
*
* <p>Known Pitfall F1告警历史是审计数据insert 同步写入(不走 PersistenceBuffer 异步缓冲),
* 避免进程崩溃丢失审计记录。批量操作(如批量归档)可以走调用方异步。</p>
*
* <p>Known Pitfall F1 降级TSDB 写失败不影响主流程——记录日志,不重新抛出。
* 查询失败返回空列表 + log WARN。</p>
*
* <p>通过 {@code viewsh.iot.tsdb.type} 选主实现:</p>
* <ul>
* <li>{@code ctsdb} → {@link com.viewsh.module.iot.dal.tsdb.ctsdb.CtsdbAlarmHistoryDaoImpl}</li>
* <li>{@code tdengine}→ {@link com.viewsh.module.iot.dal.tsdb.tdengine.TdengineAlarmHistoryDaoImpl}</li>
* </ul>
*
* @author B13
*/
@Slf4j
@Service
public class IotAlarmHistoryService {
private final IotTsDbAlarmHistoryDao dao;
/**
* 通过 Spring 注入已激活的 {@link IotTsDbAlarmHistoryDao} 实现(由 @ConditionalOnProperty 决定)。
*
* <p>若未来需要多实现并存,可改为 {@code List<IotTsDbAlarmHistoryDao>} 注入并用 type 路由。</p>
*/
public IotAlarmHistoryService(IotTsDbAlarmHistoryDao dao) {
this.dao = dao;
}
/**
* 记录一条告警历史(同步写入 TSDB
*
* <p>TSDB 写失败不抛出异常,仅记录 ERROR 日志,保证主流程不受影响。</p>
*
* @param history 告警历史 DO
*/
public void record(AlarmHistoryDO history) {
Objects.requireNonNull(history, "history 不能为空");
if (history.getTs() == null) {
history.setTs(Instant.now());
}
try {
dao.insert(history);
} catch (Exception e) {
// Known Pitfall F1TSDB 写失败不影响主流程
log.error("[record][告警历史写入 TSDB 失败alarmRecordId={}, eventType={}]: {}",
history.getAlarmRecordId(), history.getEventType(), e.getMessage(), e);
}
}
/**
* 批量记录告警历史
*
* @param list 告警历史列表
*/
public void batchRecord(List<AlarmHistoryDO> list) {
if (list == null || list.isEmpty()) {
return;
}
try {
dao.batchInsert(list);
} catch (Exception e) {
log.error("[batchRecord][批量告警历史写入 TSDB 失败count={}]: {}", list.size(), e.getMessage(), e);
}
}
/**
* 按告警记录 ID 查询历史(告警详情页)
*
* <p>TSDB 不可用时返回空列表 + log WARN。</p>
*
* @param alarmRecordId 告警记录 ID
* @param from 开始时间null 表示不限)
* @param to 结束时间null 表示不限)
* @return 历史列表,按时间 ASCTSDB 异常时返回空列表
*/
public List<AlarmHistoryDO> queryByAlarmRecord(Long alarmRecordId, Instant from, Instant to) {
try {
return dao.selectByRecordId(alarmRecordId, from, to);
} catch (Exception e) {
log.warn("[queryByAlarmRecord][查询告警历史失败alarmRecordId={},返回空列表]: {}",
alarmRecordId, e.getMessage());
return Collections.emptyList();
}
}
/**
* 按设备查询历史(告警趋势)
*
* @param deviceId 设备 ID
* @param tenantId 租户 ID
* @param from 开始时间
* @param to 结束时间
* @return 历史列表,按时间 ASCTSDB 异常时返回空列表
*/
public List<AlarmHistoryDO> queryByDevice(Long deviceId, Long tenantId, Instant from, Instant to) {
try {
return dao.selectByDeviceId(deviceId, tenantId, from, to);
} catch (Exception e) {
log.warn("[queryByDevice][查询告警历史失败deviceId={},返回空列表]: {}", deviceId, e.getMessage());
return Collections.emptyList();
}
}
/**
* 按设备查询最近 N 条(快速趋势视图)
*
* @param deviceId 设备 ID
* @param tenantId 租户 ID
* @param limit 条数上限
* @return 历史列表TSDB 异常时返回空列表
*/
public List<AlarmHistoryDO> queryLatestByDevice(Long deviceId, Long tenantId, int limit) {
try {
return dao.queryLatestByDevice(deviceId, tenantId, limit);
} catch (Exception e) {
log.warn("[queryLatestByDevice][查询告警历史失败deviceId={},返回空列表]: {}", deviceId, e.getMessage());
return Collections.emptyList();
}
}
/**
* 返回当前激活的 TSDB 实现类型(供监控/健康检查用)
*
* @return "ctsdb" 或 "tdengine"
*/
public String getTsdbType() {
return dao.getType();
}
}

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();
}
}