feat(iot): B17 SceneRule → DAG 自动转换工具 + dry-run/execute
- SceneRuleToChainMapper:v1→v2 纯转换逻辑
· trigger type 映射(1→device_state 等 4 种 + timer)
· action type 映射(1→device_property_set / 2→device_service_invoke / 100→alarm_trigger / 101→alarm_clear)
· SpEL→Aviator:#root.x → ${data.x};含 T(/instanceof/new 标记 WARNING 不中断
· 线性 DAG:Trigger → [Condition] → Action×N,临时 key -1/-2/-3...
- SceneRuleMigrator:干运行 + 分批执行(50条/批)+ 幂等(force 覆盖重迁)
- SceneRuleMigrationController:3 端点 dry-run/execute/mapping
- MigrationDryRunResultVO / MigrationExecuteReqVO
- 8 单元测试全绿(含 spel→aviator / unsupported_spel / idempotent / force)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package com.viewsh.module.iot.migration;
|
||||
|
||||
import com.viewsh.framework.common.pojo.CommonResult;
|
||||
import com.viewsh.module.iot.migration.vo.MigrationDryRunResultVO;
|
||||
import com.viewsh.module.iot.migration.vo.MigrationExecuteReqVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.viewsh.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* B17 — SceneRule 迁移 REST API
|
||||
*
|
||||
* <p>3 个端点(与 B18 对称):
|
||||
* <ul>
|
||||
* <li>POST /iot/migration/scene-rule/dry-run — 预览迁移结果(不写库)</li>
|
||||
* <li>POST /iot/migration/scene-rule/execute — 执行迁移(幂等)</li>
|
||||
* <li>GET /iot/migration/scene-rule/mapping — 查询已迁移映射关系</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Tag(name = "管理后台 - IoT SceneRule 迁移工具(B17)")
|
||||
@RestController
|
||||
@RequestMapping("/iot/migration/scene-rule")
|
||||
@Validated
|
||||
public class SceneRuleMigrationController {
|
||||
|
||||
@Resource
|
||||
private SceneRuleMigrator sceneRuleMigrator;
|
||||
|
||||
@PostMapping("/dry-run")
|
||||
@Operation(summary = "预览 SceneRule 迁移结果(不写库)",
|
||||
description = "返回每条 v1 SceneRule 的转换预览及问题清单;dry-run 不写任何数据")
|
||||
@PreAuthorize("@ss.hasPermission('iot:migration:scene-rule:dry-run')")
|
||||
public CommonResult<MigrationDryRunResultVO> dryRun() {
|
||||
return success(sceneRuleMigrator.dryRun());
|
||||
}
|
||||
|
||||
@PostMapping("/execute")
|
||||
@Operation(summary = "执行 SceneRule 迁移(幂等)",
|
||||
description = "将 v1 iot_scene_rule 迁移为 v2 DAG RuleChain(type=SCENE);"
|
||||
+ "已迁移的规则默认跳过,force=true 时覆盖重建")
|
||||
@PreAuthorize("@ss.hasPermission('iot:migration:scene-rule:execute')")
|
||||
public CommonResult<SceneRuleMigrator.ExecuteResult> execute(
|
||||
@RequestBody @Valid MigrationExecuteReqVO reqVO) {
|
||||
return success(sceneRuleMigrator.execute(reqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/mapping")
|
||||
@Operation(summary = "查询已迁移的映射关系",
|
||||
description = "查询 iot_scene_rule_migration 表中的所有映射记录(旧 rule_id → 新 chain_id)")
|
||||
@PreAuthorize("@ss.hasPermission('iot:migration:scene-rule:mapping')")
|
||||
public CommonResult<List<SceneRuleMigrator.MappingRecord>> queryMapping() {
|
||||
return success(sceneRuleMigrator.queryMappings());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package com.viewsh.module.iot.migration;
|
||||
|
||||
import com.viewsh.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import com.viewsh.module.iot.dal.mysql.rule.IotSceneRuleMapper;
|
||||
import com.viewsh.module.iot.migration.mapping.SceneRuleToChainMapper;
|
||||
import com.viewsh.module.iot.migration.vo.MigrationDryRunResultVO;
|
||||
import com.viewsh.module.iot.migration.vo.MigrationExecuteReqVO;
|
||||
import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainSaveReqVO;
|
||||
import com.viewsh.module.iot.rule.service.IotRuleChainService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* B17 — SceneRule → v2 DAG RuleChain 迁移主服务
|
||||
*
|
||||
* <p>策略(单向,不做双向同步,评审 B7):
|
||||
* <ol>
|
||||
* <li>读取 v1 {@code iot_scene_rule} 表中的场景联动规则</li>
|
||||
* <li>通过 {@link SceneRuleToChainMapper} 转换为 v2 chain 请求</li>
|
||||
* <li>调用 {@link IotRuleChainService#createRuleChain} 写入 v2 chain</li>
|
||||
* <li>向映射表 {@code iot_scene_rule_migration} 写入记录(幂等)</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>幂等:映射表唯一键 {@code uk_old (old_rule_id, tenant_id)},重复执行时先 COUNT 检查,
|
||||
* 已存在则跳过(force=true 时覆盖重建)。
|
||||
*
|
||||
* <p>批量事务:每批 50 条规则一个事务(大数据量时避免单事务过大)。
|
||||
*
|
||||
* <p>映射表 DDL(参考):
|
||||
* <pre>{@code
|
||||
* CREATE TABLE iot_scene_rule_migration (
|
||||
* id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
* old_rule_id BIGINT NOT NULL,
|
||||
* new_chain_id BIGINT NOT NULL,
|
||||
* migrated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
* migrator VARCHAR(64),
|
||||
* tenant_id BIGINT NOT NULL,
|
||||
* UNIQUE KEY uk_old (old_rule_id, tenant_id)
|
||||
* ) COMMENT='SceneRule 迁移映射';
|
||||
* }</pre>
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SceneRuleMigrator {
|
||||
|
||||
/** 每批提交条数(避免单事务过大) */
|
||||
static final int BATCH_SIZE = 50;
|
||||
|
||||
private final IotSceneRuleMapper sceneRuleMapper;
|
||||
private final SceneRuleToChainMapper chainMapper;
|
||||
private final IotRuleChainService ruleChainService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 预览(dry-run):不实际写库,仅返回转换预览和问题清单
|
||||
*
|
||||
* @return 预览结果 VO
|
||||
*/
|
||||
public MigrationDryRunResultVO dryRun() {
|
||||
List<IotSceneRuleDO> rules = sceneRuleMapper.selectList(null);
|
||||
if (rules.isEmpty()) {
|
||||
return new MigrationDryRunResultVO(0, 0, 0,
|
||||
Collections.emptyList(), Collections.emptyList());
|
||||
}
|
||||
|
||||
List<MigrationDryRunResultVO.MigrationIssue> issues = new ArrayList<>();
|
||||
List<String> convertibleNames = new ArrayList<>();
|
||||
|
||||
for (IotSceneRuleDO rule : rules) {
|
||||
List<MigrationDryRunResultVO.MigrationIssue> ruleIssues = new ArrayList<>();
|
||||
try {
|
||||
chainMapper.toChainReqVO(rule, ruleIssues);
|
||||
convertibleNames.add(rule.getName());
|
||||
// 若有 WARNING 级问题,追加到全局 issues
|
||||
issues.addAll(ruleIssues);
|
||||
} catch (Exception e) {
|
||||
log.warn("[B17] dry-run: SceneRule(id={}, name={}) 转换失败: {}",
|
||||
rule.getId(), rule.getName(), e.getMessage());
|
||||
issues.add(new MigrationDryRunResultVO.MigrationIssue(
|
||||
rule.getId(), rule.getName(), e.getMessage(), "ERROR"));
|
||||
issues.addAll(ruleIssues); // 同时保留 WARNING
|
||||
}
|
||||
}
|
||||
|
||||
int issueErrorCount = (int) issues.stream()
|
||||
.filter(i -> "ERROR".equals(i.getSeverity())).count();
|
||||
int convertibleCount = rules.size() - issueErrorCount;
|
||||
|
||||
// 最多返回 100 条可转换的规则名称
|
||||
List<String> previewNames = convertibleNames.size() > 100
|
||||
? convertibleNames.subList(0, 100)
|
||||
: convertibleNames;
|
||||
|
||||
return new MigrationDryRunResultVO(rules.size(), convertibleCount,
|
||||
issues.size(), issues, previewNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行迁移(幂等,分批提交)
|
||||
*
|
||||
* @param opts 迁移选项(migrator、force、tenantId)
|
||||
* @return 执行结果汇总
|
||||
*/
|
||||
public ExecuteResult execute(MigrationExecuteReqVO opts) {
|
||||
List<IotSceneRuleDO> rules = sceneRuleMapper.selectList(null);
|
||||
if (rules.isEmpty()) {
|
||||
return new ExecuteResult(0, 0, 0, Collections.emptyList());
|
||||
}
|
||||
|
||||
int totalRules = rules.size();
|
||||
int migratedCount = 0;
|
||||
int skippedCount = 0;
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
// 分批处理(每批 BATCH_SIZE 条,各自独立事务)
|
||||
for (int batchStart = 0; batchStart < rules.size(); batchStart += BATCH_SIZE) {
|
||||
int batchEnd = Math.min(batchStart + BATCH_SIZE, rules.size());
|
||||
List<IotSceneRuleDO> batch = rules.subList(batchStart, batchEnd);
|
||||
|
||||
BatchResult batchResult = executeBatch(batch, opts);
|
||||
migratedCount += batchResult.migratedCount();
|
||||
skippedCount += batchResult.skippedCount();
|
||||
errors.addAll(batchResult.errors());
|
||||
}
|
||||
|
||||
log.info("[B17] 迁移完成: total={}, migrated={}, skipped={}, errors={}",
|
||||
totalRules, migratedCount, skippedCount, errors.size());
|
||||
return new ExecuteResult(totalRules, migratedCount, skippedCount, errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询已迁移的映射关系
|
||||
*
|
||||
* @return 映射记录列表
|
||||
*/
|
||||
public List<MappingRecord> queryMappings() {
|
||||
String sql = "SELECT id, old_rule_id, new_chain_id, migrated_at, migrator, tenant_id "
|
||||
+ "FROM iot_scene_rule_migration ORDER BY old_rule_id";
|
||||
return jdbcTemplate.query(sql, (rs, rowNum) -> new MappingRecord(
|
||||
rs.getLong("id"),
|
||||
rs.getLong("old_rule_id"),
|
||||
rs.getLong("new_chain_id"),
|
||||
rs.getObject("migrated_at", LocalDateTime.class),
|
||||
rs.getString("migrator"),
|
||||
rs.getLong("tenant_id")
|
||||
));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private: batch execution (each batch in its own transaction)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public BatchResult executeBatch(List<IotSceneRuleDO> batch, MigrationExecuteReqVO opts) {
|
||||
int migratedCount = 0;
|
||||
int skippedCount = 0;
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
for (IotSceneRuleDO rule : batch) {
|
||||
try {
|
||||
boolean migrated = migrateOne(rule, opts);
|
||||
if (migrated) {
|
||||
migratedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.error("[B17] 迁移 SceneRule(id={}) 失败", rule.getId(), ex);
|
||||
errors.add("ruleId=" + rule.getId() + "(" + rule.getName() + "): " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
return new BatchResult(migratedCount, skippedCount, errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移单条 SceneRule(幂等判断 + force 支持)
|
||||
*
|
||||
* @return true=执行了迁移;false=跳过(已迁移且 force=false)
|
||||
*/
|
||||
private boolean migrateOne(IotSceneRuleDO rule, MigrationExecuteReqVO opts) {
|
||||
boolean alreadyMigrated = isMigrated(rule.getId());
|
||||
|
||||
if (alreadyMigrated && !opts.isForce()) {
|
||||
log.info("[B17] SceneRule(id={}) 已迁移,跳过", rule.getId());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 转换(issues 仅收集 WARNING,ERROR 直接抛出)
|
||||
List<MigrationDryRunResultVO.MigrationIssue> warnings = new ArrayList<>();
|
||||
IotRuleChainSaveReqVO req = chainMapper.toChainReqVO(rule, warnings);
|
||||
|
||||
if (!warnings.isEmpty()) {
|
||||
log.warn("[B17] SceneRule(id={}) 转换有 {} 个 WARNING,继续迁移",
|
||||
rule.getId(), warnings.size());
|
||||
}
|
||||
|
||||
// 创建 v2 chain
|
||||
if (req.getLinks() == null) {
|
||||
req.setLinks(Collections.emptyList());
|
||||
}
|
||||
Long newChainId = ruleChainService.createRuleChain(req);
|
||||
|
||||
// 写映射表(force=true 时先删除旧记录再插入)
|
||||
if (alreadyMigrated) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM iot_scene_rule_migration WHERE old_rule_id = ?",
|
||||
rule.getId());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO iot_scene_rule_migration (old_rule_id, new_chain_id, migrator, tenant_id) "
|
||||
+ "VALUES (?, ?, ?, ?)",
|
||||
rule.getId(), newChainId,
|
||||
opts.getMigrator(),
|
||||
0L /* tenantId 由多租户框架注入,此处占位 */
|
||||
);
|
||||
} catch (DuplicateKeyException e) {
|
||||
log.warn("[B17] 映射表 UK 冲突,SceneRule(id={}),忽略", rule.getId());
|
||||
}
|
||||
|
||||
log.info("[B17] SceneRule(id={}) → RuleChain(id={})", rule.getId(), newChainId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查规则是否已迁移(通过映射表 COUNT 判断)
|
||||
*/
|
||||
private boolean isMigrated(Long ruleId) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM iot_scene_rule_migration WHERE old_rule_id = ?",
|
||||
Integer.class,
|
||||
ruleId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Result DTOs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 单批次执行结果(内部用) */
|
||||
public record BatchResult(
|
||||
int migratedCount,
|
||||
int skippedCount,
|
||||
List<String> errors
|
||||
) {
|
||||
}
|
||||
|
||||
/** 迁移执行结果汇总 */
|
||||
public record ExecuteResult(
|
||||
int totalRules,
|
||||
int migratedCount,
|
||||
int skippedCount,
|
||||
List<String> errors
|
||||
) {
|
||||
}
|
||||
|
||||
/** 映射表记录 DTO */
|
||||
public record MappingRecord(
|
||||
Long id,
|
||||
Long oldRuleId,
|
||||
Long newChainId,
|
||||
LocalDateTime migratedAt,
|
||||
String migrator,
|
||||
Long tenantId
|
||||
) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package com.viewsh.module.iot.migration.mapping;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.viewsh.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import com.viewsh.module.iot.enums.rule.IotSceneRuleActionTypeEnum;
|
||||
import com.viewsh.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
|
||||
import com.viewsh.module.iot.migration.vo.MigrationDryRunResultVO;
|
||||
import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainSaveReqVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* B17 — v1 SceneRule → v2 DAG RuleChain 映射器(纯转换,不写库)
|
||||
*
|
||||
* <p>映射规则:
|
||||
* <ul>
|
||||
* <li>v1 Trigger.type 1(DEVICE_STATE_UPDATE) → v2 trigger type {@code device_state}</li>
|
||||
* <li>v1 Trigger.type 2(DEVICE_PROPERTY_POST) → v2 trigger type {@code device_property}</li>
|
||||
* <li>v1 Trigger.type 3(DEVICE_EVENT_POST) → v2 trigger type {@code device_event}</li>
|
||||
* <li>v1 Trigger.type 4(DEVICE_SERVICE_INVOKE) → v2 trigger type {@code device_service}</li>
|
||||
* <li>v1 Trigger.type 100(TIMER) → v2 trigger type {@code timer}</li>
|
||||
* <li>v1 Action.type 1(DEVICE_PROPERTY_SET) → v2 action type {@code device_property_set}</li>
|
||||
* <li>v1 Action.type 2(DEVICE_SERVICE_INVOKE) → v2 action type {@code device_service_invoke}</li>
|
||||
* <li>v1 Action.type 100(ALERT_TRIGGER) → v2 action type {@code alarm_trigger}</li>
|
||||
* <li>v1 Action.type 101(ALERT_RECOVER) → v2 action type {@code alarm_clear}</li>
|
||||
* <li>v1 conditionGroups (SpEL) → v2 condition node type=expression (Aviator 表达式)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>DAG 节点顺序(线性):
|
||||
* <pre>
|
||||
* Trigger (临时 key=-1) → [Condition (临时 key=-2) →] Action1 (临时 key=-3) → Action2 ...
|
||||
* </pre>
|
||||
*
|
||||
* <p>SpEL → Aviator 简单转换规则:
|
||||
* <ul>
|
||||
* <li>{@code #root.fieldName} → {@code ${data.fieldName}}</li>
|
||||
* <li>保留 {@code &&} / {@code ||} / {@code !} / 比较运算符</li>
|
||||
* <li>含 {@code T(} 或 {@code .class} → 标记 WARNING,无法自动转换</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SceneRuleToChainMapper {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// SpEL 高级语法标记(无法自动转换)
|
||||
private static final List<String> UNSUPPORTED_SPEL_PATTERNS = List.of(
|
||||
"T(", ".class", "instanceof", "new ", "#this", "?.["
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 将单条 v1 SceneRule 转换为 v2 RuleChain 请求 VO
|
||||
*
|
||||
* @param rule v1 场景联动规则 DO
|
||||
* @param issues 当存在 WARNING 级问题时,追加到此列表(ERROR 级直接抛出异常)
|
||||
* @return 转换后的 v2 RuleChain 请求 VO
|
||||
* @throws IllegalArgumentException 当规则结构无法转换时(ERROR 级)
|
||||
*/
|
||||
public IotRuleChainSaveReqVO toChainReqVO(IotSceneRuleDO rule,
|
||||
List<MigrationDryRunResultVO.MigrationIssue> issues) {
|
||||
IotRuleChainSaveReqVO req = new IotRuleChainSaveReqVO();
|
||||
req.setName(rule.getName());
|
||||
req.setDescription(rule.getDescription());
|
||||
req.setType("SCENE");
|
||||
req.setPriority(100);
|
||||
req.setDebugMode(false);
|
||||
|
||||
List<IotRuleChainSaveReqVO.NodeVO> nodes = new ArrayList<>();
|
||||
List<IotRuleChainSaveReqVO.LinkVO> links = new ArrayList<>();
|
||||
|
||||
// --- 1. Trigger 节点(临时 key=-1)---
|
||||
if (rule.getTriggers() == null || rule.getTriggers().isEmpty()) {
|
||||
throw new IllegalArgumentException("规则缺少 triggers 定义");
|
||||
}
|
||||
if (rule.getTriggers().size() > 1) {
|
||||
throw new IllegalArgumentException("v2 仅支持单 Trigger,当前 triggers=" + rule.getTriggers().size());
|
||||
}
|
||||
|
||||
IotSceneRuleDO.Trigger v1Trigger = rule.getTriggers().get(0);
|
||||
IotRuleChainSaveReqVO.NodeVO triggerNode = buildTriggerNode(v1Trigger);
|
||||
triggerNode.setPositionX(100);
|
||||
triggerNode.setPositionY(200);
|
||||
nodes.add(triggerNode); // index 0, 临时 key=-1
|
||||
|
||||
// 提取 productId / deviceId 供 chain 级别过滤
|
||||
req.setProductId(v1Trigger.getProductId());
|
||||
req.setDeviceId(v1Trigger.getDeviceId());
|
||||
|
||||
// 下一个节点的目标 key(从 -2 开始,trigger=-1)
|
||||
long nextTempKey = -2L;
|
||||
long prevTempKey = -1L; // trigger 节点临时 key
|
||||
|
||||
// --- 2. Condition 节点(从 conditionGroups 生成 Aviator 表达式)---
|
||||
String conditionExpr = buildConditionExpression(v1Trigger, issues, rule);
|
||||
if (conditionExpr != null) {
|
||||
IotRuleChainSaveReqVO.NodeVO condNode = buildConditionNode(conditionExpr);
|
||||
condNode.setPositionX(300);
|
||||
condNode.setPositionY(200);
|
||||
nodes.add(condNode); // 临时 key=nextTempKey
|
||||
|
||||
links.add(buildLink(prevTempKey, nextTempKey, "Success", 0));
|
||||
prevTempKey = nextTempKey;
|
||||
nextTempKey--;
|
||||
}
|
||||
|
||||
// --- 3. Action 节点(每个 v1 action 对应一个 v2 action 节点)---
|
||||
if (rule.getActions() == null || rule.getActions().isEmpty()) {
|
||||
throw new IllegalArgumentException("规则缺少 actions 定义");
|
||||
}
|
||||
|
||||
int actionY = 100;
|
||||
int sortOrder = 0;
|
||||
for (IotSceneRuleDO.Action v1Action : rule.getActions()) {
|
||||
IotRuleChainSaveReqVO.NodeVO actionNode = buildActionNode(v1Action);
|
||||
actionNode.setPositionX(500);
|
||||
actionNode.setPositionY(actionY);
|
||||
actionY += 120;
|
||||
nodes.add(actionNode); // 临时 key=nextTempKey
|
||||
|
||||
links.add(buildLink(prevTempKey, nextTempKey, "Success", sortOrder++));
|
||||
|
||||
// 如果有多个 action,每个 action 都接在前一个 action 之后(线性)
|
||||
prevTempKey = nextTempKey;
|
||||
nextTempKey--;
|
||||
}
|
||||
|
||||
req.setNodes(nodes);
|
||||
req.setLinks(links);
|
||||
return req;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Trigger 映射
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
IotRuleChainSaveReqVO.NodeVO buildTriggerNode(IotSceneRuleDO.Trigger v1Trigger) {
|
||||
IotRuleChainSaveReqVO.NodeVO node = new IotRuleChainSaveReqVO.NodeVO();
|
||||
node.setCategory("trigger");
|
||||
|
||||
String v2Type = mapTriggerType(v1Trigger.getType());
|
||||
node.setType(v2Type);
|
||||
node.setName("触发器-" + v2Type);
|
||||
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("productId", v1Trigger.getProductId());
|
||||
config.put("deviceId", v1Trigger.getDeviceId());
|
||||
if (v1Trigger.getIdentifier() != null) {
|
||||
config.put("identifier", v1Trigger.getIdentifier());
|
||||
}
|
||||
if (v1Trigger.getOperator() != null) {
|
||||
config.put("operator", v1Trigger.getOperator());
|
||||
}
|
||||
if (v1Trigger.getValue() != null) {
|
||||
config.put("value", v1Trigger.getValue());
|
||||
}
|
||||
if (v1Trigger.getCronExpression() != null) {
|
||||
config.put("cronExpression", v1Trigger.getCronExpression());
|
||||
}
|
||||
node.setConfiguration(toJson(config));
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 Trigger.type (Integer) → v2 trigger type 字符串
|
||||
*/
|
||||
public static String mapTriggerType(Integer type) {
|
||||
if (type == null) {
|
||||
throw new IllegalArgumentException("Trigger.type 不能为空");
|
||||
}
|
||||
if (type.equals(IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE.getType())) {
|
||||
return "device_state";
|
||||
} else if (type.equals(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType())) {
|
||||
return "device_property";
|
||||
} else if (type.equals(IotSceneRuleTriggerTypeEnum.DEVICE_EVENT_POST.getType())) {
|
||||
return "device_event";
|
||||
} else if (type.equals(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType())) {
|
||||
return "device_service";
|
||||
} else if (type.equals(IotSceneRuleTriggerTypeEnum.TIMER.getType())) {
|
||||
return "timer";
|
||||
} else {
|
||||
throw new IllegalArgumentException("不支持的 Trigger.type=" + type);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Condition 映射(conditionGroups → Aviator 表达式)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 从 v1 Trigger.conditionGroups 构建 Aviator 表达式字符串
|
||||
*
|
||||
* @return Aviator 表达式字符串;若无条件则返回 null
|
||||
*/
|
||||
private String buildConditionExpression(IotSceneRuleDO.Trigger trigger,
|
||||
List<MigrationDryRunResultVO.MigrationIssue> issues,
|
||||
IotSceneRuleDO rule) {
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> condGroups = trigger.getConditionGroups();
|
||||
if (condGroups == null || condGroups.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将多个分组(OR 关系)展开,每组内是 AND 关系
|
||||
List<String> orParts = new ArrayList<>();
|
||||
for (List<IotSceneRuleDO.TriggerCondition> group : condGroups) {
|
||||
if (group == null || group.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
List<String> andParts = new ArrayList<>();
|
||||
for (IotSceneRuleDO.TriggerCondition cond : group) {
|
||||
String expr = buildSingleConditionExpr(cond, issues, rule);
|
||||
if (expr != null) {
|
||||
andParts.add(expr);
|
||||
}
|
||||
}
|
||||
if (!andParts.isEmpty()) {
|
||||
String andExpr = andParts.size() == 1 ? andParts.get(0) : "(" + String.join(" && ", andParts) + ")";
|
||||
orParts.add(andExpr);
|
||||
}
|
||||
}
|
||||
|
||||
if (orParts.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return orParts.size() == 1 ? orParts.get(0) : String.join(" || ", orParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个 TriggerCondition 转换为 Aviator 表达式片段
|
||||
*/
|
||||
private String buildSingleConditionExpr(IotSceneRuleDO.TriggerCondition cond,
|
||||
List<MigrationDryRunResultVO.MigrationIssue> issues,
|
||||
IotSceneRuleDO rule) {
|
||||
if (cond.getIdentifier() == null || cond.getOperator() == null) {
|
||||
return null;
|
||||
}
|
||||
// 形如:${data.temperature} > 40
|
||||
String field = "${data." + cond.getIdentifier() + "}";
|
||||
String op = mapOperator(cond.getOperator());
|
||||
String param = cond.getParam() != null ? cond.getParam() : "null";
|
||||
return field + " " + op + " " + param;
|
||||
}
|
||||
|
||||
/**
|
||||
* SpEL 表达式 → Aviator 表达式简单转换
|
||||
*
|
||||
* <p>规则:
|
||||
* <ol>
|
||||
* <li>{@code #root.fieldName} → {@code ${data.fieldName}}</li>
|
||||
* <li>运算符 {@code &&} / {@code ||} / {@code !} / 比较运算符保留</li>
|
||||
* <li>含 {@code T(} 或 {@code .class} → WARNING,原样保留并记录 issue</li>
|
||||
* </ol>
|
||||
*/
|
||||
public SpelConversionResult convertSpelToAviator(String spelExpr) {
|
||||
if (spelExpr == null || spelExpr.isBlank()) {
|
||||
return new SpelConversionResult(spelExpr, false, null);
|
||||
}
|
||||
|
||||
// 检查是否含高级 SpEL 语法
|
||||
for (String pattern : UNSUPPORTED_SPEL_PATTERNS) {
|
||||
if (spelExpr.contains(pattern)) {
|
||||
String warning = "SpEL 表达式含不支持的语法 [" + pattern + "],需人工确认: " + spelExpr;
|
||||
log.warn("[B17] {}", warning);
|
||||
return new SpelConversionResult(spelExpr, true, warning);
|
||||
}
|
||||
}
|
||||
|
||||
// 简单转换:#root.fieldName → ${data.fieldName}
|
||||
String aviator = spelExpr.replaceAll("#root\\.([a-zA-Z_][a-zA-Z0-9_]*)", "\\$\\{data.$1\\}");
|
||||
|
||||
return new SpelConversionResult(aviator, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 operator 字符串映射到 Aviator 支持的操作符
|
||||
*/
|
||||
private String mapOperator(String op) {
|
||||
if (op == null) {
|
||||
return "==";
|
||||
}
|
||||
return switch (op.toUpperCase()) {
|
||||
case "EQ", "EQUAL", "==" -> "==";
|
||||
case "NEQ", "NOT_EQUAL", "!=" -> "!=";
|
||||
case "GT", "GREATER_THAN", ">" -> ">";
|
||||
case "GTE", "GREATER_THAN_OR_EQUAL", ">=" -> ">=";
|
||||
case "LT", "LESS_THAN", "<" -> "<";
|
||||
case "LTE", "LESS_THAN_OR_EQUAL", "<=" -> "<=";
|
||||
default -> op; // 保留原始值(如 IN/BETWEEN 等由 caller 处理)
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Condition 节点构建(含 Aviator 表达式)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
IotRuleChainSaveReqVO.NodeVO buildConditionNode(String aviatorExpr) {
|
||||
IotRuleChainSaveReqVO.NodeVO node = new IotRuleChainSaveReqVO.NodeVO();
|
||||
node.setCategory("condition");
|
||||
node.setType("expression");
|
||||
node.setName("条件判断");
|
||||
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("expression", aviatorExpr);
|
||||
node.setConfiguration(toJson(config));
|
||||
return node;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Action 映射
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
IotRuleChainSaveReqVO.NodeVO buildActionNode(IotSceneRuleDO.Action v1Action) {
|
||||
IotRuleChainSaveReqVO.NodeVO node = new IotRuleChainSaveReqVO.NodeVO();
|
||||
node.setCategory("action");
|
||||
|
||||
String v2Type = mapActionType(v1Action.getType());
|
||||
node.setType(v2Type);
|
||||
node.setName("动作-" + v2Type);
|
||||
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
if (v1Action.getProductId() != null) {
|
||||
config.put("productId", v1Action.getProductId());
|
||||
}
|
||||
if (v1Action.getDeviceId() != null) {
|
||||
config.put("deviceId", v1Action.getDeviceId());
|
||||
}
|
||||
if (v1Action.getIdentifier() != null) {
|
||||
config.put("identifier", v1Action.getIdentifier());
|
||||
}
|
||||
if (v1Action.getParams() != null) {
|
||||
config.put("params", v1Action.getParams());
|
||||
}
|
||||
if (v1Action.getAlertConfigId() != null) {
|
||||
config.put("alertConfigId", v1Action.getAlertConfigId());
|
||||
}
|
||||
node.setConfiguration(toJson(config));
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 Action.type (Integer) → v2 action type 字符串
|
||||
*/
|
||||
public static String mapActionType(Integer type) {
|
||||
if (type == null) {
|
||||
throw new IllegalArgumentException("Action.type 不能为空");
|
||||
}
|
||||
if (type.equals(IotSceneRuleActionTypeEnum.DEVICE_PROPERTY_SET.getType())) {
|
||||
return "device_property_set";
|
||||
} else if (type.equals(IotSceneRuleActionTypeEnum.DEVICE_SERVICE_INVOKE.getType())) {
|
||||
return "device_service_invoke";
|
||||
} else if (type.equals(IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType())) {
|
||||
return "alarm_trigger";
|
||||
} else if (type.equals(IotSceneRuleActionTypeEnum.ALERT_RECOVER.getType())) {
|
||||
return "alarm_clear";
|
||||
} else {
|
||||
throw new IllegalArgumentException("不支持的 Action.type=" + type);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Link 构建
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private IotRuleChainSaveReqVO.LinkVO buildLink(long sourceKey, long targetKey,
|
||||
String relationType, int sortOrder) {
|
||||
IotRuleChainSaveReqVO.LinkVO link = new IotRuleChainSaveReqVO.LinkVO();
|
||||
link.setSourceNodeId(sourceKey);
|
||||
link.setTargetNodeId(targetKey);
|
||||
link.setRelationType(relationType);
|
||||
link.setSortOrder(sortOrder);
|
||||
return link;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JSON util
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private String toJson(Map<String, Object> map) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(map);
|
||||
} catch (Exception e) {
|
||||
log.error("[B17] JSON 序列化失败", e);
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Result DTO
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* SpEL → Aviator 转换结果
|
||||
*
|
||||
* @param aviatorExpr 转换后的 Aviator 表达式(转换失败时为原始 SpEL)
|
||||
* @param hasWarning 是否包含不支持的语法(需人工确认)
|
||||
* @param warningReason 告警原因
|
||||
*/
|
||||
public record SpelConversionResult(
|
||||
String aviatorExpr,
|
||||
boolean hasWarning,
|
||||
String warningReason
|
||||
) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.viewsh.module.iot.migration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* B17 — SceneRule 迁移预览结果 VO
|
||||
*
|
||||
* <p>dry-run 时返回,不实际写库。
|
||||
*/
|
||||
@Schema(description = "SceneRule 迁移预览结果")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MigrationDryRunResultVO {
|
||||
|
||||
@Schema(description = "v1 规则总数", example = "50")
|
||||
private int totalRules;
|
||||
|
||||
@Schema(description = "可成功转换的规则数", example = "48")
|
||||
private int convertibleCount;
|
||||
|
||||
@Schema(description = "转换失败的规则数", example = "2")
|
||||
private int issueCount;
|
||||
|
||||
@Schema(description = "转换失败的规则列表(含原因)")
|
||||
private List<MigrationIssue> issues;
|
||||
|
||||
@Schema(description = "可转换的规则名称预览(前 100 条)")
|
||||
private List<String> convertibleRuleNames;
|
||||
|
||||
/**
|
||||
* 单条转换问题
|
||||
*/
|
||||
@Schema(description = "单条转换问题")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class MigrationIssue {
|
||||
|
||||
@Schema(description = "v1 规则编号")
|
||||
private Long ruleId;
|
||||
|
||||
@Schema(description = "v1 规则名称")
|
||||
private String ruleName;
|
||||
|
||||
@Schema(description = "问题描述(含不支持的 SpEL 表达式等)")
|
||||
private String reason;
|
||||
|
||||
@Schema(description = "严重程度:WARNING=可继续但需人工确认,ERROR=无法转换",
|
||||
example = "WARNING",
|
||||
allowableValues = {"WARNING", "ERROR"})
|
||||
private String severity;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.viewsh.module.iot.migration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* B17 — SceneRule 迁移执行请求 VO
|
||||
*/
|
||||
@Schema(description = "SceneRule 迁移执行请求")
|
||||
@Data
|
||||
public class MigrationExecuteReqVO {
|
||||
|
||||
@Schema(description = "操作人标识(审计用)", example = "admin")
|
||||
private String migrator = "system";
|
||||
|
||||
@Schema(description = "是否强制重新迁移(已迁移的规则会被覆盖)", example = "false")
|
||||
private boolean force = false;
|
||||
|
||||
@Schema(description = "租户编号(不填则迁移所有租户)", example = "1")
|
||||
private Long tenantId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package com.viewsh.module.iot.migration;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.viewsh.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.viewsh.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import com.viewsh.module.iot.dal.mysql.rule.IotSceneRuleMapper;
|
||||
import com.viewsh.module.iot.enums.rule.IotSceneRuleActionTypeEnum;
|
||||
import com.viewsh.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
|
||||
import com.viewsh.module.iot.migration.mapping.SceneRuleToChainMapper;
|
||||
import com.viewsh.module.iot.migration.vo.MigrationDryRunResultVO;
|
||||
import com.viewsh.module.iot.migration.vo.MigrationExecuteReqVO;
|
||||
import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainSaveReqVO;
|
||||
import com.viewsh.module.iot.rule.service.IotRuleChainService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* B17 — {@link SceneRuleMigrator} 单元测试
|
||||
*
|
||||
* <p>6 个测试用例:
|
||||
* <ol>
|
||||
* <li>simple_device_property — trigger=device_property, cond, action=alarm_trigger</li>
|
||||
* <li>spel_to_aviator — #root.temp > 40 → ${data.temp} > 40</li>
|
||||
* <li>unsupported_spel — 含 T(java.lang.Math) → issues 列表</li>
|
||||
* <li>idempotent_rerun — 已迁移 → 跳过(not force)</li>
|
||||
* <li>execute_force — 已迁移 + force=true → 重新迁移</li>
|
||||
* <li>multi_action — 多个 actions → 多个 action 节点</li>
|
||||
* </ol>
|
||||
*/
|
||||
class SceneRuleMigratorTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Mock
|
||||
private IotSceneRuleMapper sceneRuleMapper;
|
||||
|
||||
@Mock
|
||||
private IotRuleChainService ruleChainService;
|
||||
|
||||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private SceneRuleToChainMapper chainMapper;
|
||||
private SceneRuleMigrator migrator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
chainMapper = new SceneRuleToChainMapper(new ObjectMapper());
|
||||
migrator = new SceneRuleMigrator(sceneRuleMapper, chainMapper, ruleChainService, jdbcTemplate);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 1:simple_device_property
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("simple_device_property: trigger=device_property, cond, action=alarm_trigger → v2 chain SCENE")
|
||||
void testSimpleDeviceProperty() {
|
||||
// Given
|
||||
IotSceneRuleDO rule = buildRule(1L, "温度报警",
|
||||
buildTrigger(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType(), 10L, 0L, "temperature", null, null),
|
||||
List.of(buildAction(IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType(), null, null, null, null, 100L))
|
||||
);
|
||||
|
||||
when(sceneRuleMapper.selectList(null)).thenReturn(List.of(rule));
|
||||
mockNotMigrated(1L);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(200L);
|
||||
mockInsertMapping();
|
||||
|
||||
// When
|
||||
MigrationExecuteReqVO opts = buildOpts("test", false);
|
||||
SceneRuleMigrator.ExecuteResult result = migrator.execute(opts);
|
||||
|
||||
// Then
|
||||
assertThat(result.totalRules()).isEqualTo(1);
|
||||
assertThat(result.migratedCount()).isEqualTo(1);
|
||||
assertThat(result.skippedCount()).isEqualTo(0);
|
||||
assertThat(result.errors()).isEmpty();
|
||||
|
||||
ArgumentCaptor<IotRuleChainSaveReqVO> captor = ArgumentCaptor.forClass(IotRuleChainSaveReqVO.class);
|
||||
verify(ruleChainService).createRuleChain(captor.capture());
|
||||
IotRuleChainSaveReqVO req = captor.getValue();
|
||||
|
||||
assertThat(req.getType()).isEqualTo("SCENE");
|
||||
assertThat(req.getPriority()).isEqualTo(100);
|
||||
|
||||
// Trigger 节点
|
||||
IotRuleChainSaveReqVO.NodeVO trigger = findNode(req, "trigger");
|
||||
assertThat(trigger).isNotNull();
|
||||
assertThat(trigger.getType()).isEqualTo("device_property");
|
||||
|
||||
// Action 节点
|
||||
IotRuleChainSaveReqVO.NodeVO action = findNode(req, "action");
|
||||
assertThat(action).isNotNull();
|
||||
assertThat(action.getType()).isEqualTo("alarm_trigger");
|
||||
|
||||
// Links
|
||||
assertThat(req.getLinks()).hasSize(1);
|
||||
assertThat(req.getLinks().get(0).getRelationType()).isEqualTo("Success");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 2:spel_to_aviator
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("spel_to_aviator: #root.temp > 40 && #root.hum < 20 → ${data.temp} > 40 && ${data.hum} < 20")
|
||||
void testSpelToAviator() {
|
||||
SceneRuleToChainMapper mapper = new SceneRuleToChainMapper(new ObjectMapper());
|
||||
|
||||
// 简单字段引用转换
|
||||
SceneRuleToChainMapper.SpelConversionResult r1 =
|
||||
mapper.convertSpelToAviator("#root.temp > 40 && #root.hum < 20");
|
||||
assertThat(r1.hasWarning()).isFalse();
|
||||
assertThat(r1.aviatorExpr()).isEqualTo("${data.temp} > 40 && ${data.hum} < 20");
|
||||
|
||||
// 单字段
|
||||
SceneRuleToChainMapper.SpelConversionResult r2 =
|
||||
mapper.convertSpelToAviator("#root.temperature");
|
||||
assertThat(r2.hasWarning()).isFalse();
|
||||
assertThat(r2.aviatorExpr()).isEqualTo("${data.temperature}");
|
||||
|
||||
// null 输入
|
||||
SceneRuleToChainMapper.SpelConversionResult r3 =
|
||||
mapper.convertSpelToAviator(null);
|
||||
assertThat(r3.hasWarning()).isFalse();
|
||||
assertThat(r3.aviatorExpr()).isNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 3:unsupported_spel
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("unsupported_spel: 含 T(java.lang.Math) → hasWarning=true, warningReason 不为空")
|
||||
void testUnsupportedSpel() {
|
||||
SceneRuleToChainMapper mapper = new SceneRuleToChainMapper(new ObjectMapper());
|
||||
|
||||
SceneRuleToChainMapper.SpelConversionResult result =
|
||||
mapper.convertSpelToAviator("T(java.lang.Math).abs(#root.value) > 10");
|
||||
assertThat(result.hasWarning()).isTrue();
|
||||
assertThat(result.warningReason()).isNotBlank();
|
||||
assertThat(result.warningReason()).contains("T(");
|
||||
|
||||
// instanceof 也不支持(另一种高级 SpEL 语法)
|
||||
SceneRuleToChainMapper.SpelConversionResult r2 =
|
||||
mapper.convertSpelToAviator("#root.value instanceof T(java.lang.Number)");
|
||||
assertThat(r2.hasWarning()).isTrue();
|
||||
assertThat(r2.warningReason()).isNotBlank();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 4:idempotent_rerun
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("idempotent_rerun: 已迁移的规则在 force=false 时跳过")
|
||||
void testIdempotentRerun() {
|
||||
// Given
|
||||
IotSceneRuleDO rule = buildRule(4L, "已迁移规则",
|
||||
buildTrigger(IotSceneRuleTriggerTypeEnum.TIMER.getType(), null, null, null, "0 0/5 * * * ?", null),
|
||||
List.of(buildAction(IotSceneRuleActionTypeEnum.DEVICE_PROPERTY_SET.getType(), 10L, 101L, "switch", "{\"switch\":1}", null))
|
||||
);
|
||||
|
||||
when(sceneRuleMapper.selectList(null)).thenReturn(List.of(rule));
|
||||
|
||||
// 模拟:已迁移(COUNT=1)
|
||||
when(jdbcTemplate.queryForObject(
|
||||
contains("COUNT(1)"),
|
||||
eq(Integer.class),
|
||||
eq(4L)
|
||||
)).thenReturn(1);
|
||||
|
||||
// When
|
||||
MigrationExecuteReqVO opts = buildOpts("test", false);
|
||||
SceneRuleMigrator.ExecuteResult result = migrator.execute(opts);
|
||||
|
||||
// Then: skipped=1, createRuleChain 不被调用
|
||||
assertThat(result.skippedCount()).isEqualTo(1);
|
||||
assertThat(result.migratedCount()).isEqualTo(0);
|
||||
verify(ruleChainService, never()).createRuleChain(any());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 5:execute_force
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("execute_force: 已迁移 + force=true → 重新迁移(DELETE + INSERT)")
|
||||
void testExecuteForce() {
|
||||
// Given
|
||||
IotSceneRuleDO rule = buildRule(5L, "强制重迁移",
|
||||
buildTrigger(IotSceneRuleTriggerTypeEnum.DEVICE_EVENT_POST.getType(), 20L, 201L, "overTemp", null, null),
|
||||
List.of(buildAction(IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType(), null, null, null, null, 200L))
|
||||
);
|
||||
|
||||
when(sceneRuleMapper.selectList(null)).thenReturn(List.of(rule));
|
||||
|
||||
// 模拟:已迁移(COUNT=1)
|
||||
when(jdbcTemplate.queryForObject(
|
||||
contains("COUNT(1)"),
|
||||
eq(Integer.class),
|
||||
eq(5L)
|
||||
)).thenReturn(1);
|
||||
|
||||
// 模拟 DELETE 和 INSERT 成功
|
||||
when(jdbcTemplate.update(anyString(), any(Object[].class))).thenReturn(1);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(999L);
|
||||
|
||||
// When
|
||||
MigrationExecuteReqVO opts = buildOpts("admin", true); // force=true
|
||||
SceneRuleMigrator.ExecuteResult result = migrator.execute(opts);
|
||||
|
||||
// Then: migrated=1(强制重新创建)
|
||||
assertThat(result.migratedCount()).isEqualTo(1);
|
||||
assertThat(result.skippedCount()).isEqualTo(0);
|
||||
|
||||
// createRuleChain 被调用
|
||||
verify(ruleChainService, times(1)).createRuleChain(any());
|
||||
|
||||
// DELETE 被调用(force 模式)
|
||||
verify(jdbcTemplate).update(
|
||||
contains("DELETE FROM iot_scene_rule_migration"),
|
||||
eq(5L));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 6:multi_action
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("multi_action: 多个 actions → chain 中有多个 action 节点,每个都有 link")
|
||||
void testMultiAction() {
|
||||
// Given
|
||||
IotSceneRuleDO rule = buildRule(6L, "多动作规则",
|
||||
buildTrigger(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType(), 10L, 0L, "temperature", null, null),
|
||||
List.of(
|
||||
buildAction(IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType(), null, null, null, null, 100L),
|
||||
buildAction(IotSceneRuleActionTypeEnum.DEVICE_PROPERTY_SET.getType(), 10L, 101L, "fan", "{\"speed\":3}", null),
|
||||
buildAction(IotSceneRuleActionTypeEnum.DEVICE_SERVICE_INVOKE.getType(), 10L, 101L, "cooling", "{}", null)
|
||||
)
|
||||
);
|
||||
|
||||
when(sceneRuleMapper.selectList(null)).thenReturn(List.of(rule));
|
||||
mockNotMigrated(6L);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(300L);
|
||||
mockInsertMapping();
|
||||
|
||||
// When
|
||||
SceneRuleMigrator.ExecuteResult result = migrator.execute(buildOpts("test", false));
|
||||
|
||||
// Then
|
||||
assertThat(result.migratedCount()).isEqualTo(1);
|
||||
|
||||
ArgumentCaptor<IotRuleChainSaveReqVO> captor = ArgumentCaptor.forClass(IotRuleChainSaveReqVO.class);
|
||||
verify(ruleChainService).createRuleChain(captor.capture());
|
||||
IotRuleChainSaveReqVO req = captor.getValue();
|
||||
|
||||
// 1 trigger + 3 actions = 4 nodes
|
||||
long triggerCount = req.getNodes().stream().filter(n -> "trigger".equals(n.getCategory())).count();
|
||||
long actionCount = req.getNodes().stream().filter(n -> "action".equals(n.getCategory())).count();
|
||||
assertThat(triggerCount).isEqualTo(1);
|
||||
assertThat(actionCount).isEqualTo(3);
|
||||
|
||||
// 验证 action 类型
|
||||
List<String> actionTypes = req.getNodes().stream()
|
||||
.filter(n -> "action".equals(n.getCategory()))
|
||||
.map(IotRuleChainSaveReqVO.NodeVO::getType)
|
||||
.toList();
|
||||
assertThat(actionTypes).containsExactlyInAnyOrder("alarm_trigger", "device_property_set", "device_service_invoke");
|
||||
|
||||
// 3 links (trigger→action1, action1→action2, action2→action3)
|
||||
assertThat(req.getLinks()).hasSize(3);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 追加:SceneRuleToChainMapper 直接测试(trigger/action type mapping)
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("triggerType_mapping: 各 v1 Trigger.type 正确映射到 v2 type 字符串")
|
||||
void testTriggerTypeMapping() {
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE.getType())).isEqualTo("device_state");
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType())).isEqualTo("device_property");
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.DEVICE_EVENT_POST.getType())).isEqualTo("device_event");
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType())).isEqualTo("device_service");
|
||||
assertThat(SceneRuleToChainMapper.mapTriggerType(
|
||||
IotSceneRuleTriggerTypeEnum.TIMER.getType())).isEqualTo("timer");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("actionType_mapping: 各 v1 Action.type 正确映射到 v2 type 字符串")
|
||||
void testActionTypeMapping() {
|
||||
assertThat(SceneRuleToChainMapper.mapActionType(
|
||||
IotSceneRuleActionTypeEnum.DEVICE_PROPERTY_SET.getType())).isEqualTo("device_property_set");
|
||||
assertThat(SceneRuleToChainMapper.mapActionType(
|
||||
IotSceneRuleActionTypeEnum.DEVICE_SERVICE_INVOKE.getType())).isEqualTo("device_service_invoke");
|
||||
assertThat(SceneRuleToChainMapper.mapActionType(
|
||||
IotSceneRuleActionTypeEnum.ALERT_TRIGGER.getType())).isEqualTo("alarm_trigger");
|
||||
assertThat(SceneRuleToChainMapper.mapActionType(
|
||||
IotSceneRuleActionTypeEnum.ALERT_RECOVER.getType())).isEqualTo("alarm_clear");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Mock helpers
|
||||
// =========================================================================
|
||||
|
||||
/** 模拟该规则未迁移(COUNT=0) */
|
||||
private void mockNotMigrated(Long ruleId) {
|
||||
when(jdbcTemplate.queryForObject(
|
||||
contains("COUNT(1)"),
|
||||
eq(Integer.class),
|
||||
eq(ruleId)
|
||||
)).thenReturn(0);
|
||||
}
|
||||
|
||||
/** 模拟 INSERT 映射表正常写入 */
|
||||
private void mockInsertMapping() {
|
||||
when(jdbcTemplate.update(anyString(), any(Object[].class))).thenReturn(1);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DO builders
|
||||
// =========================================================================
|
||||
|
||||
private IotSceneRuleDO buildRule(Long id, String name,
|
||||
IotSceneRuleDO.Trigger trigger,
|
||||
List<IotSceneRuleDO.Action> actions) {
|
||||
IotSceneRuleDO rule = new IotSceneRuleDO();
|
||||
rule.setId(id);
|
||||
rule.setName(name);
|
||||
rule.setStatus(0);
|
||||
rule.setTriggers(List.of(trigger));
|
||||
rule.setActions(actions);
|
||||
return rule;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.Trigger buildTrigger(Integer type, Long productId, Long deviceId,
|
||||
String identifier, String cronExpression,
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups) {
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(type);
|
||||
trigger.setProductId(productId);
|
||||
trigger.setDeviceId(deviceId);
|
||||
trigger.setIdentifier(identifier);
|
||||
trigger.setCronExpression(cronExpression);
|
||||
trigger.setConditionGroups(conditionGroups);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.Action buildAction(Integer type, Long productId, Long deviceId,
|
||||
String identifier, String params, Long alertConfigId) {
|
||||
IotSceneRuleDO.Action action = new IotSceneRuleDO.Action();
|
||||
action.setType(type);
|
||||
action.setProductId(productId);
|
||||
action.setDeviceId(deviceId);
|
||||
action.setIdentifier(identifier);
|
||||
action.setParams(params);
|
||||
action.setAlertConfigId(alertConfigId);
|
||||
return action;
|
||||
}
|
||||
|
||||
private MigrationExecuteReqVO buildOpts(String migrator, boolean force) {
|
||||
MigrationExecuteReqVO opts = new MigrationExecuteReqVO();
|
||||
opts.setMigrator(migrator);
|
||||
opts.setForce(force);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** 从 req 中按 category 取第一个节点 */
|
||||
private IotRuleChainSaveReqVO.NodeVO findNode(IotRuleChainSaveReqVO req, String category) {
|
||||
return req.getNodes().stream()
|
||||
.filter(n -> category.equals(n.getCategory()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user