From ec3981195dfdd83c5e56e5c981351f40022805cb Mon Sep 17 00:00:00 2001 From: lzh Date: Fri, 24 Apr 2026 10:21:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(iot):=20B17=20SceneRule=20=E2=86=92=20DAG?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E8=BD=AC=E6=8D=A2=E5=B7=A5=E5=85=B7=20+?= =?UTF-8?q?=20dry-run/execute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../SceneRuleMigrationController.java | 63 +++ .../iot/migration/SceneRuleMigrator.java | 280 ++++++++++++ .../mapping/SceneRuleToChainMapper.java | 413 ++++++++++++++++++ .../migration/vo/MigrationDryRunResultVO.java | 61 +++ .../migration/vo/MigrationExecuteReqVO.java | 22 + .../iot/migration/SceneRuleMigratorTest.java | 385 ++++++++++++++++ 6 files changed, 1224 insertions(+) create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/SceneRuleMigrationController.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/SceneRuleMigrator.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/mapping/SceneRuleToChainMapper.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/vo/MigrationDryRunResultVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/vo/MigrationExecuteReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/migration/SceneRuleMigratorTest.java diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/SceneRuleMigrationController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/SceneRuleMigrationController.java new file mode 100644 index 00000000..90da4802 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/SceneRuleMigrationController.java @@ -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 + * + *

3 个端点(与 B18 对称): + *

+ */ +@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 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 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> queryMapping() { + return success(sceneRuleMigrator.queryMappings()); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/SceneRuleMigrator.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/SceneRuleMigrator.java new file mode 100644 index 00000000..1a2868f7 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/SceneRuleMigrator.java @@ -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 迁移主服务 + * + *

策略(单向,不做双向同步,评审 B7): + *

    + *
  1. 读取 v1 {@code iot_scene_rule} 表中的场景联动规则
  2. + *
  3. 通过 {@link SceneRuleToChainMapper} 转换为 v2 chain 请求
  4. + *
  5. 调用 {@link IotRuleChainService#createRuleChain} 写入 v2 chain
  6. + *
  7. 向映射表 {@code iot_scene_rule_migration} 写入记录(幂等)
  8. + *
+ * + *

幂等:映射表唯一键 {@code uk_old (old_rule_id, tenant_id)},重复执行时先 COUNT 检查, + * 已存在则跳过(force=true 时覆盖重建)。 + * + *

批量事务:每批 50 条规则一个事务(大数据量时避免单事务过大)。 + * + *

映射表 DDL(参考): + *

{@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 迁移映射';
+ * }
+ */ +@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 rules = sceneRuleMapper.selectList(null); + if (rules.isEmpty()) { + return new MigrationDryRunResultVO(0, 0, 0, + Collections.emptyList(), Collections.emptyList()); + } + + List issues = new ArrayList<>(); + List convertibleNames = new ArrayList<>(); + + for (IotSceneRuleDO rule : rules) { + List 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 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 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 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 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 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 batch, MigrationExecuteReqVO opts) { + int migratedCount = 0; + int skippedCount = 0; + List 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 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 errors + ) { + } + + /** 迁移执行结果汇总 */ + public record ExecuteResult( + int totalRules, + int migratedCount, + int skippedCount, + List errors + ) { + } + + /** 映射表记录 DTO */ + public record MappingRecord( + Long id, + Long oldRuleId, + Long newChainId, + LocalDateTime migratedAt, + String migrator, + Long tenantId + ) { + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/mapping/SceneRuleToChainMapper.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/mapping/SceneRuleToChainMapper.java new file mode 100644 index 00000000..592f42e1 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/mapping/SceneRuleToChainMapper.java @@ -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 映射器(纯转换,不写库) + * + *

映射规则: + *

    + *
  • v1 Trigger.type 1(DEVICE_STATE_UPDATE) → v2 trigger type {@code device_state}
  • + *
  • v1 Trigger.type 2(DEVICE_PROPERTY_POST) → v2 trigger type {@code device_property}
  • + *
  • v1 Trigger.type 3(DEVICE_EVENT_POST) → v2 trigger type {@code device_event}
  • + *
  • v1 Trigger.type 4(DEVICE_SERVICE_INVOKE) → v2 trigger type {@code device_service}
  • + *
  • v1 Trigger.type 100(TIMER) → v2 trigger type {@code timer}
  • + *
  • v1 Action.type 1(DEVICE_PROPERTY_SET) → v2 action type {@code device_property_set}
  • + *
  • v1 Action.type 2(DEVICE_SERVICE_INVOKE) → v2 action type {@code device_service_invoke}
  • + *
  • v1 Action.type 100(ALERT_TRIGGER) → v2 action type {@code alarm_trigger}
  • + *
  • v1 Action.type 101(ALERT_RECOVER) → v2 action type {@code alarm_clear}
  • + *
  • v1 conditionGroups (SpEL) → v2 condition node type=expression (Aviator 表达式)
  • + *
+ * + *

DAG 节点顺序(线性): + *

+ *   Trigger (临时 key=-1) → [Condition (临时 key=-2) →] Action1 (临时 key=-3) → Action2 ...
+ * 
+ * + *

SpEL → Aviator 简单转换规则: + *

    + *
  • {@code #root.fieldName} → {@code ${data.fieldName}}
  • + *
  • 保留 {@code &&} / {@code ||} / {@code !} / 比较运算符
  • + *
  • 含 {@code T(} 或 {@code .class} → 标记 WARNING,无法自动转换
  • + *
+ */ +@Component +@Slf4j +@RequiredArgsConstructor +public class SceneRuleToChainMapper { + + private final ObjectMapper objectMapper; + + // SpEL 高级语法标记(无法自动转换) + private static final List 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 issues) { + IotRuleChainSaveReqVO req = new IotRuleChainSaveReqVO(); + req.setName(rule.getName()); + req.setDescription(rule.getDescription()); + req.setType("SCENE"); + req.setPriority(100); + req.setDebugMode(false); + + List nodes = new ArrayList<>(); + List 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 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 issues, + IotSceneRuleDO rule) { + List> condGroups = trigger.getConditionGroups(); + if (condGroups == null || condGroups.isEmpty()) { + return null; + } + + // 将多个分组(OR 关系)展开,每组内是 AND 关系 + List orParts = new ArrayList<>(); + for (List group : condGroups) { + if (group == null || group.isEmpty()) { + continue; + } + List 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 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 表达式简单转换 + * + *

规则: + *

    + *
  1. {@code #root.fieldName} → {@code ${data.fieldName}}
  2. + *
  3. 运算符 {@code &&} / {@code ||} / {@code !} / 比较运算符保留
  4. + *
  5. 含 {@code T(} 或 {@code .class} → WARNING,原样保留并记录 issue
  6. + *
+ */ + 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 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 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 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 + ) { + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/vo/MigrationDryRunResultVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/vo/MigrationDryRunResultVO.java new file mode 100644 index 00000000..6e31745e --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/vo/MigrationDryRunResultVO.java @@ -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 + * + *

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 issues; + + @Schema(description = "可转换的规则名称预览(前 100 条)") + private List 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; + + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/vo/MigrationExecuteReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/vo/MigrationExecuteReqVO.java new file mode 100644 index 00000000..e7a5866b --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/migration/vo/MigrationExecuteReqVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/migration/SceneRuleMigratorTest.java b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/migration/SceneRuleMigratorTest.java new file mode 100644 index 00000000..63c2a56e --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/migration/SceneRuleMigratorTest.java @@ -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} 单元测试 + * + *

6 个测试用例: + *

    + *
  1. simple_device_property — trigger=device_property, cond, action=alarm_trigger
  2. + *
  3. spel_to_aviator — #root.temp > 40 → ${data.temp} > 40
  4. + *
  5. unsupported_spel — 含 T(java.lang.Math) → issues 列表
  6. + *
  7. idempotent_rerun — 已迁移 → 跳过(not force)
  8. + *
  9. execute_force — 已迁移 + force=true → 重新迁移
  10. + *
  11. multi_action — 多个 actions → 多个 action 节点
  12. + *
+ */ +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 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 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 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 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> 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); + } + +}