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:
lzh
2026-04-23 21:08:00 +08:00
parent 4614737d51
commit 6649e1abb6
18 changed files with 1295 additions and 0 deletions

View File

@@ -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());
}
// ==================== 用例 2project=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));
}
// ==================== 用例 3project=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());
}
// ==================== 用例 4getSimpleSubsystemList 返回 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个字段
}
// ==================== 用例 5deleteSubsystem 有设备 → 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());
}
// ==================== 用例 6deleteSubsystem 无设备 → 删除成功 ====================
@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);
}
// ==================== 用例 7getSubsystemDeviceStats 走 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);
}
// ==================== 用例 8rebuildDeviceCountCache 失败不阻塞启动 ====================
@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;
}
}

View File

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

View File

@@ -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 '子系统';