diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/IotRuleEngineVersionAutoConfiguration.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/IotRuleEngineVersionAutoConfiguration.java new file mode 100644 index 00000000..765e75d9 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/IotRuleEngineVersionAutoConfiguration.java @@ -0,0 +1,23 @@ +package com.viewsh.module.iot.rule.config; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; + +/** + * 规则引擎版本开关自动配置 + * + *
注册 {@link RuleEngineVersionProperties} 配置属性绑定, + * 并扫描 {@link IotRuleEngineVersionResolver} 和 {@link RuleEngineVersionAdminController}。 + * + *
通过 {@code META-INF/spring.factories} 或 {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} + * 注册为 Spring Boot 自动配置。 + * + * @author lzh + */ +@AutoConfiguration +@EnableConfigurationProperties(RuleEngineVersionProperties.class) +@ComponentScan(basePackageClasses = IotRuleEngineVersionAutoConfiguration.class) +public class IotRuleEngineVersionAutoConfiguration { + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/IotRuleEngineVersionResolver.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/IotRuleEngineVersionResolver.java new file mode 100644 index 00000000..4bd2f38f --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/IotRuleEngineVersionResolver.java @@ -0,0 +1,160 @@ +package com.viewsh.module.iot.rule.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 规则引擎版本解析器 + * + *
核心职责:判断给定子系统/租户的消息是否应路由到 v2 新规则引擎。 + * + *
三态逻辑: + *
性能要求:{@link #shouldUseV2} 被每条消息调用,须 < 1μs。 + * 使用 {@code volatile Set} + {@code contains} 本地缓存,避免每次查 Redis。 + * + *
⚠️ 评审 B11 — Known Pitfalls 落地: + *
此方法被每条设备消息调用,必须保证极低延迟(< 1μs)。 + * + * @param subsystemId 子系统 ID;为 {@code null} 时表示设备未归属任何子系统 + * @param tenantId 租户 ID(预留参数,当前未使用,用于未来租户级灰度) + * @return {@code true} 表示路由到 v2;{@code false} 表示路由到 v1 + */ + public boolean shouldUseV2(Long subsystemId, Long tenantId) { + RuleEngineVersion version = props.getVersion(); + switch (version) { + case V1: + return false; + case V2: + return true; + case HYBRID: + // ⚠️ subsystemId=null 的存量未归属设备安全默认走 v1 + if (subsystemId == null) { + return false; + } + return getV2Whitelist().contains(subsystemId); + default: + // 未知版本类型兜底走 v1 + log.warn("[shouldUseV2] 未知规则引擎版本 {},兜底走 v1", version); + return false; + } + } + + /** + * 获取 v2 子系统白名单(静态白名单 ∪ Redis 动态白名单) + * + *
缓存过期后从 Redis 刷新;Redis 不可用时降级到静态白名单,记录 warn 日志。
+ *
+ * @return 不可变的 v2 子系统 ID 集合
+ */
+ public Set 通过将 {@code cacheUpdateTime} 置零,使下次 {@link #getV2Whitelist()} 调用立即从 Redis 重新拉取。
+ */
+ public void invalidateCache() {
+ cacheUpdateTime = 0L;
+ }
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/RuleEngineVersion.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/RuleEngineVersion.java
new file mode 100644
index 00000000..14eac7d2
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/RuleEngineVersion.java
@@ -0,0 +1,35 @@
+package com.viewsh.module.iot.rule.config;
+
+/**
+ * 规则引擎版本枚举(三态)
+ *
+ * Spring Boot 配置文件中大小写不敏感,均可接受:
+ * {@code viewsh.iot.rule.engine.version: v1 / v2 / hybrid}
+ *
+ * @author lzh
+ */
+public enum RuleEngineVersion {
+
+ /**
+ * 旧规则引擎(v1)— 全量流量走 v1,用于回退
+ */
+ V1,
+
+ /**
+ * 新规则引擎(v2)— 全量流量走 v2,用于全量切换
+ */
+ V2,
+
+ /**
+ * 混合模式 — 按子系统白名单灰度路由:
+ * 白名单内子系统走 v2,其余及无子系统设备走 v1
+ */
+ HYBRID
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/RuleEngineVersionAdminController.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/RuleEngineVersionAdminController.java
new file mode 100644
index 00000000..be11b260
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/RuleEngineVersionAdminController.java
@@ -0,0 +1,150 @@
+package com.viewsh.module.iot.rule.config;
+
+import com.viewsh.framework.common.pojo.CommonResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import jakarta.annotation.Resource;
+import java.util.Collections;
+import java.util.Set;
+
+import static com.viewsh.framework.common.pojo.CommonResult.success;
+
+/**
+ * 管理后台 — 规则引擎版本管理
+ *
+ * 提供运行时动态管理 v2 子系统白名单的 REST API,用于灰度发布控制。
+ *
+ * 权限说明:调用方需具备 {@code iot:rule-engine:admin} 权限。
+ * 建议在 iot-server 部署时配合 Spring Security 的 {@code @PreAuthorize} 鉴权。
+ *
+ * URL 前缀遵循项目 Admin Controller 约定:{@code /iot/rule-engine/...}
+ * 实际在网关侧映射为 {@code /admin-api/iot/rule-engine/...}
+ *
+ * ⚠️ Known Pitfall(评审 B11):
+ * 权限:{@code iot:rule-engine:admin}(由部署层 @PreAuthorize 保证)
+ *
+ * @param subsystemId 要添加的子系统 ID
+ * @return 操作结果
+ */
+ @PutMapping("/v2-whitelist/add/{subsystemId}")
+ @Operation(summary = "添加子系统到 v2 白名单")
+ @Parameter(name = "subsystemId", description = "子系统编号", required = true)
+ @PreAuthorize("@ss.hasPermission('iot:rule-engine:admin')")
+ public CommonResult 权限:{@code iot:rule-engine:admin}(由部署层 @PreAuthorize 保证)
+ *
+ * @param subsystemId 要移除的子系统 ID
+ * @return 操作结果
+ */
+ @DeleteMapping("/v2-whitelist/remove/{subsystemId}")
+ @Operation(summary = "从 v2 白名单移除子系统")
+ @Parameter(name = "subsystemId", description = "子系统编号", required = true)
+ @PreAuthorize("@ss.hasPermission('iot:rule-engine:admin')")
+ public CommonResult 权限:{@code iot:rule-engine:query}(由部署层 @PreAuthorize 保证)
+ *
+ * @return 当前生效的 v2 子系统 ID 集合
+ */
+ @GetMapping("/v2-whitelist")
+ @Operation(summary = "查询当前 v2 子系统白名单")
+ @PreAuthorize("@ss.hasPermission('iot:rule-engine:query')")
+ public CommonResult 权限:{@code iot:rule-engine:query}(由部署层 @PreAuthorize 保证)
+ *
+ * @return 当前版本枚举值 {@link RuleEngineVersion}
+ */
+ @GetMapping("/version")
+ @Operation(summary = "查询当前规则引擎全局版本配置")
+ @PreAuthorize("@ss.hasPermission('iot:rule-engine:query')")
+ public CommonResult 正常情况下缓存按 {@code v2SubsystemRefresh} 自动过期;
+ * 此端点用于变更 Redis 后需立即生效的紧急场景。
+ *
+ * 权限:{@code iot:rule-engine:admin}(由部署层 @PreAuthorize 保证)
+ *
+ * @return 刷新后的白名单集合
+ */
+ @PostMapping("/v2-whitelist/refresh")
+ @Operation(summary = "主动刷新本地白名单缓存")
+ @PreAuthorize("@ss.hasPermission('iot:rule-engine:admin')")
+ public CommonResult 对应 YAML 配置:
+ * 默认 {@link RuleEngineVersion#V1}(安全兜底:即使配置缺失,也走旧引擎)
+ */
+ private RuleEngineVersion version = RuleEngineVersion.V1;
+
+ /**
+ * hybrid 模式下走 v2 的子系统 ID 静态白名单
+ *
+ * 仅 {@link RuleEngineVersion#HYBRID} 模式生效。
+ * 静态白名单与 Redis 动态白名单取并集。
+ */
+ private Set 仅 {@link RuleEngineVersion#HYBRID} 模式生效。
+ * 调小此值可加快 Redis 白名单变更生效,但会增加 Redis 查询频率。
+ * 紧急场景可结合 Redis Pub/Sub 主动驱逐缓存(见 Known Pitfalls B11)。
+ */
+ private Duration v2SubsystemRefresh = Duration.ofSeconds(60);
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/META-INF/spring.factories b/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/META-INF/spring.factories
index db730581..feb02730 100644
--- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/META-INF/spring.factories
+++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/META-INF/spring.factories
@@ -2,3 +2,7 @@
# 后续任务(B2+)在此注册 AutoConfiguration 类
# org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
# com.viewsh.module.iot.rule.config.IotRuleAutoConfiguration
+
+# [B19] 规则引擎版本开关自动配置(v1/v2/hybrid 三态 + 白名单管理)
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+ com.viewsh.module.iot.rule.config.IotRuleEngineVersionAutoConfiguration
diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/config/IotRuleEngineVersionResolverTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/config/IotRuleEngineVersionResolverTest.java
new file mode 100644
index 00000000..0bbc6e2f
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/config/IotRuleEngineVersionResolverTest.java
@@ -0,0 +1,213 @@
+package com.viewsh.module.iot.rule.config;
+
+import com.viewsh.framework.test.core.ut.BaseMockitoUnitTest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.springframework.data.redis.core.SetOperations;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * {@link IotRuleEngineVersionResolver} 单元测试
+ *
+ * 覆盖任务卡 B19 §6 的全部 6 个用例:
+ *
+ *
+ *
+ *
+ *
+ *
+ * @author lzh
+ */
+@Tag(name = "管理后台 - IoT 规则引擎版本管理")
+@Slf4j
+@RestController
+@RequestMapping("/iot/rule-engine")
+@Validated
+public class RuleEngineVersionAdminController {
+
+ @Resource
+ private IotRuleEngineVersionResolver versionResolver;
+
+ @Resource
+ private RuleEngineVersionProperties versionProperties;
+
+ @Resource(name = "stringRedisTemplate")
+ private StringRedisTemplate redisTemplate;
+
+ // ---------- 白名单管理端点 ----------
+
+ /**
+ * 添加子系统到 v2 白名单
+ *
+ * {@code
+ * viewsh:
+ * iot:
+ * rule:
+ * engine:
+ * version: hybrid # v1 / v2 / hybrid,默认 v1(安全兜底)
+ * v2-subsystem-ids: 5,7,12 # hybrid 模式下走 v2 的子系统 ID 静态白名单
+ * v2-subsystem-refresh: 60s # 动态白名单(Redis)刷新间隔,默认 60 秒
+ * }
+ *
+ * @author lzh
+ * @see IotRuleEngineVersionResolver
+ */
+@Data
+@ConfigurationProperties(prefix = "viewsh.iot.rule.engine")
+public class RuleEngineVersionProperties {
+
+ /**
+ * 规则引擎版本模式
+ *
+ *
+ *
+ *
+ * @author lzh
+ */
+class IotRuleEngineVersionResolverTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private IotRuleEngineVersionResolver resolver;
+
+ @Mock
+ private RuleEngineVersionProperties props;
+
+ @Mock
+ private StringRedisTemplate redisTemplate;
+
+ @Mock
+ @SuppressWarnings("rawtypes")
+ private SetOperations setOperations;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUp() {
+ // 使用 lenient 避免 V1/V2 模式测试中触发 UnnecessaryStubbingException
+ // (V1/V2 模式不调用 getV2Whitelist,这些 stub 不会被使用)
+ lenient().when(props.getV2SubsystemRefresh()).thenReturn(Duration.ofSeconds(60));
+ lenient().when(props.getV2SubsystemIds()).thenReturn(Collections.emptySet());
+ lenient().when(redisTemplate.opsForSet()).thenReturn(setOperations);
+ }
+
+ // ====================================================
+ // 用例 1: V1 模式始终返回 false
+ // ====================================================
+
+ @Test
+ void testV1Mode_alwaysFalse() {
+ when(props.getVersion()).thenReturn(RuleEngineVersion.V1);
+
+ assertFalse(resolver.shouldUseV2(5L, 1L), "V1 模式:有 subsystem 应返回 false");
+ assertFalse(resolver.shouldUseV2(null, 1L), "V1 模式:null subsystem 应返回 false");
+ assertFalse(resolver.shouldUseV2(999L, null), "V1 模式:任意 subsystem 应返回 false");
+ }
+
+ // ====================================================
+ // 用例 2: V2 模式始终返回 true
+ // ====================================================
+
+ @Test
+ void testV2Mode_alwaysTrue() {
+ when(props.getVersion()).thenReturn(RuleEngineVersion.V2);
+
+ assertTrue(resolver.shouldUseV2(5L, 1L), "V2 模式:有 subsystem 应返回 true");
+ assertTrue(resolver.shouldUseV2(null, 1L), "V2 模式:null subsystem 也应返回 true");
+ assertTrue(resolver.shouldUseV2(999L, null), "V2 模式:任意 subsystem 应返回 true");
+ }
+
+ // ====================================================
+ // 用例 3: HYBRID 模式 — 子系统在白名单内 → true
+ // ====================================================
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testHybrid_inWhitelist_true() {
+ when(props.getVersion()).thenReturn(RuleEngineVersion.HYBRID);
+ // Redis 返回包含 5 和 7 的白名单
+ when(setOperations.members(IotRuleEngineVersionResolver.REDIS_KEY_V2_WHITELIST))
+ .thenReturn(Set.of("5", "7"));
+
+ // 强制过期缓存,触发 Redis 刷新
+ resolver.invalidateCache();
+
+ assertTrue(resolver.shouldUseV2(5L, 1L), "HYBRID:subsystem 5 在白名单内 → true");
+ assertTrue(resolver.shouldUseV2(7L, 1L), "HYBRID:subsystem 7 在白名单内 → true");
+ }
+
+ // ====================================================
+ // 用例 4: HYBRID 模式 — 子系统不在白名单 → false
+ // ====================================================
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testHybrid_notInWhitelist_false() {
+ when(props.getVersion()).thenReturn(RuleEngineVersion.HYBRID);
+ // Redis 白名单只有 5 和 7
+ when(setOperations.members(IotRuleEngineVersionResolver.REDIS_KEY_V2_WHITELIST))
+ .thenReturn(Set.of("5", "7"));
+
+ resolver.invalidateCache();
+
+ assertFalse(resolver.shouldUseV2(3L, 1L), "HYBRID:subsystem 3 不在白名单 → false");
+ assertFalse(resolver.shouldUseV2(99L, 1L), "HYBRID:subsystem 99 不在白名单 → false");
+ }
+
+ // ====================================================
+ // 用例 5: HYBRID 模式 — subsystemId=null → false(安全默认)
+ // ====================================================
+
+ @Test
+ void testHybrid_nullSubsystem_false() {
+ when(props.getVersion()).thenReturn(RuleEngineVersion.HYBRID);
+
+ assertFalse(resolver.shouldUseV2(null, 1L),
+ "HYBRID:subsystemId=null(未归属设备)安全默认走 v1 → false");
+ }
+
+ // ====================================================
+ // 用例 6: HYBRID 模式 — Redis 异常时降级到静态白名单
+ // ====================================================
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testHybrid_redisFallback_toStaticList() {
+ when(props.getVersion()).thenReturn(RuleEngineVersion.HYBRID);
+ // 静态白名单包含 5
+ when(props.getV2SubsystemIds()).thenReturn(Set.of(5L));
+ // Redis 抛出运行时异常
+ when(setOperations.members(anyString())).thenThrow(new RuntimeException("Redis 连接超时"));
+
+ resolver.invalidateCache();
+
+ // subsystem 5 在静态白名单中 → 降级后仍为 true
+ assertTrue(resolver.shouldUseV2(5L, 1L),
+ "Redis 异常降级:静态白名单内的 subsystem 5 → true");
+ // subsystem 7 不在静态白名单 → false
+ assertFalse(resolver.shouldUseV2(7L, 1L),
+ "Redis 异常降级:不在静态白名单的 subsystem 7 → false");
+ }
+
+ // ====================================================
+ // 用例 7: HYBRID 模式 — 动态更新(invalidateCache 后刷新)
+ // ====================================================
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testHybrid_dynamicUpdate() {
+ when(props.getVersion()).thenReturn(RuleEngineVersion.HYBRID);
+
+ // 第一次 Redis 白名单:只有 5 和 7
+ when(setOperations.members(IotRuleEngineVersionResolver.REDIS_KEY_V2_WHITELIST))
+ .thenReturn(new HashSet<>(Set.of("5", "7")));
+
+ resolver.invalidateCache();
+
+ // 验证初始状态
+ assertFalse(resolver.shouldUseV2(8L, 1L), "初始状态:subsystem 8 不在白名单 → false");
+
+ // 模拟 Redis 白名单更新:加入 8
+ when(setOperations.members(IotRuleEngineVersionResolver.REDIS_KEY_V2_WHITELIST))
+ .thenReturn(new HashSet<>(Set.of("5", "7", "8")));
+
+ // 通过 invalidateCache 模拟缓存过期(等价于等待 cacheUpdateTime 超时)
+ resolver.invalidateCache();
+
+ // 重新触发 getV2Whitelist
+ assertTrue(resolver.shouldUseV2(8L, 1L), "动态更新后:subsystem 8 加入白名单 → true");
+ // 原有的 5 和 7 仍然在
+ assertTrue(resolver.shouldUseV2(5L, 1L), "动态更新后:subsystem 5 仍在白名单 → true");
+ }
+
+ // ====================================================
+ // 附加:HYBRID 模式 — 静态白名单与 Redis 白名单取并集
+ // ====================================================
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testHybrid_staticAndRedisMerged() {
+ when(props.getVersion()).thenReturn(RuleEngineVersion.HYBRID);
+ // 静态白名单:12
+ when(props.getV2SubsystemIds()).thenReturn(Set.of(12L));
+ // Redis 白名单:5 和 7
+ when(setOperations.members(IotRuleEngineVersionResolver.REDIS_KEY_V2_WHITELIST))
+ .thenReturn(Set.of("5", "7"));
+
+ resolver.invalidateCache();
+
+ assertTrue(resolver.shouldUseV2(12L, 1L), "并集:静态白名单内的 12 → true");
+ assertTrue(resolver.shouldUseV2(5L, 1L), "并集:Redis 白名单内的 5 → true");
+ assertTrue(resolver.shouldUseV2(7L, 1L), "并集:Redis 白名单内的 7 → true");
+ assertFalse(resolver.shouldUseV2(3L, 1L), "并集:不在任何白名单的 3 → false");
+ }
+
+}