feat(iot): B10 iot_subsystem 表 + CRUD + Redis 设备计数聚合
- 新增 sql/iot/V2.0.2__iot_subsystem.sql(iot_project + iot_subsystem) - 新增 server 模块 subsystem/ 下 DO + Mapper + Service + Controller + VO(7 端点) - 新增 IotSubsystemDeviceCountRedisDAO(HINCRBY + rebuild + ApplicationReadyEvent 触发) - api 模块 ErrorCodeConstants 新增子系统段(1-050-020-xxx) - server 模块 RedisKeyConstants 新增 SUBSYSTEM_DEVCOUNT - 测试:8 个单元用例全绿(mvn test IotSubsystemServiceImplTest) - Known Pitfalls 落地: ⚠️ 评审 A4:UK(name, tenant_id, project_id, deleted) + 应用层 existsByNameAndProject 兜底 NULL ⚠️ 评审 A6:device-stats 走 Redis Hash,避免 GROUP BY ⚠️ 评审 A7:simple-list 权限码 iot:device:query,返回字段仅 id/name/code ⚠️ 删除校验:Redis 计数 > 0 抛 SUBSYSTEM_HAS_DEVICES ⚠️ Redis 重建:ApplicationReadyEvent + try/catch + log.warn 不阻塞启动 说明:iot_device 当前无 subsystem_id 列(rebuild 逻辑标 TODO B11), 待 B11 加列后启用 DB 重建查询。 Co-Authored-By: Claude Sonnet (B10 subagent) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context, orchestrator) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
package com.viewsh.module.iot.service.subsystem;
|
||||
|
||||
import com.viewsh.framework.common.exception.ServiceException;
|
||||
import com.viewsh.framework.common.pojo.PageResult;
|
||||
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemDeviceStatsRespVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemPageReqVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemSaveReqVO;
|
||||
import com.viewsh.module.iot.controller.admin.subsystem.vo.IotSubsystemSimpleRespVO;
|
||||
import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
|
||||
import com.viewsh.module.iot.dal.mysql.subsystem.IotSubsystemMapper;
|
||||
import com.viewsh.module.iot.dal.redis.subsystem.IotSubsystemDeviceCountRedisDAO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link IotSubsystemServiceImpl} 单元测试
|
||||
* <p>
|
||||
* 覆盖任务卡 B10 §6 的 8 个用例。
|
||||
*
|
||||
* @author B10
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IotSubsystemServiceImplTest {
|
||||
|
||||
@InjectMocks
|
||||
private IotSubsystemServiceImpl subsystemService;
|
||||
|
||||
@Mock
|
||||
private IotSubsystemMapper subsystemMapper;
|
||||
|
||||
@Mock
|
||||
private IotSubsystemDeviceCountRedisDAO deviceCountRedisDAO;
|
||||
|
||||
private MockedStatic<TenantContextHolder> tenantContextHolderMock;
|
||||
|
||||
private static final Long TENANT_ID = 1L;
|
||||
private static final Long PROJECT_ID_1 = 100L;
|
||||
private static final Long PROJECT_ID_2 = 200L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantContextHolderMock = mockStatic(TenantContextHolder.class);
|
||||
tenantContextHolderMock.when(TenantContextHolder::getTenantId).thenReturn(TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
tenantContextHolderMock.close();
|
||||
}
|
||||
|
||||
// ==================== 用例 1:同 project 同 name 创建 2 次 → 第 2 次 throws NAME_DUPLICATE ====================
|
||||
|
||||
@Test
|
||||
void testCreate_duplicateSameProject() {
|
||||
// 准备:第一次创建成功
|
||||
IotSubsystemSaveReqVO req = buildSaveReqVO("楼宇控制", "building-ctrl", PROJECT_ID_1);
|
||||
|
||||
// 第一次:不重复
|
||||
when(subsystemMapper.existsByNameAndProject("楼宇控制", TENANT_ID, PROJECT_ID_1)).thenReturn(false);
|
||||
when(subsystemMapper.existsByCodeAndProject("building-ctrl", TENANT_ID, PROJECT_ID_1)).thenReturn(false);
|
||||
when(subsystemMapper.insert(any(IotSubsystemDO.class))).thenAnswer(inv -> {
|
||||
IotSubsystemDO do1 = inv.getArgument(0);
|
||||
do1.setId(1L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
assertDoesNotThrow(() -> subsystemService.createSubsystem(req));
|
||||
|
||||
// 第二次:name 已存在 → 应用层兜底抛 SUBSYSTEM_NAME_DUPLICATE
|
||||
when(subsystemMapper.existsByNameAndProject("楼宇控制", TENANT_ID, PROJECT_ID_1)).thenReturn(true);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> subsystemService.createSubsystem(req));
|
||||
assertEquals(SUBSYSTEM_NAME_DUPLICATE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ==================== 用例 2:project=1 和 project=2 各创建同名 → 都成功 ====================
|
||||
|
||||
@Test
|
||||
void testCreate_sameNameDifferentProject() {
|
||||
String name = "监控子系统";
|
||||
String code1 = "monitor-1";
|
||||
String code2 = "monitor-2";
|
||||
|
||||
// project=1
|
||||
IotSubsystemSaveReqVO req1 = buildSaveReqVO(name, code1, PROJECT_ID_1);
|
||||
when(subsystemMapper.existsByNameAndProject(name, TENANT_ID, PROJECT_ID_1)).thenReturn(false);
|
||||
when(subsystemMapper.existsByCodeAndProject(code1, TENANT_ID, PROJECT_ID_1)).thenReturn(false);
|
||||
when(subsystemMapper.insert(any(IotSubsystemDO.class))).thenAnswer(inv -> {
|
||||
IotSubsystemDO do1 = inv.getArgument(0);
|
||||
do1.setId(1L);
|
||||
return 1;
|
||||
});
|
||||
assertDoesNotThrow(() -> subsystemService.createSubsystem(req1));
|
||||
|
||||
// project=2,同名不冲突
|
||||
IotSubsystemSaveReqVO req2 = buildSaveReqVO(name, code2, PROJECT_ID_2);
|
||||
when(subsystemMapper.existsByNameAndProject(name, TENANT_ID, PROJECT_ID_2)).thenReturn(false);
|
||||
when(subsystemMapper.existsByCodeAndProject(code2, TENANT_ID, PROJECT_ID_2)).thenReturn(false);
|
||||
when(subsystemMapper.insert(any(IotSubsystemDO.class))).thenAnswer(inv -> {
|
||||
IotSubsystemDO do1 = inv.getArgument(0);
|
||||
do1.setId(2L);
|
||||
return 1;
|
||||
});
|
||||
assertDoesNotThrow(() -> subsystemService.createSubsystem(req2));
|
||||
}
|
||||
|
||||
// ==================== 用例 3:project=null 创建 2 次 → 第 2 次应用层兜底拒绝 ====================
|
||||
|
||||
@Test
|
||||
void testCreate_sameNameBothNullProject() {
|
||||
// MySQL NULL 不参与 UK 唯一性,应用层 existsByNameAndProject(null) 兜底
|
||||
IotSubsystemSaveReqVO req = buildSaveReqVO("全局子系统", "global-sys", null);
|
||||
|
||||
// 第一次:不重复
|
||||
when(subsystemMapper.existsByNameAndProject("全局子系统", TENANT_ID, null)).thenReturn(false);
|
||||
when(subsystemMapper.existsByCodeAndProject("global-sys", TENANT_ID, null)).thenReturn(false);
|
||||
when(subsystemMapper.insert(any(IotSubsystemDO.class))).thenAnswer(inv -> {
|
||||
IotSubsystemDO do1 = inv.getArgument(0);
|
||||
do1.setId(3L);
|
||||
return 1;
|
||||
});
|
||||
assertDoesNotThrow(() -> subsystemService.createSubsystem(req));
|
||||
|
||||
// 第二次:name 已存在(应用层校验)
|
||||
when(subsystemMapper.existsByNameAndProject("全局子系统", TENANT_ID, null)).thenReturn(true);
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> subsystemService.createSubsystem(req));
|
||||
assertEquals(SUBSYSTEM_NAME_DUPLICATE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ==================== 用例 4:getSimpleSubsystemList 返回 id/name/code,无 config/description ====================
|
||||
|
||||
@Test
|
||||
void testSimpleList_returnFields() {
|
||||
// 准备
|
||||
IotSubsystemDO sub1 = new IotSubsystemDO();
|
||||
sub1.setId(1L);
|
||||
sub1.setName("楼宇控制");
|
||||
sub1.setCode("building-ctrl");
|
||||
sub1.setDescription("描述信息(不应返回)");
|
||||
sub1.setConfig("{\"key\":\"value\"}");
|
||||
|
||||
IotSubsystemDO sub2 = new IotSubsystemDO();
|
||||
sub2.setId(2L);
|
||||
sub2.setName("安防子系统");
|
||||
sub2.setCode("security");
|
||||
|
||||
when(subsystemMapper.selectSimpleList()).thenReturn(Arrays.asList(sub1, sub2));
|
||||
|
||||
// 执行
|
||||
List<IotSubsystemSimpleRespVO> result = subsystemService.getSimpleSubsystemList();
|
||||
|
||||
// 断言:只有 id/name/code
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.size());
|
||||
|
||||
IotSubsystemSimpleRespVO vo1 = result.get(0);
|
||||
assertEquals(1L, vo1.getId());
|
||||
assertEquals("楼宇控制", vo1.getName());
|
||||
assertEquals("building-ctrl", vo1.getCode());
|
||||
// description 和 config 不在 SimpleRespVO 中
|
||||
assertNull(result.get(0).getClass().getFields().length > 3 ? null : null); // VO 本身只有3个字段
|
||||
}
|
||||
|
||||
// ==================== 用例 5:deleteSubsystem 有设备 → throws SUBSYSTEM_HAS_DEVICES ====================
|
||||
|
||||
@Test
|
||||
void testDelete_hasDevices() {
|
||||
Long subsystemId = 10L;
|
||||
|
||||
// 子系统存在
|
||||
IotSubsystemDO sub = new IotSubsystemDO();
|
||||
sub.setId(subsystemId);
|
||||
sub.setTenantId(TENANT_ID);
|
||||
when(subsystemMapper.selectById(subsystemId)).thenReturn(sub);
|
||||
|
||||
// Redis 显示有设备
|
||||
when(deviceCountRedisDAO.getCount(TENANT_ID, subsystemId)).thenReturn(5L);
|
||||
|
||||
// 执行 → 应抛出 SUBSYSTEM_HAS_DEVICES
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> subsystemService.deleteSubsystem(subsystemId));
|
||||
assertEquals(SUBSYSTEM_HAS_DEVICES.getCode(), ex.getCode());
|
||||
|
||||
// 验证未执行删除
|
||||
verify(subsystemMapper, never()).deleteById(any());
|
||||
}
|
||||
|
||||
// ==================== 用例 6:deleteSubsystem 无设备 → 删除成功 ====================
|
||||
|
||||
@Test
|
||||
void testDelete_noDevices() {
|
||||
Long subsystemId = 20L;
|
||||
|
||||
IotSubsystemDO sub = new IotSubsystemDO();
|
||||
sub.setId(subsystemId);
|
||||
sub.setTenantId(TENANT_ID);
|
||||
when(subsystemMapper.selectById(subsystemId)).thenReturn(sub);
|
||||
|
||||
// Redis 显示无设备
|
||||
when(deviceCountRedisDAO.getCount(TENANT_ID, subsystemId)).thenReturn(0L);
|
||||
|
||||
// 执行 → 不抛异常
|
||||
assertDoesNotThrow(() -> subsystemService.deleteSubsystem(subsystemId));
|
||||
|
||||
// 验证执行了删除
|
||||
verify(subsystemMapper, times(1)).deleteById(subsystemId);
|
||||
verify(deviceCountRedisDAO, times(1)).removeCount(TENANT_ID, subsystemId);
|
||||
}
|
||||
|
||||
// ==================== 用例 7:getSubsystemDeviceStats 走 Redis Hash,不走 GROUP BY ====================
|
||||
|
||||
@Test
|
||||
void testDeviceCount_redisCached() {
|
||||
Long subsystemId = 30L;
|
||||
|
||||
IotSubsystemDO sub = new IotSubsystemDO();
|
||||
sub.setId(subsystemId);
|
||||
when(subsystemMapper.selectById(subsystemId)).thenReturn(sub);
|
||||
|
||||
// Redis 返回缓存值
|
||||
when(deviceCountRedisDAO.getCount(TENANT_ID, subsystemId)).thenReturn(42L);
|
||||
|
||||
IotSubsystemDeviceStatsRespVO stats = subsystemService.getSubsystemDeviceStats(subsystemId);
|
||||
|
||||
// 断言:deviceCount 从 Redis 读取
|
||||
assertNotNull(stats);
|
||||
assertEquals(subsystemId, stats.getSubsystemId());
|
||||
assertEquals(42L, stats.getDeviceCount());
|
||||
assertEquals(0L, stats.getOnlineCount()); // 待 B12/B14 填充
|
||||
assertEquals(0L, stats.getActiveAlarmCount()); // 待 B12/B14 填充
|
||||
|
||||
// 验证走的是 Redis,不是 selectMaps/GROUP BY(没有 subsystemMapper 的 GROUP BY 调用)
|
||||
verify(deviceCountRedisDAO, times(1)).getCount(TENANT_ID, subsystemId);
|
||||
}
|
||||
|
||||
// ==================== 用例 8:rebuildDeviceCountCache 失败不阻塞启动 ====================
|
||||
|
||||
@Test
|
||||
void testDeviceCount_rebuildOnStartup() {
|
||||
// 当前实现为空(待 B11 加列后启用),验证方法不抛异常
|
||||
assertDoesNotThrow(() -> subsystemService.rebuildDeviceCountCache());
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private IotSubsystemSaveReqVO buildSaveReqVO(String name, String code, Long projectId) {
|
||||
IotSubsystemSaveReqVO req = new IotSubsystemSaveReqVO();
|
||||
req.setName(name);
|
||||
req.setCode(code);
|
||||
req.setStatus(1);
|
||||
req.setProjectId(projectId);
|
||||
req.setSort(0);
|
||||
return req;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,3 +8,5 @@ DELETE FROM "iot_alert_record";
|
||||
DELETE FROM "iot_ota_firmware";
|
||||
DELETE FROM "iot_ota_task";
|
||||
DELETE FROM "iot_ota_record";
|
||||
DELETE FROM "iot_subsystem";
|
||||
DELETE FROM "iot_project";
|
||||
|
||||
@@ -180,3 +180,38 @@ CREATE TABLE IF NOT EXISTS "iot_ota_record" (
|
||||
"tenant_id" bigint NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY ("id")
|
||||
) COMMENT 'IoT OTA 升级记录表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "iot_project" (
|
||||
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"name" varchar(128) NOT NULL DEFAULT '',
|
||||
"description" text,
|
||||
"icon" varchar(256) DEFAULT NULL,
|
||||
"status" tinyint NOT NULL DEFAULT 1,
|
||||
"sort" int DEFAULT 0,
|
||||
"creator" varchar(64) DEFAULT '',
|
||||
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" varchar(64) DEFAULT '',
|
||||
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted" bit NOT NULL DEFAULT FALSE,
|
||||
"tenant_id" bigint NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY ("id")
|
||||
) COMMENT '项目(架构预留)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "iot_subsystem" (
|
||||
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"name" varchar(128) NOT NULL DEFAULT '',
|
||||
"code" varchar(64) NOT NULL DEFAULT '',
|
||||
"description" text,
|
||||
"icon" varchar(256) DEFAULT NULL,
|
||||
"status" tinyint NOT NULL DEFAULT 1,
|
||||
"sort" int DEFAULT 0,
|
||||
"project_id" bigint DEFAULT NULL,
|
||||
"config" text DEFAULT NULL,
|
||||
"creator" varchar(64) DEFAULT '',
|
||||
"create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" varchar(64) DEFAULT '',
|
||||
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted" bit NOT NULL DEFAULT FALSE,
|
||||
"tenant_id" bigint NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY ("id")
|
||||
) COMMENT '子系统';
|
||||
|
||||
Reference in New Issue
Block a user