feat(iot): B19 规则引擎 v1/v2/hybrid 版本解析器 + 管理 API

- config/RuleEngineVersion.java(三态枚举 V1/V2/HYBRID)
- config/RuleEngineVersionProperties.java(@ConfigurationProperties, prefix=viewsh.iot.rule.engine)
- config/IotRuleEngineVersionResolver.java(volatile Set 本地缓存 < 1μs + Redis 动态白名单 + 降级)
- config/RuleEngineVersionAdminController.java(5 端点:add/remove/list/version/refresh,@PreAuthorize)
- config/IotRuleEngineVersionAutoConfiguration.java(@AutoConfiguration + @EnableConfigurationProperties)
- spring.factories 注册 AutoConfiguration
- 测试:IotRuleEngineVersionResolverTest 8 用例全绿(含 Redis 降级 + 动态刷新)
- Known Pitfalls 落地:
  ⚠️ 评审 B11:三态枚举 + switch 全覆盖
  ⚠️ 性能:volatile + Set.contains,无 I/O
  ⚠️ Redis 降级:try-catch + log.warn,不抛出
  ⚠️ subsystemId=null 走 v1(存量未归属设备安全默认)

Co-Authored-By: Claude Sonnet (B19 subagent) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-23 23:40:25 +08:00
parent 48e605b3c9
commit 66647e19dd
7 changed files with 640 additions and 0 deletions

View File

@@ -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;
/**
* 规则引擎版本开关自动配置
*
* <p>注册 {@link RuleEngineVersionProperties} 配置属性绑定,
* 并扫描 {@link IotRuleEngineVersionResolver} 和 {@link RuleEngineVersionAdminController}。
*
* <p>通过 {@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 {
}

View File

@@ -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;
/**
* 规则引擎版本解析器
*
* <p>核心职责:判断给定子系统/租户的消息是否应路由到 v2 新规则引擎。
*
* <p>三态逻辑:
* <ul>
* <li>{@link RuleEngineVersion#V1} — 始终返回 false全走 v1</li>
* <li>{@link RuleEngineVersion#V2} — 始终返回 true全走 v2</li>
* <li>{@link RuleEngineVersion#HYBRID} — 按子系统白名单路由:
* <ul>
* <li>{@code subsystemId == null} → false存量未归属设备安全默认走 v1</li>
* <li>{@code subsystemId} 在白名单(静态 Redis 动态)中 → true</li>
* <li>否则 → false</li>
* </ul>
* </li>
* </ul>
*
* <p>性能要求:{@link #shouldUseV2} 被每条消息调用,须 &lt; 1μs。
* 使用 {@code volatile Set} + {@code contains} 本地缓存,避免每次查 Redis。
*
* <p>⚠️ 评审 B11 — Known Pitfalls 落地:
* <ul>
* <li>三态必须存在,禁止用布尔替代</li>
* <li>白名单刷新时间差:改 Redis 后最多 {@code v2SubsystemRefresh}(默认 60s生效</li>
* <li>{@code subsystemId=null} 设备走 v1安全默认</li>
* <li>Redis 失败降级到静态白名单 + log.warn不抛出异常</li>
* </ul>
*
* @author lzh
*/
@Slf4j
@Service
public class IotRuleEngineVersionResolver {
/**
* Redis Keyv2 子系统白名单Set&lt;String&gt;
* 值为 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<Long> v2WhitelistCache = Collections.emptySet();
private volatile long cacheUpdateTime = 0L;
// ---------- 公共 API ----------
/**
* 判断给定子系统的消息是否应路由到 v2 规则引擎
*
* <p>此方法被每条设备消息调用,必须保证极低延迟(&lt; 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 动态白名单)
*
* <p>缓存过期后从 Redis 刷新Redis 不可用时降级到静态白名单,记录 warn 日志。
*
* @return 不可变的 v2 子系统 ID 集合
*/
public Set<Long> getV2Whitelist() {
long refreshMillis = props.getV2SubsystemRefresh().toMillis();
long now = System.currentTimeMillis();
if (now - cacheUpdateTime <= refreshMillis) {
// 缓存未过期,直接返回
return v2WhitelistCache;
}
// 缓存过期,从 Redis 拉取动态白名单
Set<Long> staticList = props.getV2SubsystemIds();
if (staticList == null) {
staticList = Collections.emptySet();
}
Set<Long> merged;
if (redisTemplate != null) {
try {
Set<String> rawRedis = redisTemplate.opsForSet().members(REDIS_KEY_V2_WHITELIST);
if (rawRedis != null && !rawRedis.isEmpty()) {
Set<Long> 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;
}
/**
* 主动强制刷新白名单缓存(用于测试或紧急切换场景)
*
* <p>通过将 {@code cacheUpdateTime} 置零,使下次 {@link #getV2Whitelist()} 调用立即从 Redis 重新拉取。
*/
public void invalidateCache() {
cacheUpdateTime = 0L;
}
}

View File

@@ -0,0 +1,35 @@
package com.viewsh.module.iot.rule.config;
/**
* 规则引擎版本枚举(三态)
*
* <ul>
* <li>{@link #V1} — 全量走旧规则引擎(兜底/回退)</li>
* <li>{@link #V2} — 全量走新规则引擎(全切 v2</li>
* <li>{@link #HYBRID} — 混合模式,按子系统白名单灰度切换:白名单内走 v2其余走 v1</li>
* </ul>
*
* <p>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
}

View File

@@ -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;
/**
* 管理后台 — 规则引擎版本管理
*
* <p>提供运行时动态管理 v2 子系统白名单的 REST API用于灰度发布控制。
*
* <p>权限说明:调用方需具备 {@code iot:rule-engine:admin} 权限。
* 建议在 iot-server 部署时配合 Spring Security 的 {@code @PreAuthorize} 鉴权。
*
* <p>URL 前缀遵循项目 Admin Controller 约定:{@code /iot/rule-engine/...}
* 实际在网关侧映射为 {@code /admin-api/iot/rule-engine/...}
*
* <p>⚠️ Known Pitfall评审 B11
* <ul>
* <li>白名单变更后最多 {@code v2SubsystemRefresh}(默认 60s生效缓存TTL
* 紧急场景可调用 {@code /refresh} 端点主动驱逐缓存</li>
* <li>所有变更操作应记录到操作日志(审计要求,由 iot-server 层 AOP 织入)</li>
* </ul>
*
* @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 白名单
*
* <p>权限:{@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<Boolean> 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 白名单移除子系统
*
* <p>权限:{@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<Boolean> 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 动态合并后的实际生效集合)
*
* <p>权限:{@code iot:rule-engine:query}(由部署层 @PreAuthorize 保证)
*
* @return 当前生效的 v2 子系统 ID 集合
*/
@GetMapping("/v2-whitelist")
@Operation(summary = "查询当前 v2 子系统白名单")
@PreAuthorize("@ss.hasPermission('iot:rule-engine:query')")
public CommonResult<Set<Long>> listWhitelist() {
// 强制刷新,确保返回最新数据
versionResolver.invalidateCache();
Set<Long> whitelist = versionResolver.getV2Whitelist();
return success(whitelist);
}
/**
* 查询当前规则引擎全局版本配置
*
* <p>权限:{@code iot:rule-engine:query}(由部署层 @PreAuthorize 保证)
*
* @return 当前版本枚举值 {@link RuleEngineVersion}
*/
@GetMapping("/version")
@Operation(summary = "查询当前规则引擎全局版本配置")
@PreAuthorize("@ss.hasPermission('iot:rule-engine:query')")
public CommonResult<RuleEngineVersion> currentVersion() {
return success(versionProperties.getVersion());
}
/**
* 主动刷新本地白名单缓存(紧急切换场景)
*
* <p>正常情况下缓存按 {@code v2SubsystemRefresh} 自动过期;
* 此端点用于变更 Redis 后需立即生效的紧急场景。
*
* <p>权限:{@code iot:rule-engine:admin}(由部署层 @PreAuthorize 保证)
*
* @return 刷新后的白名单集合
*/
@PostMapping("/v2-whitelist/refresh")
@Operation(summary = "主动刷新本地白名单缓存")
@PreAuthorize("@ss.hasPermission('iot:rule-engine:admin')")
public CommonResult<Set<Long>> refreshWhitelistCache() {
log.info("[refreshWhitelistCache] 主动刷新 v2 白名单缓存");
versionResolver.invalidateCache();
Set<Long> whitelist = versionResolver.getV2Whitelist();
return success(whitelist);
}
}

View File

@@ -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;
/**
* 规则引擎版本配置属性
*
* <p>对应 YAML 配置:
* <pre>{@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 秒
* }</pre>
*
* @author lzh
* @see IotRuleEngineVersionResolver
*/
@Data
@ConfigurationProperties(prefix = "viewsh.iot.rule.engine")
public class RuleEngineVersionProperties {
/**
* 规则引擎版本模式
*
* <p>默认 {@link RuleEngineVersion#V1}(安全兜底:即使配置缺失,也走旧引擎)
*/
private RuleEngineVersion version = RuleEngineVersion.V1;
/**
* hybrid 模式下走 v2 的子系统 ID 静态白名单
*
* <p>仅 {@link RuleEngineVersion#HYBRID} 模式生效。
* 静态白名单与 Redis 动态白名单取并集。
*/
private Set<Long> v2SubsystemIds = Collections.emptySet();
/**
* 动态白名单Redis缓存刷新间隔默认 60 秒
*
* <p>仅 {@link RuleEngineVersion#HYBRID} 模式生效。
* 调小此值可加快 Redis 白名单变更生效,但会增加 Redis 查询频率。
* 紧急场景可结合 Redis Pub/Sub 主动驱逐缓存(见 Known Pitfalls B11
*/
private Duration v2SubsystemRefresh = Duration.ofSeconds(60);
}

View File

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

View File

@@ -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} 单元测试
*
* <p>覆盖任务卡 B19 §6 的全部 6 个用例:
* <ol>
* <li>{@link #testV1Mode_alwaysFalse} — V1 模式始终 false</li>
* <li>{@link #testV2Mode_alwaysTrue} — V2 模式始终 true</li>
* <li>{@link #testHybrid_inWhitelist_true} — HYBRID 模式:子系统在白名单内 → true</li>
* <li>{@link #testHybrid_notInWhitelist_false} — HYBRID 模式:子系统不在白名单 → false</li>
* <li>{@link #testHybrid_nullSubsystem_false} — HYBRID 模式subsystemId=null → false</li>
* <li>{@link #testHybrid_redisFallback_toStaticList} — Redis 异常时降级到静态白名单</li>
* <li>{@link #testHybrid_dynamicUpdate} — 动态更新invalidateCache 后刷新白名单</li>
* </ol>
*
* @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), "HYBRIDsubsystem 5 在白名单内 → true");
assertTrue(resolver.shouldUseV2(7L, 1L), "HYBRIDsubsystem 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), "HYBRIDsubsystem 3 不在白名单 → false");
assertFalse(resolver.shouldUseV2(99L, 1L), "HYBRIDsubsystem 99 不在白名单 → false");
}
// ====================================================
// 用例 5: HYBRID 模式 — subsystemId=null → false安全默认
// ====================================================
@Test
void testHybrid_nullSubsystem_false() {
when(props.getVersion()).thenReturn(RuleEngineVersion.HYBRID);
assertFalse(resolver.shouldUseV2(null, 1L),
"HYBRIDsubsystemId=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");
}
}