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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 -> device is online</li>
|
||||
* <li>key absent or Redis error -> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 > 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 > 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 < 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);
|
||||
}
|
||||
@@ -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 规则节点的 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) {
|
||||
// 默认无操作
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}} -> field from message.data / message.params</li>
|
||||
* <li>{@code ${data}} -> entire message data</li>
|
||||
* <li>{@code ${meta.deviceName}} -> ctx.metadata.deviceName</li>
|
||||
* <li>{@code ${metadata.deviceName}} -> 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} -> {@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 或 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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_online:Redis 有 key → true</li>
|
||||
* <li>device_online_missing:Redis 无 key → false(不抛错)</li>
|
||||
* <li>device_offline:Redis 无 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;
|
||||
}
|
||||
}
|
||||
@@ -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} > 40 with temp=45 -> true</li>
|
||||
* <li>expr_and: ${data.temp} > 40 && ${data.hum} < 20 with temp=45, hum=15 -> true</li>
|
||||
* <li>expr_meta_var: ${meta.subsystemCode} == 'clean' -> true</li>
|
||||
* <li>expr_template_legacy: $[temp] > 40 -> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> properties) {
|
||||
return IotDeviceMessage.builder()
|
||||
.method(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())
|
||||
.params(properties)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user