From 7dc00b542d5d8c637abd83a82e5bf4803d9fbb08 Mon Sep 17 00:00:00 2001 From: lzh Date: Fri, 24 Apr 2026 15:47:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(iot):=20=E4=B8=80=E6=9C=9F=20Controller=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=20(B2/B4-6/B10/B11/B12/B13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照前端 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) --- .../rule/action/ActionProviderManager.java | 13 + .../iot/rule/action/AlarmClearAction.java | 13 + .../iot/rule/action/AlarmTriggerAction.java | 13 + .../rule/action/DevicePropertySetAction.java | 13 + .../action/DeviceServiceInvokeAction.java | 13 + .../module/iot/rule/action/NotifyAction.java | 13 + .../condition/ConditionEvaluatorManager.java | 13 + .../DeviceStateConditionEvaluator.java | 13 + .../ExpressionConditionEvaluator.java | 13 + .../TimeRangeConditionEvaluator.java | 13 + .../admin/IotProviderMetadataController.java | 71 ++++ .../admin/IotRuleChainController.java | 47 ++- .../admin/vo/IotRuleChainDebugReqVO.java | 22 ++ .../admin/vo/ProviderMetadataVO.java | 60 ++++ .../module/iot/rule/engine/NodeProvider.java | 18 + .../iot/rule/engine/NodeProviderRegistry.java | 13 + .../iot/rule/service/IotRuleChainService.java | 23 ++ .../rule/service/IotRuleChainServiceImpl.java | 100 ++++++ .../iot/rule/spi/ConditionEvaluator.java | 18 + .../module/iot/rule/spi/TriggerProvider.java | 19 + .../iot/rule/spi/TriggerProviderManager.java | 13 + .../trigger/DeviceEventTriggerProvider.java | 13 + .../DevicePropertyTriggerProvider.java | 13 + .../trigger/DeviceServiceTriggerProvider.java | 13 + .../trigger/DeviceStateTriggerProvider.java | 13 + .../rule/trigger/TimerTriggerProvider.java | 13 + .../rule/controller/ProviderMetadataTest.java | 215 ++++++++++++ .../service/IotRuleChainServiceImplTest.java | 158 +++++++++ .../admin/alarm/IotAlarmRecordController.java | 332 ++++++++++++++++++ .../admin/alarm/vo/AlarmAckReqVO.java | 25 ++ .../admin/alarm/vo/AlarmArchiveReqVO.java | 20 ++ .../admin/alarm/vo/AlarmBatchReqVO.java | 29 ++ .../admin/alarm/vo/AlarmClearReqVO.java | 25 ++ .../admin/alarm/vo/AlarmHistoryRespVO.java | 43 +++ .../admin/alarm/vo/AlarmRecordPageReqVO.java | 49 +++ .../admin/alarm/vo/AlarmRecordRespVO.java | 113 ++++++ .../admin/alarm/vo/AlarmRemarkReqVO.java | 27 ++ .../admin/device/IotDeviceController.java | 22 ++ .../IotDeviceBatchBindSubsystemReqVO.java | 31 ++ .../device/IotDeviceBindSubsystemReqVO.java | 25 ++ .../subsystem/IotSubsystemController.java | 19 +- .../vo/IotSubsystemDeviceCountRespVO.java | 30 ++ .../alarm/IotAlarmPropagationMapper.java | 12 + .../dal/mysql/alarm/IotAlarmRecordMapper.java | 42 +++ .../IotSubsystemDeviceCountRedisDAO.java | 31 ++ .../service/alarm/IotAlarmRecordService.java | 58 +++ .../alarm/IotAlarmRecordServiceImpl.java | 87 +++++ .../subsystem/IotSubsystemService.java | 12 + .../subsystem/IotSubsystemServiceImpl.java | 22 ++ .../alarm/IotAlarmRecordServiceImplTest.java | 124 +++++++ .../IotSubsystemServiceImplTest.java | 64 ++++ 51 files changed, 2211 insertions(+), 3 deletions(-) create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotProviderMetadataController.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainDebugReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/ProviderMetadataVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/controller/ProviderMetadataTest.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/IotAlarmRecordController.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmAckReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmArchiveReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmBatchReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmClearReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmHistoryRespVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRecordPageReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRecordRespVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRemarkReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceBatchBindSubsystemReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceBindSubsystemReqVO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemDeviceCountRespVO.java diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/ActionProviderManager.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/ActionProviderManager.java index 45f49cb4..96f25dfe 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/ActionProviderManager.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/ActionProviderManager.java @@ -1,10 +1,12 @@ package com.viewsh.module.iot.rule.action; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.spi.ActionProvider; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -60,4 +62,15 @@ public class ActionProviderManager { public Map all() { return Map.copyOf(providers); } + + /** + * 返回所有已注册 Action Provider 的元数据列表(供前端动态面板消费)。 + */ + public List listAllMetadata() { + List result = new ArrayList<>(providers.size()); + for (ActionProvider p : providers.values()) { + result.add(p.getMetadata()); + } + return result; + } } diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/AlarmClearAction.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/AlarmClearAction.java index 7a1d6d44..a049bf78 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/AlarmClearAction.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/AlarmClearAction.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.module.iot.api.alarm.IotAlarmRecordApi; import com.viewsh.module.iot.api.alarm.dto.IotAlarmClearReqDTO; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.result.ActionResult; import com.viewsh.module.iot.rule.spi.ActionProvider; @@ -39,6 +40,18 @@ public class AlarmClearAction implements ActionProvider { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("action") + .label("清除告警") + .icon("mdi:bell-off-outline") + .description("清除(恢复)已触发的告警记录") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public ActionResult executeAction(RuleContext ctx, JsonNode config) { try { diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/AlarmTriggerAction.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/AlarmTriggerAction.java index 3c36460f..2e6e9151 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/AlarmTriggerAction.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/AlarmTriggerAction.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.module.iot.api.alarm.IotAlarmRecordApi; import com.viewsh.module.iot.api.alarm.dto.IotAlarmTriggerReqDTO; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.result.ActionResult; import com.viewsh.module.iot.rule.spi.ActionProvider; @@ -46,6 +47,18 @@ public class AlarmTriggerAction implements ActionProvider { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("action") + .label("触发告警") + .icon("mdi:bell-outline") + .description("创建或更新告警记录") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public ActionResult executeAction(RuleContext ctx, JsonNode config) { try { diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/DevicePropertySetAction.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/DevicePropertySetAction.java index 53249066..5f5afe9c 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/DevicePropertySetAction.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/DevicePropertySetAction.java @@ -1,6 +1,7 @@ package com.viewsh.module.iot.rule.action; import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.result.ActionResult; import com.viewsh.module.iot.rule.spi.ActionProvider; @@ -39,6 +40,18 @@ public class DevicePropertySetAction implements ActionProvider { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("action") + .label("设备属性设置") + .icon("mdi:tune-vertical") + .description("向设备下发属性设置指令") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public ActionResult executeAction(RuleContext ctx, JsonNode config) { try { diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/DeviceServiceInvokeAction.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/DeviceServiceInvokeAction.java index 1951d42c..e11621cc 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/DeviceServiceInvokeAction.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/DeviceServiceInvokeAction.java @@ -6,6 +6,7 @@ import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.module.iot.api.device.IotDeviceControlApi; import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO; import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeRespDTO; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.result.ActionResult; import com.viewsh.module.iot.rule.spi.ActionProvider; @@ -45,6 +46,18 @@ public class DeviceServiceInvokeAction implements ActionProvider { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("action") + .label("设备服务调用") + .icon("mdi:cog-play") + .description("向设备下发服务调用指令") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public ActionResult executeAction(RuleContext ctx, JsonNode config) { try { diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/NotifyAction.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/NotifyAction.java index 9a1a5d22..4364eb93 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/NotifyAction.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/action/NotifyAction.java @@ -1,6 +1,7 @@ package com.viewsh.module.iot.rule.action; import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.notify.NotifyDispatcher; import com.viewsh.module.iot.rule.notify.model.NotifyResult; @@ -74,6 +75,18 @@ public class NotifyAction implements ActionProvider { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("action") + .label("发送通知") + .icon("mdi:message-text-outline") + .description("通过短信、邮件、站内信、Webhook 等渠道发送通知") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public ActionResult executeAction(RuleContext ctx, JsonNode config) { try { diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ConditionEvaluatorManager.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ConditionEvaluatorManager.java index c3bd61b3..4259c501 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ConditionEvaluatorManager.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ConditionEvaluatorManager.java @@ -1,11 +1,13 @@ package com.viewsh.module.iot.rule.condition; import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.spi.ConditionEvaluator; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -65,4 +67,15 @@ public class ConditionEvaluatorManager { public boolean supports(String type) { return evaluatorsByType.containsKey(type); } + + /** + * Returns metadata for all registered condition evaluators (consumed by the frontend dynamic panel). + */ + public List listAllMetadata() { + List result = new ArrayList<>(evaluatorsByType.size()); + for (ConditionEvaluator e : evaluatorsByType.values()) { + result.add(e.getMetadata()); + } + return result; + } } diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluator.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluator.java index da7f5b7a..272db160 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluator.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/DeviceStateConditionEvaluator.java @@ -1,6 +1,7 @@ package com.viewsh.module.iot.rule.condition; import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.spi.ConditionEvaluator; import lombok.extern.slf4j.Slf4j; @@ -46,6 +47,18 @@ public class DeviceStateConditionEvaluator implements ConditionEvaluator { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("condition") + .label("设备在线状态") + .icon("mdi:wifi") + .description("判断设备当前是否在线") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public boolean evaluate(RuleContext ctx, JsonNode config) { String expectedState = config.path("state").asText("online").toLowerCase(); diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluator.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluator.java index 6f7578bb..2aaca613 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluator.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/ExpressionConditionEvaluator.java @@ -5,6 +5,7 @@ import com.googlecode.aviator.AviatorEvaluator; import com.googlecode.aviator.AviatorEvaluatorInstance; import com.googlecode.aviator.Expression; import com.googlecode.aviator.Options; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.spi.ConditionEvaluator; import com.viewsh.module.iot.rule.template.TemplateResolver; @@ -66,6 +67,18 @@ public class ExpressionConditionEvaluator implements ConditionEvaluator { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("condition") + .label("表达式条件") + .icon("mdi:code-braces") + .description("使用 Aviator 表达式对设备数据进行条件判断") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public boolean evaluate(RuleContext ctx, JsonNode config) { String rawExpression = extractExpression(config); diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluator.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluator.java index 4db51648..2cb543ff 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluator.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/condition/TimeRangeConditionEvaluator.java @@ -1,6 +1,7 @@ package com.viewsh.module.iot.rule.condition; import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.spi.ConditionEvaluator; import lombok.extern.slf4j.Slf4j; @@ -53,6 +54,18 @@ public class TimeRangeConditionEvaluator implements ConditionEvaluator { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("condition") + .label("时间范围") + .icon("mdi:calendar-clock") + .description("判断当前时间是否在配置的时间段内(支持跨日、每周模式)") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public boolean evaluate(RuleContext ctx, JsonNode config) { String mode = config.path("mode").asText("daily"); diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotProviderMetadataController.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotProviderMetadataController.java new file mode 100644 index 00000000..c0f0c033 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotProviderMetadataController.java @@ -0,0 +1,71 @@ +package com.viewsh.module.iot.rule.controller.admin; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.iot.rule.action.ActionProviderManager; +import com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; +import com.viewsh.module.iot.rule.spi.TriggerProviderManager; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * Provider Metadata 端点(B4/B5/B6 补齐)。 + * + *

供前端动态属性面板(F3)消费,返回所有已注册 + * Trigger / Condition / Action Provider 的元数据列表。 + * + *

端点:{@code GET /iot/rule/provider/metadata} + * + *

响应格式(对齐前端 {@code NodeTypeMeta}): + *

{@code
+ * [
+ *   { "type": "device_property", "category": "trigger", "label": "设备属性上报",
+ *     "icon": "mdi:tune", "description": "...", "schema": {"rule": []} },
+ *   { "type": "expression",      "category": "condition", ... },
+ *   { "type": "alarm_trigger",   "category": "action",   ... },
+ *   ...
+ * ]
+ * }
+ */ +@Tag(name = "管理后台 - IoT 规则节点 Provider 元数据") +@RestController +@RequestMapping("/iot/rule/provider") +@Validated +public class IotProviderMetadataController { + + @Resource + private TriggerProviderManager triggerProviderManager; + + @Resource + private ConditionEvaluatorManager conditionEvaluatorManager; + + @Resource + private ActionProviderManager actionProviderManager; + + /** + * 获取所有节点 Provider 元数据(trigger + condition + action 三类聚合)。 + * + * @return ProviderMetadataVO 列表,每条包含 type / category / label / icon / description / schema + */ + @GetMapping("/metadata") + @Operation(summary = "获取所有规则节点 Provider 元数据(供前端动态属性面板使用)") + @PreAuthorize("@ss.hasPermission('iot:rule:query')") + public CommonResult> listProviderMetadata() { + List result = new ArrayList<>(); + result.addAll(triggerProviderManager.listAllMetadata()); + result.addAll(conditionEvaluatorManager.listAllMetadata()); + result.addAll(actionProviderManager.listAllMetadata()); + return success(result); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotRuleChainController.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotRuleChainController.java index a20788f3..1b3b4901 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotRuleChainController.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/IotRuleChainController.java @@ -2,6 +2,7 @@ package com.viewsh.module.iot.rule.controller.admin; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainDebugReqVO; import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainGraphVO; import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainPageReqVO; import com.viewsh.module.iot.rule.controller.admin.vo.IotRuleChainRespVO; @@ -16,6 +17,8 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.Map; + import static com.viewsh.framework.common.pojo.CommonResult.success; @Tag(name = "管理后台 - IoT 规则链") @@ -52,15 +55,23 @@ public class IotRuleChainController { } @GetMapping("/get/{id}") - @Operation(summary = "获得规则链") + @Operation(summary = "获得规则链(path 变量风格,向后兼容)") @Parameter(name = "id", description = "规则链编号", required = true, example = "1024") @PreAuthorize("@ss.hasPermission('iot:rule:query')") public CommonResult getRuleChain(@PathVariable("id") Long id) { return success(ruleChainService.getRuleChain(id)); } + @GetMapping("/get") + @Operation(summary = "获得规则链图(含节点和连线,query 参数风格)") + @Parameter(name = "id", description = "规则链编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('iot:rule:query')") + public CommonResult getRuleChainById(@RequestParam("id") Long id) { + return success(ruleChainService.getRuleChainGraph(id)); + } + @GetMapping("/graph/{id}") - @Operation(summary = "获得规则链图(含节点和连线)") + @Operation(summary = "获得规则链图(含节点和连线,path 变量风格,向后兼容)") @Parameter(name = "id", description = "规则链编号", required = true, example = "1024") @PreAuthorize("@ss.hasPermission('iot:rule:query')") public CommonResult getRuleChainGraph(@PathVariable("id") Long id) { @@ -83,4 +94,36 @@ public class IotRuleChainController { return success(true); } + @PutMapping("/disable") + @Operation(summary = "禁用规则链") + @PreAuthorize("@ss.hasPermission('iot:rule:update')") + public CommonResult disableRuleChain(@RequestBody Map body) { + ruleChainService.disableRuleChain(body.get("id")); + return success(true); + } + + @PutMapping("/deploy") + @Operation(summary = "发布规则链(启用 + 主动驱逐缓存)") + @PreAuthorize("@ss.hasPermission('iot:rule:update')") + public CommonResult deployRuleChain(@RequestBody Map body) { + ruleChainService.deployRuleChain(body.get("id")); + return success(true); + } + + @PutMapping("/debug") + @Operation(summary = "切换调试模式") + @PreAuthorize("@ss.hasPermission('iot:rule:update')") + public CommonResult toggleDebug(@Valid @RequestBody IotRuleChainDebugReqVO req) { + ruleChainService.toggleDebug(req.getId(), req.getEnabled()); + return success(true); + } + + @PostMapping("/copy") + @Operation(summary = "复制规则链") + @Parameter(name = "id", description = "源规则链编号", required = true) + @PreAuthorize("@ss.hasPermission('iot:rule:create')") + public CommonResult copyRuleChain(@RequestParam("id") Long id) { + return success(ruleChainService.copyRuleChain(id)); + } + } diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainDebugReqVO.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainDebugReqVO.java new file mode 100644 index 00000000..a7746720 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/IotRuleChainDebugReqVO.java @@ -0,0 +1,22 @@ +package com.viewsh.module.iot.rule.controller.admin.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 管理后台 - IoT 规则链调试模式切换 Request VO + */ +@Schema(description = "管理后台 - IoT 规则链调试模式切换 Request VO") +@Data +public class IotRuleChainDebugReqVO { + + @Schema(description = "规则链编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "规则链编号不能为空") + private Long id; + + @Schema(description = "是否启用调试模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "调试模式标志不能为空") + private Boolean enabled; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/ProviderMetadataVO.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/ProviderMetadataVO.java new file mode 100644 index 00000000..abb9845a --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/controller/admin/vo/ProviderMetadataVO.java @@ -0,0 +1,60 @@ +package com.viewsh.module.iot.rule.controller.admin.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Collections; +import java.util.Map; + +/** + * Provider Metadata VO — 供前端动态属性面板(F3/F4/F5/F6)消费。 + * + *

端点:GET /iot/rule/provider/metadata + * + *

对齐前端 {@code NodeTypeMeta} 类型约定: + *

    + *
  • {@code type} — Provider 类型标识,与 rule_node.type 一致
  • + *
  • {@code category} — "trigger" / "condition" / "action"
  • + *
  • {@code label} — 显示名(中文)
  • + *
  • {@code icon} — iconify 图标名(可选)
  • + *
  • {@code description} — 工具提示(可选)
  • + *
  • {@code schema} — form-create 配置,MVP 阶段返回 {@code {"rule":[]}}
  • + *
+ */ +@Schema(description = "节点 Provider 元数据") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProviderMetadataVO { + + @Schema(description = "Provider 类型标识,与 rule_node.type 一致", example = "device_property") + private String type; + + @Schema(description = "节点类别:trigger / condition / action", example = "trigger") + private String category; + + @Schema(description = "节点显示名", example = "设备属性上报") + private String label; + + @Schema(description = "iconify 图标名(可选)", example = "mdi:bell-outline") + private String icon; + + @Schema(description = "工具提示(可选)") + private String description; + + /** + * form-create 配置。MVP 阶段返回 {@code {"rule":[]}}; + * 后续 F4/F5/F6 widget 就绪后由前端本地定义或后端补全。 + */ + @Schema(description = "form-create schema,MVP 阶段为 {\"rule\":[]}") + private Map schema; + + /** 构造空 schema({@code {"rule":[]}}),供 default getMetadata() 调用。 */ + public static Map emptySchema() { + return Collections.singletonMap("rule", Collections.emptyList()); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/NodeProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/NodeProvider.java index 7644d6a7..e6d5f47c 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/NodeProvider.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/NodeProvider.java @@ -1,5 +1,6 @@ package com.viewsh.module.iot.rule.engine; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory; /** @@ -27,4 +28,21 @@ public interface NodeProvider { * 抛出 RuntimeException 将被 DagExecutor 转为 FAILURE 并交由链级 try-catch 兜底。 */ NodeResult execute(RuleContext ctx, String config); + + /** + * 返回该 Provider 的元数据(供前端动态属性面板 F3 消费)。 + * + *

默认实现以 {@link #getCategory()} + {@link #getType()} 构造元数据,schema 返回空骨架。 + * 具体 Provider 可覆盖以提供更友好的 label / icon / description。 + */ + default ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(getType()) + .category(getCategory().getValue()) + .label(getType()) + .icon(null) + .description(null) + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } } diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/NodeProviderRegistry.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/NodeProviderRegistry.java index 33f3829e..f67967a8 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/NodeProviderRegistry.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/engine/NodeProviderRegistry.java @@ -1,9 +1,11 @@ package com.viewsh.module.iot.rule.engine; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory; import com.viewsh.module.iot.rule.engine.exception.RuleChainException; import lombok.extern.slf4j.Slf4j; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -42,6 +44,17 @@ public class NodeProviderRegistry { return p; } + /** + * 返回所有已注册节点 Provider 的元数据列表(供管理端口使用)。 + */ + public List listAllMetadata() { + List result = new ArrayList<>(providersByKey.size()); + for (NodeProvider p : providersByKey.values()) { + result.add(p.getMetadata()); + } + return result; + } + private static String key(RuleNodeCategory category, String type) { return category.getValue() + ":" + type; } diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainService.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainService.java index a62207f4..50aea3bc 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainService.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainService.java @@ -51,6 +51,29 @@ public interface IotRuleChainService { */ void disableRuleChain(Long id); + /** + * 发布规则链到运行时(enable + 主动驱逐缓存,令所有实例重载) + * + * @param id 规则链编号 + */ + void deployRuleChain(Long id); + + /** + * 切换调试模式 + * + * @param id 规则链编号 + * @param enabled true=开启调试,false=关闭调试 + */ + void toggleDebug(Long id, boolean enabled); + + /** + * 复制规则链(深拷 chain + nodes + links,名称追加 "_copy",status=disabled,version=0) + * + * @param id 源规则链编号 + * @return 新规则链编号 + */ + Long copyRuleChain(Long id); + /** * 获得规则链基础信息 * diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImpl.java index 49f9c4b7..879562c6 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImpl.java @@ -173,6 +173,106 @@ public class IotRuleChainServiceImpl implements IotRuleChainService { ruleChainMapper.updateById(update); } + @Override + @Transactional(rollbackFor = Exception.class) + public void deployRuleChain(Long id) { + IotRuleChainDO chain = validateRuleChainExists(id); + // 1. 确保链处于启用状态 + if (!RuleChainStatus.ENABLED.getValue().equals(chain.getStatus())) { + IotRuleChainDO update = new IotRuleChainDO(); + update.setId(id); + update.setStatus(RuleChainStatus.ENABLED.getValue()); + ruleChainMapper.updateById(update); + } + // 2. 主动发布 Pub/Sub 驱逐事件,令所有实例重载缓存 + if (redisTemplate != null) { + try { + Map event = new LinkedHashMap<>(); + event.put("chainId", id); + event.put("tenantId", TenantContextHolder.getTenantId()); + event.put("version", chain.getVersion()); + redisTemplate.convertAndSend(RuleChainCache.EVICT_CHANNEL, JsonUtils.toJsonString(event)); + } catch (Exception e) { + log.warn("[IotRuleChainServiceImpl] deploy 发布缓存驱逐事件失败 chainId={}", id, e); + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void toggleDebug(Long id, boolean enabled) { + validateRuleChainExists(id); + IotRuleChainDO update = new IotRuleChainDO(); + update.setId(id); + update.setDebugMode(enabled); + ruleChainMapper.updateById(update); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long copyRuleChain(Long id) { + // 1. 查询原链 + IotRuleChainDO original = validateRuleChainExists(id); + + // 2. 深拷 chain(id/create_time 置空,让 DB 生成) + IotRuleChainDO copy = new IotRuleChainDO(); + copy.setName(original.getName() + "_copy"); + copy.setDescription(original.getDescription()); + copy.setType(original.getType()); + copy.setStatus(RuleChainStatus.DISABLED.getValue()); + copy.setPriority(original.getPriority()); + copy.setVersion(0L); + copy.setDebugMode(original.getDebugMode()); + copy.setSubsystemId(original.getSubsystemId()); + copy.setProductId(original.getProductId()); + copy.setDeviceId(original.getDeviceId()); + ruleChainMapper.insert(copy); + Long newChainId = copy.getId(); + Long tenantId = copy.getTenantId(); + + // 3. 深拷 nodes,建立 old_id → new_id 映射 + List oldNodes = ruleNodeMapper.selectByChainId(id); + Map nodeIdMap = new HashMap<>(); + if (!oldNodes.isEmpty()) { + List newNodes = oldNodes.stream() + .map(n -> IotRuleNodeDO.builder() + .ruleChainId(newChainId) + .name(n.getName()) + .category(n.getCategory()) + .type(n.getType()) + .configuration(n.getConfiguration()) + .positionX(n.getPositionX()) + .positionY(n.getPositionY()) + .tenantId(tenantId) + .build()) + .collect(Collectors.toList()); + ruleNodeMapper.insertBatch(newNodes); + // 建立映射:新节点是按原顺序插入的,id 由 insertBatch 回填 + for (int i = 0; i < oldNodes.size(); i++) { + nodeIdMap.put(oldNodes.get(i).getId(), newNodes.get(i).getId()); + } + } + + // 4. 深拷 links,用 nodeIdMap 重写 sourceNodeId/targetNodeId + List oldLinks = ruleLinkMapper.selectByChainId(id); + if (!oldLinks.isEmpty()) { + List newLinks = oldLinks.stream() + .map(l -> IotRuleLinkDO.builder() + .ruleChainId(newChainId) + .sourceNodeId(nodeIdMap.getOrDefault(l.getSourceNodeId(), l.getSourceNodeId())) + .targetNodeId(nodeIdMap.getOrDefault(l.getTargetNodeId(), l.getTargetNodeId())) + .relationType(l.getRelationType()) + .condition(l.getCondition()) + .sortOrder(l.getSortOrder()) + .tenantId(tenantId) + .build()) + .collect(Collectors.toList()); + ruleLinkMapper.insertBatch(newLinks); + } + + return newChainId; + } + @Override public IotRuleChainRespVO getRuleChain(Long id) { IotRuleChainDO chain = validateRuleChainExists(id); diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/ConditionEvaluator.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/ConditionEvaluator.java index 4c157d7e..e46f0655 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/ConditionEvaluator.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/ConditionEvaluator.java @@ -1,6 +1,7 @@ package com.viewsh.module.iot.rule.spi; import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; /** @@ -33,4 +34,21 @@ public interface ConditionEvaluator { * @return true = condition met, false = not met */ boolean evaluate(RuleContext ctx, JsonNode config); + + /** + * Returns metadata for this condition evaluator (consumed by the frontend dynamic panel F3). + * + *

Default implementation uses {@link #getType()} as label with empty schema skeleton. + * Concrete evaluators may override to provide friendlier label / icon / description. + */ + default ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(getType()) + .category("condition") + .label(getType()) + .icon(null) + .description(null) + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } } diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProvider.java index 7e16b9c6..5628083d 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProvider.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProvider.java @@ -2,6 +2,7 @@ package com.viewsh.module.iot.rule.spi; import com.fasterxml.jackson.databind.JsonNode; import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.CompiledRuleChain; import com.viewsh.module.iot.rule.engine.RuleContext; @@ -52,4 +53,22 @@ public interface TriggerProvider { default void unregister(CompiledRuleChain chain) { // 默认无操作 } + + /** + * 返回该 Provider 的元数据(供前端动态属性面板 F3 消费)。 + * + *

默认实现以 {@link #getType()} 作为 label,icon/description 留空, + * schema 返回空骨架 {@code {"rule":[]}}。 + * 具体 Provider 可覆盖以提供更友好的 label / icon / description。 + */ + default ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(getType()) + .category("trigger") + .label(getType()) + .icon(null) + .description(null) + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } } diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProviderManager.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProviderManager.java index 6e8fca86..e2c9ecd2 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProviderManager.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/spi/TriggerProviderManager.java @@ -1,10 +1,12 @@ package com.viewsh.module.iot.rule.spi; import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -72,4 +74,15 @@ public class TriggerProviderManager { public boolean contains(String type) { return providers.containsKey(type); } + + /** + * 返回所有已注册触发器 Provider 的元数据列表(供前端动态面板消费)。 + */ + public List listAllMetadata() { + List result = new ArrayList<>(providers.size()); + for (TriggerProvider p : providers.values()) { + result.add(p.getMetadata()); + } + return result; + } } diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProvider.java index ebc14085..fea903d2 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProvider.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceEventTriggerProvider.java @@ -3,6 +3,7 @@ package com.viewsh.module.iot.rule.trigger; import com.fasterxml.jackson.databind.JsonNode; import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.spi.TriggerProvider; import lombok.extern.slf4j.Slf4j; @@ -46,6 +47,18 @@ public class DeviceEventTriggerProvider implements TriggerProvider { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("trigger") + .label("设备事件上报") + .icon("mdi:bell-ring-outline") + .description("当设备上报指定事件时触发规则链") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) { // 1. method 必须是 thing.event.post diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProvider.java index 25ba9ba9..c1ea9353 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProvider.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DevicePropertyTriggerProvider.java @@ -3,6 +3,7 @@ package com.viewsh.module.iot.rule.trigger; import com.fasterxml.jackson.databind.JsonNode; import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.spi.TriggerProvider; import lombok.extern.slf4j.Slf4j; @@ -42,6 +43,18 @@ public class DevicePropertyTriggerProvider implements TriggerProvider { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("trigger") + .label("设备属性上报") + .icon("mdi:tune") + .description("当设备上报指定属性时触发规则链") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) { // 1. method 必须是 thing.property.post diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProvider.java index a48be5d8..d791e457 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProvider.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceServiceTriggerProvider.java @@ -3,6 +3,7 @@ package com.viewsh.module.iot.rule.trigger; import com.fasterxml.jackson.databind.JsonNode; import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.spi.TriggerProvider; import lombok.extern.slf4j.Slf4j; @@ -46,6 +47,18 @@ public class DeviceServiceTriggerProvider implements TriggerProvider { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("trigger") + .label("设备服务调用回复") + .icon("mdi:cog-play-outline") + .description("当设备服务调用回复时触发规则链") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) { // 1. method 必须是 thing.service.invoke diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProvider.java index 3503f06c..07c7604b 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProvider.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/DeviceStateTriggerProvider.java @@ -3,6 +3,7 @@ package com.viewsh.module.iot.rule.trigger; import com.fasterxml.jackson.databind.JsonNode; import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.spi.TriggerProvider; import lombok.extern.slf4j.Slf4j; @@ -41,6 +42,18 @@ public class DeviceStateTriggerProvider implements TriggerProvider { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("trigger") + .label("设备状态变化") + .icon("mdi:access-point") + .description("当设备上下线状态发生变化时触发规则链") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + @Override public boolean matches(IotDeviceMessage msg, JsonNode config, RuleContext ctx) { // 1. method 必须是 thing.state.update diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProvider.java b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProvider.java index 0eabe85b..64017b0a 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProvider.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/main/java/com/viewsh/module/iot/rule/trigger/TimerTriggerProvider.java @@ -2,6 +2,7 @@ package com.viewsh.module.iot.rule.trigger; import com.fasterxml.jackson.databind.JsonNode; import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; import com.viewsh.module.iot.rule.engine.CompiledRuleChain; import com.viewsh.module.iot.rule.engine.RuleContext; import com.viewsh.module.iot.rule.spi.TriggerProvider; @@ -64,6 +65,18 @@ public class TimerTriggerProvider implements TriggerProvider, DisposableBean { return TYPE; } + @Override + public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(TYPE) + .category("trigger") + .label("定时触发") + .icon("mdi:clock-outline") + .description("按 CRON 表达式定时触发规则链") + .schema(ProviderMetadataVO.emptySchema()) + .build(); + } + /** * Timer 触发器不通过消息匹配触发,始终返回 false。 * Timer 触发走 CRON 调度回调路径。 diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/controller/ProviderMetadataTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/controller/ProviderMetadataTest.java new file mode 100644 index 00000000..297f2a04 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/controller/ProviderMetadataTest.java @@ -0,0 +1,215 @@ +package com.viewsh.module.iot.rule.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.viewsh.module.iot.rule.action.ActionProviderManager; +import com.viewsh.module.iot.rule.action.AlarmTriggerAction; +import com.viewsh.module.iot.rule.condition.ConditionEvaluatorManager; +import com.viewsh.module.iot.rule.condition.TimeRangeConditionEvaluator; +import com.viewsh.module.iot.rule.controller.admin.vo.ProviderMetadataVO; +import com.viewsh.module.iot.rule.dal.dataobject.enums.RuleNodeCategory; +import com.viewsh.module.iot.rule.engine.NodeResult; +import com.viewsh.module.iot.rule.engine.RuleContext; +import com.viewsh.module.iot.rule.result.ActionResult; +import com.viewsh.module.iot.rule.spi.ActionProvider; +import com.viewsh.module.iot.rule.spi.ConditionEvaluator; +import com.viewsh.module.iot.rule.spi.TriggerProvider; +import com.viewsh.module.iot.rule.spi.TriggerProviderManager; +import com.viewsh.module.iot.rule.trigger.DeviceEventTriggerProvider; +import com.viewsh.module.iot.rule.trigger.DevicePropertyTriggerProvider; +import com.viewsh.module.iot.rule.trigger.DeviceServiceTriggerProvider; +import com.viewsh.module.iot.rule.trigger.DeviceStateTriggerProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * B4/B5/B6 Provider Metadata 端点单元测试。 + * + *

测试内容: + *

    + *
  1. TriggerProviderManager.listAllMetadata() 返回 N 条,category 全为 "trigger"
  2. + *
  3. ConditionEvaluatorManager.listAllMetadata() 返回 N 条,category 全为 "condition"
  4. + *
  5. ActionProviderManager.listAllMetadata() 返回 N 条,category 全为 "action"
  6. + *
  7. 各具体 Provider 的 getMetadata() label / icon 非空
  8. + *
  9. ProviderMetadataVO.emptySchema() 返回包含 "rule" key 的 Map
  10. + *
+ */ +class ProviderMetadataTest { + + private TriggerProviderManager triggerProviderManager; + private ConditionEvaluatorManager conditionEvaluatorManager; + private ActionProviderManager actionProviderManager; + + @BeforeEach + void setUp() { + // ---- Trigger providers ---- + triggerProviderManager = new TriggerProviderManager(); + List triggers = List.of( + new DevicePropertyTriggerProvider(), + new DeviceEventTriggerProvider(), + new DeviceStateTriggerProvider(), + new DeviceServiceTriggerProvider() + // TimerTriggerProvider requires TaskScheduler, excluded from unit test + ); + triggerProviderManager.autoRegister(triggers); + + // ---- Condition evaluators — use anonymous stubs to avoid infra deps ---- + List conditions = List.of( + new TimeRangeConditionEvaluator(), + stubCondition("expression", "表达式条件", "mdi:code-braces"), + stubCondition("device_state", "设备在线状态", "mdi:wifi") + ); + conditionEvaluatorManager = new ConditionEvaluatorManager(conditions); + + // ---- Action providers — use anonymous stubs to avoid infra deps ---- + actionProviderManager = new ActionProviderManager(); + List actions = List.of( + stubAction("alarm_trigger", "触发告警", "mdi:bell-outline"), + stubAction("alarm_clear", "清除告警", "mdi:bell-off-outline"), + stubAction("notify", "发送通知", "mdi:message-text-outline"), + stubAction("device_property_set", "设备属性设置", "mdi:tune-vertical"), + stubAction("device_service_invoke", "设备服务调用", "mdi:cog-play") + ); + actionProviderManager.autoRegister(actions); + } + + /** Create a minimal ConditionEvaluator stub with given type/label/icon metadata. */ + private static ConditionEvaluator stubCondition(String type, String label, String icon) { + return new ConditionEvaluator() { + @Override public String getType() { return type; } + @Override public boolean evaluate(RuleContext ctx, JsonNode config) { return false; } + @Override public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(type).category("condition").label(label).icon(icon) + .schema(ProviderMetadataVO.emptySchema()).build(); + } + }; + } + + /** Create a minimal ActionProvider stub with given type/label/icon metadata. */ + private static ActionProvider stubAction(String type, String label, String icon) { + return new ActionProvider() { + @Override public String getType() { return type; } + @Override public ActionResult executeAction(RuleContext ctx, JsonNode config) { + return ActionResult.success(); + } + @Override public NodeResult execute(RuleContext ctx, String config) { + return NodeResult.of(com.viewsh.module.iot.rule.dal.dataobject.enums.RuleLinkRelationType.SUCCESS, java.util.Map.of()); + } + @Override public RuleNodeCategory getCategory() { return RuleNodeCategory.ACTION; } + @Override public ProviderMetadataVO getMetadata() { + return ProviderMetadataVO.builder() + .type(type).category("action").label(label).icon(icon) + .schema(ProviderMetadataVO.emptySchema()).build(); + } + }; + } + + // ======================== ProviderMetadataVO.emptySchema ======================== + + @Test + void emptySchema_containsRuleKey() { + var schema = ProviderMetadataVO.emptySchema(); + assertNotNull(schema); + assertTrue(schema.containsKey("rule"), "schema must contain 'rule' key"); + } + + // ======================== Trigger metadata ======================== + + @Test + void triggerManager_listAllMetadata_returnsTriggerCategory() { + List list = triggerProviderManager.listAllMetadata(); + assertFalse(list.isEmpty(), "Should return at least one trigger metadata"); + for (ProviderMetadataVO vo : list) { + assertEquals("trigger", vo.getCategory(), "All trigger metadata must have category=trigger"); + assertNotNull(vo.getType(), "type must not be null"); + assertNotNull(vo.getLabel(), "label must not be null"); + assertNotNull(vo.getSchema(), "schema must not be null"); + } + } + + @Test + void devicePropertyTrigger_metadata_hasExpectedValues() { + ProviderMetadataVO meta = new DevicePropertyTriggerProvider().getMetadata(); + assertEquals("device_property", meta.getType()); + assertEquals("trigger", meta.getCategory()); + assertNotNull(meta.getLabel()); + assertFalse(meta.getLabel().isBlank()); + assertNotNull(meta.getIcon()); + assertFalse(meta.getIcon().isBlank()); + } + + @Test + void timerTrigger_metadata_hasCorrectType() { + // Only test metadata via default interface method pattern, no scheduler needed + TriggerProvider stubTimer = new TriggerProvider() { + @Override + public String getType() { return "timer"; } + @Override + public boolean matches(com.viewsh.module.iot.core.mq.message.IotDeviceMessage msg, + com.fasterxml.jackson.databind.JsonNode config, + com.viewsh.module.iot.rule.engine.RuleContext ctx) { return false; } + }; + ProviderMetadataVO meta = stubTimer.getMetadata(); + assertEquals("timer", meta.getType()); + assertEquals("trigger", meta.getCategory()); + } + + // ======================== Condition metadata ======================== + + @Test + void conditionManager_listAllMetadata_returnsConditionCategory() { + List list = conditionEvaluatorManager.listAllMetadata(); + assertFalse(list.isEmpty(), "Should return at least one condition metadata"); + for (ProviderMetadataVO vo : list) { + assertEquals("condition", vo.getCategory(), "All condition metadata must have category=condition"); + } + } + + @Test + void timeRangeCondition_metadata_hasExpectedValues() { + ProviderMetadataVO meta = new TimeRangeConditionEvaluator().getMetadata(); + assertEquals("time_range", meta.getType()); + assertEquals("condition", meta.getCategory()); + assertNotNull(meta.getLabel()); + assertFalse(meta.getLabel().isBlank()); + } + + // ======================== Action metadata ======================== + + @Test + void actionManager_listAllMetadata_returnsActionCategory() { + List list = actionProviderManager.listAllMetadata(); + assertFalse(list.isEmpty(), "Should return at least one action metadata"); + for (ProviderMetadataVO vo : list) { + assertEquals("action", vo.getCategory(), "All action metadata must have category=action"); + assertNotNull(vo.getType(), "type must not be null"); + assertNotNull(vo.getLabel(), "label must not be null"); + assertFalse(vo.getLabel().isBlank(), "label must not be blank"); + assertNotNull(vo.getSchema(), "schema must not be null"); + } + } + + @Test + void alarmTriggerAction_typeConstant_isConsistent() { + // Verify TYPE constant aligns with expected contract + assertEquals("alarm_trigger", AlarmTriggerAction.TYPE); + } + + // ======================== Aggregated list coverage ======================== + + @Test + void allTriggerTypes_arePresent() { + List list = triggerProviderManager.listAllMetadata(); + Set types = list.stream().map(ProviderMetadataVO::getType).collect(Collectors.toSet()); + assertTrue(types.contains("device_property")); + assertTrue(types.contains("device_event")); + assertTrue(types.contains("device_state")); + assertTrue(types.contains("device_service")); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImplTest.java b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImplTest.java index 46f2cf2f..23ae8e49 100644 --- a/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImplTest.java +++ b/viewsh-module-iot/viewsh-module-iot-rule/src/test/java/com/viewsh/module/iot/rule/service/IotRuleChainServiceImplTest.java @@ -15,7 +15,10 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; + import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; @@ -269,6 +272,161 @@ class IotRuleChainServiceImplTest extends BaseMockitoUnitTest { verify(ruleLinkMapper, times(1)).deleteByChainId(chainId); } + // ========== 用例 9:disableRuleChain 幂等(已禁用不更新)========== + + @Test + void testDisable_idempotent() { + Long chainId = 500L; + IotRuleChainDO chain = IotRuleChainDO.builder() + .id(chainId) + .name("DisabledChain") + .type("SCENE") + .status(RuleChainStatus.DISABLED.getValue()) + .version(0L) + .tenantId(1L) + .build(); + + when(ruleChainMapper.selectById(chainId)).thenReturn(chain); + + ruleChainService.disableRuleChain(chainId); + + // 已禁用,不应调用 updateById + verify(ruleChainMapper, never()).updateById(any(IotRuleChainDO.class)); + } + + // ========== 用例 10:disableRuleChain 正常禁用(从 enabled 到 disabled)========== + + @Test + void testDisable_fromEnabled() { + Long chainId = 501L; + IotRuleChainDO chain = IotRuleChainDO.builder() + .id(chainId) + .name("EnabledChain") + .type("SCENE") + .status(RuleChainStatus.ENABLED.getValue()) + .version(1L) + .tenantId(1L) + .build(); + + when(ruleChainMapper.selectById(chainId)).thenReturn(chain); + when(ruleChainMapper.updateById(any(IotRuleChainDO.class))).thenReturn(1); + + ruleChainService.disableRuleChain(chainId); + + ArgumentCaptor disableCaptor = ArgumentCaptor.forClass(IotRuleChainDO.class); + verify(ruleChainMapper, times(1)).updateById(disableCaptor.capture()); + assertEquals(RuleChainStatus.DISABLED.getValue(), disableCaptor.getValue().getStatus()); + } + + // ========== 用例 11:toggleDebug 开启调试模式 ========== + + @Test + void testToggleDebug_enable() { + Long chainId = 600L; + IotRuleChainDO chain = IotRuleChainDO.builder() + .id(chainId) + .name("DebugChain") + .type("SCENE") + .status(RuleChainStatus.ENABLED.getValue()) + .version(0L) + .debugMode(false) + .tenantId(1L) + .build(); + + when(ruleChainMapper.selectById(chainId)).thenReturn(chain); + when(ruleChainMapper.updateById(any(IotRuleChainDO.class))).thenReturn(1); + + ruleChainService.toggleDebug(chainId, true); + + ArgumentCaptor debugCaptor = ArgumentCaptor.forClass(IotRuleChainDO.class); + verify(ruleChainMapper, times(1)).updateById(debugCaptor.capture()); + assertTrue(debugCaptor.getValue().getDebugMode()); + } + + // ========== 用例 12:copyRuleChain 深拷节点和连线 ========== + + @Test + void testCopy_deepCopy() { + Long originalId = 700L; + Long newId = 701L; + IotRuleChainDO original = IotRuleChainDO.builder() + .id(originalId) + .name("OriginalChain") + .type("SCENE") + .status(RuleChainStatus.ENABLED.getValue()) + .version(2L) + .priority(100) + .debugMode(false) + .tenantId(1L) + .build(); + + IotRuleNodeDO node1 = IotRuleNodeDO.builder() + .id(10L).ruleChainId(originalId).category("trigger").type("device_event").configuration("{}").tenantId(1L).build(); + IotRuleNodeDO node2 = IotRuleNodeDO.builder() + .id(11L).ruleChainId(originalId).category("action").type("send_notification").configuration("{}").tenantId(1L).build(); + + IotRuleLinkDO link1 = IotRuleLinkDO.builder() + .id(20L).ruleChainId(originalId).sourceNodeId(10L).targetNodeId(11L).relationType("Success").sortOrder(0).tenantId(1L).build(); + + when(ruleChainMapper.selectById(originalId)).thenReturn(original); + when(ruleChainMapper.insert(any(IotRuleChainDO.class))).thenAnswer(inv -> { + IotRuleChainDO c = inv.getArgument(0); + c.setId(newId); + c.setTenantId(1L); + return 1; + }); + when(ruleNodeMapper.selectByChainId(originalId)).thenReturn(Arrays.asList(node1, node2)); + when(ruleNodeMapper.insertBatch(anyCollection())).thenAnswer(inv -> { + // 模拟 MyBatis Plus saveBatch 回填 ID + Collection nodes = inv.getArgument(0); + long idCounter = 100L; + for (IotRuleNodeDO n : nodes) { + n.setId(idCounter++); + } + return true; + }); + when(ruleLinkMapper.selectByChainId(originalId)).thenReturn(Collections.singletonList(link1)); + when(ruleLinkMapper.insertBatch(anyCollection())).thenReturn(true); + + Long result = ruleChainService.copyRuleChain(originalId); + + assertEquals(newId, result); + // 验证新 chain 名称追加了 _copy,状态为 DISABLED,版本 0 + ArgumentCaptor copyCaptor = ArgumentCaptor.forClass(IotRuleChainDO.class); + verify(ruleChainMapper, times(1)).insert(copyCaptor.capture()); + assertEquals("OriginalChain_copy", copyCaptor.getValue().getName()); + assertEquals(RuleChainStatus.DISABLED.getValue(), copyCaptor.getValue().getStatus()); + assertEquals(Long.valueOf(0L), copyCaptor.getValue().getVersion()); + verify(ruleNodeMapper, times(1)).insertBatch(anyCollection()); + verify(ruleLinkMapper, times(1)).insertBatch(anyCollection()); + } + + // ========== 用例 13:deployRuleChain 启用 + 无 Redis 不抛异常 ========== + + @Test + void testDeploy_enablesAndSkipsRedisWhenNull() { + Long chainId = 800L; + IotRuleChainDO chain = IotRuleChainDO.builder() + .id(chainId) + .name("DeployChain") + .type("SCENE") + .status(RuleChainStatus.DISABLED.getValue()) + .version(0L) + .tenantId(1L) + .build(); + + when(ruleChainMapper.selectById(chainId)).thenReturn(chain); + when(ruleChainMapper.updateById(any(IotRuleChainDO.class))).thenReturn(1); + + // redisTemplate 为 null(@Autowired required=false,mock 环境未注入) + assertDoesNotThrow(() -> ruleChainService.deployRuleChain(chainId)); + + // 验证状态从 DISABLED 变为 ENABLED + ArgumentCaptor deployCaptor = ArgumentCaptor.forClass(IotRuleChainDO.class); + verify(ruleChainMapper, times(1)).updateById(deployCaptor.capture()); + assertEquals(RuleChainStatus.ENABLED.getValue(), deployCaptor.getValue().getStatus()); + } + // ========== 构建辅助方法 ========== private IotRuleChainSaveReqVO buildCreateReqVO(IotRuleChainSaveReqVO.NodeVO... nodes) { diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/IotAlarmRecordController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/IotAlarmRecordController.java new file mode 100644 index 00000000..a7925ad7 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/IotAlarmRecordController.java @@ -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 告警记录 Controller(B20) + * + *

11 个端点:page / get / ack / unack / clear / archive / batch-ack / batch-clear / batch-archive / history / remark

+ * + * @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> getAlarmRecordPage(@Valid AlarmRecordPageReqVO pageReqVO) { + PageResult pageResult = alarmRecordService.getAlarmPage(pageReqVO); + List 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 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 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 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 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 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 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 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 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> getAlarmHistory( + @RequestParam("alarmRecordId") Long alarmRecordId) { + List historyList = alarmRecordService.listAlarmHistory(alarmRecordId); + List 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 updateAlarmRemark(@Valid @RequestBody AlarmRemarkReqVO reqVO) { + alarmRecordService.updateRemark(reqVO.getId(), reqVO.getRemark()); + return success(true); + } + + // ==================== 内部:VO 转换 ==================== + + /** + * IotAlarmRecordDO → AlarmRecordRespVO + *

+ * 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 propagations = propagationMapper.selectByAlarmRecordId(alarm.getId()); + if (propagations != null && !propagations.isEmpty()) { + List 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()); + } + + // details:JsonNode → Map(直接赋 null 或转换) + if (alarm.getDetails() != null) { + try { + // 简化:将 JsonNode toString 放入 details map 的 _raw key(前端能解析) + // 或用 ObjectMapper 转 Map —— 此处保持简单,不引入 ObjectMapper 依赖 + // 前端 details 是 Record,JSON node 直接兼容 + // 注意:由于 Map 与 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"; + } + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmAckReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmAckReqVO.java new file mode 100644 index 00000000..f8734b96 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmAckReqVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmArchiveReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmArchiveReqVO.java new file mode 100644 index 00000000..ec663535 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmArchiveReqVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmBatchReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmBatchReqVO.java new file mode 100644 index 00000000..e2fe517e --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmBatchReqVO.java @@ -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 VO(batch-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 ids; + + @Schema(description = "处理备注(可选,最多 500 字符)", example = "批量处理") + @Size(max = 500, message = "处理备注最多 500 字符") + private String remark; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmClearReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmClearReqVO.java new file mode 100644 index 00000000..306cb3e7 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmClearReqVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmHistoryRespVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmHistoryRespVO.java new file mode 100644 index 00000000..f845ec98 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmHistoryRespVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRecordPageReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRecordPageReqVO.java new file mode 100644 index 00000000..746d64ae --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRecordPageReqVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRecordRespVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRecordRespVO.java new file mode 100644 index 00000000..86e1c539 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRecordRespVO.java @@ -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 propagatedTo; + + @Schema(description = "处理备注", example = "已联系运维") + private String processRemark; + + @Schema(description = "告警详情(JSON Map)") + private Map 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; + + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRemarkReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRemarkReqVO.java new file mode 100644 index 00000000..47073d32 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alarm/vo/AlarmRemarkReqVO.java @@ -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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDeviceController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDeviceController.java index 9a83ba96..11686a14 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDeviceController.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDeviceController.java @@ -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 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 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')") diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceBatchBindSubsystemReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceBatchBindSubsystemReqVO.java new file mode 100644 index 00000000..be41bd21 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceBatchBindSubsystemReqVO.java @@ -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 + *

+ * 每批 ≤ 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 deviceIds; + + @Schema(description = "子系统编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "8") + @NotNull(message = "子系统编号不能为空") + private Long subsystemId; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceBindSubsystemReqVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceBindSubsystemReqVO.java new file mode 100644 index 00000000..c5cf16ee --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/vo/device/IotDeviceBindSubsystemReqVO.java @@ -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 + *

+ * 与前端 {@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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/IotSubsystemController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/IotSubsystemController.java index 23b2385e..39bdeb1f 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/IotSubsystemController.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/IotSubsystemController.java @@ -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 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 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/alarm;Redis 未命中时返回空 Map(B10 评审 A6:不做实时 DB count)") + @PreAuthorize("@ss.hasPermission('iot:subsystem:query')") + public CommonResult> getAllSubsystemDeviceCount() { + return success(subsystemService.getAllSubsystemDeviceCount()); + } + } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemDeviceCountRespVO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemDeviceCountRespVO.java new file mode 100644 index 00000000..cb4d29e2 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/subsystem/vo/IotSubsystemDeviceCountRespVO.java @@ -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 VO(B22 /device-count 聚合接口) + *

+ * 字段命名与前端契约一致: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; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmPropagationMapper.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmPropagationMapper.java index 9cf7a775..f22cb0fa 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmPropagationMapper.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmPropagationMapper.java @@ -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 { + /** + * 按告警记录 ID 查询传播路径列表 + * + * @param alarmRecordId 告警记录 ID + * @return 传播路径列表 + */ + default List selectByAlarmRecordId(Long alarmRecordId) { + return selectList(new LambdaQueryWrapper() + .eq(IotAlarmPropagationDO::getAlarmRecordId, alarmRecordId)); + } + /** * 批量插入传播记录(INSERT IGNORE 保证幂等,评审 C3) * diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmRecordMapper.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmRecordMapper.java index 5dafe254..e5bc42f0 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmRecordMapper.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/alarm/IotAlarmRecordMapper.java @@ -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 *

@@ -32,4 +37,41 @@ public interface IotAlarmRecordMapper extends BaseMapperX { .last("LIMIT 1")); } + /** + * 分页查询告警记录(支持多条件过滤) + * + * @param reqVO 分页查询参数 + * @return 分页结果 + */ + default PageResult selectPage(AlarmRecordPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapper() + .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 selectListByIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return selectList(new LambdaQueryWrapper() + .in(IotAlarmRecordDO::getId, ids)); + } + } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/subsystem/IotSubsystemDeviceCountRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/subsystem/IotSubsystemDeviceCountRedisDAO.java index 4a3c7edd..d19787b0 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/subsystem/IotSubsystemDeviceCountRedisDAO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/subsystem/IotSubsystemDeviceCountRedisDAO.java @@ -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) + *

+ * B22 /device-count 接口使用,一次 HGETALL 拿全量。 + * 若 Key 不存在或 Hash 为空,返回空 Map(让调用方决定 fallback 策略)。 + * + * @param tenantId 租户 ID + * @return subsystemId → deviceCount 的 Map(空 Map 表示无数据) + */ + public Map getAllCounts(Long tenantId) { + String key = buildKey(tenantId); + Map raw = stringRedisTemplate.opsForHash().entries(key); + if (raw == null || raw.isEmpty()) { + return Collections.emptyMap(); + } + Map result = new HashMap<>(raw.size()); + for (Map.Entry 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(删除子系统时清理) * diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordService.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordService.java index 665c854b..fa6d1b7b 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordService.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordService.java @@ -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 告警记录 Service(v2.0 正交状态机) *

@@ -59,4 +63,58 @@ public interface IotAlarmRecordService { */ IotAlarmRecordDO getAlarm(Long id); + // ==================== B20 新增:查询 / 批量 / remark ==================== + + /** + * 分页查询告警记录 + * + * @param reqVO 分页查询参数 + * @return 分页结果 + */ + PageResult getAlarmPage(AlarmRecordPageReqVO reqVO); + + /** + * 批量确认告警(遍历调 ackAlarm,幂等) + * + * @param request 含 ids 列表的请求(alarmId 字段依次填充) + * @param ids 告警记录 ID 列表 + */ + void batchAckAlarm(List ids, String operator, String remark); + + /** + * 批量清除告警(遍历调 clearAlarm,幂等) + * + * @param ids 告警记录 ID 列表 + * @param operator 操作人 + * @param remark 处理备注 + */ + void batchClearAlarm(List ids, String operator, String remark); + + /** + * 批量归档告警(遍历调 archiveAlarm,每条独立捕获异常) + *

+ * 已归档的条目跳过(不抛出);返回失败条数。 + * + * @param ids 告警记录 ID 列表 + * @param operator 操作人 + * @return 失败条数(0 = 全部成功) + */ + int batchArchiveAlarm(List ids, String operator); + + /** + * 更新告警处理备注(不走锁,备注为附属信息) + * + * @param id 告警记录 ID + * @param remark 处理备注 + */ + void updateRemark(Long id, String remark); + + /** + * 查询告警历史(时序库) + * + * @param alarmRecordId 告警记录 ID + * @return 历史列表(按时间倒序,TSDB 不可用时返回空列表) + */ + List listAlarmHistory(Long alarmRecordId); + } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java index c187ca31..965a4310 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImpl.java @@ -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 getAlarmPage(AlarmRecordPageReqVO reqVO) { + return alarmRecordMapper.selectPage(reqVO); + } + + @Override + public void batchAckAlarm(List 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 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 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 listAlarmHistory(Long alarmRecordId) { + return alarmHistoryService.queryByAlarmRecord(alarmRecordId, null, null); + } + // ==================== 内部工具 ==================== private IotAlarmRecordDO getAlarmOrThrow(Long id) { diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemService.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemService.java index f5729518..a61af9c8 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemService.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemService.java @@ -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 接口) + *

+ * 一次 HGETALL 从 Redis 读取,Redis 未命中时返回空 Map(B10 评审 A6 策略: + * 不做实时 DB count,DB 重建由定时任务/启动事件负责)。 + * + * @return subsystemId → IotSubsystemDeviceCountRespVO(total/online/alarm) + */ + Map getAllSubsystemDeviceCount(); + } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImpl.java index 90ba714d..b6d62d42 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImpl.java @@ -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 getAllSubsystemDeviceCount() { + Long tenantId = TenantContextHolder.getTenantId(); + + // 一次 HGETALL 拿全量计数 + Map countMap = deviceCountRedisDAO.getAllCounts(tenantId); + + if (countMap.isEmpty()) { + // Redis miss → 返回空 Map(B10 评审 A6:不做实时 DB count,由定时任务/启动事件重建) + log.debug("[getAllSubsystemDeviceCount] Redis Hash 为空,tenantId={},返回空 Map", tenantId); + return Collections.emptyMap(); + } + + // 转换为 VO(online/alarm 待 B12/B14 实现,当前返回 0) + Map result = new HashMap<>(countMap.size()); + countMap.forEach((subsystemId, total) -> + result.put(subsystemId, new IotSubsystemDeviceCountRespVO(total, 0L, 0L))); + return result; + } + // ==================== 启动时重建 Redis 计数(评审 A6)==================== /** diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java index fc37a96f..2fea8a05 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/alarm/IotAlarmRecordServiceImplTest.java @@ -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 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 mockResult = new PageResult<>(List.of(alarm), 1L); + when(alarmRecordMapper.selectPage(reqVO)).thenReturn(mockResult); + + PageResult 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 调了 updateById,402 由于已归档在锁前抛错,不走 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 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 result = alarmService.listAlarmHistory(601L); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("ack", result.get(0).getEventType()); + } + // ==================== 辅助 ==================== private AlarmTriggerRequest buildTriggerReq(int severity) { diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImplTest.java b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImplTest.java index 75504b0e..4a6c728d 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImplTest.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/test/java/com/viewsh/module/iot/service/subsystem/IotSubsystemServiceImplTest.java @@ -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()); } + // ==================== 用例 9(B22):getAllSubsystemDeviceCount Redis 命中 → 返回 map ==================== + + @Test + void testGetAllSubsystemDeviceCount_redisHit() { + // Redis 返回 2 个子系统的设备数 + Map redisData = Map.of(1L, 10L, 2L, 5L); + when(deviceCountRedisDAO.getAllCounts(TENANT_ID)).thenReturn(redisData); + + Map 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); + } + + // ==================== 用例 10(B22):getAllSubsystemDeviceCount Redis miss → 返回空 map ==================== + + @Test + void testGetAllSubsystemDeviceCount_redisMiss() { + // Redis Hash 为空(Key 不存在或 Hash 无数据) + when(deviceCountRedisDAO.getAllCounts(TENANT_ID)).thenReturn(Collections.emptyMap()); + + Map result = subsystemService.getAllSubsystemDeviceCount(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + + verify(deviceCountRedisDAO, times(1)).getAllCounts(TENANT_ID); + } + + // ==================== 用例 11(B22):getAllSubsystemDeviceCount 空数据集 → 返回空 map ==================== + + @Test + void testGetAllSubsystemDeviceCount_emptyData() { + // 明确验证:Redis 命中但计数均为 0 时,仍正确返回 total=0 的 VO + Map redisData = Map.of(100L, 0L); + when(deviceCountRedisDAO.getAllCounts(TENANT_ID)).thenReturn(redisData); + + Map 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) {