From 7c3b0a141164eab5865bdaedcb68d0615e334066 Mon Sep 17 00:00:00 2001
From: zch
Date: Thu, 23 Apr 2026 17:32:34 +0800
Subject: [PATCH] =?UTF-8?q?feat(ems):=20=E6=96=B0=E5=A2=9E=E4=BD=8D?=
=?UTF-8?q?=E7=A7=BB=E4=B8=93=E5=B1=9E=E7=9C=8B=E6=9D=BF=E5=8A=9F=E8=83=BD?=
=?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E5=91=8A=E8=AD=A6=E9=98=88=E5=80=BC?=
=?UTF-8?q?=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
refactor(ems): 重构告警阈值相关字段注释与逻辑,移除废弃的alarmLevel字段
fix(common): 修复Excel字典表达式解析时的空指针问题
test(admin): 添加报警迁移脚本的契约测试
perf(ems): 优化位移看板查询性能,新增分表查询与抽样功能
docs(ems): 完善告警阈值字段的文档注释
chore: 统一日期解析逻辑至DateUtils工具类
style: 清理部分文件中的无用导入
---
.../test/AlarmMigrationContractUnitTest.java | 41 +
.../common/excel/core/ExcelDownHandler.java | 12 +-
ruoyi-common/ruoyi-common-json/pom.xml | 6 +
.../json/handler/CustomDateDeserializer.java | 11 +-
.../common/web/config/ResourcesConfig.java | 15 +-
.../domain/EmsMonitorMetricThreshold.java | 13 +-
.../bo/EmsMonitorMetricThresholdBo.java | 10 +-
.../vo/EmsMonitorMetricThresholdVo.java | 10 +-
.../domain/vo/EmsReportPeriodSummaryVo.java | 12 +-
.../EmsMonitorMetricThresholdServiceImpl.java | 3 +-
.../ems/record/domain/EmsRecordAlarmRule.java | 17 +
.../impl/EmsRecordAlarmDataServiceImpl.java | 9 +-
.../domain/bo/DisplacementBoardQueryBo.java | 86 +-
.../mapper/DisplacementBoardMapper.java | 63 +
.../impl/DisplacementBoardServiceImpl.java | 1381 +++++++++++------
.../ems/record/EmsRecordAlarmRuleMapper.xml | 4 -
.../ems/report/DisplacementBoardMapper.xml | 246 +++
17 files changed, 1421 insertions(+), 518 deletions(-)
create mode 100644 ruoyi-admin/src/test/java/org/dromara/test/AlarmMigrationContractUnitTest.java
create mode 100644 ruoyi-ems/src/main/java/org/dromara/ems/report/mapper/DisplacementBoardMapper.java
create mode 100644 ruoyi-ems/src/main/resources/mapper/ems/report/DisplacementBoardMapper.xml
diff --git a/ruoyi-admin/src/test/java/org/dromara/test/AlarmMigrationContractUnitTest.java b/ruoyi-admin/src/test/java/org/dromara/test/AlarmMigrationContractUnitTest.java
new file mode 100644
index 0000000..f2ed6ea
--- /dev/null
+++ b/ruoyi-admin/src/test/java/org/dromara/test/AlarmMigrationContractUnitTest.java
@@ -0,0 +1,41 @@
+package org.dromara.test;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 报警迁移验证脚本契约测试。
+ *
+ * 这里不直接连数据库,而是校验脚本是否保留了可执行建表、样例数据和状态语义,
+ * 这样至少能把迁移验收用的材料锁住,避免脚本被后续改动悄悄削弱。
+ */
+@DisplayName("报警迁移验证脚本契约")
+public class AlarmMigrationContractUnitTest {
+
+ @Test
+ @DisplayName("验证报警迁移脚本包含可执行建表和样例数据")
+ public void shouldContainAlarmMigrationSeedAndSchema() throws IOException {
+ Path scriptPath = Path.of("script", "sql", "update", "update_5.6.0_alarm_validation.sql");
+ String sql = Files.readString(scriptPath, StandardCharsets.UTF_8);
+
+ assertTrue(sql.contains("CREATE TABLE [dbo].[ems_alarm_notify_group]"), "应包含通知组建表语句");
+ assertTrue(sql.contains("CREATE TABLE [dbo].[ems_alarm_notify_group_user]"), "应包含通知组成员建表语句");
+ assertTrue(sql.contains("CREATE TABLE [dbo].[ems_alarm_push_log]"), "应包含推送日志建表语句");
+ assertTrue(sql.contains("INSERT INTO [dbo].[ems_record_alarm_rule]"), "应包含报警规则样例数据");
+ assertTrue(sql.contains("INSERT INTO [dbo].[ems_alarm_action_step]"), "应包含措施步骤样例数据");
+ assertTrue(sql.contains("INSERT INTO [dbo].[ems_alarm_action_step_image]"), "应包含步骤图片样例数据");
+ assertTrue(sql.contains("INSERT INTO [dbo].[ems_record_alarm_data]"), "应包含告警记录样例数据");
+ assertTrue(sql.contains("INSERT INTO [dbo].[ems_alarm_push_log]"), "应包含推送日志样例数据");
+ assertTrue(sql.contains("'PENDING'"), "应保留待推送状态样例");
+ assertTrue(sql.contains("'SUCCESS'"), "应保留推送成功状态样例");
+ assertTrue(sql.contains("'FAILED'"), "应保留推送失败状态样例");
+ assertTrue(sql.contains("TODO"), "应保留未彻底解决的 DDL 兼容 TODO");
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java
index 05c79c4..24fdf92 100644
--- a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java
+++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java
@@ -111,7 +111,17 @@ public class ExcelDownHandler implements SheetWriteHandler {
} else if (StringUtils.isNotBlank(converterExp)) {
// 如果指定了确切的值,则直接解析确切的值
List strList = StringUtils.splitList(converterExp, format.separator());
- options = StreamUtils.toList(strList, s -> StringUtils.split(s, "=")[1]);
+ options = StreamUtils.toList(strList, s -> {
+ // readConverterExp 同时被导入导出共用,格式不完整时这里直接忽略,避免整个导出链路被错误注解拖垮。
+ String[] pair = s.split("=", 2);
+ if (pair.length < 2 || StringUtils.isBlank(pair[1])) {
+ log.warn("忽略非法的Excel字典表达式字段配置, field={}, expression={}", field.getName(), s);
+ return null;
+ }
+ return pair[1];
+ }).stream()
+ .filter(StringUtils::isNotBlank)
+ .toList();
}
} else if (field.isAnnotationPresent(ExcelEnumFormat.class)) {
// 否则如果指定了@ExcelEnumFormat,则使用枚举的逻辑
diff --git a/ruoyi-common/ruoyi-common-json/pom.xml b/ruoyi-common/ruoyi-common-json/pom.xml
index 870df5c..7464684 100644
--- a/ruoyi-common/ruoyi-common-json/pom.xml
+++ b/ruoyi-common/ruoyi-common-json/pom.xml
@@ -32,6 +32,12 @@
jackson-datatype-jsr310
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
diff --git a/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/CustomDateDeserializer.java b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/CustomDateDeserializer.java
index 21c6a6a..ca9c455 100644
--- a/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/CustomDateDeserializer.java
+++ b/ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/CustomDateDeserializer.java
@@ -1,11 +1,9 @@
package org.dromara.common.json.handler;
-import cn.hutool.core.date.DateTime;
-import cn.hutool.core.date.DateUtil;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
-import org.dromara.common.core.utils.ObjectUtils;
+import org.dromara.common.core.utils.DateUtils;
import java.io.IOException;
import java.util.Date;
@@ -27,11 +25,8 @@ public class CustomDateDeserializer extends JsonDeserializer {
*/
@Override
public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
- DateTime parse = DateUtil.parse(p.getText());
- if (ObjectUtils.isNull(parse)) {
- return null;
- }
- return parse.toJdkDate();
+ // JSON 日期入参和 GET 参数统一走项目白名单格式解析,避免同一时间字符串在不同入口出现不一致行为。
+ return DateUtils.parseDate(p.getText());
}
}
diff --git a/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/ResourcesConfig.java b/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/ResourcesConfig.java
index 81ec905..459a505 100644
--- a/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/ResourcesConfig.java
+++ b/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/ResourcesConfig.java
@@ -1,8 +1,6 @@
package org.dromara.common.web.config;
-import cn.hutool.core.date.DateTime;
-import cn.hutool.core.date.DateUtil;
-import org.dromara.common.core.utils.ObjectUtils;
+import org.dromara.common.core.utils.DateUtils;
import org.dromara.common.web.handler.GlobalExceptionHandler;
import org.dromara.common.web.interceptor.PlusWebInvokeTimeInterceptor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -34,13 +32,10 @@ public class ResourcesConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// 全局日期格式转换配置
- registry.addConverter(String.class, Date.class, source -> {
- DateTime parse = DateUtil.parse(source);
- if (ObjectUtils.isNull(parse)) {
- return null;
- }
- return parse.toJdkDate();
- });
+ registry.addConverter(String.class, Date.class, source ->
+ // 这里显式复用项目统一的白名单时间格式解析,避免 Hutool 自动猜格式时引入额外时区扫描和运行时噪声。
+ DateUtils.parseDate(source)
+ );
}
@Override
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/EmsMonitorMetricThreshold.java b/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/EmsMonitorMetricThreshold.java
index d018869..d71e5c9 100644
--- a/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/EmsMonitorMetricThreshold.java
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/EmsMonitorMetricThreshold.java
@@ -59,12 +59,21 @@ public class EmsMonitorMetricThreshold extends TenantEntity {
private BigDecimal alarmLower;
/**
- * 回差值
+ * 回差值(Hysteresis / 死区)
+ * 在告警阈值附近设置的缓冲区间,用于防止实测值在阈值线上抖动造成告警反复触发与恢复。
+ * 判定规则:实测值越过 alarmUpper / alarmLower 时触发告警;只有回落到
+ * (alarmUpper - hysteresis) 以下或 (alarmLower + hysteresis) 以上才判定为恢复。
+ * 示例:alarmUpper = 100,hysteresis = 5 → 值 ≥ 100 触发告警,只有值 ≤ 95 才视为恢复。
+ * 单位:与被测量同单位;为 null 或 0 表示不启用回差保护。
*/
private BigDecimal hysteresis;
/**
- * 持续触发秒数
+ * 持续触发秒数(Duration Seconds)
+ * 实测值必须连续越界达到该秒数才会正式触发告警,用于过滤瞬时尖峰造成的误报。
+ * 判定规则:越界计时累计 ≥ durationSec 才落库并推送;中途一旦恢复则计时清零。
+ * 示例:durationSec = 30 → 连续 28 秒越界后恢复不告警;连续 30 秒越界在第 30 秒触发告警。
+ * 单位:秒;为 null 或 0 表示不延迟,单采样越界即触发。
*/
private Integer durationSec;
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/bo/EmsMonitorMetricThresholdBo.java b/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/bo/EmsMonitorMetricThresholdBo.java
index 96671a5..a86d6c0 100644
--- a/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/bo/EmsMonitorMetricThresholdBo.java
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/bo/EmsMonitorMetricThresholdBo.java
@@ -60,12 +60,18 @@ public class EmsMonitorMetricThresholdBo extends BaseEntity {
private BigDecimal alarmLower;
/**
- * 回差值
+ * 回差值(Hysteresis / 死区)
+ * 在告警阈值附近设置缓冲区间,防止实测值在阈值线上抖动造成告警反复触发与恢复。
+ * 示例:alarmUpper = 100,hysteresis = 5 → 值 ≥ 100 触发告警,只有值 ≤ 95 才视为恢复。
+ * 单位:与被测量同单位;为 null 或 0 表示不启用回差保护。
*/
private BigDecimal hysteresis;
/**
- * 持续触发秒数
+ * 持续触发秒数(Duration Seconds)
+ * 实测值必须连续越界达到该秒数才会正式触发告警,用于过滤瞬时尖峰造成的误报。
+ * 示例:durationSec = 30 → 连续 30 秒越界才触发告警;中途恢复则计时清零。
+ * 单位:秒;为 null 或 0 表示不延迟,单采样越界即触发。
*/
private Integer durationSec;
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/vo/EmsMonitorMetricThresholdVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/vo/EmsMonitorMetricThresholdVo.java
index 7301ae4..556fb78 100644
--- a/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/vo/EmsMonitorMetricThresholdVo.java
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/vo/EmsMonitorMetricThresholdVo.java
@@ -78,13 +78,19 @@ public class EmsMonitorMetricThresholdVo implements Serializable {
private BigDecimal alarmLower;
/**
- * 回差值
+ * 回差值(Hysteresis / 死区)
+ * 在告警阈值附近设置缓冲区间,防止实测值在阈值线上抖动造成告警反复触发与恢复。
+ * 示例:alarmUpper = 100,hysteresis = 5 → 值 ≥ 100 触发告警,只有值 ≤ 95 才视为恢复。
+ * 单位:与被测量同单位;为 null 或 0 表示不启用回差保护。
*/
@ExcelProperty(value = "回差值")
private BigDecimal hysteresis;
/**
- * 持续触发秒数
+ * 持续触发秒数(Duration Seconds)
+ * 实测值必须连续越界达到该秒数才会正式触发告警,用于过滤瞬时尖峰造成的误报。
+ * 示例:durationSec = 30 → 连续 30 秒越界才触发告警;中途恢复则计时清零。
+ * 单位:秒;为 null 或 0 表示不延迟,单采样越界即触发。
*/
@ExcelProperty(value = "持续触发秒数")
private Integer durationSec;
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/vo/EmsReportPeriodSummaryVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/vo/EmsReportPeriodSummaryVo.java
index c528ecc..65baa3f 100644
--- a/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/vo/EmsReportPeriodSummaryVo.java
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/base/domain/vo/EmsReportPeriodSummaryVo.java
@@ -1,18 +1,14 @@
package org.dromara.ems.base.domain.vo;
-import java.util.Date;
-import com.fasterxml.jackson.annotation.JsonFormat;
-import org.dromara.ems.base.domain.EmsReportPeriodSummary;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
-import org.dromara.common.excel.annotation.ExcelDictFormat;
-import org.dromara.common.excel.convert.ExcelDictConvert;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
+import org.dromara.ems.base.domain.EmsReportPeriodSummary;
import java.io.Serial;
-import java.math.BigDecimal;
import java.io.Serializable;
+import java.math.BigDecimal;
import java.util.Date;
@@ -118,8 +114,8 @@ public class EmsReportPeriodSummaryVo implements Serializable {
/**
* 超限时长(秒)
*/
- @ExcelProperty(value = "超限时长", converter = ExcelDictConvert.class)
- @ExcelDictFormat(readConverterExp = "秒=")
+ // 这里直接把单位放进表头,避免把“秒”误当成字典表达式参与下拉解析,导致导出阶段崩溃。
+ @ExcelProperty(value = "超限时长(秒)")
private Integer overLimitDuration;
/**
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/base/service/impl/EmsMonitorMetricThresholdServiceImpl.java b/ruoyi-ems/src/main/java/org/dromara/ems/base/service/impl/EmsMonitorMetricThresholdServiceImpl.java
index 558f2b3..f742991 100644
--- a/ruoyi-ems/src/main/java/org/dromara/ems/base/service/impl/EmsMonitorMetricThresholdServiceImpl.java
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/base/service/impl/EmsMonitorMetricThresholdServiceImpl.java
@@ -127,7 +127,6 @@ public class EmsMonitorMetricThresholdServiceImpl implements IEmsMonitorMetricTh
queryWrapper.eq(bo.getAlarmLower() != null, "t.alarm_lower", bo.getAlarmLower());
queryWrapper.eq(bo.getHysteresis() != null, "t.hysteresis", bo.getHysteresis());
queryWrapper.eq(bo.getDurationSec() != null, "t.duration_sec", bo.getDurationSec());
- queryWrapper.eq(StringUtils.isNotBlank(bo.getAlarmLevel()), "t.alarm_level", bo.getAlarmLevel());
queryWrapper.eq(bo.getNotifyGroupId() != null, "t.notify_group_id", bo.getNotifyGroupId());
queryWrapper.eq(StringUtils.isNotBlank(bo.getIsEnable()), "t.is_enable", bo.getIsEnable());
return queryWrapper;
@@ -167,6 +166,8 @@ public class EmsMonitorMetricThresholdServiceImpl implements IEmsMonitorMetricTh
* 保存前的数据校验
*/
private void validEntityBeforeSave(EmsMonitorMetricThreshold entity){
+ // 告警级别已经从前端配置面收敛,这里统一清空,避免历史请求继续把停用字段写回数据库。
+ entity.setAlarmLevel(null);
//TODO 做一些数据校验,如唯一约束
}
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/record/domain/EmsRecordAlarmRule.java b/ruoyi-ems/src/main/java/org/dromara/ems/record/domain/EmsRecordAlarmRule.java
index e06c025..1bedb3c 100644
--- a/ruoyi-ems/src/main/java/org/dromara/ems/record/domain/EmsRecordAlarmRule.java
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/record/domain/EmsRecordAlarmRule.java
@@ -99,9 +99,26 @@ public class EmsRecordAlarmRule extends BaseEntity
@ExcelProperty(value = "恢复下限")
private BigDecimal recoverLower;
+ /**
+ * 回差(Hysteresis / 死区)
+ * 在 alarmUpper / alarmLower 附近设置的缓冲区间,用于防止实测值在阈值线上抖动
+ * 造成告警反复触发与恢复(俗称告警抖动 / alarm flapping)。
+ * 判定规则:越界后只有回落到 (alarmUpper - hysteresis) 以下或
+ * (alarmLower + hysteresis) 以上才判定为恢复,而不是一跌破阈值就立即恢复。
+ * 示例:alarmUpper = 100,hysteresis = 5 → 值 ≥ 100 触发告警,只有值 ≤ 95 才视为恢复。
+ * 单位:与被测量同单位;为 null 或 0 表示不启用回差保护。
+ */
@ExcelProperty(value = "回差")
private BigDecimal hysteresis;
+ /**
+ * 持续触发秒数(Duration Seconds)
+ * 实测值必须连续越界达到该秒数才会正式触发告警,用于过滤瞬时尖峰造成的误报
+ * (transient spike)。
+ * 判定规则:越界计时累计 ≥ durationSec 才落库并推送;中途一旦恢复则计时清零。
+ * 示例:durationSec = 30 → 连续 28 秒越界后恢复不告警;连续 30 秒越界在第 30 秒触发告警。
+ * 单位:秒;为 null 或 0 表示不延迟,单采样越界即触发。
+ */
@ExcelProperty(value = "持续触发秒数")
private Integer durationSec;
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/record/service/impl/EmsRecordAlarmDataServiceImpl.java b/ruoyi-ems/src/main/java/org/dromara/ems/record/service/impl/EmsRecordAlarmDataServiceImpl.java
index 53a7100..dec4fe8 100644
--- a/ruoyi-ems/src/main/java/org/dromara/ems/record/service/impl/EmsRecordAlarmDataServiceImpl.java
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/record/service/impl/EmsRecordAlarmDataServiceImpl.java
@@ -1014,12 +1014,10 @@ public class EmsRecordAlarmDataServiceImpl implements IEmsRecordAlarmDataService
if (ObjectUtil.isEmpty(alarmData.getMetricCode())) {
alarmData.setMetricCode(matchedRule.getMetricCode());
}
- if (ObjectUtil.isEmpty(alarmData.getAlarmLevel())) {
- alarmData.setAlarmLevel(matchedRule.getAlarmLevel());
+ // 告警级别已从规则配置入口收敛,落库链路不再从规则表回填,避免历史字段继续影响实时告警口径。
+ if (ObjectUtil.isEmpty(alarmData.getNotifyUser())) {
+ alarmData.setNotifyUser(matchedRule.getNotifyUser());
}
- if (ObjectUtil.isEmpty(alarmData.getNotifyUser())) {
- alarmData.setNotifyUser(matchedRule.getNotifyUser());
- }
if (alarmData.getThresholdValue() == null) {
if (matchedRule.getTriggerValue() != null) {
alarmData.setThresholdValue(matchedRule.getTriggerValue());
@@ -1066,7 +1064,6 @@ public class EmsRecordAlarmDataServiceImpl implements IEmsRecordAlarmDataService
pushLog.setAlarmObjId(alarmData.getObjId());
pushLog.setChannelType(resolveChannelType(target));
pushLog.setTargetValue(target);
- pushLog.setAlarmLevel(alarmData.getAlarmLevel());
pushLog.setPushContent(alarmData.getAlarmContent());
pushLog.setPushStatus(EmsAlarmPushStatusConstants.PENDING);
pushLog.setResponseMsg("已生成待推送日志,等待真实推送执行链接管");
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/bo/DisplacementBoardQueryBo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/bo/DisplacementBoardQueryBo.java
index 166638c..32d71d3 100644
--- a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/bo/DisplacementBoardQueryBo.java
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/bo/DisplacementBoardQueryBo.java
@@ -1,11 +1,89 @@
package org.dromara.ems.report.domain.bo;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
/**
* 位移专属看板查询参数。
*
- * 当前先复用振动看板已有字段结构,
- * 目的是在不破坏现有校验与序列化行为的前提下,
- * 为 displacementBoard 独立 Controller / Service 提供单独文件入口。
+ * 不再继承 {@link VibrationBoardQueryBo},彻底与“振动多指标看板”查询解耦:
+ * 1. 位移看板是单指标专属链路,BO 层不暴露 vibrationParam,避免外部再用指标切换语义调接口;
+ * 2. 所有位移专属查询在同一个 BO 内聚合,与 Controller / Service / Mapper 的拆分保持一致。
+ *
+ * 异常相关参数(highThreshold / warningThreshold / minContinuousSamples /
+ * rapidRiseThreshold / stddevThreshold)仅在异常页生效,其余页面会自动忽略。
+ * 这样 7 个位移页面可以共用同一个 BO,避免为每页单独建查询对象导致文件失控。
*/
-public class DisplacementBoardQueryBo extends VibrationBoardQueryBo {
+@Data
+public class DisplacementBoardQueryBo {
+
+ /**
+ * 单设备编码。
+ * 前端点击设备树叶子节点时传入,进入“单设备位移趋势”模式。
+ * Service 层会将其合并进 monitorIds 列表,统一用 IN 查询以简化 SQL 分支。
+ */
+ private String monitorId;
+
+ /**
+ * 多设备编码列表。
+ * 前端点击设备树父节点时传入,进入“多设备位移对比”模式。
+ * 与 monitorId 可同时存在,Service 层会去重合并。
+ */
+ private List monitorIds;
+
+ /**
+ * 开始记录时间,格式 yyyy-MM-dd HH:mm:ss。
+ * 必填,未传时 Service 层会 fail fast 报错。
+ * 前端默认填充“最近 24 小时”,避免菜单首次打开时空白。
+ */
+ private String beginRecordTime;
+
+ /**
+ * 结束记录时间,格式 yyyy-MM-dd HH:mm:ss。
+ * 必填,且与 beginRecordTime 的跨度不得超过 90 天。
+ */
+ private String endRecordTime;
+
+ /**
+ * 抽样间隔(分钟)。
+ * 默认 1(不抽样),大于 1 时后端走 ROW_NUMBER 窗口抽样 SQL,
+ * 在数据库层就压缩数据量,减少网络传输和 Java 内存消耗。
+ * 上限 1440(即一天),避免误传极端值后抽成“无数据”。
+ */
+ private Integer samplingInterval;
+
+ /**
+ * 高风险阈值(仅异常页使用)。
+ * 前端可不传,后端会根据位移指标填充经验默认值(300um)。
+ * 前端传参可覆盖默认值,实现“可配置不硬编码”。
+ */
+ private BigDecimal highThreshold;
+
+ /**
+ * 预警阈值(仅异常页使用)。
+ * 用于“连续超标”检测,低于高风险阈值但持续偏高本身就是预警信号。
+ */
+ private BigDecimal warningThreshold;
+
+ /**
+ * 连续超标最小样本数(仅异常页使用)。
+ * 默认 3,即连续 3 个采样点超过预警阈值才记为一次连续超标事件。
+ */
+ private Integer minContinuousSamples;
+
+ /**
+ * 变化过快阈值(仅异常页使用)。
+ * 相邻两个采样点的差值超过此值时记为“变化过快”事件,
+ * 用于检测突发故障场景。
+ */
+ private BigDecimal rapidRiseThreshold;
+
+ /**
+ * 抖动阈值——标准差(仅异常页使用)。
+ * 某小时内指标值的标准差超过此值时记为“抖动异常”,
+ * 用于检测传感器不稳定或设备间歇性异常振动场景。
+ */
+ private BigDecimal stddevThreshold;
}
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/mapper/DisplacementBoardMapper.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/mapper/DisplacementBoardMapper.java
new file mode 100644
index 0000000..de2c795
--- /dev/null
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/mapper/DisplacementBoardMapper.java
@@ -0,0 +1,63 @@
+package org.dromara.ems.report.mapper;
+
+import org.apache.ibatis.annotations.Param;
+import org.dromara.ems.record.domain.RecordIotenvInstant;
+import org.dromara.ems.report.domain.bo.DisplacementBoardQueryBo;
+
+import java.util.List;
+
+/**
+ * 位移专属看板 Mapper。
+ *
+ * 单独拆出位移专用 Mapper,而不是继续借道 {@link VibrationBoardMapper},
+ * 是为了:
+ * 1. SQL 层就固定 “仅查询 vibrationDisplacement” 口径,避免先拿多指标结果再在 Java 层裁剪;
+ * 2. 把振动设备过滤(monitor_type=10)、分表 UNION ALL 查询、ROW_NUMBER 抽样、白名单防注入
+ * 完全收敛在位移专属 XML 内,后续振动通用 SQL 变化不再影响位移页面口径。
+ *
+ * 只暴露 4 个方法(分析原始 / 分析抽样 / 质量原始 / 质量抽样),
+ * 其中分析类 SQL 会在 WHERE 中提前过滤 vibration_displacement IS NOT NULL AND > 0,
+ * 质量类 SQL 仅按时间/设备过滤,不提前按位移有效值过滤,以保证质量页分母口径准确。
+ */
+public interface DisplacementBoardMapper {
+
+ /**
+ * 查询位移看板分析类原始明细。
+ * 当抽样间隔 <= 1 时走这条路径。
+ * XML 中已通过 INNER JOIN monitor_type=10 + vibration_displacement 有效值过滤
+ * 固定位移专属口径。
+ *
+ * @param tableNames 已经通过白名单正则校验的日分表名列表,绝对不会包含非法字符串
+ * @param query 查询参数(时间范围、设备列表)
+ */
+ List selectRawData(@Param("tableNames") List tableNames,
+ @Param("query") DisplacementBoardQueryBo query);
+
+ /**
+ * 查询位移看板分析类抽样明细。
+ * 当抽样间隔 > 1 时走这条路径,在 DB 层通过 ROW_NUMBER 窗口函数
+ * 按“设备 + 时间桶”分区降采样,每个桶只保留最新一条记录。
+ * 比客户端抽样更高效——数据在 DB 层就被压缩。
+ *
+ * @param tableNames 已经通过白名单正则校验的日分表名列表
+ * @param query 查询参数(时间范围、设备列表、抽样间隔)
+ */
+ List selectSampledData(@Param("tableNames") List tableNames,
+ @Param("query") DisplacementBoardQueryBo query);
+
+ /**
+ * 查询位移质量页原始样本。
+ * 与分析类 SQL 不同,这里不提前按位移有效值过滤,
+ * 否则 sampleCount 会被错误缩小,导致质量页覆盖率虚高。
+ */
+ List selectQualityRawData(@Param("tableNames") List tableNames,
+ @Param("query") DisplacementBoardQueryBo query);
+
+ /**
+ * 查询位移质量页抽样样本。
+ * 当抽样间隔 > 1 时仍在 DB 层完成降采样,但不会提前按位移有效值过滤,
+ * 以保证质量页分母口径稳定。
+ */
+ List selectQualitySampledData(@Param("tableNames") List tableNames,
+ @Param("query") DisplacementBoardQueryBo query);
+}
diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/DisplacementBoardServiceImpl.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/DisplacementBoardServiceImpl.java
index ebf350e..a24d965 100644
--- a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/DisplacementBoardServiceImpl.java
+++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/DisplacementBoardServiceImpl.java
@@ -7,7 +7,6 @@ import org.dromara.common.core.exception.ServiceException;
import org.dromara.ems.record.domain.RecordIotenvInstant;
import org.dromara.ems.record.service.RecordIotenvPartitionService;
import org.dromara.ems.report.domain.bo.DisplacementBoardQueryBo;
-import org.dromara.ems.report.domain.bo.VibrationBoardQueryBo;
import org.dromara.ems.report.domain.vo.displacementboard.DisplacementAdvancedPageVo;
import org.dromara.ems.report.domain.vo.displacementboard.DisplacementAnomalyPageVo;
import org.dromara.ems.report.domain.vo.displacementboard.DisplacementComparisonPageVo;
@@ -15,16 +14,13 @@ import org.dromara.ems.report.domain.vo.displacementboard.DisplacementDistributi
import org.dromara.ems.report.domain.vo.displacementboard.DisplacementOverviewPageVo;
import org.dromara.ems.report.domain.vo.displacementboard.DisplacementQualityPageVo;
import org.dromara.ems.report.domain.vo.displacementboard.DisplacementTrendPageVo;
-import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAdvancedPageVo;
import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAnomalyPageVo;
-import org.dromara.ems.report.domain.vo.vibrationboard.VibrationComparisonPageVo;
import org.dromara.ems.report.domain.vo.vibrationboard.VibrationDistributionPageVo;
-import org.dromara.ems.report.domain.vo.vibrationboard.VibrationOverviewPageVo;
-import org.dromara.ems.report.domain.vo.vibrationboard.VibrationQualityPageVo;
-import org.dromara.ems.report.domain.vo.vibrationboard.VibrationTrendPageVo;
-import org.dromara.ems.report.mapper.VibrationBoardMapper;
+import org.dromara.ems.report.mapper.DisplacementBoardMapper;
import org.dromara.ems.report.service.IDisplacementBoardService;
-import org.dromara.ems.report.service.IVibrationBoardService;
+import org.dromara.ems.report.service.impl.support.VibrationAnomalyAnalyzer;
+import org.dromara.ems.report.service.impl.support.VibrationDistributionAggregator;
+import org.dromara.ems.report.service.impl.support.VibrationMathUtils;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@@ -37,8 +33,11 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
+import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
@@ -46,9 +45,16 @@ import java.util.regex.Pattern;
* 位移专属看板服务实现。
*
* 职责边界:
- * 1. 振动通用能力仍由 {@link IVibrationBoardService} 提供
- * 2. 位移专属统计口径、结果裁剪和质量页分母逻辑全部收口在本类
- * 3. 不再把位移专属实现塞回 VibrationBoardServiceImpl,避免污染振动多指标兼容能力
+ * 1. 完全独立于 {@code IVibrationBoardService} 与 {@code VibrationBoardMapper};
+ * 2. 底层查询走 {@link DisplacementBoardMapper},SQL 层就锁死 vibrationDisplacement 单指标口径,
+ * 不再做 “先查四维指标再在 Java 层裁剪” 的无效计算;
+ * 3. 总览页在一次分表查询基础上同时产出分析类指标与质量覆盖率,避免一次请求触发两套重型查询链路;
+ * 4. 保留 Fail Fast 全套保护:时间跨度 90 天上限 + 估算行数 50 万上限 + 单 SQL UNION 31 张分表上限
+ * + 分表名白名单正则校验(抵御 OWASP A03 / SQL 注入)。
+ *
+ * 保留 {@code VibrationDistributionAggregator} / {@code VibrationAnomalyAnalyzer}
+ * 等 stateless 工具:它们只接受 “Row + 指标提取函数”,不会额外触发 SQL,也不会计算多指标结果。
+ * 使用它们不违反 “彻底拆出位移专属聚合逻辑” 的诉求——本质上它们就是数学/时间窗口工具类。
*/
@Service
@RequiredArgsConstructor
@@ -57,81 +63,233 @@ public class DisplacementBoardServiceImpl implements IDisplacementBoardService {
private static final String DISPLACEMENT_FIELD = "vibrationDisplacement";
private static final String DISPLACEMENT_LABEL = "位移";
private static final String DISPLACEMENT_UNIT = "um";
+
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ /**
+ * 分表名白名单正则:仅允许 record_iotenv_instant_YYYYMMDD 格式。
+ * MyBatis XML 通过 ${tableName} 拼接 SQL,纵深防御避免上游被修改后传播 SQL 注入。
+ */
private static final Pattern TABLE_NAME_PATTERN = Pattern.compile("^record_iotenv_instant_\\d{8}$");
+
+ /** 查询跨度上限(天),防止一次拉取数月数据导致内存或 SQL Server 超时。 */
private static final int MAX_QUERY_DAYS = 90;
+
+ /** 趋势页最大点数——超过后进行桶 min/max 压缩,避免 ECharts 渲染卡顿。 */
+ private static final int MAX_TREND_POINTS = 1200;
+
+ /** 对比页最多展示设备数——过多会导致柱状图和散点图难以辨认。 */
+ private static final int MAX_COMPARE_DEVICES = 12;
+
+ /** 总览页设备排名展示上限。 */
+ private static final int MAX_OVERVIEW_RANK = 10;
+
+ /** 高级页桑基/矩形树图设备上限,避免节点过多压垮 ECharts。 */
+ private static final int MAX_SANKEY_DEVICES = 20;
+ private static final int MAX_TREEMAP_DEVICES = 30;
+
+ /** 单次查询允许返回的估算最大记录数,避免多设备分钟级全量拉取把 JVM 堆打爆。 */
private static final long MAX_ESTIMATED_QUERY_ROWS = 500_000L;
+
+ /** 单条 SQL 最大 UNION 分表数,超出时分批查询再归并。 */
private static final int MAX_UNION_TABLES = 31;
- private final IVibrationBoardService vibrationBoardService;
- private final VibrationBoardMapper vibrationBoardMapper;
+ /** 分布统计聚合器(无状态,可安全单例持有)。 */
+ private static final VibrationDistributionAggregator DISTRIBUTION_AGGREGATOR = new VibrationDistributionAggregator();
+
+ /** 异常分析器(无状态,可安全单例持有)。 */
+ private static final VibrationAnomalyAnalyzer ANOMALY_ANALYZER = new VibrationAnomalyAnalyzer();
+
+ private final DisplacementBoardMapper displacementBoardMapper;
private final RecordIotenvPartitionService recordIotenvPartitionService;
+ // =============== 总览 ===============
+
@Override
public DisplacementOverviewPageVo listOverviewData(DisplacementBoardQueryBo bo) {
- VibrationBoardQueryBo query = normalizeDisplacementQueryBo(bo);
- VibrationOverviewPageVo overview = vibrationBoardService.listOverviewData(query);
- DisplacementQualityPageVo quality = listQualityData(bo);
+ // 总览页一次分表查询同时承担分析口径和质量口径:
+ // 1) qualityRows = 不按位移有效值过滤的原始样本,承载 sampleCount/deviceCount/coverageRate;
+ // 2) validRows = 在 Java 层一次过滤拿到的位移有效样本,承载 avg/max/latest/ranks/gauges。
+ // 这样一次分表 IO 就能同时产出两类指标,彻底避免旧实现的“两套重型查询链路叠加”。
+ List qualityRows = listQualityPageData(bo);
+ DisplacementOverviewPageVo vo = emptyOverview();
+ if (CollUtil.isEmpty(qualityRows)) {
+ return vo;
+ }
- VibrationOverviewPageVo result = overview == null ? new VibrationOverviewPageVo() : overview;
- List displacementCards = (result.getMetricCards() == null ? Collections.emptyList() : result.getMetricCards())
- .stream()
- .filter(item -> DISPLACEMENT_FIELD.equals(item.getField()))
- .toList();
- return buildDisplacementOverviewVo(result, quality, displacementCards);
+ List validRows = filterValidDisplacementRows(qualityRows);
+ int sampleCount = qualityRows.size();
+ int validCount = validRows.size();
+
+ vo.setSampleCount(sampleCount);
+ vo.setDeviceCount(countDistinctMonitors(qualityRows));
+ vo.setCoverageRate(divideRate(validCount, sampleCount));
+
+ if (validCount == 0) {
+ return vo;
+ }
+
+ DisplacementMetricSnapshot snapshot = buildMetricSnapshot(validRows);
+ vo.setMetricCards(List.of(buildMetricCard(snapshot)));
+ vo.setPrimaryMetricStats(buildPrimaryMetricStats(snapshot));
+
+ // 设备画像复用同一批位移有效样本,避免再做一次分组/累加。
+ Map> grouped = groupByMonitor(validRows);
+ List profiles = buildDeviceProfiles(grouped);
+ vo.setDeviceRanks(buildOverviewDeviceRanks(profiles));
+ vo.setGaugeItems(buildOverviewGaugeItems(snapshot, grouped.size()));
+ return vo;
}
+ private DisplacementOverviewPageVo emptyOverview() {
+ DisplacementOverviewPageVo vo = new DisplacementOverviewPageVo();
+ vo.setMetricField(DISPLACEMENT_FIELD);
+ vo.setMetricLabel(DISPLACEMENT_LABEL);
+ vo.setUnit(DISPLACEMENT_UNIT);
+ vo.setSampleCount(0);
+ vo.setDeviceCount(0);
+ vo.setCoverageRate(zeroRate());
+ vo.setMetricCards(Collections.emptyList());
+ vo.setGaugeItems(Collections.emptyList());
+ vo.setPrimaryMetricStats(new DisplacementOverviewPageVo.PrimaryMetricStats());
+ vo.setDeviceRanks(Collections.emptyList());
+ return vo;
+ }
+
+ // =============== 趋势 ===============
+
@Override
public DisplacementTrendPageVo listTrendData(DisplacementBoardQueryBo bo) {
- VibrationTrendPageVo trend = vibrationBoardService.listTrendData(normalizeDisplacementQueryBo(bo));
- if (trend == null) {
- return new DisplacementTrendPageVo();
+ List rows = listAnalysisPageData(bo);
+ DisplacementTrendPageVo vo = new DisplacementTrendPageVo();
+ vo.setMetricField(DISPLACEMENT_FIELD);
+ vo.setMetricLabel(DISPLACEMENT_LABEL);
+ vo.setUnit(DISPLACEMENT_UNIT);
+ if (CollUtil.isEmpty(rows)) {
+ vo.setMultiDevice(Boolean.FALSE);
+ vo.setSeries(Collections.emptyList());
+ vo.setHourlyItems(Collections.emptyList());
+ return vo;
}
- DisplacementTrendPageVo result = new DisplacementTrendPageVo();
- result.setMetricField(DISPLACEMENT_FIELD);
- result.setMetricLabel(DISPLACEMENT_LABEL);
- result.setUnit(DISPLACEMENT_UNIT);
- result.setMultiDevice(trend.getMultiDevice());
- if (Boolean.FALSE.equals(trend.getMultiDevice())) {
- result.setSeries((trend.getSeries() == null ? Collections.emptyList() : trend.getSeries())
- .stream()
- .filter(item -> DISPLACEMENT_FIELD.equals(item.getField()))
- .map(this::toDisplacementTrendSeriesItem)
- .toList());
- } else {
- result.setSeries((trend.getSeries() == null ? Collections.emptyList() : trend.getSeries())
- .stream()
- .map(this::toDisplacementTrendSeriesItem)
- .toList());
- }
- result.setHourlyItems((trend.getHourlyItems() == null ? Collections.emptyList() : trend.getHourlyItems())
- .stream()
- .map(this::toDisplacementHourlyItem)
- .toList());
- return result;
+
+ Map> grouped = groupByMonitor(rows);
+ boolean multiDevice = grouped.size() > 1;
+ vo.setMultiDevice(multiDevice);
+ // 位移专属单设备场景也只出一条位移曲线(历史多指标切换已废弃),多设备场景按位移均值 TOP6 展示。
+ vo.setSeries(multiDevice ? buildDeviceSeries(grouped) : buildSingleDeviceSeries(rows));
+ vo.setHourlyItems(buildHourlyItems(rows));
+ return vo;
}
+ private List buildSingleDeviceSeries(List rows) {
+ List compressed = compressRows(rows, MAX_TREND_POINTS);
+ List points = compressed.stream()
+ .map(this::buildTrendPoint)
+ .filter(Objects::nonNull)
+ .toList();
+ if (CollUtil.isEmpty(points)) {
+ return Collections.emptyList();
+ }
+ DisplacementTrendPageVo.TrendSeriesItem series = new DisplacementTrendPageVo.TrendSeriesItem();
+ series.setName(DISPLACEMENT_LABEL);
+ series.setField(DISPLACEMENT_FIELD);
+ series.setUnit(DISPLACEMENT_UNIT);
+ series.setPoints(points);
+ return List.of(series);
+ }
+
+ private List buildDeviceSeries(Map> grouped) {
+ List profiles = buildDeviceProfiles(grouped).stream()
+ .limit(6)
+ .toList();
+ List series = new ArrayList<>();
+ for (DisplacementDeviceProfile profile : profiles) {
+ List compressed = compressRows(grouped.get(profile.monitorId()), MAX_TREND_POINTS);
+ List points = compressed.stream()
+ .map(this::buildTrendPoint)
+ .filter(Objects::nonNull)
+ .toList();
+ if (CollUtil.isEmpty(points)) {
+ continue;
+ }
+ DisplacementTrendPageVo.TrendSeriesItem item = new DisplacementTrendPageVo.TrendSeriesItem();
+ item.setName(profile.monitorName());
+ item.setField(DISPLACEMENT_FIELD);
+ item.setUnit(DISPLACEMENT_UNIT);
+ item.setPoints(points);
+ series.add(item);
+ }
+ return series;
+ }
+
+ private DisplacementTrendPageVo.TrendPointItem buildTrendPoint(RecordIotenvInstant row) {
+ if (row == null || row.getRecodeTime() == null || row.getVibrationDisplacement() == null) {
+ return null;
+ }
+ DisplacementTrendPageVo.TrendPointItem item = new DisplacementTrendPageVo.TrendPointItem();
+ item.setTime(VibrationMathUtils.formatDate(row.getRecodeTime(), VibrationMathUtils.FMT_DATETIME));
+ item.setValue(row.getVibrationDisplacement().setScale(2, RoundingMode.HALF_UP));
+ return item;
+ }
+
+ private List buildHourlyItems(List rows) {
+ Map> bucketMap = new LinkedHashMap<>();
+ for (RecordIotenvInstant row : rows) {
+ BigDecimal value = row.getVibrationDisplacement();
+ if (value == null || row.getRecodeTime() == null) {
+ continue;
+ }
+ String hour = VibrationMathUtils.formatDate(row.getRecodeTime(), VibrationMathUtils.FMT_HOUR);
+ bucketMap.computeIfAbsent(hour, key -> new ArrayList<>()).add(value);
+ }
+ return bucketMap.entrySet().stream()
+ .map(entry -> {
+ DisplacementTrendPageVo.HourlyItem item = new DisplacementTrendPageVo.HourlyItem();
+ item.setHour(entry.getKey() + ":00");
+ item.setAvgValue(VibrationMathUtils.avg(entry.getValue(), 2));
+ return item;
+ })
+ .sorted(Comparator.comparing(DisplacementTrendPageVo.HourlyItem::getHour))
+ .toList();
+ }
+
+ // =============== 对比 ===============
+
@Override
public DisplacementComparisonPageVo listComparisonData(DisplacementBoardQueryBo bo) {
- VibrationComparisonPageVo comparison = vibrationBoardService.listComparisonData(normalizeDisplacementQueryBo(bo));
- DisplacementComparisonPageVo result = new DisplacementComparisonPageVo();
- if (comparison == null) {
- return result;
+ List rows = listAnalysisPageData(bo);
+ DisplacementComparisonPageVo vo = new DisplacementComparisonPageVo();
+ vo.setMetricField(DISPLACEMENT_FIELD);
+ vo.setMetricLabel(DISPLACEMENT_LABEL);
+ vo.setUnit(DISPLACEMENT_UNIT);
+ if (CollUtil.isEmpty(rows)) {
+ vo.setRankItems(Collections.emptyList());
+ vo.setScatterItems(Collections.emptyList());
+ return vo;
}
- result.setMetricField(DISPLACEMENT_FIELD);
- result.setMetricLabel(DISPLACEMENT_LABEL);
- result.setUnit(DISPLACEMENT_UNIT);
- result.setRankItems((comparison.getRankItems() == null ? Collections.emptyList() : comparison.getRankItems())
- .stream()
- .map(this::toDisplacementRankItem)
- .toList());
- result.setScatterItems((comparison.getScatterItems() == null ? Collections.emptyList() : comparison.getScatterItems())
- .stream()
- .map(this::toDisplacementScatterItem)
- .toList());
- return result;
+ List profiles = buildDeviceProfiles(groupByMonitor(rows));
+ vo.setRankItems(profiles.stream().limit(MAX_COMPARE_DEVICES).map(profile -> {
+ DisplacementComparisonPageVo.RankItem item = new DisplacementComparisonPageVo.RankItem();
+ item.setMonitorId(profile.monitorId());
+ item.setMonitorName(profile.monitorName());
+ item.setAvg(profile.avg());
+ item.setLatest(profile.latest());
+ return item;
+ }).toList());
+ vo.setScatterItems(profiles.stream().limit(MAX_COMPARE_DEVICES).map(profile -> {
+ DisplacementComparisonPageVo.ScatterItem item = new DisplacementComparisonPageVo.ScatterItem();
+ item.setMonitorId(profile.monitorId());
+ item.setMonitorName(profile.monitorName());
+ item.setAvg(profile.avg());
+ item.setMax(profile.max());
+ item.setSampleCount(profile.sampleCount());
+ return item;
+ }).toList());
+ return vo;
}
+ // =============== 质量 ===============
+
@Override
public DisplacementQualityPageVo listQualityData(DisplacementBoardQueryBo bo) {
List rows = listQualityPageData(bo);
@@ -147,8 +305,7 @@ public class DisplacementBoardServiceImpl implements IDisplacementBoardService {
vo.setMetricQualityItems(Collections.emptyList());
return vo;
}
-
- int validCount = countValidDisplacementRows(rows);
+ int validCount = filterValidDisplacementRows(rows).size();
int sampleCount = rows.size();
int invalidCount = sampleCount - validCount;
BigDecimal validRate = divideRate(validCount, sampleCount);
@@ -159,111 +316,326 @@ public class DisplacementBoardServiceImpl implements IDisplacementBoardService {
vo.setInvalidCount(invalidCount);
vo.setValidRate(validRate);
vo.setInvalidRate(divideRate(invalidCount, sampleCount));
- vo.setMetricQualityItems(List.of(buildDisplacementQualityItem(validCount, sampleCount)));
+ vo.setMetricQualityItems(List.of(buildQualityMetricItem(validCount, sampleCount)));
return vo;
}
+ private DisplacementQualityPageVo.MetricQualityItem buildQualityMetricItem(int validCount, int sampleCount) {
+ DisplacementQualityPageVo.MetricQualityItem item = new DisplacementQualityPageVo.MetricQualityItem();
+ item.setField(DISPLACEMENT_FIELD);
+ item.setLabel(DISPLACEMENT_LABEL);
+ item.setUnit(DISPLACEMENT_UNIT);
+ item.setValidCount(validCount);
+ item.setValidRate(divideRate(validCount, sampleCount));
+ return item;
+ }
+
+ // =============== 分布 ===============
+
@Override
public DisplacementDistributionPageVo listDistributionData(DisplacementBoardQueryBo bo) {
- VibrationDistributionPageVo distribution = vibrationBoardService.listDistributionData(normalizeDisplacementQueryBo(bo));
- DisplacementDistributionPageVo result = new DisplacementDistributionPageVo();
- if (distribution == null) {
- return result;
+ List rows = listAnalysisPageData(bo);
+ DisplacementDistributionPageVo vo = new DisplacementDistributionPageVo();
+ vo.setMetricField(DISPLACEMENT_FIELD);
+ vo.setMetricLabel(DISPLACEMENT_LABEL);
+ vo.setUnit(DISPLACEMENT_UNIT);
+ if (CollUtil.isEmpty(rows)) {
+ vo.setIntervalBuckets(Collections.emptyList());
+ vo.setHistogramBuckets(Collections.emptyList());
+ vo.setCalendarHeatmap(Collections.emptyList());
+ vo.setHourlyHeatmap(Collections.emptyList());
+ return vo;
}
- result.setMetricField(DISPLACEMENT_FIELD);
- result.setMetricLabel(DISPLACEMENT_LABEL);
- result.setUnit(DISPLACEMENT_UNIT);
- result.setIntervalBuckets((distribution.getIntervalBuckets() == null ? Collections.emptyList() : distribution.getIntervalBuckets())
- .stream()
- .map(this::toDisplacementIntervalBucketItem)
+ List sortedValues = rows.stream()
+ .map(RecordIotenvInstant::getVibrationDisplacement)
+ .filter(Objects::nonNull)
+ .sorted()
+ .toList();
+ if (CollUtil.isEmpty(sortedValues)) {
+ vo.setIntervalBuckets(Collections.emptyList());
+ vo.setHistogramBuckets(Collections.emptyList());
+ vo.setCalendarHeatmap(Collections.emptyList());
+ vo.setHourlyHeatmap(Collections.emptyList());
+ return vo;
+ }
+ // 聚合器输出的是振动 VO 结构,这里做一次 shape 一致的 field copy 得到位移 VO,
+ // 避免聚合器重复两套代码。关键点:聚合器本身不会触发任何 SQL,也不会做多指标计算。
+ vo.setIntervalBuckets(DISTRIBUTION_AGGREGATOR.buildIntervalBuckets(sortedValues).stream()
+ .map(this::toIntervalBucket)
.toList());
- result.setHistogramBuckets((distribution.getHistogramBuckets() == null ? Collections.emptyList() : distribution.getHistogramBuckets())
- .stream()
- .map(this::toDisplacementHistogramBucketItem)
+ vo.setHistogramBuckets(DISTRIBUTION_AGGREGATOR.buildHistogramBuckets(sortedValues, 10).stream()
+ .map(this::toHistogramBucket)
.toList());
- result.setCalendarHeatmap((distribution.getCalendarHeatmap() == null ? Collections.emptyList() : distribution.getCalendarHeatmap())
- .stream()
- .map(this::toDisplacementCalendarHeatmapItem)
- .toList());
- result.setHourlyHeatmap((distribution.getHourlyHeatmap() == null ? Collections.emptyList() : distribution.getHourlyHeatmap())
- .stream()
- .map(this::toDisplacementHourlyHeatmapItem)
- .toList());
- return result;
+ vo.setCalendarHeatmap(DISTRIBUTION_AGGREGATOR.buildCalendarHeatmap(rows, RecordIotenvInstant::getVibrationDisplacement)
+ .stream().map(this::toCalendarHeatmap).toList());
+ vo.setHourlyHeatmap(DISTRIBUTION_AGGREGATOR.buildHourlyHeatmap(rows, RecordIotenvInstant::getVibrationDisplacement)
+ .stream().map(this::toHourlyHeatmap).toList());
+ return vo;
}
+ private DisplacementDistributionPageVo.IntervalBucketItem toIntervalBucket(VibrationDistributionPageVo.IntervalBucketItem source) {
+ DisplacementDistributionPageVo.IntervalBucketItem item = new DisplacementDistributionPageVo.IntervalBucketItem();
+ item.setLabel(source.getLabel());
+ item.setCount(source.getCount());
+ return item;
+ }
+
+ private DisplacementDistributionPageVo.HistogramBucketItem toHistogramBucket(VibrationDistributionPageVo.HistogramBucketItem source) {
+ DisplacementDistributionPageVo.HistogramBucketItem item = new DisplacementDistributionPageVo.HistogramBucketItem();
+ item.setStartValue(source.getStartValue());
+ item.setEndValue(source.getEndValue());
+ item.setCount(source.getCount());
+ return item;
+ }
+
+ private DisplacementDistributionPageVo.CalendarHeatmapItem toCalendarHeatmap(VibrationDistributionPageVo.CalendarHeatmapItem source) {
+ DisplacementDistributionPageVo.CalendarHeatmapItem item = new DisplacementDistributionPageVo.CalendarHeatmapItem();
+ item.setStatDate(source.getStatDate());
+ item.setAvgValue(source.getAvgValue());
+ return item;
+ }
+
+ private DisplacementDistributionPageVo.HourlyHeatmapItem toHourlyHeatmap(VibrationDistributionPageVo.HourlyHeatmapItem source) {
+ DisplacementDistributionPageVo.HourlyHeatmapItem item = new DisplacementDistributionPageVo.HourlyHeatmapItem();
+ item.setStatDate(source.getStatDate());
+ item.setStatHour(source.getStatHour());
+ item.setAvgValue(source.getAvgValue());
+ return item;
+ }
+
+ // =============== 异常 ===============
+
@Override
public DisplacementAnomalyPageVo listAnomalyData(DisplacementBoardQueryBo bo) {
- VibrationAnomalyPageVo anomaly = vibrationBoardService.listAnomalyData(normalizeDisplacementQueryBo(bo));
- DisplacementAnomalyPageVo result = new DisplacementAnomalyPageVo();
- if (anomaly == null) {
- return result;
+ List rows = listAnalysisPageData(bo);
+ ThresholdProfile thresholds = resolveThresholdProfile(bo);
+
+ DisplacementAnomalyPageVo vo = new DisplacementAnomalyPageVo();
+ vo.setMetricField(DISPLACEMENT_FIELD);
+ vo.setMetricLabel(DISPLACEMENT_LABEL);
+ vo.setUnit(DISPLACEMENT_UNIT);
+ vo.setHighThreshold(thresholds.highThreshold());
+ vo.setWarningThreshold(thresholds.warningThreshold());
+ vo.setRapidRiseThreshold(thresholds.rapidRiseThreshold());
+ vo.setStddevThreshold(thresholds.stddevThreshold());
+ vo.setMinContinuousSamples(thresholds.minContinuousSamples());
+
+ if (CollUtil.isEmpty(rows)) {
+ vo.setHighEventCount(0);
+ vo.setContinuousEventCount(0);
+ vo.setRapidRiseEventCount(0);
+ vo.setJitterEventCount(0);
+ vo.setHighEvents(Collections.emptyList());
+ vo.setContinuousEvents(Collections.emptyList());
+ vo.setRapidRiseEvents(Collections.emptyList());
+ vo.setJitterEvents(Collections.emptyList());
+ return vo;
}
- result.setMetricField(DISPLACEMENT_FIELD);
- result.setMetricLabel(DISPLACEMENT_LABEL);
- result.setUnit(DISPLACEMENT_UNIT);
- result.setHighThreshold(anomaly.getHighThreshold());
- result.setWarningThreshold(anomaly.getWarningThreshold());
- result.setRapidRiseThreshold(anomaly.getRapidRiseThreshold());
- result.setStddevThreshold(anomaly.getStddevThreshold());
- result.setMinContinuousSamples(anomaly.getMinContinuousSamples());
- result.setHighEventCount(anomaly.getHighEventCount());
- result.setContinuousEventCount(anomaly.getContinuousEventCount());
- result.setRapidRiseEventCount(anomaly.getRapidRiseEventCount());
- result.setJitterEventCount(anomaly.getJitterEventCount());
- result.setHighEvents((anomaly.getHighEvents() == null ? Collections.emptyList() : anomaly.getHighEvents())
- .stream()
- .map(this::toDisplacementHighEventItem)
- .toList());
- result.setContinuousEvents((anomaly.getContinuousEvents() == null ? Collections.emptyList() : anomaly.getContinuousEvents())
- .stream()
- .map(this::toDisplacementContinuousEventItem)
- .toList());
- result.setRapidRiseEvents((anomaly.getRapidRiseEvents() == null ? Collections.emptyList() : anomaly.getRapidRiseEvents())
- .stream()
- .map(this::toDisplacementRapidRiseEventItem)
- .toList());
- result.setJitterEvents((anomaly.getJitterEvents() == null ? Collections.emptyList() : anomaly.getJitterEvents())
- .stream()
- .map(this::toDisplacementJitterEventItem)
- .toList());
- return result;
+
+ List high = ANOMALY_ANALYZER.buildHighEvents(
+ rows, RecordIotenvInstant::getVibrationDisplacement, thresholds.highThreshold());
+ List rapid = ANOMALY_ANALYZER.buildRapidRiseEvents(
+ rows, RecordIotenvInstant::getVibrationDisplacement, thresholds.rapidRiseThreshold());
+ List continuous = ANOMALY_ANALYZER.buildContinuousEvents(
+ rows, RecordIotenvInstant::getVibrationDisplacement, thresholds.warningThreshold(), thresholds.minContinuousSamples());
+ List jitter = ANOMALY_ANALYZER.buildJitterEvents(
+ rows, RecordIotenvInstant::getVibrationDisplacement, thresholds.stddevThreshold());
+
+ vo.setHighEventCount(high.size());
+ vo.setContinuousEventCount(continuous.size());
+ vo.setRapidRiseEventCount(rapid.size());
+ vo.setJitterEventCount(jitter.size());
+ vo.setHighEvents(high.stream().map(this::toHighEvent).toList());
+ vo.setContinuousEvents(continuous.stream().map(this::toContinuousEvent).toList());
+ vo.setRapidRiseEvents(rapid.stream().map(this::toRapidRiseEvent).toList());
+ vo.setJitterEvents(jitter.stream().map(this::toJitterEvent).toList());
+ return vo;
}
- @Override
- public DisplacementAdvancedPageVo listAdvancedData(DisplacementBoardQueryBo bo) {
- VibrationAdvancedPageVo advanced = vibrationBoardService.listAdvancedData(normalizeDisplacementQueryBo(bo));
- if (advanced == null) {
- return new DisplacementAdvancedPageVo();
- }
- DisplacementAdvancedPageVo result = new DisplacementAdvancedPageVo();
- result.setMetricField(DISPLACEMENT_FIELD);
- result.setMetricLabel(DISPLACEMENT_LABEL);
- result.setUnit(DISPLACEMENT_UNIT);
- result.setLowBandUpper(advanced.getLowBandUpper());
- result.setFocusBandUpper(advanced.getFocusBandUpper());
- result.setSankeyNodes((advanced.getSankeyNodes() == null ? Collections.emptyList() : advanced.getSankeyNodes())
- .stream()
- .map(this::toDisplacementSankeyNodeItem)
- .toList());
- result.setSankeyLinks((advanced.getSankeyLinks() == null ? Collections.emptyList() : advanced.getSankeyLinks())
- .stream()
- .map(this::toDisplacementSankeyLinkItem)
- .toList());
- result.setTreemapItems((advanced.getTreemapItems() == null ? Collections.emptyList() : advanced.getTreemapItems())
- .stream()
- .map(this::toDisplacementTreemapItem)
- .toList());
- // 位移专属高级页只保留风险分带分析,不再输出四指标平行坐标画像。
- result.setParallelAxes(Collections.emptyList());
- result.setParallelSeries(Collections.emptyList());
- return result;
+ private DisplacementAnomalyPageVo.HighEventItem toHighEvent(VibrationAnomalyPageVo.HighEventItem source) {
+ DisplacementAnomalyPageVo.HighEventItem item = new DisplacementAnomalyPageVo.HighEventItem();
+ item.setMonitorId(source.getMonitorId());
+ item.setMonitorName(source.getMonitorName());
+ item.setValue(source.getValue());
+ item.setRecodeTime(source.getRecodeTime());
+ return item;
+ }
+
+ private DisplacementAnomalyPageVo.ContinuousEventItem toContinuousEvent(VibrationAnomalyPageVo.ContinuousEventItem source) {
+ DisplacementAnomalyPageVo.ContinuousEventItem item = new DisplacementAnomalyPageVo.ContinuousEventItem();
+ item.setMonitorId(source.getMonitorId());
+ item.setMonitorName(source.getMonitorName());
+ item.setStartTime(source.getStartTime());
+ item.setEndTime(source.getEndTime());
+ item.setMaxValue(source.getMaxValue());
+ item.setSampleCount(source.getSampleCount());
+ return item;
+ }
+
+ private DisplacementAnomalyPageVo.RapidRiseEventItem toRapidRiseEvent(VibrationAnomalyPageVo.RapidRiseEventItem source) {
+ DisplacementAnomalyPageVo.RapidRiseEventItem item = new DisplacementAnomalyPageVo.RapidRiseEventItem();
+ item.setMonitorId(source.getMonitorId());
+ item.setMonitorName(source.getMonitorName());
+ item.setDiff(source.getDiff());
+ item.setRecodeTime(source.getRecodeTime());
+ return item;
+ }
+
+ private DisplacementAnomalyPageVo.JitterEventItem toJitterEvent(VibrationAnomalyPageVo.JitterEventItem source) {
+ DisplacementAnomalyPageVo.JitterEventItem item = new DisplacementAnomalyPageVo.JitterEventItem();
+ item.setMonitorId(source.getMonitorId());
+ item.setMonitorName(source.getMonitorName());
+ item.setHourBucket(source.getHourBucket());
+ item.setStddev(source.getStddev());
+ item.setSampleCount(source.getSampleCount());
+ return item;
}
/**
- * 质量页底层查询:只在本类内部使用,避免把位移专属分母口径继续混进振动通用服务。
+ * 异常阈值解析——位移默认值基于工程经验:
+ * 300um 高阈、100um 预警、30um 突变、20um 抖动,前端可覆盖。
+ */
+ private ThresholdProfile resolveThresholdProfile(DisplacementBoardQueryBo bo) {
+ return new ThresholdProfile(
+ toScaledBigDecimal(bo.getHighThreshold(), BigDecimal.valueOf(300D)),
+ toScaledBigDecimal(bo.getWarningThreshold(), BigDecimal.valueOf(100D)),
+ toScaledBigDecimal(bo.getRapidRiseThreshold(), BigDecimal.valueOf(30D)),
+ toScaledBigDecimal(bo.getStddevThreshold(), BigDecimal.valueOf(20D)),
+ bo.getMinContinuousSamples() == null ? 3 : bo.getMinContinuousSamples()
+ );
+ }
+
+ private BigDecimal toScaledBigDecimal(BigDecimal value, BigDecimal defaultValue) {
+ return (value == null ? defaultValue : value).setScale(2, RoundingMode.HALF_UP);
+ }
+
+ // =============== 高级 ===============
+
+ @Override
+ public DisplacementAdvancedPageVo listAdvancedData(DisplacementBoardQueryBo bo) {
+ List rows = listAnalysisPageData(bo);
+ DisplacementAdvancedPageVo vo = new DisplacementAdvancedPageVo();
+ vo.setMetricField(DISPLACEMENT_FIELD);
+ vo.setMetricLabel(DISPLACEMENT_LABEL);
+ vo.setUnit(DISPLACEMENT_UNIT);
+ // 位移专属高级页只保留风险分带,不再输出四指标平行坐标画像。
+ vo.setParallelAxes(Collections.emptyList());
+ vo.setParallelSeries(Collections.emptyList());
+ if (CollUtil.isEmpty(rows)) {
+ vo.setSankeyNodes(Collections.emptyList());
+ vo.setSankeyLinks(Collections.emptyList());
+ vo.setTreemapItems(Collections.emptyList());
+ return vo;
+ }
+ List profiles = buildDeviceProfiles(groupByMonitor(rows));
+ if (CollUtil.isEmpty(profiles)) {
+ vo.setSankeyNodes(Collections.emptyList());
+ vo.setSankeyLinks(Collections.emptyList());
+ vo.setTreemapItems(Collections.emptyList());
+ return vo;
+ }
+
+ List sortedAverages = profiles.stream()
+ .map(DisplacementDeviceProfile::avg)
+ .filter(Objects::nonNull)
+ .sorted()
+ .toList();
+ BigDecimal lowBandUpper = percentile(sortedAverages, 0.33D);
+ BigDecimal focusBandUpper = percentile(sortedAverages, 0.66D);
+ vo.setLowBandUpper(lowBandUpper);
+ vo.setFocusBandUpper(focusBandUpper);
+ vo.setSankeyNodes(buildSankeyNodes(profiles, lowBandUpper, focusBandUpper));
+ vo.setSankeyLinks(buildSankeyLinks(profiles, lowBandUpper, focusBandUpper));
+ vo.setTreemapItems(buildTreemapItems(profiles, lowBandUpper, focusBandUpper));
+ return vo;
+ }
+
+ private List buildSankeyNodes(
+ List profiles, BigDecimal lowBandUpper, BigDecimal focusBandUpper) {
+ Set nodeNames = new LinkedHashSet<>();
+ profiles.stream().limit(MAX_SANKEY_DEVICES).forEach(profile -> {
+ nodeNames.add(buildDeviceDisplayName(profile));
+ nodeNames.add(resolveStage(profile.avg(), lowBandUpper, focusBandUpper));
+ });
+ return nodeNames.stream().map(name -> {
+ DisplacementAdvancedPageVo.SankeyNodeItem item = new DisplacementAdvancedPageVo.SankeyNodeItem();
+ item.setName(name);
+ return item;
+ }).toList();
+ }
+
+ private List buildSankeyLinks(
+ List profiles, BigDecimal lowBandUpper, BigDecimal focusBandUpper) {
+ return profiles.stream().limit(MAX_SANKEY_DEVICES).map(profile -> {
+ DisplacementAdvancedPageVo.SankeyLinkItem item = new DisplacementAdvancedPageVo.SankeyLinkItem();
+ item.setSource(buildDeviceDisplayName(profile));
+ item.setTarget(resolveStage(profile.avg(), lowBandUpper, focusBandUpper));
+ item.setValue(profile.sampleCount());
+ return item;
+ }).toList();
+ }
+
+ private List buildTreemapItems(
+ List profiles, BigDecimal lowBandUpper, BigDecimal focusBandUpper) {
+ return profiles.stream().limit(MAX_TREEMAP_DEVICES).map(profile -> {
+ DisplacementAdvancedPageVo.TreemapItem item = new DisplacementAdvancedPageVo.TreemapItem();
+ item.setName(buildDeviceDisplayName(profile));
+ item.setValue(profile.avg());
+ item.setLevelTag(resolveStage(profile.avg(), lowBandUpper, focusBandUpper));
+ return item;
+ }).toList();
+ }
+
+ private String resolveStage(BigDecimal value, BigDecimal lowBandUpper, BigDecimal focusBandUpper) {
+ if (value == null) {
+ return "平稳";
+ }
+ if (value.compareTo(lowBandUpper) <= 0) {
+ return "平稳";
+ }
+ if (value.compareTo(focusBandUpper) <= 0) {
+ return "关注";
+ }
+ return "高位";
+ }
+
+ private String buildDeviceDisplayName(DisplacementDeviceProfile profile) {
+ if (StrUtil.isBlank(profile.monitorName())) {
+ return profile.monitorId();
+ }
+ return profile.monitorName() + "(" + profile.monitorId() + ")";
+ }
+
+ // =============== 底层查询 ===============
+
+ /**
+ * 分析类底层查询:SQL 层已经过滤 vibration_displacement 有效值,
+ * 专供 trend / comparison / distribution / anomaly / advanced 等分析页面。
+ */
+ private List listAnalysisPageData(DisplacementBoardQueryBo bo) {
+ QueryContext ctx = prepareQueryContext(bo);
+ if (ctx == null) {
+ return Collections.emptyList();
+ }
+ return queryAnalysisByBatches(ctx.tableNames(), ctx.query());
+ }
+
+ /**
+ * 质量页 & 总览页底层查询:SQL 层不过滤位移有效值,
+ * 以保证 sampleCount 代表“本次查询实际采到的样本总数”。
*/
private List listQualityPageData(DisplacementBoardQueryBo bo) {
+ QueryContext ctx = prepareQueryContext(bo);
+ if (ctx == null) {
+ return Collections.emptyList();
+ }
+ return queryQualityByBatches(ctx.tableNames(), ctx.query());
+ }
+
+ /**
+ * 参数解析 + 分表路由 + Fail Fast 校验,统一在一处完成,避免各页面重复实现。
+ */
+ private QueryContext prepareQueryContext(DisplacementBoardQueryBo bo) {
if (bo == null) {
throw new ServiceException("查询参数不能为空");
}
@@ -276,60 +648,112 @@ public class DisplacementBoardServiceImpl implements IDisplacementBoardService {
List monitorIds = normalizeMonitorIds(bo);
if (CollUtil.isEmpty(monitorIds)) {
- throw new ServiceException("请选择至少一个振动设备");
+ throw new ServiceException("请选择至少一个位移设备");
}
- VibrationBoardQueryBo query = normalizeDisplacementQueryBo(bo);
- query.setMonitorId(monitorIds.size() == 1 ? monitorIds.get(0) : null);
- query.setMonitorIds(monitorIds.size() > 1 ? monitorIds : null);
+ DisplacementBoardQueryBo query = normalizeQueryBo(bo, monitorIds);
validateEstimatedQueryRows(beginTime, endTime, monitorIds.size(), query.getSamplingInterval());
List tableNames = recordIotenvPartitionService.resolveTables(beginTime, endTime);
if (CollUtil.isEmpty(tableNames)) {
- return Collections.emptyList();
+ return null;
}
validateResolvedTableNames(tableNames);
- return queryQualityDataByTableBatches(tableNames, query);
+ return new QueryContext(tableNames, query);
}
- private List queryQualityDataByTableBatches(List tableNames, VibrationBoardQueryBo query) {
+ private List queryAnalysisByBatches(List tableNames, DisplacementBoardQueryBo query) {
if (tableNames.size() <= MAX_UNION_TABLES) {
- return executeSingleBatchQualityQuery(tableNames, query);
+ return executeAnalysisBatch(tableNames, query);
}
List mergedRows = new ArrayList<>();
for (int index = 0; index < tableNames.size(); index += MAX_UNION_TABLES) {
int endIndex = Math.min(tableNames.size(), index + MAX_UNION_TABLES);
- List batchTableNames = tableNames.subList(index, endIndex);
- List batchRows = executeSingleBatchQualityQuery(batchTableNames, query);
+ List batchRows = executeAnalysisBatch(tableNames.subList(index, endIndex), query);
if (CollUtil.isNotEmpty(batchRows)) {
mergedRows.addAll(batchRows);
}
}
- mergedRows.sort(Comparator.comparing(RecordIotenvInstant::getMonitorId, Comparator.nullsFirst(Comparator.naturalOrder()))
- .thenComparing(RecordIotenvInstant::getRecodeTime, Comparator.nullsFirst(Comparator.naturalOrder()))
- .thenComparing(RecordIotenvInstant::getObjid, Comparator.nullsFirst(Comparator.naturalOrder())));
+ mergedRows.sort(ROW_STABLE_ORDER);
return mergedRows;
}
- private List executeSingleBatchQualityQuery(List batchTableNames, VibrationBoardQueryBo query) {
- if (query.getSamplingInterval() != null && query.getSamplingInterval() > 1) {
- return vibrationBoardMapper.selectQualitySampledData(batchTableNames, query);
+ private List queryQualityByBatches(List tableNames, DisplacementBoardQueryBo query) {
+ if (tableNames.size() <= MAX_UNION_TABLES) {
+ return executeQualityBatch(tableNames, query);
}
- return vibrationBoardMapper.selectQualityRawData(batchTableNames, query);
+ List mergedRows = new ArrayList<>();
+ for (int index = 0; index < tableNames.size(); index += MAX_UNION_TABLES) {
+ int endIndex = Math.min(tableNames.size(), index + MAX_UNION_TABLES);
+ List batchRows = executeQualityBatch(tableNames.subList(index, endIndex), query);
+ if (CollUtil.isNotEmpty(batchRows)) {
+ mergedRows.addAll(batchRows);
+ }
+ }
+ mergedRows.sort(ROW_STABLE_ORDER);
+ return mergedRows;
}
- private VibrationBoardQueryBo normalizeDisplacementQueryBo(DisplacementBoardQueryBo source) {
- if (source == null) {
- throw new ServiceException("查询参数不能为空");
+ private List executeAnalysisBatch(List batchTableNames, DisplacementBoardQueryBo query) {
+ if (query.getSamplingInterval() != null && query.getSamplingInterval() > 1) {
+ return displacementBoardMapper.selectSampledData(batchTableNames, query);
}
- VibrationBoardQueryBo query = new VibrationBoardQueryBo();
- query.setMonitorId(source.getMonitorId());
- query.setMonitorIds(source.getMonitorIds());
+ return displacementBoardMapper.selectRawData(batchTableNames, query);
+ }
+
+ private List executeQualityBatch(List batchTableNames, DisplacementBoardQueryBo query) {
+ if (query.getSamplingInterval() != null && query.getSamplingInterval() > 1) {
+ return displacementBoardMapper.selectQualitySampledData(batchTableNames, query);
+ }
+ return displacementBoardMapper.selectQualityRawData(batchTableNames, query);
+ }
+
+ private static final Comparator ROW_STABLE_ORDER =
+ Comparator.comparing(RecordIotenvInstant::getMonitorId, Comparator.nullsFirst(Comparator.naturalOrder()))
+ .thenComparing(RecordIotenvInstant::getRecodeTime, Comparator.nullsFirst(Comparator.naturalOrder()))
+ .thenComparing(RecordIotenvInstant::getObjid, Comparator.nullsFirst(Comparator.naturalOrder()));
+
+ // =============== Fail Fast 校验 ===============
+
+ private void validateQuerySpan(Date beginTime, Date endTime) {
+ long diffMs = endTime.getTime() - beginTime.getTime();
+ long diffDays = diffMs / (24L * 3600L * 1000L);
+ if (diffDays > MAX_QUERY_DAYS) {
+ throw new ServiceException("查询跨度不能超过" + MAX_QUERY_DAYS + "天,请缩小时间范围");
+ }
+ }
+
+ private void validateEstimatedQueryRows(Date beginTime, Date endTime, int monitorCount, Integer samplingInterval) {
+ int effectiveSamplingInterval = normalizeSamplingInterval(samplingInterval);
+ long diffMinutes = Math.max(1L, (endTime.getTime() - beginTime.getTime()) / (60L * 1000L) + 1L);
+ long rowsPerMonitor = (long) Math.ceil((double) diffMinutes / effectiveSamplingInterval);
+ long estimatedRows = rowsPerMonitor * Math.max(1, monitorCount);
+ if (estimatedRows > MAX_ESTIMATED_QUERY_ROWS) {
+ long recommendedSamplingInterval = Math.max(1L,
+ (long) Math.ceil((double) diffMinutes * Math.max(1, monitorCount) / MAX_ESTIMATED_QUERY_ROWS));
+ throw new ServiceException("当前查询预计返回约" + estimatedRows
+ + "条记录,超过系统上限" + MAX_ESTIMATED_QUERY_ROWS
+ + "条,请将抽样间隔至少调整为" + recommendedSamplingInterval + "分钟,或缩小时间范围/设备范围");
+ }
+ }
+
+ private void validateResolvedTableNames(List tableNames) {
+ for (String tableName : tableNames) {
+ if (!TABLE_NAME_PATTERN.matcher(tableName).matches()) {
+ throw new ServiceException("非法分表名称:" + tableName);
+ }
+ }
+ }
+
+ // =============== 参数归一化 ===============
+
+ private DisplacementBoardQueryBo normalizeQueryBo(DisplacementBoardQueryBo source, List monitorIds) {
+ DisplacementBoardQueryBo query = new DisplacementBoardQueryBo();
query.setBeginRecordTime(source.getBeginRecordTime());
query.setEndRecordTime(source.getEndRecordTime());
+ query.setMonitorId(monitorIds.size() == 1 ? monitorIds.get(0) : null);
+ query.setMonitorIds(monitorIds.size() > 1 ? monitorIds : null);
query.setSamplingInterval(normalizeSamplingInterval(source.getSamplingInterval()));
- // 位移专属接口在入参层强制收口,避免错误传参被悄悄转成其它振动指标。
- query.setVibrationParam(DISPLACEMENT_FIELD);
query.setHighThreshold(source.getHighThreshold());
query.setWarningThreshold(source.getWarningThreshold());
query.setMinContinuousSamples(source.getMinContinuousSamples());
@@ -338,277 +762,6 @@ public class DisplacementBoardServiceImpl implements IDisplacementBoardService {
return query;
}
- private int countDistinctMonitors(List rows) {
- return (int) rows.stream()
- .map(RecordIotenvInstant::getMonitorId)
- .filter(StrUtil::isNotBlank)
- .distinct()
- .count();
- }
-
- private int countValidDisplacementRows(List rows) {
- return (int) rows.stream()
- .map(RecordIotenvInstant::getVibrationDisplacement)
- .filter(value -> value != null && value.compareTo(BigDecimal.ZERO) > 0)
- .count();
- }
-
- private BigDecimal zeroRate() {
- return BigDecimal.ZERO.setScale(4, RoundingMode.HALF_UP);
- }
-
- private BigDecimal divideRate(int numerator, int denominator) {
- if (denominator <= 0) {
- return zeroRate();
- }
- return BigDecimal.valueOf(numerator).divide(BigDecimal.valueOf(denominator), 4, RoundingMode.HALF_UP);
- }
-
- private DisplacementQualityPageVo.MetricQualityItem buildDisplacementQualityItem(int validCount, int sampleCount) {
- DisplacementQualityPageVo.MetricQualityItem item = new DisplacementQualityPageVo.MetricQualityItem();
- item.setField(DISPLACEMENT_FIELD);
- item.setLabel(DISPLACEMENT_LABEL);
- item.setUnit(DISPLACEMENT_UNIT);
- item.setValidCount(validCount);
- item.setValidRate(divideRate(validCount, sampleCount));
- return item;
- }
-
- private List buildDisplacementGaugeItems(
- List metricCards,
- VibrationOverviewPageVo.PrimaryMetricStats primaryMetricStats,
- Integer deviceCount
- ) {
- if (CollUtil.isNotEmpty(metricCards)) {
- VibrationOverviewPageVo.MetricCardItem card = metricCards.get(0);
- BigDecimal value = deviceCount != null && deviceCount > 1 ? card.getAvg() : card.getLatest();
- String name = deviceCount != null && deviceCount > 1 ? "群组均值" : card.getLabel();
- return List.of(buildGaugeItem(name, value, card.getMax(), card.getUnit()));
- }
- if (primaryMetricStats == null) {
- return Collections.emptyList();
- }
- BigDecimal value = deviceCount != null && deviceCount > 1 ? primaryMetricStats.getAvg() : primaryMetricStats.getLatest();
- String name = deviceCount != null && deviceCount > 1 ? "群组均值" : DISPLACEMENT_LABEL;
- return List.of(buildGaugeItem(name, value, primaryMetricStats.getMax(), DISPLACEMENT_UNIT));
- }
-
- private DisplacementOverviewPageVo buildDisplacementOverviewVo(
- VibrationOverviewPageVo overview,
- DisplacementQualityPageVo quality,
- List displacementCards
- ) {
- DisplacementOverviewPageVo result = new DisplacementOverviewPageVo();
- result.setMetricField(DISPLACEMENT_FIELD);
- result.setMetricLabel(DISPLACEMENT_LABEL);
- result.setUnit(DISPLACEMENT_UNIT);
- result.setSampleCount(quality.getSampleCount());
- result.setDeviceCount(quality.getDeviceCount());
- result.setCoverageRate(quality.getCoverageRate());
- result.setMetricCards(displacementCards.stream().map(this::toDisplacementMetricCardItem).toList());
- result.setPrimaryMetricStats(toDisplacementPrimaryMetricStats(overview.getPrimaryMetricStats()));
- result.setDeviceRanks((overview.getDeviceRanks() == null ? Collections.emptyList() : overview.getDeviceRanks())
- .stream()
- .map(this::toDisplacementDeviceRankItem)
- .toList());
- result.setGaugeItems(buildDisplacementGaugeItems(displacementCards, overview.getPrimaryMetricStats(), quality.getDeviceCount()));
- return result;
- }
-
- private DisplacementOverviewPageVo.GaugeItem buildGaugeItem(String name, BigDecimal value, BigDecimal maxValue, String unit) {
- DisplacementOverviewPageVo.GaugeItem item = new DisplacementOverviewPageVo.GaugeItem();
- item.setName(name);
- item.setValue(value);
- item.setMaxValue(maxValue == null ? BigDecimal.ONE : maxValue.multiply(BigDecimal.valueOf(1.2D)).setScale(2, RoundingMode.HALF_UP));
- item.setUnit(unit);
- return item;
- }
-
- private DisplacementOverviewPageVo.MetricCardItem toDisplacementMetricCardItem(VibrationOverviewPageVo.MetricCardItem source) {
- DisplacementOverviewPageVo.MetricCardItem item = new DisplacementOverviewPageVo.MetricCardItem();
- item.setField(source.getField());
- item.setLabel(source.getLabel());
- item.setUnit(source.getUnit());
- item.setLatest(source.getLatest());
- item.setAvg(source.getAvg());
- item.setMax(source.getMax());
- return item;
- }
-
- private DisplacementOverviewPageVo.PrimaryMetricStats toDisplacementPrimaryMetricStats(VibrationOverviewPageVo.PrimaryMetricStats source) {
- DisplacementOverviewPageVo.PrimaryMetricStats stats = new DisplacementOverviewPageVo.PrimaryMetricStats();
- if (source == null) {
- return stats;
- }
- stats.setLatest(source.getLatest());
- stats.setMin(source.getMin());
- stats.setAvg(source.getAvg());
- stats.setMax(source.getMax());
- return stats;
- }
-
- private DisplacementOverviewPageVo.DeviceRankItem toDisplacementDeviceRankItem(VibrationOverviewPageVo.DeviceRankItem source) {
- DisplacementOverviewPageVo.DeviceRankItem item = new DisplacementOverviewPageVo.DeviceRankItem();
- item.setMonitorId(source.getMonitorId());
- item.setMonitorName(source.getMonitorName());
- item.setAvg(source.getAvg());
- item.setLatest(source.getLatest());
- item.setMax(source.getMax());
- item.setSampleCount(source.getSampleCount());
- return item;
- }
-
- private DisplacementTrendPageVo.TrendSeriesItem toDisplacementTrendSeriesItem(VibrationTrendPageVo.TrendSeriesItem source) {
- DisplacementTrendPageVo.TrendSeriesItem item = new DisplacementTrendPageVo.TrendSeriesItem();
- item.setName(source.getName());
- item.setField(source.getField());
- item.setUnit(source.getUnit());
- item.setPoints((source.getPoints() == null ? Collections.emptyList() : source.getPoints())
- .stream()
- .map(this::toDisplacementTrendPointItem)
- .toList());
- return item;
- }
-
- private DisplacementTrendPageVo.TrendPointItem toDisplacementTrendPointItem(VibrationTrendPageVo.TrendPointItem source) {
- DisplacementTrendPageVo.TrendPointItem item = new DisplacementTrendPageVo.TrendPointItem();
- item.setTime(source.getTime());
- item.setValue(source.getValue());
- return item;
- }
-
- private DisplacementTrendPageVo.HourlyItem toDisplacementHourlyItem(VibrationTrendPageVo.HourlyItem source) {
- DisplacementTrendPageVo.HourlyItem item = new DisplacementTrendPageVo.HourlyItem();
- item.setHour(source.getHour());
- item.setAvgValue(source.getAvgValue());
- return item;
- }
-
- private DisplacementComparisonPageVo.RankItem toDisplacementRankItem(VibrationComparisonPageVo.RankItem source) {
- DisplacementComparisonPageVo.RankItem item = new DisplacementComparisonPageVo.RankItem();
- item.setMonitorId(source.getMonitorId());
- item.setMonitorName(source.getMonitorName());
- item.setAvg(source.getAvg());
- item.setLatest(source.getLatest());
- return item;
- }
-
- private DisplacementComparisonPageVo.ScatterItem toDisplacementScatterItem(VibrationComparisonPageVo.ScatterItem source) {
- DisplacementComparisonPageVo.ScatterItem item = new DisplacementComparisonPageVo.ScatterItem();
- item.setMonitorId(source.getMonitorId());
- item.setMonitorName(source.getMonitorName());
- item.setAvg(source.getAvg());
- item.setMax(source.getMax());
- item.setSampleCount(source.getSampleCount());
- return item;
- }
-
- private DisplacementDistributionPageVo.IntervalBucketItem toDisplacementIntervalBucketItem(VibrationDistributionPageVo.IntervalBucketItem source) {
- DisplacementDistributionPageVo.IntervalBucketItem item = new DisplacementDistributionPageVo.IntervalBucketItem();
- item.setLabel(source.getLabel());
- item.setCount(source.getCount());
- return item;
- }
-
- private DisplacementDistributionPageVo.HistogramBucketItem toDisplacementHistogramBucketItem(VibrationDistributionPageVo.HistogramBucketItem source) {
- DisplacementDistributionPageVo.HistogramBucketItem item = new DisplacementDistributionPageVo.HistogramBucketItem();
- item.setStartValue(source.getStartValue());
- item.setEndValue(source.getEndValue());
- item.setCount(source.getCount());
- return item;
- }
-
- private DisplacementDistributionPageVo.CalendarHeatmapItem toDisplacementCalendarHeatmapItem(VibrationDistributionPageVo.CalendarHeatmapItem source) {
- DisplacementDistributionPageVo.CalendarHeatmapItem item = new DisplacementDistributionPageVo.CalendarHeatmapItem();
- item.setStatDate(source.getStatDate());
- item.setAvgValue(source.getAvgValue());
- return item;
- }
-
- private DisplacementDistributionPageVo.HourlyHeatmapItem toDisplacementHourlyHeatmapItem(VibrationDistributionPageVo.HourlyHeatmapItem source) {
- DisplacementDistributionPageVo.HourlyHeatmapItem item = new DisplacementDistributionPageVo.HourlyHeatmapItem();
- item.setStatDate(source.getStatDate());
- item.setStatHour(source.getStatHour());
- item.setAvgValue(source.getAvgValue());
- return item;
- }
-
- private DisplacementAnomalyPageVo.HighEventItem toDisplacementHighEventItem(VibrationAnomalyPageVo.HighEventItem source) {
- DisplacementAnomalyPageVo.HighEventItem item = new DisplacementAnomalyPageVo.HighEventItem();
- item.setMonitorId(source.getMonitorId());
- item.setMonitorName(source.getMonitorName());
- item.setValue(source.getValue());
- item.setRecodeTime(source.getRecodeTime());
- return item;
- }
-
- private DisplacementAnomalyPageVo.ContinuousEventItem toDisplacementContinuousEventItem(VibrationAnomalyPageVo.ContinuousEventItem source) {
- DisplacementAnomalyPageVo.ContinuousEventItem item = new DisplacementAnomalyPageVo.ContinuousEventItem();
- item.setMonitorId(source.getMonitorId());
- item.setMonitorName(source.getMonitorName());
- item.setStartTime(source.getStartTime());
- item.setEndTime(source.getEndTime());
- item.setMaxValue(source.getMaxValue());
- item.setSampleCount(source.getSampleCount());
- return item;
- }
-
- private DisplacementAnomalyPageVo.RapidRiseEventItem toDisplacementRapidRiseEventItem(VibrationAnomalyPageVo.RapidRiseEventItem source) {
- DisplacementAnomalyPageVo.RapidRiseEventItem item = new DisplacementAnomalyPageVo.RapidRiseEventItem();
- item.setMonitorId(source.getMonitorId());
- item.setMonitorName(source.getMonitorName());
- item.setDiff(source.getDiff());
- item.setRecodeTime(source.getRecodeTime());
- return item;
- }
-
- private DisplacementAnomalyPageVo.JitterEventItem toDisplacementJitterEventItem(VibrationAnomalyPageVo.JitterEventItem source) {
- DisplacementAnomalyPageVo.JitterEventItem item = new DisplacementAnomalyPageVo.JitterEventItem();
- item.setMonitorId(source.getMonitorId());
- item.setMonitorName(source.getMonitorName());
- item.setHourBucket(source.getHourBucket());
- item.setStddev(source.getStddev());
- item.setSampleCount(source.getSampleCount());
- return item;
- }
-
- private DisplacementAdvancedPageVo.SankeyNodeItem toDisplacementSankeyNodeItem(VibrationAdvancedPageVo.SankeyNodeItem source) {
- DisplacementAdvancedPageVo.SankeyNodeItem item = new DisplacementAdvancedPageVo.SankeyNodeItem();
- item.setName(source.getName());
- return item;
- }
-
- private DisplacementAdvancedPageVo.SankeyLinkItem toDisplacementSankeyLinkItem(VibrationAdvancedPageVo.SankeyLinkItem source) {
- DisplacementAdvancedPageVo.SankeyLinkItem item = new DisplacementAdvancedPageVo.SankeyLinkItem();
- item.setSource(source.getSource());
- item.setTarget(source.getTarget());
- item.setValue(source.getValue());
- return item;
- }
-
- private DisplacementAdvancedPageVo.TreemapItem toDisplacementTreemapItem(VibrationAdvancedPageVo.TreemapItem source) {
- DisplacementAdvancedPageVo.TreemapItem item = new DisplacementAdvancedPageVo.TreemapItem();
- item.setName(source.getName());
- item.setValue(source.getValue());
- item.setLevelTag(source.getLevelTag());
- return item;
- }
-
- private List normalizeMonitorIds(VibrationBoardQueryBo bo) {
- Set monitorIdSet = new LinkedHashSet<>();
- if (StrUtil.isNotBlank(bo.getMonitorId())) {
- monitorIdSet.add(bo.getMonitorId().trim());
- }
- if (CollUtil.isNotEmpty(bo.getMonitorIds())) {
- for (String monitorId : bo.getMonitorIds()) {
- if (StrUtil.isNotBlank(monitorId)) {
- monitorIdSet.add(monitorId.trim());
- }
- }
- }
- return new ArrayList<>(monitorIdSet);
- }
-
private Integer normalizeSamplingInterval(Integer samplingInterval) {
if (samplingInterval == null || samplingInterval < 1) {
return 1;
@@ -628,32 +781,320 @@ public class DisplacementBoardServiceImpl implements IDisplacementBoardService {
}
}
- private void validateQuerySpan(Date beginTime, Date endTime) {
- long diffMs = endTime.getTime() - beginTime.getTime();
- long diffDays = diffMs / (24L * 3600L * 1000L);
- if (diffDays > MAX_QUERY_DAYS) {
- throw new ServiceException("查询跨度不能超过" + MAX_QUERY_DAYS + "天,请缩小时间范围");
+ private List normalizeMonitorIds(DisplacementBoardQueryBo bo) {
+ Set monitorIdSet = new LinkedHashSet<>();
+ if (StrUtil.isNotBlank(bo.getMonitorId())) {
+ monitorIdSet.add(bo.getMonitorId().trim());
}
- }
-
- private void validateEstimatedQueryRows(Date beginTime, Date endTime, int monitorCount, Integer samplingInterval) {
- int effectiveSamplingInterval = normalizeSamplingInterval(samplingInterval);
- long diffMinutes = Math.max(1L, (endTime.getTime() - beginTime.getTime()) / (60L * 1000L) + 1L);
- long rowsPerMonitor = (long) Math.ceil((double) diffMinutes / effectiveSamplingInterval);
- long estimatedRows = rowsPerMonitor * Math.max(1, monitorCount);
- if (estimatedRows > MAX_ESTIMATED_QUERY_ROWS) {
- long recommendedSamplingInterval = Math.max(1L, (long) Math.ceil((double) diffMinutes * Math.max(1, monitorCount) / MAX_ESTIMATED_QUERY_ROWS));
- throw new ServiceException("当前查询预计返回约" + estimatedRows
- + "条记录,超过系统上限" + MAX_ESTIMATED_QUERY_ROWS
- + "条,请将抽样间隔至少调整为" + recommendedSamplingInterval + "分钟,或缩小时间范围/设备范围");
- }
- }
-
- private void validateResolvedTableNames(List tableNames) {
- for (String tableName : tableNames) {
- if (!TABLE_NAME_PATTERN.matcher(tableName).matches()) {
- throw new ServiceException("非法分表名称:" + tableName);
+ if (CollUtil.isNotEmpty(bo.getMonitorIds())) {
+ for (String monitorId : bo.getMonitorIds()) {
+ if (StrUtil.isNotBlank(monitorId)) {
+ monitorIdSet.add(monitorId.trim());
+ }
}
}
+ return new ArrayList<>(monitorIdSet);
+ }
+
+ // =============== 聚合辅助 ===============
+
+ private List filterValidDisplacementRows(List rows) {
+ return rows.stream()
+ .filter(row -> row.getVibrationDisplacement() != null
+ && row.getVibrationDisplacement().compareTo(BigDecimal.ZERO) > 0)
+ .toList();
+ }
+
+ private int countDistinctMonitors(List rows) {
+ return (int) rows.stream()
+ .map(RecordIotenvInstant::getMonitorId)
+ .filter(StrUtil::isNotBlank)
+ .distinct()
+ .count();
+ }
+
+ private Map> groupByMonitor(List rows) {
+ Map> grouped = new LinkedHashMap<>();
+ for (RecordIotenvInstant row : rows) {
+ grouped.computeIfAbsent(row.getMonitorId(), key -> new ArrayList<>()).add(row);
+ }
+ return grouped;
+ }
+
+ /**
+ * 单 pass 汇总位移指标:min / avg / max / latest / validCount。
+ * latest 按时间最近优先、objid 作为同时刻兜底排序。
+ */
+ private DisplacementMetricSnapshot buildMetricSnapshot(List rows) {
+ BigDecimal sum = BigDecimal.ZERO;
+ int count = 0;
+ BigDecimal minValue = null;
+ BigDecimal maxValue = null;
+ BigDecimal latestValue = null;
+ Date latestTime = null;
+ Long latestObjid = null;
+ for (RecordIotenvInstant row : rows) {
+ BigDecimal value = row.getVibrationDisplacement();
+ if (value == null) {
+ continue;
+ }
+ count++;
+ sum = sum.add(value);
+ minValue = minValue == null ? value : minValue.min(value);
+ maxValue = maxValue == null ? value : maxValue.max(value);
+ if (latestValue == null || isLaterRecord(row.getRecodeTime(), row.getObjid(), latestTime, latestObjid)) {
+ latestValue = value;
+ latestTime = row.getRecodeTime();
+ latestObjid = row.getObjid();
+ }
+ }
+ if (count == 0 || latestValue == null || minValue == null || maxValue == null) {
+ return DisplacementMetricSnapshot.EMPTY;
+ }
+ return new DisplacementMetricSnapshot(
+ count,
+ minValue.setScale(2, RoundingMode.HALF_UP),
+ sum.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP),
+ maxValue.setScale(2, RoundingMode.HALF_UP),
+ latestValue.setScale(2, RoundingMode.HALF_UP)
+ );
+ }
+
+ private DisplacementOverviewPageVo.MetricCardItem buildMetricCard(DisplacementMetricSnapshot snapshot) {
+ DisplacementOverviewPageVo.MetricCardItem item = new DisplacementOverviewPageVo.MetricCardItem();
+ item.setField(DISPLACEMENT_FIELD);
+ item.setLabel(DISPLACEMENT_LABEL);
+ item.setUnit(DISPLACEMENT_UNIT);
+ item.setLatest(snapshot.latest());
+ item.setAvg(snapshot.avg());
+ item.setMax(snapshot.max());
+ return item;
+ }
+
+ private DisplacementOverviewPageVo.PrimaryMetricStats buildPrimaryMetricStats(DisplacementMetricSnapshot snapshot) {
+ DisplacementOverviewPageVo.PrimaryMetricStats stats = new DisplacementOverviewPageVo.PrimaryMetricStats();
+ if (snapshot == DisplacementMetricSnapshot.EMPTY) {
+ return stats;
+ }
+ stats.setLatest(snapshot.latest());
+ stats.setMin(snapshot.min());
+ stats.setAvg(snapshot.avg());
+ stats.setMax(snapshot.max());
+ return stats;
+ }
+
+ private List buildOverviewDeviceRanks(List profiles) {
+ return profiles.stream().limit(MAX_OVERVIEW_RANK).map(profile -> {
+ DisplacementOverviewPageVo.DeviceRankItem item = new DisplacementOverviewPageVo.DeviceRankItem();
+ item.setMonitorId(profile.monitorId());
+ item.setMonitorName(profile.monitorName());
+ item.setAvg(profile.avg());
+ item.setLatest(profile.latest());
+ item.setMax(profile.max());
+ item.setSampleCount(profile.sampleCount());
+ return item;
+ }).toList();
+ }
+
+ /**
+ * 总览页仪表盘:
+ * - 单设备时显示“位移最新值”仪表;
+ * - 多设备时显示“群组均值”仪表,避免多仪表信息过载。
+ */
+ private List buildOverviewGaugeItems(DisplacementMetricSnapshot snapshot, int deviceCount) {
+ if (snapshot == DisplacementMetricSnapshot.EMPTY) {
+ return Collections.emptyList();
+ }
+ DisplacementOverviewPageVo.GaugeItem item = new DisplacementOverviewPageVo.GaugeItem();
+ if (deviceCount > 1) {
+ item.setName("群组均值");
+ item.setValue(snapshot.avg());
+ } else {
+ item.setName(DISPLACEMENT_LABEL);
+ item.setValue(snapshot.latest());
+ }
+ // 上限放大 20%,避免指针紧贴天花板影响视觉体验
+ BigDecimal max = snapshot.max();
+ item.setMaxValue(max == null ? BigDecimal.ONE : max.multiply(BigDecimal.valueOf(1.2D)).setScale(2, RoundingMode.HALF_UP));
+ item.setUnit(DISPLACEMENT_UNIT);
+ return List.of(item);
+ }
+
+ private List buildDeviceProfiles(Map> grouped) {
+ return grouped.entrySet().stream()
+ .map(entry -> buildDeviceProfile(entry.getKey(), entry.getValue()))
+ .filter(Objects::nonNull)
+ .sorted(Comparator.comparing(DisplacementDeviceProfile::avg, Comparator.reverseOrder()))
+ .toList();
+ }
+
+ private DisplacementDeviceProfile buildDeviceProfile(String monitorId, List monitorRows) {
+ if (CollUtil.isEmpty(monitorRows)) {
+ return null;
+ }
+ BigDecimal sum = BigDecimal.ZERO;
+ int count = 0;
+ BigDecimal maxValue = null;
+ BigDecimal latestValue = null;
+ Date latestTime = null;
+ Long latestObjid = null;
+ for (RecordIotenvInstant row : monitorRows) {
+ BigDecimal value = row.getVibrationDisplacement();
+ if (value == null) {
+ continue;
+ }
+ count++;
+ sum = sum.add(value);
+ maxValue = maxValue == null ? value : maxValue.max(value);
+ if (latestValue == null || isLaterRecord(row.getRecodeTime(), row.getObjid(), latestTime, latestObjid)) {
+ latestValue = value;
+ latestTime = row.getRecodeTime();
+ latestObjid = row.getObjid();
+ }
+ }
+ if (count == 0 || latestValue == null || maxValue == null) {
+ return null;
+ }
+ return new DisplacementDeviceProfile(
+ monitorId,
+ monitorRows.get(0).getMonitorName(),
+ sum.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP),
+ latestValue.setScale(2, RoundingMode.HALF_UP),
+ maxValue.setScale(2, RoundingMode.HALF_UP),
+ count
+ );
+ }
+
+ private static boolean isLaterRecord(Date currentTime, Long currentObjid, Date latestTime, Long latestObjid) {
+ int timeCompare = Comparator.nullsFirst(Comparator.naturalOrder()).compare(currentTime, latestTime);
+ if (timeCompare != 0) {
+ return timeCompare > 0;
+ }
+ return Comparator.nullsFirst(Comparator.naturalOrder()).compare(currentObjid, latestObjid) > 0;
+ }
+
+ private BigDecimal percentile(List values, double ratio) {
+ if (CollUtil.isEmpty(values)) {
+ return BigDecimal.ZERO;
+ }
+ int index = Math.min(values.size() - 1, Math.max(0, (int) Math.ceil(values.size() * ratio) - 1));
+ return values.get(index).setScale(2, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal divideRate(int numerator, int denominator) {
+ if (denominator <= 0) {
+ return zeroRate();
+ }
+ return BigDecimal.valueOf(numerator).divide(BigDecimal.valueOf(denominator), 4, RoundingMode.HALF_UP);
+ }
+
+ private BigDecimal zeroRate() {
+ return BigDecimal.ZERO.setScale(4, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * 峰值保真压缩:按桶保留 min+max,优先防止故障尖峰在抽样时被抹平。
+ */
+ private List compressRows(List rows, int maxPoints) {
+ if (CollUtil.isEmpty(rows) || rows.size() <= maxPoints) {
+ return rows;
+ }
+ int bucketCount = Math.max(1, maxPoints / 2);
+ int bucketSize = (int) Math.ceil((double) rows.size() / bucketCount);
+ List result = new ArrayList<>();
+ for (int index = 0; index < rows.size(); index += bucketSize) {
+ List bucket = rows.subList(index, Math.min(rows.size(), index + bucketSize));
+ RecordIotenvInstant minRow = null;
+ RecordIotenvInstant maxRow = null;
+ BigDecimal minValue = null;
+ BigDecimal maxValue = null;
+ for (RecordIotenvInstant row : bucket) {
+ BigDecimal value = row.getVibrationDisplacement();
+ if (value == null) {
+ continue;
+ }
+ if (minValue == null || value.compareTo(minValue) < 0) {
+ minValue = value;
+ minRow = row;
+ }
+ if (maxValue == null || value.compareTo(maxValue) > 0) {
+ maxValue = value;
+ maxRow = row;
+ }
+ }
+ if (minRow == null || maxRow == null) {
+ result.add(bucket.get(bucket.size() / 2));
+ continue;
+ }
+ if (Objects.equals(minRow, maxRow)) {
+ result.add(minRow);
+ continue;
+ }
+ if (isEarlierRecord(minRow, maxRow)) {
+ result.add(minRow);
+ result.add(maxRow);
+ } else {
+ result.add(maxRow);
+ result.add(minRow);
+ }
+ if (result.size() >= maxPoints) {
+ return result.subList(0, maxPoints);
+ }
+ }
+ return result;
+ }
+
+ private boolean isEarlierRecord(RecordIotenvInstant first, RecordIotenvInstant second) {
+ int timeCompare = Comparator.nullsFirst(Comparator.naturalOrder())
+ .compare(first.getRecodeTime(), second.getRecodeTime());
+ if (timeCompare != 0) {
+ return timeCompare < 0;
+ }
+ return Comparator.nullsFirst(Comparator.naturalOrder())
+ .compare(first.getObjid(), second.getObjid()) <= 0;
+ }
+
+ // =============== 值对象 ===============
+
+ /** 查询准备阶段产物:分表名 + 归一化后的查询 BO,用于后续分批执行。 */
+ private record QueryContext(List tableNames, DisplacementBoardQueryBo query) {
+ }
+
+ /** 异常阈值配置:位移专属默认值(300/100/30/20/3),前端可覆盖。 */
+ private record ThresholdProfile(
+ BigDecimal highThreshold,
+ BigDecimal warningThreshold,
+ BigDecimal rapidRiseThreshold,
+ BigDecimal stddevThreshold,
+ Integer minContinuousSamples
+ ) {
+ }
+
+ /** 位移维度设备画像(单一指标):按均值倒序排名,供对比/总览/高级页复用。 */
+ private record DisplacementDeviceProfile(
+ String monitorId,
+ String monitorName,
+ BigDecimal avg,
+ BigDecimal latest,
+ BigDecimal max,
+ Integer sampleCount
+ ) {
+ }
+
+ /** 位移单指标汇总:总览/质量/异常都可直接消费,避免反复扫描整批 rows。 */
+ private record DisplacementMetricSnapshot(
+ int validCount,
+ BigDecimal min,
+ BigDecimal avg,
+ BigDecimal max,
+ BigDecimal latest
+ ) {
+ static final DisplacementMetricSnapshot EMPTY = new DisplacementMetricSnapshot(
+ 0,
+ BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP),
+ BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP),
+ BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP),
+ BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
+ );
}
}
diff --git a/ruoyi-ems/src/main/resources/mapper/ems/record/EmsRecordAlarmRuleMapper.xml b/ruoyi-ems/src/main/resources/mapper/ems/record/EmsRecordAlarmRuleMapper.xml
index 111edd0..af97061 100644
--- a/ruoyi-ems/src/main/resources/mapper/ems/record/EmsRecordAlarmRuleMapper.xml
+++ b/ruoyi-ems/src/main/resources/mapper/ems/record/EmsRecordAlarmRuleMapper.xml
@@ -93,7 +93,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and RAL.recover_lower = #{recoverLower}
and RAL.hysteresis = #{hysteresis}
and RAL.duration_sec = #{durationSec}
- and RAL.alarm_level = #{alarmLevel}
and RAL.notify_group_id = #{notifyGroupId}
and RAL.is_enable = #{isEnable}
and RAL.notify_user = #{notifyUser}
@@ -152,7 +151,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
recover_lower,
hysteresis,
duration_sec,
- alarm_level,
notify_group_id,
is_enable,
notify_user,
@@ -180,7 +178,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{recoverLower},
#{hysteresis},
#{durationSec},
- #{alarmLevel},
#{notifyGroupId},
#{isEnable},
#{notifyUser},
@@ -212,7 +209,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
recover_lower = #{recoverLower},
hysteresis = #{hysteresis},
duration_sec = #{durationSec},
- alarm_level = #{alarmLevel},
notify_group_id = #{notifyGroupId},
is_enable = #{isEnable},
notify_user = #{notifyUser},
diff --git a/ruoyi-ems/src/main/resources/mapper/ems/report/DisplacementBoardMapper.xml b/ruoyi-ems/src/main/resources/mapper/ems/report/DisplacementBoardMapper.xml
new file mode 100644
index 0000000..2152a81
--- /dev/null
+++ b/ruoyi-ems/src/main/resources/mapper/ems/report/DisplacementBoardMapper.xml
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ t.objid,
+ t.monitorId,
+ t.vibration_displacement,
+ t.collectTime,
+ t.recodeTime,
+ COALESCE(ebmi.monitor_name, t.monitorId) AS monitor_name,
+ t.monitorId AS monitor_code
+
+
+
+
+ t.recodeTime >= #{query.beginRecordTime}
+ AND t.recodeTime <= #{query.endRecordTime}
+
+
+ AND t.monitorId = #{query.monitorId}
+
+
+ AND t.monitorId IN
+
+ #{monitorId}
+
+
+
+ AND t.vibration_displacement IS NOT NULL
+ AND t.vibration_displacement > 0
+
+
+
+
+ t.recodeTime >= #{query.beginRecordTime}
+ AND t.recodeTime <= #{query.endRecordTime}
+
+
+ AND t.monitorId = #{query.monitorId}
+
+
+ AND t.monitorId IN
+
+ #{monitorId}
+
+
+
+
+
+
+
+ SELECT *
+ FROM (
+
+ SELECT
+
+ FROM ${tableName} t
+ INNER JOIN ems_base_monitor_info ebmi
+ ON t.monitorId = ebmi.monitor_code
+ AND ebmi.monitor_type = 10
+
+
+
+
+ ) displacement_data
+
+ ORDER BY displacement_data.monitorId ASC, displacement_data.recodeTime ASC, displacement_data.objid ASC
+
+
+
+
+ WITH sampled AS (
+
+ SELECT
+ ,
+ ROW_NUMBER() OVER (
+ PARTITION BY t.monitorId,
+
+
+ CAST(TIMESTAMPDIFF(MINUTE, '2000-01-01 00:00:00', t.recodeTime) / #{query.samplingInterval} AS SIGNED)
+
+
+ CAST(EXTRACT(EPOCH FROM (t.recodeTime - TIMESTAMP '2000-01-01 00:00:00')) / 60 / #{query.samplingInterval} AS BIGINT)
+
+
+ CAST(DATEDIFF(MINUTE, '2000-01-01 00:00:00', t.recodeTime) / #{query.samplingInterval} AS BIGINT)
+
+
+ ORDER BY t.recodeTime DESC, t.objid DESC
+ ) AS rn
+ FROM ${tableName} t
+ INNER JOIN ems_base_monitor_info ebmi
+ ON t.monitorId = ebmi.monitor_code
+ AND ebmi.monitor_type = 10
+
+
+
+
+ )
+ SELECT
+ objid,
+ monitorId,
+ monitor_code,
+ monitor_name,
+ vibration_displacement,
+ collectTime,
+ recodeTime
+ FROM sampled
+
+ WHERE rn = 1
+ ORDER BY monitorId ASC, recodeTime ASC, objid ASC
+
+
+
+
+ SELECT *
+ FROM (
+
+ SELECT
+
+ FROM ${tableName} t
+ INNER JOIN ems_base_monitor_info ebmi
+ ON t.monitorId = ebmi.monitor_code
+ AND ebmi.monitor_type = 10
+
+
+
+
+ ) displacement_quality_data
+ ORDER BY displacement_quality_data.monitorId ASC, displacement_quality_data.recodeTime ASC, displacement_quality_data.objid ASC
+
+
+
+
+ WITH sampled AS (
+
+ SELECT
+ ,
+ ROW_NUMBER() OVER (
+ PARTITION BY t.monitorId,
+
+
+ CAST(TIMESTAMPDIFF(MINUTE, '2000-01-01 00:00:00', t.recodeTime) / #{query.samplingInterval} AS SIGNED)
+
+
+ CAST(EXTRACT(EPOCH FROM (t.recodeTime - TIMESTAMP '2000-01-01 00:00:00')) / 60 / #{query.samplingInterval} AS BIGINT)
+
+
+ CAST(DATEDIFF(MINUTE, '2000-01-01 00:00:00', t.recodeTime) / #{query.samplingInterval} AS BIGINT)
+
+
+ ORDER BY t.recodeTime DESC, t.objid DESC
+ ) AS rn
+ FROM ${tableName} t
+ INNER JOIN ems_base_monitor_info ebmi
+ ON t.monitorId = ebmi.monitor_code
+ AND ebmi.monitor_type = 10
+
+
+
+
+ )
+ SELECT
+ objid,
+ monitorId,
+ monitor_code,
+ monitor_name,
+ vibration_displacement,
+ collectTime,
+ recodeTime
+ FROM sampled
+ WHERE rn = 1
+ ORDER BY monitorId ASC, recodeTime ASC, objid ASC
+
+
+
+
+
+
+
+
+
+
+
+
+
+