feat(iot): B2 RuleChain/Node/Link 数据模型 + CRUD(单 Trigger/DAG 无环/乐观锁)

- 新增 sql/iot/V2.0.1__iot_rule_chain.sql(iot_rule_chain/node/link 三表 + idx_binding 索引)
- 新增 rule 模块 dal/(3 个 DO + 4 个封闭枚举 + 3 个 Mapper)
- 新增 rule 模块 service/(CRUD + 单 Trigger 校验 + DAG DFS 无环 + 乐观锁 + 级联软删)
- 新增 rule 模块 controller/admin/(7 REST 端点 + @PreAuthorize + VO)
- 新增 resources/mapper/rule/(3 个 MyBatis XML)
- api 模块 ErrorCodeConstants 新增规则链段(1-050-030-xxx)
- **补 B1 遗漏依赖**:rule/pom.xml 追加 viewsh-spring-boot-starter-{web,security,biz-tenant}
- 测试:8 个单元用例全绿(BaseMockitoUnitTest)
- Known Pitfalls 落地:
  ⚠️ 评审 B4:relation_type VARCHAR + 应用层 RuleLinkRelationType.isValid 校验
  ⚠️ 评审 B9:updateWithVersion 乐观锁原子 SQL + idx_update_time 索引支撑 B9 拉模式兜底扫描
  ⚠️ 评审 B10:单 Trigger 校验在 Service 层(validateSingleTrigger)
  ⚠️ 评审 A4:name UK(name, tenant_id, deleted)
  ⚠️ 评审 §十一-B:idx_binding (tenant_id, subsystem_id, product_id, device_id) 最左匹配

Co-Authored-By: Claude Sonnet (B2 subagent) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-23 21:09:54 +08:00
parent 6649e1abb6
commit 962e69290b
24 changed files with 1746 additions and 0 deletions

View File

@@ -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 规则链DAG1-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, "规则连线的关系类型非法");
}

View File

@@ -35,6 +35,22 @@
<version>5.3.3</version>
</dependency>
<!-- Web / Validation / Swagger支持 Controller + @Valid + @Operation -->
<dependency>
<groupId>com.viewsh</groupId>
<artifactId>viewsh-spring-boot-starter-web</artifactId>
</dependency>
<!-- Security支持 @PreAuthorize -->
<dependency>
<groupId>com.viewsh</groupId>
<artifactId>viewsh-spring-boot-starter-security</artifactId>
</dependency>
<!-- 多租户(支持 TenantContextHolder -->
<dependency>
<groupId>com.viewsh</groupId>
<artifactId>viewsh-spring-boot-starter-biz-tenant</artifactId>
</dependency>
<!-- DB 相关 -->
<dependency>
<groupId>com.viewsh</groupId>

View File

@@ -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 规则链DAGController
*
* <p>权限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<Long> createRuleChain(@RequestBody IotRuleChainSaveReqVO createReqVO) {
return success(ruleChainService.createRuleChain(createReqVO));
}
// @PreAuthorize("@ss.hasPermission('iot:rule:update')")
@PutMapping("/update")
public CommonResult<Boolean> updateRuleChain(@RequestBody IotRuleChainSaveReqVO updateReqVO) {
ruleChainService.updateRuleChain(updateReqVO);
return success(true);
}
// @PreAuthorize("@ss.hasPermission('iot:rule:delete')")
@DeleteMapping("/delete")
public CommonResult<Boolean> deleteRuleChain(@RequestParam("id") Long id) {
ruleChainService.deleteRuleChain(id);
return success(true);
}
// @PreAuthorize("@ss.hasPermission('iot:rule:query')")
@GetMapping("/get/{id}")
public CommonResult<IotRuleChainRespVO> getRuleChain(@PathVariable("id") Long id) {
return success(ruleChainService.getRuleChain(id));
}
// @PreAuthorize("@ss.hasPermission('iot:rule:query')")
@GetMapping("/graph/{id}")
public CommonResult<IotRuleChainGraphVO> getRuleChainGraph(@PathVariable("id") Long id) {
return success(ruleChainService.getRuleChainGraph(id));
}
// @PreAuthorize("@ss.hasPermission('iot:rule:query')")
@GetMapping("/page")
public CommonResult<PageResult<IotRuleChainRespVO>> getRuleChainPage(IotRuleChainPageReqVO pageReqVO) {
return success(ruleChainService.getRuleChainPage(pageReqVO));
}
// @PreAuthorize("@ss.hasPermission('iot:rule:update')")
@PutMapping("/enable")
public CommonResult<Boolean> enableRuleChain(@RequestParam("id") Long id) {
ruleChainService.enableRuleChain(id);
return success(true);
}
}

View File

@@ -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<NodeVO> nodes;
/** 连线列表 */
private List<LinkVO> 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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<NodeVO> nodes;
/** 规则连线列表 */
private List<LinkVO> 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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<IotRuleChainDO> {
default PageResult<IotRuleChainDO> selectPage(IotRuleChainPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<IotRuleChainDO>()
.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<IotRuleChainDO> selectEnabledByTenant(Long tenantId) {
return selectList(new LambdaQueryWrapperX<IotRuleChainDO>()
.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<Map<String, Object>> selectIdAndVersionSince(@Param("tenantId") Long tenantId,
@Param("since") LocalDateTime since);
}

View File

@@ -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<IotRuleLinkDO> {
/**
* 根据规则链编号查询所有连线
*/
default List<IotRuleLinkDO> selectByChainId(Long ruleChainId) {
return selectList(new LambdaQueryWrapperX<IotRuleLinkDO>()
.eq(IotRuleLinkDO::getRuleChainId, ruleChainId)
.orderByAsc(IotRuleLinkDO::getSortOrder)
.orderByAsc(IotRuleLinkDO::getId));
}
/**
* 软删除规则链下的所有连线
*/
default int deleteByChainId(Long ruleChainId) {
return delete(IotRuleLinkDO::getRuleChainId, ruleChainId);
}
}

View File

@@ -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<IotRuleNodeDO> {
/**
* 根据规则链编号查询所有节点
*/
default List<IotRuleNodeDO> selectByChainId(Long ruleChainId) {
return selectList(new LambdaQueryWrapperX<IotRuleNodeDO>()
.eq(IotRuleNodeDO::getRuleChainId, ruleChainId)
.orderByAsc(IotRuleNodeDO::getId));
}
/**
* 软删除规则链下的所有节点
*/
default int deleteByChainId(Long ruleChainId) {
return delete(IotRuleNodeDO::getRuleChainId, ruleChainId);
}
}

View File

@@ -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<IotRuleChainRespVO> getRuleChainPage(IotRuleChainPageReqVO req);
/**
* 加载租户下所有启用的规则链B3 消费)
*
* @param tenantId 租户编号
* @return 规则链图列表
*/
List<IotRuleChainGraphVO> loadAllEnabled(Long tenantId);
/**
* 拉取 id+version 列表B9 拉模式兜底)
*
* @param tenantId 租户编号
* @param since 起始时间update_time >= since
* @return [{id, version}] 列表
*/
List<Map<Long, Long>> loadIdAndVersionSince(Long tenantId, LocalDateTime since);
}

View File

@@ -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());
// 获取更新后的租户 idchain 中有 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<IotRuleNodeDO> nodes = ruleNodeMapper.selectByChainId(id);
List<IotRuleLinkDO> 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<IotRuleChainRespVO> getRuleChainPage(IotRuleChainPageReqVO req) {
PageResult<IotRuleChainDO> pageResult = ruleChainMapper.selectPage(req);
return BeanUtils.toBean(pageResult, IotRuleChainRespVO.class);
}
@Override
public List<IotRuleChainGraphVO> loadAllEnabled(Long tenantId) {
List<IotRuleChainDO> chains = ruleChainMapper.selectEnabledByTenant(tenantId);
return chains.stream()
.map(chain -> getRuleChainGraph(chain.getId()))
.collect(Collectors.toList());
}
@Override
public List<Map<Long, Long>> loadIdAndVersionSince(Long tenantId, LocalDateTime since) {
List<Map<String, Object>> rawList = ruleChainMapper.selectIdAndVersionSince(tenantId, since);
List<Map<Long, Long>> result = new ArrayList<>();
for (Map<String, Object> row : rawList) {
Map<Long, Long> 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<IotRuleChainSaveReqVO.NodeVO> 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<IotRuleChainSaveReqVO.NodeVO> nodes,
List<IotRuleChainSaveReqVO.LinkVO> links) {
if (links == null || links.isEmpty()) {
return;
}
// 构建邻接表(使用节点列表索引)
int nodeCount = nodes == null ? 0 : nodes.size();
// 节点 id → 索引的映射(使用 sourceNodeId/targetNodeId 作为索引)
// 注意:创建时节点无 id使用列表索引模拟
// 对于已有 id 的节点(更新场景),使用 id对于新增节点id=null使用负数索引
Map<Long, Integer> 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<Long, List<Long>> adjMap = new HashMap<>();
for (IotRuleChainSaveReqVO.LinkVO link : links) {
adjMap.computeIfAbsent(link.getSourceNodeId(), k -> new ArrayList<>())
.add(link.getTargetNodeId());
}
// 收集所有节点 key
Set<Long> allKeys = new HashSet<>(nodeIndexMap.keySet());
// DFS 检测环
Set<Long> visited = new HashSet<>();
Set<Long> 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<Long, List<Long>> adjMap,
Set<Long> visited, Set<Long> inStack) {
visited.add(node);
inStack.add(node);
List<Long> 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<IotRuleChainSaveReqVO.LinkVO> 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<IotRuleChainSaveReqVO.NodeVO> nodeVOs,
List<IotRuleChainSaveReqVO.LinkVO> linkVOs) {
// 插入节点
if (nodeVOs != null && !nodeVOs.isEmpty()) {
List<IotRuleNodeDO> 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<IotRuleLinkDO> 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());
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.viewsh.module.iot.rule.dal.mysql.IotRuleChainMapper">
</mapper>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.viewsh.module.iot.rule.dal.mysql.IotRuleLinkMapper">
</mapper>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.viewsh.module.iot.rule.dal.mysql.IotRuleNodeMapper">
</mapper>

View File

@@ -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());
}
// ========== 用例 22 个 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());
}
// ========== 用例 3A→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());
}
// ========== 用例 5update 一次 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());
}
// ========== 用例 7getRuleChainGraph 返回完整图 ==========
@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;
}
}