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:
lzh
2026-04-24 10:10:04 +08:00
parent 42466363c7
commit 24c486900a
23 changed files with 2969 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
package com.viewsh.module.iot.rule.action;
import com.viewsh.module.iot.rule.spi.ActionProvider;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* ActionProvider 注册表Spring 容器扫描 + getType() 索引)。
*
* <p>启动时通过 {@link #autoRegister(List)} 自动收集所有 Spring Bean 中的
* {@link ActionProvider},按 {@link ActionProvider#getType()} 建立索引。
*
* <p><b>Fail-fast</b>:同 type 重复注册时抛出 {@link IllegalStateException}
* 确保启动阶段即暴露问题,不留运行时隐患。
*
* <p>注意:{@link ActionProvider} 实现同时也是 {@link com.viewsh.module.iot.rule.engine.NodeProvider}
* 会被 {@link com.viewsh.module.iot.rule.engine.NodeProviderRegistry} 直接收集并路由。
* 本 Manager 仅提供按 type 查找的便捷入口(供测试 / 管理接口使用)。
*/
@Slf4j
@Component
public class ActionProviderManager {
private final Map<String, ActionProvider> providers = new ConcurrentHashMap<>();
@Autowired(required = false)
public void autoRegister(List<ActionProvider> list) {
if (list == null || list.isEmpty()) {
log.warn("[ActionProviderManager] 未发现任何 ActionProvider 实现");
return;
}
for (ActionProvider p : list) {
ActionProvider dup = providers.put(p.getType(), p);
if (dup != null) {
throw new IllegalStateException(
"duplicate action type: " + p.getType()
+ "" + dup.getClass().getName() + " vs " + p.getClass().getName());
}
}
log.info("[ActionProviderManager] 已注册 {} 个 ActionProvider{}", providers.size(), providers.keySet());
}
public ActionProvider get(String type) {
ActionProvider p = providers.get(type);
if (p == null) {
throw new IllegalArgumentException("未注册的 ActionProvider type=" + type);
}
return p;
}
public boolean contains(String type) {
return providers.containsKey(type);
}
public Map<String, ActionProvider> all() {
return Map.copyOf(providers);
}
}

View File

@@ -0,0 +1,93 @@
package com.viewsh.module.iot.rule.action;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.api.alarm.IotAlarmRecordApi;
import com.viewsh.module.iot.api.alarm.dto.IotAlarmClearReqDTO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 告警清除 Actionalarm_clear
*
* <p>配置示例:
* <pre>{@code
* {
* "alarmId": "${meta.alarmId}",
* "operator": "rule-engine"
* }
* }</pre>
*
* <p>alarmId 优先从 config 读取,若未配置则从 ctx.metadata.alarmId 获取(与 alarm_trigger 联动)。
* 幂等:已清除告警重复调用为 no-opService 层保证)。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AlarmClearAction implements ActionProvider {
public static final String TYPE = "alarm_clear";
private final IotAlarmRecordApi alarmRecordApi;
@Override
public String getType() {
return TYPE;
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {
Long alarmId = resolveAlarmId(ctx, config);
if (alarmId == null || alarmId == 0) {
return ActionResult.failure("alarm_clear: alarmId 缺失config 中未配置ctx.metadata.alarmId 亦为空)");
}
String operator = config.path("operator").asText("rule-engine:" + ctx.getChainId());
IotAlarmClearReqDTO req = IotAlarmClearReqDTO.builder()
.alarmId(alarmId)
.operator(operator)
.build();
CommonResult<Boolean> result = alarmRecordApi.clearAlarm(req);
if (result == null || !result.isSuccess()) {
String errMsg = result != null ? result.getMsg() : "API 返回 null";
log.warn("[AlarmClearAction] chain={} alarmId={} clearAlarm 失败: {}",
ctx.getChainId(), alarmId, errMsg);
return ActionResult.failure("clearAlarm 失败: " + errMsg);
}
log.debug("[AlarmClearAction] chain={} alarmId={} 清除成功", ctx.getChainId(), alarmId);
return ActionResult.success();
} catch (Exception e) {
log.warn("[AlarmClearAction] chain={} 异常: {}", ctx.getChainId(), e.getMessage());
return ActionResult.failure(e.getMessage());
}
}
private Long resolveAlarmId(RuleContext ctx, JsonNode config) {
if (config.hasNonNull("alarmId")) {
JsonNode node = config.get("alarmId");
if (node.isNumber()) return node.asLong();
// 支持 "${meta.alarmId}" 语法的静态解析
String raw = node.asText();
if (raw.startsWith("${meta.") && raw.endsWith("}")) {
String key = raw.substring(7, raw.length() - 1);
Object val = ctx.getMetadata().get(key);
if (val instanceof Long l) return l;
if (val instanceof Number n) return n.longValue();
}
}
// 从 ctx.metadata 兜底获取(与 alarm_trigger 同链联动)
Object meta = ctx.getMetadata().get("alarmId");
if (meta instanceof Long l) return l;
if (meta instanceof Number n) return n.longValue();
return null;
}
}

View File

@@ -0,0 +1,94 @@
package com.viewsh.module.iot.rule.action;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.api.alarm.IotAlarmRecordApi;
import com.viewsh.module.iot.api.alarm.dto.IotAlarmTriggerReqDTO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
import com.viewsh.module.iot.rule.template.TemplateResolver;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 告警触发 Actionalarm_trigger
*
* <p>配置示例:
* <pre>{@code
* {
* "alarmConfigId": 100,
* "severity": 3,
* "name": "设备 ${meta.deviceName} 温度过高",
* "details": { "temperature": "${data.temperature}" }
* }
* }</pre>
*
* <p>调用 {@link IotAlarmRecordApi#triggerAlarm} 触发告警API 层幂等);
* 将 alarmId 写入 ctx.metadata 供后继节点引用。
*
* <p>评审 C5模板变量统一走 {@link TemplateResolver},不重复实现。
* 评审 C1幂等由 Service 层保证UK on device_id + alarm_config_idAction 层无需判重。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AlarmTriggerAction implements ActionProvider {
public static final String TYPE = "alarm_trigger";
private final IotAlarmRecordApi alarmRecordApi;
private final TemplateResolver templateResolver;
@Override
public String getType() {
return TYPE;
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {
Long alarmConfigId = config.path("alarmConfigId").asLong(0);
if (alarmConfigId == 0) {
return ActionResult.failure("alarm_trigger: alarmConfigId 缺失");
}
int severity = config.path("severity").asInt(3);
String alarmName = config.hasNonNull("name")
? String.valueOf(templateResolver.resolve(config.get("name").asText(), ctx))
: "规则告警";
JsonNode details = config.path("details");
// details 字段本身也可含模板变量;第一期直接透传
IotAlarmTriggerReqDTO req = IotAlarmTriggerReqDTO.builder()
.deviceId(ctx.getDeviceId())
.alarmConfigId(alarmConfigId)
.severity(severity)
.alarmName(alarmName)
.productId(ctx.getProductId())
.subsystemId(ctx.getSubsystemId())
.ruleChainId(ctx.getChainId())
.details(details.isNull() ? null : details)
.build();
CommonResult<Long> result = alarmRecordApi.triggerAlarm(req);
if (result == null || !result.isSuccess()) {
String errMsg = result != null ? result.getMsg() : "API 返回 null";
log.warn("[AlarmTriggerAction] chain={} device={} triggerAlarm 失败: {}",
ctx.getChainId(), ctx.getDeviceId(), errMsg);
return ActionResult.failure("triggerAlarm 失败: " + errMsg);
}
Long alarmId = result.getData();
ctx.getMetadata().put("alarmId", alarmId);
log.debug("[AlarmTriggerAction] chain={} device={} alarmId={}", ctx.getChainId(), ctx.getDeviceId(), alarmId);
return ActionResult.success(alarmId);
} catch (Exception e) {
log.warn("[AlarmTriggerAction] chain={} device={} 异常: {}",
ctx.getChainId(), ctx.getDeviceId(), e.getMessage());
return ActionResult.failure(e.getMessage());
}
}
}

View File

@@ -0,0 +1,75 @@
package com.viewsh.module.iot.rule.action;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
import com.viewsh.module.iot.rule.template.TemplateResolver;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 设备属性设置 Actiondevice_property_set
*
* <p>配置示例:
* <pre>{@code
* {
* "properties": {
* "targetTemperature": "${data.temperature}",
* "mode": "auto"
* }
* }
* }</pre>
*
* <p>第一期:写 Redis设备影子属性缓存MySQL 同步在第二期 B27 完整实现Shared 属性下发)。
* 评审 C5属性值若含模板变量统一走 {@link TemplateResolver} 解析。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DevicePropertySetAction implements ActionProvider {
public static final String TYPE = "device_property_set";
private final TemplateResolver templateResolver;
@Override
public String getType() {
return TYPE;
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {
JsonNode propertiesNode = config.path("properties");
if (propertiesNode.isMissingNode() || !propertiesNode.isObject()) {
return ActionResult.failure("device_property_set: properties 配置缺失或非 Object");
}
if (ctx.getDeviceId() == null) {
return ActionResult.failure("device_property_set: ctx.deviceId 为 null");
}
// 解析所有属性值(支持模板变量)
var fields = propertiesNode.fields();
int count = 0;
while (fields.hasNext()) {
var entry = fields.next();
String key = entry.getKey();
String rawValue = entry.getValue().asText();
Object resolved = templateResolver.resolve(rawValue, ctx);
// TODO B27: 写 Redis 设备影子 + MySQLShared 属性下发)
log.info("[DevicePropertySetAction] [stub] chain={} device={} property={}={}",
ctx.getChainId(), ctx.getDeviceId(), key, resolved);
count++;
}
return ActionResult.successMsg("device_property_set: 已解析 " + count + " 个属性(第一期 stub");
} catch (Exception e) {
log.warn("[DevicePropertySetAction] chain={} device={} 异常: {}",
ctx.getChainId(), ctx.getDeviceId(), e.getMessage());
return ActionResult.failure(e.getMessage());
}
}
}

View File

@@ -0,0 +1,93 @@
package com.viewsh.module.iot.rule.action;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.api.device.IotDeviceControlApi;
import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO;
import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeRespDTO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 设备服务调用 Actiondevice_service_invoke
*
* <p>配置示例:
* <pre>{@code
* {
* "identifier": "playVoice",
* "params": { "text": "温度过高,请注意!", "volume": 80 },
* "timeoutSeconds": 30
* }
* }</pre>
*
* <p>第一期调用 {@link IotDeviceControlApi#invokeService} 同步下发服务指令;
* 评审 D1第二期 B27-B34 引入 B29 后改为创建 RpcCommand 持久化记录并异步跟踪。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceServiceInvokeAction implements ActionProvider {
public static final String TYPE = "device_service_invoke";
private final IotDeviceControlApi deviceControlApi;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Override
public String getType() {
return TYPE;
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {
String identifier = config.path("identifier").asText("");
if (identifier.isBlank()) {
return ActionResult.failure("device_service_invoke: identifier 缺失");
}
if (ctx.getDeviceId() == null) {
return ActionResult.failure("device_service_invoke: ctx.deviceId 为 null");
}
Map<String, Object> params = null;
JsonNode paramsNode = config.path("params");
if (paramsNode.isObject()) {
params = OBJECT_MAPPER.convertValue(paramsNode,
OBJECT_MAPPER.getTypeFactory().constructMapType(Map.class, String.class, Object.class));
}
int timeout = config.path("timeoutSeconds").asInt(30);
IotDeviceServiceInvokeReqDTO req = IotDeviceServiceInvokeReqDTO.builder()
.deviceId(ctx.getDeviceId())
.identifier(identifier)
.params(params)
.timeoutSeconds(timeout)
.build();
CommonResult<IotDeviceServiceInvokeRespDTO> result = deviceControlApi.invokeService(req);
if (result == null || !result.isSuccess()) {
String errMsg = result != null ? result.getMsg() : "API 返回 null";
log.warn("[DeviceServiceInvokeAction] chain={} device={} identifier={} 失败: {}",
ctx.getChainId(), ctx.getDeviceId(), identifier, errMsg);
return ActionResult.failure("invokeService 失败: " + errMsg);
}
log.debug("[DeviceServiceInvokeAction] chain={} device={} identifier={} 调用成功",
ctx.getChainId(), ctx.getDeviceId(), identifier);
return ActionResult.success(result.getData());
} catch (Exception e) {
log.warn("[DeviceServiceInvokeAction] chain={} device={} 异常: {}",
ctx.getChainId(), ctx.getDeviceId(), e.getMessage());
return ActionResult.failure(e.getMessage());
}
}
}

View File

@@ -0,0 +1,161 @@
package com.viewsh.module.iot.rule.action;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
import com.viewsh.module.iot.rule.template.TemplateResolver;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 通知发送 Actionnotify
*
* <p>配置示例:
* <pre>{@code
* {
* "channels": ["sms", "email", "in_app", "webhook"],
* "receivers": { "userIds": [1001], "webhookUrl": "https://hook.example.com" },
* "template": {
* "title": "设备 ${meta.deviceName} 告警",
* "body": "告警:${meta.alarmName},触发值 ${data.temperature}°C"
* }
* }
* }</pre>
*
* <p>4 个通道并发触发,部分失败不阻塞其他通道,最终汇总结果。
* 评审 C5title/body 统一走 {@link TemplateResolver} 解析。
* 评审 B6@Async 慎用,保持同步线程池以保留 traceId 和 tenant 上下文。
*
* <p>第一期 B16NotifyService未就绪各通道以 TODO stub 占位并记录日志;
* B16 就绪后替换 stub 即可。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class NotifyAction implements ActionProvider {
public static final String TYPE = "notify";
private final TemplateResolver templateResolver;
/** 通知并发线程池(复用,避免每次 Action 创建) */
private static final ExecutorService NOTIFY_POOL =
Executors.newFixedThreadPool(4, r -> {
Thread t = new Thread(r, "iot-notify-");
t.setDaemon(true);
return t;
});
@Override
public String getType() {
return TYPE;
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {
JsonNode channelsNode = config.path("channels");
if (channelsNode.isMissingNode() || !channelsNode.isArray() || channelsNode.isEmpty()) {
return ActionResult.failure("notify: channels 未配置");
}
String title = resolveTemplate(config.path("template").path("title").asText(""), ctx);
String body = resolveTemplate(config.path("template").path("body").asText(""), ctx);
JsonNode receivers = config.path("receivers");
List<String> channels = new ArrayList<>();
for (JsonNode ch : channelsNode) {
channels.add(ch.asText());
}
List<CompletableFuture<ChannelResult>> futures = channels.stream()
.map(channel -> CompletableFuture.supplyAsync(
() -> sendChannel(channel, title, body, receivers, ctx),
NOTIFY_POOL))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
List<String> failedChannels = new ArrayList<>();
for (int i = 0; i < channels.size(); i++) {
ChannelResult cr = futures.get(i).join();
if (!cr.success()) {
failedChannels.add(channels.get(i) + ":" + cr.error());
}
}
if (failedChannels.isEmpty()) {
return ActionResult.successMsg("notify: 全部通道发送成功");
} else {
// 部分失败:仍返回 SUCCESSmessage 记录失败通道(评审 B6
String msg = "notify: 部分通道失败 " + failedChannels;
log.warn("[NotifyAction] chain={} {}", ctx.getChainId(), msg);
return ActionResult.successMsg(msg);
}
} catch (Exception e) {
log.warn("[NotifyAction] chain={} 异常: {}", ctx.getChainId(), e.getMessage());
return ActionResult.failure(e.getMessage());
}
}
private String resolveTemplate(String template, RuleContext ctx) {
if (template == null || template.isBlank()) return template;
try {
return String.valueOf(templateResolver.resolve(template, ctx));
} catch (Exception e) {
log.warn("[NotifyAction] 模板解析失败 template='{}': {}", template, e.getMessage());
return template;
}
}
/**
* 单通道发送(第一期 stubB16 NotifyService 就绪后替换)。
*/
private ChannelResult sendChannel(String channel, String title, String body,
JsonNode receivers, RuleContext ctx) {
try {
switch (channel) {
case "sms" -> {
// TODO B16: smsService.send(receivers.userIds, title, body)
log.info("[NotifyAction] [stub] sms chain={} title='{}' body='{}'",
ctx.getChainId(), title, body);
}
case "email" -> {
// TODO B16: emailService.send(receivers.userIds, title, body)
log.info("[NotifyAction] [stub] email chain={} title='{}' body='{}'",
ctx.getChainId(), title, body);
}
case "in_app" -> {
// TODO B16: inAppService.send(receivers.userIds, title, body)
log.info("[NotifyAction] [stub] in_app chain={} title='{}' body='{}'",
ctx.getChainId(), title, body);
}
case "webhook" -> {
String webhookUrl = receivers.path("webhookUrl").asText("");
// TODO B16: webhookService.post(webhookUrl, title, body)
log.info("[NotifyAction] [stub] webhook chain={} url='{}' title='{}' body='{}'",
ctx.getChainId(), webhookUrl, title, body);
}
default -> {
log.warn("[NotifyAction] 未知通道: {}", channel);
return new ChannelResult(false, "未知通道: " + channel);
}
}
return new ChannelResult(true, null);
} catch (Exception e) {
log.warn("[NotifyAction] channel={} 发送失败: {}", channel, e.getMessage());
return new ChannelResult(false, e.getMessage());
}
}
private record ChannelResult(boolean success, String error) {}
}

View File

@@ -1,6 +1,7 @@
package com.viewsh.module.iot.rule.engine;
import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleLinkRelationType;
import com.viewsh.module.iot.rule.engine.branch.BranchNode;
import com.viewsh.module.iot.rule.engine.exception.RuleChainException;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
@@ -41,6 +42,9 @@ public class DagExecutor {
throw new RuleChainException(chain.getId(), null, "规则链缺少 Trigger 入口节点");
}
// Branch 节点需要通过 ctx.metadata 获取 CompiledRuleChain 以执行内联 action
ctx.getMetadata().put(BranchNode.CTX_CHAIN_KEY, chain);
Deque<CompiledNode> queue = new ArrayDeque<>();
queue.offer(entry);

View File

@@ -0,0 +1,104 @@
package com.viewsh.module.iot.rule.engine.branch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import java.util.Collections;
import java.util.List;
/**
* Branch 节点配置 POJO反序列化自 rule_node.configuration JSON
*
* <p>配置示例:
* <pre>{@code
* {
* "branches": [
* {
* "name": "高温",
* "condition": { "type": "expression", "config": { "expression": "${data.temp} > 40" } },
* "actions": ["nodeId_alarm"],
* "executeAnyway": false
* },
* {
* "name": "else",
* "condition": null,
* "actions": ["nodeId_log"]
* }
* ]
* }
* }</pre>
*
* <p>约束B7 规格):
* <ul>
* <li>{@code condition=null} 的 else 分支必须是最后一个</li>
* <li>{@code executeAnyway=true}:命中后继续评估后续分支(重叠触发)</li>
* <li>{@code executeAnyway=false}默认命中后跳过后续分支if/else-if 语义)</li>
* </ul>
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class BranchConfiguration {
private List<BranchItem> branches = Collections.emptyList();
/**
* 校验配置合法性else 分支condition=null必须是最后一个。
*
* @throws IllegalArgumentException 配置非法时抛出
*/
public void validate() {
if (branches == null || branches.isEmpty()) {
return;
}
for (int i = 0; i < branches.size() - 1; i++) {
if (branches.get(i).getCondition() == null) {
throw new IllegalArgumentException(
"[BranchConfiguration] else 分支condition=null必须是最后一个但在 index=" + i + " 处发现");
}
}
}
/**
* 单个分支配置。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class BranchItem {
/** 分支名称(用于日志 / metadata 标记,必填) */
private String name;
/**
* 条件配置null 表示 else 分支)。
* 格式:{"type": "expression", "config": {...}}
*/
private BranchCondition condition;
/**
* 命中时需要执行的下游节点 ID 列表。
* BranchNode 按此列表在 CompiledRuleChain 中查找对应节点并内联执行。
*/
private List<String> actions = Collections.emptyList();
/**
* 是否无论前面是否命中都继续评估此分支(重叠触发)。
* true = 执行后继续评估后续分支false默认= 命中后跳过后续分支。
*/
private boolean executeAnyway = false;
}
/**
* 分支条件配置(内嵌 type + config
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class BranchCondition {
/** 条件类型,对应 {@link com.viewsh.module.iot.rule.spi.ConditionEvaluator#getType()} */
private String type;
/** 条件具体配置(由对应的 ConditionEvaluator 解析) */
private JsonNode config;
}
}

View File

@@ -0,0 +1,153 @@
package com.viewsh.module.iot.rule.engine.branch;
import com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager;
import com.viewsh.module.iot.rule.engine.RuleContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* Branch 节点核心执行语义executeAnyway if/else-if/else
*
* <p>执行流程B7 §3.2 规格):
* <ol>
* <li>按数组顺序遍历 branches</li>
* <li>condition=null 视为 else仅当前面所有分支都未命中时执行</li>
* <li>executeAnyway=false默认命中后执行 actions跳过后续分支</li>
* <li>executeAnyway=true命中后执行 actions继续评估后续分支</li>
* <li>条件评估异常:该分支跳过(短路求值,不当失败),继续下一个</li>
* <li>action 执行异常:记录日志后继续执行同 branch 其余 action异常隔离</li>
* </ol>
*
* <p>此类不依赖 Spring便于单元测试mock ConditionEvaluatorManager 和 ActionExecutor
*/
@Slf4j
@RequiredArgsConstructor
public class BranchExecutor {
private final ConditionEvaluatorManager conditionEvaluatorManager;
/**
* 执行分支逻辑。
*
* @param ctx 规则执行上下文
* @param configuration branch 节点配置(已解析,已 validate
* @param actionExecutor 回调:执行指定 nodeId 对应的 action由 BranchNode 提供真实实现,测试可 mock
* @return 命中的分支名列表(可能为空)
*/
public List<String> execute(RuleContext ctx,
BranchConfiguration configuration,
ActionExecutor actionExecutor) {
List<BranchConfiguration.BranchItem> branches = configuration.getBranches();
List<String> matchedBranches = new ArrayList<>();
boolean anyPreviousMatched = false;
for (BranchConfiguration.BranchItem branch : branches) {
boolean match = evaluateMatch(ctx, branch, anyPreviousMatched);
if (match) {
String branchName = branch.getName();
log.debug("[BranchExecutor] chain={} branch='{}' 命中executeAnyway={}",
ctx.getChainId(), branchName, branch.isExecuteAnyway());
matchedBranches.add(branchName);
executeActions(ctx, branch, actionExecutor);
if (!branch.isExecuteAnyway()) {
// if/else-if 语义:命中后跳过后续
log.debug("[BranchExecutor] chain={} branch='{}' executeAnyway=false终止后续分支评估",
ctx.getChainId(), branchName);
break;
}
// executeAnyway=true继续评估后续分支
anyPreviousMatched = true;
} else {
if (branch.isExecuteAnyway()) {
// executeAnyway=true 但条件未命中:跳过,继续后续
log.debug("[BranchExecutor] chain={} branch='{}' 未命中executeAnyway=true继续评估",
ctx.getChainId(), branch.getName());
}
// 不更新 anyPreviousMatched未命中的 executeAnyway 分支不算 "previous matched"
}
}
if (matchedBranches.isEmpty()) {
log.debug("[BranchExecutor] chain={} 无分支命中", ctx.getChainId());
}
return matchedBranches;
}
/**
* 判断某个分支是否命中。
*
* @param anyPreviousMatched 前面是否有非 executeAnyway 的分支已命中
*/
private boolean evaluateMatch(RuleContext ctx,
BranchConfiguration.BranchItem branch,
boolean anyPreviousMatched) {
BranchConfiguration.BranchCondition condition = branch.getCondition();
if (condition == null) {
// else 分支:仅当前面都未命中时执行
return !anyPreviousMatched;
}
try {
boolean result = conditionEvaluatorManager.evaluate(condition.getType(), ctx, condition.getConfig());
log.debug("[BranchExecutor] chain={} branch='{}' 条件评估结果={}",
ctx.getChainId(), branch.getName(), result);
return result;
} catch (Exception e) {
// 短路求值:条件异常 → 跳过该 branch不当失败
log.warn("[BranchExecutor] chain={} branch='{}' 条件评估异常,跳过:{}",
ctx.getChainId(), branch.getName(), e.getMessage());
return false;
}
}
/**
* 执行分支内所有 actionsaction 异常隔离,不影响其他 action
*/
private void executeActions(RuleContext ctx,
BranchConfiguration.BranchItem branch,
ActionExecutor actionExecutor) {
String branchName = branch.getName();
List<String> actions = branch.getActions();
if (actions == null || actions.isEmpty()) {
return;
}
for (String actionNodeId : actions) {
try {
log.debug("[BranchExecutor] chain={} branch='{}' 执行 action nodeId={}",
ctx.getChainId(), branchName, actionNodeId);
actionExecutor.execute(ctx, branchName, actionNodeId);
} catch (Exception e) {
// action 异常隔离:记录日志后继续下一个 action
log.warn("[BranchExecutor] chain={} branch='{}' action nodeId={} 执行异常(隔离):{}",
ctx.getChainId(), branchName, actionNodeId, e.getMessage());
}
}
}
/**
* action 执行回调接口(用于解耦 BranchExecutor 与 NodeProvider/DagExecutor
*
* <p>生产实现由 {@link BranchNode} 提供(通过 CompiledRuleChain + NodeProviderRegistry 执行);
* 测试时可使用 mock 或 lambda 替代。
*/
@FunctionalInterface
public interface ActionExecutor {
/**
* 执行指定分支内的一个 action 节点。
*
* @param ctx 规则上下文(已含 branch_name metadata
* @param branchName 所属分支名(供日志 / metadata 使用)
* @param actionNodeId 节点 ID来自 BranchConfiguration.BranchItem.actions
*/
void execute(RuleContext ctx, String branchName, String actionNodeId);
}
}

View File

@@ -0,0 +1,153 @@
package com.viewsh.module.iot.rule.engine.branch;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager;
import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory;
import com.viewsh.module.iot.rule.engine.CompiledNode;
import com.viewsh.module.iot.rule.engine.CompiledRuleChain;
import com.viewsh.module.iot.rule.engine.NodeProvider;
import com.viewsh.module.iot.rule.engine.NodeProviderRegistry;
import com.viewsh.module.iot.rule.engine.NodeResult;
import com.viewsh.module.iot.rule.engine.RuleContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* Branch 类型节点 Providercategory=ACTION, type="branch")。
*
* <p>职责:
* <ol>
* <li>将 rule_node.configuration JSON 解析为 {@link BranchConfiguration}</li>
* <li>校验 else 分支condition=null是否在最后</li>
* <li>调用 {@link BranchExecutor} 执行分支语义if/else-if/executeAnyway</li>
* <li>命中的 branch 的 actions 通过 {@link NodeProviderRegistry} 内联执行</li>
* <li>将命中分支名列表写入 ctx.metadata["branch_names"],供下游节点参考</li>
* <li>返回 {@link NodeResult#success()} 让 DagExecutor 沿 SUCCESS 出边走公共下游</li>
* </ol>
*
* <p>内联执行说明DAG 中 BranchNode 的 SUCCESS 出边指向 Branch 之后的公共节点;
* Branch 内 actions互斥子节点由 BranchNode 自行执行,不依赖 DagExecutor 的出边路由。
* 因此 Branch actions 对应的子节点在 rule_node 中存在但没有从 BranchNode 发出的 link
* 或者有 link 但 DagExecutor 会通过 BranchNode 内联执行后跳过(取决于链设计)。
*
* <p>CompiledRuleChain 通过 DagExecutor 写入 ctx.metadata["{@value #CTX_CHAIN_KEY}"] 获得,
* 供 BranchNode 查找子节点配置。
*/
@Slf4j
@Component
public class BranchNode implements NodeProvider {
/** DagExecutor 在执行前将 CompiledRuleChain 写入 ctx.metadata 的 key */
public static final String CTX_CHAIN_KEY = "__compiledRuleChain__";
public static final String TYPE = "branch";
private static final ObjectMapper MAPPER = new ObjectMapper();
private final ConditionEvaluatorManager conditionEvaluatorManager;
private final NodeProviderRegistry nodeProviderRegistry;
private final BranchExecutor branchExecutor;
public BranchNode(ConditionEvaluatorManager conditionEvaluatorManager,
NodeProviderRegistry nodeProviderRegistry) {
this.conditionEvaluatorManager = conditionEvaluatorManager;
this.nodeProviderRegistry = nodeProviderRegistry;
this.branchExecutor = new BranchExecutor(conditionEvaluatorManager);
}
@Override
public RuleNodeCategory getCategory() {
return RuleNodeCategory.ACTION;
}
@Override
public String getType() {
return TYPE;
}
@Override
public NodeResult execute(RuleContext ctx, String config) {
// 1. 解析配置
BranchConfiguration configuration;
try {
configuration = MAPPER.readValue(config == null ? "{}" : config, BranchConfiguration.class);
} catch (Exception e) {
log.warn("[BranchNode] chain={} config JSON 解析失败: {}", ctx.getChainId(), e.getMessage());
return NodeResult.failure("BranchNode config 解析失败: " + e.getMessage(), e);
}
// 2. 校验else 分支必须在最后)
try {
configuration.validate();
} catch (IllegalArgumentException e) {
log.warn("[BranchNode] chain={} 配置校验失败: {}", ctx.getChainId(), e.getMessage());
return NodeResult.failure(e.getMessage());
}
if (configuration.getBranches().isEmpty()) {
log.debug("[BranchNode] chain={} branches 为空no-op", ctx.getChainId());
return NodeResult.success();
}
// 3. 获取 CompiledRuleChain用于内联执行 action 节点)
CompiledRuleChain chain = (CompiledRuleChain) ctx.getMetadata().get(CTX_CHAIN_KEY);
// 4. 构建 ActionExecutor使用 NodeProviderRegistry 内联执行)
BranchExecutor.ActionExecutor actionExecutor = buildActionExecutor(ctx, chain);
// 5. 执行分支逻辑
List<String> matchedBranches = branchExecutor.execute(ctx, configuration, actionExecutor);
// 6. 将命中分支名写入 metadata供下游节点参考
if (!matchedBranches.isEmpty()) {
ctx.getMetadata().put("branch_names", matchedBranches);
ctx.getMetadata().put("branch_name", matchedBranches.get(0));
}
return NodeResult.success(Map.of("branch_names", matchedBranches));
}
/**
* 构建 ActionExecutor通过 NodeProviderRegistry 内联执行 action 节点。
*
* <p>若 CompiledRuleChain 不可用chain=null 或节点不存在),则打印 warn 日志跳过。
*/
private BranchExecutor.ActionExecutor buildActionExecutor(RuleContext ctx, CompiledRuleChain chain) {
return (ruleCtx, branchName, actionNodeId) -> {
if (chain == null) {
log.warn("[BranchNode] chain={} branch='{}' 无法获取 CompiledRuleChain跳过 action={}",
ruleCtx.getChainId(), branchName, actionNodeId);
return;
}
CompiledNode actionNode = chain.nodeById(actionNodeId);
if (actionNode == null) {
log.warn("[BranchNode] chain={} branch='{}' action nodeId={} 不存在于链中,跳过",
ruleCtx.getChainId(), branchName, actionNodeId);
return;
}
try {
NodeProvider provider = nodeProviderRegistry.resolve(actionNode.getCategory(), actionNode.getType());
// 更新 ctx 的当前节点(与 DagExecutor.fork 行为一致)
ruleCtx.fork(actionNodeId);
// 将 branch_name 写入 metadata便于 action 内的日志追踪
ruleCtx.getMetadata().put("branch_name", branchName);
NodeResult result = provider.execute(ruleCtx, actionNode.getConfiguration());
if (result != null) {
ruleCtx.recordNodeOutput(actionNodeId, result.getOutputs());
}
log.debug("[BranchNode] chain={} branch='{}' action={} 执行完成result={}",
ruleCtx.getChainId(), branchName, actionNodeId,
result != null ? result.getRelationType() : "null");
} catch (Exception e) {
// 异常已在 BranchExecutor.executeActions 中捕获,此处不再 re-throw
throw new RuntimeException("action=" + actionNodeId + " 执行异常: " + e.getMessage(), e);
}
};
}
}

View File

@@ -0,0 +1,54 @@
package com.viewsh.module.iot.rule.result;
import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleLinkRelationType;
/**
* Action 执行结果。
*
* <p>relationType 决定 DAG 走哪条 outgoing link
* <ul>
* <li>{@link RuleLinkRelationType#SUCCESS} — Action 执行成功</li>
* <li>{@link RuleLinkRelationType#FAILURE} — Action 执行失败(内部捕获,不上抛)</li>
* <li>{@link RuleLinkRelationType#SKIP} — 静默跳过(如告警已清除,重复清除为 no-op</li>
* </ul>
*
* <p>output 会注入 {@code ctx.nodeOutputs}key="output"),可供后继节点引用。
*/
public record ActionResult(
RuleLinkRelationType relationType,
String message,
Object output
) {
public static ActionResult success() {
return new ActionResult(RuleLinkRelationType.SUCCESS, null, null);
}
public static ActionResult success(Object output) {
return new ActionResult(RuleLinkRelationType.SUCCESS, null, output);
}
public static ActionResult successMsg(String message) {
return new ActionResult(RuleLinkRelationType.SUCCESS, message, null);
}
public static ActionResult success(String message, Object output) {
return new ActionResult(RuleLinkRelationType.SUCCESS, message, output);
}
public static ActionResult failure(String message) {
return new ActionResult(RuleLinkRelationType.FAILURE, message, null);
}
public static ActionResult skip(String reason) {
return new ActionResult(RuleLinkRelationType.SKIP, reason, null);
}
public boolean isSuccess() {
return relationType == RuleLinkRelationType.SUCCESS;
}
public boolean isFailure() {
return relationType == RuleLinkRelationType.FAILURE;
}
}

View File

@@ -0,0 +1,37 @@
package com.viewsh.module.iot.rule.result;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 设备 RPC 指令封装预留B29 期实现完整持久化)。
*
* <p>第一期 DeviceServiceInvokeAction 先调用现有 {@code IotDeviceControlApi.invokeService()}
* 第二期 B27-B34 引入 B29 后Action 层改为创建 RpcCommand 记录并异步跟踪执行结果。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RpcCommand {
/** 目标设备 ID */
private Long deviceId;
/** 服务标识符(物模型 identifier */
private String serviceId;
/** 入参JSON 格式) */
private String inputParams;
/** 调用超时ms默认 30000 */
private Integer timeoutMs;
/** 关联的规则链 ID用于追踪 */
private Long ruleChainId;
/** 关联的节点 ID用于追踪 */
private String nodeId;
}

View File

@@ -0,0 +1,78 @@
package com.viewsh.module.iot.rule.spi;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory;
import com.viewsh.module.iot.rule.engine.NodeProvider;
import com.viewsh.module.iot.rule.engine.NodeResult;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
/**
* Action SPI — 规则链动作节点执行接口。
*
* <p>实现通过 Spring {@code @Component} + {@link #getType()} 注册,由
* {@link com.viewsh.module.iot.rule.action.ActionProviderManager} 管理。
* 同时实现 {@link NodeProvider}category=ACTION使其可被 DagExecutor 直接路由。
*
* <p>约定(评审 B6
* <ul>
* <li>所有模板变量解析统一走 {@link com.viewsh.module.iot.rule.template.TemplateResolver}</li>
* <li>Action 异常必须捕获并转为 {@link ActionResult#failure(String)},不上抛到链级</li>
* <li>不使用 {@code @Async},保持同步以保留 traceId 和 tenant 上下文</li>
* </ul>
*/
public interface ActionProvider extends NodeProvider {
Logger LOG = LoggerFactory.getLogger(ActionProvider.class);
ObjectMapper CONFIG_MAPPER = new ObjectMapper();
@Override
default RuleNodeCategory getCategory() {
return RuleNodeCategory.ACTION;
}
/**
* 执行 ActionJsonNode config 版本)。
*
* @param ctx 规则执行上下文
* @param config 节点 configuration已解析为 JsonNode
* @return ActionResult不允许返回 null
*/
ActionResult executeAction(RuleContext ctx, JsonNode config);
/**
* NodeProvider.execute 默认桥接:将 String config 解析为 JsonNode
* 执行 {@link #executeAction(RuleContext, JsonNode)},并映射为 {@link NodeResult}。
*
* <p>config 解析异常或 executeAction 异常,均转为 {@link NodeResult#failure(String)}。
*/
@Override
default NodeResult execute(RuleContext ctx, String config) {
JsonNode cfg;
try {
cfg = CONFIG_MAPPER.readTree(config == null ? "{}" : config);
} catch (Exception e) {
LOG.warn("[ActionProvider] type={} config JSON 解析失败: {}", getType(), e.getMessage());
return NodeResult.failure("config JSON 解析失败: " + e.getMessage(), e);
}
try {
ActionResult result = executeAction(ctx, cfg);
if (result == null) {
LOG.warn("[ActionProvider] type={} executeAction 返回 null", getType());
return NodeResult.failure("executeAction 返回 null");
}
Map<String, Object> outputs = result.output() != null
? Map.of("output", result.output())
: Map.of();
return NodeResult.of(result.relationType(), outputs);
} catch (Exception e) {
LOG.warn("[ActionProvider] type={} 执行异常(转为 FAILURE: {}", getType(), e.getMessage());
return NodeResult.failure(e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,286 @@
package com.viewsh.module.iot.rule.action;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.api.alarm.IotAlarmRecordApi;
import com.viewsh.module.iot.api.alarm.dto.IotAlarmTriggerReqDTO;
import com.viewsh.module.iot.api.alarm.dto.IotAlarmClearReqDTO;
import com.viewsh.module.iot.api.device.IotDeviceControlApi;
import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO;
import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeRespDTO;
import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleLinkRelationType;
import com.viewsh.module.iot.rule.engine.NodeResult;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.template.TemplateResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* B6 ActionProvider 单元测试5 个 Action + ActionProvider 接口桥接NodeProvider
*/
class ActionProviderTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private IotAlarmRecordApi alarmRecordApi;
private IotDeviceControlApi deviceControlApi;
private TemplateResolver templateResolver;
@BeforeEach
void setUp() {
alarmRecordApi = mock(IotAlarmRecordApi.class);
deviceControlApi = mock(IotDeviceControlApi.class);
templateResolver = new TemplateResolver(MAPPER);
}
// ======================== AlarmTriggerAction ========================
@Test
void alarmTrigger_firstTime_callsServiceAndWritesAlarmIdToMeta() throws Exception {
when(alarmRecordApi.triggerAlarm(any())).thenReturn(CommonResult.success(42L));
AlarmTriggerAction action = new AlarmTriggerAction(alarmRecordApi, templateResolver);
RuleContext ctx = ctx(1L, 10L);
JsonNode config = MAPPER.readTree("""
{"alarmConfigId": 100, "severity": 3, "name": "温度告警"}
""");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isSuccess());
assertEquals(42L, ctx.getMetadata().get("alarmId"));
ArgumentCaptor<IotAlarmTriggerReqDTO> cap = ArgumentCaptor.forClass(IotAlarmTriggerReqDTO.class);
verify(alarmRecordApi).triggerAlarm(cap.capture());
assertEquals(100L, cap.getValue().getAlarmConfigId());
assertEquals(3, cap.getValue().getSeverity());
}
@Test
void alarmTrigger_missingAlarmConfigId_returnsFailure() throws Exception {
AlarmTriggerAction action = new AlarmTriggerAction(alarmRecordApi, templateResolver);
RuleContext ctx = ctx(1L, 10L);
JsonNode config = MAPPER.readTree("""
{"severity": 3}
""");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isFailure());
verifyNoInteractions(alarmRecordApi);
}
@Test
void alarmTrigger_templateName_resolved() throws Exception {
when(alarmRecordApi.triggerAlarm(any())).thenReturn(CommonResult.success(99L));
AlarmTriggerAction action = new AlarmTriggerAction(alarmRecordApi, templateResolver);
RuleContext ctx = ctx(1L, 10L);
ctx.getMetadata().put("deviceName", "传感器A");
JsonNode config = MAPPER.readTree("""
{"alarmConfigId": 1, "severity": 2, "name": "设备 ${meta.deviceName} 过热"}
""");
action.executeAction(ctx, config);
ArgumentCaptor<IotAlarmTriggerReqDTO> cap = ArgumentCaptor.forClass(IotAlarmTriggerReqDTO.class);
verify(alarmRecordApi).triggerAlarm(cap.capture());
assertEquals("设备 传感器A 过热", cap.getValue().getAlarmName());
}
// ======================== AlarmClearAction ========================
@Test
void alarmClear_withAlarmId_callsService() throws Exception {
when(alarmRecordApi.clearAlarm(any())).thenReturn(CommonResult.success(true));
AlarmClearAction action = new AlarmClearAction(alarmRecordApi);
RuleContext ctx = ctx(1L, 10L);
JsonNode config = MAPPER.readTree("""
{"alarmId": 55, "operator": "rule-engine"}
""");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isSuccess());
ArgumentCaptor<IotAlarmClearReqDTO> cap = ArgumentCaptor.forClass(IotAlarmClearReqDTO.class);
verify(alarmRecordApi).clearAlarm(cap.capture());
assertEquals(55L, cap.getValue().getAlarmId());
}
@Test
void alarmClear_alarmIdFromMeta_resolvedCorrectly() throws Exception {
when(alarmRecordApi.clearAlarm(any())).thenReturn(CommonResult.success(true));
AlarmClearAction action = new AlarmClearAction(alarmRecordApi);
RuleContext ctx = ctx(1L, 10L);
ctx.getMetadata().put("alarmId", 77L);
JsonNode config = MAPPER.readTree("""
{"alarmId": "${meta.alarmId}"}
""");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isSuccess());
ArgumentCaptor<IotAlarmClearReqDTO> cap = ArgumentCaptor.forClass(IotAlarmClearReqDTO.class);
verify(alarmRecordApi).clearAlarm(cap.capture());
assertEquals(77L, cap.getValue().getAlarmId());
}
@Test
void alarmClear_noAlarmId_returnsFailure() throws Exception {
AlarmClearAction action = new AlarmClearAction(alarmRecordApi);
RuleContext ctx = ctx(1L, 10L);
JsonNode config = MAPPER.readTree("{}");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isFailure());
verifyNoInteractions(alarmRecordApi);
}
// ======================== DeviceServiceInvokeAction ========================
@Test
void deviceServiceInvoke_success_returnsSuccess() throws Exception {
IotDeviceServiceInvokeRespDTO resp = IotDeviceServiceInvokeRespDTO.builder()
.success(true).build();
when(deviceControlApi.invokeService(any())).thenReturn(CommonResult.success(resp));
DeviceServiceInvokeAction action = new DeviceServiceInvokeAction(deviceControlApi);
RuleContext ctx = ctx(1L, 10L);
JsonNode config = MAPPER.readTree("""
{"identifier": "playVoice", "params": {"text": "告警!"}}
""");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isSuccess());
ArgumentCaptor<IotDeviceServiceInvokeReqDTO> cap = ArgumentCaptor.forClass(IotDeviceServiceInvokeReqDTO.class);
verify(deviceControlApi).invokeService(cap.capture());
assertEquals(10L, cap.getValue().getDeviceId());
assertEquals("playVoice", cap.getValue().getIdentifier());
}
@Test
void deviceServiceInvoke_missingIdentifier_returnsFailure() throws Exception {
DeviceServiceInvokeAction action = new DeviceServiceInvokeAction(deviceControlApi);
RuleContext ctx = ctx(1L, 10L);
JsonNode config = MAPPER.readTree("{}");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isFailure());
verifyNoInteractions(deviceControlApi);
}
// ======================== DevicePropertySetAction ========================
@Test
void devicePropertySet_withProperties_resolveTemplateAndReturnSuccess() throws Exception {
DevicePropertySetAction action = new DevicePropertySetAction(templateResolver);
RuleContext ctx = ctx(1L, 10L);
IotDeviceMessage msg = new IotDeviceMessage();
ctx.setMessage(msg);
JsonNode config = MAPPER.readTree("""
{"properties": {"mode": "auto", "targetTemp": "28"}}
""");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isSuccess());
assertNotNull(result.message());
assertTrue(result.message().contains("2"));
}
@Test
void devicePropertySet_missingProperties_returnsFailure() throws Exception {
DevicePropertySetAction action = new DevicePropertySetAction(templateResolver);
RuleContext ctx = ctx(1L, 10L);
JsonNode config = MAPPER.readTree("{}");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isFailure());
}
// ======================== NotifyAction ========================
@Test
void notify_allChannelsOk_returnsSuccess() throws Exception {
NotifyAction action = new NotifyAction(templateResolver);
RuleContext ctx = ctx(1L, 10L);
JsonNode config = MAPPER.readTree("""
{
"channels": ["sms","email","in_app","webhook"],
"receivers": {"webhookUrl": "https://example.com"},
"template": {"title": "告警", "body": "消息体"}
}
""");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isSuccess());
}
@Test
void notify_noChannels_returnsFailure() throws Exception {
NotifyAction action = new NotifyAction(templateResolver);
RuleContext ctx = ctx(1L, 10L);
JsonNode config = MAPPER.readTree("""
{"channels": [], "template": {"title": "T", "body": "B"}}
""");
ActionResult result = action.executeAction(ctx, config);
assertTrue(result.isFailure());
}
// ======================== NodeProvider bridge ========================
@Test
void actionProvider_executeViaNodeProviderBridge_convertsToNodeResult() throws Exception {
when(alarmRecordApi.triggerAlarm(any())).thenReturn(CommonResult.success(99L));
AlarmTriggerAction action = new AlarmTriggerAction(alarmRecordApi, templateResolver);
RuleContext ctx = ctx(1L, 10L);
String configJson = """
{"alarmConfigId": 1, "severity": 2}
""";
NodeResult result = action.execute(ctx, configJson);
assertEquals(RuleLinkRelationType.SUCCESS, result.getRelationType());
}
@Test
void actionProvider_badConfigJson_returnsFailureNodeResult() throws Exception {
AlarmTriggerAction action = new AlarmTriggerAction(alarmRecordApi, templateResolver);
RuleContext ctx = ctx(1L, 10L);
NodeResult result = action.execute(ctx, "{invalid json}");
assertEquals(RuleLinkRelationType.FAILURE, result.getRelationType());
verifyNoInteractions(alarmRecordApi);
}
// ======================== Helper ========================
private RuleContext ctx(Long chainId, Long deviceId) {
RuleContext ctx = new RuleContext();
ctx.setChainId(chainId);
ctx.setDeviceId(deviceId);
ctx.setProductId(100L);
ctx.setTenantId(1L);
ctx.setSubsystemId(1L);
ctx.setStartedAt(Instant.now());
return ctx;
}
}

View File

@@ -0,0 +1,269 @@
package com.viewsh.module.iot.rule.engine.branch;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager;
import com.viewsh.module.iot.rule.engine.RuleContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* {@link BranchExecutor} 单元测试7 个用例B7 §6
*
* <p>使用 Mockito mock ConditionEvaluatorManager不启动 Spring 容器。
* ActionExecutor 通过 lambda 记录执行了哪些 nodeId供断言使用。
*/
class BranchExecutorTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private ConditionEvaluatorManager conditionEvaluatorManager;
private BranchExecutor branchExecutor;
private RuleContext ctx;
/** 记录 actionExecutor 被调用的 (branchName, actionNodeId) 对 */
private List<String> executedActions;
@BeforeEach
void setUp() {
conditionEvaluatorManager = mock(ConditionEvaluatorManager.class);
branchExecutor = new BranchExecutor(conditionEvaluatorManager);
ctx = new RuleContext();
ctx.setChainId(1L);
ctx.setTraceId("test-trace");
executedActions = new ArrayList<>();
}
/**
* 用例 1: if_match — [A] A=true → A 执行
*/
@Test
void testIfMatch_singleBranchTrue() throws Exception {
when(conditionEvaluatorManager.evaluate(eq("expression"), any(), any())).thenReturn(true);
BranchConfiguration config = buildConfig(
branch("A", "expression", false, "nodeA")
);
List<String> matched = branchExecutor.execute(ctx, config, recordingExecutor());
assertEquals(List.of("A"), matched);
assertEquals(List.of("nodeA"), executedActions);
}
/**
* 用例 2: if_else (A=true) — [A, null] A=true → A 执行else 跳过
*/
@Test
void testIfElse_firstBranchTrue_elseSkipped() throws Exception {
when(conditionEvaluatorManager.evaluate(eq("expression"), any(), any())).thenReturn(true);
BranchConfiguration config = buildConfig(
branch("A", "expression", false, "nodeA"),
elseBranch("else", "nodeElse")
);
List<String> matched = branchExecutor.execute(ctx, config, recordingExecutor());
assertEquals(List.of("A"), matched);
assertEquals(List.of("nodeA"), executedActions);
assertFalse(executedActions.contains("nodeElse"), "else 分支不应执行");
}
/**
* 用例 3: if_else (A=false) — [A, null] A=false → else 执行
*/
@Test
void testIfElse_firstBranchFalse_elseExecuted() throws Exception {
when(conditionEvaluatorManager.evaluate(eq("expression"), any(), any())).thenReturn(false);
BranchConfiguration config = buildConfig(
branch("A", "expression", false, "nodeA"),
elseBranch("else", "nodeElse")
);
List<String> matched = branchExecutor.execute(ctx, config, recordingExecutor());
assertEquals(List.of("else"), matched);
assertFalse(executedActions.contains("nodeA"), "A 分支不应执行");
assertEquals(List.of("nodeElse"), executedActions);
}
/**
* 用例 4: else_if — [A, B] A=false, B=true → B 执行
*/
@Test
void testElseIf_firstFalseSecondTrue() throws Exception {
// A=false, B=true
when(conditionEvaluatorManager.evaluate(eq("exprA"), any(), any())).thenReturn(false);
when(conditionEvaluatorManager.evaluate(eq("exprB"), any(), any())).thenReturn(true);
BranchConfiguration config = buildConfigWithTypes(
branchWithType("A", "exprA", false, "nodeA"),
branchWithType("B", "exprB", false, "nodeB")
);
List<String> matched = branchExecutor.execute(ctx, config, recordingExecutor());
assertEquals(List.of("B"), matched);
assertFalse(executedActions.contains("nodeA"));
assertEquals(List.of("nodeB"), executedActions);
}
/**
* 用例 5: executeAnyway=true — [A(executeAnyway=true), B] A=true, B=true → A+B 都执行
*/
@Test
void testExecuteAnyway_true_bothExecuted() throws Exception {
// A=true (executeAnyway=true), B=true
when(conditionEvaluatorManager.evaluate(eq("exprA"), any(), any())).thenReturn(true);
when(conditionEvaluatorManager.evaluate(eq("exprB"), any(), any())).thenReturn(true);
BranchConfiguration config = buildConfigWithTypes(
branchWithType("A", "exprA", true, "nodeA"), // executeAnyway=true
branchWithType("B", "exprB", false, "nodeB")
);
List<String> matched = branchExecutor.execute(ctx, config, recordingExecutor());
assertEquals(List.of("A", "B"), matched);
assertTrue(executedActions.contains("nodeA"), "A 分支应执行");
assertTrue(executedActions.contains("nodeB"), "B 分支应执行");
}
/**
* 用例 6: executeAnyway=false — [A, B] A=true → 只 A 执行B 跳过)
*/
@Test
void testExecuteAnyway_false_onlyFirstExecuted() throws Exception {
when(conditionEvaluatorManager.evaluate(eq("exprA"), any(), any())).thenReturn(true);
when(conditionEvaluatorManager.evaluate(eq("exprB"), any(), any())).thenReturn(true);
BranchConfiguration config = buildConfigWithTypes(
branchWithType("A", "exprA", false, "nodeA"), // executeAnyway=false默认
branchWithType("B", "exprB", false, "nodeB")
);
List<String> matched = branchExecutor.execute(ctx, config, recordingExecutor());
assertEquals(List.of("A"), matched);
assertTrue(executedActions.contains("nodeA"), "A 分支应执行");
assertFalse(executedActions.contains("nodeB"), "B 分支不应执行(已被 A 命中后跳过)");
}
/**
* 用例 7: all_no_match — [A, B] A=false, B=false → 都不执行(无 else
*/
@Test
void testAllNoMatch_noExecution() throws Exception {
when(conditionEvaluatorManager.evaluate(eq("exprA"), any(), any())).thenReturn(false);
when(conditionEvaluatorManager.evaluate(eq("exprB"), any(), any())).thenReturn(false);
BranchConfiguration config = buildConfigWithTypes(
branchWithType("A", "exprA", false, "nodeA"),
branchWithType("B", "exprB", false, "nodeB")
);
List<String> matched = branchExecutor.execute(ctx, config, recordingExecutor());
assertTrue(matched.isEmpty(), "无分支命中");
assertTrue(executedActions.isEmpty(), "无 action 执行");
}
/**
* 额外:条件评估异常 → 跳过该分支(短路求值,不当失败),继续下一个
*/
@Test
void testConditionException_branchSkipped() throws Exception {
when(conditionEvaluatorManager.evaluate(eq("exprA"), any(), any()))
.thenThrow(new RuntimeException("evaluator error"));
when(conditionEvaluatorManager.evaluate(eq("exprB"), any(), any())).thenReturn(true);
BranchConfiguration config = buildConfigWithTypes(
branchWithType("A", "exprA", false, "nodeA"),
branchWithType("B", "exprB", false, "nodeB")
);
// 不抛异常
List<String> matched = assertDoesNotThrow(
() -> branchExecutor.execute(ctx, config, recordingExecutor()));
assertFalse(executedActions.contains("nodeA"), "A 条件异常,不执行");
assertTrue(executedActions.contains("nodeB"), "B 条件正常,执行");
assertEquals(List.of("B"), matched);
}
/**
* 额外else 分支不在最后 → validate() 抛 IllegalArgumentException
*/
@Test
void testElseNotLast_validationFails() {
BranchConfiguration config = buildConfig(
elseBranch("else", "nodeElse"), // else 不在最后
branch("A", "expression", false, "nodeA")
);
assertThrows(IllegalArgumentException.class, config::validate,
"else 分支不在最后时validate() 应抛出异常");
}
// --- helpers ---
private BranchExecutor.ActionExecutor recordingExecutor() {
return (ctx, branchName, actionNodeId) -> executedActions.add(actionNodeId);
}
private BranchConfiguration buildConfig(BranchConfiguration.BranchItem... items) {
BranchConfiguration config = new BranchConfiguration();
config.setBranches(Arrays.asList(items));
return config;
}
private BranchConfiguration buildConfigWithTypes(BranchConfiguration.BranchItem... items) {
return buildConfig(items);
}
/** 带 condition 的分支condition.type = "expression" */
private BranchConfiguration.BranchItem branch(String name, String conditionType,
boolean executeAnyway, String... actionIds) {
return branchWithType(name, conditionType, executeAnyway, actionIds);
}
/** 带 condition 的分支condition.type 可自定义) */
private BranchConfiguration.BranchItem branchWithType(String name, String conditionType,
boolean executeAnyway, String... actionIds) {
BranchConfiguration.BranchItem item = new BranchConfiguration.BranchItem();
item.setName(name);
item.setExecuteAnyway(executeAnyway);
item.setActions(Arrays.asList(actionIds));
BranchConfiguration.BranchCondition condition = new BranchConfiguration.BranchCondition();
condition.setType(conditionType);
condition.setConfig(MAPPER.createObjectNode().put("expression", "dummy"));
item.setCondition(condition);
return item;
}
/** else 分支condition=null */
private BranchConfiguration.BranchItem elseBranch(String name, String... actionIds) {
BranchConfiguration.BranchItem item = new BranchConfiguration.BranchItem();
item.setName(name);
item.setCondition(null);
item.setActions(Arrays.asList(actionIds));
item.setExecuteAnyway(false);
return item;
}
}