feat(iot): 一期 Controller 补齐 (B2/B4-6/B10/B11/B12/B13)

对照前端 feat/iot-2.0 已固化 API 契约补齐 5 组缺失端点(发现于一期 19/19
宣称完成后前端联调阶段),归属原任务卡 Controller 层返工,不占用二期
B20+ 编号。

- B2  规则链: 补 PUT /disable /deploy /debug + POST /copy?id= +
              新增 GET /rule-chain/get?id= 返 GraphVO(保留 /get/{id})
              deployRuleChain=enable+主动 Pub/Sub evict(对齐 B8)
- B10 子系统: 新增 GET /device-count 聚合(HGETALL 返空 map 遵循 A6)
              + GET /get?id= query 别名(保留 /get/{id})
- B11 设备:   新增驼峰 PUT /bindSubsystem /batchBindSubsystem
              + 2 ReqVO,保留 kebab 兼容
- B12/B13 告警: 新增 IotAlarmRecordController(整缺)11 端点:
                page/get/ack/unack/clear/archive/batch-{ack,clear,archive}/
                history/remark;Service 补 6 方法(getPage/batchAck/
                batchClear/batchArchive/updateRemark/listHistory)
                + Mapper 2 方法 + 8 VO
- B4/5/6 节点元数据: 新增 GET /iot/rule/provider/metadata 聚合端点;
                    3 SPI 加 default getMetadata(),4 Manager 加
                    listAllMetadata(),13 具体 Provider 覆写(中文 label
                    + mdi: icon),schema MVP 空骨架 {rule:[]}

测试:
- iot-rule   191/191 全绿(+5 B2 补齐 +9 B4/5/6 补齐)
- iot-server 106 active/161 Skipped v1 遗产 全绿
            (+6 B12/B13 补齐 +3 B10 补齐)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 15:47:41 +08:00
parent 9912b73c56
commit 7dc00b542d
51 changed files with 2211 additions and 3 deletions

View File

@@ -3,7 +3,10 @@ package com.viewsh.module.iot.service.alarm;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.alarm.vo.AlarmRecordPageReqVO;
import com.viewsh.module.iot.dal.dataobject.alarm.AlarmHistoryDO;
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
import com.viewsh.module.iot.dal.mysql.alarm.IotAlarmRecordMapper;
import com.viewsh.module.iot.service.alarm.dto.AlarmStateTransitionRequest;
@@ -19,6 +22,8 @@ import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.function.Supplier;
import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
@@ -57,6 +62,10 @@ class IotAlarmRecordServiceImplTest {
@Mock
private IotAlarmPropagationService alarmPropagationService;
/** B20告警历史 Service */
@Mock
private IotAlarmHistoryService alarmHistoryService;
private MockedStatic<TenantContextHolder> tenantMock;
private static final Long TENANT_ID = 1L;
@@ -366,6 +375,121 @@ class IotAlarmRecordServiceImplTest {
verify(cacheService).evict(666L);
}
// ==================== B20 新增测试用例 ====================
// ==================== B20 用例 1分页查询 ====================
@Test
void testGetAlarmPage() {
AlarmRecordPageReqVO reqVO = new AlarmRecordPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setSeverity(1);
IotAlarmRecordDO alarm = activeAlarm(100L);
PageResult<IotAlarmRecordDO> mockResult = new PageResult<>(List.of(alarm), 1L);
when(alarmRecordMapper.selectPage(reqVO)).thenReturn(mockResult);
PageResult<IotAlarmRecordDO> result = alarmService.getAlarmPage(reqVO);
assertNotNull(result);
assertEquals(1L, result.getTotal());
assertEquals(1, result.getList().size());
assertEquals(100L, result.getList().get(0).getId());
verify(alarmRecordMapper).selectPage(reqVO);
}
// ==================== B20 用例 2批量确认遍历单条幂等 ====================
@Test
@SuppressWarnings("unchecked")
void testBatchAckAlarm() {
// 三个告警 ID各自未确认
IotAlarmRecordDO a1 = activeAlarm(201L);
IotAlarmRecordDO a2 = activeAlarm(202L);
IotAlarmRecordDO a3 = activeAlarm(203L);
when(alarmRecordMapper.selectById(201L)).thenReturn(a1);
when(alarmRecordMapper.selectById(202L)).thenReturn(a2);
when(alarmRecordMapper.selectById(203L)).thenReturn(a3);
alarmService.batchAckAlarm(List.of(201L, 202L, 203L), "admin", "批量确认");
// 三条各调一次 updateById
verify(alarmRecordMapper, times(3)).updateById(any(IotAlarmRecordDO.class));
}
// ==================== B20 用例 3批量清除 ====================
@Test
@SuppressWarnings("unchecked")
void testBatchClearAlarm() {
IotAlarmRecordDO a1 = activeAlarm(301L);
IotAlarmRecordDO a2 = activeAlarm(302L);
when(alarmRecordMapper.selectById(301L)).thenReturn(a1);
when(alarmRecordMapper.selectById(302L)).thenReturn(a2);
alarmService.batchClearAlarm(List.of(301L, 302L), "system", null);
verify(alarmRecordMapper, times(2)).updateById(any(IotAlarmRecordDO.class));
}
// ==================== B20 用例 4批量归档独立捕获已归档 ====================
@Test
@SuppressWarnings("unchecked")
void testBatchArchiveAlarm_partialAlreadyArchived() {
IotAlarmRecordDO active = activeAlarm(401L);
// 402 已归档 → archiveAlarm 会抛 ALARM_ALREADY_ARCHIVED
IotAlarmRecordDO archived = IotAlarmRecordDO.builder()
.id(402L).ackState(1).clearState(1).archived(1).triggerCount(5).build();
when(alarmRecordMapper.selectById(401L)).thenReturn(active);
when(alarmRecordMapper.selectById(402L)).thenReturn(archived);
int failCount = alarmService.batchArchiveAlarm(List.of(401L, 402L), "admin");
// 401 成功402 失败
assertEquals(1, failCount);
// 401 调了 updateById402 由于已归档在锁前抛错,不走 updateById
verify(alarmRecordMapper, times(1)).updateById(any(IotAlarmRecordDO.class));
}
// ==================== B20 用例 5更新备注 ====================
@Test
void testUpdateRemark() {
IotAlarmRecordDO alarm = activeAlarm(501L);
when(alarmRecordMapper.selectById(501L)).thenReturn(alarm);
alarmService.updateRemark(501L, "已通知运维");
ArgumentCaptor<IotAlarmRecordDO> captor = ArgumentCaptor.forClass(IotAlarmRecordDO.class);
verify(alarmRecordMapper).updateById(captor.capture());
assertEquals(501L, captor.getValue().getId());
assertEquals("已通知运维", captor.getValue().getProcessRemark());
// 不走分布式锁
verify(lockService, never()).executeWithLock(any(), any(), any());
}
// ==================== B20 用例 6查询历史代理给 HistoryService ====================
@Test
void testListAlarmHistory() {
AlarmHistoryDO history = AlarmHistoryDO.builder()
.alarmRecordId(601L)
.eventType("ack")
.ts(Instant.now())
.operator("admin")
.build();
when(alarmHistoryService.queryByAlarmRecord(eq(601L), any(), any()))
.thenReturn(List.of(history));
List<AlarmHistoryDO> result = alarmService.listAlarmHistory(601L);
assertNotNull(result);
assertEquals(1, result.size());
assertEquals("ack", result.get(0).getEventType());
}
// ==================== 辅助 ====================
private AlarmTriggerRequest buildTriggerReq(int severity) {

View File

@@ -3,6 +3,7 @@ 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.IotSubsystemDeviceCountRespVO;
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;
@@ -21,7 +22,9 @@ import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
@@ -259,6 +262,67 @@ class IotSubsystemServiceImplTest {
assertDoesNotThrow(() -> subsystemService.rebuildDeviceCountCache());
}
// ==================== 用例 9B22getAllSubsystemDeviceCount Redis 命中 → 返回 map ====================
@Test
void testGetAllSubsystemDeviceCount_redisHit() {
// Redis 返回 2 个子系统的设备数
Map<Long, Long> redisData = Map.of(1L, 10L, 2L, 5L);
when(deviceCountRedisDAO.getAllCounts(TENANT_ID)).thenReturn(redisData);
Map<Long, IotSubsystemDeviceCountRespVO> result = subsystemService.getAllSubsystemDeviceCount();
assertNotNull(result);
assertEquals(2, result.size());
IotSubsystemDeviceCountRespVO vo1 = result.get(1L);
assertNotNull(vo1);
assertEquals(10L, vo1.getTotal());
assertEquals(0L, vo1.getOnline()); // 待 B12/B14
assertEquals(0L, vo1.getAlarm()); // 待 B12/B14
IotSubsystemDeviceCountRespVO vo2 = result.get(2L);
assertNotNull(vo2);
assertEquals(5L, vo2.getTotal());
verify(deviceCountRedisDAO, times(1)).getAllCounts(TENANT_ID);
}
// ==================== 用例 10B22getAllSubsystemDeviceCount Redis miss → 返回空 map ====================
@Test
void testGetAllSubsystemDeviceCount_redisMiss() {
// Redis Hash 为空Key 不存在或 Hash 无数据)
when(deviceCountRedisDAO.getAllCounts(TENANT_ID)).thenReturn(Collections.emptyMap());
Map<Long, IotSubsystemDeviceCountRespVO> result = subsystemService.getAllSubsystemDeviceCount();
assertNotNull(result);
assertTrue(result.isEmpty());
verify(deviceCountRedisDAO, times(1)).getAllCounts(TENANT_ID);
}
// ==================== 用例 11B22getAllSubsystemDeviceCount 空数据集 → 返回空 map ====================
@Test
void testGetAllSubsystemDeviceCount_emptyData() {
// 明确验证Redis 命中但计数均为 0 时,仍正确返回 total=0 的 VO
Map<Long, Long> redisData = Map.of(100L, 0L);
when(deviceCountRedisDAO.getAllCounts(TENANT_ID)).thenReturn(redisData);
Map<Long, IotSubsystemDeviceCountRespVO> result = subsystemService.getAllSubsystemDeviceCount();
assertNotNull(result);
assertEquals(1, result.size());
IotSubsystemDeviceCountRespVO vo = result.get(100L);
assertNotNull(vo);
assertEquals(0L, vo.getTotal());
assertEquals(0L, vo.getOnline());
assertEquals(0L, vo.getAlarm());
}
// ==================== 辅助方法 ====================
private IotSubsystemSaveReqVO buildSaveReqVO(String name, String code, Long projectId) {