diff --git a/sql/iot/V2.0.4__iot_alarm_record.sql b/sql/iot/V2.0.4__iot_alarm_record.sql new file mode 100644 index 00000000..a237cf39 --- /dev/null +++ b/sql/iot/V2.0.4__iot_alarm_record.sql @@ -0,0 +1,64 @@ +-- [B12] iot_alarm_record + iot_alarm_propagation 正交状态机 +-- 版本: V2.0.4(任务卡命名 V2.0.0,但已有 V2.0.1-V2.0.3 故递增至 V2.0.4) +-- 评审修正: +-- C1:ack_state + clear_state + archived 三字段正交替代线性 4 枚举 +-- C2:联合 UK 替代 MD5 哈希列 +-- C3:iot_alarm_propagation 关联表替代 propagated_to JSON + +CREATE TABLE IF NOT EXISTS iot_alarm_record ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + alarm_config_id BIGINT NOT NULL COMMENT '告警配置 ID', + alarm_name VARCHAR(128) COMMENT '告警名称(冗余 config.name,避免反查)', + severity TINYINT NOT NULL COMMENT '严重度 1-5:CRITICAL/MAJOR/MINOR/WARNING/INFO', + + -- 【评审 C1】正交三字段替代线性状态机 + ack_state TINYINT NOT NULL DEFAULT 0 COMMENT '确认状态 0=未确认 1=已确认', + clear_state TINYINT NOT NULL DEFAULT 0 COMMENT '清除状态 0=活跃 1=已清除', + archived TINYINT NOT NULL DEFAULT 0 COMMENT '归档 0=未归档 1=已归档(归档后不可修改)', + + device_id BIGINT NOT NULL COMMENT '设备编号', + product_id BIGINT COMMENT '产品编号(冗余)', + subsystem_id BIGINT COMMENT '归属子系统(评审 R2)', + rule_chain_id BIGINT COMMENT '触发此告警的 v2 规则链', + scene_rule_id BIGINT COMMENT '触发此告警的 v1 场景规则(灰度期兼容)', + + start_ts DATETIME NOT NULL COMMENT '首次触发时间', + end_ts DATETIME COMMENT '最近触发时间', + clear_ts DATETIME COMMENT '清除时间', + ack_ts DATETIME COMMENT '确认时间', + archive_ts DATETIME COMMENT '归档时间', + + details JSON COMMENT '告警详情(可累积)', + trigger_count INT NOT NULL DEFAULT 1 COMMENT '持续触发次数', + process_remark TEXT COMMENT '处理备注', + + tenant_id BIGINT NOT NULL COMMENT '租户编号', + creator VARCHAR(64) COMMENT '创建者', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updater VARCHAR(64) COMMENT '更新者', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted BIT NOT NULL DEFAULT b'0' COMMENT '是否删除', + + -- 【评审 C2】联合 UK 替代 MD5 哈希列 + UNIQUE KEY uk_device_config (device_id, alarm_config_id, tenant_id, deleted), + + -- 查询优化索引(状态组合 + 租户 + 时间) + INDEX idx_state (tenant_id, clear_state, ack_state, archived, start_ts), + INDEX idx_severity (tenant_id, severity, start_ts), + INDEX idx_subsystem (tenant_id, subsystem_id, start_ts), + INDEX idx_rule_chain(rule_chain_id), + INDEX idx_device (device_id, start_ts) +) COMMENT = '告警记录(v2.0 正交状态机)'; + +-- 【评审 C3】告警传播关联表(替代 propagated_to JSON 高频查询) +CREATE TABLE IF NOT EXISTS iot_alarm_propagation ( + alarm_record_id BIGINT NOT NULL COMMENT '告警记录 ID', + asset_type VARCHAR(32) NOT NULL COMMENT 'SUBSYSTEM / FLOOR / BUILDING', + asset_id BIGINT NOT NULL COMMENT '资产 ID', + asset_name VARCHAR(128) COMMENT '资产名称冗余', + tenant_id BIGINT NOT NULL COMMENT '租户编号', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (alarm_record_id, asset_type, asset_id), + INDEX idx_asset (asset_type, asset_id, tenant_id), + INDEX idx_record (alarm_record_id) +) COMMENT = '告警沿资产层级传播关联表(评审 C3)'; diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/enums/ErrorCodeConstants.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/enums/ErrorCodeConstants.java index bf999880..4c92a083 100644 --- a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/enums/ErrorCodeConstants.java +++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/enums/ErrorCodeConstants.java @@ -82,6 +82,12 @@ public interface ErrorCodeConstants { // ========== IoT 告警记录 1-050-014-000 ========== ErrorCode ALERT_RECORD_NOT_EXISTS = new ErrorCode(1_050_014_000, "IoT 告警记录不存在"); + // ========== IoT 告警记录 v2(正交状态机)1-050-021-000 ========== + ErrorCode ALARM_RECORD_NOT_EXISTS = new ErrorCode(1_050_021_000, "告警记录不存在"); + ErrorCode ALARM_ALREADY_ARCHIVED = new ErrorCode(1_050_021_001, "告警已归档,不允许修改"); + ErrorCode ALARM_SEVERITY_INVALID = new ErrorCode(1_050_021_002, "告警严重度非法(应为 1-5)"); + ErrorCode ALARM_TRIGGER_REQUIRED_FIELD = new ErrorCode(1_050_021_003, "告警触发参数缺失必填字段"); + // ========== IoT 子系统 1-050-020-000 ========== ErrorCode SUBSYSTEM_NOT_EXISTS = new ErrorCode(1_050_020_000, "子系统不存在"); ErrorCode SUBSYSTEM_NAME_DUPLICATE = new ErrorCode(1_050_020_001, "同项目下子系统名称已存在"); @@ -95,5 +101,6 @@ public interface ErrorCodeConstants { ErrorCode RULE_CHAIN_CYCLE_DETECTED = new ErrorCode(1_050_030_003, "规则链存在环路,不允许保存"); ErrorCode RULE_CHAIN_OPTIMISTIC_LOCK_CONFLICT = new ErrorCode(1_050_030_004, "规则链已被其他操作修改,请刷新后重试"); ErrorCode RULE_CHAIN_INVALID_RELATION_TYPE = new ErrorCode(1_050_030_005, "规则连线的关系类型非法"); + ErrorCode TRIGGER_TYPE_NOT_FOUND = new ErrorCode(1_050_030_006, "未找到触发器类型"); } \ No newline at end of file diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ConditionEvaluatorManager.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ConditionEvaluatorManager.java new file mode 100644 index 00000000..c3bd61b3 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ConditionEvaluatorManager.java @@ -0,0 +1,68 @@ +package com.viewsh.module.iot.rule.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.rule.engine.RuleContext; +import com.viewsh.module.iot.rule.spi.ConditionEvaluator; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Manager for {@link ConditionEvaluator} instances, routing by type. + * + *
All {@code @Component} implementations of {@link ConditionEvaluator} are auto-injected by + * Spring at startup and indexed by {@link ConditionEvaluator#getType()} in an immutable map. + * + *
Usage: + *
{@code
+ * boolean result = manager.evaluate("expression", ctx, configJsonNode);
+ * }
+ */
+@Slf4j
+@Component
+public class ConditionEvaluatorManager {
+
+ private final MapConfig JSON: + *
{@code
+ * { "state": "online" } // online / offline
+ * }
+ *
+ * Queries Redis key {@code iot:device:online:{deviceId}}: + *
Can be disabled via {@code iot.rule.condition.device_state.enabled=false}. + */ +@Slf4j +@Component +@ConditionalOnProperty(prefix = "iot.rule.condition.device_state", name = "enabled", havingValue = "true", + matchIfMissing = true) +public class DeviceStateConditionEvaluator implements ConditionEvaluator { + + public static final String TYPE = "device_state"; + + /** Redis key pattern: iot:device:online:{deviceId} */ + static final String REDIS_KEY_PATTERN = "iot:device:online:%s"; + + private final StringRedisTemplate redisTemplate; + + public DeviceStateConditionEvaluator(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public String getType() { + return TYPE; + } + + @Override + public boolean evaluate(RuleContext ctx, JsonNode config) { + String expectedState = config.path("state").asText("online").toLowerCase(); + boolean expectOnline = "online".equals(expectedState); + + boolean isOnline = isDeviceOnline(ctx.getDeviceId()); + + return expectOnline == isOnline; + } + + // ---- Private methods ---- + + /** + * Query device online state. + * + * @param deviceId device ID (null is treated as offline) + * @return true = online, false = offline or key not found in Redis + */ + boolean isDeviceOnline(Long deviceId) { + if (deviceId == null) { + log.debug("[DeviceStateConditionEvaluator] deviceId is null, treating as offline"); + return false; + } + String redisKey = String.format(REDIS_KEY_PATTERN, deviceId); + try { + Boolean exists = redisTemplate.hasKey(redisKey); + if (exists == null) { + log.debug("[DeviceStateConditionEvaluator] Redis hasKey returned null (possible connection issue), " + + "deviceId={} treated as offline", deviceId); + return false; + } + return exists; + } catch (Exception e) { + // Known Pitfalls: treat as offline on Redis error, no exception thrown + log.warn("[DeviceStateConditionEvaluator] Redis query failed, deviceId={} treated as offline: {}", + deviceId, e.getMessage()); + return false; + } + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluator.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluator.java new file mode 100644 index 00000000..6f7578bb --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluator.java @@ -0,0 +1,126 @@ +package com.viewsh.module.iot.rule.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.googlecode.aviator.AviatorEvaluator; +import com.googlecode.aviator.AviatorEvaluatorInstance; +import com.googlecode.aviator.Expression; +import com.googlecode.aviator.Options; +import com.viewsh.module.iot.rule.engine.RuleContext; +import com.viewsh.module.iot.rule.spi.ConditionEvaluator; +import com.viewsh.module.iot.rule.template.TemplateResolver; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Expression condition evaluator (Aviator engine). + * + *
Config JSON example: + *
{@code
+ * { "expression": "${data.temperature} > 40 && ${data.humidity} < 20" }
+ * }
+ *
+ * Implementation details: + *
Can be disabled via {@code iot.rule.condition.expression.enabled=false}.
+ */
+@Slf4j
+@Component
+@ConditionalOnProperty(prefix = "iot.rule.condition.expression", name = "enabled", havingValue = "true",
+ matchIfMissing = true)
+public class ExpressionConditionEvaluator implements ConditionEvaluator {
+
+ public static final String TYPE = "expression";
+
+ /** Compiled expression cache capacity (LRU) */
+ private static final int CACHE_MAX_SIZE = 256;
+
+ private final AviatorEvaluatorInstance aviator;
+ private final TemplateResolver templateResolver;
+
+ /** LRU compiled cache: Aviator expression string -> compiled Expression */
+ private final Map Config JSON:
+ * Known pitfalls:
+ * Can be disabled via {@code iot.rule.condition.time_range.enabled=false}.
+ */
+@Slf4j
+@Component
+@ConditionalOnProperty(prefix = "iot.rule.condition.time_range", name = "enabled", havingValue = "true",
+ matchIfMissing = true)
+public class TimeRangeConditionEvaluator implements ConditionEvaluator {
+
+ public static final String TYPE = "time_range";
+
+ private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai");
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ @Override
+ public boolean evaluate(RuleContext ctx, JsonNode config) {
+ String mode = config.path("mode").asText("daily");
+ String tz = config.path("timezone").asText("Asia/Shanghai");
+ ZoneId zoneId;
+ try {
+ zoneId = ZoneId.of(tz);
+ } catch (Exception e) {
+ log.warn("[TimeRangeConditionEvaluator] Invalid timezone '{}', falling back to Asia/Shanghai", tz);
+ zoneId = DEFAULT_ZONE;
+ }
+
+ ZonedDateTime now = ZonedDateTime.now(zoneId);
+
+ return switch (mode) {
+ case "daily" -> evaluateDaily(now, config);
+ case "weekly" -> evaluateWeekly(now, config);
+ case "holiday" -> {
+ log.warn("[TimeRangeConditionEvaluator] holiday mode is not supported in phase 1, returning false. "
+ + "Holiday calendar data source will be implemented in a separate task.");
+ yield false;
+ }
+ default -> {
+ log.warn("[TimeRangeConditionEvaluator] Unknown mode='{}', returning false", mode);
+ yield false;
+ }
+ };
+ }
+
+ // ---- Private methods ----
+
+ private boolean evaluateDaily(ZonedDateTime now, JsonNode config) {
+ LocalTime currentTime = now.toLocalTime();
+ LocalTime start = parseTime(config, "startTime");
+ LocalTime end = parseTime(config, "endTime");
+ return isInTimeRange(currentTime, start, end);
+ }
+
+ private boolean evaluateWeekly(ZonedDateTime now, JsonNode config) {
+ // First check the time window
+ if (!evaluateDaily(now, config)) {
+ return false;
+ }
+ // Then check the day of week
+ List Implementations register via Spring {@code @Component} and are routed by
+ * {@link com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager} using {@link #getType()}.
+ *
+ * Convention (review B5):
+ * 触发器是 DAG 规则链的入口,负责判断消息是否匹配该规则链的触发条件。
+ * 第一期实现 5 种:device_state / device_property / device_event / device_service / timer。
+ *
+ * 注册方式:Spring {@code @Component} + {@link #getType()} 索引
+ * (规范清单禁用 ServiceLoader / @SPI)。
+ *
+ * 评审 A3 / AGENTS.md §五:SPI Provider 注册统一用 Spring Bean。
+ */
+public interface TriggerProvider {
+
+ /**
+ * 类型标识(小写+下划线)。
+ * 封闭枚举:device_state / device_property / device_event / device_service / timer。
+ */
+ String getType();
+
+ /**
+ * 判断消息是否匹配触发条件。
+ *
+ * 无副作用:只做判断,不修改 ctx,不写数据库,不发 MQ。
+ * 写数据由 Action 节点负责。
+ *
+ * @param msg 设备消息
+ * @param config 规则节点的 configuration(JSON)
+ * @param ctx 规则上下文(只读)
+ * @return true 表示触发
+ */
+ boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx);
+
+ /**
+ * 注册规则链(如 Timer 类型需要注册 CRON 任务)。
+ * 默认空实现,非定时触发器不需覆盖。
+ */
+ default void register(CompiledRuleChain chain, JsonNode config) {
+ // 默认无操作
+ }
+
+ /**
+ * 注销规则链(如 Timer 类型需要取消 CRON 任务,避免僵尸任务)。
+ * 默认空实现,非定时触发器不需覆盖。
+ */
+ default void unregister(CompiledRuleChain chain) {
+ // 默认无操作
+ }
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProviderManager.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProviderManager.java
new file mode 100644
index 00000000..6e8fca86
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProviderManager.java
@@ -0,0 +1,75 @@
+package com.viewsh.module.iot.rule.spi;
+
+import com.viewsh.framework.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static com.viewsh.module.iot.enums.ErrorCodeConstants.TRIGGER_TYPE_NOT_FOUND;
+
+/**
+ * TriggerProvider 注册表(Spring 容器扫描 + getType() 索引)。
+ *
+ * 启动时通过 {@link #autoRegister(List)} 自动收集所有 Spring Bean 中的
+ * {@link TriggerProvider},按 {@link TriggerProvider#getType()} 建立索引。
+ *
+ * Fail-fast:同 type 重复注册时抛出 {@link IllegalStateException},
+ * 确保启动阶段即暴露问题,不留运行时隐患。
+ *
+ * 评审 A3 / AGENTS.md §五:SPI 注册方式采用 Spring Bean + getType() 索引。
+ */
+@Slf4j
+@Component
+public class TriggerProviderManager {
+
+ private final Map 当触发器配置非法(如 CRON 表达式格式错误、productId 无效等),
+ * 抛出此异常;由链级 try-catch 捕获并记录,不影响其他规则链执行。
+ */
+public class TriggerMatchException extends RuntimeException {
+
+ private final String triggerType;
+ private final Long chainId;
+
+ public TriggerMatchException(String triggerType, String message) {
+ this(triggerType, null, message, null);
+ }
+
+ public TriggerMatchException(String triggerType, Long chainId, String message) {
+ this(triggerType, chainId, message, null);
+ }
+
+ public TriggerMatchException(String triggerType, Long chainId, String message, Throwable cause) {
+ super(message, cause);
+ this.triggerType = triggerType;
+ this.chainId = chainId;
+ }
+
+ public String getTriggerType() {
+ return triggerType;
+ }
+
+ public Long getChainId() {
+ return chainId;
+ }
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/template/TemplateResolver.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/template/TemplateResolver.java
new file mode 100644
index 00000000..e04b26c0
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/template/TemplateResolver.java
@@ -0,0 +1,231 @@
+package com.viewsh.module.iot.rule.template;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.viewsh.module.iot.rule.engine.RuleContext;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Unified template variable resolver (review B5 key correction).
+ *
+ * Supported variable formats (all using {@code ${namespace.key}}):
+ * Legacy syntax prohibited: template strings containing {@code $[...]} will throw
+ * {@link TemplateSyntaxException}.
+ *
+ * Missing variables return {@code null} (no exception thrown); callers decide fallback strategy.
+ */
+@Slf4j
+@Component
+public class TemplateResolver {
+
+ /** New syntax: ${...} */
+ static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\$\\{([^}]+)}");
+
+ /** Legacy syntax detection: $[...] - reject immediately on match */
+ static final Pattern LEGACY_PATTERN = Pattern.compile("\\$\\[([^]]+)]");
+
+ private final ObjectMapper objectMapper;
+
+ public TemplateResolver(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * Replace all {@code ${namespace.key}} placeholders in the template string with actual values.
+ * If the template contains only a single placeholder with no surrounding text, the original
+ * Object value is returned directly (preserving type, not serializing to String).
+ *
+ * @param template string containing placeholders
+ * @param ctx rule execution context
+ * @return resolved object (String / Number / Boolean / Map / null)
+ * @throws TemplateSyntaxException if template contains legacy {@code $[...]} syntax
+ */
+ public Object resolve(String template, RuleContext ctx) {
+ validateNoLegacySyntax(template);
+
+ Matcher m = TEMPLATE_PATTERN.matcher(template);
+ // Fast path: template is a single placeholder (no surrounding text) - return original type
+ if (isSinglePlaceholder(template)) {
+ m.reset();
+ if (m.find()) {
+ return resolveVariable(m.group(1), ctx);
+ }
+ }
+
+ // General path: string replacement
+ StringBuffer sb = new StringBuffer();
+ m.reset();
+ while (m.find()) {
+ Object val = resolveVariable(m.group(1), ctx);
+ m.appendReplacement(sb, Matcher.quoteReplacement(val == null ? "" : String.valueOf(val)));
+ }
+ m.appendTail(sb);
+ return sb.toString();
+ }
+
+ /**
+ * Expand template placeholders into an Aviator variable name-to-value map
+ * (used by ExpressionConditionEvaluator).
+ * Returns a {@code varName -> value} map where varName is the key with namespace prefix removed
+ * (e.g. {@code temperature} for {@code data.temperature}).
+ */
+ public Map 配置示例:
+ * 匹配条件:
+ * 事件标识提取优先级:
+ * params.identifier > params.eventId > config.event(精确匹配)
+ */
+@Slf4j
+@Component
+public class DeviceEventTriggerProvider implements TriggerProvider {
+
+ private static final String TYPE = "device_event";
+ private static final String METHOD_EVENT_POST = IotDeviceMessageMethodEnum.EVENT_POST.getMethod();
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ @Override
+ public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) {
+ // 1. method 必须是 thing.event.post
+ if (!METHOD_EVENT_POST.equals(msg.getMethod())) {
+ return false;
+ }
+
+ // 2. productId 快速过滤
+ if (config.hasNonNull("productId")) {
+ Long configProductId = config.get("productId").asLong();
+ if (!configProductId.equals(ctx.getProductId())) {
+ return false;
+ }
+ }
+
+ // 3. 确定配置中的事件集合
+ Set 配置示例:
+ * 匹配条件:
+ * 性能注意事项(评审 ⚠️ device_property 匹配性能):
+ * identifiers 构建为 {@link Set},保证 O(1) 查找,高 QPS 下不线性遍历。
+ */
+@Slf4j
+@Component
+public class DevicePropertyTriggerProvider implements TriggerProvider {
+
+ private static final String TYPE = "device_property";
+ private static final String METHOD_PROPERTY_POST = IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod();
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ @Override
+ public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) {
+ // 1. method 必须是 thing.property.post
+ if (!METHOD_PROPERTY_POST.equals(msg.getMethod())) {
+ return false;
+ }
+
+ // 2. productId 快速过滤
+ if (config.hasNonNull("productId")) {
+ Long configProductId = config.get("productId").asLong();
+ if (!configProductId.equals(ctx.getProductId())) {
+ return false;
+ }
+ }
+
+ // 3. identifiers 集合匹配(O(1) Set 包含判断)
+ JsonNode identifiersNode = config.get("identifiers");
+ if (identifiersNode == null || !identifiersNode.isArray() || identifiersNode.isEmpty()) {
+ // 未配置 identifiers,视为匹配所有属性上报
+ return true;
+ }
+
+ Set 配置示例:
+ * 匹配条件:
+ * 任务卡测试用例:method="reset", msg.method="reset", msg.reply=true → match。
+ */
+@Slf4j
+@Component
+public class DeviceServiceTriggerProvider implements TriggerProvider {
+
+ private static final String TYPE = "device_service";
+ private static final String METHOD_SERVICE_INVOKE = IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod();
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ @Override
+ public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) {
+ // 1. method 必须是 thing.service.invoke
+ if (!METHOD_SERVICE_INVOKE.equals(msg.getMethod())) {
+ return false;
+ }
+
+ // 2. 必须是服务调用回复(有响应码 code 或有 data,或者通过 skipReply=false 判断)
+ // 任务卡描述 msg.reply=true — 映射到消息有 code 或 data 的情况
+ boolean isReply = msg.getCode() != null || msg.getData() != null;
+ if (!isReply) {
+ return false;
+ }
+
+ // 3. productId 快速过滤
+ if (config.hasNonNull("productId")) {
+ Long configProductId = config.get("productId").asLong();
+ if (!configProductId.equals(ctx.getProductId())) {
+ return false;
+ }
+ }
+
+ // 4. 确定配置中的服务方法集合
+ Set 配置示例:
+ * 匹配条件:
+ * 状态值映射(参照 {@link com.viewsh.module.iot.core.enums.IotDeviceStateEnum}):
+ * 1 → "online",2 → "offline"。
+ */
+@Slf4j
+@Component
+public class DeviceStateTriggerProvider implements TriggerProvider {
+
+ private static final String TYPE = "device_state";
+ private static final String METHOD_STATE_UPDATE = IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod();
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ @Override
+ public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) {
+ // 1. method 必须是 thing.state.update
+ if (!METHOD_STATE_UPDATE.equals(msg.getMethod())) {
+ return false;
+ }
+
+ // 2. productId 快速过滤(如果 config 指定了 productId)
+ if (config.hasNonNull("productId")) {
+ Long configProductId = config.get("productId").asLong();
+ if (!configProductId.equals(ctx.getProductId())) {
+ return false;
+ }
+ }
+
+ // 3. events 集合匹配(O(1) Set 查找)
+ JsonNode eventsNode = config.get("events");
+ if (eventsNode == null || !eventsNode.isArray() || eventsNode.isEmpty()) {
+ // 未配置 events,视为匹配所有状态变化
+ return true;
+ }
+
+ Set 配置示例:
+ * 调度机制:使用 Spring TaskScheduler 动态注册 CRON 任务。
+ * 每条规则链对应一个 ScheduledFuture,按 CompiledRuleChain.getId() 索引。
+ *
+ * 注销保证(评审 Timer 注销):
+ * 规则链 disable/delete 时 unregister(CompiledRuleChain) 取消对应 future,
+ * 避免僵尸任务内存泄漏。Bean 销毁时也会清理所有残留任务。
+ *
+ * 时区:默认 Asia/Shanghai(评审 CRON 时区)。
+ *
+ * matches() 说明:Timer 触发器不通过消息 matches() 判断,
+ * 而是由 CRON 调度直接触发规则链执行;matches() 始终返回 false(避免被消息路由误触发)。
+ * Timer 触发走专用回调路径,不走普通消息路由。
+ *
+ * 多租户:Timer 执行时需通过 RuleContext 注入 tenant_id,
+ * 规则链查询不跨租户(评审 多租户)。
+ */
+@Slf4j
+@Component
+public class TimerTriggerProvider implements TriggerProvider, DisposableBean {
+
+ private static final String TYPE = "timer";
+ private static final String DEFAULT_TIMEZONE = "Asia/Shanghai";
+
+ private final TaskScheduler taskScheduler;
+
+ /**
+ * chainId to ScheduledFuture (active CRON tasks).
+ */
+ private final Map 覆盖任务卡 B5 §6 用例:
+ * Covers B5 task card section 6 cases:
+ * Covers B5 task card section 6 cases:
+ * Covers B5 task card section 6 template-related cases:
+ *
+ * 替代 v1 {@code propagated_to} JSON 字段的高频查询。
+ * 由 B15 告警传播任务写入;本卡只建表 + DO 骨架。
+ *
+ * @author B12
+ */
+@TableName("iot_alarm_propagation")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class IotAlarmPropagationDO extends TenantBaseDO {
+
+ /** 告警记录 ID(联合主键) */
+ private Long alarmRecordId;
+
+ /** 资产类型:SUBSYSTEM / FLOOR / BUILDING(联合主键) */
+ private String assetType;
+
+ /** 资产 ID(联合主键) */
+ private Long assetId;
+
+ /** 资产名称冗余 */
+ private String assetName;
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmRecordDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmRecordDO.java
new file mode 100644
index 00000000..c40f1c71
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmRecordDO.java
@@ -0,0 +1,118 @@
+package com.viewsh.module.iot.dal.dataobject.alarm;
+
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.viewsh.framework.tenant.core.db.TenantBaseDO;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * IoT 告警记录 DO(v2.0 正交状态机)
+ *
+ * 评审修正:
+ * - C1:{@code ack_state + clear_state + archived} 三字段正交,替代线性 4 枚举
+ * - C2:联合 UK {@code (device_id, alarm_config_id, tenant_id, deleted)},不使用 MD5 哈希
+ *
+ * 合法状态组合(Service 层强制):
+ *
+ * {@link com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmSeverity}
+ */
+ private Integer severity;
+
+ /**
+ * 确认状态 0=未确认 1=已确认
+ *
+ * {@link com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmAckState}
+ */
+ private Integer ackState;
+
+ /**
+ * 清除状态 0=活跃 1=已清除
+ *
+ * {@link com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmClearState}
+ */
+ private Integer clearState;
+
+ /** 归档 0=未归档 1=已归档(归档后不可修改) */
+ private Integer archived;
+
+ /** 设备编号 */
+ private Long deviceId;
+
+ /** 产品编号(冗余) */
+ private Long productId;
+
+ /** 归属子系统 ID(评审 R2) */
+ private Long subsystemId;
+
+ /** 触发此告警的 v2 规则链 ID */
+ private Long ruleChainId;
+
+ /** 触发此告警的 v1 场景规则 ID(灰度期兼容) */
+ private Long sceneRuleId;
+
+ /** 首次触发时间 */
+ private LocalDateTime startTs;
+
+ /** 最近触发时间(累积触发时更新) */
+ private LocalDateTime endTs;
+
+ /** 清除时间 */
+ private LocalDateTime clearTs;
+
+ /** 确认时间 */
+ private LocalDateTime ackTs;
+
+ /** 归档时间 */
+ private LocalDateTime archiveTs;
+
+ /** 告警详情(可累积) */
+ @TableField(typeHandler = JacksonTypeHandler.class)
+ private JsonNode details;
+
+ /** 持续触发次数 */
+ private Integer triggerCount;
+
+ /** 处理备注 */
+ private String processRemark;
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmAckState.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmAckState.java
new file mode 100644
index 00000000..36122d6b
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmAckState.java
@@ -0,0 +1,37 @@
+package com.viewsh.module.iot.dal.dataobject.alarm.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 告警确认状态(评审 C1 正交三字段之一)
+ *
+ * DB 存 TINYINT:0=未确认 / 1=已确认
+ *
+ * @author B12
+ */
+@Getter
+@AllArgsConstructor
+public enum AlarmAckState {
+
+ /** 未确认(新触发告警默认态) */
+ UNACK(0, "未确认"),
+ /** 已确认(人工或规则确认过) */
+ ACK(1, "已确认");
+
+ private final Integer code;
+ private final String desc;
+
+ public static AlarmAckState of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ return Arrays.stream(values())
+ .filter(s -> s.code.equals(code))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("unknown AlarmAckState code: " + code));
+ }
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmClearState.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmClearState.java
new file mode 100644
index 00000000..e28a4c6c
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmClearState.java
@@ -0,0 +1,37 @@
+package com.viewsh.module.iot.dal.dataobject.alarm.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 告警清除状态(评审 C1 正交三字段之一)
+ *
+ * DB 存 TINYINT:0=活跃 / 1=已清除
+ *
+ * @author B12
+ */
+@Getter
+@AllArgsConstructor
+public enum AlarmClearState {
+
+ /** 活跃(告警持续中) */
+ ACTIVE(0, "活跃"),
+ /** 已清除(规则自动或手动清除) */
+ CLEARED(1, "已清除");
+
+ private final Integer code;
+ private final String desc;
+
+ public static AlarmClearState of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ return Arrays.stream(values())
+ .filter(s -> s.code.equals(code))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("unknown AlarmClearState code: " + code));
+ }
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmSeverity.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmSeverity.java
new file mode 100644
index 00000000..753c7c3e
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmSeverity.java
@@ -0,0 +1,47 @@
+package com.viewsh.module.iot.dal.dataobject.alarm.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 告警严重度(1-5)
+ *
+ * DB 存 TINYINT;与 ITU-T X.733 / ThingsBoard Severity 一致,数值越小越严重。
+ *
+ * @author B12
+ */
+@Getter
+@AllArgsConstructor
+public enum AlarmSeverity {
+
+ /** 严重:P0 立即响应 */
+ CRITICAL(1, "严重"),
+ /** 重要:P1 */
+ MAJOR(2, "重要"),
+ /** 次要:P2 */
+ MINOR(3, "次要"),
+ /** 警告:P3 */
+ WARNING(4, "警告"),
+ /** 提示:P4 */
+ INFO(5, "提示");
+
+ private final Integer code;
+ private final String desc;
+
+ public static AlarmSeverity of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ return Arrays.stream(values())
+ .filter(s -> s.code.equals(code))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("unknown AlarmSeverity code: " + code));
+ }
+
+ public static boolean isValid(Integer code) {
+ return code != null && code >= 1 && code <= 5;
+ }
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmRecordMapper.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmRecordMapper.java
new file mode 100644
index 00000000..5dafe254
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmRecordMapper.java
@@ -0,0 +1,35 @@
+package com.viewsh.module.iot.dal.mysql.alarm;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.viewsh.framework.mybatis.core.mapper.BaseMapperX;
+import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * IoT 告警记录 Mapper
+ *
+ * 状态迁移 CAS 语义由 Service 层组合 {@link #selectById}/{@link #updateById} 实现,
+ * 防止 Mockito 对 default method 的半 mock 行为。
+ *
+ * @author B12
+ */
+@Mapper
+public interface IotAlarmRecordMapper extends BaseMapperX
+ * 用于 triggerAlarm 的幂等 upsert;仅返回 archived=0 的记录,
+ * 已归档则视为新告警触发(UK 含 deleted 列,archived 本身不在 UK)。
+ */
+ default IotAlarmRecordDO selectActiveByDeviceAndConfig(Long deviceId, Long alarmConfigId, Long tenantId) {
+ return selectOne(new LambdaQueryWrapper
+ * 评审修正:
+ * - C1 正交三字段 (ack_state, clear_state, archived)
+ * - C2 联合 UK 幂等 upsert
+ * - C4 竞态保护(SET NX PX + Lua 解锁)由 B14 叠加到本 Service 的方法上
+ *
+ * @author B12
+ */
+public interface IotAlarmRecordService {
+
+ /**
+ * 触发告警(规则引擎 AlarmTriggerAction 调用)
+ *
+ * 幂等:同一 (device_id, alarm_config_id, tenant_id) 活跃记录已存在 → trigger_count++ + 更新 end_ts;
+ * 否则插入新记录 (0,0,0) + trigger_count=1。
+ *
+ * @return 告警记录 ID(新建或既有)
+ */
+ Long triggerAlarm(AlarmTriggerRequest request);
+
+ /**
+ * 确认告警(UNACK → ACK)
+ *
+ * 对 ack_state=0 的记录置为 1;归档后拒绝。幂等:ack_state=1 调用返回同一 id,不抛错。
+ */
+ void ackAlarm(AlarmStateTransitionRequest request);
+
+ /**
+ * 撤销确认(ACK → UNACK)
+ *
+ * 归档后拒绝。幂等:ack_state=0 调用返回同一 id,不抛错。
+ */
+ void unackAlarm(AlarmStateTransitionRequest request);
+
+ /**
+ * 清除告警(ACTIVE → CLEARED)
+ *
+ * 归档后拒绝。幂等:clear_state=1 调用返回同一 id,不抛错。
+ */
+ void clearAlarm(AlarmStateTransitionRequest request);
+
+ /**
+ * 归档告警(最终关闭)
+ *
+ * 非幂等:已归档再调用抛 ALARM_ALREADY_ARCHIVED。
+ */
+ void archiveAlarm(AlarmStateTransitionRequest request);
+
+ /**
+ * 按 ID 查询
+ */
+ IotAlarmRecordDO getAlarm(Long id);
+
+}
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
new file mode 100644
index 00000000..e63a6c13
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java
@@ -0,0 +1,207 @@
+package com.viewsh.module.iot.service.alarm;
+
+import com.viewsh.framework.tenant.core.context.TenantContextHolder;
+import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
+import com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmSeverity;
+import com.viewsh.module.iot.dal.mysql.alarm.IotAlarmRecordMapper;
+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.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.validation.annotation.Validated;
+
+import java.time.LocalDateTime;
+
+import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
+
+/**
+ * IoT 告警记录 Service 实现(v2.0 正交状态机)
+ *
+ * 本卡职责:建表 / 枚举 / DO / Service 状态机核心 + 幂等 upsert。
+ * 不实现:分布式锁(B14)、告警传播(B15)、通知(B16)、
+ * 告警条件结构化存储(评审 E4)、Redis 实时计数(B14/O1)。
+ *
+ * Known Pitfalls 落地:
+ * - ⚠️ 评审 C1:正交三字段替代线性 4 枚举({@link IotAlarmRecordDO})
+ * - ⚠️ 评审 C2:联合 UK 幂等 upsert({@link IotAlarmRecordMapper#selectActiveByDeviceAndConfig})
+ * - ⚠️ 评审 C4:TODO B14 加分布式锁(triggerAlarm 当前仅 CAS)
+ * - ⚠️ 归档后不可修改:5 个状态迁移 API 都先校验 archived=0
+ * - ⚠️ tenant_id:基于 {@link TenantContextHolder}
+ *
+ * @author B12
+ */
+@Slf4j
+@Service
+@Validated
+public class IotAlarmRecordServiceImpl implements IotAlarmRecordService {
+
+ @Resource
+ private IotAlarmRecordMapper alarmRecordMapper;
+
+ // ==================== 触发(幂等 upsert) ====================
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long triggerAlarm(AlarmTriggerRequest request) {
+ // 1. 参数校验
+ if (request == null
+ || request.getDeviceId() == null
+ || request.getAlarmConfigId() == null
+ || request.getSeverity() == null) {
+ throw exception(ALARM_TRIGGER_REQUIRED_FIELD);
+ }
+ if (!AlarmSeverity.isValid(request.getSeverity())) {
+ throw exception(ALARM_SEVERITY_INVALID);
+ }
+
+ Long tenantId = TenantContextHolder.getTenantId();
+ LocalDateTime now = LocalDateTime.now();
+
+ // 2. 幂等 upsert(评审 C2)
+ // TODO B14:此处应套分布式锁 SET NX PX + Lua,防并发 trigger 造成双写
+ IotAlarmRecordDO existing = alarmRecordMapper.selectActiveByDeviceAndConfig(
+ request.getDeviceId(), request.getAlarmConfigId(), tenantId);
+
+ if (existing != null) {
+ // 已存在活跃记录 → trigger_count++ + end_ts 更新 + details 覆盖
+ IotAlarmRecordDO update = new IotAlarmRecordDO();
+ update.setId(existing.getId());
+ update.setEndTs(now);
+ int oldCount = existing.getTriggerCount() == null ? 1 : existing.getTriggerCount();
+ update.setTriggerCount(oldCount + 1);
+ if (request.getDetails() != null) {
+ update.setDetails(request.getDetails());
+ }
+ alarmRecordMapper.updateById(update);
+ log.debug("[triggerAlarm] 累积触发 id={} count={}", existing.getId(), oldCount + 1);
+ return existing.getId();
+ }
+
+ // 3. 新建(0, 0, 0)
+ IotAlarmRecordDO record = IotAlarmRecordDO.builder()
+ .alarmConfigId(request.getAlarmConfigId())
+ .alarmName(request.getAlarmName())
+ .severity(request.getSeverity())
+ .ackState(0)
+ .clearState(0)
+ .archived(0)
+ .deviceId(request.getDeviceId())
+ .productId(request.getProductId())
+ .subsystemId(request.getSubsystemId())
+ .ruleChainId(request.getRuleChainId())
+ .sceneRuleId(request.getSceneRuleId())
+ .startTs(now)
+ .endTs(now)
+ .details(request.getDetails())
+ .triggerCount(1)
+ .build();
+ alarmRecordMapper.insert(record);
+ log.info("[triggerAlarm] 新建告警 id={} device={} config={}",
+ record.getId(), request.getDeviceId(), request.getAlarmConfigId());
+ return record.getId();
+ }
+
+ // ==================== 状态迁移(CAS by Service 层) ====================
+
+ @Override
+ public void ackAlarm(AlarmStateTransitionRequest request) {
+ IotAlarmRecordDO alarm = requireActiveAlarm(request.getAlarmId());
+ if (Integer.valueOf(1).equals(alarm.getAckState())) {
+ log.debug("[ackAlarm] 告警 id={} 已确认,幂等跳过", alarm.getId());
+ return;
+ }
+ IotAlarmRecordDO update = new IotAlarmRecordDO();
+ update.setId(alarm.getId());
+ update.setAckState(1);
+ update.setAckTs(LocalDateTime.now());
+ update.setUpdater(request.getOperator());
+ if (request.getRemark() != null && !request.getRemark().isBlank()) {
+ update.setProcessRemark(request.getRemark());
+ }
+ alarmRecordMapper.updateById(update);
+ }
+
+ @Override
+ public void unackAlarm(AlarmStateTransitionRequest request) {
+ IotAlarmRecordDO alarm = requireActiveAlarm(request.getAlarmId());
+ if (Integer.valueOf(0).equals(alarm.getAckState())) {
+ log.debug("[unackAlarm] 告警 id={} 未确认,幂等跳过", alarm.getId());
+ return;
+ }
+ IotAlarmRecordDO update = new IotAlarmRecordDO();
+ update.setId(alarm.getId());
+ update.setAckState(0);
+ update.setAckTs(null);
+ update.setUpdater(request.getOperator());
+ if (request.getRemark() != null && !request.getRemark().isBlank()) {
+ update.setProcessRemark(request.getRemark());
+ }
+ alarmRecordMapper.updateById(update);
+ }
+
+ @Override
+ public void clearAlarm(AlarmStateTransitionRequest request) {
+ IotAlarmRecordDO alarm = requireActiveAlarm(request.getAlarmId());
+ if (Integer.valueOf(1).equals(alarm.getClearState())) {
+ log.debug("[clearAlarm] 告警 id={} 已清除,幂等跳过", alarm.getId());
+ return;
+ }
+ IotAlarmRecordDO update = new IotAlarmRecordDO();
+ update.setId(alarm.getId());
+ update.setClearState(1);
+ update.setClearTs(LocalDateTime.now());
+ update.setUpdater(request.getOperator());
+ if (request.getRemark() != null && !request.getRemark().isBlank()) {
+ update.setProcessRemark(request.getRemark());
+ }
+ alarmRecordMapper.updateById(update);
+ }
+
+ @Override
+ public void archiveAlarm(AlarmStateTransitionRequest request) {
+ IotAlarmRecordDO alarm = getAlarmOrThrow(request.getAlarmId());
+ if (Integer.valueOf(1).equals(alarm.getArchived())) {
+ // 归档非幂等:已归档再调用抛业务异常
+ throw exception(ALARM_ALREADY_ARCHIVED);
+ }
+ IotAlarmRecordDO update = new IotAlarmRecordDO();
+ update.setId(alarm.getId());
+ update.setArchived(1);
+ update.setArchiveTs(LocalDateTime.now());
+ update.setUpdater(request.getOperator());
+ if (request.getRemark() != null && !request.getRemark().isBlank()) {
+ update.setProcessRemark(request.getRemark());
+ }
+ alarmRecordMapper.updateById(update);
+ }
+
+ // ==================== 查询 ====================
+
+ @Override
+ public IotAlarmRecordDO getAlarm(Long id) {
+ return alarmRecordMapper.selectById(id);
+ }
+
+ // ==================== 内部工具 ====================
+
+ private IotAlarmRecordDO getAlarmOrThrow(Long id) {
+ IotAlarmRecordDO alarm = alarmRecordMapper.selectById(id);
+ if (alarm == null) {
+ throw exception(ALARM_RECORD_NOT_EXISTS);
+ }
+ return alarm;
+ }
+
+ /** 要求告警存在且未归档(归档后禁止 ack/unack/clear) */
+ private IotAlarmRecordDO requireActiveAlarm(Long id) {
+ IotAlarmRecordDO alarm = getAlarmOrThrow(id);
+ if (Integer.valueOf(1).equals(alarm.getArchived())) {
+ throw exception(ALARM_ALREADY_ARCHIVED);
+ }
+ return alarm;
+ }
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/dto/AlarmStateTransitionRequest.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/dto/AlarmStateTransitionRequest.java
new file mode 100644
index 00000000..2dacd5ce
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/dto/AlarmStateTransitionRequest.java
@@ -0,0 +1,28 @@
+package com.viewsh.module.iot.service.alarm.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 告警状态迁移请求(ack / clear / archive / unack 公用)
+ *
+ * @author B12
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AlarmStateTransitionRequest {
+
+ /** 告警记录 ID */
+ private Long alarmId;
+
+ /** 操作人 */
+ private String operator;
+
+ /** 处理备注 */
+ private String remark;
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/dto/AlarmTriggerRequest.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/dto/AlarmTriggerRequest.java
new file mode 100644
index 00000000..b0725521
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/dto/AlarmTriggerRequest.java
@@ -0,0 +1,47 @@
+package com.viewsh.module.iot.service.alarm.dto;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 触发告警请求(规则引擎 AlarmTriggerAction 调用)
+ *
+ * @author B12
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AlarmTriggerRequest {
+
+ /** 设备 ID(必填) */
+ private Long deviceId;
+
+ /** 告警配置 ID(必填) */
+ private Long alarmConfigId;
+
+ /** 严重度 1-5(必填) */
+ private Integer severity;
+
+ /** 告警名称(冗余,便于查询) */
+ private String alarmName;
+
+ /** 产品 ID(冗余,可选) */
+ private Long productId;
+
+ /** 所属子系统 ID(可选) */
+ private Long subsystemId;
+
+ /** v2 规则链 ID(可选) */
+ private Long ruleChainId;
+
+ /** v1 场景规则 ID(可选,灰度期兼容) */
+ private Long sceneRuleId;
+
+ /** 触发详情(JSON) */
+ private JsonNode details;
+
+}
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
new file mode 100644
index 00000000..db79c699
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java
@@ -0,0 +1,298 @@
+package com.viewsh.module.iot.service.alarm;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.viewsh.framework.common.exception.ServiceException;
+import com.viewsh.framework.tenant.core.context.TenantContextHolder;
+import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
+import com.viewsh.module.iot.dal.mysql.alarm.IotAlarmRecordMapper;
+import com.viewsh.module.iot.service.alarm.dto.AlarmStateTransitionRequest;
+import com.viewsh.module.iot.service.alarm.dto.AlarmTriggerRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+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.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * {@link IotAlarmRecordServiceImpl} 单元测试(覆盖任务卡 B12 §6.1 的 10 个用例)。
+ *
+ * @author B12
+ */
+@ExtendWith(MockitoExtension.class)
+class IotAlarmRecordServiceImplTest {
+
+ @InjectMocks
+ private IotAlarmRecordServiceImpl alarmService;
+
+ @Mock
+ private IotAlarmRecordMapper alarmRecordMapper;
+
+ private MockedStatic{@code
+ * {
+ * "mode": "daily", // daily / weekly / holiday
+ * "startTime": "09:00",
+ * "endTime": "18:00",
+ * "daysOfWeek": [1,2,3,4,5], // weekly mode (1=Monday, 7=Sunday, ISO-8601)
+ * "timezone": "Asia/Shanghai" // optional, defaults to Asia/Shanghai
+ * }
+ * }
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+public interface ConditionEvaluator {
+
+ /**
+ * Condition type identifier, aligned with rule_node.type.
+ * Examples: {@code "expression"} / {@code "time_range"} / {@code "device_state"}
+ */
+ String getType();
+
+ /**
+ * Evaluate whether the condition is satisfied.
+ *
+ * @param ctx rule chain execution context (message, metadata, device info)
+ * @param config node configuration JSON (parsed by each Evaluator)
+ * @return true = condition met, false = not met
+ */
+ boolean evaluate(RuleContext ctx, JsonNode config);
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProvider.java
new file mode 100644
index 00000000..7e16b9c6
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProvider.java
@@ -0,0 +1,55 @@
+package com.viewsh.module.iot.rule.spi;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
+import com.viewsh.module.iot.rule.engine.CompiledRuleChain;
+import com.viewsh.module.iot.rule.engine.RuleContext;
+
+/**
+ * 触发器 SPI 接口。
+ *
+ *
+ *
+ *
+ *
+ * { "event": "alarm", "productId": 10 }
+ *
+ * 或多事件:
+ *
+ * { "events": ["alarm", "fault"], "productId": 10 }
+ *
+ *
+ *
+ *
+ *
+ *
+ * { "identifiers": ["temperature", "humidity"], "productId": 10 }
+ *
+ *
+ *
+ *
+ *
+ *
+ * { "method": "reset", "productId": 10 }
+ *
+ * 或多服务:
+ *
+ * { "methods": ["reset", "reboot"], "productId": 10 }
+ *
+ *
+ *
+ *
+ *
+ *
+ * { "events": ["online", "offline"], "productId": 10 }
+ *
+ *
+ *
+ *
+ *
+ * {@code
+ * { "cron": "0 * /5 * * * ?", "timezone": "Asia/Shanghai" }
+ * }
+ *
+ *
+ *
+ */
+class DeviceStateConditionEvaluatorTest extends BaseMockitoUnitTest {
+
+ @Mock
+ private StringRedisTemplate redisTemplate;
+
+ private DeviceStateConditionEvaluator evaluator;
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ evaluator = new DeviceStateConditionEvaluator(redisTemplate);
+ objectMapper = new ObjectMapper();
+ }
+
+ // ---- device_online: Redis 有 key ----
+
+ @Test
+ void device_online_redisKeyExists_returnsTrue() {
+ Long deviceId = 100L;
+ String key = "iot:device:online:" + deviceId;
+ when(redisTemplate.hasKey(key)).thenReturn(true);
+
+ RuleContext ctx = ctxWithDevice(deviceId);
+ boolean result = evaluator.evaluate(ctx, configOnline());
+ assertTrue(result, "Redis key 存在 + 期望 online → true");
+ }
+
+ // ---- device_online_missing: Redis 无 key ----
+
+ @Test
+ void device_online_redisKeyMissing_returnsFalse() {
+ Long deviceId = 200L;
+ String key = "iot:device:online:" + deviceId;
+ when(redisTemplate.hasKey(key)).thenReturn(false);
+
+ RuleContext ctx = ctxWithDevice(deviceId);
+ boolean result = evaluator.evaluate(ctx, configOnline());
+ assertFalse(result, "Redis key 不存在 + 期望 online → false(不抛错)");
+ }
+
+ // ---- device_offline: Redis 无 key + 期望 offline ----
+
+ @Test
+ void device_offline_redisKeyMissing_expectOffline_returnsTrue() {
+ Long deviceId = 300L;
+ String key = "iot:device:online:" + deviceId;
+ when(redisTemplate.hasKey(key)).thenReturn(false);
+
+ RuleContext ctx = ctxWithDevice(deviceId);
+ boolean result = evaluator.evaluate(ctx, configOffline());
+ assertTrue(result, "Redis key 不存在(设备离线)+ 期望 offline → true");
+ }
+
+ @Test
+ void device_offline_redisKeyExists_expectOffline_returnsFalse() {
+ Long deviceId = 400L;
+ String key = "iot:device:online:" + deviceId;
+ when(redisTemplate.hasKey(key)).thenReturn(true);
+
+ RuleContext ctx = ctxWithDevice(deviceId);
+ boolean result = evaluator.evaluate(ctx, configOffline());
+ assertFalse(result, "Redis key 存在(设备在线)+ 期望 offline → false");
+ }
+
+ // ---- Redis 异常容错 ----
+
+ @Test
+ void device_redisException_treatedAsOffline_noExceptionThrown() {
+ Long deviceId = 500L;
+ String key = "iot:device:online:" + deviceId;
+ when(redisTemplate.hasKey(key)).thenThrow(new RuntimeException("Redis 连接超时"));
+
+ RuleContext ctx = ctxWithDevice(deviceId);
+ // 不抛异常,视为 offline
+ assertDoesNotThrow(() -> {
+ boolean result = evaluator.evaluate(ctx, configOnline());
+ assertFalse(result, "Redis 异常时视为 offline,期望 online → false");
+ });
+ }
+
+ @Test
+ void device_redisReturnsNull_treatedAsOffline() {
+ Long deviceId = 600L;
+ String key = "iot:device:online:" + deviceId;
+ when(redisTemplate.hasKey(key)).thenReturn(null);
+
+ RuleContext ctx = ctxWithDevice(deviceId);
+ assertDoesNotThrow(() -> {
+ boolean result = evaluator.evaluate(ctx, configOnline());
+ assertFalse(result, "Redis 返回 null 时视为 offline → false");
+ });
+ }
+
+ // ---- deviceId 为 null ----
+
+ @Test
+ void device_nullDeviceId_returnsFalse() {
+ RuleContext ctx = ctxWithDevice(null);
+ boolean result = evaluator.evaluate(ctx, configOnline());
+ assertFalse(result, "deviceId 为 null 时视为 offline → false");
+ verifyNoInteractions(redisTemplate);
+ }
+
+ // ---- getType ----
+
+ @Test
+ void getType_returnsDeviceState() {
+ assertEquals("device_state", evaluator.getType());
+ }
+
+ // ---- Redis key 格式 ----
+
+ @Test
+ void redisKey_usesCorrectPattern() {
+ Long deviceId = 789L;
+ when(redisTemplate.hasKey("iot:device:online:789")).thenReturn(true);
+ assertTrue(evaluator.isDeviceOnline(deviceId));
+ verify(redisTemplate).hasKey("iot:device:online:789");
+ }
+
+ // ---- helpers ----
+
+ private RuleContext ctxWithDevice(Long deviceId) {
+ RuleContext ctx = new RuleContext();
+ ctx.setDeviceId(deviceId);
+ return ctx;
+ }
+
+ private ObjectNode configOnline() {
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put("state", "online");
+ return node;
+ }
+
+ private ObjectNode configOffline() {
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put("state", "offline");
+ return node;
+ }
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluatorTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluatorTest.java
new file mode 100644
index 00000000..5e4924fd
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluatorTest.java
@@ -0,0 +1,141 @@
+package com.viewsh.module.iot.rule.condition;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
+import com.viewsh.module.iot.rule.engine.RuleContext;
+import com.viewsh.module.iot.rule.template.TemplateResolver;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link ExpressionConditionEvaluator}.
+ *
+ *
+ *
+ */
+class ExpressionConditionEvaluatorTest {
+
+ private ExpressionConditionEvaluator evaluator;
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ objectMapper = new ObjectMapper();
+ TemplateResolver templateResolver = new TemplateResolver(objectMapper);
+ evaluator = new ExpressionConditionEvaluator(templateResolver);
+ }
+
+ // ---- expr_simple ----
+
+ @Test
+ void expr_simple_tempOver40_returnsTrue() {
+ RuleContext ctx = ctxWithData(Map.of("temp", 45));
+ boolean result = evaluator.evaluate(ctx, config("${data.temp} > 40"));
+ assertTrue(result, "temp=45 > 40 should be true");
+ }
+
+ @Test
+ void expr_simple_tempBelow40_returnsFalse() {
+ RuleContext ctx = ctxWithData(Map.of("temp", 35));
+ boolean result = evaluator.evaluate(ctx, config("${data.temp} > 40"));
+ assertFalse(result, "temp=35 > 40 should be false");
+ }
+
+ // ---- expr_and ----
+
+ @Test
+ void expr_and_bothConditionsMet_returnsTrue() {
+ RuleContext ctx = ctxWithData(Map.of("temp", 45, "hum", 15));
+ boolean result = evaluator.evaluate(ctx, config("${data.temp} > 40 && ${data.hum} < 20"));
+ assertTrue(result, "temp=45>40 && hum=15<20 should be true");
+ }
+
+ @Test
+ void expr_and_oneConditionFails_returnsFalse() {
+ RuleContext ctx = ctxWithData(Map.of("temp", 45, "hum", 25));
+ boolean result = evaluator.evaluate(ctx, config("${data.temp} > 40 && ${data.hum} < 20"));
+ assertFalse(result, "hum=25 does not satisfy < 20, should be false");
+ }
+
+ // ---- expr_meta_var ----
+
+ @Test
+ void expr_meta_var_subsystemCodeMatch_returnsTrue() {
+ RuleContext ctx = new RuleContext();
+ ctx.getMetadata().put("subsystemCode", "clean");
+ boolean result = evaluator.evaluate(ctx, config("${meta.subsystemCode} == 'clean'"));
+ assertTrue(result, "${meta.subsystemCode} == 'clean' should be true");
+ }
+
+ @Test
+ void expr_meta_var_subsystemCodeNoMatch_returnsFalse() {
+ RuleContext ctx = new RuleContext();
+ ctx.getMetadata().put("subsystemCode", "other");
+ boolean result = evaluator.evaluate(ctx, config("${meta.subsystemCode} == 'clean'"));
+ assertFalse(result, "subsystemCode='other' != 'clean' should be false");
+ }
+
+ // ---- expr_template_legacy ----
+
+ @Test
+ void expr_template_legacy_throwsTemplateSyntaxException() {
+ RuleContext ctx = ctxWithData(Map.of("temp", 45));
+ assertThrows(TemplateResolver.TemplateSyntaxException.class,
+ () -> evaluator.evaluate(ctx, config("$[temp] > 40")),
+ "Legacy $[...] syntax should be rejected with TemplateSyntaxException");
+ }
+
+ // ---- Compiled cache ----
+
+ @Test
+ void compiledCache_sameExpressionHitsCache() {
+ // Execute same expression 10 times; should not fail and should use cache
+ RuleContext ctx = ctxWithData(Map.of("temp", 45));
+ String expression = "${data.temp} > 40";
+ for (int i = 0; i < 10; i++) {
+ assertTrue(evaluator.evaluate(ctx, config(expression)));
+ }
+ }
+
+ // ---- Missing expression field ----
+
+ @Test
+ void missingExpression_throwsIllegalArgumentException() {
+ RuleContext ctx = ctxWithData(Map.of("temp", 45));
+ ObjectNode emptyConfig = objectMapper.createObjectNode();
+ assertThrows(IllegalArgumentException.class,
+ () -> evaluator.evaluate(ctx, emptyConfig));
+ }
+
+ // ---- getType ----
+
+ @Test
+ void getType_returnsExpression() {
+ assertEquals("expression", evaluator.getType());
+ }
+
+ // ---- helpers ----
+
+ private ObjectNode config(String expression) {
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put("expression", expression);
+ return node;
+ }
+
+ private RuleContext ctxWithData(Map
+ *
+ */
+class TimeRangeConditionEvaluatorTest {
+
+ private TimeRangeConditionEvaluator evaluator;
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ objectMapper = new ObjectMapper();
+ evaluator = new TimeRangeConditionEvaluator();
+ }
+
+ // ---- isInTimeRange static method direct tests ----
+
+ @Test
+ void isInTimeRange_normal_withinRange_returnsTrue() {
+ assertTrue(TimeRangeConditionEvaluator.isInTimeRange(
+ LocalTime.of(14, 0),
+ LocalTime.of(9, 0),
+ LocalTime.of(18, 0)));
+ }
+
+ @Test
+ void isInTimeRange_normal_beforeRange_returnsFalse() {
+ assertFalse(TimeRangeConditionEvaluator.isInTimeRange(
+ LocalTime.of(8, 59),
+ LocalTime.of(9, 0),
+ LocalTime.of(18, 0)));
+ }
+
+ @Test
+ void isInTimeRange_normal_atStartTime_returnsTrue() {
+ assertTrue(TimeRangeConditionEvaluator.isInTimeRange(
+ LocalTime.of(9, 0),
+ LocalTime.of(9, 0),
+ LocalTime.of(18, 0)));
+ }
+
+ @Test
+ void isInTimeRange_normal_atEndTime_returnsFalse() {
+ // end is exclusive boundary
+ assertFalse(TimeRangeConditionEvaluator.isInTimeRange(
+ LocalTime.of(18, 0),
+ LocalTime.of(9, 0),
+ LocalTime.of(18, 0)));
+ }
+
+ @Test
+ void isInTimeRange_crossMidnight_02h00_returnsTrue() {
+ // 22:00-06:00, current time 02:00 should be in range
+ assertTrue(TimeRangeConditionEvaluator.isInTimeRange(
+ LocalTime.of(2, 0),
+ LocalTime.of(22, 0),
+ LocalTime.of(6, 0)));
+ }
+
+ @Test
+ void isInTimeRange_crossMidnight_23h00_returnsTrue() {
+ // 22:00-06:00, current time 23:00 should be in range
+ assertTrue(TimeRangeConditionEvaluator.isInTimeRange(
+ LocalTime.of(23, 0),
+ LocalTime.of(22, 0),
+ LocalTime.of(6, 0)));
+ }
+
+ @Test
+ void isInTimeRange_crossMidnight_10h00_returnsFalse() {
+ // 22:00-06:00, current time 10:00 is NOT in range
+ assertFalse(TimeRangeConditionEvaluator.isInTimeRange(
+ LocalTime.of(10, 0),
+ LocalTime.of(22, 0),
+ LocalTime.of(6, 0)));
+ }
+
+ @Test
+ void isInTimeRange_crossMidnight_exactStart_returnsTrue() {
+ // 22:00 is start time, should be in range (inclusive left boundary)
+ assertTrue(TimeRangeConditionEvaluator.isInTimeRange(
+ LocalTime.of(22, 0),
+ LocalTime.of(22, 0),
+ LocalTime.of(6, 0)));
+ }
+
+ @Test
+ void isInTimeRange_crossMidnight_exactEnd_returnsFalse() {
+ // 06:00 is end time (exclusive right boundary)
+ assertFalse(TimeRangeConditionEvaluator.isInTimeRange(
+ LocalTime.of(6, 0),
+ LocalTime.of(22, 0),
+ LocalTime.of(6, 0)));
+ }
+
+ // ---- evaluate (daily mode) ----
+
+ @Test
+ void evaluate_daily_getType() {
+ assertEquals("time_range", evaluator.getType());
+ }
+
+ @Test
+ void evaluate_daily_missingStartTime_throwsException() {
+ RuleContext ctx = new RuleContext();
+ ObjectNode config = objectMapper.createObjectNode();
+ config.put("mode", "daily");
+ config.put("endTime", "18:00");
+ assertThrows(IllegalArgumentException.class, () -> evaluator.evaluate(ctx, config));
+ }
+
+ @Test
+ void evaluate_daily_invalidTime_throwsException() {
+ RuleContext ctx = new RuleContext();
+ ObjectNode config = objectMapper.createObjectNode();
+ config.put("mode", "daily");
+ config.put("startTime", "25:00");
+ config.put("endTime", "18:00");
+ assertThrows(Exception.class, () -> evaluator.evaluate(ctx, config));
+ }
+
+ // ---- evaluate (weekly mode) ----
+
+ @Test
+ void evaluate_weekly_daysOfWeekEmpty_fallbackToDaily() {
+ // weekly mode with no daysOfWeek configured, falls back to daily
+ RuleContext ctx = new RuleContext();
+ ObjectNode config = buildDailyConfig("00:00", "23:59");
+ config.put("mode", "weekly");
+ // No daysOfWeek added - falls back to daily (current time must be in 00:00-23:59)
+ assertTrue(evaluator.evaluate(ctx, config),
+ "No daysOfWeek config falls back to daily; 00:00-23:59 should be true");
+ }
+
+ // ---- evaluate (holiday mode) ----
+
+ @Test
+ void evaluate_holiday_returnsFalse() {
+ RuleContext ctx = new RuleContext();
+ ObjectNode config = objectMapper.createObjectNode();
+ config.put("mode", "holiday");
+ config.put("startTime", "09:00");
+ config.put("endTime", "18:00");
+ assertFalse(evaluator.evaluate(ctx, config),
+ "holiday mode not supported in phase 1, should return false");
+ }
+
+ // ---- evaluate (unknown mode) ----
+
+ @Test
+ void evaluate_unknownMode_returnsFalse() {
+ RuleContext ctx = new RuleContext();
+ ObjectNode config = objectMapper.createObjectNode();
+ config.put("mode", "unknown_mode");
+ config.put("startTime", "09:00");
+ config.put("endTime", "18:00");
+ assertFalse(evaluator.evaluate(ctx, config));
+ }
+
+ // ---- helpers ----
+
+ private ObjectNode buildDailyConfig(String start, String end) {
+ ObjectNode config = objectMapper.createObjectNode();
+ config.put("mode", "daily");
+ config.put("startTime", start);
+ config.put("endTime", end);
+ return config;
+ }
+
+ @SuppressWarnings("unused")
+ private ObjectNode buildWeeklyConfig(String start, String end, int... days) {
+ ObjectNode config = buildDailyConfig(start, end);
+ config.put("mode", "weekly");
+ ArrayNode arr = config.putArray("daysOfWeek");
+ for (int d : days) arr.add(d);
+ return config;
+ }
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/template/TemplateResolverTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/template/TemplateResolverTest.java
new file mode 100644
index 00000000..5f2280c7
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/template/TemplateResolverTest.java
@@ -0,0 +1,164 @@
+package com.viewsh.module.iot.rule.template;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
+import com.viewsh.module.iot.rule.engine.RuleContext;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link TemplateResolver}.
+ *
+ *
+ *
+ */
+class TemplateResolverTest {
+
+ private TemplateResolver resolver;
+
+ @BeforeEach
+ void setUp() {
+ resolver = new TemplateResolver(new ObjectMapper());
+ }
+
+ // ---- resolve method ----
+
+ @Test
+ void resolve_data_simpleField() {
+ RuleContext ctx = ctxWithData(Map.of("temperature", 45));
+ Object result = resolver.resolve("${data.temperature}", ctx);
+ assertEquals(45, result);
+ }
+
+ @Test
+ void resolve_data_returnsNull_whenMissing() {
+ RuleContext ctx = ctxWithData(Map.of("temperature", 45));
+ Object result = resolver.resolve("${data.humidity}", ctx);
+ assertNull(result);
+ }
+
+ @Test
+ void resolve_meta_field() {
+ RuleContext ctx = ctxWithMeta("subsystemCode", "clean");
+ Object result = resolver.resolve("${meta.subsystemCode}", ctx);
+ assertEquals("clean", result);
+ }
+
+ @Test
+ void resolve_metadata_alias() {
+ RuleContext ctx = ctxWithMeta("subsystemCode", "clean");
+ Object result = resolver.resolve("${metadata.subsystemCode}", ctx);
+ assertEquals("clean", result);
+ }
+
+ @Test
+ void resolve_meta_returnsNull_whenMissing() {
+ RuleContext ctx = ctxWithMeta("subsystemCode", "clean");
+ Object result = resolver.resolve("${meta.nonExist}", ctx);
+ assertNull(result);
+ }
+
+ @Test
+ void resolve_singlePlaceholder_preservesType() {
+ // Single placeholder should return original type (integer 45, not String "45")
+ RuleContext ctx = ctxWithData(Map.of("temp", 45));
+ Object result = resolver.resolve("${data.temp}", ctx);
+ assertInstanceOf(Integer.class, result);
+ assertEquals(45, result);
+ }
+
+ @Test
+ void resolve_multiplePlaceholders_inString() {
+ RuleContext ctx = ctxWithData(Map.of("temp", 45, "hum", 30));
+ Object result = resolver.resolve("temp=${data.temp}, hum=${data.hum}", ctx);
+ assertEquals("temp=45, hum=30", result);
+ }
+
+ @Test
+ void resolve_legacySyntax_throwsException() {
+ RuleContext ctx = ctxWithData(Map.of("temp", 45));
+ assertThrows(TemplateResolver.TemplateSyntaxException.class,
+ () -> resolver.resolve("$[temp] > 40", ctx));
+ }
+
+ // ---- toAviatorExpression ----
+
+ @Test
+ void toAviatorExpression_replacesPlaceholders() {
+ String expr = resolver.toAviatorExpression("${data.temperature} > 40 && ${data.humidity} < 20");
+ assertEquals("temperature > 40 && humidity < 20", expr);
+ }
+
+ @Test
+ void toAviatorExpression_meta_prefix() {
+ String expr = resolver.toAviatorExpression("${meta.subsystemCode} == 'clean'");
+ assertEquals("subsystemCode == 'clean'", expr);
+ }
+
+ @Test
+ void toAviatorExpression_legacySyntax_throwsException() {
+ assertThrows(TemplateResolver.TemplateSyntaxException.class,
+ () -> resolver.toAviatorExpression("$[temp] > 40"));
+ }
+
+ // ---- resolveToEnvMap ----
+
+ @Test
+ void resolveToEnvMap_buildsCorrectKeys() {
+ RuleContext ctx = ctxWithData(Map.of("temp", 45, "hum", 15));
+ Map
+ * (ack_state, clear_state, archived)
+ * (0, 0, 0) ACTIVE 活跃未确认 — 刚触发
+ * (1, 0, 0) ACTIVE 活跃已确认 — 值班处理中
+ * (0, 1, 0) CLEARED 已清除未确认 — 自动恢复,待审计
+ * (1, 1, 0) CLEARED 已清除已确认 — 完整处理链
+ * (*, *, 1) ARCHIVED — 归档不可修改
+ *
+ *
+ * @author B12
+ */
+@TableName(value = "iot_alarm_record", autoResultMap = true)
+@KeySequence("iot_alarm_record_seq")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class IotAlarmRecordDO extends TenantBaseDO {
+
+ /** 主键 */
+ @TableId
+ private Long id;
+
+ /** 告警配置 ID */
+ private Long alarmConfigId;
+
+ /** 告警名称(冗余 config.name,避免反查) */
+ private String alarmName;
+
+ /**
+ * 严重度 1-5 对应 CRITICAL / MAJOR / MINOR / WARNING / INFO
+ *