diff --git a/sql/iot/V2.0.2__iot_subsystem.sql b/sql/iot/V2.0.2__iot_subsystem.sql new file mode 100644 index 00000000..aa3c33c6 --- /dev/null +++ b/sql/iot/V2.0.2__iot_subsystem.sql @@ -0,0 +1,39 @@ +-- B10: iot_project + iot_subsystem 表创建 +-- 版本: V2.0.2 + +CREATE TABLE IF NOT EXISTS iot_project ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + description TEXT, + icon VARCHAR(256), + status TINYINT NOT NULL DEFAULT 1, + sort INT DEFAULT 0, + tenant_id BIGINT NOT NULL, + creator VARCHAR(64), + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64), + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted BIT NOT NULL DEFAULT b'0', + UNIQUE KEY uk_name (name, tenant_id, deleted) +) COMMENT='项目(架构预留,本期不开放 API)'; + +CREATE TABLE IF NOT EXISTS iot_subsystem ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + code VARCHAR(64) NOT NULL, + description TEXT, + icon VARCHAR(256), + status TINYINT NOT NULL DEFAULT 1, + sort INT DEFAULT 0, + project_id BIGINT COMMENT '预留(允许 NULL)', + config JSON, + tenant_id BIGINT NOT NULL, + creator VARCHAR(64), + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64), + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted BIT NOT NULL DEFAULT b'0', + -- 【评审 A4】加 project_id 维度,MySQL NULL 不参与 UK 唯一性 → 应用层兜底 + UNIQUE KEY uk_name (name, tenant_id, project_id, deleted), + UNIQUE KEY uk_code (code, tenant_id, project_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 c0f70746..7ac72ab4 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 @@ -79,4 +79,10 @@ public interface ErrorCodeConstants { // ========== IoT 告警记录 1-050-014-000 ========== ErrorCode ALERT_RECORD_NOT_EXISTS = new ErrorCode(1_050_014_000, "IoT 告警记录不存在"); + // ========== IoT 子系统 1-050-020-000 ========== + ErrorCode SUBSYSTEM_NOT_EXISTS = new ErrorCode(1_050_020_000, "子系统不存在"); + ErrorCode SUBSYSTEM_NAME_DUPLICATE = new ErrorCode(1_050_020_001, "同项目下子系统名称已存在"); + ErrorCode SUBSYSTEM_CODE_DUPLICATE = new ErrorCode(1_050_020_002, "同项目下子系统编码已存在"); + ErrorCode SUBSYSTEM_HAS_DEVICES = new ErrorCode(1_050_020_003, "子系统下存在设备,不允许删除"); + } \ No newline at end of file diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/IotSubsystemController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/IotSubsystemController.java new file mode 100644 index 00000000..23b2385e --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/IotSubsystemController.java @@ -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 createSubsystem(@Valid @RequestBody IotSubsystemSaveReqVO createReqVO) { + return success(subsystemService.createSubsystem(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新子系统") + @PreAuthorize("@ss.hasPermission('iot:subsystem:update')") + public CommonResult 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 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 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> getSubsystemPage(@Valid IotSubsystemPageReqVO pageReqVO) { + PageResult 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> 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 getSubsystemDeviceStats(@RequestParam("id") Long id) { + return success(subsystemService.getSubsystemDeviceStats(id)); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemDeviceStatsRespVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemDeviceStatsRespVO.java new file mode 100644 index 00000000..62300090 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemDeviceStatsRespVO.java @@ -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 扩展) + *

+ * - 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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemPageReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemPageReqVO.java new file mode 100644 index 00000000..b40a424e --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemPageReqVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemRespVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemRespVO.java new file mode 100644 index 00000000..87fddc09 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemRespVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemSaveReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemSaveReqVO.java new file mode 100644 index 00000000..b3f48377 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemSaveReqVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemSimpleRespVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemSimpleRespVO.java new file mode 100644 index 00000000..466cdf38 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemSimpleRespVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/subsystem/IotProjectDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/subsystem/IotProjectDO.java new file mode 100644 index 00000000..bd05555d --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/subsystem/IotProjectDO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/subsystem/IotSubsystemDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/subsystem/IotSubsystemDO.java new file mode 100644 index 00000000..0637141c --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/subsystem/IotSubsystemDO.java @@ -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) + *

+ * 评审 A4:UK 含 project_id;MySQL NULL 不参与 UK 唯一性 → 应用层兜底 + */ + private Long projectId; + + /** + * 子系统自定义配置(JSON) + */ + private String config; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/subsystem/IotSubsystemMapper.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/subsystem/IotSubsystemMapper.java new file mode 100644 index 00000000..75bad063 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/subsystem/IotSubsystemMapper.java @@ -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 { + + /** + * 分页查询子系统 + */ + default PageResult selectPage(IotSubsystemPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .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 等敏感字段 + *

+ * 评审 A7:simple-list 返回字段受限 + */ + default List selectSimpleList() { + return selectList(new QueryWrapper() + .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 wrapper = new LambdaQueryWrapper() + .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 wrapper = new LambdaQueryWrapper() + .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 wrapper = new LambdaQueryWrapper() + .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 wrapper = new LambdaQueryWrapper() + .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; + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/RedisKeyConstants.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/RedisKeyConstants.java index 07d1916e..aa165dea 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/RedisKeyConstants.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/RedisKeyConstants.java @@ -84,4 +84,14 @@ public interface RedisKeyConstants { */ String SCENE_RULE_LIST = "iot:scene_rule_list"; + /** + * 子系统设备计数缓存,采用 HASH 结构(评审 A6:避免 GROUP BY 性能问题) + *

+ * KEY 格式:iot:subsystem:devcount:{tenantId} + * HASH KEY:subsystemId + * VALUE:设备数量(long) + * TTL:30 分钟兜底 + 启动时从 DB 重建 + */ + String SUBSYSTEM_DEVCOUNT = "iot:subsystem:devcount:%d"; + } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/subsystem/IotSubsystemDeviceCountRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/subsystem/IotSubsystemDeviceCountRedisDAO.java new file mode 100644 index 00000000..4a3c7edd --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/subsystem/IotSubsystemDeviceCountRedisDAO.java @@ -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 + *

+ * 评审 A6:采用 HASH 结构缓存各子系统设备数量,避免 GROUP BY 查询的性能问题。 + *

+ * 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 + *

+ * 在 {@code ApplicationReadyEvent} 时触发,Redis 丢失后重建防止数据不一致。 + * 失败时 try/catch + log.warn,不阻塞启动。 + * + * @param tenantId 租户 ID + * @param countMap subsystemId → deviceCount 的映射(来自 DB 查询) + */ + public void rebuild(Long tenantId, Map 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); + } + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemService.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemService.java new file mode 100644 index 00000000..f5729518 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemService.java @@ -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 { + + /** + * 创建子系统 + *

+ * 应用层兜底:校验同 project 内 name/code 唯一性(评审 A4) + * 同步初始化 Redis 设备计数 Hash(评审 A6) + * + * @param createReqVO 创建信息 + * @return 子系统 ID + */ + Long createSubsystem(@Valid IotSubsystemSaveReqVO createReqVO); + + /** + * 更新子系统 + * + * @param updateReqVO 更新信息 + */ + void updateSubsystem(@Valid IotSubsystemSaveReqVO updateReqVO); + + /** + * 删除子系统 + *

+ * 校验无设备(评审 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 getSubsystemPage(IotSubsystemPageReqVO pageReqVO); + + /** + * 获得子系统精简列表(评审 A7:仅返回 id/name/code,权限 iot:device:query) + * + * @return 精简列表 + */ + List getSimpleSubsystemList(); + + /** + * 获得子系统设备统计(评审 A6:从 Redis Hash 读取,避免 GROUP BY) + * + * @param subsystemId 子系统 ID + * @return 设备统计 + */ + IotSubsystemDeviceStatsRespVO getSubsystemDeviceStats(Long subsystemId); + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImpl.java new file mode 100644 index 00000000..75b3d0cc --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImpl.java @@ -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 getSubsystemPage(IotSubsystemPageReqVO pageReqVO) { + return subsystemMapper.selectPage(pageReqVO); + } + + @Override + public List getSimpleSubsystemList() { + // 评审 A7:仅返回 id/name/code,不含 description/config + List 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 + *

+ * 注意:待 B11 添加 iot_device.subsystem_id 列后,启用真正的 DB 查询逻辑。 + * 当前 iot_device 表尚无 subsystem_id 列,方法体为空实现(避免阻塞启动)。 + *

+ * TODO [B11] 取消注释以下 DB 查询逻辑: + *

+     *   Map<Long, Long> countMap = deviceMapper.selectCountGroupBySubsystemId();
+     *   // 按租户分组后分别 rebuild
+     * 
+ */ + @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()); + } + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImplTest.java b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImplTest.java new file mode 100644 index 00000000..f5bc83bc --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImplTest.java @@ -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} 单元测试 + *

+ * 覆盖任务卡 B10 §6 的 8 个用例。 + * + * @author B10 + */ +@ExtendWith(MockitoExtension.class) +class IotSubsystemServiceImplTest { + + @InjectMocks + private IotSubsystemServiceImpl subsystemService; + + @Mock + private IotSubsystemMapper subsystemMapper; + + @Mock + private IotSubsystemDeviceCountRedisDAO deviceCountRedisDAO; + + private MockedStatic 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 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; + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/resources/sql/clean.sql b/viewsh-module-iot/viewsh-module-iot-server/src/test/resources/sql/clean.sql index ae1c5e51..ba3cea6c 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/test/resources/sql/clean.sql +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/resources/sql/clean.sql @@ -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"; diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/resources/sql/create_tables.sql b/viewsh-module-iot/viewsh-module-iot-server/src/test/resources/sql/create_tables.sql index 306c66b5..e00a95cb 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/test/resources/sql/create_tables.sql +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/resources/sql/create_tables.sql @@ -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 '子系统';