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

+ * + * @author lzh + */ +@Slf4j +@Service +public class IotRuleEngineVersionResolver { + + /** + * Redis Key:v2 子系统白名单(Set<String>) + * 值为 subsystemId 的字符串形式,例如 "5", "7", "12" + */ + public static final String REDIS_KEY_V2_WHITELIST = "iot:rule:engine:v2-whitelist"; + + @Resource + private RuleEngineVersionProperties props; + + /** + * Redis 客户端(可选注入,测试或无 Redis 环境时允许为 null) + */ + @Autowired(required = false) + private StringRedisTemplate redisTemplate; + + // ---------- 缓存字段(volatile 保证可见性,避免加锁影响性能)---------- + + private volatile Set v2WhitelistCache = Collections.emptySet(); + private volatile long cacheUpdateTime = 0L; + + // ---------- 公共 API ---------- + + /** + * 判断给定子系统的消息是否应路由到 v2 规则引擎 + * + *

此方法被每条设备消息调用,必须保证极低延迟(< 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 getV2Whitelist() { + long refreshMillis = props.getV2SubsystemRefresh().toMillis(); + long now = System.currentTimeMillis(); + + if (now - cacheUpdateTime <= refreshMillis) { + // 缓存未过期,直接返回 + return v2WhitelistCache; + } + + // 缓存过期,从 Redis 拉取动态白名单 + Set staticList = props.getV2SubsystemIds(); + if (staticList == null) { + staticList = Collections.emptySet(); + } + + Set merged; + if (redisTemplate != null) { + try { + Set rawRedis = redisTemplate.opsForSet().members(REDIS_KEY_V2_WHITELIST); + if (rawRedis != null && !rawRedis.isEmpty()) { + Set dynamicList = rawRedis.stream() + .map(Long::valueOf) + .collect(Collectors.toSet()); + dynamicList.addAll(staticList); + merged = Collections.unmodifiableSet(dynamicList); + } else { + merged = Collections.unmodifiableSet(staticList); + } + } catch (Exception e) { + // ⚠️ Redis 失败降级到静态白名单,不抛出异常 + log.warn("[getV2Whitelist] 从 Redis 读取 v2 白名单失败,降级使用静态配置。key={}, error={}", + REDIS_KEY_V2_WHITELIST, e.getMessage()); + merged = Collections.unmodifiableSet(staticList); + } + } else { + // 无 Redis(测试环境),仅使用静态白名单 + merged = Collections.unmodifiableSet(staticList); + } + + // 更新缓存(允许多线程竞态:最坏情况是多次刷新,无数据不一致问题) + v2WhitelistCache = merged; + cacheUpdateTime = now; + return merged; + } + + /** + * 主动强制刷新白名单缓存(用于测试或紧急切换场景) + * + *

通过将 {@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): + *

+ * + * @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 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 addToWhitelist(@PathVariable Long subsystemId) { + log.info("[addToWhitelist] 添加子系统到 v2 白名单: subsystemId={}", subsystemId); + redisTemplate.opsForSet().add(IotRuleEngineVersionResolver.REDIS_KEY_V2_WHITELIST, + subsystemId.toString()); + // 主动驱逐本地缓存,加速生效 + versionResolver.invalidateCache(); + return success(true); + } + + /** + * 从 v2 白名单移除子系统 + * + *

权限:{@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 removeFromWhitelist(@PathVariable Long subsystemId) { + log.info("[removeFromWhitelist] 从 v2 白名单移除子系统: subsystemId={}", subsystemId); + redisTemplate.opsForSet().remove(IotRuleEngineVersionResolver.REDIS_KEY_V2_WHITELIST, + subsystemId.toString()); + // 主动驱逐本地缓存,加速生效 + versionResolver.invalidateCache(); + return success(true); + } + + /** + * 查询当前 v2 子系统白名单(静态 ∪ Redis 动态合并后的实际生效集合) + * + *

权限:{@code iot:rule-engine:query}(由部署层 @PreAuthorize 保证) + * + * @return 当前生效的 v2 子系统 ID 集合 + */ + @GetMapping("/v2-whitelist") + @Operation(summary = "查询当前 v2 子系统白名单") + @PreAuthorize("@ss.hasPermission('iot:rule-engine:query')") + public CommonResult> listWhitelist() { + // 强制刷新,确保返回最新数据 + versionResolver.invalidateCache(); + Set whitelist = versionResolver.getV2Whitelist(); + return success(whitelist); + } + + /** + * 查询当前规则引擎全局版本配置 + * + *

权限:{@code iot:rule-engine:query}(由部署层 @PreAuthorize 保证) + * + * @return 当前版本枚举值 {@link RuleEngineVersion} + */ + @GetMapping("/version") + @Operation(summary = "查询当前规则引擎全局版本配置") + @PreAuthorize("@ss.hasPermission('iot:rule-engine:query')") + public CommonResult currentVersion() { + return success(versionProperties.getVersion()); + } + + /** + * 主动刷新本地白名单缓存(紧急切换场景) + * + *

正常情况下缓存按 {@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> refreshWhitelistCache() { + log.info("[refreshWhitelistCache] 主动刷新 v2 白名单缓存"); + versionResolver.invalidateCache(); + Set whitelist = versionResolver.getV2Whitelist(); + return success(whitelist); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/RuleEngineVersionProperties.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/RuleEngineVersionProperties.java new file mode 100644 index 00000000..56c5767f --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/config/RuleEngineVersionProperties.java @@ -0,0 +1,55 @@ +package com.viewsh.module.iot.rule.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; +import java.util.Collections; +import java.util.Set; + +/** + * 规则引擎版本配置属性 + * + *

对应 YAML 配置: + *

{@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 { + + /** + * 规则引擎版本模式 + * + *

默认 {@link RuleEngineVersion#V1}(安全兜底:即使配置缺失,也走旧引擎) + */ + private RuleEngineVersion version = RuleEngineVersion.V1; + + /** + * hybrid 模式下走 v2 的子系统 ID 静态白名单 + * + *

仅 {@link RuleEngineVersion#HYBRID} 模式生效。 + * 静态白名单与 Redis 动态白名单取并集。 + */ + private Set v2SubsystemIds = Collections.emptySet(); + + /** + * 动态白名单(Redis)缓存刷新间隔,默认 60 秒 + * + *

仅 {@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 个用例: + *

    + *
  1. {@link #testV1Mode_alwaysFalse} — V1 模式始终 false
  2. + *
  3. {@link #testV2Mode_alwaysTrue} — V2 模式始终 true
  4. + *
  5. {@link #testHybrid_inWhitelist_true} — HYBRID 模式:子系统在白名单内 → true
  6. + *
  7. {@link #testHybrid_notInWhitelist_false} — HYBRID 模式:子系统不在白名单 → false
  8. + *
  9. {@link #testHybrid_nullSubsystem_false} — HYBRID 模式:subsystemId=null → false
  10. + *
  11. {@link #testHybrid_redisFallback_toStaticList} — Redis 异常时降级到静态白名单
  12. + *
  13. {@link #testHybrid_dynamicUpdate} — 动态更新:invalidateCache 后刷新白名单
  14. + *
+ * + * @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"); + } + +}