feat(iot): Wave 4 Round 1 — B12/B4/B5 告警状态机 + 规则引擎 SPI

主会话 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) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet (subagent) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 00:35:14 +08:00
parent 1f87d599c0
commit 42466363c7
38 changed files with 3900 additions and 0 deletions

View File

@@ -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.
*
* <p>All {@code @Component} implementations of {@link ConditionEvaluator} are auto-injected by
* Spring at startup and indexed by {@link ConditionEvaluator#getType()} in an immutable map.
*
* <p>Usage:
* <pre>{@code
* boolean result = manager.evaluate("expression", ctx, configJsonNode);
* }</pre>
*/
@Slf4j
@Component
public class ConditionEvaluatorManager {
private final Map<String, ConditionEvaluator> evaluatorsByType;
public ConditionEvaluatorManager(List<ConditionEvaluator> 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);
}
}

View File

@@ -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.
*
* <p>Config JSON:
* <pre>{@code
* { "state": "online" } // online / offline
* }</pre>
*
* <p>Queries Redis key {@code iot:device:online:{deviceId}}:
* <ul>
* <li>key present -&gt; device is online</li>
* <li>key absent or Redis error -&gt; treated as offline (no exception thrown, per Known Pitfalls)</li>
* </ul>
*
* <p>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;
}
}
}

View File

@@ -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).
*
* <p>Config JSON example:
* <pre>{@code
* { "expression": "${data.temperature} > 40 && ${data.humidity} < 20" }
* }</pre>
*
* <p>Implementation details:
* <ol>
* <li>{@link TemplateResolver} converts template vars to Aviator variable names
* (e.g. {@code ${data.temperature}} becomes {@code temperature})</li>
* <li>Compiled expression cache: LRU(256), keyed by the Aviator expression string</li>
* <li>Aviator sandbox basics: MAX_LOOP_COUNT=1000, DISABLE_ASSIGNMENT=true
* (first-phase basic protection; full 8-layer sandbox deferred to B44)</li>
* </ol>
*
* <p>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<String, Expression> compiledCache = new LinkedHashMap<>(CACHE_MAX_SIZE, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Expression> 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<String, Object> 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;
}
}

View File

@@ -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.
*
* <p>Config JSON:
* <pre>{@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
* }
* }</pre>
*
* <p>Known pitfalls:
* <ul>
* <li>Cross-midnight scenario (startTime &gt; endTime, e.g. 22:00-06:00):
* split into two segments [22:00, 24:00) U [00:00, 06:00)</li>
* <li>holiday mode is not supported in phase 1; returns false with a warning log
* (holiday calendar data source is a separate task)</li>
* </ul>
*
* <p>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<Integer> 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 &gt; 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<Integer> parseDaysOfWeek(JsonNode config) {
JsonNode arr = config.get("daysOfWeek");
List<Integer> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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.
*
* <p>Implementations register via Spring {@code @Component} and are routed by
* {@link com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager} using {@link #getType()}.
*
* <p>Convention (review B5):
* <ul>
* <li>Template variables use unified {@code ${data.x}} / {@code ${meta.x}} format;
* old {@code $[identifier]} syntax is prohibited.</li>
* <li>Each evaluate call must be idempotent. RuntimeException is handled by the caller.</li>
* <li>p99 &lt; 2ms - cache expensive objects (e.g. compiled expressions).</li>
* </ul>
*/
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);
}

View File

@@ -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 接口。
*
* <p>触发器是 DAG 规则链的入口,负责判断消息是否匹配该规则链的触发条件。
* 第一期实现 5 种device_state / device_property / device_event / device_service / timer。
*
* <p>注册方式Spring {@code @Component} + {@link #getType()} 索引
* (规范清单禁用 ServiceLoader / @SPI
*
* <p>评审 A3 / AGENTS.md §五SPI Provider 注册统一用 Spring Bean。
*/
public interface TriggerProvider {
/**
* 类型标识(小写+下划线)。
* 封闭枚举device_state / device_property / device_event / device_service / timer。
*/
String getType();
/**
* 判断消息是否匹配触发条件。
*
* <p><b>无副作用</b>:只做判断,不修改 ctx不写数据库不发 MQ。
* 写数据由 Action 节点负责。
*
* @param msg 设备消息
* @param config 规则节点的 configurationJSON
* @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) {
// 默认无操作
}
}

View File

@@ -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() 索引)。
*
* <p>启动时通过 {@link #autoRegister(List)} 自动收集所有 Spring Bean 中的
* {@link TriggerProvider},按 {@link TriggerProvider#getType()} 建立索引。
*
* <p><b>Fail-fast</b>:同 type 重复注册时抛出 {@link IllegalStateException}
* 确保启动阶段即暴露问题,不留运行时隐患。
*
* <p>评审 A3 / AGENTS.md §五SPI 注册方式采用 Spring Bean + getType() 索引。
*/
@Slf4j
@Component
public class TriggerProviderManager {
private final Map<String, TriggerProvider> providers = new ConcurrentHashMap<>();
/**
* Spring 自动注入所有 {@link TriggerProvider} Bean 并建立类型索引。
*
* @param list Spring 容器中所有 TriggerProvider 实现required=false 允许无实现时启动)
*/
@Autowired(required = false)
public void autoRegister(List<TriggerProvider> 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);
}
}

View File

@@ -0,0 +1,35 @@
package com.viewsh.module.iot.rule.spi.exception;
/**
* 触发器匹配过程中的异常。
*
* <p>当触发器配置非法(如 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;
}
}

View File

@@ -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).
*
* <p>Supported variable formats (all using {@code ${namespace.key}}):
* <ul>
* <li>{@code ${data.temperature}} -&gt; field from message.data / message.params</li>
* <li>{@code ${data}} -&gt; entire message data</li>
* <li>{@code ${meta.deviceName}} -&gt; ctx.metadata.deviceName</li>
* <li>{@code ${metadata.deviceName}} -&gt; same as meta (both aliases supported)</li>
* </ul>
*
* <p>Legacy syntax prohibited: template strings containing {@code $[...]} will throw
* {@link TemplateSyntaxException}.
*
* <p>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<String, Object> resolveToEnvMap(String template, RuleContext ctx) {
validateNoLegacySyntax(template);
java.util.LinkedHashMap<String, Object> 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<String, Object>) 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<String, Object> 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} -&gt; {@code temperature},
* {@code data.sensor.temp} -&gt; {@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);
}
}
}

View File

@@ -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
*
* <p>配置示例:
* <pre>
* { "event": "alarm", "productId": 10 }
* </pre>
* 或多事件:
* <pre>
* { "events": ["alarm", "fault"], "productId": 10 }
* </pre>
*
* <p>匹配条件:
* <ol>
* <li>消息 method 必须是 {@code thing.event.post}(事件上报)</li>
* <li>若 config.productId 存在,消息的 productId 必须匹配</li>
* <li>消息的事件标识(从 params.identifier 或 method suffix 提取)在配置的 event/events 中</li>
* </ol>
*
* <p>事件标识提取优先级:
* 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<String> 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<String> buildConfigEventSet(JsonNode config) {
Set<String> 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;
}
}

View File

@@ -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
*
* <p>配置示例:
* <pre>
* { "identifiers": ["temperature", "humidity"], "productId": 10 }
* </pre>
*
* <p>匹配条件:
* <ol>
* <li>消息 method 必须是 {@code thing.property.post}(属性上报)</li>
* <li>若 config.productId 存在,消息的 productId 必须匹配</li>
* <li>config.identifiers 中至少有一个属性标识符出现在消息 params 中</li>
* </ol>
*
* <p><b>性能注意事项</b>(评审 ⚠️ 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<String> identifiers = new HashSet<>();
for (JsonNode id : identifiersNode) {
identifiers.add(id.asText());
}
// 检查消息 params 中是否包含任意一个 identifier
return hasAnyIdentifier(msg.getParams(), identifiers);
}
/**
* 检查消息 params 中是否包含 identifiers 中的任一 key。
* params 通常是 Map&lt;String, Object&gt; 或 JsonNode。
*/
@SuppressWarnings("unchecked")
private boolean hasAnyIdentifier(Object params, Set<String> 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;
}
}

View File

@@ -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
*
* <p>配置示例:
* <pre>
* { "method": "reset", "productId": 10 }
* </pre>
* 或多服务:
* <pre>
* { "methods": ["reset", "reboot"], "productId": 10 }
* </pre>
*
* <p>匹配条件:
* <ol>
* <li>消息 method 必须是 {@code thing.service.invoke}(服务调用)</li>
* <li>消息必须是服务调用回复code != null 或 data != null 表示这是响应方向)</li>
* <li>若 config.productId 存在,消息的 productId 必须匹配</li>
* <li>消息的服务方法名params.identifier 或 params.method在配置的 method/methods 中</li>
* </ol>
*
* <p>任务卡测试用例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<String> 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<String> buildConfigMethodSet(JsonNode config) {
Set<String> 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;
}
}

View File

@@ -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
*
* <p>配置示例:
* <pre>
* { "events": ["online", "offline"], "productId": 10 }
* </pre>
*
* <p>匹配条件:
* <ol>
* <li>消息 method 必须是 {@code thing.state.update}</li>
* <li>消息 params.state 对应的文本标识在 config.events 集合中</li>
* <li>若 config.productId 存在,消息的 productId 必须匹配快速过滤O(1)</li>
* </ol>
*
* <p>状态值映射(参照 {@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<String> 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();
}
}

View File

@@ -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
*
* <p>配置示例:
* <pre>{@code
* { "cron": "0 * /5 * * * ?", "timezone": "Asia/Shanghai" }
* }</pre>
*
* <p><b>调度机制</b>:使用 Spring TaskScheduler 动态注册 CRON 任务。
* 每条规则链对应一个 ScheduledFuture按 CompiledRuleChain.getId() 索引。
*
* <p><b>注销保证</b>(评审 Timer 注销):
* 规则链 disable/delete 时 unregister(CompiledRuleChain) 取消对应 future
* 避免僵尸任务内存泄漏。Bean 销毁时也会清理所有残留任务。
*
* <p><b>时区</b>:默认 Asia/Shanghai评审 CRON 时区)。
*
* <p><b>matches() 说明</b>Timer 触发器不通过消息 matches() 判断,
* 而是由 CRON 调度直接触发规则链执行matches() 始终返回 false避免被消息路由误触发
* Timer 触发走专用回调路径,不走普通消息路由。
*
* <p><b>多租户</b>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<Long, ScheduledFuture<?>> 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();
}
}

View File

@@ -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} 单元测试。
*
* <p>覆盖任务卡 B5 §6 用例:
* <ul>
* <li>device_onlineRedis 有 key → true</li>
* <li>device_online_missingRedis 无 key → false不抛错</li>
* <li>device_offlineRedis 无 key + 期望 offline → true</li>
* <li>Redis 抛异常容错(视为 offline</li>
* <li>deviceId 为 null → false</li>
* </ul>
*/
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;
}
}

View File

@@ -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}.
*
* <p>Covers B5 task card section 6 cases:
* <ul>
* <li>expr_simple: ${data.temp} &gt; 40 with temp=45 -&gt; true</li>
* <li>expr_and: ${data.temp} &gt; 40 &amp;&amp; ${data.hum} &lt; 20 with temp=45, hum=15 -&gt; true</li>
* <li>expr_meta_var: ${meta.subsystemCode} == 'clean' -&gt; true</li>
* <li>expr_template_legacy: $[temp] &gt; 40 -&gt; throws TemplateSyntaxException</li>
* <li>Compiled cache: same expression is not recompiled</li>
* </ul>
*/
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<String, Object> data) {
RuleContext ctx = new RuleContext();
ctx.setMessage(IotDeviceMessage.builder().data(data).build());
return ctx;
}
}

View File

@@ -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}.
*
* <p>Covers B5 task card section 6 cases:
* <ul>
* <li>time_weekday: weekday 9-18 time window</li>
* <li>time_cross_midnight: cross-midnight 22:00-06:00</li>
* <li>isInTimeRange static method tested directly (no Spring context needed)</li>
* </ul>
*/
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;
}
}

View File

@@ -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}.
*
* <p>Covers B5 task card section 6 template-related cases:
* <ul>
* <li>${data.x}, ${meta.x}, ${metadata.x} prefixes</li>
* <li>Nested paths (${data.sensor.temp})</li>
* <li>Missing variables return null</li>
* <li>Legacy $[...] syntax throws exception</li>
* </ul>
*/
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<String, Object> 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<String, Object> 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<String, Object> 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;
}
}

View File

@@ -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} 单元测试。
* 任务卡 §6device_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();
}
}

View File

@@ -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} 单元测试。
* 任务卡 §6device_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<String, Object> properties) {
return IotDeviceMessage.builder()
.method(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())
.params(properties)
.build();
}
}

View File

@@ -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} 单元测试。
* 任务卡 §6device_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();
}
}

View File

@@ -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} 单元测试。
* 任务卡 §6device_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();
}
}

View File

@@ -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} 单元测试。
* 任务卡 §6timer 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();
}
}

View File

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