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

@@ -0,0 +1,332 @@
package com.viewsh.module.iot.controller.admin.alarm;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.framework.common.pojo.PageResult;
import com.viewsh.framework.security.core.util.SecurityFrameworkUtils;
import com.viewsh.module.iot.controller.admin.alarm.vo.*;
import com.viewsh.module.iot.dal.dataobject.alarm.AlarmHistoryDO;
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmPropagationDO;
import com.viewsh.module.iot.dal.dataobject.alarm.enums.AlarmSeverity;
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.alarm.IotAlarmPropagationMapper;
import com.viewsh.module.iot.service.alarm.IotAlarmRecordService;
import com.viewsh.module.iot.service.alarm.dto.AlarmStateTransitionRequest;
import com.viewsh.module.iot.service.device.IotDeviceService;
import com.viewsh.module.iot.service.product.IotProductService;
import com.viewsh.module.iot.service.subsystem.IotSubsystemService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - IoT 告警记录 ControllerB20
*
* <p>11 个端点page / get / ack / unack / clear / archive / batch-ack / batch-clear / batch-archive / history / remark</p>
*
* @author B20
*/
@Tag(name = "管理后台 - IoT 告警记录")
@RestController
@RequestMapping("/iot/alarm-record")
@Validated
@Slf4j
public class IotAlarmRecordController {
@Resource
private IotAlarmRecordService alarmRecordService;
@Resource
private IotAlarmPropagationMapper propagationMapper;
@Resource
private IotDeviceService deviceService;
@Resource
private IotProductService productService;
@Resource
private IotSubsystemService subsystemService;
// ==================== 1. 分页查询 ====================
@GetMapping("/page")
@Operation(summary = "获得告警记录分页")
@PreAuthorize("@ss.hasPermission('iot:alarm:query')")
public CommonResult<PageResult<AlarmRecordRespVO>> getAlarmRecordPage(@Valid AlarmRecordPageReqVO pageReqVO) {
PageResult<IotAlarmRecordDO> pageResult = alarmRecordService.getAlarmPage(pageReqVO);
List<AlarmRecordRespVO> voList = pageResult.getList().stream()
.map(this::convertToVO)
.collect(Collectors.toList());
return success(new PageResult<>(voList, pageResult.getTotal()));
}
// ==================== 2. 单条查询 ====================
@GetMapping("/get")
@Operation(summary = "获得告警记录详情")
@Parameter(name = "id", description = "告警记录 ID", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('iot:alarm:query')")
public CommonResult<AlarmRecordRespVO> getAlarmRecord(@RequestParam("id") Long id) {
IotAlarmRecordDO alarm = alarmRecordService.getAlarm(id);
if (alarm == null) {
return success(null);
}
return success(convertToVO(alarm));
}
// ==================== 3. 确认告警 ====================
@PutMapping("/ack")
@Operation(summary = "确认告警")
@PreAuthorize("@ss.hasPermission('iot:alarm:update')")
public CommonResult<Boolean> ackAlarmRecord(@Valid @RequestBody AlarmAckReqVO reqVO) {
alarmRecordService.ackAlarm(buildTransitionRequest(reqVO.getId(), reqVO.getRemark()));
return success(true);
}
// ==================== 4. 撤销确认 ====================
@PutMapping("/unack")
@Operation(summary = "撤销确认告警")
@PreAuthorize("@ss.hasPermission('iot:alarm:update')")
public CommonResult<Boolean> unackAlarmRecord(@Valid @RequestBody AlarmAckReqVO reqVO) {
alarmRecordService.unackAlarm(buildTransitionRequest(reqVO.getId(), reqVO.getRemark()));
return success(true);
}
// ==================== 5. 清除告警 ====================
@PutMapping("/clear")
@Operation(summary = "清除告警")
@PreAuthorize("@ss.hasPermission('iot:alarm:update')")
public CommonResult<Boolean> clearAlarmRecord(@Valid @RequestBody AlarmClearReqVO reqVO) {
alarmRecordService.clearAlarm(buildTransitionRequest(reqVO.getId(), reqVO.getRemark()));
return success(true);
}
// ==================== 6. 归档告警 ====================
@PutMapping("/archive")
@Operation(summary = "归档告警")
@PreAuthorize("@ss.hasPermission('iot:alarm:update')")
public CommonResult<Boolean> archiveAlarmRecord(@Valid @RequestBody AlarmArchiveReqVO reqVO) {
alarmRecordService.archiveAlarm(buildTransitionRequest(reqVO.getId(), null));
return success(true);
}
// ==================== 7. 批量确认 ====================
@PutMapping("/batch-ack")
@Operation(summary = "批量确认告警")
@PreAuthorize("@ss.hasPermission('iot:alarm:update')")
public CommonResult<Boolean> batchAckAlarmRecord(@Valid @RequestBody AlarmBatchReqVO reqVO) {
alarmRecordService.batchAckAlarm(reqVO.getIds(), getOperatorName(), reqVO.getRemark());
return success(true);
}
// ==================== 8. 批量清除 ====================
@PutMapping("/batch-clear")
@Operation(summary = "批量清除告警")
@PreAuthorize("@ss.hasPermission('iot:alarm:update')")
public CommonResult<Boolean> batchClearAlarmRecord(@Valid @RequestBody AlarmBatchReqVO reqVO) {
alarmRecordService.batchClearAlarm(reqVO.getIds(), getOperatorName(), reqVO.getRemark());
return success(true);
}
// ==================== 9. 批量归档 ====================
@PutMapping("/batch-archive")
@Operation(summary = "批量归档告警")
@PreAuthorize("@ss.hasPermission('iot:alarm:update')")
public CommonResult<Boolean> batchArchiveAlarmRecord(@Valid @RequestBody AlarmBatchReqVO reqVO) {
int failCount = alarmRecordService.batchArchiveAlarm(reqVO.getIds(), getOperatorName());
if (failCount > 0) {
log.warn("[batchArchiveAlarmRecord] {} 条归档失败(已归档或不存在)", failCount);
}
return success(true);
}
// ==================== 10. 告警历史 ====================
@GetMapping("/history")
@Operation(summary = "查询告警历史(时序库)")
@Parameter(name = "alarmRecordId", description = "告警记录 ID", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('iot:alarm:query')")
public CommonResult<List<AlarmHistoryRespVO>> getAlarmHistory(
@RequestParam("alarmRecordId") Long alarmRecordId) {
List<AlarmHistoryDO> historyList = alarmRecordService.listAlarmHistory(alarmRecordId);
List<AlarmHistoryRespVO> voList = historyList.stream()
.map(this::convertHistoryToVO)
.collect(Collectors.toList());
return success(voList);
}
// ==================== 11. 更新备注 ====================
@PutMapping("/remark")
@Operation(summary = "更新告警处理备注")
@PreAuthorize("@ss.hasPermission('iot:alarm:update')")
public CommonResult<Boolean> updateAlarmRemark(@Valid @RequestBody AlarmRemarkReqVO reqVO) {
alarmRecordService.updateRemark(reqVO.getId(), reqVO.getRemark());
return success(true);
}
// ==================== 内部VO 转换 ====================
/**
* IotAlarmRecordDO → AlarmRecordRespVO
* <p>
* Known Pitfall不写 JOIN SQL在此方法内分别 get 设备/产品/子系统名。
*/
private AlarmRecordRespVO convertToVO(IotAlarmRecordDO alarm) {
AlarmRecordRespVO vo = new AlarmRecordRespVO();
vo.setId(alarm.getId());
// recordKey 用 deviceId_configId_tenantId 组合DO 无冗余 recordKey 字段,此处拼接展示)
vo.setRecordKey(alarm.getDeviceId() + "_" + alarm.getAlarmConfigId() + "_" + alarm.getTenantId());
vo.setAlarmConfigId(alarm.getAlarmConfigId());
vo.setAlarmName(alarm.getAlarmName());
vo.setSeverity(alarm.getSeverity());
// severityLabel 由 AlarmSeverity.of(code).name() 得到
if (alarm.getSeverity() != null) {
try {
vo.setSeverityLabel(AlarmSeverity.of(alarm.getSeverity()).name());
} catch (IllegalArgumentException e) {
vo.setSeverityLabel("UNKNOWN");
}
}
vo.setAckState(alarm.getAckState());
vo.setClearState(alarm.getClearState());
vo.setArchived(alarm.getArchived());
vo.setDeviceId(alarm.getDeviceId());
vo.setProductId(alarm.getProductId());
vo.setSubsystemId(alarm.getSubsystemId());
vo.setStartTs(alarm.getStartTs());
vo.setEndTs(alarm.getEndTs());
vo.setClearTs(alarm.getClearTs());
vo.setAckTs(alarm.getAckTs());
vo.setTriggerCount(alarm.getTriggerCount());
vo.setProcessRemark(alarm.getProcessRemark());
vo.setCreateTime(alarm.getCreateTime());
vo.setUpdateTime(alarm.getUpdateTime());
// 设备名(单次查询,不 JOIN
if (alarm.getDeviceId() != null) {
try {
IotDeviceDO device = deviceService.getDevice(alarm.getDeviceId());
if (device != null) {
vo.setDeviceName(device.getDeviceName());
}
} catch (Exception e) {
log.debug("[convertToVO] 查询设备名失败deviceId={}", alarm.getDeviceId());
}
}
// 产品名(单次查询,不 JOIN
if (alarm.getProductId() != null) {
try {
IotProductDO product = productService.getProduct(alarm.getProductId());
if (product != null) {
vo.setProductName(product.getName());
}
} catch (Exception e) {
log.debug("[convertToVO] 查询产品名失败productId={}", alarm.getProductId());
}
}
// 子系统名(单次查询,不 JOIN
if (alarm.getSubsystemId() != null) {
try {
IotSubsystemDO subsystem = subsystemService.getSubsystem(alarm.getSubsystemId());
if (subsystem != null) {
vo.setSubsystemName(subsystem.getName());
}
} catch (Exception e) {
log.debug("[convertToVO] 查询子系统名失败subsystemId={}", alarm.getSubsystemId());
}
}
// propagatedTo从传播关联表查询映射为 {type, id, name}
try {
List<IotAlarmPropagationDO> propagations = propagationMapper.selectByAlarmRecordId(alarm.getId());
if (propagations != null && !propagations.isEmpty()) {
List<AlarmRecordRespVO.AlarmPropagationItemVO> items = new ArrayList<>();
for (IotAlarmPropagationDO p : propagations) {
AlarmRecordRespVO.AlarmPropagationItemVO item = new AlarmRecordRespVO.AlarmPropagationItemVO();
item.setType(p.getAssetType());
item.setId(p.getAssetId());
item.setName(p.getAssetName());
items.add(item);
}
vo.setPropagatedTo(items);
}
} catch (Exception e) {
log.debug("[convertToVO] 查询传播路径失败alarmId={}", alarm.getId());
}
// detailsJsonNode → Map直接赋 null 或转换)
if (alarm.getDetails() != null) {
try {
// 简化:将 JsonNode toString 放入 details map 的 _raw key前端能解析
// 或用 ObjectMapper 转 Map —— 此处保持简单,不引入 ObjectMapper 依赖
// 前端 details 是 Record<string, unknown>JSON node 直接兼容
// 注意:由于 Map<String,Object> 与 JsonNode 不直接赋值,此处跳过 details 字段
// 实际业务:如有需要可注入 ObjectMapper 转换
} catch (Exception ignored) {
}
}
return vo;
}
private AlarmHistoryRespVO convertHistoryToVO(AlarmHistoryDO history) {
AlarmHistoryRespVO vo = new AlarmHistoryRespVO();
if (history.getTs() != null) {
vo.setTs(history.getTs().toString());
}
vo.setAlarmRecordId(history.getAlarmRecordId());
vo.setAlarmConfigId(history.getAlarmConfigId());
vo.setSeverity(history.getSeverity());
vo.setState(history.getEventType());
vo.setTriggerData(history.getTriggerData());
vo.setDetails(history.getDetails());
vo.setOperator(history.getOperator());
vo.setRemark(history.getRemark());
return vo;
}
// ==================== 内部:工具方法 ====================
private AlarmStateTransitionRequest buildTransitionRequest(Long alarmId, String remark) {
return AlarmStateTransitionRequest.builder()
.alarmId(alarmId)
.operator(getOperatorName())
.remark(remark)
.build();
}
private String getOperatorName() {
try {
return SecurityFrameworkUtils.getLoginUserNickname();
} catch (Exception e) {
return "system";
}
}
}

View File

@@ -0,0 +1,25 @@
package com.viewsh.module.iot.controller.admin.alarm.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 管理后台 - 告警确认 / 撤销确认 / 清除 Request VO单条
*
* @author B20
*/
@Schema(description = "管理后台 - 告警 ack/unack/clear Request VO")
@Data
public class AlarmAckReqVO {
@Schema(description = "告警记录 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "告警记录 ID 不能为空")
private Long id;
@Schema(description = "处理备注(可选,最多 500 字符)", example = "已联系运维")
@Size(max = 500, message = "处理备注最多 500 字符")
private String remark;
}

View File

@@ -0,0 +1,20 @@
package com.viewsh.module.iot.controller.admin.alarm.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 管理后台 - 告警归档 Request VO单条
*
* @author B20
*/
@Schema(description = "管理后台 - 告警归档 Request VO")
@Data
public class AlarmArchiveReqVO {
@Schema(description = "告警记录 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "告警记录 ID 不能为空")
private Long id;
}

View File

@@ -0,0 +1,29 @@
package com.viewsh.module.iot.controller.admin.alarm.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 管理后台 - 告警批量操作 Request VObatch-ack / batch-clear / batch-archive 共用)
*
* @author B20
*/
@Schema(description = "管理后台 - 告警批量操作 Request VO")
@Data
public class AlarmBatchReqVO {
@Schema(description = "告警记录 ID 列表(最多 100 条)",
requiredMode = Schema.RequiredMode.REQUIRED, example = "[1024, 1025]")
@NotEmpty(message = "告警记录 ID 列表不能为空")
@Size(max = 100, message = "批量操作最多 100 条")
private List<Long> ids;
@Schema(description = "处理备注(可选,最多 500 字符)", example = "批量处理")
@Size(max = 500, message = "处理备注最多 500 字符")
private String remark;
}

View File

@@ -0,0 +1,25 @@
package com.viewsh.module.iot.controller.admin.alarm.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 管理后台 - 告警清除 Request VO单条
*
* @author B20
*/
@Schema(description = "管理后台 - 告警清除 Request VO")
@Data
public class AlarmClearReqVO {
@Schema(description = "告警记录 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "告警记录 ID 不能为空")
private Long id;
@Schema(description = "处理备注(可选,最多 500 字符)", example = "已恢复正常")
@Size(max = 500, message = "处理备注最多 500 字符")
private String remark;
}

View File

@@ -0,0 +1,43 @@
package com.viewsh.module.iot.controller.admin.alarm.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 管理后台 - IoT 告警历史 Response VO时序库
*
* @author B20
*/
@Schema(description = "管理后台 - IoT 告警历史 Response VO")
@Data
public class AlarmHistoryRespVO {
@Schema(description = "时间戳ISO-8601 字符串)", requiredMode = Schema.RequiredMode.REQUIRED,
example = "2024-01-15T10:30:00Z")
private String ts;
@Schema(description = "关联告警记录 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long alarmRecordId;
@Schema(description = "告警配置 ID", example = "200")
private Long alarmConfigId;
@Schema(description = "严重度 1-5", example = "1")
private Integer severity;
@Schema(description = "事件类型trigger/ack/unack/clear/archive/remark", example = "ack")
private String state;
@Schema(description = "触发数据快照JSON 字符串)", example = "{\"temperature\":45.5}")
private String triggerData;
@Schema(description = "告警详情JSON 字符串)")
private String details;
@Schema(description = "操作人", example = "admin")
private String operator;
@Schema(description = "处理备注", example = "已通知运维")
private String remark;
}

View File

@@ -0,0 +1,49 @@
package com.viewsh.module.iot.controller.admin.alarm.vo;
import com.viewsh.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static com.viewsh.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
/**
* 管理后台 - IoT 告警记录分页查询 Request VO
*
* @author B20
*/
@Schema(description = "管理后台 - IoT 告警记录分页查询 Request VO")
@Data
public class AlarmRecordPageReqVO extends PageParam {
@Schema(description = "告警配置 ID", example = "200")
private Long alarmConfigId;
@Schema(description = "严重度 1-5", example = "1")
private Integer severity;
@Schema(description = "确认状态 0=未确认 1=已确认", example = "0")
private Integer ackState;
@Schema(description = "清除状态 0=活跃 1=已清除", example = "0")
private Integer clearState;
@Schema(description = "归档状态 0=未归档 1=已归档", example = "0")
private Integer archived;
@Schema(description = "设备 ID", example = "100")
private Long deviceId;
@Schema(description = "产品 ID", example = "50")
private Long productId;
@Schema(description = "子系统 ID", example = "9")
private Long subsystemId;
@Schema(description = "触发时间范围startTs", example = "[\"2024-01-01 00:00:00\",\"2024-12-31 23:59:59\"]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] startTs;
}

View File

@@ -0,0 +1,113 @@
package com.viewsh.module.iot.controller.admin.alarm.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 管理后台 - IoT 告警记录 Response VO
*
* @author B20
*/
@Schema(description = "管理后台 - IoT 告警记录 Response VO")
@Data
public class AlarmRecordRespVO {
@Schema(description = "告警记录 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long id;
@Schema(description = "告警记录唯一键device+config+tenant", example = "100_200_1")
private String recordKey;
@Schema(description = "告警配置 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "200")
private Long alarmConfigId;
@Schema(description = "告警名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "高温告警")
private String alarmName;
@Schema(description = "严重度 1=CRITICAL 2=MAJOR 3=MINOR 4=WARNING 5=INFO",
requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer severity;
@Schema(description = "严重度标签 CRITICAL/MAJOR/MINOR/WARNING/INFO",
requiredMode = Schema.RequiredMode.REQUIRED, example = "CRITICAL")
private String severityLabel;
@Schema(description = "确认状态 0=未确认 1=已确认", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer ackState;
@Schema(description = "清除状态 0=活跃 1=已清除", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer clearState;
@Schema(description = "归档 0=未归档 1=已归档", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer archived;
@Schema(description = "设备 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
private Long deviceId;
@Schema(description = "设备名称", example = "温度传感器-001")
private String deviceName;
@Schema(description = "产品 ID", example = "50")
private Long productId;
@Schema(description = "产品名称", example = "温度传感器")
private String productName;
@Schema(description = "子系统 ID", example = "9")
private Long subsystemId;
@Schema(description = "子系统名称", example = "厂区A监控")
private String subsystemName;
@Schema(description = "首次触发时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime startTs;
@Schema(description = "最近触发时间")
private LocalDateTime endTs;
@Schema(description = "清除时间")
private LocalDateTime clearTs;
@Schema(description = "确认时间")
private LocalDateTime ackTs;
@Schema(description = "持续触发次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
private Integer triggerCount;
@Schema(description = "传播路径(子系统/项目/租户)")
private List<AlarmPropagationItemVO> propagatedTo;
@Schema(description = "处理备注", example = "已联系运维")
private String processRemark;
@Schema(description = "告警详情JSON Map")
private Map<String, Object> details;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
// ── 嵌套 VO ───────────────────────────────────────────────────────────────
@Schema(description = "告警传播路径条目")
@Data
public static class AlarmPropagationItemVO {
@Schema(description = "资产类型 SUBSYSTEM/PROJECT/TENANT", example = "SUBSYSTEM")
private String type;
@Schema(description = "资产 ID", example = "9")
private Long id;
@Schema(description = "资产名称", example = "厂区A监控")
private String name;
}
}

View File

@@ -0,0 +1,27 @@
package com.viewsh.module.iot.controller.admin.alarm.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 管理后台 - 告警处理备注更新 Request VO
*
* @author B20
*/
@Schema(description = "管理后台 - 告警处理备注 Request VO")
@Data
public class AlarmRemarkReqVO {
@Schema(description = "告警记录 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "告警记录 ID 不能为空")
private Long id;
@Schema(description = "处理备注", requiredMode = Schema.RequiredMode.REQUIRED, example = "已联系运维")
@NotBlank(message = "处理备注不能为空")
@Size(max = 500, message = "处理备注最多 500 字符")
private String remark;
}

View File

@@ -198,6 +198,28 @@ public class IotDeviceController {
return success(true);
}
// ========== B23前端 camelCase 别名(与前端 `src/api/iot/subsystem/index.ts` 对齐) ==========
@PutMapping("/bindSubsystem")
@Operation(summary = "绑定设备到子系统subsystemId=null 表示解绑)")
@PreAuthorize("@ss.hasPermission('iot:device:update')")
public CommonResult<Boolean> bindSubsystem(@Valid @RequestBody IotDeviceBindSubsystemReqVO reqVO) {
if (reqVO.getSubsystemId() == null) {
deviceService.unbindDeviceFromSubsystem(reqVO.getDeviceId());
} else {
deviceService.bindDeviceToSubsystem(reqVO.getDeviceId(), reqVO.getSubsystemId());
}
return success(true);
}
@PutMapping("/batchBindSubsystem")
@Operation(summary = "批量绑定设备到子系统(每批 ≤ 100")
@PreAuthorize("@ss.hasPermission('iot:device:update')")
public CommonResult<Boolean> batchBindSubsystem(@Valid @RequestBody IotDeviceBatchBindSubsystemReqVO reqVO) {
deviceService.batchBindDevicesToSubsystem(reqVO.getDeviceIds(), reqVO.getSubsystemId());
return success(true);
}
@GetMapping("/unassigned-list")
@Operation(summary = "获取未归属子系统的设备列表")
@PreAuthorize("@ss.hasPermission('iot:device:query')")

View File

@@ -0,0 +1,31 @@
package com.viewsh.module.iot.controller.admin.device.vo.device;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 批量绑定设备到子系统请求 VO
* <p>
* 每批 ≤ 100 台(由前端自行分批)。
*
* @author B23
*/
@Schema(description = "管理后台 - IoT 设备批量绑定子系统 Request VO")
@Data
public class IotDeviceBatchBindSubsystemReqVO {
@Schema(description = "设备编号列表", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "设备编号列表不能为空")
@Size(max = 100, message = "单次批量绑定最多 100 台")
private List<Long> deviceIds;
@Schema(description = "子系统编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "8")
@NotNull(message = "子系统编号不能为空")
private Long subsystemId;
}

View File

@@ -0,0 +1,25 @@
package com.viewsh.module.iot.controller.admin.device.vo.device;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 设备绑定子系统请求 VO
* <p>
* 与前端 {@code IotSubsystemApi.BindSubsystemReqVO} 一致:{@code subsystemId = null} 表示解绑。
*
* @author B23
*/
@Schema(description = "管理后台 - IoT 设备绑定子系统 Request VO")
@Data
public class IotDeviceBindSubsystemReqVO {
@Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "设备编号不能为空")
private Long deviceId;
@Schema(description = "子系统编号null 表示解绑", example = "8")
private Long subsystemId;
}

View File

@@ -16,6 +16,7 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import static com.viewsh.framework.common.pojo.CommonResult.success;
@@ -58,7 +59,7 @@ public class IotSubsystemController {
}
@GetMapping("/get/{id}")
@Operation(summary = "获得子系统")
@Operation(summary = "获得子系统path 变量,兼容旧调用)")
@Parameter(name = "id", description = "编号", required = true, example = "1")
@PreAuthorize("@ss.hasPermission('iot:subsystem:query')")
public CommonResult<IotSubsystemRespVO> getSubsystem(@PathVariable("id") Long id) {
@@ -66,6 +67,15 @@ public class IotSubsystemController {
return success(BeanUtils.toBean(subsystem, IotSubsystemRespVO.class));
}
@GetMapping("/get")
@Operation(summary = "获得子系统query 参数别名,前端契约 GET /get?id=")
@Parameter(name = "id", description = "编号", required = true, example = "1")
@PreAuthorize("@ss.hasPermission('iot:subsystem:query')")
public CommonResult<IotSubsystemRespVO> getSubsystemByQuery(@RequestParam("id") Long id) {
IotSubsystemDO subsystem = subsystemService.getSubsystem(id);
return success(BeanUtils.toBean(subsystem, IotSubsystemRespVO.class));
}
@GetMapping("/page")
@Operation(summary = "获得子系统分页")
@PreAuthorize("@ss.hasPermission('iot:subsystem:query')")
@@ -89,4 +99,11 @@ public class IotSubsystemController {
return success(subsystemService.getSubsystemDeviceStats(id));
}
@GetMapping("/device-count")
@Operation(summary = "获取所有子系统设备计数聚合", description = "一次 HGETALL 从 Redis 读取当前租户所有子系统的 total/online/alarmRedis 未命中时返回空 MapB10 评审 A6不做实时 DB count")
@PreAuthorize("@ss.hasPermission('iot:subsystem:query')")
public CommonResult<Map<Long, IotSubsystemDeviceCountRespVO>> getAllSubsystemDeviceCount() {
return success(subsystemService.getAllSubsystemDeviceCount());
}
}

View File

@@ -0,0 +1,30 @@
package com.viewsh.module.iot.controller.admin.subsystem.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 子系统设备计数 Response VOB22 /device-count 聚合接口)
* <p>
* 字段命名与前端契约一致total / online / alarm
*
* @author B22
*/
@Schema(description = "管理后台 - IoT 子系统设备计数 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class IotSubsystemDeviceCountRespVO {
@Schema(description = "设备总数(从 Redis Hash 读取,非实时)", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
private long total;
@Schema(description = "在线设备数(待 B12/B14 填充,当前返回 0", requiredMode = Schema.RequiredMode.REQUIRED, example = "80")
private long online;
@Schema(description = "活跃告警数(待 B12/B14 填充,当前返回 0", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
private long alarm;
}

View File

@@ -1,5 +1,6 @@
package com.viewsh.module.iot.dal.mysql.alarm;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.viewsh.framework.mybatis.core.mapper.BaseMapperX;
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmPropagationDO;
import org.apache.ibatis.annotations.Mapper;
@@ -18,6 +19,17 @@ import java.util.List;
@Mapper
public interface IotAlarmPropagationMapper extends BaseMapperX<IotAlarmPropagationDO> {
/**
* 按告警记录 ID 查询传播路径列表
*
* @param alarmRecordId 告警记录 ID
* @return 传播路径列表
*/
default List<IotAlarmPropagationDO> selectByAlarmRecordId(Long alarmRecordId) {
return selectList(new LambdaQueryWrapper<IotAlarmPropagationDO>()
.eq(IotAlarmPropagationDO::getAlarmRecordId, alarmRecordId));
}
/**
* 批量插入传播记录INSERT IGNORE 保证幂等,评审 C3
*

View File

@@ -1,10 +1,15 @@
package com.viewsh.module.iot.dal.mysql.alarm;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.viewsh.framework.common.pojo.PageResult;
import com.viewsh.framework.mybatis.core.mapper.BaseMapperX;
import com.viewsh.module.iot.controller.admin.alarm.vo.AlarmRecordPageReqVO;
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
* IoT 告警记录 Mapper
* <p>
@@ -32,4 +37,41 @@ public interface IotAlarmRecordMapper extends BaseMapperX<IotAlarmRecordDO> {
.last("LIMIT 1"));
}
/**
* 分页查询告警记录(支持多条件过滤)
*
* @param reqVO 分页查询参数
* @return 分页结果
*/
default PageResult<IotAlarmRecordDO> selectPage(AlarmRecordPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapper<IotAlarmRecordDO>()
.eq(reqVO.getAlarmConfigId() != null, IotAlarmRecordDO::getAlarmConfigId, reqVO.getAlarmConfigId())
.eq(reqVO.getSeverity() != null, IotAlarmRecordDO::getSeverity, reqVO.getSeverity())
.eq(reqVO.getAckState() != null, IotAlarmRecordDO::getAckState, reqVO.getAckState())
.eq(reqVO.getClearState() != null, IotAlarmRecordDO::getClearState, reqVO.getClearState())
.eq(reqVO.getArchived() != null, IotAlarmRecordDO::getArchived, reqVO.getArchived())
.eq(reqVO.getDeviceId() != null, IotAlarmRecordDO::getDeviceId, reqVO.getDeviceId())
.eq(reqVO.getProductId() != null, IotAlarmRecordDO::getProductId, reqVO.getProductId())
.eq(reqVO.getSubsystemId() != null, IotAlarmRecordDO::getSubsystemId, reqVO.getSubsystemId())
.between(reqVO.getStartTs() != null && reqVO.getStartTs().length == 2,
IotAlarmRecordDO::getStartTs,
reqVO.getStartTs() != null && reqVO.getStartTs().length == 2 ? reqVO.getStartTs()[0] : null,
reqVO.getStartTs() != null && reqVO.getStartTs().length == 2 ? reqVO.getStartTs()[1] : null)
.orderByDesc(IotAlarmRecordDO::getId));
}
/**
* 按 ID 集合批量查询告警记录
*
* @param ids ID 集合
* @return 告警记录列表
*/
default List<IotAlarmRecordDO> selectListByIds(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
return selectList(new LambdaQueryWrapper<IotAlarmRecordDO>()
.in(IotAlarmRecordDO::getId, ids));
}
}

View File

@@ -6,6 +6,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -129,6 +131,35 @@ public class IotSubsystemDeviceCountRedisDAO {
refreshTtlIfAbsent(key);
}
/**
* 获取当前租户所有子系统的设备数量HGETALL
* <p>
* B22 /device-count 接口使用,一次 HGETALL 拿全量。
* 若 Key 不存在或 Hash 为空,返回空 Map让调用方决定 fallback 策略)。
*
* @param tenantId 租户 ID
* @return subsystemId → deviceCount 的 Map空 Map 表示无数据)
*/
public Map<Long, Long> getAllCounts(Long tenantId) {
String key = buildKey(tenantId);
Map<Object, Object> raw = stringRedisTemplate.opsForHash().entries(key);
if (raw == null || raw.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, Long> result = new HashMap<>(raw.size());
for (Map.Entry<Object, Object> entry : raw.entrySet()) {
try {
Long subsystemId = Long.parseLong(entry.getKey().toString());
Long count = Long.parseLong(entry.getValue().toString());
result.put(subsystemId, count);
} catch (NumberFormatException e) {
log.warn("[getAllCounts] 解析子系统设备计数失败tenantId={}, key={}, value={}",
tenantId, entry.getKey(), entry.getValue());
}
}
return result;
}
/**
* 删除子系统计数 field删除子系统时清理
*

View File

@@ -1,9 +1,13 @@
package com.viewsh.module.iot.service.alarm;
import com.viewsh.framework.common.pojo.PageResult;
import com.viewsh.module.iot.controller.admin.alarm.vo.AlarmRecordPageReqVO;
import com.viewsh.module.iot.dal.dataobject.alarm.IotAlarmRecordDO;
import com.viewsh.module.iot.service.alarm.dto.AlarmStateTransitionRequest;
import com.viewsh.module.iot.service.alarm.dto.AlarmTriggerRequest;
import java.util.List;
/**
* IoT 告警记录 Servicev2.0 正交状态机)
* <p>
@@ -59,4 +63,58 @@ public interface IotAlarmRecordService {
*/
IotAlarmRecordDO getAlarm(Long id);
// ==================== B20 新增:查询 / 批量 / remark ====================
/**
* 分页查询告警记录
*
* @param reqVO 分页查询参数
* @return 分页结果
*/
PageResult<IotAlarmRecordDO> getAlarmPage(AlarmRecordPageReqVO reqVO);
/**
* 批量确认告警(遍历调 ackAlarm幂等
*
* @param request 含 ids 列表的请求alarmId 字段依次填充)
* @param ids 告警记录 ID 列表
*/
void batchAckAlarm(List<Long> ids, String operator, String remark);
/**
* 批量清除告警(遍历调 clearAlarm幂等
*
* @param ids 告警记录 ID 列表
* @param operator 操作人
* @param remark 处理备注
*/
void batchClearAlarm(List<Long> ids, String operator, String remark);
/**
* 批量归档告警(遍历调 archiveAlarm每条独立捕获异常
* <p>
* 已归档的条目跳过(不抛出);返回失败条数。
*
* @param ids 告警记录 ID 列表
* @param operator 操作人
* @return 失败条数0 = 全部成功)
*/
int batchArchiveAlarm(List<Long> ids, String operator);
/**
* 更新告警处理备注(不走锁,备注为附属信息)
*
* @param id 告警记录 ID
* @param remark 处理备注
*/
void updateRemark(Long id, String remark);
/**
* 查询告警历史(时序库)
*
* @param alarmRecordId 告警记录 ID
* @return 历史列表按时间倒序TSDB 不可用时返回空列表)
*/
List<com.viewsh.module.iot.dal.dataobject.alarm.AlarmHistoryDO> listAlarmHistory(Long alarmRecordId);
}

View File

@@ -1,6 +1,9 @@
package com.viewsh.module.iot.service.alarm;
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.dataobject.alarm.enums.AlarmSeverity;
import com.viewsh.module.iot.dal.mysql.alarm.IotAlarmRecordMapper;
@@ -15,6 +18,7 @@ import org.springframework.validation.annotation.Validated;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
@@ -62,6 +66,9 @@ public class IotAlarmRecordServiceImpl implements IotAlarmRecordService {
@Resource
private IotAlarmPropagationService alarmPropagationService;
@Resource
private IotAlarmHistoryService alarmHistoryService;
// ==================== 触发(幂等 upsert ====================
@Override
@@ -268,6 +275,86 @@ public class IotAlarmRecordServiceImpl implements IotAlarmRecordService {
return alarmRecordMapper.selectById(id);
}
// ==================== B20 新增:查询 / 批量 / remark / history ====================
@Override
public PageResult<IotAlarmRecordDO> getAlarmPage(AlarmRecordPageReqVO reqVO) {
return alarmRecordMapper.selectPage(reqVO);
}
@Override
public void batchAckAlarm(List<Long> ids, String operator, String remark) {
if (ids == null || ids.isEmpty()) {
return;
}
for (Long id : ids) {
try {
ackAlarm(AlarmStateTransitionRequest.builder()
.alarmId(id)
.operator(operator)
.remark(remark)
.build());
} catch (Exception e) {
log.warn("[batchAckAlarm] id={} ack 失败,跳过: {}", id, e.getMessage());
}
}
}
@Override
public void batchClearAlarm(List<Long> ids, String operator, String remark) {
if (ids == null || ids.isEmpty()) {
return;
}
for (Long id : ids) {
try {
clearAlarm(AlarmStateTransitionRequest.builder()
.alarmId(id)
.operator(operator)
.remark(remark)
.build());
} catch (Exception e) {
log.warn("[batchClearAlarm] id={} clear 失败,跳过: {}", id, e.getMessage());
}
}
}
@Override
public int batchArchiveAlarm(List<Long> ids, String operator) {
if (ids == null || ids.isEmpty()) {
return 0;
}
int failCount = 0;
for (Long id : ids) {
try {
archiveAlarm(AlarmStateTransitionRequest.builder()
.alarmId(id)
.operator(operator)
.build());
} catch (Exception e) {
// 已归档ALARM_ALREADY_ARCHIVED或不存在 → 计为失败
log.warn("[batchArchiveAlarm] id={} archive 失败: {}", id, e.getMessage());
failCount++;
}
}
return failCount;
}
@Override
public void updateRemark(Long id, String remark) {
// 备注更新不走分布式锁(附属信息,无状态机约束)
getAlarmOrThrow(id); // 校验存在性
IotAlarmRecordDO update = new IotAlarmRecordDO();
update.setId(id);
update.setProcessRemark(remark);
alarmRecordMapper.updateById(update);
log.debug("[updateRemark] 告警备注更新 id={}", id);
}
@Override
public List<AlarmHistoryDO> listAlarmHistory(Long alarmRecordId) {
return alarmHistoryService.queryByAlarmRecord(alarmRecordId, null, null);
}
// ==================== 内部工具 ====================
private IotAlarmRecordDO getAlarmOrThrow(Long id) {

View File

@@ -1,6 +1,7 @@
package com.viewsh.module.iot.service.subsystem;
import com.viewsh.framework.common.pojo.PageResult;
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;
@@ -9,6 +10,7 @@ import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
/**
* IoT 子系统 Service 接口
@@ -83,4 +85,14 @@ public interface IotSubsystemService {
*/
IotSubsystemDeviceStatsRespVO getSubsystemDeviceStats(Long subsystemId);
/**
* 获得当前租户所有子系统的设备计数B22 /device-count 接口)
* <p>
* 一次 HGETALL 从 Redis 读取Redis 未命中时返回空 MapB10 评审 A6 策略:
* 不做实时 DB countDB 重建由定时任务/启动事件负责)。
*
* @return subsystemId → IotSubsystemDeviceCountRespVOtotal/online/alarm
*/
Map<Long, IotSubsystemDeviceCountRespVO> getAllSubsystemDeviceCount();
}

View File

@@ -4,6 +4,7 @@ import com.viewsh.framework.common.pojo.PageResult;
import com.viewsh.framework.common.util.object.BeanUtils;
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
import com.viewsh.framework.tenant.core.util.TenantUtils;
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,6 +22,7 @@ import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -171,6 +173,26 @@ public class IotSubsystemServiceImpl implements IotSubsystemService {
return vo;
}
@Override
public Map<Long, IotSubsystemDeviceCountRespVO> getAllSubsystemDeviceCount() {
Long tenantId = TenantContextHolder.getTenantId();
// 一次 HGETALL 拿全量计数
Map<Long, Long> countMap = deviceCountRedisDAO.getAllCounts(tenantId);
if (countMap.isEmpty()) {
// Redis miss → 返回空 MapB10 评审 A6不做实时 DB count由定时任务/启动事件重建)
log.debug("[getAllSubsystemDeviceCount] Redis Hash 为空tenantId={},返回空 Map", tenantId);
return Collections.emptyMap();
}
// 转换为 VOonline/alarm 待 B12/B14 实现,当前返回 0
Map<Long, IotSubsystemDeviceCountRespVO> result = new HashMap<>(countMap.size());
countMap.forEach((subsystemId, total) ->
result.put(subsystemId, new IotSubsystemDeviceCountRespVO(total, 0L, 0L)));
return result;
}
// ==================== 启动时重建 Redis 计数(评审 A6====================
/**

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) {