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:
lzh
2026-04-23 21:08:00 +08:00
parent 4614737d51
commit 6649e1abb6
18 changed files with 1295 additions and 0 deletions

View File

@@ -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评审 A6deviceCount 来自 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));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
* 评审 A4UK 含 project_idMySQL NULL 不参与 UK 唯一性 → 应用层兜底
*/
private Long projectId;
/**
* 子系统自定义配置JSON
*/
private String config;
}

View File

@@ -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>
* 评审 A7simple-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;
}
}

View File

@@ -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 KEYsubsystemId
* VALUE设备数量long
* TTL30 分钟兜底 + 启动时从 DB 重建
*/
String SUBSYSTEM_DEVCOUNT = "iot:subsystem:devcount:%d";
}

View File

@@ -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;
// ==================== 计数操作 ====================
/**
* 子系统设备计数 +1B11 绑定设备时调用)
*
* @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);
}
/**
* 子系统设备计数 -1B11 解绑设备时调用)
*
* @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);
}
}
}

View File

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

View File

@@ -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 校验(评审 A4MySQL 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&lt;Long, Long&gt; 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());
}
}
}

View File

@@ -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());
}
// ==================== 用例 2project=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));
}
// ==================== 用例 3project=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());
}
// ==================== 用例 4getSimpleSubsystemList 返回 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个字段
}
// ==================== 用例 5deleteSubsystem 有设备 → 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());
}
// ==================== 用例 6deleteSubsystem 无设备 → 删除成功 ====================
@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);
}
// ==================== 用例 7getSubsystemDeviceStats 走 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);
}
// ==================== 用例 8rebuildDeviceCountCache 失败不阻塞启动 ====================
@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;
}
}

View File

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

View File

@@ -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 '子系统';