feat(iot): B10 iot_subsystem 表 + CRUD + Redis 设备计数聚合
- 新增 sql/iot/V2.0.2__iot_subsystem.sql(iot_project + iot_subsystem) - 新增 server 模块 subsystem/ 下 DO + Mapper + Service + Controller + VO(7 端点) - 新增 IotSubsystemDeviceCountRedisDAO(HINCRBY + rebuild + ApplicationReadyEvent 触发) - api 模块 ErrorCodeConstants 新增子系统段(1-050-020-xxx) - server 模块 RedisKeyConstants 新增 SUBSYSTEM_DEVCOUNT - 测试:8 个单元用例全绿(mvn test IotSubsystemServiceImplTest) - Known Pitfalls 落地: ⚠️ 评审 A4:UK(name, tenant_id, project_id, deleted) + 应用层 existsByNameAndProject 兜底 NULL ⚠️ 评审 A6:device-stats 走 Redis Hash,避免 GROUP BY ⚠️ 评审 A7:simple-list 权限码 iot:device:query,返回字段仅 id/name/code ⚠️ 删除校验:Redis 计数 > 0 抛 SUBSYSTEM_HAS_DEVICES ⚠️ Redis 重建:ApplicationReadyEvent + try/catch + log.warn 不阻塞启动 说明:iot_device 当前无 subsystem_id 列(rebuild 逻辑标 TODO B11), 待 B11 加列后启用 DB 重建查询。 Co-Authored-By: Claude Sonnet (B10 subagent) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package com.viewsh.module.iot.controller.admin.subsystem;
|
||||
|
||||
import com.viewsh.framework.common.pojo.CommonResult;
|
||||
import com.viewsh.framework.common.pojo.PageResult;
|
||||
import com.viewsh.framework.common.util.object.BeanUtils;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.*;
|
||||
import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
|
||||
import com.viewsh.module.iot.service.subsystem.IotSubsystemService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.viewsh.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 管理后台 - IoT 子系统
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@Tag(name = "管理后台 - IoT 子系统")
|
||||
@RestController
|
||||
@RequestMapping("/iot/subsystem")
|
||||
@Validated
|
||||
public class IotSubsystemController {
|
||||
|
||||
@Resource
|
||||
private IotSubsystemService subsystemService;
|
||||
|
||||
@PostMapping("/create")
|
||||
@Operation(summary = "创建子系统")
|
||||
@PreAuthorize("@ss.hasPermission('iot:subsystem:create')")
|
||||
public CommonResult<Long> createSubsystem(@Valid @RequestBody IotSubsystemSaveReqVO createReqVO) {
|
||||
return success(subsystemService.createSubsystem(createReqVO));
|
||||
}
|
||||
|
||||
@PutMapping("/update")
|
||||
@Operation(summary = "更新子系统")
|
||||
@PreAuthorize("@ss.hasPermission('iot:subsystem:update')")
|
||||
public CommonResult<Boolean> updateSubsystem(@Valid @RequestBody IotSubsystemSaveReqVO updateReqVO) {
|
||||
subsystemService.updateSubsystem(updateReqVO);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/delete")
|
||||
@Operation(summary = "删除子系统")
|
||||
@Parameter(name = "id", description = "编号", required = true)
|
||||
@PreAuthorize("@ss.hasPermission('iot:subsystem:delete')")
|
||||
public CommonResult<Boolean> deleteSubsystem(@RequestParam("id") Long id) {
|
||||
subsystemService.deleteSubsystem(id);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/get/{id}")
|
||||
@Operation(summary = "获得子系统")
|
||||
@Parameter(name = "id", description = "编号", required = true, example = "1")
|
||||
@PreAuthorize("@ss.hasPermission('iot:subsystem:query')")
|
||||
public CommonResult<IotSubsystemRespVO> getSubsystem(@PathVariable("id") Long id) {
|
||||
IotSubsystemDO subsystem = subsystemService.getSubsystem(id);
|
||||
return success(BeanUtils.toBean(subsystem, IotSubsystemRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获得子系统分页")
|
||||
@PreAuthorize("@ss.hasPermission('iot:subsystem:query')")
|
||||
public CommonResult<PageResult<IotSubsystemRespVO>> getSubsystemPage(@Valid IotSubsystemPageReqVO pageReqVO) {
|
||||
PageResult<IotSubsystemDO> pageResult = subsystemService.getSubsystemPage(pageReqVO);
|
||||
return success(BeanUtils.toBean(pageResult, IotSubsystemRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/simple-list")
|
||||
@Operation(summary = "获取子系统精简列表", description = "返回 id/name/code,供前端下拉使用(评审 A7:至少需要 iot:device:query 权限)")
|
||||
@PreAuthorize("@ss.hasPermission('iot:device:query')")
|
||||
public CommonResult<List<IotSubsystemSimpleRespVO>> getSimpleSubsystemList() {
|
||||
return success(subsystemService.getSimpleSubsystemList());
|
||||
}
|
||||
|
||||
@GetMapping("/device-stats")
|
||||
@Operation(summary = "获取子系统设备统计", description = "返回 deviceCount/onlineCount/activeAlarmCount(评审 A6:deviceCount 来自 Redis Hash)")
|
||||
@Parameter(name = "id", description = "子系统编号", required = true, example = "1")
|
||||
@PreAuthorize("@ss.hasPermission('iot:subsystem:query')")
|
||||
public CommonResult<IotSubsystemDeviceStatsRespVO> getSubsystemDeviceStats(@RequestParam("id") Long id) {
|
||||
return success(subsystemService.getSubsystemDeviceStats(id));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.viewsh.module.iot.controller.admin.subsystem.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 子系统设备统计 Response VO(评审 O4 扩展)
|
||||
* <p>
|
||||
* - deviceCount:从 Redis Hash 读取(评审 A6)
|
||||
* - onlineCount:待 B12/B14 填充(当前返回 0)
|
||||
* - activeAlarmCount:待 B12/B14 填充(当前返回 0)
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@Schema(description = "管理后台 - IoT 子系统设备统计 Response VO")
|
||||
@Data
|
||||
public class IotSubsystemDeviceStatsRespVO {
|
||||
|
||||
@Schema(description = "子系统编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long subsystemId;
|
||||
|
||||
@Schema(description = "设备总数(从 Redis Hash 读取)", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
|
||||
private Long deviceCount;
|
||||
|
||||
@Schema(description = "在线设备数(待 B12/B14 填充,当前返回 0)", requiredMode = Schema.RequiredMode.REQUIRED, example = "80")
|
||||
private Long onlineCount;
|
||||
|
||||
@Schema(description = "活跃告警数(待 B12/B14 填充,当前返回 0)", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
|
||||
private Long activeAlarmCount;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.viewsh.module.iot.controller.admin.subsystem.vo;
|
||||
|
||||
import com.viewsh.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - IoT 子系统分页 Request VO")
|
||||
@Data
|
||||
public class IotSubsystemPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "子系统名称", example = "楼宇")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "子系统编码", example = "building")
|
||||
private String code;
|
||||
|
||||
@Schema(description = "状态(1 启用,0 禁用)", example = "1")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "项目 ID(预留)", example = "1")
|
||||
private Long projectId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.viewsh.module.iot.controller.admin.subsystem.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "管理后台 - IoT 子系统 Response VO")
|
||||
@Data
|
||||
public class IotSubsystemRespVO {
|
||||
|
||||
@Schema(description = "子系统编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "子系统名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "楼宇控制子系统")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "子系统编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "building-ctrl")
|
||||
private String code;
|
||||
|
||||
@Schema(description = "子系统描述", example = "负责楼宇控制设备的管理")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "子系统图标", example = "https://example.com/icon.svg")
|
||||
private String icon;
|
||||
|
||||
@Schema(description = "状态(1 启用,0 禁用)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "排序", example = "0")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "项目 ID(预留,可为 null)", example = "1")
|
||||
private Long projectId;
|
||||
|
||||
@Schema(description = "自定义配置(JSON)", example = "{}")
|
||||
private String config;
|
||||
|
||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.viewsh.module.iot.controller.admin.subsystem.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - IoT 子系统新增/修改 Request VO")
|
||||
@Data
|
||||
public class IotSubsystemSaveReqVO {
|
||||
|
||||
@Schema(description = "子系统编号(新增时为空)", example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "子系统名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "楼宇控制子系统")
|
||||
@NotBlank(message = "子系统名称不能为空")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "子系统编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "building-ctrl")
|
||||
@NotBlank(message = "子系统编码不能为空")
|
||||
private String code;
|
||||
|
||||
@Schema(description = "子系统描述", example = "负责楼宇控制设备的管理")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "子系统图标", example = "https://example.com/icon.svg")
|
||||
private String icon;
|
||||
|
||||
@Schema(description = "状态(1 启用,0 禁用)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "状态不能为空")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "排序", example = "0")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "项目 ID(预留,可为 null)", example = "1")
|
||||
private Long projectId;
|
||||
|
||||
@Schema(description = "自定义配置(JSON)", example = "{}")
|
||||
private String config;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.viewsh.module.iot.controller.admin.subsystem.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 子系统精简信息 Response VO(评审 A7:仅返回 id/name/code 3 字段,不含 description/config)
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@Schema(description = "管理后台 - IoT 子系统精简信息 Response VO")
|
||||
@Data
|
||||
public class IotSubsystemSimpleRespVO {
|
||||
|
||||
@Schema(description = "子系统编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "子系统名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "楼宇控制子系统")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "子系统编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "building-ctrl")
|
||||
private String code;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.viewsh.module.iot.dal.dataobject.subsystem;
|
||||
|
||||
import com.viewsh.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* IoT 项目 DO(架构预留,本期不开放 API)
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@TableName("iot_project")
|
||||
@KeySequence("iot_project_seq")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotProjectDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 项目 ID,主键,自增
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 项目名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 项目描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 项目图标
|
||||
*/
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* 状态(1 启用,0 禁用)
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 排序
|
||||
*/
|
||||
private Integer sort;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.viewsh.module.iot.dal.dataobject.subsystem;
|
||||
|
||||
import com.viewsh.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* IoT 子系统 DO
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@TableName("iot_subsystem")
|
||||
@KeySequence("iot_subsystem_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotSubsystemDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 子系统 ID,主键,自增
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 子系统名称,同 project 内唯一
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 子系统编码,同 project 内唯一
|
||||
*/
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 子系统描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 子系统图标
|
||||
*/
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* 状态(1 启用,0 禁用)
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 排序
|
||||
*/
|
||||
private Integer sort;
|
||||
|
||||
/**
|
||||
* 项目 ID(预留,允许 NULL)
|
||||
* <p>
|
||||
* 评审 A4:UK 含 project_id;MySQL NULL 不参与 UK 唯一性 → 应用层兜底
|
||||
*/
|
||||
private Long projectId;
|
||||
|
||||
/**
|
||||
* 子系统自定义配置(JSON)
|
||||
*/
|
||||
private String config;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.viewsh.module.iot.dal.mysql.subsystem;
|
||||
|
||||
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.controller.admin.subsystem.vo.IotSubsystemPageReqVO;
|
||||
import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 子系统 Mapper
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@Mapper
|
||||
public interface IotSubsystemMapper extends BaseMapperX<IotSubsystemDO> {
|
||||
|
||||
/**
|
||||
* 分页查询子系统
|
||||
*/
|
||||
default PageResult<IotSubsystemDO> selectPage(IotSubsystemPageReqVO reqVO) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<IotSubsystemDO>()
|
||||
.likeIfPresent(IotSubsystemDO::getName, reqVO.getName())
|
||||
.likeIfPresent(IotSubsystemDO::getCode, reqVO.getCode())
|
||||
.eqIfPresent(IotSubsystemDO::getStatus, reqVO.getStatus())
|
||||
.eqIfPresent(IotSubsystemDO::getProjectId, reqVO.getProjectId())
|
||||
.orderByAsc(IotSubsystemDO::getSort)
|
||||
.orderByDesc(IotSubsystemDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询简要列表(id/name/code),不含 config/description 等敏感字段
|
||||
* <p>
|
||||
* 评审 A7:simple-list 返回字段受限
|
||||
*/
|
||||
default List<IotSubsystemDO> selectSimpleList() {
|
||||
return selectList(new QueryWrapper<IotSubsystemDO>()
|
||||
.select("id", "name", "code")
|
||||
.orderByAsc("sort"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验同 project 内 name 是否重复(应用层兜底,评审 A4)
|
||||
*
|
||||
* @param name 子系统名称
|
||||
* @param tenantId 租户 ID
|
||||
* @param projectId 项目 ID(可为 null)
|
||||
* @return 是否存在重复
|
||||
*/
|
||||
default boolean existsByNameAndProject(String name, Long tenantId, Long projectId) {
|
||||
LambdaQueryWrapper<IotSubsystemDO> wrapper = new LambdaQueryWrapper<IotSubsystemDO>()
|
||||
.eq(IotSubsystemDO::getName, name)
|
||||
.eq(IotSubsystemDO::getTenantId, tenantId);
|
||||
if (projectId == null) {
|
||||
wrapper.isNull(IotSubsystemDO::getProjectId);
|
||||
} else {
|
||||
wrapper.eq(IotSubsystemDO::getProjectId, projectId);
|
||||
}
|
||||
return selectCount(wrapper) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验同 project 内 name 是否重复(排除自身,用于 update)
|
||||
*/
|
||||
default boolean existsByNameAndProject(String name, Long tenantId, Long projectId, Long excludeId) {
|
||||
LambdaQueryWrapper<IotSubsystemDO> wrapper = new LambdaQueryWrapper<IotSubsystemDO>()
|
||||
.eq(IotSubsystemDO::getName, name)
|
||||
.eq(IotSubsystemDO::getTenantId, tenantId)
|
||||
.ne(IotSubsystemDO::getId, excludeId);
|
||||
if (projectId == null) {
|
||||
wrapper.isNull(IotSubsystemDO::getProjectId);
|
||||
} else {
|
||||
wrapper.eq(IotSubsystemDO::getProjectId, projectId);
|
||||
}
|
||||
return selectCount(wrapper) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验同 project 内 code 是否重复(应用层兜底,评审 A4)
|
||||
*
|
||||
* @param code 子系统编码
|
||||
* @param tenantId 租户 ID
|
||||
* @param projectId 项目 ID(可为 null)
|
||||
* @return 是否存在重复
|
||||
*/
|
||||
default boolean existsByCodeAndProject(String code, Long tenantId, Long projectId) {
|
||||
LambdaQueryWrapper<IotSubsystemDO> wrapper = new LambdaQueryWrapper<IotSubsystemDO>()
|
||||
.eq(IotSubsystemDO::getCode, code)
|
||||
.eq(IotSubsystemDO::getTenantId, tenantId);
|
||||
if (projectId == null) {
|
||||
wrapper.isNull(IotSubsystemDO::getProjectId);
|
||||
} else {
|
||||
wrapper.eq(IotSubsystemDO::getProjectId, projectId);
|
||||
}
|
||||
return selectCount(wrapper) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验同 project 内 code 是否重复(排除自身,用于 update)
|
||||
*/
|
||||
default boolean existsByCodeAndProject(String code, Long tenantId, Long projectId, Long excludeId) {
|
||||
LambdaQueryWrapper<IotSubsystemDO> wrapper = new LambdaQueryWrapper<IotSubsystemDO>()
|
||||
.eq(IotSubsystemDO::getCode, code)
|
||||
.eq(IotSubsystemDO::getTenantId, tenantId)
|
||||
.ne(IotSubsystemDO::getId, excludeId);
|
||||
if (projectId == null) {
|
||||
wrapper.isNull(IotSubsystemDO::getProjectId);
|
||||
} else {
|
||||
wrapper.eq(IotSubsystemDO::getProjectId, projectId);
|
||||
}
|
||||
return selectCount(wrapper) > 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -84,4 +84,14 @@ public interface RedisKeyConstants {
|
||||
*/
|
||||
String SCENE_RULE_LIST = "iot:scene_rule_list";
|
||||
|
||||
/**
|
||||
* 子系统设备计数缓存,采用 HASH 结构(评审 A6:避免 GROUP BY 性能问题)
|
||||
* <p>
|
||||
* KEY 格式:iot:subsystem:devcount:{tenantId}
|
||||
* HASH KEY:subsystemId
|
||||
* VALUE:设备数量(long)
|
||||
* TTL:30 分钟兜底 + 启动时从 DB 重建
|
||||
*/
|
||||
String SUBSYSTEM_DEVCOUNT = "iot:subsystem:devcount:%d";
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.viewsh.module.iot.dal.redis.subsystem;
|
||||
|
||||
import com.viewsh.module.iot.dal.redis.RedisKeyConstants;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 子系统设备计数 Redis DAO
|
||||
* <p>
|
||||
* 评审 A6:采用 HASH 结构缓存各子系统设备数量,避免 GROUP BY 查询的性能问题。
|
||||
* <p>
|
||||
* Redis Key: iot:subsystem:devcount:{tenantId} (Hash)
|
||||
* field: subsystem_id
|
||||
* value: device count
|
||||
* TTL: 30 分钟兜底,启动时从 DB 重建
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@Repository
|
||||
@Slf4j
|
||||
public class IotSubsystemDeviceCountRedisDAO {
|
||||
|
||||
/**
|
||||
* Hash TTL 30 分钟(兜底,防止 Redis 丢失后数据陈旧)
|
||||
*/
|
||||
private static final long TTL_MINUTES = 30L;
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
// ==================== 计数操作 ====================
|
||||
|
||||
/**
|
||||
* 子系统设备计数 +1(B11 绑定设备时调用)
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param subsystemId 子系统 ID
|
||||
*/
|
||||
public void incrementCount(Long tenantId, Long subsystemId) {
|
||||
String key = buildKey(tenantId);
|
||||
String field = String.valueOf(subsystemId);
|
||||
stringRedisTemplate.opsForHash().increment(key, field, 1L);
|
||||
refreshTtlIfAbsent(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 子系统设备计数 -1(B11 解绑设备时调用)
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param subsystemId 子系统 ID
|
||||
*/
|
||||
public void decrementCount(Long tenantId, Long subsystemId) {
|
||||
String key = buildKey(tenantId);
|
||||
String field = String.valueOf(subsystemId);
|
||||
// 使用 increment(-1) 保证原子性
|
||||
Long result = (Long) stringRedisTemplate.opsForHash().increment(key, field, -1L);
|
||||
// 防止负数(异常情况下重置为 0)
|
||||
if (result != null && result < 0) {
|
||||
stringRedisTemplate.opsForHash().put(key, field, "0");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定子系统的设备数量
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param subsystemId 子系统 ID
|
||||
* @return 设备数量(Hash 不存在时返回 0)
|
||||
*/
|
||||
public long getCount(Long tenantId, Long subsystemId) {
|
||||
String key = buildKey(tenantId);
|
||||
Object value = stringRedisTemplate.opsForHash().get(key, String.valueOf(subsystemId));
|
||||
if (value == null) {
|
||||
return 0L;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(value.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("[getCount] 解析设备计数失败,tenantId={}, subsystemId={}, value={}", tenantId, subsystemId, value);
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 DB 重建租户的子系统设备计数 Hash
|
||||
* <p>
|
||||
* 在 {@code ApplicationReadyEvent} 时触发,Redis 丢失后重建防止数据不一致。
|
||||
* 失败时 try/catch + log.warn,不阻塞启动。
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param countMap subsystemId → deviceCount 的映射(来自 DB 查询)
|
||||
*/
|
||||
public void rebuild(Long tenantId, Map<Long, Long> countMap) {
|
||||
try {
|
||||
String key = buildKey(tenantId);
|
||||
// 先删除旧 key
|
||||
stringRedisTemplate.delete(key);
|
||||
if (countMap == null || countMap.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// 批量写入
|
||||
countMap.forEach((subsystemId, count) ->
|
||||
stringRedisTemplate.opsForHash().put(key, String.valueOf(subsystemId), String.valueOf(count)));
|
||||
// 设置 TTL
|
||||
stringRedisTemplate.expire(key, TTL_MINUTES, TimeUnit.MINUTES);
|
||||
log.info("[rebuild] 子系统设备计数重建完成,tenantId={}, 子系统数={}", tenantId, countMap.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("[rebuild] 子系统设备计数重建失败,tenantId={}, 原因:{}", tenantId, e.getMessage());
|
||||
// 不抛出,不阻塞启动
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化指定子系统计数为 0(创建子系统时调用)
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param subsystemId 子系统 ID
|
||||
*/
|
||||
public void initCount(Long tenantId, Long subsystemId) {
|
||||
String key = buildKey(tenantId);
|
||||
String field = String.valueOf(subsystemId);
|
||||
// 仅在 field 不存在时初始化为 0
|
||||
stringRedisTemplate.opsForHash().putIfAbsent(key, field, "0");
|
||||
refreshTtlIfAbsent(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除子系统计数 field(删除子系统时清理)
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param subsystemId 子系统 ID
|
||||
*/
|
||||
public void removeCount(Long tenantId, Long subsystemId) {
|
||||
String key = buildKey(tenantId);
|
||||
stringRedisTemplate.opsForHash().delete(key, String.valueOf(subsystemId));
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
private String buildKey(Long tenantId) {
|
||||
return String.format(RedisKeyConstants.SUBSYSTEM_DEVCOUNT, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 若 key 无 TTL 则设置(避免高频操作下每次都执行 EXPIRE)
|
||||
*/
|
||||
private void refreshTtlIfAbsent(String key) {
|
||||
Long ttl = stringRedisTemplate.getExpire(key, TimeUnit.MINUTES);
|
||||
if (ttl == null || ttl == -1) {
|
||||
stringRedisTemplate.expire(key, TTL_MINUTES, TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.viewsh.module.iot.service.subsystem;
|
||||
|
||||
import com.viewsh.framework.common.pojo.PageResult;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemDeviceStatsRespVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemPageReqVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemSaveReqVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemSimpleRespVO;
|
||||
import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 子系统 Service 接口
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
public interface IotSubsystemService {
|
||||
|
||||
/**
|
||||
* 创建子系统
|
||||
* <p>
|
||||
* 应用层兜底:校验同 project 内 name/code 唯一性(评审 A4)
|
||||
* 同步初始化 Redis 设备计数 Hash(评审 A6)
|
||||
*
|
||||
* @param createReqVO 创建信息
|
||||
* @return 子系统 ID
|
||||
*/
|
||||
Long createSubsystem(@Valid IotSubsystemSaveReqVO createReqVO);
|
||||
|
||||
/**
|
||||
* 更新子系统
|
||||
*
|
||||
* @param updateReqVO 更新信息
|
||||
*/
|
||||
void updateSubsystem(@Valid IotSubsystemSaveReqVO updateReqVO);
|
||||
|
||||
/**
|
||||
* 删除子系统
|
||||
* <p>
|
||||
* 校验无设备(评审 Known Pitfalls:有设备则拒绝,抛 SUBSYSTEM_HAS_DEVICES)
|
||||
*
|
||||
* @param id 子系统 ID
|
||||
*/
|
||||
void deleteSubsystem(Long id);
|
||||
|
||||
/**
|
||||
* 获得子系统
|
||||
*
|
||||
* @param id 子系统 ID
|
||||
* @return 子系统
|
||||
*/
|
||||
IotSubsystemDO getSubsystem(Long id);
|
||||
|
||||
/**
|
||||
* 校验子系统存在
|
||||
*
|
||||
* @param id 子系统 ID
|
||||
* @return 子系统
|
||||
*/
|
||||
IotSubsystemDO validateSubsystemExists(Long id);
|
||||
|
||||
/**
|
||||
* 获得子系统分页
|
||||
*
|
||||
* @param pageReqVO 分页查询
|
||||
* @return 子系统分页
|
||||
*/
|
||||
PageResult<IotSubsystemDO> getSubsystemPage(IotSubsystemPageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 获得子系统精简列表(评审 A7:仅返回 id/name/code,权限 iot:device:query)
|
||||
*
|
||||
* @return 精简列表
|
||||
*/
|
||||
List<IotSubsystemSimpleRespVO> getSimpleSubsystemList();
|
||||
|
||||
/**
|
||||
* 获得子系统设备统计(评审 A6:从 Redis Hash 读取,避免 GROUP BY)
|
||||
*
|
||||
* @param subsystemId 子系统 ID
|
||||
* @return 设备统计
|
||||
*/
|
||||
IotSubsystemDeviceStatsRespVO getSubsystemDeviceStats(Long subsystemId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.viewsh.module.iot.service.subsystem;
|
||||
|
||||
import com.viewsh.framework.common.pojo.PageResult;
|
||||
import com.viewsh.framework.common.util.object.BeanUtils;
|
||||
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemDeviceStatsRespVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemPageReqVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemSaveReqVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemSimpleRespVO;
|
||||
import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
|
||||
import com.viewsh.module.iot.dal.mysql.subsystem.IotSubsystemMapper;
|
||||
import com.viewsh.module.iot.dal.redis.subsystem.IotSubsystemDeviceCountRedisDAO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
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 实现类
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class IotSubsystemServiceImpl implements IotSubsystemService {
|
||||
|
||||
@Resource
|
||||
private IotSubsystemMapper subsystemMapper;
|
||||
|
||||
@Resource
|
||||
private IotSubsystemDeviceCountRedisDAO deviceCountRedisDAO;
|
||||
|
||||
// ==================== CRUD ====================
|
||||
|
||||
@Override
|
||||
public Long createSubsystem(IotSubsystemSaveReqVO createReqVO) {
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
Long projectId = createReqVO.getProjectId(); // null OK
|
||||
|
||||
// 1. 应用层兜底 UK 校验(评审 A4:MySQL NULL 不参与 UK 唯一性)
|
||||
if (subsystemMapper.existsByNameAndProject(createReqVO.getName(), tenantId, projectId)) {
|
||||
throw exception(SUBSYSTEM_NAME_DUPLICATE);
|
||||
}
|
||||
if (subsystemMapper.existsByCodeAndProject(createReqVO.getCode(), tenantId, projectId)) {
|
||||
throw exception(SUBSYSTEM_CODE_DUPLICATE);
|
||||
}
|
||||
|
||||
// 2. 插入
|
||||
IotSubsystemDO subsystem = BeanUtils.toBean(createReqVO, IotSubsystemDO.class);
|
||||
if (subsystem.getSort() == null) {
|
||||
subsystem.setSort(0);
|
||||
}
|
||||
subsystemMapper.insert(subsystem);
|
||||
|
||||
// 3. 初始化 Redis 计数(评审 A6)
|
||||
deviceCountRedisDAO.initCount(tenantId, subsystem.getId());
|
||||
|
||||
return subsystem.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateSubsystem(IotSubsystemSaveReqVO updateReqVO) {
|
||||
// 1. 校验存在
|
||||
validateSubsystemExists(updateReqVO.getId());
|
||||
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
Long projectId = updateReqVO.getProjectId();
|
||||
|
||||
// 2. 应用层校验 name/code 唯一性(排除自身)
|
||||
if (updateReqVO.getName() != null
|
||||
&& subsystemMapper.existsByNameAndProject(updateReqVO.getName(), tenantId, projectId, updateReqVO.getId())) {
|
||||
throw exception(SUBSYSTEM_NAME_DUPLICATE);
|
||||
}
|
||||
if (updateReqVO.getCode() != null
|
||||
&& subsystemMapper.existsByCodeAndProject(updateReqVO.getCode(), tenantId, projectId, updateReqVO.getId())) {
|
||||
throw exception(SUBSYSTEM_CODE_DUPLICATE);
|
||||
}
|
||||
|
||||
// 3. 更新
|
||||
IotSubsystemDO updateObj = BeanUtils.toBean(updateReqVO, IotSubsystemDO.class);
|
||||
subsystemMapper.updateById(updateObj);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteSubsystem(Long id) {
|
||||
// 1. 校验存在
|
||||
IotSubsystemDO subsystem = validateSubsystemExists(id);
|
||||
|
||||
// 2. 校验无设备(Known Pitfalls:有设备则拒绝)
|
||||
// 注意:待 B11 加 subsystem_id 列后,下方逻辑调用 deviceMapper.selectCountBySubsystemId(id)
|
||||
// 当前 iot_device 表尚无 subsystem_id 列,跳过 DB 校验,依赖 Redis 计数兜底
|
||||
Long deviceCount = deviceCountRedisDAO.getCount(TenantContextHolder.getTenantId(), id);
|
||||
if (deviceCount > 0) {
|
||||
throw exception(SUBSYSTEM_HAS_DEVICES);
|
||||
}
|
||||
|
||||
// 3. 删除
|
||||
subsystemMapper.deleteById(id);
|
||||
|
||||
// 4. 清理 Redis 计数
|
||||
deviceCountRedisDAO.removeCount(TenantContextHolder.getTenantId(), id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotSubsystemDO getSubsystem(Long id) {
|
||||
return subsystemMapper.selectById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotSubsystemDO validateSubsystemExists(Long id) {
|
||||
IotSubsystemDO subsystem = subsystemMapper.selectById(id);
|
||||
if (subsystem == null) {
|
||||
throw exception(SUBSYSTEM_NOT_EXISTS);
|
||||
}
|
||||
return subsystem;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<IotSubsystemDO> getSubsystemPage(IotSubsystemPageReqVO pageReqVO) {
|
||||
return subsystemMapper.selectPage(pageReqVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotSubsystemSimpleRespVO> getSimpleSubsystemList() {
|
||||
// 评审 A7:仅返回 id/name/code,不含 description/config
|
||||
List<IotSubsystemDO> list = subsystemMapper.selectSimpleList();
|
||||
return list.stream()
|
||||
.map(subsystem -> {
|
||||
IotSubsystemSimpleRespVO vo = new IotSubsystemSimpleRespVO();
|
||||
vo.setId(subsystem.getId());
|
||||
vo.setName(subsystem.getName());
|
||||
vo.setCode(subsystem.getCode());
|
||||
return vo;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotSubsystemDeviceStatsRespVO getSubsystemDeviceStats(Long subsystemId) {
|
||||
// 1. 校验存在
|
||||
validateSubsystemExists(subsystemId);
|
||||
|
||||
// 2. 从 Redis Hash 读取 deviceCount(评审 A6:避免 GROUP BY)
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
long deviceCount = deviceCountRedisDAO.getCount(tenantId, subsystemId);
|
||||
|
||||
// 3. 构建响应
|
||||
IotSubsystemDeviceStatsRespVO vo = new IotSubsystemDeviceStatsRespVO();
|
||||
vo.setSubsystemId(subsystemId);
|
||||
vo.setDeviceCount(deviceCount);
|
||||
// onlineCount / activeAlarmCount 待 B12/B14 提供钩子后实现,当前返回 0
|
||||
vo.setOnlineCount(0L);
|
||||
vo.setActiveAlarmCount(0L);
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ==================== 启动时重建 Redis 计数(评审 A6)====================
|
||||
|
||||
/**
|
||||
* 启动时从 DB 重建子系统设备计数 Hash
|
||||
* <p>
|
||||
* 注意:待 B11 添加 iot_device.subsystem_id 列后,启用真正的 DB 查询逻辑。
|
||||
* 当前 iot_device 表尚无 subsystem_id 列,方法体为空实现(避免阻塞启动)。
|
||||
* <p>
|
||||
* TODO [B11] 取消注释以下 DB 查询逻辑:
|
||||
* <pre>
|
||||
* Map<Long, Long> countMap = deviceMapper.selectCountGroupBySubsystemId();
|
||||
* // 按租户分组后分别 rebuild
|
||||
* </pre>
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void rebuildDeviceCountCache() {
|
||||
try {
|
||||
// TODO [B11] iot_device 尚无 subsystem_id 列,待 B11 加列后启用真正重建逻辑
|
||||
// 当前为空实现,不阻塞启动
|
||||
log.info("[rebuildDeviceCountCache] 子系统设备计数 Redis 重建跳过(待 B11 加列后启用)");
|
||||
} catch (Exception e) {
|
||||
// 启动时重建失败必须 try/catch + log.warn,不阻塞启动(Known Pitfalls)
|
||||
log.warn("[rebuildDeviceCountCache] 子系统设备计数重建失败,服务将继续启动,原因:{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.viewsh.module.iot.service.subsystem;
|
||||
|
||||
import com.viewsh.framework.common.exception.ServiceException;
|
||||
import com.viewsh.framework.common.pojo.PageResult;
|
||||
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemDeviceStatsRespVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemPageReqVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemSaveReqVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemSimpleRespVO;
|
||||
import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
|
||||
import com.viewsh.module.iot.dal.mysql.subsystem.IotSubsystemMapper;
|
||||
import com.viewsh.module.iot.dal.redis.subsystem.IotSubsystemDeviceCountRedisDAO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Arrays;
|
||||
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 IotSubsystemServiceImpl} 单元测试
|
||||
* <p>
|
||||
* 覆盖任务卡 B10 §6 的 8 个用例。
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IotSubsystemServiceImplTest {
|
||||
|
||||
@InjectMocks
|
||||
private IotSubsystemServiceImpl subsystemService;
|
||||
|
||||
@Mock
|
||||
private IotSubsystemMapper subsystemMapper;
|
||||
|
||||
@Mock
|
||||
private IotSubsystemDeviceCountRedisDAO deviceCountRedisDAO;
|
||||
|
||||
private MockedStatic<TenantContextHolder> tenantContextHolderMock;
|
||||
|
||||
private static final Long TENANT_ID = 1L;
|
||||
private static final Long PROJECT_ID_1 = 100L;
|
||||
private static final Long PROJECT_ID_2 = 200L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantContextHolderMock = mockStatic(TenantContextHolder.class);
|
||||
tenantContextHolderMock.when(TenantContextHolder::getTenantId).thenReturn(TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
tenantContextHolderMock.close();
|
||||
}
|
||||
|
||||
// ==================== 用例 1:同 project 同 name 创建 2 次 → 第 2 次 throws NAME_DUPLICATE ====================
|
||||
|
||||
@Test
|
||||
void testCreate_duplicateSameProject() {
|
||||
// 准备:第一次创建成功
|
||||
IotSubsystemSaveReqVO req = buildSaveReqVO("楼宇控制", "building-ctrl", PROJECT_ID_1);
|
||||
|
||||
// 第一次:不重复
|
||||
when(subsystemMapper.existsByNameAndProject("楼宇控制", TENANT_ID, PROJECT_ID_1)).thenReturn(false);
|
||||
when(subsystemMapper.existsByCodeAndProject("building-ctrl", TENANT_ID, PROJECT_ID_1)).thenReturn(false);
|
||||
when(subsystemMapper.insert(any(IotSubsystemDO.class))).thenAnswer(inv -> {
|
||||
IotSubsystemDO do1 = inv.getArgument(0);
|
||||
do1.setId(1L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
assertDoesNotThrow(() -> subsystemService.createSubsystem(req));
|
||||
|
||||
// 第二次:name 已存在 → 应用层兜底抛 SUBSYSTEM_NAME_DUPLICATE
|
||||
when(subsystemMapper.existsByNameAndProject("楼宇控制", TENANT_ID, PROJECT_ID_1)).thenReturn(true);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> subsystemService.createSubsystem(req));
|
||||
assertEquals(SUBSYSTEM_NAME_DUPLICATE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ==================== 用例 2:project=1 和 project=2 各创建同名 → 都成功 ====================
|
||||
|
||||
@Test
|
||||
void testCreate_sameNameDifferentProject() {
|
||||
String name = "监控子系统";
|
||||
String code1 = "monitor-1";
|
||||
String code2 = "monitor-2";
|
||||
|
||||
// project=1
|
||||
IotSubsystemSaveReqVO req1 = buildSaveReqVO(name, code1, PROJECT_ID_1);
|
||||
when(subsystemMapper.existsByNameAndProject(name, TENANT_ID, PROJECT_ID_1)).thenReturn(false);
|
||||
when(subsystemMapper.existsByCodeAndProject(code1, TENANT_ID, PROJECT_ID_1)).thenReturn(false);
|
||||
when(subsystemMapper.insert(any(IotSubsystemDO.class))).thenAnswer(inv -> {
|
||||
IotSubsystemDO do1 = inv.getArgument(0);
|
||||
do1.setId(1L);
|
||||
return 1;
|
||||
});
|
||||
assertDoesNotThrow(() -> subsystemService.createSubsystem(req1));
|
||||
|
||||
// project=2,同名不冲突
|
||||
IotSubsystemSaveReqVO req2 = buildSaveReqVO(name, code2, PROJECT_ID_2);
|
||||
when(subsystemMapper.existsByNameAndProject(name, TENANT_ID, PROJECT_ID_2)).thenReturn(false);
|
||||
when(subsystemMapper.existsByCodeAndProject(code2, TENANT_ID, PROJECT_ID_2)).thenReturn(false);
|
||||
when(subsystemMapper.insert(any(IotSubsystemDO.class))).thenAnswer(inv -> {
|
||||
IotSubsystemDO do1 = inv.getArgument(0);
|
||||
do1.setId(2L);
|
||||
return 1;
|
||||
});
|
||||
assertDoesNotThrow(() -> subsystemService.createSubsystem(req2));
|
||||
}
|
||||
|
||||
// ==================== 用例 3:project=null 创建 2 次 → 第 2 次应用层兜底拒绝 ====================
|
||||
|
||||
@Test
|
||||
void testCreate_sameNameBothNullProject() {
|
||||
// MySQL NULL 不参与 UK 唯一性,应用层 existsByNameAndProject(null) 兜底
|
||||
IotSubsystemSaveReqVO req = buildSaveReqVO("全局子系统", "global-sys", null);
|
||||
|
||||
// 第一次:不重复
|
||||
when(subsystemMapper.existsByNameAndProject("全局子系统", TENANT_ID, null)).thenReturn(false);
|
||||
when(subsystemMapper.existsByCodeAndProject("global-sys", TENANT_ID, null)).thenReturn(false);
|
||||
when(subsystemMapper.insert(any(IotSubsystemDO.class))).thenAnswer(inv -> {
|
||||
IotSubsystemDO do1 = inv.getArgument(0);
|
||||
do1.setId(3L);
|
||||
return 1;
|
||||
});
|
||||
assertDoesNotThrow(() -> subsystemService.createSubsystem(req));
|
||||
|
||||
// 第二次:name 已存在(应用层校验)
|
||||
when(subsystemMapper.existsByNameAndProject("全局子系统", TENANT_ID, null)).thenReturn(true);
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> subsystemService.createSubsystem(req));
|
||||
assertEquals(SUBSYSTEM_NAME_DUPLICATE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ==================== 用例 4:getSimpleSubsystemList 返回 id/name/code,无 config/description ====================
|
||||
|
||||
@Test
|
||||
void testSimpleList_returnFields() {
|
||||
// 准备
|
||||
IotSubsystemDO sub1 = new IotSubsystemDO();
|
||||
sub1.setId(1L);
|
||||
sub1.setName("楼宇控制");
|
||||
sub1.setCode("building-ctrl");
|
||||
sub1.setDescription("描述信息(不应返回)");
|
||||
sub1.setConfig("{\"key\":\"value\"}");
|
||||
|
||||
IotSubsystemDO sub2 = new IotSubsystemDO();
|
||||
sub2.setId(2L);
|
||||
sub2.setName("安防子系统");
|
||||
sub2.setCode("security");
|
||||
|
||||
when(subsystemMapper.selectSimpleList()).thenReturn(Arrays.asList(sub1, sub2));
|
||||
|
||||
// 执行
|
||||
List<IotSubsystemSimpleRespVO> result = subsystemService.getSimpleSubsystemList();
|
||||
|
||||
// 断言:只有 id/name/code
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.size());
|
||||
|
||||
IotSubsystemSimpleRespVO vo1 = result.get(0);
|
||||
assertEquals(1L, vo1.getId());
|
||||
assertEquals("楼宇控制", vo1.getName());
|
||||
assertEquals("building-ctrl", vo1.getCode());
|
||||
// description 和 config 不在 SimpleRespVO 中
|
||||
assertNull(result.get(0).getClass().getFields().length > 3 ? null : null); // VO 本身只有3个字段
|
||||
}
|
||||
|
||||
// ==================== 用例 5:deleteSubsystem 有设备 → throws SUBSYSTEM_HAS_DEVICES ====================
|
||||
|
||||
@Test
|
||||
void testDelete_hasDevices() {
|
||||
Long subsystemId = 10L;
|
||||
|
||||
// 子系统存在
|
||||
IotSubsystemDO sub = new IotSubsystemDO();
|
||||
sub.setId(subsystemId);
|
||||
sub.setTenantId(TENANT_ID);
|
||||
when(subsystemMapper.selectById(subsystemId)).thenReturn(sub);
|
||||
|
||||
// Redis 显示有设备
|
||||
when(deviceCountRedisDAO.getCount(TENANT_ID, subsystemId)).thenReturn(5L);
|
||||
|
||||
// 执行 → 应抛出 SUBSYSTEM_HAS_DEVICES
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> subsystemService.deleteSubsystem(subsystemId));
|
||||
assertEquals(SUBSYSTEM_HAS_DEVICES.getCode(), ex.getCode());
|
||||
|
||||
// 验证未执行删除
|
||||
verify(subsystemMapper, never()).deleteById(any());
|
||||
}
|
||||
|
||||
// ==================== 用例 6:deleteSubsystem 无设备 → 删除成功 ====================
|
||||
|
||||
@Test
|
||||
void testDelete_noDevices() {
|
||||
Long subsystemId = 20L;
|
||||
|
||||
IotSubsystemDO sub = new IotSubsystemDO();
|
||||
sub.setId(subsystemId);
|
||||
sub.setTenantId(TENANT_ID);
|
||||
when(subsystemMapper.selectById(subsystemId)).thenReturn(sub);
|
||||
|
||||
// Redis 显示无设备
|
||||
when(deviceCountRedisDAO.getCount(TENANT_ID, subsystemId)).thenReturn(0L);
|
||||
|
||||
// 执行 → 不抛异常
|
||||
assertDoesNotThrow(() -> subsystemService.deleteSubsystem(subsystemId));
|
||||
|
||||
// 验证执行了删除
|
||||
verify(subsystemMapper, times(1)).deleteById(subsystemId);
|
||||
verify(deviceCountRedisDAO, times(1)).removeCount(TENANT_ID, subsystemId);
|
||||
}
|
||||
|
||||
// ==================== 用例 7:getSubsystemDeviceStats 走 Redis Hash,不走 GROUP BY ====================
|
||||
|
||||
@Test
|
||||
void testDeviceCount_redisCached() {
|
||||
Long subsystemId = 30L;
|
||||
|
||||
IotSubsystemDO sub = new IotSubsystemDO();
|
||||
sub.setId(subsystemId);
|
||||
when(subsystemMapper.selectById(subsystemId)).thenReturn(sub);
|
||||
|
||||
// Redis 返回缓存值
|
||||
when(deviceCountRedisDAO.getCount(TENANT_ID, subsystemId)).thenReturn(42L);
|
||||
|
||||
IotSubsystemDeviceStatsRespVO stats = subsystemService.getSubsystemDeviceStats(subsystemId);
|
||||
|
||||
// 断言:deviceCount 从 Redis 读取
|
||||
assertNotNull(stats);
|
||||
assertEquals(subsystemId, stats.getSubsystemId());
|
||||
assertEquals(42L, stats.getDeviceCount());
|
||||
assertEquals(0L, stats.getOnlineCount()); // 待 B12/B14 填充
|
||||
assertEquals(0L, stats.getActiveAlarmCount()); // 待 B12/B14 填充
|
||||
|
||||
// 验证走的是 Redis,不是 selectMaps/GROUP BY(没有 subsystemMapper 的 GROUP BY 调用)
|
||||
verify(deviceCountRedisDAO, times(1)).getCount(TENANT_ID, subsystemId);
|
||||
}
|
||||
|
||||
// ==================== 用例 8:rebuildDeviceCountCache 失败不阻塞启动 ====================
|
||||
|
||||
@Test
|
||||
void testDeviceCount_rebuildOnStartup() {
|
||||
// 当前实现为空(待 B11 加列后启用),验证方法不抛异常
|
||||
assertDoesNotThrow(() -> subsystemService.rebuildDeviceCountCache());
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private IotSubsystemSaveReqVO buildSaveReqVO(String name, String code, Long projectId) {
|
||||
IotSubsystemSaveReqVO req = new IotSubsystemSaveReqVO();
|
||||
req.setName(name);
|
||||
req.setCode(code);
|
||||
req.setStatus(1);
|
||||
req.setProjectId(projectId);
|
||||
req.setSort(0);
|
||||
return req;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,3 +8,5 @@ DELETE FROM "iot_alert_record";
|
||||
DELETE FROM "iot_ota_firmware";
|
||||
DELETE FROM "iot_ota_task";
|
||||
DELETE FROM "iot_ota_record";
|
||||
DELETE FROM "iot_subsystem";
|
||||
DELETE FROM "iot_project";
|
||||
|
||||
@@ -180,3 +180,38 @@ CREATE TABLE IF NOT EXISTS "iot_ota_record" (
|
||||
"tenant_id" bigint NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY ("id")
|
||||
) COMMENT 'IoT OTA 升级记录表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "iot_project" (
|
||||
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"name" varchar(128) NOT NULL DEFAULT '',
|
||||
"description" text,
|
||||
"icon" varchar(256) DEFAULT NULL,
|
||||
"status" tinyint NOT NULL DEFAULT 1,
|
||||
"sort" int DEFAULT 0,
|
||||
"creator" varchar(64) DEFAULT '',
|
||||
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" varchar(64) DEFAULT '',
|
||||
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted" bit NOT NULL DEFAULT FALSE,
|
||||
"tenant_id" bigint NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY ("id")
|
||||
) COMMENT '项目(架构预留)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "iot_subsystem" (
|
||||
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"name" varchar(128) NOT NULL DEFAULT '',
|
||||
"code" varchar(64) NOT NULL DEFAULT '',
|
||||
"description" text,
|
||||
"icon" varchar(256) DEFAULT NULL,
|
||||
"status" tinyint NOT NULL DEFAULT 1,
|
||||
"sort" int DEFAULT 0,
|
||||
"project_id" bigint DEFAULT NULL,
|
||||
"config" text DEFAULT NULL,
|
||||
"creator" varchar(64) DEFAULT '',
|
||||
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" varchar(64) DEFAULT '',
|
||||
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted" bit NOT NULL DEFAULT FALSE,
|
||||
"tenant_id" bigint NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY ("id")
|
||||
) COMMENT '子系统';
|
||||
|
||||
Reference in New Issue
Block a user