feat(iot): Wave 4 Round 2 — B6/B7/B18 ActionProvider + 分支执行 + DataRule迁移
B6 ActionProvider SPI + 5 核心动作(alarm/notify/device-ctrl): - ActionProvider 接口(extends NodeProvider,默认 bridge execute) - ActionResult record(SUCCESS/FAILURE/SKIP + output + message) - ActionProviderManager(Spring 自动收集 + fail-fast 重复 type) - AlarmTriggerAction(调用 IotAlarmRecordApi.triggerAlarm,模板变量解析) - AlarmClearAction(alarmId 从 config 或 ctx.metadata 解析,幂等) - NotifyAction(4 通道并发 + 部分失败不阻塞,第一期 stub) - DeviceServiceInvokeAction(调用 IotDeviceControlApi.invokeService) - DevicePropertySetAction(第一期 stub,B27 补全 Redis/MySQL) - IotAlarmRecordApi + DTO(rule 模块→server 跨模块接口) - IotAlarmRecordApiImpl(server 端 FeignClient 实现,委托 Service) - 14 单元测试全绿 B7 分支执行逻辑(executeAnyway if/else-if/else): - BranchConfiguration POJO(branches[] + executeAnyway + BranchCondition) - BranchExecutor(核心语义:else/executeAnyway/条件异常短路/action 异常隔离) - BranchNode NodeProvider(ACTION/"branch",内联执行命中 branch actions) - DagExecutor 最小扩展(ctx.metadata 传递 CompiledRuleChain 供 BranchNode 使用) - 9 单元测试全绿(含 validate else 位置校验) B18 DataRule → DAG 自动转换工具: - DataRuleToChainMapper(v1→v2 映射,6 种 Sink,合并/拆分多 source) - DataRuleMigrator(dry-run + execute + 幂等映射表) - DataRuleMigrationController(3 端点:dry-run/execute/mapping) - 8 单元测试全绿 测试总计:rule 模块 159/159 ✓,server 模块 8/8(B18)✓ Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
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.IotDataRuleDO;
|
||||
import com.viewsh.module.iot.dal.dataobject.rule.IotDataSinkDO;
|
||||
import com.viewsh.module.iot.dal.dataobject.rule.config.*;
|
||||
import com.viewsh.module.iot.dal.mysql.rule.IotDataRuleMapper;
|
||||
import com.viewsh.module.iot.dal.mysql.rule.IotDataSinkMapper;
|
||||
import com.viewsh.module.iot.enums.rule.IotDataSinkTypeEnum;
|
||||
import com.viewsh.module.iot.migration.mapping.DataRuleToChainMapper;
|
||||
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.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* B18 — {@link DataRuleMigrator} 单元测试
|
||||
*
|
||||
* <p>6 个测试用例:
|
||||
* <ol>
|
||||
* <li>single_source_http — 1 source + 1 http sink → 1 chain (Trigger + HttpPush)</li>
|
||||
* <li>multi_sink — 1 source + 5 sinks → 1 chain (Trigger + 5 Actions)</li>
|
||||
* <li>multi_source_mergeable — 2 sources 同 productId + method → 合并为 1 chain</li>
|
||||
* <li>multi_source_split — 2 sources 不同 productId → 拆为 2 chain</li>
|
||||
* <li>mq_provider_preserve — sink=rocketmq → mq_push, config.provider="rocketmq"</li>
|
||||
* <li>idempotent — 重跑 → 跳过(映射表 UK 冲突不抛错)</li>
|
||||
* </ol>
|
||||
*/
|
||||
class DataRuleMigratorTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Mock
|
||||
private IotDataRuleMapper dataRuleMapper;
|
||||
|
||||
@Mock
|
||||
private IotDataSinkMapper dataSinkMapper;
|
||||
|
||||
@Mock
|
||||
private IotRuleChainService ruleChainService;
|
||||
|
||||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private DataRuleToChainMapper chainMapper;
|
||||
private DataRuleMigrator migrator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
chainMapper = new DataRuleToChainMapper(new ObjectMapper());
|
||||
migrator = new DataRuleMigrator(dataRuleMapper, dataSinkMapper, chainMapper, ruleChainService, jdbcTemplate);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 1:single_source_http
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("single_source_http: 1 source + 1 http sink → 1 chain with Trigger + HttpPush action")
|
||||
void testSingleSourceHttp() {
|
||||
// Given
|
||||
Long ruleId = 1L;
|
||||
Long sinkId = 100L;
|
||||
|
||||
IotDataRuleDO rule = buildRule(ruleId, "HTTP 规则",
|
||||
List.of(buildSource(10L, 101L, "testProp", "property")),
|
||||
List.of(sinkId));
|
||||
|
||||
IotDataSinkDO httpSink = buildSink(sinkId, "HTTP推送", IotDataSinkTypeEnum.HTTP.getType(), buildHttpConfig());
|
||||
|
||||
mockMappers(List.of(rule), List.of(httpSink));
|
||||
mockMigrationTableEmpty(ruleId, 0);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(200L);
|
||||
mockInsertMapping();
|
||||
|
||||
// When
|
||||
DataRuleMigrator.ExecuteResult result = migrator.execute("test");
|
||||
|
||||
// Then
|
||||
assertThat(result.totalRules()).isEqualTo(1);
|
||||
assertThat(result.migratedCount()).isEqualTo(1);
|
||||
assertThat(result.skippedCount()).isEqualTo(0);
|
||||
assertThat(result.errors()).isEmpty();
|
||||
|
||||
// 验证 createRuleChain 被调用,且 chain type=DATA
|
||||
ArgumentCaptor<IotRuleChainSaveReqVO> captor = ArgumentCaptor.forClass(IotRuleChainSaveReqVO.class);
|
||||
verify(ruleChainService).createRuleChain(captor.capture());
|
||||
IotRuleChainSaveReqVO req = captor.getValue();
|
||||
assertThat(req.getType()).isEqualTo("DATA");
|
||||
// trigger 节点
|
||||
IotRuleChainSaveReqVO.NodeVO trigger = req.getNodes().stream()
|
||||
.filter(n -> "trigger".equals(n.getCategory())).findFirst().orElseThrow();
|
||||
assertThat(trigger.getType()).startsWith("device.");
|
||||
// action 节点
|
||||
IotRuleChainSaveReqVO.NodeVO action = req.getNodes().stream()
|
||||
.filter(n -> "action".equals(n.getCategory())).findFirst().orElseThrow();
|
||||
assertThat(action.getType()).isEqualTo("http_push");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 2:multi_sink
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("multi_sink: 1 source + 5 sinks → 1 chain with Trigger + 5 action nodes")
|
||||
void testMultiSink() {
|
||||
// Given
|
||||
Long ruleId = 2L;
|
||||
List<Long> sinkIds = List.of(201L, 202L, 203L, 204L, 205L);
|
||||
|
||||
IotDataRuleDO rule = buildRule(ruleId, "多 Sink 规则",
|
||||
List.of(buildSource(10L, 0L, null, "property")),
|
||||
sinkIds);
|
||||
|
||||
List<IotDataSinkDO> sinks = List.of(
|
||||
buildSink(201L, "HTTP", IotDataSinkTypeEnum.HTTP.getType(), buildHttpConfig()),
|
||||
buildSink(202L, "RocketMQ", IotDataSinkTypeEnum.ROCKETMQ.getType(), buildRocketMQConfig()),
|
||||
buildSink(203L, "Kafka", IotDataSinkTypeEnum.KAFKA.getType(), buildKafkaConfig()),
|
||||
buildSink(204L, "RabbitMQ", IotDataSinkTypeEnum.RABBITMQ.getType(), buildRabbitMQConfig()),
|
||||
buildSink(205L, "Redis", IotDataSinkTypeEnum.REDIS.getType(), buildRedisConfig())
|
||||
);
|
||||
|
||||
mockMappers(List.of(rule), sinks);
|
||||
mockMigrationTableEmpty(ruleId, 0);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(300L);
|
||||
mockInsertMapping();
|
||||
|
||||
// When
|
||||
DataRuleMigrator.ExecuteResult result = migrator.execute("test");
|
||||
|
||||
// Then
|
||||
assertThat(result.migratedCount()).isEqualTo(1);
|
||||
ArgumentCaptor<IotRuleChainSaveReqVO> captor = ArgumentCaptor.forClass(IotRuleChainSaveReqVO.class);
|
||||
verify(ruleChainService).createRuleChain(captor.capture());
|
||||
IotRuleChainSaveReqVO req = captor.getValue();
|
||||
|
||||
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(5);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 3:multi_source_mergeable
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("multi_source_mergeable: 2 sources 同 productId + method → 合并为 1 chain")
|
||||
void testMultiSourceMergeable() {
|
||||
// Given - same productId + method
|
||||
Long ruleId = 3L;
|
||||
Long sinkId = 301L;
|
||||
|
||||
IotDataRuleDO rule = buildRule(ruleId, "可合并多源",
|
||||
List.of(
|
||||
buildSource(50L, 0L, "temp", "property"),
|
||||
buildSource(50L, 0L, "humidity", "property")
|
||||
),
|
||||
List.of(sinkId));
|
||||
|
||||
IotDataSinkDO sink = buildSink(sinkId, "HTTP", IotDataSinkTypeEnum.HTTP.getType(), buildHttpConfig());
|
||||
|
||||
mockMappers(List.of(rule), List.of(sink));
|
||||
mockMigrationTableEmpty(ruleId, 0);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(400L);
|
||||
mockInsertMapping();
|
||||
|
||||
// When
|
||||
DataRuleMigrator.ExecuteResult result = migrator.execute("test");
|
||||
|
||||
// Then: only 1 chain created (merged)
|
||||
assertThat(result.migratedCount()).isEqualTo(1);
|
||||
verify(ruleChainService, times(1)).createRuleChain(any());
|
||||
|
||||
// 验证 trigger config 中 identifiers 包含两个值
|
||||
ArgumentCaptor<IotRuleChainSaveReqVO> captor = ArgumentCaptor.forClass(IotRuleChainSaveReqVO.class);
|
||||
verify(ruleChainService).createRuleChain(captor.capture());
|
||||
IotRuleChainSaveReqVO req = captor.getValue();
|
||||
IotRuleChainSaveReqVO.NodeVO trigger = req.getNodes().stream()
|
||||
.filter(n -> "trigger".equals(n.getCategory())).findFirst().orElseThrow();
|
||||
assertThat(trigger.getConfiguration()).contains("temp").contains("humidity");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 4:multi_source_split
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("multi_source_split: 2 sources 不同 productId → 拆为 2 chain")
|
||||
void testMultiSourceSplit() {
|
||||
// Given - different productId
|
||||
Long ruleId = 4L;
|
||||
Long sinkId = 401L;
|
||||
|
||||
IotDataRuleDO rule = buildRule(ruleId, "拆分多源",
|
||||
List.of(
|
||||
buildSource(60L, 0L, "temp", "property"),
|
||||
buildSource(70L, 0L, "temp", "property")
|
||||
),
|
||||
List.of(sinkId));
|
||||
|
||||
IotDataSinkDO sink = buildSink(sinkId, "HTTP", IotDataSinkTypeEnum.HTTP.getType(), buildHttpConfig());
|
||||
|
||||
mockMappers(List.of(rule), List.of(sink));
|
||||
// source_index 0 未迁移,source_index 1 未迁移
|
||||
mockMigrationTableEmpty(ruleId, 0);
|
||||
mockMigrationTableEmpty(ruleId, 1);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(500L).thenReturn(501L);
|
||||
mockInsertMapping();
|
||||
|
||||
// When
|
||||
DataRuleMigrator.ExecuteResult result = migrator.execute("test");
|
||||
|
||||
// Then: 2 chains created (split)
|
||||
assertThat(result.migratedCount()).isEqualTo(2);
|
||||
verify(ruleChainService, times(2)).createRuleChain(any());
|
||||
|
||||
// 验证 chain 名称带序号
|
||||
ArgumentCaptor<IotRuleChainSaveReqVO> captor = ArgumentCaptor.forClass(IotRuleChainSaveReqVO.class);
|
||||
verify(ruleChainService, times(2)).createRuleChain(captor.capture());
|
||||
List<IotRuleChainSaveReqVO> reqs = captor.getAllValues();
|
||||
assertThat(reqs.get(0).getName()).endsWith("-source1");
|
||||
assertThat(reqs.get(1).getName()).endsWith("-source2");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 5:mq_provider_preserve
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("mq_provider_preserve: sink=rocketmq → action type=mq_push, config.provider=rocketmq")
|
||||
void testMqProviderPreserve() {
|
||||
// Given
|
||||
Long ruleId = 5L;
|
||||
Long sinkId = 501L;
|
||||
|
||||
IotDataRuleDO rule = buildRule(ruleId, "RocketMQ 规则",
|
||||
List.of(buildSource(10L, 0L, "temp", "property")),
|
||||
List.of(sinkId));
|
||||
|
||||
IotDataSinkDO rmqSink = buildSink(sinkId, "RMQ", IotDataSinkTypeEnum.ROCKETMQ.getType(), buildRocketMQConfig());
|
||||
|
||||
mockMappers(List.of(rule), List.of(rmqSink));
|
||||
mockMigrationTableEmpty(ruleId, 0);
|
||||
when(ruleChainService.createRuleChain(any())).thenReturn(600L);
|
||||
mockInsertMapping();
|
||||
|
||||
// When
|
||||
migrator.execute("test");
|
||||
|
||||
// Then: action type=mq_push + provider=rocketmq
|
||||
ArgumentCaptor<IotRuleChainSaveReqVO> captor = ArgumentCaptor.forClass(IotRuleChainSaveReqVO.class);
|
||||
verify(ruleChainService).createRuleChain(captor.capture());
|
||||
IotRuleChainSaveReqVO req = captor.getValue();
|
||||
|
||||
IotRuleChainSaveReqVO.NodeVO action = req.getNodes().stream()
|
||||
.filter(n -> "action".equals(n.getCategory())).findFirst().orElseThrow();
|
||||
assertThat(action.getType()).isEqualTo("mq_push");
|
||||
assertThat(action.getConfiguration()).contains("\"provider\"").contains("rocketmq");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 用例 6:idempotent
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("idempotent: 重跑时映射表已存在 → 跳过,不重复创建 chain")
|
||||
void testIdempotent() {
|
||||
// Given
|
||||
Long ruleId = 6L;
|
||||
Long sinkId = 601L;
|
||||
|
||||
IotDataRuleDO rule = buildRule(ruleId, "幂等测试",
|
||||
List.of(buildSource(10L, 0L, "temp", "property")),
|
||||
List.of(sinkId));
|
||||
|
||||
IotDataSinkDO sink = buildSink(sinkId, "HTTP", IotDataSinkTypeEnum.HTTP.getType(), buildHttpConfig());
|
||||
|
||||
mockMappers(List.of(rule), List.of(sink));
|
||||
|
||||
// 模拟:映射表已存在该记录(COUNT=1)
|
||||
when(jdbcTemplate.queryForObject(
|
||||
contains("COUNT(1)"),
|
||||
eq(Integer.class),
|
||||
eq(ruleId), eq(0)
|
||||
)).thenReturn(1);
|
||||
|
||||
// When
|
||||
DataRuleMigrator.ExecuteResult result = migrator.execute("test");
|
||||
|
||||
// Then: skipped=1, createRuleChain 不被调用
|
||||
assertThat(result.skippedCount()).isEqualTo(1);
|
||||
assertThat(result.migratedCount()).isEqualTo(0);
|
||||
verify(ruleChainService, never()).createRuleChain(any());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper: DataRuleToChainMapper 直接测试(canMerge 逻辑)
|
||||
// =========================================================================
|
||||
@Test
|
||||
@DisplayName("canMerge: 相同 productId + method 可合并")
|
||||
void testCanMerge_same() {
|
||||
DataRuleToChainMapper m = new DataRuleToChainMapper(new ObjectMapper());
|
||||
List<IotDataRuleDO.SourceConfig> sources = List.of(
|
||||
buildSource(1L, 0L, "a", "property"),
|
||||
buildSource(1L, 0L, "b", "property")
|
||||
);
|
||||
assertThat(m.canMerge(sources)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("canMerge: 不同 productId 不可合并")
|
||||
void testCanMerge_diff() {
|
||||
DataRuleToChainMapper m = new DataRuleToChainMapper(new ObjectMapper());
|
||||
List<IotDataRuleDO.SourceConfig> sources = List.of(
|
||||
buildSource(1L, 0L, "a", "property"),
|
||||
buildSource(2L, 0L, "a", "property")
|
||||
);
|
||||
assertThat(m.canMerge(sources)).isFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Mock helpers
|
||||
// =========================================================================
|
||||
|
||||
private void mockMappers(List<IotDataRuleDO> rules, List<IotDataSinkDO> sinks) {
|
||||
when(dataRuleMapper.selectList(null)).thenReturn(rules);
|
||||
when(dataSinkMapper.selectList(null)).thenReturn(sinks);
|
||||
}
|
||||
|
||||
/** 模拟映射表 COUNT 查询返回 0(未迁移) */
|
||||
private void mockMigrationTableEmpty(Long ruleId, int sourceIndex) {
|
||||
when(jdbcTemplate.queryForObject(
|
||||
contains("COUNT(1)"),
|
||||
eq(Integer.class),
|
||||
eq(ruleId), eq(sourceIndex)
|
||||
)).thenReturn(0);
|
||||
}
|
||||
|
||||
/** 模拟 INSERT 映射表(正常写入) */
|
||||
private void mockInsertMapping() {
|
||||
when(jdbcTemplate.update(anyString(), any(), any(), any(), any(), any())).thenReturn(1);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DO builders
|
||||
// =========================================================================
|
||||
|
||||
private IotDataRuleDO buildRule(Long id, String name,
|
||||
List<IotDataRuleDO.SourceConfig> sources,
|
||||
List<Long> sinkIds) {
|
||||
IotDataRuleDO rule = new IotDataRuleDO();
|
||||
rule.setId(id);
|
||||
rule.setName(name);
|
||||
rule.setSourceConfigs(sources);
|
||||
rule.setSinkIds(sinkIds);
|
||||
return rule;
|
||||
}
|
||||
|
||||
private IotDataRuleDO.SourceConfig buildSource(Long productId, Long deviceId, String identifier, String method) {
|
||||
IotDataRuleDO.SourceConfig src = new IotDataRuleDO.SourceConfig();
|
||||
src.setProductId(productId);
|
||||
src.setDeviceId(deviceId);
|
||||
src.setIdentifier(identifier);
|
||||
src.setMethod(method);
|
||||
return src;
|
||||
}
|
||||
|
||||
private IotDataSinkDO buildSink(Long id, String name, Integer type, IotAbstractDataSinkConfig config) {
|
||||
IotDataSinkDO sink = new IotDataSinkDO();
|
||||
sink.setId(id);
|
||||
sink.setName(name);
|
||||
sink.setType(type);
|
||||
sink.setConfig(config);
|
||||
return sink;
|
||||
}
|
||||
|
||||
// Config builders
|
||||
private IotDataSinkHttpConfig buildHttpConfig() {
|
||||
IotDataSinkHttpConfig cfg = new IotDataSinkHttpConfig();
|
||||
cfg.setUrl("http://example.com/api");
|
||||
cfg.setMethod("POST");
|
||||
cfg.setBody("{\"msg\":\"hello\"}");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private IotDataSinkRocketMQConfig buildRocketMQConfig() {
|
||||
IotDataSinkRocketMQConfig cfg = new IotDataSinkRocketMQConfig();
|
||||
cfg.setNameServer("127.0.0.1:9876");
|
||||
cfg.setTopic("iot-topic");
|
||||
cfg.setTags("tag1");
|
||||
cfg.setGroup("iot-group");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private IotDataSinkKafkaConfig buildKafkaConfig() {
|
||||
IotDataSinkKafkaConfig cfg = new IotDataSinkKafkaConfig();
|
||||
cfg.setBootstrapServers("127.0.0.1:9092");
|
||||
cfg.setTopic("iot-kafka-topic");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private IotDataSinkRabbitMQConfig buildRabbitMQConfig() {
|
||||
IotDataSinkRabbitMQConfig cfg = new IotDataSinkRabbitMQConfig();
|
||||
cfg.setHost("127.0.0.1");
|
||||
cfg.setPort(5672);
|
||||
cfg.setExchange("iot-exchange");
|
||||
cfg.setRoutingKey("iot.key");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private IotDataSinkRedisConfig buildRedisConfig() {
|
||||
IotDataSinkRedisConfig cfg = new IotDataSinkRedisConfig();
|
||||
cfg.setHost("127.0.0.1");
|
||||
cfg.setPort(6379);
|
||||
cfg.setTopic("iot-channel");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user