From 42466363c741c0865cfbb851aaa444e31d6e7551 Mon Sep 17 00:00:00 2001 From: lzh Date: Fri, 24 Apr 2026 00:35:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(iot):=20Wave=204=20Round=201=20=E2=80=94?= =?UTF-8?q?=20B12/B4/B5=20=E5=91=8A=E8=AD=A6=E7=8A=B6=E6=80=81=E6=9C=BA=20?= =?UTF-8?q?+=20=E8=A7=84=E5=88=99=E5=BC=95=E6=93=8E=20SPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主会话 Opus: - B12 iot_alarm_record 正交状态机(ack_state + clear_state + archived) * V2.0.4__iot_alarm_record.sql:主表 + iot_alarm_propagation 关联表 * 评审 C1 正交三字段(替代线性 4 枚举,表达"已清除未确认") * 评审 C2 联合 UK (device_id, alarm_config_id, tenant_id, deleted) * 评审 C3 传播关联表(替代 propagated_to JSON 查询) * Service 5 方法:triggerAlarm / ackAlarm / unackAlarm / clearAlarm / archiveAlarm * 幂等 upsert(trigger_count++)+ 归档后禁止修改 * 13 单元测试全绿 * TODO B14 分布式锁 / B15 传播 / B16 通知 Sonnet subagent B4:TriggerProvider SPI + 5 内置触发器 * spi/TriggerProvider + TriggerProviderManager(@Component + getType 索引,fail-fast 重复 type) * trigger/DeviceState / DeviceProperty / DeviceEvent / DeviceService / Timer(Spring TaskScheduler) * 评审 A3 落地:禁 ServiceLoader / @SPI * 44 单元测试全绿 Sonnet subagent B5:ConditionEvaluator SPI + 3 条件 + 统一模板变量 * spi/ConditionEvaluator + condition/Manager * condition/Expression(Aviator + LRU(256) 编译缓存) * condition/TimeRange(跨午夜支持) * condition/DeviceState(Redis 查询,空值按 offline) * template/TemplateResolver:\${namespace.key},拒绝 \$[...] 旧语法(评审 B5) * TODO B44 完整 8 层 Aviator 沙箱 * 50 单元测试全绿(TemplateResolver 16 + 条件 3x ≈ 34) 测试汇总:rule 136 全绿 / server 13 新增全绿 Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Sonnet (subagent) --- sql/iot/V2.0.4__iot_alarm_record.sql | 64 ++++ .../module/iot/enums/ErrorCodeConstants.java | 7 + .../condition/ConditionEvaluatorManager.java | 68 ++++ .../DeviceStateConditionEvaluator.java | 88 ++++++ .../ExpressionConditionEvaluator.java | 126 ++++++++ .../TimeRangeConditionEvaluator.java | 158 ++++++++++ .../rule/engine/RuleEngineConfiguration.java | 17 + .../iot/rule/spi/ConditionEvaluator.java | 36 +++ .../module/iot/rule/spi/TriggerProvider.java | 55 ++++ .../iot/rule/spi/TriggerProviderManager.java | 75 +++++ .../spi/exception/TriggerMatchException.java | 35 ++ .../iot/rule/template/TemplateResolver.java | 231 ++++++++++++++ .../trigger/DeviceEventTriggerProvider.java | 134 ++++++++ .../DevicePropertyTriggerProvider.java | 109 +++++++ .../trigger/DeviceServiceTriggerProvider.java | 139 ++++++++ .../trigger/DeviceStateTriggerProvider.java | 120 +++++++ .../rule/trigger/TimerTriggerProvider.java | 179 +++++++++++ .../DeviceStateConditionEvaluatorTest.java | 166 ++++++++++ .../ExpressionConditionEvaluatorTest.java | 141 +++++++++ .../TimeRangeConditionEvaluatorTest.java | 197 ++++++++++++ .../rule/template/TemplateResolverTest.java | 164 ++++++++++ .../DeviceEventTriggerProviderTest.java | 102 ++++++ .../DevicePropertyTriggerProviderTest.java | 110 +++++++ .../DeviceServiceTriggerProviderTest.java | 109 +++++++ .../DeviceStateTriggerProviderTest.java | 110 +++++++ .../trigger/TimerTriggerProviderTest.java | 138 ++++++++ .../trigger/TriggerProviderManagerTest.java | 69 ++++ .../alarm/IotAlarmPropagationDO.java | 37 +++ .../dataobject/alarm/IotAlarmRecordDO.java | 118 +++++++ .../dataobject/alarm/enums/AlarmAckState.java | 37 +++ .../alarm/enums/AlarmClearState.java | 37 +++ .../dataobject/alarm/enums/AlarmSeverity.java | 47 +++ .../dal/mysql/alarm/IotAlarmRecordMapper.java | 35 ++ .../service/alarm/IotAlarmRecordService.java | 62 ++++ .../alarm/IotAlarmRecordServiceImpl.java | 207 ++++++++++++ .../dto/AlarmStateTransitionRequest.java | 28 ++ .../alarm/dto/AlarmTriggerRequest.java | 47 +++ .../alarm/IotAlarmRecordServiceImplTest.java | 298 ++++++++++++++++++ 38 files changed, 3900 insertions(+) create mode 100644 sql/iot/V2.0.4__iot_alarm_record.sql create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ConditionEvaluatorManager.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluator.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluator.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluator.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/ConditionEvaluator.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProvider.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProviderManager.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/exception/TriggerMatchException.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/template/TemplateResolver.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProvider.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProvider.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProvider.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProvider.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProvider.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluatorTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluatorTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluatorTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/template/TemplateResolverTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProviderTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProviderTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProviderTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProviderTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProviderTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/TriggerProviderManagerTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmPropagationDO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmRecordDO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmAckState.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmClearState.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/enums/AlarmSeverity.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmRecordMapper.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordService.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/dto/AlarmStateTransitionRequest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/dto/AlarmTriggerRequest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java 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 Map evaluatorsByType; + + public ConditionEvaluatorManager(List evaluators) { + this.evaluatorsByType = evaluators.stream() + .collect(Collectors.toUnmodifiableMap( + ConditionEvaluator::getType, + Function.identity(), + (a, b) -> { + throw new IllegalStateException( + "Duplicate ConditionEvaluator type=" + a.getType() + + ", conflicting class: " + b.getClass().getName()); + })); + log.info("[ConditionEvaluatorManager] Registered {} condition evaluators: {}", + evaluatorsByType.size(), evaluatorsByType.keySet()); + } + + /** + * Evaluate the condition. + * + * @param type condition type (e.g. "expression" / "time_range" / "device_state") + * @param ctx rule execution context + * @param config node configuration JSON + * @return true = satisfied, false = not satisfied + * @throws IllegalArgumentException if no evaluator is registered for the given type + */ + public boolean evaluate(String type, RuleContext ctx, JsonNode config) { + ConditionEvaluator evaluator = evaluatorsByType.get(type); + if (evaluator == null) { + throw new IllegalArgumentException("No ConditionEvaluator registered for type=" + type); + } + return evaluator.evaluate(ctx, config); + } + + /** + * Check if an evaluator is registered for the given type. + */ + public boolean supports(String type) { + return evaluatorsByType.containsKey(type); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluator.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluator.java new file mode 100644 index 00000000..da7f5b7a --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluator.java @@ -0,0 +1,88 @@ +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Device online state condition evaluator. + * + *

Config JSON: + *

{@code
+ * { "state": "online" }   // online / offline
+ * }
+ * + *

Queries Redis key {@code iot:device:online:{deviceId}}: + *

    + *
  • key present -> device is online
  • + *
  • key absent or Redis error -> treated as offline (no exception thrown, per Known Pitfalls)
  • + *
+ * + *

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: + *

    + *
  1. {@link TemplateResolver} converts template vars to Aviator variable names + * (e.g. {@code ${data.temperature}} becomes {@code temperature})
  2. + *
  3. Compiled expression cache: LRU(256), keyed by the Aviator expression string
  4. + *
  5. Aviator sandbox basics: MAX_LOOP_COUNT=1000, DISABLE_ASSIGNMENT=true + * (first-phase basic protection; full 8-layer sandbox deferred to B44)
  6. + *
+ * + *

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 compiledCache = new LinkedHashMap<>(CACHE_MAX_SIZE, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > CACHE_MAX_SIZE; + } + }; + + public ExpressionConditionEvaluator(TemplateResolver templateResolver) { + this.templateResolver = templateResolver; + this.aviator = buildAviatorInstance(); + } + + @Override + public String getType() { + return TYPE; + } + + @Override + public boolean evaluate(RuleContext ctx, JsonNode config) { + String rawExpression = extractExpression(config); + // 1. Convert ${data.x} / ${meta.x} to Aviator variable names (also validates no legacy syntax) + String aviatorExpr = templateResolver.toAviatorExpression(rawExpression); + // 2. Compile (cache hit or new compilation) + Expression compiled = getOrCompile(aviatorExpr); + // 3. Build variable environment map + Map env = templateResolver.resolveToEnvMap(rawExpression, ctx); + // 4. Execute + Object result = compiled.execute(env); + if (result instanceof Boolean b) { + return b; + } + log.warn("[ExpressionConditionEvaluator] Expression result is not Boolean, type={}, value={}, defaulting to false", + result == null ? "null" : result.getClass().getSimpleName(), result); + return false; + } + + // ---- Private methods ---- + + private String extractExpression(JsonNode config) { + if (config == null || !config.has("expression")) { + throw new IllegalArgumentException( + "ExpressionConditionEvaluator: config is missing 'expression' field"); + } + String expr = config.get("expression").asText(); + if (expr == null || expr.isBlank()) { + throw new IllegalArgumentException( + "ExpressionConditionEvaluator: expression cannot be blank"); + } + return expr; + } + + private Expression getOrCompile(String aviatorExpr) { + // LinkedHashMap is not thread-safe; synchronized block protects concurrent access. + // Cost is negligible (pure memory operation < 1us). + synchronized (compiledCache) { + return compiledCache.computeIfAbsent(aviatorExpr, expr -> { + log.debug("[ExpressionConditionEvaluator] Compiling and caching expression: {}", expr); + return aviator.compile(expr, true); + }); + } + } + + /** + * Build Aviator instance with basic sandbox configuration. + * Phase 1: basic protection only; full 8-layer sandbox deferred to B44. + */ + private static AviatorEvaluatorInstance buildAviatorInstance() { + AviatorEvaluatorInstance instance = AviatorEvaluator.newInstance(); + // Maximum loop iterations (prevents infinite while/for loops) + instance.setOption(Options.MAX_LOOP_COUNT, 1000L); + // TODO B44: Aviator 5.3 没有 DISABLE_ASSIGNMENT_OPERATOR; + // 完整 8 层沙箱(禁赋值 / 黑名单类 / 资源上限)由 B44 实现 + return instance; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluator.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluator.java new file mode 100644 index 00000000..4db51648 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluator.java @@ -0,0 +1,158 @@ +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Time range condition evaluator. + * + *

Config JSON: + *

{@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
+ * }
+ * }
+ * + *

Known pitfalls: + *

    + *
  • Cross-midnight scenario (startTime > endTime, e.g. 22:00-06:00): + * split into two segments [22:00, 24:00) U [00:00, 06:00)
  • + *
  • holiday mode is not supported in phase 1; returns false with a warning log + * (holiday calendar data source is a separate task)
  • + *
+ * + *

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 daysOfWeek = parseDaysOfWeek(config); + if (daysOfWeek.isEmpty()) { + // No daysOfWeek configured, fall back to daily behavior + return true; + } + int todayIso = now.getDayOfWeek().getValue(); // 1=Monday, 7=Sunday (ISO-8601) + return daysOfWeek.contains(todayIso); + } + + /** + * Check whether currentTime falls within [start, end). + * Supports cross-midnight: when start > end, treated as [start, 24:00) U [00:00, end). + */ + static boolean isInTimeRange(LocalTime currentTime, LocalTime start, LocalTime end) { + if (start == null || end == null) { + return false; + } + if (!start.isAfter(end)) { + // Normal range (not including 00:00-00:00 special case) + return !currentTime.isBefore(start) && currentTime.isBefore(end); + } else { + // Cross-midnight: two segments + // Segment 1: [start, 24:00) -> currentTime >= start + // Segment 2: [00:00, end) -> currentTime < end + return !currentTime.isBefore(start) || currentTime.isBefore(end); + } + } + + private LocalTime parseTime(JsonNode config, String field) { + String val = config.path(field).asText(null); + if (val == null || val.isBlank()) { + throw new IllegalArgumentException( + "TimeRangeConditionEvaluator: missing field '" + field + "'"); + } + try { + return LocalTime.parse(val); + } catch (Exception e) { + throw new IllegalArgumentException( + "TimeRangeConditionEvaluator: field '" + field + + "' has invalid time format (expected HH:mm), actual=" + val); + } + } + + private List parseDaysOfWeek(JsonNode config) { + JsonNode arr = config.get("daysOfWeek"); + List result = new ArrayList<>(); + if (arr != null && arr.isArray()) { + for (JsonNode item : arr) { + int day = item.asInt(); + if (day >= DayOfWeek.MONDAY.getValue() && day <= DayOfWeek.SUNDAY.getValue()) { + result.add(day); + } else { + log.warn("[TimeRangeConditionEvaluator] Invalid daysOfWeek value {} (expected 1-7), ignored", day); + } + } + } + return result; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/RuleEngineConfiguration.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/RuleEngineConfiguration.java index f6cd6b3e..11fbf33d 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/RuleEngineConfiguration.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/RuleEngineConfiguration.java @@ -6,6 +6,8 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import java.util.Collections; import java.util.List; @@ -52,4 +54,19 @@ public class RuleEngineConfiguration { public RuleEngine ruleEngine(ChainIndex chainIndex, DagExecutor dagExecutor, MeterRegistry meterRegistry) { return new DefaultRuleEngine(chainIndex, dagExecutor, meterRegistry); } + + /** + * 提供 TaskScheduler Bean 供 TimerTriggerProvider 使用。 + * 若外部已提供(如 Spring Boot Actuator / @EnableScheduling),则使用外部的。 + */ + @Bean + @ConditionalOnMissingBean(TaskScheduler.class) + public TaskScheduler ruleEngineTaskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(4); + scheduler.setThreadNamePrefix("iot-rule-timer-"); + scheduler.setWaitForTasksToCompleteOnShutdown(false); + scheduler.initialize(); + return scheduler; + } } diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/ConditionEvaluator.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/ConditionEvaluator.java new file mode 100644 index 00000000..4c157d7e --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/ConditionEvaluator.java @@ -0,0 +1,36 @@ +package com.viewsh.module.iot.rule.spi; + +import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.rule.engine.RuleContext; + +/** + * Condition evaluator SPI. + * + *

Implementations register via Spring {@code @Component} and are routed by + * {@link com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager} using {@link #getType()}. + * + *

Convention (review B5): + *

    + *
  • Template variables use unified {@code ${data.x}} / {@code ${meta.x}} format; + * old {@code $[identifier]} syntax is prohibited.
  • + *
  • Each evaluate call must be idempotent. RuntimeException is handled by the caller.
  • + *
  • p99 < 2ms - cache expensive objects (e.g. compiled expressions).
  • + *
+ */ +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 接口。 + * + *

触发器是 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 providers = new ConcurrentHashMap<>(); + + /** + * Spring 自动注入所有 {@link TriggerProvider} Bean 并建立类型索引。 + * + * @param list Spring 容器中所有 TriggerProvider 实现(required=false 允许无实现时启动) + */ + @Autowired(required = false) + public void autoRegister(List list) { + if (list == null || list.isEmpty()) { + log.warn("[TriggerProviderManager] 未发现任何 TriggerProvider 实现"); + return; + } + for (TriggerProvider p : list) { + TriggerProvider dup = providers.put(p.getType(), p); + if (dup != null) { + // Fail-fast:重复 type 立即抛出,阻止应用启动 + throw new IllegalStateException( + "duplicate trigger type: " + p.getType() + + " — " + dup.getClass().getName() + " vs " + p.getClass().getName()); + } + } + log.info("[TriggerProviderManager] 已注册 {} 个 TriggerProvider:{}", providers.size(), providers.keySet()); + } + + /** + * 按 type 查找 TriggerProvider。 + * + * @param type 触发器类型(如 "device_state") + * @return 对应 Provider + * @throws ServiceException 如果 type 未注册 + */ + public TriggerProvider get(String type) { + TriggerProvider p = providers.get(type); + if (p == null) { + throw new ServiceException(TRIGGER_TYPE_NOT_FOUND); + } + return p; + } + + /** + * 是否存在指定 type 的 Provider。 + */ + public boolean contains(String type) { + return providers.containsKey(type); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/exception/TriggerMatchException.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/exception/TriggerMatchException.java new file mode 100644 index 00000000..e72aa41c --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/exception/TriggerMatchException.java @@ -0,0 +1,35 @@ +package com.viewsh.module.iot.rule.spi.exception; + +/** + * 触发器匹配过程中的异常。 + * + *

当触发器配置非法(如 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}}): + *

    + *
  • {@code ${data.temperature}} -> field from message.data / message.params
  • + *
  • {@code ${data}} -> entire message data
  • + *
  • {@code ${meta.deviceName}} -> ctx.metadata.deviceName
  • + *
  • {@code ${metadata.deviceName}} -> same as meta (both aliases supported)
  • + *
+ * + *

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 resolveToEnvMap(String template, RuleContext ctx) { + validateNoLegacySyntax(template); + java.util.LinkedHashMap env = new java.util.LinkedHashMap<>(); + Matcher m = TEMPLATE_PATTERN.matcher(template); + while (m.find()) { + String path = m.group(1); // e.g. data.temperature + Object val = resolveVariable(path, ctx); + // Aviator variable name = key after namespace prefix + String varName = toAviatorVar(path); + env.put(varName, val); + } + return env; + } + + /** + * Convert a template string to an Aviator expression by replacing + * {@code ${data.x}} with the corresponding variable name {@code x}. + */ + public String toAviatorExpression(String template) { + validateNoLegacySyntax(template); + Matcher m = TEMPLATE_PATTERN.matcher(template); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String varName = toAviatorVar(m.group(1)); + m.appendReplacement(sb, Matcher.quoteReplacement(varName)); + } + m.appendTail(sb); + return sb.toString(); + } + + // ---- Private methods ---- + + private void validateNoLegacySyntax(String template) { + if (template != null && LEGACY_PATTERN.matcher(template).find()) { + throw new TemplateSyntaxException( + "Template contains deprecated $[...] syntax, use ${namespace.key} instead: " + template); + } + } + + private boolean isSinglePlaceholder(String template) { + Matcher m = TEMPLATE_PATTERN.matcher(template); + if (!m.find()) return false; + // Entire string is ${...} with nothing before or after + return m.start() == 0 && m.end() == template.length() && !m.find(); + } + + /** + * Resolve variable path, e.g. {@code data.temperature}, {@code meta.deviceName}, {@code data}. + */ + Object resolveVariable(String path, RuleContext ctx) { + if (path == null || path.isBlank()) return null; + + int dotIdx = path.indexOf('.'); + String ns = dotIdx < 0 ? path : path.substring(0, dotIdx); + String key = dotIdx < 0 ? null : path.substring(dotIdx + 1); + + return switch (ns) { + case "data" -> resolveFromData(key, ctx); + case "meta", "metadata" -> resolveFromMeta(key, ctx); + default -> { + log.debug("[TemplateResolver] Unknown namespace '{}' in path '{}', returning null", ns, path); + yield null; + } + }; + } + + private Object resolveFromData(String key, RuleContext ctx) { + if (ctx.getMessage() == null) return null; + // Use data or params (device property reports are typically in params) + Object dataObj = ctx.getMessage().getData() != null + ? ctx.getMessage().getData() + : ctx.getMessage().getParams(); + if (key == null) { + // ${data} -> entire message data portion + return dataObj; + } + return extractFromObject(dataObj, key); + } + + private Object resolveFromMeta(String key, RuleContext ctx) { + if (key == null) return ctx.getMetadata(); + return ctx.getMetadata().get(key); + } + + @SuppressWarnings("unchecked") + private Object extractFromObject(Object obj, String key) { + if (obj == null) return null; + // Support nested paths, e.g. data.sensor.temperature + String[] parts = key.split("\\.", 2); + String first = parts[0]; + Object val; + if (obj instanceof Map) { + val = ((Map) obj).get(first); + } else if (obj instanceof JsonNode) { + JsonNode node = ((JsonNode) obj).get(first); + val = node == null ? null : jsonNodeToJava(node); + } else { + // Try converting via ObjectMapper to Map + try { + Map map = objectMapper.convertValue(obj, objectMapper.getTypeFactory() + .constructMapType(Map.class, String.class, Object.class)); + val = map.get(first); + } catch (Exception e) { + log.debug("[TemplateResolver] Cannot extract field '{}' from {}: {}", + first, obj.getClass().getSimpleName(), e.getMessage()); + return null; + } + } + if (parts.length > 1 && val != null) { + return extractFromObject(val, parts[1]); + } + return val; + } + + private Object jsonNodeToJava(JsonNode node) { + if (node.isNull()) return null; + if (node.isBoolean()) return node.booleanValue(); + if (node.isInt()) return node.intValue(); + if (node.isLong()) return node.longValue(); + if (node.isDouble() || node.isFloat()) return node.doubleValue(); + if (node.isTextual()) return node.textValue(); + return node; // complex types remain as JsonNode + } + + /** + * Convert path to Aviator variable name (take the part after first '.', replace remaining '.' with '_'). + * Example: {@code data.temperature} -> {@code temperature}, + * {@code data.sensor.temp} -> {@code sensor_temp} + */ + String toAviatorVar(String path) { + int dotIdx = path.indexOf('.'); + if (dotIdx < 0) return path; + String rest = path.substring(dotIdx + 1); + // Replace remaining '.' with '_' to avoid Aviator parsing nested access + return rest.replace('.', '_'); + } + + // ---- Inner exception ---- + + /** + * Template syntax error (legacy $[...] syntax or other invalid format). + */ + public static class TemplateSyntaxException extends RuntimeException { + public TemplateSyntaxException(String message) { + super(message); + } + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProvider.java new file mode 100644 index 00000000..ebc14085 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProvider.java @@ -0,0 +1,134 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.engine.RuleContext; +import com.viewsh.module.iot.rule.spi.TriggerProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 设备事件上报触发器(device_event)。 + * + *

配置示例: + *

+ * { "event": "alarm", "productId": 10 }
+ * 
+ * 或多事件: + *
+ * { "events": ["alarm", "fault"], "productId": 10 }
+ * 
+ * + *

匹配条件: + *

    + *
  1. 消息 method 必须是 {@code thing.event.post}(事件上报)
  2. + *
  3. 若 config.productId 存在,消息的 productId 必须匹配
  4. + *
  5. 消息的事件标识(从 params.identifier 或 method suffix 提取)在配置的 event/events 中
  6. + *
+ * + *

事件标识提取优先级: + * 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 configEvents = buildConfigEventSet(config); + if (configEvents.isEmpty()) { + // 未配置具体事件,匹配所有事件上报 + return true; + } + + // 4. 从消息 params 中提取事件标识 + String msgEventId = extractEventIdentifier(msg); + return msgEventId != null && configEvents.contains(msgEventId); + } + + /** + * 从配置中构建事件标识集合。 + * 支持单事件 config.event 和多事件 config.events。 + */ + private Set buildConfigEventSet(JsonNode config) { + Set events = new HashSet<>(); + + // 单事件字段 + if (config.hasNonNull("event")) { + events.add(config.get("event").asText()); + } + + // 多事件数组 + JsonNode eventsNode = config.get("events"); + if (eventsNode != null && eventsNode.isArray()) { + for (JsonNode e : eventsNode) { + events.add(e.asText()); + } + } + + return events; + } + + /** + * 从消息 params 中提取事件标识符。 + * 阿里云 Alink 协议:params.identifier 字段。 + */ + @SuppressWarnings("unchecked") + private String extractEventIdentifier(IotDeviceMessage msg) { + Object params = msg.getParams(); + if (params == null) { + return null; + } + + if (params instanceof Map map) { + Object identifier = map.get("identifier"); + if (identifier != null) { + return String.valueOf(identifier); + } + Object eventId = map.get("eventId"); + if (eventId != null) { + return String.valueOf(eventId); + } + return null; + } + + if (params instanceof JsonNode jsonNode) { + if (jsonNode.hasNonNull("identifier")) { + return jsonNode.get("identifier").asText(); + } + if (jsonNode.hasNonNull("eventId")) { + return jsonNode.get("eventId").asText(); + } + return null; + } + + return null; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProvider.java new file mode 100644 index 00000000..25ba9ba9 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProvider.java @@ -0,0 +1,109 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.engine.RuleContext; +import com.viewsh.module.iot.rule.spi.TriggerProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 设备属性上报触发器(device_property)。 + * + *

配置示例: + *

+ * { "identifiers": ["temperature", "humidity"], "productId": 10 }
+ * 
+ * + *

匹配条件: + *

    + *
  1. 消息 method 必须是 {@code thing.property.post}(属性上报)
  2. + *
  3. 若 config.productId 存在,消息的 productId 必须匹配
  4. + *
  5. config.identifiers 中至少有一个属性标识符出现在消息 params 中
  6. + *
+ * + *

性能注意事项(评审 ⚠️ 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 identifiers = new HashSet<>(); + for (JsonNode id : identifiersNode) { + identifiers.add(id.asText()); + } + + // 检查消息 params 中是否包含任意一个 identifier + return hasAnyIdentifier(msg.getParams(), identifiers); + } + + /** + * 检查消息 params 中是否包含 identifiers 中的任一 key。 + * params 通常是 Map<String, Object> 或 JsonNode。 + */ + @SuppressWarnings("unchecked") + private boolean hasAnyIdentifier(Object params, Set identifiers) { + if (params == null) { + return false; + } + + if (params instanceof Map map) { + // O(min(|identifiers|, |params|)) — 用 identifiers 的 Set 做 contains 更快 + for (Object key : map.keySet()) { + if (identifiers.contains(String.valueOf(key))) { + return true; + } + } + return false; + } + + if (params instanceof JsonNode jsonNode && jsonNode.isObject()) { + // 遍历 params 的 field names,在 O(1) set 中查找 + var iter = jsonNode.fieldNames(); + while (iter.hasNext()) { + if (identifiers.contains(iter.next())) { + return true; + } + } + return false; + } + + return false; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProvider.java new file mode 100644 index 00000000..a48be5d8 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProvider.java @@ -0,0 +1,139 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.engine.RuleContext; +import com.viewsh.module.iot.rule.spi.TriggerProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 设备服务调用回复触发器(device_service)。 + * + *

配置示例: + *

+ * { "method": "reset", "productId": 10 }
+ * 
+ * 或多服务: + *
+ * { "methods": ["reset", "reboot"], "productId": 10 }
+ * 
+ * + *

匹配条件: + *

    + *
  1. 消息 method 必须是 {@code thing.service.invoke}(服务调用)
  2. + *
  3. 消息必须是服务调用回复(code != null 或 data != null 表示这是响应方向)
  4. + *
  5. 若 config.productId 存在,消息的 productId 必须匹配
  6. + *
  7. 消息的服务方法名(params.identifier 或 params.method)在配置的 method/methods 中
  8. + *
+ * + *

任务卡测试用例: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 configMethods = buildConfigMethodSet(config); + if (configMethods.isEmpty()) { + // 未配置具体方法,匹配所有服务调用回复 + return true; + } + + // 5. 从消息 params 中提取服务方法标识 + String msgMethod = extractServiceMethod(msg); + return msgMethod != null && configMethods.contains(msgMethod); + } + + /** + * 从配置中构建服务方法标识集合。 + * 支持单方法 config.method 和多方法 config.methods。 + */ + private Set buildConfigMethodSet(JsonNode config) { + Set methods = new HashSet<>(); + + if (config.hasNonNull("method")) { + methods.add(config.get("method").asText()); + } + + JsonNode methodsNode = config.get("methods"); + if (methodsNode != null && methodsNode.isArray()) { + for (JsonNode m : methodsNode) { + methods.add(m.asText()); + } + } + + return methods; + } + + /** + * 从消息 params 中提取服务方法标识。 + * 优先从 params.identifier 取,其次 params.method。 + */ + @SuppressWarnings("unchecked") + private String extractServiceMethod(IotDeviceMessage msg) { + Object params = msg.getParams(); + if (params instanceof Map map) { + Object identifier = map.get("identifier"); + if (identifier != null) { + return String.valueOf(identifier); + } + Object method = map.get("method"); + if (method != null) { + return String.valueOf(method); + } + } + if (params instanceof JsonNode jsonNode) { + if (jsonNode.hasNonNull("identifier")) { + return jsonNode.get("identifier").asText(); + } + if (jsonNode.hasNonNull("method")) { + return jsonNode.get("method").asText(); + } + } + // 如果 params 里没有,尝试从 data 中取 + Object data = msg.getData(); + if (data instanceof Map dataMap) { + Object identifier = dataMap.get("identifier"); + if (identifier != null) { + return String.valueOf(identifier); + } + } + return null; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProvider.java new file mode 100644 index 00000000..3503f06c --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProvider.java @@ -0,0 +1,120 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.engine.RuleContext; +import com.viewsh.module.iot.rule.spi.TriggerProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Set; + +/** + * 设备状态触发器(device_state)。 + * + *

配置示例: + *

+ * { "events": ["online", "offline"], "productId": 10 }
+ * 
+ * + *

匹配条件: + *

    + *
  1. 消息 method 必须是 {@code thing.state.update}
  2. + *
  3. 消息 params.state 对应的文本标识在 config.events 集合中
  4. + *
  5. 若 config.productId 存在,消息的 productId 必须匹配(快速过滤,O(1))
  6. + *
+ * + *

状态值映射(参照 {@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 events = new HashSet<>(); + for (JsonNode e : eventsNode) { + events.add(e.asText()); + } + + // 从 params 中获取状态 + String stateText = extractStateText(msg); + return stateText != null && events.contains(stateText); + } + + /** + * 从消息 params 中提取状态文本标识。 + * params 格式:{"state": 1} 或 {"state": "online"} + */ + private String extractStateText(IotDeviceMessage msg) { + Object params = msg.getParams(); + if (params == null) { + return null; + } + + // params 可能是 Map(反序列化后)或 JsonNode + if (params instanceof java.util.Map map) { + Object stateVal = map.get("state"); + if (stateVal == null) { + return null; + } + return mapStateToText(stateVal); + } + + if (params instanceof JsonNode jsonNode) { + JsonNode stateNode = jsonNode.get("state"); + if (stateNode == null) { + return null; + } + return mapStateToText(stateNode.isNumber() ? stateNode.asInt() : stateNode.asText()); + } + + return null; + } + + private String mapStateToText(Object stateVal) { + if (stateVal instanceof String s) { + return s.toLowerCase(); + } + if (stateVal instanceof Number n) { + int state = n.intValue(); + return switch (state) { + case 1 -> "online"; + case 2 -> "offline"; + default -> String.valueOf(state); + }; + } + return stateVal.toString().toLowerCase(); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProvider.java new file mode 100644 index 00000000..0eabe85b --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProvider.java @@ -0,0 +1,179 @@ +package com.viewsh.module.iot.rule.trigger; + +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; +import com.viewsh.module.iot.rule.spi.TriggerProvider; +import com.viewsh.module.iot.rule.spi.exception.TriggerMatchException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.TimeZone; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; + +/** + * 定时触发器(timer)。 + * + *

配置示例: + *

{@code
+ * { "cron": "0 * /5 * * * ?", "timezone": "Asia/Shanghai" }
+ * }
+ * + *

调度机制:使用 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> scheduledTasks = new ConcurrentHashMap<>(); + + public TimerTriggerProvider(TaskScheduler taskScheduler) { + this.taskScheduler = taskScheduler; + } + + @Override + public String getType() { + return TYPE; + } + + /** + * Timer 触发器不通过消息匹配触发,始终返回 false。 + * Timer 触发走 CRON 调度回调路径。 + */ + @Override + public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) { + return false; + } + + /** + * 注册规则链的 CRON 定时任务。 + * + * @param chain 编译后的规则链 + * @param config timer 节点的配置(含 cron 和可选 timezone) + * @throws TriggerMatchException 如果 cron 表达式无效或 timezone 非法 + */ + @Override + public void register(CompiledRuleChain chain, JsonNode config) { + String cron = extractCron(chain.getId(), config); + String timezone = extractTimezone(config); + + log.info("[TimerTrigger] register chainId={} cron={} timezone={}", chain.getId(), cron, timezone); + + // 如果已有任务,先取消(防止重复注册) + cancelExisting(chain.getId()); + + try { + CronTrigger cronTrigger = new CronTrigger(cron, TimeZone.getTimeZone(timezone)); + ScheduledFuture future = taskScheduler.schedule( + () -> onTimerFire(chain), + cronTrigger + ); + scheduledTasks.put(chain.getId(), future); + } catch (IllegalArgumentException e) { + throw new TriggerMatchException(TYPE, chain.getId(), + "CRON 表达式无效: " + cron + " — " + e.getMessage(), e); + } + } + + /** + * 注销规则链的 CRON 定时任务。规则链 disable/delete 时调用。 + */ + @Override + public void unregister(CompiledRuleChain chain) { + log.info("[TimerTrigger] unregister chainId={}", chain.getId()); + cancelExisting(chain.getId()); + } + + /** + * Bean 销毁时取消所有残留的定时任务(兜底,避免内存泄漏)。 + */ + @Override + public void destroy() { + log.info("[TimerTrigger] Bean destroy, cancelling {} tasks", scheduledTasks.size()); + scheduledTasks.forEach((chainId, future) -> { + if (future != null && !future.isCancelled()) { + future.cancel(false); + } + }); + scheduledTasks.clear(); + } + + /** + * CRON 触发回调。由 TaskScheduler 线程调用。 + * 实际调用 RuleEngine 执行规则链(当前是日志桩,集成时由 B8 接入 RuleEngine)。 + * + * TODO: B8 集成时注入 RuleEngine,在此处触发规则链执行并设置正确的 tenant_id。 + */ + protected void onTimerFire(CompiledRuleChain chain) { + log.debug("[TimerTrigger] fired chainId={} tenantId={}", chain.getId(), chain.getTenantId()); + // B8 集成时实现:ruleEngine.executeTimerChain(chain) + } + + private void cancelExisting(Long chainId) { + ScheduledFuture old = scheduledTasks.remove(chainId); + if (old != null && !old.isCancelled()) { + old.cancel(false); + } + } + + private String extractCron(Long chainId, JsonNode config) { + JsonNode cronNode = config.get("cron"); + if (cronNode == null || cronNode.isNull() || cronNode.asText().isBlank()) { + throw new TriggerMatchException(TYPE, chainId, "timer 触发器缺少必须的 cron 表达式"); + } + return cronNode.asText().trim(); + } + + private String extractTimezone(JsonNode config) { + if (config.hasNonNull("timezone")) { + String tz = config.get("timezone").asText(); + if (!tz.isBlank()) { + return tz; + } + } + return DEFAULT_TIMEZONE; + } + + /** + * 获取当前已注册的定时任务数量(用于测试和监控)。 + */ + public int getScheduledTaskCount() { + return scheduledTasks.size(); + } + + /** + * 判断指定规则链是否已注册定时任务(用于测试)。 + */ + public boolean isRegistered(Long chainId) { + ScheduledFuture future = scheduledTasks.get(chainId); + return future != null && !future.isCancelled() && !future.isDone(); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluatorTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluatorTest.java new file mode 100644 index 00000000..d279d752 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluatorTest.java @@ -0,0 +1,166 @@ +package com.viewsh.module.iot.rule.condition; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.viewsh.framework.test.core.ut.BaseMockitoUnitTest; +import com.viewsh.module.iot.rule.engine.RuleContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link DeviceStateConditionEvaluator} 单元测试。 + * + *

覆盖任务卡 B5 §6 用例: + *

    + *
  • device_online:Redis 有 key → true
  • + *
  • device_online_missing:Redis 无 key → false(不抛错)
  • + *
  • device_offline:Redis 无 key + 期望 offline → true
  • + *
  • Redis 抛异常容错(视为 offline)
  • + *
  • deviceId 为 null → false
  • + *
+ */ +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}. + * + *

Covers B5 task card section 6 cases: + *

    + *
  • expr_simple: ${data.temp} > 40 with temp=45 -> true
  • + *
  • expr_and: ${data.temp} > 40 && ${data.hum} < 20 with temp=45, hum=15 -> true
  • + *
  • expr_meta_var: ${meta.subsystemCode} == 'clean' -> true
  • + *
  • expr_template_legacy: $[temp] > 40 -> throws TemplateSyntaxException
  • + *
  • Compiled cache: same expression is not recompiled
  • + *
+ */ +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 data) { + RuleContext ctx = new RuleContext(); + ctx.setMessage(IotDeviceMessage.builder().data(data).build()); + return ctx; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluatorTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluatorTest.java new file mode 100644 index 00000000..473940cd --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluatorTest.java @@ -0,0 +1,197 @@ +package com.viewsh.module.iot.rule.condition; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.viewsh.module.iot.rule.engine.RuleContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalTime; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link TimeRangeConditionEvaluator}. + * + *

Covers B5 task card section 6 cases: + *

    + *
  • time_weekday: weekday 9-18 time window
  • + *
  • time_cross_midnight: cross-midnight 22:00-06:00
  • + *
  • isInTimeRange static method tested directly (no Spring context needed)
  • + *
+ */ +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}. + * + *

Covers B5 task card section 6 template-related cases: + *

    + *
  • ${data.x}, ${meta.x}, ${metadata.x} prefixes
  • + *
  • Nested paths (${data.sensor.temp})
  • + *
  • Missing variables return null
  • + *
  • Legacy $[...] syntax throws exception
  • + *
+ */ +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 env = resolver.resolveToEnvMap( + "${data.temp} > 40 && ${data.hum} < 20", ctx); + assertEquals(45, env.get("temp")); + assertEquals(15, env.get("hum")); + } + + @Test + void resolveToEnvMap_mixedPrefixes() { + RuleContext ctx = ctxWithMeta("code", "clean"); + ctx.setMessage(IotDeviceMessage.builder().data(Map.of("level", 5)).build()); + Map env = resolver.resolveToEnvMap( + "${meta.code} == 'clean' && ${data.level} > 3", ctx); + assertEquals("clean", env.get("code")); + assertEquals(5, env.get("level")); + } + + // ---- toAviatorVar ---- + + @Test + void toAviatorVar_simpleKey() { + assertEquals("temperature", resolver.toAviatorVar("data.temperature")); + } + + @Test + void toAviatorVar_nestedKey_replaceDotWithUnderscore() { + assertEquals("sensor_temp", resolver.toAviatorVar("data.sensor.temp")); + } + + @Test + void toAviatorVar_noPrefix() { + assertEquals("data", resolver.toAviatorVar("data")); + } + + // ---- helpers ---- + + private RuleContext ctxWithData(Map data) { + RuleContext ctx = new RuleContext(); + ctx.setMessage(IotDeviceMessage.builder().data(data).build()); + return ctx; + } + + private RuleContext ctxWithMeta(String key, Object value) { + RuleContext ctx = new RuleContext(); + ctx.getMetadata().put(key, value); + return ctx; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProviderTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProviderTest.java new file mode 100644 index 00000000..14f890a6 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProviderTest.java @@ -0,0 +1,102 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +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.assertj.core.api.Assertions.assertThat; + +/** + * {@link DeviceEventTriggerProvider} 单元测试。 + * 任务卡 §6:device_event matches 正反例。 + */ +class DeviceEventTriggerProviderTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private DeviceEventTriggerProvider provider; + private RuleContext ctx; + + @BeforeEach + void setUp() { + provider = new DeviceEventTriggerProvider(); + ctx = new RuleContext(); + ctx.setTenantId(1L); + ctx.setProductId(10L); + ctx.setDeviceId(100L); + } + + // ===== 正例 ===== + + @Test + void testMatch_eventMatches() throws Exception { + // 任务卡:event="alarm", msg.event="alarm" → match + JsonNode config = MAPPER.readTree("{\"event\":\"alarm\"}"); + IotDeviceMessage msg = buildEventMsg("alarm"); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_eventsArrayContainsEvent() throws Exception { + JsonNode config = MAPPER.readTree("{\"events\":[\"alarm\",\"fault\"]}"); + IotDeviceMessage msg = buildEventMsg("fault"); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_noEventConfig_matchesAll() throws Exception { + JsonNode config = MAPPER.readTree("{}"); + IotDeviceMessage msg = buildEventMsg("any_event"); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + // ===== 反例 ===== + + @Test + void testNoMatch_eventNotInConfig() throws Exception { + JsonNode config = MAPPER.readTree("{\"event\":\"alarm\"}"); + IotDeviceMessage msg = buildEventMsg("fault"); // "fault" != "alarm" + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_wrongMethod() throws Exception { + JsonNode config = MAPPER.readTree("{\"event\":\"alarm\"}"); + IotDeviceMessage msg = IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) + .params(Map.of("identifier", "alarm")) + .build(); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_productIdMismatch() throws Exception { + JsonNode config = MAPPER.readTree("{\"event\":\"alarm\",\"productId\":99}"); + ctx.setProductId(10L); + IotDeviceMessage msg = buildEventMsg("alarm"); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_nullParams() throws Exception { + JsonNode config = MAPPER.readTree("{\"event\":\"alarm\"}"); + IotDeviceMessage msg = IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.EVENT_POST.getMethod()) + .params(null) + .build(); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + private IotDeviceMessage buildEventMsg(String eventIdentifier) { + return IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.EVENT_POST.getMethod()) + .params(Map.of("identifier", eventIdentifier)) + .build(); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProviderTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProviderTest.java new file mode 100644 index 00000000..2e3b3e3c --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProviderTest.java @@ -0,0 +1,110 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +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.assertj.core.api.Assertions.assertThat; + +/** + * {@link DevicePropertyTriggerProvider} 单元测试。 + * 任务卡 §6:device_property matches 正反例。 + */ +class DevicePropertyTriggerProviderTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private DevicePropertyTriggerProvider provider; + private RuleContext ctx; + + @BeforeEach + void setUp() { + provider = new DevicePropertyTriggerProvider(); + ctx = new RuleContext(); + ctx.setTenantId(1L); + ctx.setProductId(10L); + ctx.setDeviceId(100L); + } + + // ===== 正例 ===== + + @Test + void testMatch_identifierPresent_matches() throws Exception { + // 任务卡:identifiers=[temp], msg.data.temp=25 → match + JsonNode config = MAPPER.readTree("{\"identifiers\":[\"temperature\"]}"); + IotDeviceMessage msg = buildPropertyMsg(Map.of("temperature", 25)); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_multipleIdentifiers_onePresent_matches() throws Exception { + JsonNode config = MAPPER.readTree("{\"identifiers\":[\"temperature\",\"humidity\"]}"); + IotDeviceMessage msg = buildPropertyMsg(Map.of("humidity", 60)); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_emptyIdentifiers_matchesAnyProperty() throws Exception { + JsonNode config = MAPPER.readTree("{\"identifiers\":[]}"); + IotDeviceMessage msg = buildPropertyMsg(Map.of("anyProp", 1)); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_noIdentifiersKey_matchesAnyProperty() throws Exception { + JsonNode config = MAPPER.readTree("{}"); + IotDeviceMessage msg = buildPropertyMsg(Map.of("anyProp", 1)); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + // ===== 反例 ===== + + @Test + void testNoMatch_identifierNotPresent() throws Exception { + // 任务卡:identifiers=[temp], msg.data.humidity=60 → no match + JsonNode config = MAPPER.readTree("{\"identifiers\":[\"temperature\"]}"); + IotDeviceMessage msg = buildPropertyMsg(Map.of("humidity", 60)); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_wrongMethod() throws Exception { + JsonNode config = MAPPER.readTree("{\"identifiers\":[\"temperature\"]}"); + IotDeviceMessage msg = IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.EVENT_POST.getMethod()) + .params(Map.of("temperature", 25)) + .build(); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_productIdMismatch() throws Exception { + JsonNode config = MAPPER.readTree("{\"identifiers\":[\"temperature\"],\"productId\":99}"); + ctx.setProductId(10L); // 10 != 99 + IotDeviceMessage msg = buildPropertyMsg(Map.of("temperature", 25)); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_nullParams() throws Exception { + JsonNode config = MAPPER.readTree("{\"identifiers\":[\"temperature\"]}"); + IotDeviceMessage msg = IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) + .params(null) + .build(); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + private IotDeviceMessage buildPropertyMsg(Map properties) { + return IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) + .params(properties) + .build(); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProviderTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProviderTest.java new file mode 100644 index 00000000..742da979 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProviderTest.java @@ -0,0 +1,109 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +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.assertj.core.api.Assertions.assertThat; + +/** + * {@link DeviceServiceTriggerProvider} 单元测试。 + * 任务卡 §6:device_service matches 正反例。 + */ +class DeviceServiceTriggerProviderTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private DeviceServiceTriggerProvider provider; + private RuleContext ctx; + + @BeforeEach + void setUp() { + provider = new DeviceServiceTriggerProvider(); + ctx = new RuleContext(); + ctx.setTenantId(1L); + ctx.setProductId(10L); + ctx.setDeviceId(100L); + } + + // ===== 正例 ===== + + @Test + void testMatch_method_reset_reply() throws Exception { + // 任务卡:method="reset", msg.method="reset", msg.reply=true → match + JsonNode config = MAPPER.readTree("{\"method\":\"reset\"}"); + IotDeviceMessage msg = buildServiceReplyMsg("reset"); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_methodsArray_matches() throws Exception { + JsonNode config = MAPPER.readTree("{\"methods\":[\"reset\",\"reboot\"]}"); + IotDeviceMessage msg = buildServiceReplyMsg("reboot"); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_noMethodConfig_matchesAllReplies() throws Exception { + JsonNode config = MAPPER.readTree("{}"); + IotDeviceMessage msg = buildServiceReplyMsg("anyMethod"); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + // ===== 反例 ===== + + @Test + void testNoMatch_notAReply() throws Exception { + // msg has no code and no data = not a reply + JsonNode config = MAPPER.readTree("{\"method\":\"reset\"}"); + IotDeviceMessage msg = IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()) + .params(Map.of("identifier", "reset")) + // no code, no data — request, not reply + .build(); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_wrongMethod() throws Exception { + JsonNode config = MAPPER.readTree("{\"method\":\"reset\"}"); + IotDeviceMessage msg = IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) + .params(Map.of("identifier", "reset")) + .code(200) + .build(); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_methodMismatch() throws Exception { + JsonNode config = MAPPER.readTree("{\"method\":\"reset\"}"); + IotDeviceMessage msg = buildServiceReplyMsg("reboot"); // "reboot" != "reset" + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_productIdMismatch() throws Exception { + JsonNode config = MAPPER.readTree("{\"method\":\"reset\",\"productId\":99}"); + ctx.setProductId(10L); + IotDeviceMessage msg = buildServiceReplyMsg("reset"); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + /** + * 构建服务调用回复消息(code != null 表示这是响应)。 + */ + private IotDeviceMessage buildServiceReplyMsg(String method) { + return IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()) + .params(Map.of("identifier", method)) + .code(200) // 有响应码 = 是回复 + .build(); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProviderTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProviderTest.java new file mode 100644 index 00000000..d590d1ae --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProviderTest.java @@ -0,0 +1,110 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +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.assertj.core.api.Assertions.assertThat; + +/** + * {@link DeviceStateTriggerProvider} 单元测试。 + * 任务卡 §6:device_state matches 正反例。 + */ +class DeviceStateTriggerProviderTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private DeviceStateTriggerProvider provider; + private RuleContext ctx; + + @BeforeEach + void setUp() { + provider = new DeviceStateTriggerProvider(); + ctx = new RuleContext(); + ctx.setTenantId(1L); + ctx.setProductId(10L); + ctx.setDeviceId(100L); + } + + // ===== 正例 ===== + + @Test + void testMatch_online_event_matches() throws Exception { + JsonNode config = MAPPER.readTree("{\"events\":[\"online\"],\"productId\":10}"); + IotDeviceMessage msg = buildStateMsg(1); // 1 = online + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_offline_event_matches() throws Exception { + JsonNode config = MAPPER.readTree("{\"events\":[\"online\",\"offline\"],\"productId\":10}"); + IotDeviceMessage msg = buildStateMsg(2); // 2 = offline + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_noProductIdFilter_anyProduct_matches() throws Exception { + JsonNode config = MAPPER.readTree("{\"events\":[\"online\"]}"); + ctx.setProductId(999L); // 不同 productId + IotDeviceMessage msg = buildStateMsg(1); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + @Test + void testMatch_emptyEvents_matchesAll() throws Exception { + JsonNode config = MAPPER.readTree("{\"events\":[]}"); + IotDeviceMessage msg = buildStateMsg(1); + assertThat(provider.matches(msg, config, ctx)).isTrue(); + } + + // ===== 反例 ===== + + @Test + void testNoMatch_wrongMethod() throws Exception { + JsonNode config = MAPPER.readTree("{\"events\":[\"online\"]}"); + IotDeviceMessage msg = IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) // 不是 state.update + .params(Map.of("state", 1)) + .build(); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_eventNotInConfig() throws Exception { + // 任务卡:msg.type=report, config.events=["online"] → no match + JsonNode config = MAPPER.readTree("{\"events\":[\"online\"]}"); + IotDeviceMessage msg = buildStateMsg(2); // offline, not in events + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_productIdMismatch() throws Exception { + JsonNode config = MAPPER.readTree("{\"events\":[\"online\"],\"productId\":99}"); + ctx.setProductId(10L); // 10 != 99 + IotDeviceMessage msg = buildStateMsg(1); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + @Test + void testNoMatch_nullParams() throws Exception { + JsonNode config = MAPPER.readTree("{\"events\":[\"online\"]}"); + IotDeviceMessage msg = IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()) + .params(null) + .build(); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + private IotDeviceMessage buildStateMsg(int state) { + return IotDeviceMessage.builder() + .method(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()) + .params(Map.of("state", state)) + .build(); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProviderTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProviderTest.java new file mode 100644 index 00000000..ac4f957e --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProviderTest.java @@ -0,0 +1,138 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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; +import com.viewsh.module.iot.rule.spi.exception.TriggerMatchException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@link TimerTriggerProvider} 单元测试。 + * 任务卡 §6:timer register/unregister + matches 行为。 + */ +class TimerTriggerProviderTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ThreadPoolTaskScheduler taskScheduler; + private TimerTriggerProvider provider; + private RuleContext ctx; + + @BeforeEach + void setUp() { + taskScheduler = new ThreadPoolTaskScheduler(); + taskScheduler.setPoolSize(2); + taskScheduler.setThreadNamePrefix("test-timer-"); + taskScheduler.initialize(); + + provider = new TimerTriggerProvider(taskScheduler); + + ctx = new RuleContext(); + ctx.setTenantId(1L); + } + + @AfterEach + void tearDown() { + provider.destroy(); + taskScheduler.destroy(); + } + + // ===== matches() 始终 false ===== + + @Test + void testMatches_alwaysFalse() throws Exception { + JsonNode config = MAPPER.readTree("{\"cron\":\"0 */5 * * * ?\"}"); + IotDeviceMessage msg = IotDeviceMessage.builder().method("thing.property.post").build(); + assertThat(provider.matches(msg, config, ctx)).isFalse(); + } + + // ===== register / unregister ===== + + @Test + void testRegister_validCron_taskRegistered() throws Exception { + JsonNode config = MAPPER.readTree("{\"cron\":\"0 */5 * * * ?\"}"); + CompiledRuleChain chain = buildChain(1L); + provider.register(chain, config); + assertThat(provider.isRegistered(1L)).isTrue(); + assertThat(provider.getScheduledTaskCount()).isEqualTo(1); + } + + @Test + void testUnregister_removesTask() throws Exception { + JsonNode config = MAPPER.readTree("{\"cron\":\"0 */5 * * * ?\"}"); + CompiledRuleChain chain = buildChain(2L); + provider.register(chain, config); + assertThat(provider.isRegistered(2L)).isTrue(); + + provider.unregister(chain); + assertThat(provider.isRegistered(2L)).isFalse(); + assertThat(provider.getScheduledTaskCount()).isEqualTo(0); + } + + @Test + void testRegister_twice_replacesOldTask() throws Exception { + JsonNode config1 = MAPPER.readTree("{\"cron\":\"0 */5 * * * ?\"}"); + JsonNode config2 = MAPPER.readTree("{\"cron\":\"0 */10 * * * ?\"}"); + CompiledRuleChain chain = buildChain(3L); + + provider.register(chain, config1); + provider.register(chain, config2); + + // 只有一个任务(第二次覆盖了第一次) + assertThat(provider.getScheduledTaskCount()).isEqualTo(1); + assertThat(provider.isRegistered(3L)).isTrue(); + } + + @Test + void testRegister_invalidCron_throwsTriggerMatchException() throws Exception { + JsonNode config = MAPPER.readTree("{\"cron\":\"invalid-cron\"}"); + CompiledRuleChain chain = buildChain(4L); + assertThatThrownBy(() -> provider.register(chain, config)) + .isInstanceOf(TriggerMatchException.class) + .hasMessageContaining("CRON 表达式无效"); + } + + @Test + void testRegister_missingCron_throwsTriggerMatchException() throws Exception { + JsonNode config = MAPPER.readTree("{}"); + CompiledRuleChain chain = buildChain(5L); + assertThatThrownBy(() -> provider.register(chain, config)) + .isInstanceOf(TriggerMatchException.class) + .hasMessageContaining("cron"); + } + + @Test + void testRegister_customTimezone() throws Exception { + JsonNode config = MAPPER.readTree("{\"cron\":\"0 0 8 * * ?\",\"timezone\":\"UTC\"}"); + CompiledRuleChain chain = buildChain(6L); + provider.register(chain, config); + assertThat(provider.isRegistered(6L)).isTrue(); + } + + @Test + void testDestroy_cancelsAllTasks() throws Exception { + JsonNode config = MAPPER.readTree("{\"cron\":\"0 */5 * * * ?\"}"); + provider.register(buildChain(10L), config); + provider.register(buildChain(11L), config); + assertThat(provider.getScheduledTaskCount()).isEqualTo(2); + + provider.destroy(); + assertThat(provider.getScheduledTaskCount()).isEqualTo(0); + } + + private CompiledRuleChain buildChain(Long id) { + return CompiledRuleChain.builder() + .id(id) + .tenantId(1L) + .name("test-chain-" + id) + .build(); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/TriggerProviderManagerTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/TriggerProviderManagerTest.java new file mode 100644 index 00000000..57cf6ff8 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/trigger/TriggerProviderManagerTest.java @@ -0,0 +1,69 @@ +package com.viewsh.module.iot.rule.trigger; + +import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.module.iot.rule.spi.TriggerProvider; +import com.viewsh.module.iot.rule.spi.TriggerProviderManager; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@link TriggerProviderManager} 单元测试。 + */ +class TriggerProviderManagerTest { + + @Test + void testAutoRegister_success() { + TriggerProviderManager manager = new TriggerProviderManager(); + DeviceStateTriggerProvider provider = new DeviceStateTriggerProvider(); + manager.autoRegister(List.of(provider)); + assertThat(manager.get("device_state")).isSameAs(provider); + } + + @Test + void testAutoRegister_duplicateType_throwsIllegalState() { + TriggerProviderManager manager = new TriggerProviderManager(); + // 两个同 type 的 Provider — 应 fail-fast + TriggerProvider dup1 = new DeviceStateTriggerProvider(); + TriggerProvider dup2 = new DeviceStateTriggerProvider(); + + assertThatThrownBy(() -> manager.autoRegister(List.of(dup1, dup2))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate trigger type: device_state"); + } + + @Test + void testGet_unknownType_throwsServiceException() { + TriggerProviderManager manager = new TriggerProviderManager(); + manager.autoRegister(List.of(new DeviceStateTriggerProvider())); + + assertThatThrownBy(() -> manager.get("unknown_type")) + .isInstanceOf(ServiceException.class); + } + + @Test + void testAutoRegister_emptyList_noException() { + TriggerProviderManager manager = new TriggerProviderManager(); + // 空列表不抛出异常 + manager.autoRegister(List.of()); + assertThat(manager.contains("device_state")).isFalse(); + } + + @Test + void testAutoRegister_null_noException() { + TriggerProviderManager manager = new TriggerProviderManager(); + manager.autoRegister(null); + assertThat(manager.contains("timer")).isFalse(); + } + + @Test + void testContains() { + TriggerProviderManager manager = new TriggerProviderManager(); + manager.autoRegister(List.of(new DeviceStateTriggerProvider())); + assertThat(manager.contains("device_state")).isTrue(); + assertThat(manager.contains("timer")).isFalse(); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmPropagationDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmPropagationDO.java new file mode 100644 index 00000000..71fe3762 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/alarm/IotAlarmPropagationDO.java @@ -0,0 +1,37 @@ +package com.viewsh.module.iot.dal.dataobject.alarm; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.viewsh.framework.tenant.core.db.TenantBaseDO; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 告警沿资产层级传播关联表(评审 C3) + *

+ * 替代 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 层强制): + *

+ * (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 + *

+ * {@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 { + + /** + * 查活跃(未归档)告警记录:按 UK (device_id, alarm_config_id, tenant_id) 取最新。 + *

+ * 用于 triggerAlarm 的幂等 upsert;仅返回 archived=0 的记录, + * 已归档则视为新告警触发(UK 含 deleted 列,archived 本身不在 UK)。 + */ + default IotAlarmRecordDO selectActiveByDeviceAndConfig(Long deviceId, Long alarmConfigId, Long tenantId) { + return selectOne(new LambdaQueryWrapper() + .eq(IotAlarmRecordDO::getDeviceId, deviceId) + .eq(IotAlarmRecordDO::getAlarmConfigId, alarmConfigId) + .eq(IotAlarmRecordDO::getTenantId, tenantId) + .eq(IotAlarmRecordDO::getArchived, 0) + .orderByDesc(IotAlarmRecordDO::getId) + .last("LIMIT 1")); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordService.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordService.java new file mode 100644 index 00000000..665c854b --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordService.java @@ -0,0 +1,62 @@ +package com.viewsh.module.iot.service.alarm; + +import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO; +import com.viewsh.module.iot.service.alarm.dto.AlarmStateTransitionRequest; +import com.viewsh.module.iot.service.alarm.dto.AlarmTriggerRequest; + +/** + * IoT 告警记录 Service(v2.0 正交状态机) + *

+ * 评审修正: + * - 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 tenantMock; + + private static final Long TENANT_ID = 1L; + private static final Long DEVICE_ID = 100L; + private static final Long CONFIG_ID = 200L; + + @BeforeEach + void setUp() { + tenantMock = mockStatic(TenantContextHolder.class); + tenantMock.when(TenantContextHolder::getTenantId).thenReturn(TENANT_ID); + } + + @AfterEach + void tearDown() { + tenantMock.close(); + } + + // ==================== 用例 1:首次触发 → 插入 (0,0,0) ==================== + + @Test + void testTriggerAlarm_new() { + when(alarmRecordMapper.selectActiveByDeviceAndConfig(DEVICE_ID, CONFIG_ID, TENANT_ID)) + .thenReturn(null); + when(alarmRecordMapper.insert(any(IotAlarmRecordDO.class))).thenAnswer(inv -> { + IotAlarmRecordDO d = inv.getArgument(0); + d.setId(555L); + return 1; + }); + + Long id = alarmService.triggerAlarm(buildTriggerReq(3)); + + assertEquals(555L, id); + ArgumentCaptor captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class); + verify(alarmRecordMapper).insert(captor.capture()); + IotAlarmRecordDO inserted = captor.getValue(); + assertEquals(0, inserted.getAckState()); + assertEquals(0, inserted.getClearState()); + assertEquals(0, inserted.getArchived()); + assertEquals(1, inserted.getTriggerCount()); + assertNotNull(inserted.getStartTs()); + assertNotNull(inserted.getEndTs()); + } + + // ==================== 用例 2:已有活跃告警再触发 → trigger_count++ ==================== + + @Test + void testTriggerAlarm_existing() { + IotAlarmRecordDO existing = IotAlarmRecordDO.builder() + .id(555L).deviceId(DEVICE_ID).alarmConfigId(CONFIG_ID) + .ackState(0).clearState(0).archived(0).triggerCount(3) + .build(); + when(alarmRecordMapper.selectActiveByDeviceAndConfig(DEVICE_ID, CONFIG_ID, TENANT_ID)) + .thenReturn(existing); + + Long id = alarmService.triggerAlarm(buildTriggerReq(3)); + + assertEquals(555L, id); + verify(alarmRecordMapper, never()).insert(any(IotAlarmRecordDO.class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class); + verify(alarmRecordMapper).updateById(captor.capture()); + assertEquals(4, captor.getValue().getTriggerCount()); + assertNotNull(captor.getValue().getEndTs()); + } + + // ==================== 用例 2b:非法 severity → 抛错 ==================== + + @Test + void testTriggerAlarm_invalidSeverity() { + AlarmTriggerRequest req = buildTriggerReq(9); + ServiceException ex = assertThrows(ServiceException.class, () -> alarmService.triggerAlarm(req)); + assertEquals(ALARM_SEVERITY_INVALID.getCode(), ex.getCode()); + } + + // ==================== 用例 3:活跃告警确认 → (0→1, 0, 0) ==================== + + @Test + void testAckAlarm() { + IotAlarmRecordDO active = activeAlarm(10L); + when(alarmRecordMapper.selectById(10L)).thenReturn(active); + + alarmService.ackAlarm(AlarmStateTransitionRequest.builder() + .alarmId(10L).operator("admin").remark("处理中").build()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class); + verify(alarmRecordMapper).updateById(captor.capture()); + assertEquals(1, captor.getValue().getAckState()); + assertNotNull(captor.getValue().getAckTs()); + assertEquals("admin", captor.getValue().getUpdater()); + assertEquals("处理中", captor.getValue().getProcessRemark()); + } + + // ==================== 用例 4:活跃告警清除 → (*, 0→1, 0) ==================== + + @Test + void testClearAlarm_fromActive() { + IotAlarmRecordDO active = activeAlarm(11L); + when(alarmRecordMapper.selectById(11L)).thenReturn(active); + + alarmService.clearAlarm(AlarmStateTransitionRequest.builder() + .alarmId(11L).operator("system").build()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class); + verify(alarmRecordMapper).updateById(captor.capture()); + assertEquals(1, captor.getValue().getClearState()); + assertNotNull(captor.getValue().getClearTs()); + } + + // ==================== 用例 5:先自动清除再人工确认(评审 C1 关键场景) ================== + + @Test + void testClearAlarm_thenAck() { + // 已清除未确认:(0, 1, 0) + IotAlarmRecordDO cleared = IotAlarmRecordDO.builder() + .id(12L).ackState(0).clearState(1).archived(0).triggerCount(5) + .build(); + when(alarmRecordMapper.selectById(12L)).thenReturn(cleared); + + alarmService.ackAlarm(AlarmStateTransitionRequest.builder().alarmId(12L).operator("qa").build()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class); + verify(alarmRecordMapper).updateById(captor.capture()); + // 关键:ack_state=1 但 clear_state 未动(Service 只改目标字段) + assertEquals(1, captor.getValue().getAckState()); + assertNull(captor.getValue().getClearState(), "clearState 不应被触碰,保持原值 1"); + } + + // ==================== 用例 6:先确认再清除 → (1, 0→1, 0) ==================== + + @Test + void testAckAlarm_thenClear() { + IotAlarmRecordDO acked = IotAlarmRecordDO.builder() + .id(13L).ackState(1).clearState(0).archived(0).triggerCount(2) + .build(); + when(alarmRecordMapper.selectById(13L)).thenReturn(acked); + + alarmService.clearAlarm(AlarmStateTransitionRequest.builder().alarmId(13L).build()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class); + verify(alarmRecordMapper).updateById(captor.capture()); + assertEquals(1, captor.getValue().getClearState()); + assertNull(captor.getValue().getAckState(), "ackState 不应被触碰,保持原值 1"); + } + + // ==================== 用例 7:归档 → (*, *, 0→1) ==================== + + @Test + void testArchiveAlarm() { + IotAlarmRecordDO active = activeAlarm(14L); + when(alarmRecordMapper.selectById(14L)).thenReturn(active); + + alarmService.archiveAlarm(AlarmStateTransitionRequest.builder().alarmId(14L).build()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class); + verify(alarmRecordMapper).updateById(captor.capture()); + assertEquals(1, captor.getValue().getArchived()); + assertNotNull(captor.getValue().getArchiveTs()); + } + + // ==================== 用例 8:归档后尝试确认 → throws ALARM_ALREADY_ARCHIVED ==================== + + @Test + void testArchiveAlarm_thenAck_rejected() { + IotAlarmRecordDO archived = IotAlarmRecordDO.builder() + .id(15L).ackState(1).clearState(1).archived(1).triggerCount(10) + .build(); + when(alarmRecordMapper.selectById(15L)).thenReturn(archived); + + AlarmStateTransitionRequest req = AlarmStateTransitionRequest.builder().alarmId(15L).build(); + ServiceException ex = assertThrows(ServiceException.class, () -> alarmService.ackAlarm(req)); + assertEquals(ALARM_ALREADY_ARCHIVED.getCode(), ex.getCode()); + + // 归档再归档也拒绝 + ex = assertThrows(ServiceException.class, () -> alarmService.archiveAlarm(req)); + assertEquals(ALARM_ALREADY_ARCHIVED.getCode(), ex.getCode()); + verify(alarmRecordMapper, never()).updateById(any(IotAlarmRecordDO.class)); + } + + // ==================== 用例 9:撤销确认 → (1→0, *, 0) ==================== + + @Test + void testUnackAlarm() { + IotAlarmRecordDO acked = IotAlarmRecordDO.builder() + .id(16L).ackState(1).clearState(0).archived(0).triggerCount(1) + .build(); + when(alarmRecordMapper.selectById(16L)).thenReturn(acked); + + alarmService.unackAlarm(AlarmStateTransitionRequest.builder() + .alarmId(16L).operator("admin").remark("误操作").build()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class); + verify(alarmRecordMapper).updateById(captor.capture()); + assertEquals(0, captor.getValue().getAckState()); + assertNull(captor.getValue().getAckTs(), "unack 应清 ackTs"); + assertEquals("误操作", captor.getValue().getProcessRemark()); + } + + // ==================== 用例 10:tenant_id 过滤传递到 Mapper(租户隔离) ==================== + + @Test + void testTenantIsolation_triggerPassesTenantId() { + when(alarmRecordMapper.selectActiveByDeviceAndConfig(DEVICE_ID, CONFIG_ID, TENANT_ID)).thenReturn(null); + when(alarmRecordMapper.insert(any(IotAlarmRecordDO.class))).thenAnswer(inv -> { + ((IotAlarmRecordDO) inv.getArgument(0)).setId(999L); + return 1; + }); + + alarmService.triggerAlarm(buildTriggerReq(3)); + + // 验证 Service 用 TenantContextHolder 传给 Mapper + verify(alarmRecordMapper).selectActiveByDeviceAndConfig(eq(DEVICE_ID), eq(CONFIG_ID), eq(TENANT_ID)); + } + + // ==================== 附加:幂等 ack / clear ==================== + + @Test + void testAckAlarm_idempotent() { + IotAlarmRecordDO acked = IotAlarmRecordDO.builder() + .id(17L).ackState(1).clearState(0).archived(0).triggerCount(1).build(); + when(alarmRecordMapper.selectById(17L)).thenReturn(acked); + + alarmService.ackAlarm(AlarmStateTransitionRequest.builder().alarmId(17L).build()); + + // 已确认 → no-op + verify(alarmRecordMapper, never()).updateById(any(IotAlarmRecordDO.class)); + } + + @Test + void testAlarmNotFound() { + when(alarmRecordMapper.selectById(999L)).thenReturn(null); + + AlarmStateTransitionRequest req = AlarmStateTransitionRequest.builder().alarmId(999L).build(); + ServiceException ex = assertThrows(ServiceException.class, () -> alarmService.ackAlarm(req)); + assertEquals(ALARM_RECORD_NOT_EXISTS.getCode(), ex.getCode()); + } + + // ==================== 辅助 ==================== + + private AlarmTriggerRequest buildTriggerReq(int severity) { + ObjectNode details = JsonNodeFactory.instance.objectNode().put("temperature", 45.5); + return AlarmTriggerRequest.builder() + .deviceId(DEVICE_ID) + .alarmConfigId(CONFIG_ID) + .alarmName("高温告警") + .severity(severity) + .subsystemId(9L) + .ruleChainId(8L) + .details(details) + .build(); + } + + private IotAlarmRecordDO activeAlarm(Long id) { + return IotAlarmRecordDO.builder() + .id(id).deviceId(DEVICE_ID).alarmConfigId(CONFIG_ID) + .ackState(0).clearState(0).archived(0).triggerCount(1) + .build(); + } + +}