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 个测试用例:
+ *
+ * - simple_device_property — trigger=device_property, cond, action=alarm_trigger
+ * - spel_to_aviator — #root.temp > 40 → ${data.temp} > 40
+ * - unsupported_spel — 含 T(java.lang.Math) → issues 列表
+ * - idempotent_rerun — 已迁移 → 跳过(not force)
+ * - execute_force — 已迁移 + force=true → 重新迁移
+ * - multi_action — 多个 actions → 多个 action 节点
+ *
+ */
+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);
+ }
+
+}