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,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);
}
}