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:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
Reference in New Issue
Block a user