feat(iot): B11 iot_device.subsystem_id + 设备归属绑定 API(一期允许 NULL)

- 新增 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 00:03:57 +08:00
parent ae74b4752a
commit 1f87d599c0
15 changed files with 622 additions and 16 deletions

View File

@@ -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<Boolean> 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<Boolean> batchBindDevicesToSubsystem(
@RequestParam("deviceIds") Collection<Long> 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<Boolean> unbindDeviceFromSubsystem(@RequestParam("deviceId") Long deviceId) {
deviceService.unbindDeviceFromSubsystem(deviceId);
return success(true);
}
@GetMapping("/unassigned-list")
@Operation(summary = "获取未归属子系统的设备列表")
@PreAuthorize("@ss.hasPermission('iot:device:query')")
public CommonResult<List<IotDeviceRespVO>> getUnassignedDevices() {
return success(BeanUtils.toBean(deviceService.getUnassignedDevices(), IotDeviceRespVO.class));
}
// TODO @haohao可以使用 @RequestParam("productKey") String productKey, @RequestParam("deviceNames") List<String> deviceNames 来接收哇?
@GetMapping("/list-by-product-key-and-names")
@Operation(summary = "通过产品标识和设备名称列表获取设备")

View File

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

View File

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

View File

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

View File

@@ -163,4 +163,11 @@ public class IotDeviceDO extends ProjectBaseDO {
*/
private String config;
/**
* 所属子系统 ID一期可 NULL存量设备兼容二期全量分配后改 NOT NULL
* <p>
* 关联 {@link com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO#getId()}
*/
private Long subsystemId;
}

View File

@@ -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<IotDeviceDO> {
default PageResult<IotDeviceDO> selectPage(IotDevicePageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<IotDeviceDO>()
LambdaQueryWrapperX<IotDeviceDO> wrapper = new LambdaQueryWrapperX<IotDeviceDO>()
.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<IotDeviceDO> {
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<Long, Long> selectCountGroupBySubsystemId() {
List<Map<String, Object>> result = selectMaps(new QueryWrapper<IotDeviceDO>()
.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 新子系统 IDnull 表示清空)
*/
default void updateSubsystemIdByIds(Collection<Long> deviceIds, Long subsystemId) {
update(new IotDeviceDO().setSubsystemId(subsystemId),
new LambdaUpdateWrapper<IotDeviceDO>().in(IotDeviceDO::getId, deviceIds));
}
/**
* 查询指定产品下的设备数量
*

View File

@@ -284,4 +284,40 @@ public interface IotDeviceService {
*/
void updateDeviceFirmware(Long deviceId, Long firmwareId);
// ==================== B11子系统绑定 ====================
/**
* 将单个设备绑定(或改绑)到指定子系统
* <p>
* 会同步更新 Redis 设备计数(旧子系统 -1新子系统 +1
*
* @param deviceId 设备 ID
* @param subsystemId 目标子系统 ID
*/
void bindDeviceToSubsystem(Long deviceId, Long subsystemId);
/**
* 批量将设备绑定到指定子系统
* <p>
* 支持从不同子系统批量迁移Redis 计数按实际变化量同步
*
* @param deviceIds 设备 ID 集合
* @param subsystemId 目标子系统 ID
*/
void batchBindDevicesToSubsystem(Collection<Long> deviceIds, Long subsystemId);
/**
* 解绑设备与子系统的关联(预留,置 subsystem_id = NULL
*
* @param deviceId 设备 ID
*/
void unbindDeviceFromSubsystem(Long deviceId);
/**
* 获得未归属子系统的设备列表subsystem_id IS NULL
*
* @return 设备列表
*/
List<IotDeviceDO> getUnassignedDevices();
}

View File

@@ -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<Long> deviceIds, Long subsystemId) {
if (CollUtil.isEmpty(deviceIds)) {
return;
}
// 1. 校验目标子系统存在且同租户
validateSubsystemBelongsToCurrentTenant(subsystemId);
// 2. 查询所有设备,统计旧子系统变化量
List<IotDeviceDO> devices = deviceMapper.selectByIds(deviceIds);
if (CollUtil.isEmpty(devices)) {
return;
}
Long tenantId = TenantContextHolder.getTenantId();
// 3. 统计旧子系统的减量(各子系统需减少的设备数)
Map<Long, Long> 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<IotDeviceDO> getUnassignedDevices() {
return deviceMapper.selectList(new LambdaQueryWrapperX<IotDeviceDO>()
.isNull(IotDeviceDO::getSubsystemId));
}
@Override
@Transactional(rollbackFor = Exception.class) // 添加事务,异常则回滚所有导入
public IotDeviceImportRespVO importDevice(List<IotDeviceImportExcelVO> importDevices, boolean updateSupport) {

View File

@@ -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<Long, Long> 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());

View File

@@ -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 子系统绑定相关
* <p>
* 覆盖任务卡 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<TenantContextHolder> tenantContextHolderMock;
private MockedStatic<TransactionSynchronizationManager> 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<Long> deviceIds = new ArrayList<>();
List<IotDeviceDO> 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<IotDeviceDO> 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<IotDeviceDO> 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;
}
}

View File

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

View File

@@ -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 '',