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

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