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 + + + + + + + + + + + + + +