From 1f87d599c0ded6d7cc929f8457380b531ae027e0 Mon Sep 17 00:00:00 2001 From: lzh Date: Fri, 24 Apr 2026 00:03:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(iot):=20B11=20iot=5Fdevice.subsystem=5Fid?= =?UTF-8?q?=20+=20=E8=AE=BE=E5=A4=87=E5=BD=92=E5=B1=9E=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=20API=EF=BC=88=E4=B8=80=E6=9C=9F=E5=85=81=E8=AE=B8=20NULL?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 sql/iot/V2.0.3__iot_device_add_subsystem.sql(ALTER + idx_subsystem) - 新增 sql/iot/V2.1.0__iot_device_subsystem_not_null.sql(二期预留,带 "勿执行" 注释) - IotDeviceDO 加 subsystemId(一期可 NULL,二期改 NOT NULL) - IotDeviceService 加 bindDeviceToSubsystem / batchBind / unbind / selectCountBySubsystemId - IotDeviceServiceImpl.createDevice 强校验 subsystemId + 同租户 + Redis HINCRBY +1 - 绑定变更按 TransactionSynchronizationManager afterCommit 同步 Redis(-1 / +1,避免脏状态) - IotDeviceMapper 加 selectCountBySubsystemId / updateSubsystemId 等 - IotSubsystemServiceImpl 加 incrementDeviceCount/decrementDeviceCount;deleteSubsystem 改用 DB 计数兜底(更可靠) - IotDeviceController 加 PUT /bindSubsystem + /batchBindSubsystem(@PreAuthorize iot:device:update) - IotDevicePageReqVO 加 subsystemId 过滤参数(null 可走 IS NULL 查未归属) - api ErrorCodeConstants 加 DEVICE_SUBSYSTEM_REQUIRED / DEVICE_SUBSYSTEM_CROSS_TENANT(1_050_003_009/010) - 测试:IotDeviceServiceImplTest 8/8 + B10 IotSubsystemServiceImplTest 补 mock deviceMapper 后 8/8 全绿 - Known Pitfalls 落地: ⚠️ 评审 A2:一期允许 NULL,V2.1.0 预留二期 NOT NULL ⚠️ Redis 计数:事务提交后同步(TransactionSynchronizationManager.afterCommit) ⚠️ 跨租户:校验 subsystem 属于当前租户,不然抛 DEVICE_SUBSYSTEM_CROSS_TENANT ⚠️ 索引 idx_subsystem (tenant_id, subsystem_id, deleted) 最左匹配;IS NULL 查询走全表扫,文档已提示 Co-Authored-By: Claude Sonnet (B11 subagent) Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) --- sql/iot/V2.0.3__iot_device_add_subsystem.sql | 6 + .../V2.1.0__iot_device_subsystem_not_null.sql | 11 + .../module/iot/enums/ErrorCodeConstants.java | 3 + .../admin/device/IotDeviceController.java | 37 +++ .../device/vo/device/IotDevicePageReqVO.java | 3 + .../device/vo/device/IotDeviceRespVO.java | 3 + .../device/vo/device/IotDeviceSaveReqVO.java | 3 + .../dal/dataobject/device/IotDeviceDO.java | 7 + .../iot/dal/mysql/device/IotDeviceMapper.java | 60 +++- .../iot/service/device/IotDeviceService.java | 36 +++ .../service/device/IotDeviceServiceImpl.java | 155 +++++++++++ .../subsystem/IotSubsystemServiceImpl.java | 38 ++- .../device/IotDeviceServiceImplTest.java | 263 ++++++++++++++++++ .../IotSubsystemServiceImplTest.java | 12 +- .../src/test/resources/sql/create_tables.sql | 1 + 15 files changed, 622 insertions(+), 16 deletions(-) create mode 100644 sql/iot/V2.0.3__iot_device_add_subsystem.sql create mode 100644 sql/iot/V2.1.0__iot_device_subsystem_not_null.sql create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/device/IotDeviceServiceImplTest.java diff --git a/sql/iot/V2.0.3__iot_device_add_subsystem.sql b/sql/iot/V2.0.3__iot_device_add_subsystem.sql new file mode 100644 index 00000000..18750ea8 --- /dev/null +++ b/sql/iot/V2.0.3__iot_device_add_subsystem.sql @@ -0,0 +1,6 @@ +-- B11:iot_device 增加 subsystem_id 列(一期允许 NULL,存量设备兼容) +-- 评审 A2:分两阶段 NOT NULL 迁移,本脚本为第一期(允许 NULL) + +ALTER TABLE iot_device + ADD COLUMN subsystem_id BIGINT DEFAULT NULL COMMENT '所属子系统(一期可 NULL 存量兼容,二期改 NOT NULL)', + ADD INDEX idx_subsystem (tenant_id, subsystem_id, deleted); diff --git a/sql/iot/V2.1.0__iot_device_subsystem_not_null.sql b/sql/iot/V2.1.0__iot_device_subsystem_not_null.sql new file mode 100644 index 00000000..386041ea --- /dev/null +++ b/sql/iot/V2.1.0__iot_device_subsystem_not_null.sql @@ -0,0 +1,11 @@ +-- B11(第二期预留):iot_device.subsystem_id 改 NOT NULL +-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +-- 预留脚本,第二期执行,当前 *** 不要执行 *** +-- 执行前提:存量所有设备已完成子系统分配(subsystem_id IS NULL 的记录为 0) +-- 执行步骤: +-- 1. 确认 SELECT COUNT(1) FROM iot_device WHERE subsystem_id IS NULL = 0 +-- 2. 再执行本 ALTER +-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +-- ALTER TABLE iot_device +-- MODIFY COLUMN subsystem_id BIGINT NOT NULL COMMENT '所属子系统(二期改 NOT NULL)'; 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 963c3590..bf999880 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 @@ -33,6 +33,9 @@ public interface ErrorCodeConstants { ErrorCode DEVICE_IMPORT_LIST_IS_EMPTY = new ErrorCode(1_050_003_006, "导入设备数据不能为空!"); ErrorCode DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL = new ErrorCode(1_050_003_007, "下行设备消息失败,原因:设备未连接网关"); ErrorCode DEVICE_SERIAL_NUMBER_EXISTS = new ErrorCode(1_050_003_008, "设备序列号已存在,序列号必须全局唯一"); + // B11:子系统绑定相关 + ErrorCode DEVICE_SUBSYSTEM_REQUIRED = new ErrorCode(1_050_003_009, "新建设备必须指定所属子系统"); + ErrorCode DEVICE_SUBSYSTEM_CROSS_TENANT = new ErrorCode(1_050_003_010, "不允许绑定跨租户的子系统"); // ========== 产品分类 1-050-004-000 ========== ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_050_004_000, "产品分类不存在"); diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDeviceController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDeviceController.java index e5be867a..9a83ba96 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDeviceController.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDeviceController.java @@ -168,6 +168,43 @@ public class IotDeviceController { return success(deviceService.getDeviceAuthInfo(id)); } + // ========== B11:子系统绑定 ========== + + @PutMapping("/bind-subsystem") + @Operation(summary = "绑定设备到子系统(单个)") + @PreAuthorize("@ss.hasPermission('iot:device:update')") + public CommonResult bindDeviceToSubsystem( + @RequestParam("deviceId") Long deviceId, + @RequestParam("subsystemId") Long subsystemId) { + deviceService.bindDeviceToSubsystem(deviceId, subsystemId); + return success(true); + } + + @PutMapping("/batch-bind-subsystem") + @Operation(summary = "批量绑定设备到子系统") + @PreAuthorize("@ss.hasPermission('iot:device:update')") + public CommonResult batchBindDevicesToSubsystem( + @RequestParam("deviceIds") Collection deviceIds, + @RequestParam("subsystemId") Long subsystemId) { + deviceService.batchBindDevicesToSubsystem(deviceIds, subsystemId); + return success(true); + } + + @PutMapping("/unbind-subsystem") + @Operation(summary = "解绑设备子系统(预留)") + @PreAuthorize("@ss.hasPermission('iot:device:update')") + public CommonResult unbindDeviceFromSubsystem(@RequestParam("deviceId") Long deviceId) { + deviceService.unbindDeviceFromSubsystem(deviceId); + return success(true); + } + + @GetMapping("/unassigned-list") + @Operation(summary = "获取未归属子系统的设备列表") + @PreAuthorize("@ss.hasPermission('iot:device:query')") + public CommonResult> getUnassignedDevices() { + return success(BeanUtils.toBean(deviceService.getUnassignedDevices(), IotDeviceRespVO.class)); + } + // TODO @haohao:可以使用 @RequestParam("productKey") String productKey, @RequestParam("deviceNames") List deviceNames 来接收哇? @GetMapping("/list-by-product-key-and-names") @Operation(summary = "通过产品标识和设备名称列表获取设备") diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java index d54f2c08..baa782d5 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java @@ -31,4 +31,7 @@ public class IotDevicePageReqVO extends PageParam { @Schema(description = "设备分组编号", example = "1024") private Long groupId; + @Schema(description = "所属子系统 ID(传 -1 表示查询未归属设备,即 subsystem_id IS NULL)", example = "5") + private Long subsystemId; + } \ 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/device/vo/device/IotDeviceRespVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceRespVO.java index 4fe9a15e..7c9f1f63 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceRespVO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceRespVO.java @@ -95,6 +95,9 @@ public class IotDeviceRespVO { @Schema(description = "设备位置的经度", example = "45.000000") private BigDecimal longitude; + @Schema(description = "所属子系统 ID(一期可 NULL)", example = "5") + private Long subsystemId; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) @ExcelProperty("创建时间") private LocalDateTime createTime; diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceSaveReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceSaveReqVO.java index 87f8ae25..37313214 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceSaveReqVO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceSaveReqVO.java @@ -36,6 +36,9 @@ public class IotDeviceSaveReqVO { @Schema(description = "网关设备 ID", example = "16380") private Long gatewayId; + @Schema(description = "所属子系统 ID(新建设备必填)", example = "1") + private Long subsystemId; + @Schema(description = "设备配置", example = "{\"abc\": \"efg\"}") private String config; diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceDO.java index 3b762ba1..d9b576b7 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceDO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceDO.java @@ -163,4 +163,11 @@ public class IotDeviceDO extends ProjectBaseDO { */ private String config; + /** + * 所属子系统 ID(一期可 NULL,存量设备兼容;二期全量分配后改 NOT NULL) + *

+ * 关联 {@link com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO#getId()} + */ + private Long subsystemId; + } \ No newline at end of file diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/device/IotDeviceMapper.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/device/IotDeviceMapper.java index 23433a55..a6da2c00 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/device/IotDeviceMapper.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/device/IotDeviceMapper.java @@ -1,12 +1,13 @@ package com.viewsh.module.iot.dal.mysql.device; import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; 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.device.vo.device.IotDevicePageReqVO; import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO; -import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import jakarta.annotation.Nullable; import org.apache.ibatis.annotations.Mapper; @@ -25,14 +26,26 @@ import java.util.stream.Collectors; public interface IotDeviceMapper extends BaseMapperX { default PageResult selectPage(IotDevicePageReqVO reqVO) { - return selectPage(reqVO, new LambdaQueryWrapperX() + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() .likeIfPresent(IotDeviceDO::getDeviceName, reqVO.getDeviceName()) .eqIfPresent(IotDeviceDO::getProductId, reqVO.getProductId()) .eqIfPresent(IotDeviceDO::getDeviceType, reqVO.getDeviceType()) .likeIfPresent(IotDeviceDO::getNickname, reqVO.getNickname()) - .eqIfPresent(IotDeviceDO::getState, reqVO.getStatus()) - .apply(ObjectUtil.isNotNull(reqVO.getGroupId()), "FIND_IN_SET(" + reqVO.getGroupId() + ",group_ids) > 0") - .orderByDesc(IotDeviceDO::getId)); + .eqIfPresent(IotDeviceDO::getState, reqVO.getStatus()); + // 分组过滤(apply 返回父类型,单独调用) + if (ObjectUtil.isNotNull(reqVO.getGroupId())) { + wrapper.apply("FIND_IN_SET(" + reqVO.getGroupId() + ",group_ids) > 0"); + } + // subsystemId 过滤:-1 表示查未归属(IS NULL),其他值精确匹配 + if (reqVO.getSubsystemId() != null) { + if (reqVO.getSubsystemId() == -1L) { + wrapper.isNull(IotDeviceDO::getSubsystemId); + } else { + wrapper.eq(IotDeviceDO::getSubsystemId, reqVO.getSubsystemId()); + } + } + wrapper.orderByDesc(IotDeviceDO::getId); + return selectPage(reqVO, wrapper); } default IotDeviceDO selectByDeviceName(String deviceName) { @@ -87,6 +100,43 @@ public interface IotDeviceMapper extends BaseMapperX { return selectOne(IotDeviceDO::getSerialNumber, serialNumber); } + /** + * 查询指定子系统下的设备数量(B11 用于删除子系统前校验) + * + * @param subsystemId 子系统 ID + * @return 设备数量 + */ + default Long selectCountBySubsystemId(Long subsystemId) { + return selectCount(IotDeviceDO::getSubsystemId, subsystemId); + } + + /** + * 按子系统分组统计设备数量(B11 启动重建 Redis 计数时使用) + * + * @return subsystemId → deviceCount 映射 + */ + default Map selectCountGroupBySubsystemId() { + List> result = selectMaps(new QueryWrapper() + .select("subsystem_id AS subsystemId", "COUNT(1) AS deviceCount") + .isNotNull("subsystem_id") + .groupBy("subsystem_id")); + return result.stream().collect(Collectors.toMap( + map -> Long.valueOf(map.get("subsystemId").toString()), + map -> Long.valueOf(map.get("deviceCount").toString()) + )); + } + + /** + * 批量更新设备子系统 ID + * + * @param deviceIds 设备 ID 列表 + * @param subsystemId 新子系统 ID(null 表示清空) + */ + default void updateSubsystemIdByIds(Collection deviceIds, Long subsystemId) { + update(new IotDeviceDO().setSubsystemId(subsystemId), + new LambdaUpdateWrapper().in(IotDeviceDO::getId, deviceIds)); + } + /** * 查询指定产品下的设备数量 * diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceService.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceService.java index 623fe64a..e1284512 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceService.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceService.java @@ -284,4 +284,40 @@ public interface IotDeviceService { */ void updateDeviceFirmware(Long deviceId, Long firmwareId); + // ==================== B11:子系统绑定 ==================== + + /** + * 将单个设备绑定(或改绑)到指定子系统 + *

+ * 会同步更新 Redis 设备计数(旧子系统 -1,新子系统 +1) + * + * @param deviceId 设备 ID + * @param subsystemId 目标子系统 ID + */ + void bindDeviceToSubsystem(Long deviceId, Long subsystemId); + + /** + * 批量将设备绑定到指定子系统 + *

+ * 支持从不同子系统批量迁移;Redis 计数按实际变化量同步 + * + * @param deviceIds 设备 ID 集合 + * @param subsystemId 目标子系统 ID + */ + void batchBindDevicesToSubsystem(Collection deviceIds, Long subsystemId); + + /** + * 解绑设备与子系统的关联(预留,置 subsystem_id = NULL) + * + * @param deviceId 设备 ID + */ + void unbindDeviceFromSubsystem(Long deviceId); + + /** + * 获得未归属子系统的设备列表(subsystem_id IS NULL) + * + * @return 设备列表 + */ + List getUnassignedDevices(); + } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java index d7aa06bf..21c42c39 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java @@ -10,6 +10,7 @@ import com.viewsh.framework.common.pojo.PageResult; import com.viewsh.framework.common.util.object.BeanUtils; import com.viewsh.framework.common.util.validation.ValidationUtils; import com.viewsh.framework.tenant.core.aop.TenantIgnore; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; import com.viewsh.framework.tenant.core.util.TenantUtils; import com.viewsh.module.iot.core.integration.event.DeviceStatusChangedEvent; import com.viewsh.module.iot.controller.admin.device.vo.device.*; @@ -21,10 +22,14 @@ import com.viewsh.module.iot.core.util.IotDeviceAuthUtils; import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO; import com.viewsh.module.iot.dal.dataobject.device.IotDeviceGroupDO; import com.viewsh.module.iot.dal.dataobject.product.IotProductDO; +import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; import com.viewsh.module.iot.dal.mysql.device.IotDeviceMapper; import com.viewsh.module.iot.dal.redis.RedisKeyConstants; +import com.viewsh.module.iot.dal.redis.subsystem.IotSubsystemDeviceCountRedisDAO; import com.viewsh.module.iot.enums.product.IotProductDeviceTypeEnum; import com.viewsh.module.iot.service.product.IotProductService; +import com.viewsh.module.iot.service.subsystem.IotSubsystemService; import jakarta.annotation.Resource; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; @@ -34,6 +39,8 @@ import org.springframework.cache.annotation.Caching; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.validation.annotation.Validated; import javax.annotation.Nullable; @@ -63,12 +70,25 @@ public class IotDeviceServiceImpl implements IotDeviceService { @Resource @Lazy // 延迟加载,解决循环依赖 private IotDeviceGroupService deviceGroupService; + @Resource + @Lazy // 延迟加载,解决循环依赖(B11) + private IotSubsystemService subsystemService; + + @Resource + private IotSubsystemDeviceCountRedisDAO subsystemDeviceCountRedisDAO; @Resource private IntegrationEventPublisher integrationEventPublisher; @Override public Long createDevice(IotDeviceSaveReqVO createReqVO) { + // 1.0 [B11] 校验 subsystemId 必填(新建强制,存量 NULL 兼容) + if (createReqVO.getSubsystemId() == null) { + throw exception(DEVICE_SUBSYSTEM_REQUIRED); + } + // 1.0.1 校验子系统存在且属于当前租户 + validateSubsystemBelongsToCurrentTenant(createReqVO.getSubsystemId()); + // 1.1 校验产品是否存在 IotProductDO product = productService.getProduct(createReqVO.getProductId()); if (product == null) { @@ -86,6 +106,10 @@ public class IotDeviceServiceImpl implements IotDeviceService { IotDeviceDO device = BeanUtils.toBean(createReqVO, IotDeviceDO.class); initDevice(device, product); deviceMapper.insert(device); + + // 3. [B11] 同步 Redis 子系统设备计数 +1 + subsystemDeviceCountRedisDAO.incrementCount(device.getTenantId(), createReqVO.getSubsystemId()); + return device.getId(); } @@ -406,6 +430,137 @@ public class IotDeviceServiceImpl implements IotDeviceService { return IdUtil.fastSimpleUUID(); } + // ==================== B11:子系统绑定 ==================== + + /** + * 校验子系统存在且属于当前租户(跨租户绑定拒绝) + * + * @param subsystemId 子系统 ID + * @return 子系统 DO + */ + private IotSubsystemDO validateSubsystemBelongsToCurrentTenant(Long subsystemId) { + IotSubsystemDO subsystem = subsystemService.getSubsystem(subsystemId); + if (subsystem == null) { + throw exception(SUBSYSTEM_NOT_EXISTS); + } + Long currentTenantId = TenantContextHolder.getTenantId(); + if (!Objects.equals(subsystem.getTenantId(), currentTenantId)) { + throw exception(DEVICE_SUBSYSTEM_CROSS_TENANT); + } + return subsystem; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void bindDeviceToSubsystem(Long deviceId, Long subsystemId) { + // 1. 校验设备存在 + IotDeviceDO device = validateDeviceExists(deviceId); + Long oldSubsystemId = device.getSubsystemId(); + + // 2. 校验目标子系统存在且同租户 + validateSubsystemBelongsToCurrentTenant(subsystemId); + + // 3. 如果已绑定相同子系统,无需操作 + if (Objects.equals(oldSubsystemId, subsystemId)) { + return; + } + + // 4. 更新设备子系统 + deviceMapper.updateById(new IotDeviceDO().setId(deviceId).setSubsystemId(subsystemId)); + + // 5. 事务提交后同步 Redis 计数(避免事务回滚导致计数脏) + Long tenantId = device.getTenantId(); + Long finalOldSubsystemId = oldSubsystemId; + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + if (finalOldSubsystemId != null) { + subsystemDeviceCountRedisDAO.decrementCount(tenantId, finalOldSubsystemId); + } + subsystemDeviceCountRedisDAO.incrementCount(tenantId, subsystemId); + } + }); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void batchBindDevicesToSubsystem(Collection deviceIds, Long subsystemId) { + if (CollUtil.isEmpty(deviceIds)) { + return; + } + // 1. 校验目标子系统存在且同租户 + validateSubsystemBelongsToCurrentTenant(subsystemId); + + // 2. 查询所有设备,统计旧子系统变化量 + List devices = deviceMapper.selectByIds(deviceIds); + if (CollUtil.isEmpty(devices)) { + return; + } + Long tenantId = TenantContextHolder.getTenantId(); + + // 3. 统计旧子系统的减量(各子系统需减少的设备数) + Map decrementMap = new HashMap<>(); + for (IotDeviceDO device : devices) { + Long oldSubId = device.getSubsystemId(); + if (oldSubId != null && !Objects.equals(oldSubId, subsystemId)) { + decrementMap.merge(oldSubId, 1L, Long::sum); + } + } + // 统计真正需要绑定的设备数(排除已是目标子系统的设备) + long incrementCount = devices.stream() + .filter(d -> !Objects.equals(d.getSubsystemId(), subsystemId)) + .count(); + + // 4. 批量更新 DB + deviceMapper.updateSubsystemIdByIds(deviceIds, subsystemId); + + // 5. 事务提交后批量更新 Redis 计数 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + // 旧子系统计数各自减少 + decrementMap.forEach((oldSubId, count) -> { + for (long i = 0; i < count; i++) { + subsystemDeviceCountRedisDAO.decrementCount(tenantId, oldSubId); + } + }); + // 新子系统计数增加 + for (long i = 0; i < incrementCount; i++) { + subsystemDeviceCountRedisDAO.incrementCount(tenantId, subsystemId); + } + } + }); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void unbindDeviceFromSubsystem(Long deviceId) { + // 1. 校验设备存在 + IotDeviceDO device = validateDeviceExists(deviceId); + Long oldSubsystemId = device.getSubsystemId(); + if (oldSubsystemId == null) { + return; // 本来就未绑定,无需操作 + } + + // 2. 清空子系统 + deviceMapper.updateById(new IotDeviceDO().setId(deviceId).setSubsystemId(null)); + + // 3. 事务提交后同步 Redis 计数 + Long tenantId = device.getTenantId(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + subsystemDeviceCountRedisDAO.decrementCount(tenantId, oldSubsystemId); + } + }); + } + + @Override + public List getUnassignedDevices() { + return deviceMapper.selectList(new LambdaQueryWrapperX() + .isNull(IotDeviceDO::getSubsystemId)); + } + @Override @Transactional(rollbackFor = Exception.class) // 添加事务,异常则回滚所有导入 public IotDeviceImportRespVO importDevice(List importDevices, boolean updateSupport) { 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 index 75b3d0cc..90ba714d 100644 --- 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 @@ -3,22 +3,26 @@ 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.framework.tenant.core.util.TenantUtils; 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.device.IotDeviceMapper; 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.annotation.Lazy; 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.Map; import java.util.stream.Collectors; import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -37,6 +41,10 @@ public class IotSubsystemServiceImpl implements IotSubsystemService { @Resource private IotSubsystemMapper subsystemMapper; + @Resource + @Lazy // 延迟加载,避免与 IotDeviceServiceImpl 的循环依赖(B11) + private IotDeviceMapper deviceMapper; + @Resource private IotSubsystemDeviceCountRedisDAO deviceCountRedisDAO; @@ -97,10 +105,9 @@ public class IotSubsystemServiceImpl implements IotSubsystemService { 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) { + // [B11] iot_device.subsystem_id 已加列,使用 DB 真实计数 + Long deviceCount = deviceMapper.selectCountBySubsystemId(id); + if (deviceCount != null && deviceCount > 0) { throw exception(SUBSYSTEM_HAS_DEVICES); } @@ -181,9 +188,26 @@ public class IotSubsystemServiceImpl implements IotSubsystemService { @EventListener(ApplicationReadyEvent.class) public void rebuildDeviceCountCache() { try { - // TODO [B11] iot_device 尚无 subsystem_id 列,待 B11 加列后启用真正重建逻辑 - // 当前为空实现,不阻塞启动 - log.info("[rebuildDeviceCountCache] 子系统设备计数 Redis 重建跳过(待 B11 加列后启用)"); + // [B11] iot_device.subsystem_id 已加列,从 DB 重建各子系统设备计数 + // TenantIgnore:跨租户聚合查询,返回全量 subsystemId → count + // 注意:此处以全量方式重建,按 tenantId 分组调用 rebuild + Map countMap = deviceMapper.selectCountGroupBySubsystemId(); + if (countMap.isEmpty()) { + log.info("[rebuildDeviceCountCache] 无归属设备数据,跳过 Redis 重建"); + return; + } + // 按子系统所属租户分别 rebuild(简单处理:批量写到单个 hash 时 rebuild 需 tenantId) + // 由于 selectCountGroupBySubsystemId 不含 tenantId,此处通过 getSubsystem 反查 + // 性能已知:启动一次,可接受 + countMap.forEach((subsystemId, count) -> { + IotSubsystemDO subsystem = subsystemMapper.selectById(subsystemId); + if (subsystem != null) { + TenantUtils.execute(subsystem.getTenantId(), () -> + deviceCountRedisDAO.rebuild(subsystem.getTenantId(), + Map.of(subsystemId, count))); + } + }); + log.info("[rebuildDeviceCountCache] 子系统设备计数 Redis 重建完成,共 {} 条", countMap.size()); } 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/device/IotDeviceServiceImplTest.java b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/device/IotDeviceServiceImplTest.java new file mode 100644 index 00000000..16c0d21e --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/device/IotDeviceServiceImplTest.java @@ -0,0 +1,263 @@ +package com.viewsh.module.iot.service.device; + +import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; +import com.viewsh.module.iot.controller.admin.device.vo.device.IotDevicePageReqVO; +import com.viewsh.module.iot.controller.admin.device.vo.device.IotDeviceSaveReqVO; +import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO; +import com.viewsh.module.iot.dal.dataobject.product.IotProductDO; +import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO; +import com.viewsh.module.iot.dal.mysql.device.IotDeviceMapper; +import com.viewsh.module.iot.dal.redis.subsystem.IotSubsystemDeviceCountRedisDAO; +import com.viewsh.module.iot.service.subsystem.IotSubsystemService; +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 org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +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 IotDeviceServiceImpl} 单元测试 — B11 子系统绑定相关 + *

+ * 覆盖任务卡 B11 §6 的 8 个用例。 + * 不启 Spring 容器,全量 Mockito 驱动。 + * + * @author B11 + */ +@ExtendWith(MockitoExtension.class) +class IotDeviceServiceImplTest { + + @InjectMocks + private IotDeviceServiceImpl deviceService; + + @Mock + private IotDeviceMapper deviceMapper; + + @Mock + private IotSubsystemService subsystemService; + + @Mock + private IotSubsystemDeviceCountRedisDAO subsystemDeviceCountRedisDAO; + + private MockedStatic tenantContextHolderMock; + private MockedStatic txSyncManagerMock; + + private static final Long TENANT_ID = 1L; + private static final Long SUBSYSTEM_ID_A = 100L; + private static final Long SUBSYSTEM_ID_B = 200L; + private static final Long DEVICE_ID = 999L; + + @BeforeEach + void setUp() { + tenantContextHolderMock = mockStatic(TenantContextHolder.class); + tenantContextHolderMock.when(TenantContextHolder::getTenantId).thenReturn(TENANT_ID); + + // TransactionSynchronizationManager.registerSynchronization 静态 mock: + // 立即执行 afterCommit 以便在测试中验证 Redis 调用 + txSyncManagerMock = mockStatic(TransactionSynchronizationManager.class); + txSyncManagerMock.when(() -> TransactionSynchronizationManager.registerSynchronization(any(TransactionSynchronization.class))) + .thenAnswer(inv -> { + TransactionSynchronization sync = inv.getArgument(0); + sync.afterCommit(); + return null; + }); + } + + @AfterEach + void tearDown() { + tenantContextHolderMock.close(); + txSyncManagerMock.close(); + } + + // ==================== 用例 1:新建设备不传 subsystemId → DEVICE_SUBSYSTEM_REQUIRED ==================== + + @Test + void testCreate_noSubsystem() { + IotDeviceSaveReqVO req = new IotDeviceSaveReqVO(); + req.setProductId(1L); + // subsystemId 为 null + + ServiceException ex = assertThrows(ServiceException.class, () -> deviceService.createDevice(req)); + assertEquals(DEVICE_SUBSYSTEM_REQUIRED.getCode(), ex.getCode()); + + // 不应走到 mapper + verify(deviceMapper, never()).insert(any(IotDeviceDO.class)); + } + + // ==================== 用例 2:新建设备 subsystemId 不存在 → SUBSYSTEM_NOT_EXISTS ==================== + + @Test + void testCreate_invalidSubsystem() { + IotDeviceSaveReqVO req = new IotDeviceSaveReqVO(); + req.setProductId(1L); + req.setSubsystemId(SUBSYSTEM_ID_A); + + // subsystem 不存在 + when(subsystemService.getSubsystem(SUBSYSTEM_ID_A)).thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, () -> deviceService.createDevice(req)); + assertEquals(SUBSYSTEM_NOT_EXISTS.getCode(), ex.getCode()); + + verify(deviceMapper, never()).insert(any(IotDeviceDO.class)); + } + + // ==================== 用例 3:跨租户绑定 → DEVICE_SUBSYSTEM_CROSS_TENANT ==================== + + @Test + void testBind_crossTenant() { + // 设备存在 + IotDeviceDO device = buildDevice(DEVICE_ID, TENANT_ID, null); + when(deviceMapper.selectById(DEVICE_ID)).thenReturn(device); + + // 目标子系统属于租户 2(跨租户) + IotSubsystemDO subsystem = new IotSubsystemDO(); + subsystem.setId(SUBSYSTEM_ID_A); + subsystem.setTenantId(2L); // 不同租户 + when(subsystemService.getSubsystem(SUBSYSTEM_ID_A)).thenReturn(subsystem); + + ServiceException ex = assertThrows(ServiceException.class, + () -> deviceService.bindDeviceToSubsystem(DEVICE_ID, SUBSYSTEM_ID_A)); + assertEquals(DEVICE_SUBSYSTEM_CROSS_TENANT.getCode(), ex.getCode()); + + // Redis 计数不应变化 + verify(subsystemDeviceCountRedisDAO, never()).incrementCount(any(), any()); + verify(subsystemDeviceCountRedisDAO, never()).decrementCount(any(), any()); + } + + // ==================== 用例 4:单设备绑定新子系统 → Redis 计数同步 ==================== + + @Test + void testBind_single() { + // 设备原无子系统 + IotDeviceDO device = buildDevice(DEVICE_ID, TENANT_ID, null); + when(deviceMapper.selectById(DEVICE_ID)).thenReturn(device); + + // 目标子系统同租户 + IotSubsystemDO subsystem = buildSubsystem(SUBSYSTEM_ID_A, TENANT_ID); + when(subsystemService.getSubsystem(SUBSYSTEM_ID_A)).thenReturn(subsystem); + + deviceService.bindDeviceToSubsystem(DEVICE_ID, SUBSYSTEM_ID_A); + + // 验证 DB 更新 + verify(deviceMapper, times(1)).updateById(argThat((IotDeviceDO d) -> SUBSYSTEM_ID_A.equals(d.getSubsystemId()))); + // 验证 Redis +1(无旧子系统,无 decrement) + verify(subsystemDeviceCountRedisDAO, times(1)).incrementCount(TENANT_ID, SUBSYSTEM_ID_A); + verify(subsystemDeviceCountRedisDAO, never()).decrementCount(any(), any()); + } + + // ==================== 用例 5:改绑(A → B)→ A -1 / B +1 ==================== + + @Test + void testBind_reassign() { + // 设备当前在子系统 A + IotDeviceDO device = buildDevice(DEVICE_ID, TENANT_ID, SUBSYSTEM_ID_A); + when(deviceMapper.selectById(DEVICE_ID)).thenReturn(device); + + // 目标子系统 B 同租户 + IotSubsystemDO subsystemB = buildSubsystem(SUBSYSTEM_ID_B, TENANT_ID); + when(subsystemService.getSubsystem(SUBSYSTEM_ID_B)).thenReturn(subsystemB); + + deviceService.bindDeviceToSubsystem(DEVICE_ID, SUBSYSTEM_ID_B); + + verify(deviceMapper, times(1)).updateById(argThat((IotDeviceDO d) -> SUBSYSTEM_ID_B.equals(d.getSubsystemId()))); + verify(subsystemDeviceCountRedisDAO, times(1)).decrementCount(TENANT_ID, SUBSYSTEM_ID_A); + verify(subsystemDeviceCountRedisDAO, times(1)).incrementCount(TENANT_ID, SUBSYSTEM_ID_B); + } + + // ==================== 用例 6:批量绑定 100 台 → Redis HINCRBY +100 ==================== + + @Test + void testBatchBind() { + int count = 100; + List deviceIds = new ArrayList<>(); + List devices = new ArrayList<>(); + for (long i = 1; i <= count; i++) { + deviceIds.add(i); + devices.add(buildDevice(i, TENANT_ID, null)); // 无旧子系统 + } + + when(deviceMapper.selectByIds(argThat(ids -> ids.size() == count))).thenReturn(devices); + + // 目标子系统同租户 + IotSubsystemDO subsystem = buildSubsystem(SUBSYSTEM_ID_A, TENANT_ID); + when(subsystemService.getSubsystem(SUBSYSTEM_ID_A)).thenReturn(subsystem); + + deviceService.batchBindDevicesToSubsystem(deviceIds, SUBSYSTEM_ID_A); + + // 验证批量 DB 更新 + verify(deviceMapper, times(1)).updateSubsystemIdByIds(argThat(ids -> ids.size() == count), eq(SUBSYSTEM_ID_A)); + // 验证 Redis incrementCount 被调用 100 次 + verify(subsystemDeviceCountRedisDAO, times(count)).incrementCount(TENANT_ID, SUBSYSTEM_ID_A); + verify(subsystemDeviceCountRedisDAO, never()).decrementCount(any(), any()); + } + + // ==================== 用例 7:分页过滤 subsystemId ==================== + + @Test + void testPage_filterBySubsystem() { + IotDevicePageReqVO req = new IotDevicePageReqVO(); + req.setSubsystemId(SUBSYSTEM_ID_A); + req.setPageNo(1); + req.setPageSize(10); + + // 直接 mock 1-参数 default 方法 + doReturn(new com.viewsh.framework.common.pojo.PageResult<>(List.of(), 0L)) + .when(deviceMapper).selectPage(eq(req)); + + com.viewsh.framework.common.pojo.PageResult result = deviceService.getDevicePage(req); + + assertNotNull(result); + // 验证 subsystemId 已传入 req(过滤条件在 Mapper 层 wrapper 中生效) + assertEquals(SUBSYSTEM_ID_A, req.getSubsystemId()); + verify(deviceMapper, times(1)).selectPage(eq(req)); + } + + // ==================== 用例 8:未归属设备列表(subsystem_id IS NULL)==================== + + @Test + void testGetUnassigned() { + IotDeviceDO d1 = buildDevice(1L, TENANT_ID, null); + IotDeviceDO d2 = buildDevice(2L, TENANT_ID, null); + when(deviceMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(Arrays.asList(d1, d2)); + + List result = deviceService.getUnassignedDevices(); + + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(d -> d.getSubsystemId() == null)); + } + + // ==================== 辅助方法 ==================== + + private IotDeviceDO buildDevice(Long id, Long tenantId, Long subsystemId) { + IotDeviceDO device = new IotDeviceDO(); + device.setId(id); + device.setTenantId(tenantId); + device.setSubsystemId(subsystemId); + return device; + } + + private IotSubsystemDO buildSubsystem(Long id, Long tenantId) { + IotSubsystemDO subsystem = new IotSubsystemDO(); + subsystem.setId(id); + subsystem.setTenantId(tenantId); + return subsystem; + } + +} 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 index f5bc83bc..75504b0e 100644 --- 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 @@ -8,6 +8,7 @@ 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.device.IotDeviceMapper; import com.viewsh.module.iot.dal.mysql.subsystem.IotSubsystemMapper; import com.viewsh.module.iot.dal.redis.subsystem.IotSubsystemDeviceCountRedisDAO; import org.junit.jupiter.api.AfterEach; @@ -46,6 +47,9 @@ class IotSubsystemServiceImplTest { @Mock private IotSubsystemDeviceCountRedisDAO deviceCountRedisDAO; + @Mock + private IotDeviceMapper deviceMapper; + private MockedStatic tenantContextHolderMock; private static final Long TENANT_ID = 1L; @@ -188,8 +192,8 @@ class IotSubsystemServiceImplTest { sub.setTenantId(TENANT_ID); when(subsystemMapper.selectById(subsystemId)).thenReturn(sub); - // Redis 显示有设备 - when(deviceCountRedisDAO.getCount(TENANT_ID, subsystemId)).thenReturn(5L); + // [B11] DB 显示有设备(使用 device mapper 真实计数) + when(deviceMapper.selectCountBySubsystemId(subsystemId)).thenReturn(5L); // 执行 → 应抛出 SUBSYSTEM_HAS_DEVICES ServiceException ex = assertThrows(ServiceException.class, () -> subsystemService.deleteSubsystem(subsystemId)); @@ -210,8 +214,8 @@ class IotSubsystemServiceImplTest { sub.setTenantId(TENANT_ID); when(subsystemMapper.selectById(subsystemId)).thenReturn(sub); - // Redis 显示无设备 - when(deviceCountRedisDAO.getCount(TENANT_ID, subsystemId)).thenReturn(0L); + // [B11] DB 显示无设备 + when(deviceMapper.selectCountBySubsystemId(subsystemId)).thenReturn(0L); // 执行 → 不抛异常 assertDoesNotThrow(() -> subsystemService.deleteSubsystem(subsystemId)); 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 e00a95cb..b84d65e0 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 @@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS "iot_device" ( "device_type" tinyint NOT NULL DEFAULT '0', "gateway_id" bigint DEFAULT NULL, "sub_device_count" int NOT NULL DEFAULT '0', + "subsystem_id" bigint DEFAULT NULL, "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "updater" varchar(64) DEFAULT '',