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:
lzh
2026-04-24 10:21:52 +08:00
parent 24c486900a
commit ec3981195d
6 changed files with 1224 additions and 0 deletions

View File

@@ -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 RuleChaintype=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());
}
}

View File

@@ -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 仅收集 WARNINGERROR 直接抛出)
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
) {
}
}

View File

@@ -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
) {
}
}

View File

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

View File

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

View File

@@ -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);
}
// =========================================================================
// 用例 1simple_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");
}
// =========================================================================
// 用例 2spel_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();
}
// =========================================================================
// 用例 3unsupported_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();
}
// =========================================================================
// 用例 4idempotent_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());
}
// =========================================================================
// 用例 5execute_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));
}
// =========================================================================
// 用例 6multi_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);
}
}