feat(iot): 一期 Controller 补齐 (B2/B4-6/B10/B11/B12/B13)

对照前端 feat/iot-2.0 已固化 API 契约补齐 5 组缺失端点(发现于一期 19/19
宣称完成后前端联调阶段),归属原任务卡 Controller 层返工,不占用二期
B20+ 编号。

- B2  规则链: 补 PUT /disable /deploy /debug + POST /copy?id= +
              新增 GET /rule-chain/get?id= 返 GraphVO(保留 /get/{id})
              deployRuleChain=enable+主动 Pub/Sub evict(对齐 B8)
- B10 子系统: 新增 GET /device-count 聚合(HGETALL 返空 map 遵循 A6)
              + GET /get?id= query 别名(保留 /get/{id})
- B11 设备:   新增驼峰 PUT /bindSubsystem /batchBindSubsystem
              + 2 ReqVO,保留 kebab 兼容
- B12/B13 告警: 新增 IotAlarmRecordController(整缺)11 端点:
                page/get/ack/unack/clear/archive/batch-{ack,clear,archive}/
                history/remark;Service 补 6 方法(getPage/batchAck/
                batchClear/batchArchive/updateRemark/listHistory)
                + Mapper 2 方法 + 8 VO
- B4/5/6 节点元数据: 新增 GET /iot/rule/provider/metadata 聚合端点;
                    3 SPI 加 default getMetadata(),4 Manager 加
                    listAllMetadata(),13 具体 Provider 覆写(中文 label
                    + mdi: icon),schema MVP 空骨架 {rule:[]}

测试:
- iot-rule   191/191 全绿(+5 B2 补齐 +9 B4/5/6 补齐)
- iot-server 106 active/161 Skipped v1 遗产 全绿
            (+6 B12/B13 补齐 +3 B10 补齐)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 15:47:41 +08:00
parent 9912b73c56
commit 7dc00b542d
51 changed files with 2211 additions and 3 deletions

View File

@@ -1,10 +1,12 @@
package com.viewsh.module.iot.rule.action;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
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.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -60,4 +62,15 @@ public class ActionProviderManager {
public Map<String, ActionProvider> all() {
return Map.copyOf(providers);
}
/**
* 返回所有已注册 Action Provider 的元数据列表(供前端动态面板消费)。
*/
public List<ProviderMetadataVO> listAllMetadata() {
List<ProviderMetadataVO> result = new ArrayList<>(providers.size());
for (ActionProvider p : providers.values()) {
result.add(p.getMetadata());
}
return result;
}
}

View File

@@ -4,6 +4,7 @@ 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.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
@@ -39,6 +40,18 @@ public class AlarmClearAction implements ActionProvider {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("action")
.label("清除告警")
.icon("mdi:bell-off-outline")
.description("清除(恢复)已触发的告警记录")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {

View File

@@ -4,6 +4,7 @@ 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.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
@@ -46,6 +47,18 @@ public class AlarmTriggerAction implements ActionProvider {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("action")
.label("触发告警")
.icon("mdi:bell-outline")
.description("创建或更新告警记录")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {

View File

@@ -1,6 +1,7 @@
package com.viewsh.module.iot.rule.action;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
@@ -39,6 +40,18 @@ public class DevicePropertySetAction implements ActionProvider {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("action")
.label("设备属性设置")
.icon("mdi:tune-vertical")
.description("向设备下发属性设置指令")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {

View File

@@ -6,6 +6,7 @@ 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.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.result.ActionResult;
import com.viewsh.module.iot.rule.spi.ActionProvider;
@@ -45,6 +46,18 @@ public class DeviceServiceInvokeAction implements ActionProvider {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("action")
.label("设备服务调用")
.icon("mdi:cog-play")
.description("向设备下发服务调用指令")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {

View File

@@ -1,6 +1,7 @@
package com.viewsh.module.iot.rule.action;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.notify.NotifyDispatcher;
import com.viewsh.module.iot.rule.notify.model.NotifyResult;
@@ -74,6 +75,18 @@ public class NotifyAction implements ActionProvider {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("action")
.label("发送通知")
.icon("mdi:message-text-outline")
.description("通过短信、邮件、站内信、Webhook 等渠道发送通知")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public ActionResult executeAction(RuleContext ctx, JsonNode config) {
try {

View File

@@ -1,11 +1,13 @@
package com.viewsh.module.iot.rule.condition;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.spi.ConditionEvaluator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -65,4 +67,15 @@ public class ConditionEvaluatorManager {
public boolean supports(String type) {
return evaluatorsByType.containsKey(type);
}
/**
* Returns metadata for all registered condition evaluators (consumed by the frontend dynamic panel).
*/
public List<ProviderMetadataVO> listAllMetadata() {
List<ProviderMetadataVO> result = new ArrayList<>(evaluatorsByType.size());
for (ConditionEvaluator e : evaluatorsByType.values()) {
result.add(e.getMetadata());
}
return result;
}
}

View File

@@ -1,6 +1,7 @@
package com.viewsh.module.iot.rule.condition;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.spi.ConditionEvaluator;
import lombok.extern.slf4j.Slf4j;
@@ -46,6 +47,18 @@ public class DeviceStateConditionEvaluator implements ConditionEvaluator {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("condition")
.label("设备在线状态")
.icon("mdi:wifi")
.description("判断设备当前是否在线")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public boolean evaluate(RuleContext ctx, JsonNode config) {
String expectedState = config.path("state").asText("online").toLowerCase();

View File

@@ -5,6 +5,7 @@ import com.googlecode.aviator.AviatorEvaluator;
import com.googlecode.aviator.AviatorEvaluatorInstance;
import com.googlecode.aviator.Expression;
import com.googlecode.aviator.Options;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.spi.ConditionEvaluator;
import com.viewsh.module.iot.rule.template.TemplateResolver;
@@ -66,6 +67,18 @@ public class ExpressionConditionEvaluator implements ConditionEvaluator {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("condition")
.label("表达式条件")
.icon("mdi:code-braces")
.description("使用 Aviator 表达式对设备数据进行条件判断")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public boolean evaluate(RuleContext ctx, JsonNode config) {
String rawExpression = extractExpression(config);

View File

@@ -1,6 +1,7 @@
package com.viewsh.module.iot.rule.condition;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.spi.ConditionEvaluator;
import lombok.extern.slf4j.Slf4j;
@@ -53,6 +54,18 @@ public class TimeRangeConditionEvaluator implements ConditionEvaluator {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("condition")
.label("时间范围")
.icon("mdi:calendar-clock")
.description("判断当前时间是否在配置的时间段内(支持跨日、每周模式)")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public boolean evaluate(RuleContext ctx, JsonNode config) {
String mode = config.path("mode").asText("daily");

View File

@@ -0,0 +1,71 @@
package com.viewsh.module.iot.rule.controller.admin;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.rule.action.ActionProviderManager;
import com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.spi.TriggerProviderManager;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* Provider Metadata 端点B4/B5/B6 补齐)。
*
* <p>供前端动态属性面板F3消费返回所有已注册
* Trigger / Condition / Action Provider 的元数据列表。
*
* <p>端点:{@code GET /iot/rule/provider/metadata}
*
* <p>响应格式(对齐前端 {@code NodeTypeMeta}
* <pre>{@code
* [
* { "type": "device_property", "category": "trigger", "label": "设备属性上报",
* "icon": "mdi:tune", "description": "...", "schema": {"rule": []} },
* { "type": "expression", "category": "condition", ... },
* { "type": "alarm_trigger", "category": "action", ... },
* ...
* ]
* }</pre>
*/
@Tag(name = "管理后台 - IoT 规则节点 Provider 元数据")
@RestController
@RequestMapping("/iot/rule/provider")
@Validated
public class IotProviderMetadataController {
@Resource
private TriggerProviderManager triggerProviderManager;
@Resource
private ConditionEvaluatorManager conditionEvaluatorManager;
@Resource
private ActionProviderManager actionProviderManager;
/**
* 获取所有节点 Provider 元数据trigger + condition + action 三类聚合)。
*
* @return ProviderMetadataVO 列表,每条包含 type / category / label / icon / description / schema
*/
@GetMapping("/metadata")
@Operation(summary = "获取所有规则节点 Provider 元数据(供前端动态属性面板使用)")
@PreAuthorize("@ss.hasPermission('iot:rule:query')")
public CommonResult<List<ProviderMetadataVO>> listProviderMetadata() {
List<ProviderMetadataVO> result = new ArrayList<>();
result.addAll(triggerProviderManager.listAllMetadata());
result.addAll(conditionEvaluatorManager.listAllMetadata());
result.addAll(actionProviderManager.listAllMetadata());
return success(result);
}
}

View File

@@ -2,6 +2,7 @@ package com.viewsh.module.iot.rule.controller.admin;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.framework.common.pojo.PageResult;
import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainDebugReqVO;
import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainGraphVO;
import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainPageReqVO;
import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainRespVO;
@@ -16,6 +17,8 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import static com.viewsh.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - IoT 规则链")
@@ -52,15 +55,23 @@ public class IotRuleChainController {
}
@GetMapping("/get/{id}")
@Operation(summary = "获得规则链")
@Operation(summary = "获得规则链path 变量风格,向后兼容)")
@Parameter(name = "id", description = "规则链编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('iot:rule:query')")
public CommonResult<IotRuleChainRespVO> getRuleChain(@PathVariable("id") Long id) {
return success(ruleChainService.getRuleChain(id));
}
@GetMapping("/get")
@Operation(summary = "获得规则链图含节点和连线query 参数风格)")
@Parameter(name = "id", description = "规则链编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('iot:rule:query')")
public CommonResult<IotRuleChainGraphVO> getRuleChainById(@RequestParam("id") Long id) {
return success(ruleChainService.getRuleChainGraph(id));
}
@GetMapping("/graph/{id}")
@Operation(summary = "获得规则链图(含节点和连线)")
@Operation(summary = "获得规则链图(含节点和连线path 变量风格,向后兼容")
@Parameter(name = "id", description = "规则链编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('iot:rule:query')")
public CommonResult<IotRuleChainGraphVO> getRuleChainGraph(@PathVariable("id") Long id) {
@@ -83,4 +94,36 @@ public class IotRuleChainController {
return success(true);
}
@PutMapping("/disable")
@Operation(summary = "禁用规则链")
@PreAuthorize("@ss.hasPermission('iot:rule:update')")
public CommonResult<Boolean> disableRuleChain(@RequestBody Map<String, Long> body) {
ruleChainService.disableRuleChain(body.get("id"));
return success(true);
}
@PutMapping("/deploy")
@Operation(summary = "发布规则链(启用 + 主动驱逐缓存)")
@PreAuthorize("@ss.hasPermission('iot:rule:update')")
public CommonResult<Boolean> deployRuleChain(@RequestBody Map<String, Long> body) {
ruleChainService.deployRuleChain(body.get("id"));
return success(true);
}
@PutMapping("/debug")
@Operation(summary = "切换调试模式")
@PreAuthorize("@ss.hasPermission('iot:rule:update')")
public CommonResult<Boolean> toggleDebug(@Valid @RequestBody IotRuleChainDebugReqVO req) {
ruleChainService.toggleDebug(req.getId(), req.getEnabled());
return success(true);
}
@PostMapping("/copy")
@Operation(summary = "复制规则链")
@Parameter(name = "id", description = "源规则链编号", required = true)
@PreAuthorize("@ss.hasPermission('iot:rule:create')")
public CommonResult<Long> copyRuleChain(@RequestParam("id") Long id) {
return success(ruleChainService.copyRuleChain(id));
}
}

View File

@@ -0,0 +1,22 @@
package com.viewsh.module.iot.rule.controller.admin.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 管理后台 - IoT 规则链调试模式切换 Request VO
*/
@Schema(description = "管理后台 - IoT 规则链调试模式切换 Request VO")
@Data
public class IotRuleChainDebugReqVO {
@Schema(description = "规则链编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "规则链编号不能为空")
private Long id;
@Schema(description = "是否启用调试模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
@NotNull(message = "调试模式标志不能为空")
private Boolean enabled;
}

View File

@@ -0,0 +1,60 @@
package com.viewsh.module.iot.rule.controller.admin.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Collections;
import java.util.Map;
/**
* Provider Metadata VO — 供前端动态属性面板F3/F4/F5/F6消费。
*
* <p>端点GET /iot/rule/provider/metadata
*
* <p>对齐前端 {@code NodeTypeMeta} 类型约定:
* <ul>
* <li>{@code type} — Provider 类型标识,与 rule_node.type 一致</li>
* <li>{@code category} — "trigger" / "condition" / "action"</li>
* <li>{@code label} — 显示名(中文)</li>
* <li>{@code icon} — iconify 图标名(可选)</li>
* <li>{@code description} — 工具提示(可选)</li>
* <li>{@code schema} — form-create 配置MVP 阶段返回 {@code {"rule":[]}}</li>
* </ul>
*/
@Schema(description = "节点 Provider 元数据")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProviderMetadataVO {
@Schema(description = "Provider 类型标识,与 rule_node.type 一致", example = "device_property")
private String type;
@Schema(description = "节点类别trigger / condition / action", example = "trigger")
private String category;
@Schema(description = "节点显示名", example = "设备属性上报")
private String label;
@Schema(description = "iconify 图标名(可选)", example = "mdi:bell-outline")
private String icon;
@Schema(description = "工具提示(可选)")
private String description;
/**
* form-create 配置。MVP 阶段返回 {@code {"rule":[]}}
* 后续 F4/F5/F6 widget 就绪后由前端本地定义或后端补全。
*/
@Schema(description = "form-create schemaMVP 阶段为 {\"rule\":[]}")
private Map<String, Object> schema;
/** 构造空 schema{@code {"rule":[]}}),供 default getMetadata() 调用。 */
public static Map<String, Object> emptySchema() {
return Collections.singletonMap("rule", Collections.emptyList());
}
}

View File

@@ -1,5 +1,6 @@
package com.viewsh.module.iot.rule.engine;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory;
/**
@@ -27,4 +28,21 @@ public interface NodeProvider {
* 抛出 RuntimeException 将被 DagExecutor 转为 FAILURE 并交由链级 try-catch 兜底。
*/
NodeResult execute(RuleContext ctx, String config);
/**
* 返回该 Provider 的元数据(供前端动态属性面板 F3 消费)。
*
* <p>默认实现以 {@link #getCategory()} + {@link #getType()} 构造元数据schema 返回空骨架。
* 具体 Provider 可覆盖以提供更友好的 label / icon / description。
*/
default ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(getType())
.category(getCategory().getValue())
.label(getType())
.icon(null)
.description(null)
.schema(ProviderMetadataVO.emptySchema())
.build();
}
}

View File

@@ -1,9 +1,11 @@
package com.viewsh.module.iot.rule.engine;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory;
import com.viewsh.module.iot.rule.engine.exception.RuleChainException;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -42,6 +44,17 @@ public class NodeProviderRegistry {
return p;
}
/**
* 返回所有已注册节点 Provider 的元数据列表(供管理端口使用)。
*/
public List<ProviderMetadataVO> listAllMetadata() {
List<ProviderMetadataVO> result = new ArrayList<>(providersByKey.size());
for (NodeProvider p : providersByKey.values()) {
result.add(p.getMetadata());
}
return result;
}
private static String key(RuleNodeCategory category, String type) {
return category.getValue() + ":" + type;
}

View File

@@ -51,6 +51,29 @@ public interface IotRuleChainService {
*/
void disableRuleChain(Long id);
/**
* 发布规则链到运行时enable + 主动驱逐缓存,令所有实例重载)
*
* @param id 规则链编号
*/
void deployRuleChain(Long id);
/**
* 切换调试模式
*
* @param id 规则链编号
* @param enabled true=开启调试false=关闭调试
*/
void toggleDebug(Long id, boolean enabled);
/**
* 复制规则链(深拷 chain + nodes + links名称追加 "_copy"status=disabledversion=0
*
* @param id 源规则链编号
* @return 新规则链编号
*/
Long copyRuleChain(Long id);
/**
* 获得规则链基础信息
*

View File

@@ -173,6 +173,106 @@ public class IotRuleChainServiceImpl implements IotRuleChainService {
ruleChainMapper.updateById(update);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deployRuleChain(Long id) {
IotRuleChainDO chain = validateRuleChainExists(id);
// 1. 确保链处于启用状态
if (!RuleChainStatus.ENABLED.getValue().equals(chain.getStatus())) {
IotRuleChainDO update = new IotRuleChainDO();
update.setId(id);
update.setStatus(RuleChainStatus.ENABLED.getValue());
ruleChainMapper.updateById(update);
}
// 2. 主动发布 Pub/Sub 驱逐事件,令所有实例重载缓存
if (redisTemplate != null) {
try {
Map<String, Object> event = new LinkedHashMap<>();
event.put("chainId", id);
event.put("tenantId", TenantContextHolder.getTenantId());
event.put("version", chain.getVersion());
redisTemplate.convertAndSend(RuleChainCache.EVICT_CHANNEL, JsonUtils.toJsonString(event));
} catch (Exception e) {
log.warn("[IotRuleChainServiceImpl] deploy 发布缓存驱逐事件失败 chainId={}", id, e);
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void toggleDebug(Long id, boolean enabled) {
validateRuleChainExists(id);
IotRuleChainDO update = new IotRuleChainDO();
update.setId(id);
update.setDebugMode(enabled);
ruleChainMapper.updateById(update);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long copyRuleChain(Long id) {
// 1. 查询原链
IotRuleChainDO original = validateRuleChainExists(id);
// 2. 深拷 chainid/create_time 置空,让 DB 生成)
IotRuleChainDO copy = new IotRuleChainDO();
copy.setName(original.getName() + "_copy");
copy.setDescription(original.getDescription());
copy.setType(original.getType());
copy.setStatus(RuleChainStatus.DISABLED.getValue());
copy.setPriority(original.getPriority());
copy.setVersion(0L);
copy.setDebugMode(original.getDebugMode());
copy.setSubsystemId(original.getSubsystemId());
copy.setProductId(original.getProductId());
copy.setDeviceId(original.getDeviceId());
ruleChainMapper.insert(copy);
Long newChainId = copy.getId();
Long tenantId = copy.getTenantId();
// 3. 深拷 nodes建立 old_id → new_id 映射
List<IotRuleNodeDO> oldNodes = ruleNodeMapper.selectByChainId(id);
Map<Long, Long> nodeIdMap = new HashMap<>();
if (!oldNodes.isEmpty()) {
List<IotRuleNodeDO> newNodes = oldNodes.stream()
.map(n -> IotRuleNodeDO.builder()
.ruleChainId(newChainId)
.name(n.getName())
.category(n.getCategory())
.type(n.getType())
.configuration(n.getConfiguration())
.positionX(n.getPositionX())
.positionY(n.getPositionY())
.tenantId(tenantId)
.build())
.collect(Collectors.toList());
ruleNodeMapper.insertBatch(newNodes);
// 建立映射新节点是按原顺序插入的id 由 insertBatch 回填
for (int i = 0; i < oldNodes.size(); i++) {
nodeIdMap.put(oldNodes.get(i).getId(), newNodes.get(i).getId());
}
}
// 4. 深拷 links用 nodeIdMap 重写 sourceNodeId/targetNodeId
List<IotRuleLinkDO> oldLinks = ruleLinkMapper.selectByChainId(id);
if (!oldLinks.isEmpty()) {
List<IotRuleLinkDO> newLinks = oldLinks.stream()
.map(l -> IotRuleLinkDO.builder()
.ruleChainId(newChainId)
.sourceNodeId(nodeIdMap.getOrDefault(l.getSourceNodeId(), l.getSourceNodeId()))
.targetNodeId(nodeIdMap.getOrDefault(l.getTargetNodeId(), l.getTargetNodeId()))
.relationType(l.getRelationType())
.condition(l.getCondition())
.sortOrder(l.getSortOrder())
.tenantId(tenantId)
.build())
.collect(Collectors.toList());
ruleLinkMapper.insertBatch(newLinks);
}
return newChainId;
}
@Override
public IotRuleChainRespVO getRuleChain(Long id) {
IotRuleChainDO chain = validateRuleChainExists(id);

View File

@@ -1,6 +1,7 @@
package com.viewsh.module.iot.rule.spi;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
/**
@@ -33,4 +34,21 @@ public interface ConditionEvaluator {
* @return true = condition met, false = not met
*/
boolean evaluate(RuleContext ctx, JsonNode config);
/**
* Returns metadata for this condition evaluator (consumed by the frontend dynamic panel F3).
*
* <p>Default implementation uses {@link #getType()} as label with empty schema skeleton.
* Concrete evaluators may override to provide friendlier label / icon / description.
*/
default ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(getType())
.category("condition")
.label(getType())
.icon(null)
.description(null)
.schema(ProviderMetadataVO.emptySchema())
.build();
}
}

View File

@@ -2,6 +2,7 @@ package com.viewsh.module.iot.rule.spi;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.CompiledRuleChain;
import com.viewsh.module.iot.rule.engine.RuleContext;
@@ -52,4 +53,22 @@ public interface TriggerProvider {
default void unregister(CompiledRuleChain chain) {
// 默认无操作
}
/**
* 返回该 Provider 的元数据(供前端动态属性面板 F3 消费)。
*
* <p>默认实现以 {@link #getType()} 作为 labelicon/description 留空,
* schema 返回空骨架 {@code {"rule":[]}}。
* 具体 Provider 可覆盖以提供更友好的 label / icon / description。
*/
default ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(getType())
.category("trigger")
.label(getType())
.icon(null)
.description(null)
.schema(ProviderMetadataVO.emptySchema())
.build();
}
}

View File

@@ -1,10 +1,12 @@
package com.viewsh.module.iot.rule.spi;
import com.viewsh.framework.common.exception.ServiceException;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -72,4 +74,15 @@ public class TriggerProviderManager {
public boolean contains(String type) {
return providers.containsKey(type);
}
/**
* 返回所有已注册触发器 Provider 的元数据列表(供前端动态面板消费)。
*/
public List<ProviderMetadataVO> listAllMetadata() {
List<ProviderMetadataVO> result = new ArrayList<>(providers.size());
for (TriggerProvider p : providers.values()) {
result.add(p.getMetadata());
}
return result;
}
}

View File

@@ -3,6 +3,7 @@ package com.viewsh.module.iot.rule.trigger;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum;
import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.spi.TriggerProvider;
import lombok.extern.slf4j.Slf4j;
@@ -46,6 +47,18 @@ public class DeviceEventTriggerProvider implements TriggerProvider {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("trigger")
.label("设备事件上报")
.icon("mdi:bell-ring-outline")
.description("当设备上报指定事件时触发规则链")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) {
// 1. method 必须是 thing.event.post

View File

@@ -3,6 +3,7 @@ package com.viewsh.module.iot.rule.trigger;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum;
import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.spi.TriggerProvider;
import lombok.extern.slf4j.Slf4j;
@@ -42,6 +43,18 @@ public class DevicePropertyTriggerProvider implements TriggerProvider {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("trigger")
.label("设备属性上报")
.icon("mdi:tune")
.description("当设备上报指定属性时触发规则链")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) {
// 1. method 必须是 thing.property.post

View File

@@ -3,6 +3,7 @@ package com.viewsh.module.iot.rule.trigger;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum;
import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.spi.TriggerProvider;
import lombok.extern.slf4j.Slf4j;
@@ -46,6 +47,18 @@ public class DeviceServiceTriggerProvider implements TriggerProvider {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("trigger")
.label("设备服务调用回复")
.icon("mdi:cog-play-outline")
.description("当设备服务调用回复时触发规则链")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) {
// 1. method 必须是 thing.service.invoke

View File

@@ -3,6 +3,7 @@ package com.viewsh.module.iot.rule.trigger;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum;
import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.spi.TriggerProvider;
import lombok.extern.slf4j.Slf4j;
@@ -41,6 +42,18 @@ public class DeviceStateTriggerProvider implements TriggerProvider {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("trigger")
.label("设备状态变化")
.icon("mdi:access-point")
.description("当设备上下线状态发生变化时触发规则链")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
@Override
public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) {
// 1. method 必须是 thing.state.update

View File

@@ -2,6 +2,7 @@ package com.viewsh.module.iot.rule.trigger;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.engine.CompiledRuleChain;
import com.viewsh.module.iot.rule.engine.RuleContext;
import com.viewsh.module.iot.rule.spi.TriggerProvider;
@@ -64,6 +65,18 @@ public class TimerTriggerProvider implements TriggerProvider, DisposableBean {
return TYPE;
}
@Override
public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(TYPE)
.category("trigger")
.label("定时触发")
.icon("mdi:clock-outline")
.description("按 CRON 表达式定时触发规则链")
.schema(ProviderMetadataVO.emptySchema())
.build();
}
/**
* Timer 触发器不通过消息匹配触发,始终返回 false。
* Timer 触发走 CRON 调度回调路径。

View File

@@ -0,0 +1,215 @@
package com.viewsh.module.iot.rule.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.viewsh.module.iot.rule.action.ActionProviderManager;
import com.viewsh.module.iot.rule.action.AlarmTriggerAction;
import com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager;
import com.viewsh.module.iot.rule.condition.TimeRangeConditionEvaluator;
import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO;
import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory;
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.spi.ActionProvider;
import com.viewsh.module.iot.rule.spi.ConditionEvaluator;
import com.viewsh.module.iot.rule.spi.TriggerProvider;
import com.viewsh.module.iot.rule.spi.TriggerProviderManager;
import com.viewsh.module.iot.rule.trigger.DeviceEventTriggerProvider;
import com.viewsh.module.iot.rule.trigger.DevicePropertyTriggerProvider;
import com.viewsh.module.iot.rule.trigger.DeviceServiceTriggerProvider;
import com.viewsh.module.iot.rule.trigger.DeviceStateTriggerProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
/**
* B4/B5/B6 Provider Metadata 端点单元测试。
*
* <p>测试内容:
* <ol>
* <li>TriggerProviderManager.listAllMetadata() 返回 N 条category 全为 "trigger"</li>
* <li>ConditionEvaluatorManager.listAllMetadata() 返回 N 条category 全为 "condition"</li>
* <li>ActionProviderManager.listAllMetadata() 返回 N 条category 全为 "action"</li>
* <li>各具体 Provider 的 getMetadata() label / icon 非空</li>
* <li>ProviderMetadataVO.emptySchema() 返回包含 "rule" key 的 Map</li>
* </ol>
*/
class ProviderMetadataTest {
private TriggerProviderManager triggerProviderManager;
private ConditionEvaluatorManager conditionEvaluatorManager;
private ActionProviderManager actionProviderManager;
@BeforeEach
void setUp() {
// ---- Trigger providers ----
triggerProviderManager = new TriggerProviderManager();
List<TriggerProvider> triggers = List.of(
new DevicePropertyTriggerProvider(),
new DeviceEventTriggerProvider(),
new DeviceStateTriggerProvider(),
new DeviceServiceTriggerProvider()
// TimerTriggerProvider requires TaskScheduler, excluded from unit test
);
triggerProviderManager.autoRegister(triggers);
// ---- Condition evaluators — use anonymous stubs to avoid infra deps ----
List<ConditionEvaluator> conditions = List.of(
new TimeRangeConditionEvaluator(),
stubCondition("expression", "表达式条件", "mdi:code-braces"),
stubCondition("device_state", "设备在线状态", "mdi:wifi")
);
conditionEvaluatorManager = new ConditionEvaluatorManager(conditions);
// ---- Action providers — use anonymous stubs to avoid infra deps ----
actionProviderManager = new ActionProviderManager();
List<ActionProvider> actions = List.of(
stubAction("alarm_trigger", "触发告警", "mdi:bell-outline"),
stubAction("alarm_clear", "清除告警", "mdi:bell-off-outline"),
stubAction("notify", "发送通知", "mdi:message-text-outline"),
stubAction("device_property_set", "设备属性设置", "mdi:tune-vertical"),
stubAction("device_service_invoke", "设备服务调用", "mdi:cog-play")
);
actionProviderManager.autoRegister(actions);
}
/** Create a minimal ConditionEvaluator stub with given type/label/icon metadata. */
private static ConditionEvaluator stubCondition(String type, String label, String icon) {
return new ConditionEvaluator() {
@Override public String getType() { return type; }
@Override public boolean evaluate(RuleContext ctx, JsonNode config) { return false; }
@Override public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(type).category("condition").label(label).icon(icon)
.schema(ProviderMetadataVO.emptySchema()).build();
}
};
}
/** Create a minimal ActionProvider stub with given type/label/icon metadata. */
private static ActionProvider stubAction(String type, String label, String icon) {
return new ActionProvider() {
@Override public String getType() { return type; }
@Override public ActionResult executeAction(RuleContext ctx, JsonNode config) {
return ActionResult.success();
}
@Override public NodeResult execute(RuleContext ctx, String config) {
return NodeResult.of(com.viewsh.module.iot.rule.dal.dataobject.enums.RuleLinkRelationType.SUCCESS, java.util.Map.of());
}
@Override public RuleNodeCategory getCategory() { return RuleNodeCategory.ACTION; }
@Override public ProviderMetadataVO getMetadata() {
return ProviderMetadataVO.builder()
.type(type).category("action").label(label).icon(icon)
.schema(ProviderMetadataVO.emptySchema()).build();
}
};
}
// ======================== ProviderMetadataVO.emptySchema ========================
@Test
void emptySchema_containsRuleKey() {
var schema = ProviderMetadataVO.emptySchema();
assertNotNull(schema);
assertTrue(schema.containsKey("rule"), "schema must contain 'rule' key");
}
// ======================== Trigger metadata ========================
@Test
void triggerManager_listAllMetadata_returnsTriggerCategory() {
List<ProviderMetadataVO> list = triggerProviderManager.listAllMetadata();
assertFalse(list.isEmpty(), "Should return at least one trigger metadata");
for (ProviderMetadataVO vo : list) {
assertEquals("trigger", vo.getCategory(), "All trigger metadata must have category=trigger");
assertNotNull(vo.getType(), "type must not be null");
assertNotNull(vo.getLabel(), "label must not be null");
assertNotNull(vo.getSchema(), "schema must not be null");
}
}
@Test
void devicePropertyTrigger_metadata_hasExpectedValues() {
ProviderMetadataVO meta = new DevicePropertyTriggerProvider().getMetadata();
assertEquals("device_property", meta.getType());
assertEquals("trigger", meta.getCategory());
assertNotNull(meta.getLabel());
assertFalse(meta.getLabel().isBlank());
assertNotNull(meta.getIcon());
assertFalse(meta.getIcon().isBlank());
}
@Test
void timerTrigger_metadata_hasCorrectType() {
// Only test metadata via default interface method pattern, no scheduler needed
TriggerProvider stubTimer = new TriggerProvider() {
@Override
public String getType() { return "timer"; }
@Override
public boolean matches(com.viewsh.module.iot.core.mq.message.IotDeviceMessage msg,
com.fasterxml.jackson.databind.JsonNode config,
com.viewsh.module.iot.rule.engine.RuleContext ctx) { return false; }
};
ProviderMetadataVO meta = stubTimer.getMetadata();
assertEquals("timer", meta.getType());
assertEquals("trigger", meta.getCategory());
}
// ======================== Condition metadata ========================
@Test
void conditionManager_listAllMetadata_returnsConditionCategory() {
List<ProviderMetadataVO> list = conditionEvaluatorManager.listAllMetadata();
assertFalse(list.isEmpty(), "Should return at least one condition metadata");
for (ProviderMetadataVO vo : list) {
assertEquals("condition", vo.getCategory(), "All condition metadata must have category=condition");
}
}
@Test
void timeRangeCondition_metadata_hasExpectedValues() {
ProviderMetadataVO meta = new TimeRangeConditionEvaluator().getMetadata();
assertEquals("time_range", meta.getType());
assertEquals("condition", meta.getCategory());
assertNotNull(meta.getLabel());
assertFalse(meta.getLabel().isBlank());
}
// ======================== Action metadata ========================
@Test
void actionManager_listAllMetadata_returnsActionCategory() {
List<ProviderMetadataVO> list = actionProviderManager.listAllMetadata();
assertFalse(list.isEmpty(), "Should return at least one action metadata");
for (ProviderMetadataVO vo : list) {
assertEquals("action", vo.getCategory(), "All action metadata must have category=action");
assertNotNull(vo.getType(), "type must not be null");
assertNotNull(vo.getLabel(), "label must not be null");
assertFalse(vo.getLabel().isBlank(), "label must not be blank");
assertNotNull(vo.getSchema(), "schema must not be null");
}
}
@Test
void alarmTriggerAction_typeConstant_isConsistent() {
// Verify TYPE constant aligns with expected contract
assertEquals("alarm_trigger", AlarmTriggerAction.TYPE);
}
// ======================== Aggregated list coverage ========================
@Test
void allTriggerTypes_arePresent() {
List<ProviderMetadataVO> list = triggerProviderManager.listAllMetadata();
Set<String> types = list.stream().map(ProviderMetadataVO::getType).collect(Collectors.toSet());
assertTrue(types.contains("device_property"));
assertTrue(types.contains("device_event"));
assertTrue(types.contains("device_state"));
assertTrue(types.contains("device_service"));
}
}

View File

@@ -15,7 +15,10 @@ import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@@ -269,6 +272,161 @@ class IotRuleChainServiceImplTest extends BaseMockitoUnitTest {
verify(ruleLinkMapper, times(1)).deleteByChainId(chainId);
}
// ========== 用例 9disableRuleChain 幂等(已禁用不更新)==========
@Test
void testDisable_idempotent() {
Long chainId = 500L;
IotRuleChainDO chain = IotRuleChainDO.builder()
.id(chainId)
.name("DisabledChain")
.type("SCENE")
.status(RuleChainStatus.DISABLED.getValue())
.version(0L)
.tenantId(1L)
.build();
when(ruleChainMapper.selectById(chainId)).thenReturn(chain);
ruleChainService.disableRuleChain(chainId);
// 已禁用,不应调用 updateById
verify(ruleChainMapper, never()).updateById(any(IotRuleChainDO.class));
}
// ========== 用例 10disableRuleChain 正常禁用(从 enabled 到 disabled==========
@Test
void testDisable_fromEnabled() {
Long chainId = 501L;
IotRuleChainDO chain = IotRuleChainDO.builder()
.id(chainId)
.name("EnabledChain")
.type("SCENE")
.status(RuleChainStatus.ENABLED.getValue())
.version(1L)
.tenantId(1L)
.build();
when(ruleChainMapper.selectById(chainId)).thenReturn(chain);
when(ruleChainMapper.updateById(any(IotRuleChainDO.class))).thenReturn(1);
ruleChainService.disableRuleChain(chainId);
ArgumentCaptor<IotRuleChainDO> disableCaptor = ArgumentCaptor.forClass(IotRuleChainDO.class);
verify(ruleChainMapper, times(1)).updateById(disableCaptor.capture());
assertEquals(RuleChainStatus.DISABLED.getValue(), disableCaptor.getValue().getStatus());
}
// ========== 用例 11toggleDebug 开启调试模式 ==========
@Test
void testToggleDebug_enable() {
Long chainId = 600L;
IotRuleChainDO chain = IotRuleChainDO.builder()
.id(chainId)
.name("DebugChain")
.type("SCENE")
.status(RuleChainStatus.ENABLED.getValue())
.version(0L)
.debugMode(false)
.tenantId(1L)
.build();
when(ruleChainMapper.selectById(chainId)).thenReturn(chain);
when(ruleChainMapper.updateById(any(IotRuleChainDO.class))).thenReturn(1);
ruleChainService.toggleDebug(chainId, true);
ArgumentCaptor<IotRuleChainDO> debugCaptor = ArgumentCaptor.forClass(IotRuleChainDO.class);
verify(ruleChainMapper, times(1)).updateById(debugCaptor.capture());
assertTrue(debugCaptor.getValue().getDebugMode());
}
// ========== 用例 12copyRuleChain 深拷节点和连线 ==========
@Test
void testCopy_deepCopy() {
Long originalId = 700L;
Long newId = 701L;
IotRuleChainDO original = IotRuleChainDO.builder()
.id(originalId)
.name("OriginalChain")
.type("SCENE")
.status(RuleChainStatus.ENABLED.getValue())
.version(2L)
.priority(100)
.debugMode(false)
.tenantId(1L)
.build();
IotRuleNodeDO node1 = IotRuleNodeDO.builder()
.id(10L).ruleChainId(originalId).category("trigger").type("device_event").configuration("{}").tenantId(1L).build();
IotRuleNodeDO node2 = IotRuleNodeDO.builder()
.id(11L).ruleChainId(originalId).category("action").type("send_notification").configuration("{}").tenantId(1L).build();
IotRuleLinkDO link1 = IotRuleLinkDO.builder()
.id(20L).ruleChainId(originalId).sourceNodeId(10L).targetNodeId(11L).relationType("Success").sortOrder(0).tenantId(1L).build();
when(ruleChainMapper.selectById(originalId)).thenReturn(original);
when(ruleChainMapper.insert(any(IotRuleChainDO.class))).thenAnswer(inv -> {
IotRuleChainDO c = inv.getArgument(0);
c.setId(newId);
c.setTenantId(1L);
return 1;
});
when(ruleNodeMapper.selectByChainId(originalId)).thenReturn(Arrays.asList(node1, node2));
when(ruleNodeMapper.insertBatch(anyCollection())).thenAnswer(inv -> {
// 模拟 MyBatis Plus saveBatch 回填 ID
Collection<IotRuleNodeDO> nodes = inv.getArgument(0);
long idCounter = 100L;
for (IotRuleNodeDO n : nodes) {
n.setId(idCounter++);
}
return true;
});
when(ruleLinkMapper.selectByChainId(originalId)).thenReturn(Collections.singletonList(link1));
when(ruleLinkMapper.insertBatch(anyCollection())).thenReturn(true);
Long result = ruleChainService.copyRuleChain(originalId);
assertEquals(newId, result);
// 验证新 chain 名称追加了 _copy状态为 DISABLED版本 0
ArgumentCaptor<IotRuleChainDO> copyCaptor = ArgumentCaptor.forClass(IotRuleChainDO.class);
verify(ruleChainMapper, times(1)).insert(copyCaptor.capture());
assertEquals("OriginalChain_copy", copyCaptor.getValue().getName());
assertEquals(RuleChainStatus.DISABLED.getValue(), copyCaptor.getValue().getStatus());
assertEquals(Long.valueOf(0L), copyCaptor.getValue().getVersion());
verify(ruleNodeMapper, times(1)).insertBatch(anyCollection());
verify(ruleLinkMapper, times(1)).insertBatch(anyCollection());
}
// ========== 用例 13deployRuleChain 启用 + 无 Redis 不抛异常 ==========
@Test
void testDeploy_enablesAndSkipsRedisWhenNull() {
Long chainId = 800L;
IotRuleChainDO chain = IotRuleChainDO.builder()
.id(chainId)
.name("DeployChain")
.type("SCENE")
.status(RuleChainStatus.DISABLED.getValue())
.version(0L)
.tenantId(1L)
.build();
when(ruleChainMapper.selectById(chainId)).thenReturn(chain);
when(ruleChainMapper.updateById(any(IotRuleChainDO.class))).thenReturn(1);
// redisTemplate 为 null@Autowired required=falsemock 环境未注入)
assertDoesNotThrow(() -> ruleChainService.deployRuleChain(chainId));
// 验证状态从 DISABLED 变为 ENABLED
ArgumentCaptor<IotRuleChainDO> deployCaptor = ArgumentCaptor.forClass(IotRuleChainDO.class);
verify(ruleChainMapper, times(1)).updateById(deployCaptor.capture());
assertEquals(RuleChainStatus.ENABLED.getValue(), deployCaptor.getValue().getStatus());
}
// ========== 构建辅助方法 ==========
private IotRuleChainSaveReqVO buildCreateReqVO(IotRuleChainSaveReqVO.NodeVO... nodes) {