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:
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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} 被每条消息调用,须 < 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 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<Long> v2WhitelistCache = Collections.emptySet();
|
||||
private volatile long cacheUpdateTime = 0L;
|
||||
|
||||
// ---------- 公共 API ----------
|
||||
|
||||
/**
|
||||
* 判断给定子系统的消息是否应路由到 v2 规则引擎
|
||||
*
|
||||
* <p>此方法被每条设备消息调用,必须保证极低延迟(< 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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), "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");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user