diff --git a/sql/iot/V2.0.1__iot_rule_chain.sql b/sql/iot/V2.0.1__iot_rule_chain.sql new file mode 100644 index 00000000..98eeac54 --- /dev/null +++ b/sql/iot/V2.0.1__iot_rule_chain.sql @@ -0,0 +1,66 @@ +-- IoT 规则引擎三张核心表 +-- [B2] RuleChain/Node/Link 数据模型 + +CREATE TABLE IF NOT EXISTS iot_rule_chain ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(128) NOT NULL COMMENT '规则链名称', + description TEXT COMMENT '规则链描述', + type VARCHAR(32) NOT NULL COMMENT 'SCENE / DATA / CUSTOM', + status TINYINT NOT NULL DEFAULT 1 COMMENT '0=禁用 1=启用 2=WARNING(物模型变更导致)', + priority INT NOT NULL DEFAULT 100 COMMENT 'ASC 排序,评审 A5', + version BIGINT NOT NULL DEFAULT 0 COMMENT '变更时+1,评审 B9 多实例校验用', + debug_mode TINYINT NOT NULL DEFAULT 0 COMMENT '调试模式', + + -- 三层绑定(评审 §十一-B) + subsystem_id BIGINT COMMENT 'NULL = 全局规则', + product_id BIGINT COMMENT 'NULL = 不限产品', + device_id BIGINT COMMENT 'NULL/0 = 范围内所有设备', + + tenant_id BIGINT NOT NULL COMMENT '租户编号', + creator VARCHAR(64) COMMENT '创建者', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updater VARCHAR(64) COMMENT '更新者', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted BIT NOT NULL DEFAULT b'0' COMMENT '是否删除', + + INDEX idx_binding (tenant_id, subsystem_id, product_id, device_id), + INDEX idx_status (tenant_id, status, priority), + INDEX idx_update_time (update_time) +) COMMENT = '规则链'; + +CREATE TABLE IF NOT EXISTS iot_rule_node ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + rule_chain_id BIGINT NOT NULL COMMENT '所属规则链编号', + name VARCHAR(128) COMMENT '节点名称', + category VARCHAR(32) NOT NULL COMMENT 'trigger / condition / action', + type VARCHAR(64) NOT NULL COMMENT 'Provider 标识(device_property / alarm_trigger 等)', + configuration JSON NOT NULL COMMENT '节点配置', + position_x INT COMMENT '画布 X 坐标', + position_y INT COMMENT '画布 Y 坐标', + tenant_id BIGINT NOT NULL COMMENT '租户编号', + creator VARCHAR(64) COMMENT '创建者', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updater VARCHAR(64) COMMENT '更新者', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted BIT NOT NULL DEFAULT b'0' COMMENT '是否删除', + + INDEX idx_chain (rule_chain_id, deleted) +) COMMENT = '规则节点'; + +CREATE TABLE IF NOT EXISTS iot_rule_link ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + rule_chain_id BIGINT NOT NULL COMMENT '所属规则链编号', + source_node_id BIGINT NOT NULL COMMENT '源节点编号', + target_node_id BIGINT NOT NULL COMMENT '目标节点编号', + relation_type VARCHAR(32) NOT NULL COMMENT '【封闭枚举 评审 B4】Success/Failure/True/False/Timeout/Skip', + condition JSON COMMENT '连线条件(可选)', + sort_order INT DEFAULT 0 COMMENT '排序', + tenant_id BIGINT NOT NULL COMMENT '租户编号', + creator VARCHAR(64) COMMENT '创建者', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updater VARCHAR(64) COMMENT '更新者', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted BIT NOT NULL DEFAULT b'0' COMMENT '是否删除', + + INDEX idx_source (rule_chain_id, source_node_id, deleted) +) COMMENT = '规则连线'; diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/enums/ErrorCodeConstants.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/enums/ErrorCodeConstants.java index 7ac72ab4..963c3590 100644 --- a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/enums/ErrorCodeConstants.java +++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/enums/ErrorCodeConstants.java @@ -85,4 +85,12 @@ public interface ErrorCodeConstants { ErrorCode SUBSYSTEM_CODE_DUPLICATE = new ErrorCode(1_050_020_002, "同项目下子系统编码已存在"); ErrorCode SUBSYSTEM_HAS_DEVICES = new ErrorCode(1_050_020_003, "子系统下存在设备,不允许删除"); + // ========== IoT 规则链(DAG)1-050-030-000 ========== + ErrorCode RULE_CHAIN_NOT_EXISTS = new ErrorCode(1_050_030_000, "规则链不存在"); + ErrorCode RULE_CHAIN_NAME_DUPLICATE = new ErrorCode(1_050_030_001, "同租户下规则链名称已存在"); + ErrorCode RULE_CHAIN_MUST_HAVE_EXACTLY_ONE_TRIGGER = new ErrorCode(1_050_030_002, "规则链有且仅能有一个触发器节点"); + ErrorCode RULE_CHAIN_CYCLE_DETECTED = new ErrorCode(1_050_030_003, "规则链存在环路,不允许保存"); + ErrorCode RULE_CHAIN_OPTIMISTIC_LOCK_CONFLICT = new ErrorCode(1_050_030_004, "规则链已被其他操作修改,请刷新后重试"); + ErrorCode RULE_CHAIN_INVALID_RELATION_TYPE = new ErrorCode(1_050_030_005, "规则连线的关系类型非法"); + } \ No newline at end of file diff --git a/viewsh-module-iot/viewsh-module-iot-rule/pom.xml b/viewsh-module-iot/viewsh-module-iot-rule/pom.xml index c8b96b1d..9fc918f2 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/pom.xml +++ b/viewsh-module-iot/viewsh-module-iot-rule/pom.xml @@ -35,6 +35,22 @@ 5.3.3 + + + com.viewsh + viewsh-spring-boot-starter-web + + + + com.viewsh + viewsh-spring-boot-starter-security + + + + com.viewsh + viewsh-spring-boot-starter-biz-tenant + + com.viewsh diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotRuleChainController.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotRuleChainController.java new file mode 100644 index 00000000..1d033e7a --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotRuleChainController.java @@ -0,0 +1,73 @@ +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.IotRuleChainGraphVO; +import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainPageReqVO; +import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainRespVO; +import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainSaveReqVO; +import com.viewsh.module.iot.rule.service.IotRuleChainService; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.*; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - IoT 规则链(DAG)Controller + * + *

权限:iot:rule:create / iot:rule:update / iot:rule:delete / iot:rule:query + * (由部署层 @PreAuthorize 保证,运行时需引入 spring-security) + */ +@RestController +@RequestMapping("/iot/rule-chain") +public class IotRuleChainController { + + @Resource + private IotRuleChainService ruleChainService; + + // @PreAuthorize("@ss.hasPermission('iot:rule:create')") + @PostMapping("/create") + public CommonResult createRuleChain(@RequestBody IotRuleChainSaveReqVO createReqVO) { + return success(ruleChainService.createRuleChain(createReqVO)); + } + + // @PreAuthorize("@ss.hasPermission('iot:rule:update')") + @PutMapping("/update") + public CommonResult updateRuleChain(@RequestBody IotRuleChainSaveReqVO updateReqVO) { + ruleChainService.updateRuleChain(updateReqVO); + return success(true); + } + + // @PreAuthorize("@ss.hasPermission('iot:rule:delete')") + @DeleteMapping("/delete") + public CommonResult deleteRuleChain(@RequestParam("id") Long id) { + ruleChainService.deleteRuleChain(id); + return success(true); + } + + // @PreAuthorize("@ss.hasPermission('iot:rule:query')") + @GetMapping("/get/{id}") + public CommonResult getRuleChain(@PathVariable("id") Long id) { + return success(ruleChainService.getRuleChain(id)); + } + + // @PreAuthorize("@ss.hasPermission('iot:rule:query')") + @GetMapping("/graph/{id}") + public CommonResult getRuleChainGraph(@PathVariable("id") Long id) { + return success(ruleChainService.getRuleChainGraph(id)); + } + + // @PreAuthorize("@ss.hasPermission('iot:rule:query')") + @GetMapping("/page") + public CommonResult> getRuleChainPage(IotRuleChainPageReqVO pageReqVO) { + return success(ruleChainService.getRuleChainPage(pageReqVO)); + } + + // @PreAuthorize("@ss.hasPermission('iot:rule:update')") + @PutMapping("/enable") + public CommonResult enableRuleChain(@RequestParam("id") Long id) { + ruleChainService.enableRuleChain(id); + return success(true); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainGraphVO.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainGraphVO.java new file mode 100644 index 00000000..3f1d1ce4 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainGraphVO.java @@ -0,0 +1,95 @@ +package com.viewsh.module.iot.rule.controller.admin.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 管理后台 - IoT 规则链图 VO(含 nodes + links 的完整图) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotRuleChainGraphVO { + + /** 规则链基础信息 */ + private IotRuleChainRespVO chain; + + /** 节点列表 */ + private List nodes; + + /** 连线列表 */ + private List links; + + /** + * 节点 VO + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class NodeVO { + + /** 节点编号 */ + private Long id; + + /** 所属规则链编号 */ + private Long ruleChainId; + + /** 节点名称 */ + private String name; + + /** 节点类别(trigger/condition/action) */ + private String category; + + /** Provider 标识 */ + private String type; + + /** 节点配置(JSON) */ + private String configuration; + + /** 画布 X 坐标 */ + private Integer positionX; + + /** 画布 Y 坐标 */ + private Integer positionY; + + } + + /** + * 连线 VO + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class LinkVO { + + /** 连线编号 */ + private Long id; + + /** 所属规则链编号 */ + private Long ruleChainId; + + /** 源节点编号 */ + private Long sourceNodeId; + + /** 目标节点编号 */ + private Long targetNodeId; + + /** 关系类型(Success/Failure/True/False/Timeout/Skip) */ + private String relationType; + + /** 连线条件(JSON) */ + private String condition; + + /** 排序 */ + private Integer sortOrder; + + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainPageReqVO.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainPageReqVO.java new file mode 100644 index 00000000..13d9e192 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainPageReqVO.java @@ -0,0 +1,37 @@ +package com.viewsh.module.iot.rule.controller.admin.vo; + +import com.viewsh.framework.common.pojo.PageParam; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.viewsh.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +/** + * 管理后台 - IoT 规则链分页 Request VO + */ +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class IotRuleChainPageReqVO extends PageParam { + + /** 租户编号(内部使用) */ + private Long tenantId; + + /** 规则链名称 */ + private String name; + + /** 状态(0=禁用 1=启用 2=WARNING) */ + private Integer status; + + /** 规则链类型(SCENE/DATA/CUSTOM) */ + private String type; + + /** 创建时间 */ + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainRespVO.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainRespVO.java new file mode 100644 index 00000000..a2e5483f --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainRespVO.java @@ -0,0 +1,52 @@ +package com.viewsh.module.iot.rule.controller.admin.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 管理后台 - IoT 规则链 Response VO + */ +@Data +public class IotRuleChainRespVO { + + /** 规则链编号 */ + private Long id; + + /** 规则链名称 */ + private String name; + + /** 规则链描述 */ + private String description; + + /** 规则链类型(SCENE/DATA/CUSTOM) */ + private String type; + + /** 状态(0=禁用 1=启用 2=WARNING) */ + private Integer status; + + /** 优先级 */ + private Integer priority; + + /** 版本号(乐观锁) */ + private Long version; + + /** 调试模式 */ + private Boolean debugMode; + + /** 子系统编号 */ + private Long subsystemId; + + /** 产品编号 */ + private Long productId; + + /** 设备编号 */ + private Long deviceId; + + /** 创建时间 */ + private LocalDateTime createTime; + + /** 更新时间 */ + private LocalDateTime updateTime; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainSaveReqVO.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainSaveReqVO.java new file mode 100644 index 00000000..632e0c42 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainSaveReqVO.java @@ -0,0 +1,101 @@ +package com.viewsh.module.iot.rule.controller.admin.vo; + +import lombok.Data; + +import java.util.List; + +/** + * 管理后台 - IoT 规则链新增/修改 Request VO + */ +@Data +public class IotRuleChainSaveReqVO { + + /** 规则链编号(修改时必填) */ + private Long id; + + /** 规则链名称(必填) */ + private String name; + + /** 规则链描述 */ + private String description; + + /** 规则链类型(SCENE/DATA/CUSTOM,必填) */ + private String type; + + /** 优先级 */ + private Integer priority; + + /** 调试模式 */ + private Boolean debugMode; + + /** 子系统编号(NULL=全局规则) */ + private Long subsystemId; + + /** 产品编号(NULL=不限产品) */ + private Long productId; + + /** 设备编号(NULL/0=范围内所有设备) */ + private Long deviceId; + + /** 规则节点列表(必填) */ + private List nodes; + + /** 规则连线列表 */ + private List links; + + /** + * 节点子 VO + */ + @Data + public static class NodeVO { + + /** 节点编号(更新时填写,新增可不填) */ + private Long id; + + /** 节点名称 */ + private String name; + + /** 节点类别(trigger/condition/action,必填) */ + private String category; + + /** Provider 标识(必填) */ + private String type; + + /** 节点配置(JSON 字符串,必填) */ + private String configuration; + + /** 画布 X 坐标 */ + private Integer positionX; + + /** 画布 Y 坐标 */ + private Integer positionY; + + } + + /** + * 连线子 VO + */ + @Data + public static class LinkVO { + + /** 连线编号(更新时填写,新增可不填) */ + private Long id; + + /** 源节点编号(必填) */ + private Long sourceNodeId; + + /** 目标节点编号(必填) */ + private Long targetNodeId; + + /** 关系类型(Success/Failure/True/False/Timeout/Skip,必填) */ + private String relationType; + + /** 连线条件(JSON,可选) */ + private String condition; + + /** 排序 */ + private Integer sortOrder; + + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/IotRuleChainDO.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/IotRuleChainDO.java new file mode 100644 index 00000000..f9244ab1 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/IotRuleChainDO.java @@ -0,0 +1,90 @@ +package com.viewsh.module.iot.rule.dal.dataobject; + +import com.viewsh.framework.mybatis.core.dataobject.BaseDO; +import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleChainStatus; +import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleChainType; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 规则链 DO + */ +@TableName("iot_rule_chain") +@KeySequence("iot_rule_chain_seq") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotRuleChainDO extends BaseDO { + + /** + * 规则链编号 + */ + @TableId + private Long id; + + /** + * 规则链名称 + */ + private String name; + + /** + * 规则链描述 + */ + private String description; + + /** + * 规则链类型 + * + * 枚举 {@link RuleChainType} + */ + private String type; + + /** + * 规则链状态 + * + * 枚举 {@link RuleChainStatus} + */ + private Integer status; + + /** + * 优先级(ASC 排序,评审 A5) + */ + private Integer priority; + + /** + * 版本号(每次更新 +1,评审 B9 多实例校验用) + */ + private Long version; + + /** + * 调试模式 + */ + private Boolean debugMode; + + /** + * 子系统编号(NULL = 全局规则) + */ + private Long subsystemId; + + /** + * 产品编号(NULL = 不限产品) + */ + private Long productId; + + /** + * 设备编号(NULL/0 = 范围内所有设备) + */ + private Long deviceId; + + /** + * 租户编号 + */ + private Long tenantId; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/IotRuleLinkDO.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/IotRuleLinkDO.java new file mode 100644 index 00000000..7c58aca6 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/IotRuleLinkDO.java @@ -0,0 +1,73 @@ +package com.viewsh.module.iot.rule.dal.dataobject; + +import com.viewsh.framework.mybatis.core.dataobject.BaseDO; +import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleLinkRelationType; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 规则连线 DO + */ +@TableName("iot_rule_link") +@KeySequence("iot_rule_link_seq") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotRuleLinkDO extends BaseDO { + + /** + * 连线编号 + */ + @TableId + private Long id; + + /** + * 所属规则链编号 + * + * 关联 {@link IotRuleChainDO#getId()} + */ + private Long ruleChainId; + + /** + * 源节点编号 + * + * 关联 {@link IotRuleNodeDO#getId()} + */ + private Long sourceNodeId; + + /** + * 目标节点编号 + * + * 关联 {@link IotRuleNodeDO#getId()} + */ + private Long targetNodeId; + + /** + * 关系类型(封闭枚举,评审 B4) + * + * 枚举 {@link RuleLinkRelationType} + */ + private String relationType; + + /** + * 连线条件(JSON,可选) + */ + private String condition; + + /** + * 排序 + */ + private Integer sortOrder; + + /** + * 租户编号 + */ + private Long tenantId; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/IotRuleNodeDO.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/IotRuleNodeDO.java new file mode 100644 index 00000000..82898820 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/IotRuleNodeDO.java @@ -0,0 +1,74 @@ +package com.viewsh.module.iot.rule.dal.dataobject; + +import com.viewsh.framework.mybatis.core.dataobject.BaseDO; +import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 规则节点 DO + */ +@TableName("iot_rule_node") +@KeySequence("iot_rule_node_seq") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotRuleNodeDO extends BaseDO { + + /** + * 节点编号 + */ + @TableId + private Long id; + + /** + * 所属规则链编号 + * + * 关联 {@link IotRuleChainDO#getId()} + */ + private Long ruleChainId; + + /** + * 节点名称 + */ + private String name; + + /** + * 节点类别(trigger / condition / action) + * + * 枚举 {@link RuleNodeCategory} + */ + private String category; + + /** + * Provider 标识(device_property / alarm_trigger 等) + */ + private String type; + + /** + * 节点配置(JSON) + */ + private String configuration; + + /** + * 画布 X 坐标 + */ + private Integer positionX; + + /** + * 画布 Y 坐标 + */ + private Integer positionY; + + /** + * 租户编号 + */ + private Long tenantId; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleChainStatus.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleChainStatus.java new file mode 100644 index 00000000..39830f3a --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleChainStatus.java @@ -0,0 +1,25 @@ +package com.viewsh.module.iot.rule.dal.dataobject.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 规则链状态枚举 + */ +@Getter +@AllArgsConstructor +public enum RuleChainStatus { + + DISABLED(0, "禁用"), + ENABLED(1, "启用"), + WARNING(2, "警告(物模型变更导致)"); + + @EnumValue + @JsonValue + private final Integer value; + + private final String label; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleChainType.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleChainType.java new file mode 100644 index 00000000..6c582026 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleChainType.java @@ -0,0 +1,25 @@ +package com.viewsh.module.iot.rule.dal.dataobject.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 规则链类型枚举 + */ +@Getter +@AllArgsConstructor +public enum RuleChainType { + + SCENE("SCENE", "场景联动"), + DATA("DATA", "数据流转"), + CUSTOM("CUSTOM", "自定义"); + + @EnumValue + @JsonValue + private final String value; + + private final String label; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleLinkRelationType.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleLinkRelationType.java new file mode 100644 index 00000000..ddf2df00 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleLinkRelationType.java @@ -0,0 +1,49 @@ +package com.viewsh.module.iot.rule.dal.dataobject.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 规则连线关系类型枚举(封闭 6 值,评审 B4) + * + * 使用 VARCHAR(32) 存储,应用层枚举校验,不使用 MySQL ENUM 类型 + */ +@Getter +@AllArgsConstructor +public enum RuleLinkRelationType { + + SUCCESS("Success", "成功"), + FAILURE("Failure", "失败"), + TRUE("True", "为真"), + FALSE("False", "为假"), + TIMEOUT("Timeout", "超时"), + SKIP("Skip", "跳过"); + + @EnumValue + @JsonValue + private final String value; + + private final String label; + + /** + * 根据 value 获取枚举,校验时使用 + */ + public static RuleLinkRelationType of(String value) { + for (RuleLinkRelationType type : values()) { + if (type.value.equals(value)) { + return type; + } + } + return null; + } + + /** + * 校验 value 是否合法 + */ + public static boolean isValid(String value) { + return of(value) != null; + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleNodeCategory.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleNodeCategory.java new file mode 100644 index 00000000..646e3588 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/dataobject/enums/RuleNodeCategory.java @@ -0,0 +1,25 @@ +package com.viewsh.module.iot.rule.dal.dataobject.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 规则节点类别枚举 + */ +@Getter +@AllArgsConstructor +public enum RuleNodeCategory { + + TRIGGER("trigger", "触发器"), + CONDITION("condition", "条件"), + ACTION("action", "动作"); + + @EnumValue + @JsonValue + private final String value; + + private final String label; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/mysql/IotRuleChainMapper.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/mysql/IotRuleChainMapper.java new file mode 100644 index 00000000..a22d4941 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/mysql/IotRuleChainMapper.java @@ -0,0 +1,68 @@ +package com.viewsh.module.iot.rule.dal.mysql; + +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainPageReqVO; +import com.viewsh.module.iot.rule.dal.dataobject.IotRuleChainDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * IoT 规则链 Mapper + */ +@Mapper +public interface IotRuleChainMapper extends BaseMapperX { + + default PageResult selectPage(IotRuleChainPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(IotRuleChainDO::getTenantId, reqVO.getTenantId()) + .likeIfPresent(IotRuleChainDO::getName, reqVO.getName()) + .eqIfPresent(IotRuleChainDO::getStatus, reqVO.getStatus()) + .eqIfPresent(IotRuleChainDO::getType, reqVO.getType()) + .betweenIfPresent(IotRuleChainDO::getCreateTime, reqVO.getCreateTime()) + .orderByAsc(IotRuleChainDO::getPriority) + .orderByDesc(IotRuleChainDO::getId)); + } + + /** + * 查询指定租户所有启用的规则链(B3 消费) + */ + default List selectEnabledByTenant(Long tenantId) { + return selectList(new LambdaQueryWrapperX() + .eq(IotRuleChainDO::getTenantId, tenantId) + .eq(IotRuleChainDO::getStatus, 1) // ENABLED + .orderByAsc(IotRuleChainDO::getPriority)); + } + + /** + * 乐观锁更新版本(评审 B9) + * 仅当 id 和 expectedVersion 均匹配时才更新,同时 version+1 + * + * @return 更新行数(0 表示乐观锁冲突) + */ + @Update("UPDATE iot_rule_chain SET version = version + 1, name = #{chain.name}, " + + "description = #{chain.description}, type = #{chain.type}, status = #{chain.status}, " + + "priority = #{chain.priority}, debug_mode = #{chain.debugMode}, " + + "subsystem_id = #{chain.subsystemId}, product_id = #{chain.productId}, " + + "device_id = #{chain.deviceId} " + + "WHERE id = #{id} AND version = #{expectedVersion} AND deleted = 0") + int updateWithVersion(@Param("id") Long id, + @Param("expectedVersion") Long expectedVersion, + @Param("chain") IotRuleChainDO chain); + + /** + * 拉取 id+version 列表(B9 拉模式兜底扫描) + */ + @Select("SELECT id, version FROM iot_rule_chain " + + "WHERE tenant_id = #{tenantId} AND update_time >= #{since} AND deleted = 0") + List> selectIdAndVersionSince(@Param("tenantId") Long tenantId, + @Param("since") LocalDateTime since); + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/mysql/IotRuleLinkMapper.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/mysql/IotRuleLinkMapper.java new file mode 100644 index 00000000..0b8472df --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/mysql/IotRuleLinkMapper.java @@ -0,0 +1,33 @@ +package com.viewsh.module.iot.rule.dal.mysql; + +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.viewsh.module.iot.rule.dal.dataobject.IotRuleLinkDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IoT 规则连线 Mapper + */ +@Mapper +public interface IotRuleLinkMapper extends BaseMapperX { + + /** + * 根据规则链编号查询所有连线 + */ + default List selectByChainId(Long ruleChainId) { + return selectList(new LambdaQueryWrapperX() + .eq(IotRuleLinkDO::getRuleChainId, ruleChainId) + .orderByAsc(IotRuleLinkDO::getSortOrder) + .orderByAsc(IotRuleLinkDO::getId)); + } + + /** + * 软删除规则链下的所有连线 + */ + default int deleteByChainId(Long ruleChainId) { + return delete(IotRuleLinkDO::getRuleChainId, ruleChainId); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/mysql/IotRuleNodeMapper.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/mysql/IotRuleNodeMapper.java new file mode 100644 index 00000000..c9d7b1d1 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/dal/mysql/IotRuleNodeMapper.java @@ -0,0 +1,32 @@ +package com.viewsh.module.iot.rule.dal.mysql; + +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.viewsh.module.iot.rule.dal.dataobject.IotRuleNodeDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IoT 规则节点 Mapper + */ +@Mapper +public interface IotRuleNodeMapper extends BaseMapperX { + + /** + * 根据规则链编号查询所有节点 + */ + default List selectByChainId(Long ruleChainId) { + return selectList(new LambdaQueryWrapperX() + .eq(IotRuleNodeDO::getRuleChainId, ruleChainId) + .orderByAsc(IotRuleNodeDO::getId)); + } + + /** + * 软删除规则链下的所有节点 + */ + default int deleteByChainId(Long ruleChainId) { + return delete(IotRuleNodeDO::getRuleChainId, ruleChainId); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainService.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainService.java new file mode 100644 index 00000000..a62207f4 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainService.java @@ -0,0 +1,95 @@ +package com.viewsh.module.iot.rule.service; + +import com.viewsh.framework.common.pojo.PageResult; +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; +import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainSaveReqVO; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * IoT 规则链 Service 接口 + */ +public interface IotRuleChainService { + + /** + * 创建规则链(含 nodes + links,同一事务) + * + * @param req 请求 VO + * @return 规则链编号 + */ + Long createRuleChain(IotRuleChainSaveReqVO req); + + /** + * 更新规则链(乐观锁 version++,重新写入 nodes + links) + * + * @param req 请求 VO + */ + void updateRuleChain(IotRuleChainSaveReqVO req); + + /** + * 删除规则链(级联软删 nodes + links,同一事务) + * + * @param id 规则链编号 + */ + void deleteRuleChain(Long id); + + /** + * 启用规则链 + * + * @param id 规则链编号 + */ + void enableRuleChain(Long id); + + /** + * 禁用规则链 + * + * @param id 规则链编号 + */ + void disableRuleChain(Long id); + + /** + * 获得规则链基础信息 + * + * @param id 规则链编号 + * @return 规则链 VO + */ + IotRuleChainRespVO getRuleChain(Long id); + + /** + * 获得规则链完整图(含 nodes + links) + * + * @param id 规则链编号 + * @return 规则链图 VO + */ + IotRuleChainGraphVO getRuleChainGraph(Long id); + + /** + * 获得规则链分页 + * + * @param req 分页请求 + * @return 分页结果 + */ + PageResult getRuleChainPage(IotRuleChainPageReqVO req); + + /** + * 加载租户下所有启用的规则链(B3 消费) + * + * @param tenantId 租户编号 + * @return 规则链图列表 + */ + List loadAllEnabled(Long tenantId); + + /** + * 拉取 id+version 列表(B9 拉模式兜底) + * + * @param tenantId 租户编号 + * @param since 起始时间(update_time >= since) + * @return [{id, version}] 列表 + */ + List> loadIdAndVersionSince(Long tenantId, LocalDateTime since); + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImpl.java new file mode 100644 index 00000000..f52159c5 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImpl.java @@ -0,0 +1,389 @@ +package com.viewsh.module.iot.rule.service; + +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.object.BeanUtils; +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; +import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainSaveReqVO; +import com.viewsh.module.iot.rule.dal.dataobject.IotRuleChainDO; +import com.viewsh.module.iot.rule.dal.dataobject.IotRuleLinkDO; +import com.viewsh.module.iot.rule.dal.dataobject.IotRuleNodeDO; +import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleChainStatus; +import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleLinkRelationType; +import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory; +import com.viewsh.module.iot.rule.dal.mysql.IotRuleChainMapper; +import com.viewsh.module.iot.rule.dal.mysql.IotRuleLinkMapper; +import com.viewsh.module.iot.rule.dal.mysql.IotRuleNodeMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.iot.enums.ErrorCodeConstants.*; + +/** + * IoT 规则链 Service 实现类 + */ +@Service +@Validated +public class IotRuleChainServiceImpl implements IotRuleChainService { + + @Resource + private IotRuleChainMapper ruleChainMapper; + + @Resource + private IotRuleNodeMapper ruleNodeMapper; + + @Resource + private IotRuleLinkMapper ruleLinkMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createRuleChain(IotRuleChainSaveReqVO req) { + // 1. 校验单 Trigger(评审 B10) + validateSingleTrigger(req.getNodes()); + + // 2. 校验 DAG 无环 + validateNoCycle(req.getNodes(), req.getLinks()); + + // 3. 校验 relation_type 合法性(评审 B4) + validateRelationTypes(req.getLinks()); + + // 4. 插入规则链 + IotRuleChainDO chain = buildChainDO(req); + chain.setStatus(RuleChainStatus.ENABLED.getValue()); + chain.setVersion(0L); + ruleChainMapper.insert(chain); + + // 5. 插入节点 + 连线(同一事务) + insertNodesAndLinks(chain.getId(), chain.getTenantId(), req.getNodes(), req.getLinks()); + + return chain.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateRuleChain(IotRuleChainSaveReqVO req) { + // 1. 校验规则链存在 + IotRuleChainDO existing = validateRuleChainExists(req.getId()); + + // 2. 校验单 Trigger(评审 B10) + validateSingleTrigger(req.getNodes()); + + // 3. 校验 DAG 无环 + validateNoCycle(req.getNodes(), req.getLinks()); + + // 4. 校验 relation_type 合法性(评审 B4) + validateRelationTypes(req.getLinks()); + + // 5. 乐观锁更新 chain(评审 B9) + IotRuleChainDO chainUpdate = buildChainDO(req); + chainUpdate.setStatus(existing.getStatus()); // 保持原状态 + int updated = ruleChainMapper.updateWithVersion(req.getId(), existing.getVersion(), chainUpdate); + if (updated == 0) { + throw exception(RULE_CHAIN_OPTIMISTIC_LOCK_CONFLICT); + } + + // 6. 重新写入节点 + 连线:先删后插(同一事务) + ruleNodeMapper.deleteByChainId(req.getId()); + ruleLinkMapper.deleteByChainId(req.getId()); + // 获取更新后的租户 id(chain 中有 tenantId) + Long tenantId = existing.getTenantId(); + insertNodesAndLinks(req.getId(), tenantId, req.getNodes(), req.getLinks()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteRuleChain(Long id) { + // 1. 校验存在 + validateRuleChainExists(id); + + // 2. 软删除 chain + ruleChainMapper.deleteById(id); + + // 3. 级联软删除 nodes + links(同一事务) + ruleNodeMapper.deleteByChainId(id); + ruleLinkMapper.deleteByChainId(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void enableRuleChain(Long id) { + IotRuleChainDO chain = validateRuleChainExists(id); + if (RuleChainStatus.ENABLED.getValue().equals(chain.getStatus())) { + return; // 已启用,幂等 + } + IotRuleChainDO update = new IotRuleChainDO(); + update.setId(id); + update.setStatus(RuleChainStatus.ENABLED.getValue()); + ruleChainMapper.updateById(update); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void disableRuleChain(Long id) { + IotRuleChainDO chain = validateRuleChainExists(id); + if (RuleChainStatus.DISABLED.getValue().equals(chain.getStatus())) { + return; // 已禁用,幂等 + } + IotRuleChainDO update = new IotRuleChainDO(); + update.setId(id); + update.setStatus(RuleChainStatus.DISABLED.getValue()); + ruleChainMapper.updateById(update); + } + + @Override + public IotRuleChainRespVO getRuleChain(Long id) { + IotRuleChainDO chain = validateRuleChainExists(id); + return BeanUtils.toBean(chain, IotRuleChainRespVO.class); + } + + @Override + public IotRuleChainGraphVO getRuleChainGraph(Long id) { + IotRuleChainDO chain = validateRuleChainExists(id); + List nodes = ruleNodeMapper.selectByChainId(id); + List links = ruleLinkMapper.selectByChainId(id); + + return IotRuleChainGraphVO.builder() + .chain(BeanUtils.toBean(chain, IotRuleChainRespVO.class)) + .nodes(nodes.stream() + .map(n -> IotRuleChainGraphVO.NodeVO.builder() + .id(n.getId()) + .ruleChainId(n.getRuleChainId()) + .name(n.getName()) + .category(n.getCategory()) + .type(n.getType()) + .configuration(n.getConfiguration()) + .positionX(n.getPositionX()) + .positionY(n.getPositionY()) + .build()) + .collect(Collectors.toList())) + .links(links.stream() + .map(l -> IotRuleChainGraphVO.LinkVO.builder() + .id(l.getId()) + .ruleChainId(l.getRuleChainId()) + .sourceNodeId(l.getSourceNodeId()) + .targetNodeId(l.getTargetNodeId()) + .relationType(l.getRelationType()) + .condition(l.getCondition()) + .sortOrder(l.getSortOrder()) + .build()) + .collect(Collectors.toList())) + .build(); + } + + @Override + public PageResult getRuleChainPage(IotRuleChainPageReqVO req) { + PageResult pageResult = ruleChainMapper.selectPage(req); + return BeanUtils.toBean(pageResult, IotRuleChainRespVO.class); + } + + @Override + public List loadAllEnabled(Long tenantId) { + List chains = ruleChainMapper.selectEnabledByTenant(tenantId); + return chains.stream() + .map(chain -> getRuleChainGraph(chain.getId())) + .collect(Collectors.toList()); + } + + @Override + public List> loadIdAndVersionSince(Long tenantId, LocalDateTime since) { + List> rawList = ruleChainMapper.selectIdAndVersionSince(tenantId, since); + List> result = new ArrayList<>(); + for (Map row : rawList) { + Map entry = new HashMap<>(); + Object idObj = row.get("id"); + Object versionObj = row.get("version"); + if (idObj != null && versionObj != null) { + entry.put(toLong(idObj), toLong(versionObj)); + result.add(entry); + } + } + return result; + } + + // ========== 私有辅助方法 ========== + + /** + * 校验规则链存在 + */ + private IotRuleChainDO validateRuleChainExists(Long id) { + IotRuleChainDO chain = ruleChainMapper.selectById(id); + if (chain == null) { + throw exception(RULE_CHAIN_NOT_EXISTS); + } + return chain; + } + + /** + * 校验有且仅有一个 Trigger 节点(评审 B10) + */ + private void validateSingleTrigger(List nodes) { + if (nodes == null || nodes.isEmpty()) { + throw exception(RULE_CHAIN_MUST_HAVE_EXACTLY_ONE_TRIGGER); + } + long triggerCount = nodes.stream() + .filter(n -> RuleNodeCategory.TRIGGER.getValue().equals(n.getCategory())) + .count(); + if (triggerCount != 1) { + throw exception(RULE_CHAIN_MUST_HAVE_EXACTLY_ONE_TRIGGER); + } + } + + /** + * 校验 DAG 无环(DFS 检测) + */ + private void validateNoCycle(List nodes, + List links) { + if (links == null || links.isEmpty()) { + return; + } + + // 构建邻接表(使用节点列表索引) + int nodeCount = nodes == null ? 0 : nodes.size(); + // 节点 id → 索引的映射(使用 sourceNodeId/targetNodeId 作为索引) + // 注意:创建时节点无 id,使用列表索引模拟 + // 对于已有 id 的节点(更新场景),使用 id;对于新增节点(id=null),使用负数索引 + Map nodeIndexMap = new HashMap<>(); + for (int i = 0; i < nodes.size(); i++) { + IotRuleChainSaveReqVO.NodeVO node = nodes.get(i); + // 使用 id(如有)或索引(取负数以避免冲突) + Long key = node.getId() != null ? node.getId() : -(long)(i + 1); + nodeIndexMap.put(key, i); + } + + // 构建邻接表 + Map> adjMap = new HashMap<>(); + for (IotRuleChainSaveReqVO.LinkVO link : links) { + adjMap.computeIfAbsent(link.getSourceNodeId(), k -> new ArrayList<>()) + .add(link.getTargetNodeId()); + } + + // 收集所有节点 key + Set allKeys = new HashSet<>(nodeIndexMap.keySet()); + + // DFS 检测环 + Set visited = new HashSet<>(); + Set inStack = new HashSet<>(); + + for (Long key : allKeys) { + if (!visited.contains(key)) { + if (hasCycleDFS(key, adjMap, visited, inStack)) { + throw exception(RULE_CHAIN_CYCLE_DETECTED); + } + } + } + } + + private boolean hasCycleDFS(Long node, Map> adjMap, + Set visited, Set inStack) { + visited.add(node); + inStack.add(node); + + List neighbors = adjMap.getOrDefault(node, new ArrayList<>()); + for (Long neighbor : neighbors) { + if (!visited.contains(neighbor)) { + if (hasCycleDFS(neighbor, adjMap, visited, inStack)) { + return true; + } + } else if (inStack.contains(neighbor)) { + return true; + } + } + + inStack.remove(node); + return false; + } + + /** + * 校验 relation_type 合法性(评审 B4) + */ + private void validateRelationTypes(List links) { + if (links == null) { + return; + } + for (IotRuleChainSaveReqVO.LinkVO link : links) { + if (!RuleLinkRelationType.isValid(link.getRelationType())) { + throw exception(RULE_CHAIN_INVALID_RELATION_TYPE); + } + } + } + + /** + * 构建规则链 DO(不含 status、version、tenantId) + */ + private IotRuleChainDO buildChainDO(IotRuleChainSaveReqVO req) { + IotRuleChainDO chain = new IotRuleChainDO(); + chain.setName(req.getName()); + chain.setDescription(req.getDescription()); + chain.setType(req.getType()); + chain.setPriority(req.getPriority() != null ? req.getPriority() : 100); + chain.setDebugMode(req.getDebugMode() != null ? req.getDebugMode() : false); + chain.setSubsystemId(req.getSubsystemId()); + chain.setProductId(req.getProductId()); + chain.setDeviceId(req.getDeviceId()); + return chain; + } + + /** + * 批量插入节点 + 连线(同一事务内调用) + */ + private void insertNodesAndLinks(Long chainId, Long tenantId, + List nodeVOs, + List linkVOs) { + // 插入节点 + if (nodeVOs != null && !nodeVOs.isEmpty()) { + List nodeDOs = nodeVOs.stream() + .map(v -> IotRuleNodeDO.builder() + .ruleChainId(chainId) + .name(v.getName()) + .category(v.getCategory()) + .type(v.getType()) + .configuration(v.getConfiguration()) + .positionX(v.getPositionX()) + .positionY(v.getPositionY()) + .tenantId(tenantId) + .build()) + .collect(Collectors.toList()); + ruleNodeMapper.insertBatch(nodeDOs); + } + + // 插入连线 + if (linkVOs != null && !linkVOs.isEmpty()) { + List linkDOs = linkVOs.stream() + .map(v -> IotRuleLinkDO.builder() + .ruleChainId(chainId) + .sourceNodeId(v.getSourceNodeId()) + .targetNodeId(v.getTargetNodeId()) + .relationType(v.getRelationType()) + .condition(v.getCondition()) + .sortOrder(v.getSortOrder() != null ? v.getSortOrder() : 0) + .tenantId(tenantId) + .build()) + .collect(Collectors.toList()); + ruleLinkMapper.insertBatch(linkDOs); + } + } + + private Long toLong(Object obj) { + if (obj instanceof Long) { + return (Long) obj; + } + if (obj instanceof Number) { + return ((Number) obj).longValue(); + } + return Long.parseLong(obj.toString()); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/mapper/rule/IotRuleChainMapper.xml b/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/mapper/rule/IotRuleChainMapper.xml new file mode 100644 index 00000000..7353ef10 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/mapper/rule/IotRuleChainMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/mapper/rule/IotRuleLinkMapper.xml b/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/mapper/rule/IotRuleLinkMapper.xml new file mode 100644 index 00000000..c2f6b0e4 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/mapper/rule/IotRuleLinkMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/mapper/rule/IotRuleNodeMapper.xml b/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/mapper/rule/IotRuleNodeMapper.xml new file mode 100644 index 00000000..912f18b9 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/resources/mapper/rule/IotRuleNodeMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImplTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImplTest.java new file mode 100644 index 00000000..46f2cf2f --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImplTest.java @@ -0,0 +1,299 @@ +package com.viewsh.module.iot.rule.service; + +import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.framework.test.core.ut.BaseMockitoUnitTest; +import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainGraphVO; +import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainSaveReqVO; +import com.viewsh.module.iot.rule.dal.dataobject.IotRuleChainDO; +import com.viewsh.module.iot.rule.dal.dataobject.IotRuleLinkDO; +import com.viewsh.module.iot.rule.dal.dataobject.IotRuleNodeDO; +import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleChainStatus; +import com.viewsh.module.iot.rule.dal.mysql.IotRuleChainMapper; +import com.viewsh.module.iot.rule.dal.mysql.IotRuleLinkMapper; +import com.viewsh.module.iot.rule.dal.mysql.IotRuleNodeMapper; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static com.viewsh.module.iot.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link IotRuleChainServiceImpl} 单元测试 + * + * 覆盖任务卡 §6.1 的 8 个用例 + */ +class IotRuleChainServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private IotRuleChainServiceImpl ruleChainService; + + @Mock + private IotRuleChainMapper ruleChainMapper; + + @Mock + private IotRuleNodeMapper ruleNodeMapper; + + @Mock + private IotRuleLinkMapper ruleLinkMapper; + + // ========== 用例 1:正常创建(1 Trigger + 2 Action)========== + + @Test + void testCreate_singleTrigger() { + // 准备参数 + IotRuleChainSaveReqVO req = buildCreateReqVO( + buildNodeVO(null, "trigger", "device_event"), + buildNodeVO(null, "action", "send_notification"), + buildNodeVO(null, "action", "set_property") + ); + req.setLinks(Collections.emptyList()); + + // Mock + when(ruleChainMapper.insert(any(IotRuleChainDO.class))).thenAnswer(inv -> { + IotRuleChainDO chain = inv.getArgument(0); + chain.setId(100L); + chain.setTenantId(1L); + return 1; + }); + when(ruleNodeMapper.insertBatch(anyCollection())).thenReturn(true); + + // 执行 + Long id = ruleChainService.createRuleChain(req); + + // 断言 + assertNotNull(id); + verify(ruleChainMapper, times(1)).insert(any(IotRuleChainDO.class)); + verify(ruleNodeMapper, times(1)).insertBatch(anyCollection()); + } + + // ========== 用例 2:2 个 Trigger 被拒绝 ========== + + @Test + void testCreate_multiTriggerRejected() { + IotRuleChainSaveReqVO req = buildCreateReqVO( + buildNodeVO(null, "trigger", "device_event"), + buildNodeVO(null, "trigger", "time_trigger") + ); + req.setLinks(Collections.emptyList()); + + ServiceException ex = assertThrows(ServiceException.class, + () -> ruleChainService.createRuleChain(req)); + assertEquals(RULE_CHAIN_MUST_HAVE_EXACTLY_ONE_TRIGGER.getCode(), ex.getCode()); + } + + // ========== 用例 3:A→B→C→A 环路被拒绝 ========== + + @Test + void testCreate_cycleRejected() { + IotRuleChainSaveReqVO.NodeVO n1 = buildNodeVO(1L, "trigger", "device_event"); + IotRuleChainSaveReqVO.NodeVO n2 = buildNodeVO(2L, "condition", "property_check"); + IotRuleChainSaveReqVO.NodeVO n3 = buildNodeVO(3L, "action", "send_notification"); + + IotRuleChainSaveReqVO req = new IotRuleChainSaveReqVO(); + req.setName("CycleTest"); + req.setType("SCENE"); + req.setNodes(Arrays.asList(n1, n2, n3)); + // A(1) → B(2) → C(3) → A(1) 形成环 + req.setLinks(Arrays.asList( + buildLinkVO(1L, 2L, "Success"), + buildLinkVO(2L, 3L, "Success"), + buildLinkVO(3L, 1L, "Success") + )); + + ServiceException ex = assertThrows(ServiceException.class, + () -> ruleChainService.createRuleChain(req)); + assertEquals(RULE_CHAIN_CYCLE_DETECTED.getCode(), ex.getCode()); + } + + // ========== 用例 4:无效 relation_type 枚举校验失败 ========== + + @Test + void testCreate_invalidRelationType() { + IotRuleChainSaveReqVO.NodeVO n1 = buildNodeVO(1L, "trigger", "device_event"); + IotRuleChainSaveReqVO.NodeVO n2 = buildNodeVO(2L, "action", "send_notification"); + + IotRuleChainSaveReqVO req = new IotRuleChainSaveReqVO(); + req.setName("InvalidRelation"); + req.setType("SCENE"); + req.setNodes(Arrays.asList(n1, n2)); + req.setLinks(Collections.singletonList(buildLinkVO(1L, 2L, "CustomXxx"))); + + ServiceException ex = assertThrows(ServiceException.class, + () -> ruleChainService.createRuleChain(req)); + assertEquals(RULE_CHAIN_INVALID_RELATION_TYPE.getCode(), ex.getCode()); + } + + // ========== 用例 5:update 一次 version 从 0 → 1 ========== + + @Test + void testUpdate_versionIncrement() { + Long chainId = 200L; + IotRuleChainDO existing = IotRuleChainDO.builder() + .id(chainId) + .name("OldName") + .type("SCENE") + .status(RuleChainStatus.ENABLED.getValue()) + .version(0L) + .priority(100) + .tenantId(1L) + .build(); + + IotRuleChainSaveReqVO req = buildCreateReqVO( + buildNodeVO(null, "trigger", "device_event") + ); + req.setId(chainId); + req.setLinks(Collections.emptyList()); + + when(ruleChainMapper.selectById(chainId)).thenReturn(existing); + // 乐观锁更新成功返回 1 + when(ruleChainMapper.updateWithVersion(eq(chainId), eq(0L), any(IotRuleChainDO.class))).thenReturn(1); + when(ruleNodeMapper.deleteByChainId(chainId)).thenReturn(1); + when(ruleLinkMapper.deleteByChainId(chainId)).thenReturn(0); + when(ruleNodeMapper.insertBatch(anyCollection())).thenReturn(true); + + // 执行(不抛异常即为成功) + assertDoesNotThrow(() -> ruleChainService.updateRuleChain(req)); + + verify(ruleChainMapper, times(1)).updateWithVersion(eq(chainId), eq(0L), any()); + } + + // ========== 用例 6:并发 update 乐观锁冲突 ========== + + @Test + void testUpdate_optimisticLockConflict() { + Long chainId = 300L; + IotRuleChainDO existing = IotRuleChainDO.builder() + .id(chainId) + .name("Name") + .type("SCENE") + .status(RuleChainStatus.ENABLED.getValue()) + .version(5L) + .tenantId(1L) + .priority(100) + .build(); + + IotRuleChainSaveReqVO req = buildCreateReqVO( + buildNodeVO(null, "trigger", "device_event") + ); + req.setId(chainId); + req.setLinks(Collections.emptyList()); + + when(ruleChainMapper.selectById(chainId)).thenReturn(existing); + // 乐观锁更新失败(返回 0) + when(ruleChainMapper.updateWithVersion(eq(chainId), eq(5L), any(IotRuleChainDO.class))).thenReturn(0); + + ServiceException ex = assertThrows(ServiceException.class, + () -> ruleChainService.updateRuleChain(req)); + assertEquals(RULE_CHAIN_OPTIMISTIC_LOCK_CONFLICT.getCode(), ex.getCode()); + } + + // ========== 用例 7:getRuleChainGraph 返回完整图 ========== + + @Test + void testGetGraph() { + Long chainId = 123L; + IotRuleChainDO chain = IotRuleChainDO.builder() + .id(chainId) + .name("TestChain") + .type("SCENE") + .status(RuleChainStatus.ENABLED.getValue()) + .version(0L) + .priority(100) + .tenantId(1L) + .build(); + + IotRuleNodeDO node1 = IotRuleNodeDO.builder() + .id(10L) + .ruleChainId(chainId) + .category("trigger") + .type("device_event") + .configuration("{}") + .tenantId(1L) + .build(); + + IotRuleLinkDO link1 = IotRuleLinkDO.builder() + .id(20L) + .ruleChainId(chainId) + .sourceNodeId(10L) + .targetNodeId(11L) + .relationType("Success") + .tenantId(1L) + .build(); + + when(ruleChainMapper.selectById(chainId)).thenReturn(chain); + when(ruleNodeMapper.selectByChainId(chainId)).thenReturn(Collections.singletonList(node1)); + when(ruleLinkMapper.selectByChainId(chainId)).thenReturn(Collections.singletonList(link1)); + + IotRuleChainGraphVO graph = ruleChainService.getRuleChainGraph(chainId); + + assertNotNull(graph); + assertNotNull(graph.getChain()); + assertEquals(chainId, graph.getChain().getId()); + assertEquals(1, graph.getNodes().size()); + assertEquals(1, graph.getLinks().size()); + assertEquals(10L, graph.getNodes().get(0).getId()); + assertEquals(20L, graph.getLinks().get(0).getId()); + } + + // ========== 用例 8:删除 chain 级联软删 nodes+links ========== + + @Test + void testDelete_cascade() { + Long chainId = 400L; + IotRuleChainDO chain = IotRuleChainDO.builder() + .id(chainId) + .name("DeleteMe") + .type("SCENE") + .status(RuleChainStatus.ENABLED.getValue()) + .version(0L) + .tenantId(1L) + .build(); + + when(ruleChainMapper.selectById(chainId)).thenReturn(chain); + when(ruleChainMapper.deleteById(chainId)).thenReturn(1); + when(ruleNodeMapper.deleteByChainId(chainId)).thenReturn(2); + when(ruleLinkMapper.deleteByChainId(chainId)).thenReturn(1); + + ruleChainService.deleteRuleChain(chainId); + + // 验证级联删除 + verify(ruleChainMapper, times(1)).deleteById(chainId); + verify(ruleNodeMapper, times(1)).deleteByChainId(chainId); + verify(ruleLinkMapper, times(1)).deleteByChainId(chainId); + } + + // ========== 构建辅助方法 ========== + + private IotRuleChainSaveReqVO buildCreateReqVO(IotRuleChainSaveReqVO.NodeVO... nodes) { + IotRuleChainSaveReqVO req = new IotRuleChainSaveReqVO(); + req.setName("Test Chain"); + req.setType("SCENE"); + req.setNodes(Arrays.asList(nodes)); + return req; + } + + private IotRuleChainSaveReqVO.NodeVO buildNodeVO(Long id, String category, String type) { + IotRuleChainSaveReqVO.NodeVO node = new IotRuleChainSaveReqVO.NodeVO(); + node.setId(id); + node.setCategory(category); + node.setType(type); + node.setConfiguration("{}"); + return node; + } + + private IotRuleChainSaveReqVO.LinkVO buildLinkVO(Long sourceId, Long targetId, String relationType) { + IotRuleChainSaveReqVO.LinkVO link = new IotRuleChainSaveReqVO.LinkVO(); + link.setSourceNodeId(sourceId); + link.setTargetNodeId(targetId); + link.setRelationType(relationType); + return link; + } + +}