From 057749a9c90234547903649a388c8369e1a8147a Mon Sep 17 00:00:00 2001 From: zch Date: Thu, 9 Apr 2026 15:41:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E6=8C=AF=E5=8A=A8=E6=8A=A5=E8=A1=A8):?= =?UTF-8?q?=20=E4=BC=98=E5=8C=96=E6=97=B6=E9=97=B4=E5=A4=84=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=8E=8B=E7=BC=A9=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将时间字段类型从Date改为String,统一使用VibrationMathUtils处理时间格式 重构数据压缩算法保留峰值特征,优化分表查询性能与设备画像构建 增加VibrationMathUtils工具类集中数学计算与时间格式化逻辑 --- .../VibrationAnomalyPageVo.java | 9 +- .../impl/VibrationBoardServiceImpl.java | 239 +++++++++++++----- .../support/VibrationAnomalyAnalyzer.java | 36 +-- .../VibrationDistributionAggregator.java | 44 ++-- .../impl/support/VibrationMathUtils.java | 70 +++++ 5 files changed, 283 insertions(+), 115 deletions(-) create mode 100644 ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationMathUtils.java diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAnomalyPageVo.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAnomalyPageVo.java index 8cc9987..f6c8708 100644 --- a/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAnomalyPageVo.java +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/domain/vo/vibrationboard/VibrationAnomalyPageVo.java @@ -3,7 +3,6 @@ package org.dromara.ems.report.domain.vo.vibrationboard; import lombok.Data; import java.math.BigDecimal; -import java.util.Date; import java.util.List; /** @@ -79,7 +78,7 @@ public class VibrationAnomalyPageVo { /** 触发高风险的指标值 */ private BigDecimal value; /** 事件发生时刻 */ - private Date recodeTime; + private String recodeTime; } /** @@ -92,9 +91,9 @@ public class VibrationAnomalyPageVo { /** 设备名称 */ private String monitorName; /** 连续超标段开始时刻 */ - private Date startTime; + private String startTime; /** 连续超标段结束时刻 */ - private Date endTime; + private String endTime; /** 段内峰值 */ private BigDecimal maxValue; /** 连续超标的采样点数,越长说明劣化越严重 */ @@ -113,7 +112,7 @@ public class VibrationAnomalyPageVo { /** 相邻两点的差值 */ private BigDecimal diff; /** 突变发生时刻(后一个点的时间) */ - private Date recodeTime; + private String recodeTime; } /** diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/VibrationBoardServiceImpl.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/VibrationBoardServiceImpl.java index ee98f27..df771ec 100644 --- a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/VibrationBoardServiceImpl.java +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/VibrationBoardServiceImpl.java @@ -18,6 +18,7 @@ import org.dromara.ems.report.mapper.VibrationBoardMapper; 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; @@ -81,6 +82,9 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { /** 单次查询允许返回的估算最大记录数,避免多设备分钟级全量拉取把 JVM 堆打爆 */ private static final long MAX_ESTIMATED_QUERY_ROWS = 500_000L; + /** 单条 SQL 最大 UNION 分表数,超出时分批查询再归并,降低数据库优化器编译压力 */ + private static final int MAX_UNION_TABLES = 31; + /** 分布统计聚合器(无状态工具类,用单例避免重复创建) */ private static final VibrationDistributionAggregator DISTRIBUTION_AGGREGATOR = new VibrationDistributionAggregator(); @@ -532,13 +536,44 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { // 白名单校验——纵深防御,即使 resolveTables 内部逻辑被修改也不会发生 SQL 注入 validateResolvedTableNames(tableNames); - // 抽样间隔 > 1 分钟时使用 ROW_NUMBER 窗口抽样,否则直查原始数据 - if (query.getSamplingInterval() != null && query.getSamplingInterval() > 1) { - // 调用抽样查询:按抽样间隔窗口取数据 - return vibrationBoardMapper.selectSampledData(tableNames, query); + // 抽样间隔 > 1 分钟时使用 ROW_NUMBER 窗口抽样,否则直查原始数据; + // 当分表数量过多时自动分批查询并在 JVM 侧稳定归并,避免单 SQL 过长压垮优化器。 + return queryDataByTableBatches(tableNames, query); + } + + /** + * 分表查询执行器:控制单条 SQL 的 UNION 分表数,并保证最终行序稳定。 + */ + private List queryDataByTableBatches(List tableNames, VibrationBoardQueryBo query) { + // 分表数量未超阈值时仍走单次查询,避免不必要的批次合并开销 + if (tableNames.size() <= MAX_UNION_TABLES) { + return executeSingleBatchQuery(tableNames, query); } - // 直查原始数据:不抽样,返回全部符合条件的记录 - return vibrationBoardMapper.selectRawData(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 = executeSingleBatchQuery(batchTableNames, query); + if (CollUtil.isNotEmpty(batchRows)) { + mergedRows.addAll(batchRows); + } + } + // 分批结果必须再次全局排序,避免不同批次的记录交错后影响“latest/相邻差值”等算法正确性 + mergedRows.sort(Comparator.comparing(RecordIotenvInstant::getMonitorId, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(RecordIotenvInstant::getRecodeTime, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(RecordIotenvInstant::getObjid, Comparator.nullsFirst(Comparator.naturalOrder()))); + return mergedRows; + } + + /** + * 单批次查询执行:根据抽样间隔自动选择原始查询或抽样查询。 + */ + private List executeSingleBatchQuery(List batchTableNames, VibrationBoardQueryBo query) { + if (query.getSamplingInterval() != null && query.getSamplingInterval() > 1) { + return vibrationBoardMapper.selectSampledData(batchTableNames, query); + } + return vibrationBoardMapper.selectRawData(batchTableNames, query); } /** @@ -821,25 +856,6 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { return values.get(index).setScale(2, RoundingMode.HALF_UP); } - /** - * 取时间上最近的一条记录的指标值。 - * 必须从原始行按 recodeTime 排序取最后一条,而不能从排序后的均值列表中取—— - * 否则“latest”实际拿到的是数值最大值,与逻辑语义相差很远。 - */ - private BigDecimal extractLatestMetricValue(List rows, String metricField) { - // 流式处理:过滤指标值非空的行 → 按时间取最新的一条 → 提取指标值 - return rows.stream() - // 过滤指标值为空的行 - .filter(row -> extractMetricValue(row, metricField) != null) - // 按记录时间升序取最大值(即最新时间),时间相同时按主键 objid 排序保证稳定性 - .max(Comparator.comparing(RecordIotenvInstant::getRecodeTime, Comparator.nullsFirst(Comparator.naturalOrder())) - .thenComparing(RecordIotenvInstant::getObjid, Comparator.nullsFirst(Comparator.naturalOrder()))) - // 从最新记录中提取指标值 - .map(row -> extractMetricValue(row, metricField)) - // 无符合条件的记录时返回 null - .orElse(null); - } - /** * 构建设备维度画像:将每台设备的四维振动均值 + 当前指标 latest/max/count 聚合成一个 Profile。 * 多个页面(对比、高级、总览)共用这个 Profile,避免重复分组+统计。 @@ -866,26 +882,75 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { * 单设备画像构建:趋势页、高级页和总览页都可以直接复用,避免重复 groupByMonitor。 */ private DeviceMetricProfile buildDeviceProfile(String monitorId, List monitorRows, String currentMetricField) { - // 当前设备在主看指标上的有效值已按升序排列,后续 max/分布统计都可直接复用 - List currentValues = extractMetricValues(monitorRows, currentMetricField); - // 无有效值时不构建设备画像,避免空设备参与后续排名 - if (CollUtil.isEmpty(currentValues)) { + // 单设备无数据时直接返回,避免后续空指针 + if (CollUtil.isEmpty(monitorRows)) { return null; } - // latestValue 必须从时间维度取最近记录,而不是排序后的尾部 - BigDecimal latestValue = extractLatestMetricValue(monitorRows, currentMetricField); + + // 主看指标聚合(单 pass):避免每台设备重复 stream + sort 带来的高额 CPU 开销 + BigDecimal currentSum = BigDecimal.ZERO; + int currentCount = 0; + BigDecimal currentMax = null; + BigDecimal currentLatestValue = null; + Date currentLatestTime = null; + Long currentLatestObjid = null; + + // 四个固定维度也在同一次遍历里完成均值累加,避免每个维度都单独再扫一遍 + BigDecimal speedSum = BigDecimal.ZERO; + int speedCount = 0; + BigDecimal displacementSum = BigDecimal.ZERO; + int displacementCount = 0; + BigDecimal accelerationSum = BigDecimal.ZERO; + int accelerationCount = 0; + BigDecimal tempSum = BigDecimal.ZERO; + int tempCount = 0; + + for (RecordIotenvInstant row : monitorRows) { + BigDecimal currentValue = extractMetricValue(row, currentMetricField); + if (currentValue != null) { + currentCount++; + currentSum = currentSum.add(currentValue); + currentMax = currentMax == null ? currentValue : currentMax.max(currentValue); + if (currentLatestValue == null || isLaterRecord(row.getRecodeTime(), row.getObjid(), currentLatestTime, currentLatestObjid)) { + currentLatestValue = currentValue; + currentLatestTime = row.getRecodeTime(); + currentLatestObjid = row.getObjid(); + } + } + if (row.getVibrationSpeed() != null) { + speedCount++; + speedSum = speedSum.add(row.getVibrationSpeed()); + } + if (row.getVibrationDisplacement() != null) { + displacementCount++; + displacementSum = displacementSum.add(row.getVibrationDisplacement()); + } + if (row.getVibrationAcceleration() != null) { + accelerationCount++; + accelerationSum = accelerationSum.add(row.getVibrationAcceleration()); + } + if (row.getVibrationTemp() != null) { + tempCount++; + tempSum = tempSum.add(row.getVibrationTemp()); + } + } + // 主看指标没有有效值时不构建设备画像,避免空设备参与后续排名 + if (currentCount == 0 || currentLatestValue == null || currentMax == null) { + return null; + } + // 构建设备画像,四个维度均值保留下来供高级页平行坐标和对比页复用 return new DeviceMetricProfile( monitorId, monitorRows.get(0).getMonitorName(), - avg(currentValues), - latestValue == null ? null : latestValue.setScale(2, RoundingMode.HALF_UP), - currentValues.get(currentValues.size() - 1).setScale(2, RoundingMode.HALF_UP), - currentValues.size(), - avg(extractMetricValues(monitorRows, "vibrationSpeed")), - avg(extractMetricValues(monitorRows, "vibrationDisplacement")), - avg(extractMetricValues(monitorRows, "vibrationAcceleration")), - avg(extractMetricValues(monitorRows, "vibrationTemp")) + avg(currentSum, currentCount), + currentLatestValue.setScale(2, RoundingMode.HALF_UP), + currentMax.setScale(2, RoundingMode.HALF_UP), + currentCount, + avg(speedSum, speedCount), + avg(displacementSum, displacementCount), + avg(accelerationSum, accelerationCount), + avg(tempSum, tempCount) ); } @@ -1048,24 +1113,21 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { * 保留两位小数与前端展示对齐。 */ private BigDecimal avg(List values) { - // 空列表返回零值(保留两位小数),避免上层做空判断 - if (CollUtil.isEmpty(values)) { - // 返回 0.00 作为默认均值 - return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); - } - // 对列表所有元素求和,初始值为零 - BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); - // 求和除以元素个数得到均值,保留两位小数并四舍五入 - return sum.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_UP); + return VibrationMathUtils.avg(values, 2); + } + + /** + * 均值计算:单 pass 累加场景(sum/count)直接复用,避免先组装中间 List。 + */ + private BigDecimal avg(BigDecimal sum, int count) { + return VibrationMathUtils.avg(sum, count, 2); } /** * 日期格式化工具:统一使用系统时区,确保与数据库存储时区一致。 */ - private String formatDate(Date date, String pattern) { - // 将 Date 转为系统时区的 LocalDateTime,再按指定模式格式化为字符串返回 - return DateTimeFormatter.ofPattern(pattern) - .format(date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); + private String formatDate(Date date, DateTimeFormatter formatter) { + return VibrationMathUtils.formatDate(date, formatter); } /** 指标元数据:字段名 + 显示标签 + 单位,集中维护避免前后端不一致 */ @@ -1352,8 +1414,6 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { * 先压缩到 MAX_TREND_POINTS 以内,避免 ECharts 渲染超过万点时浏览器卡死。 */ private List buildTrendMetricSeries(List rows) { - // 先压缩到 MAX_TREND_POINTS 以内,避免 ECharts 渲染超过万点时浏览器卡死 - List compressed = compressRows(rows, MAX_TREND_POINTS); // 定义四个振动维度的元数据 List metas = List.of( resolveMetricMeta("vibrationSpeed"), @@ -1365,6 +1425,8 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { List series = new ArrayList<>(); // 遍历每个振动维度,构建对应的趋势系列 for (MetricMeta meta : metas) { + // 按当前维度进行峰值保真压缩,避免桶中位数策略吞掉故障尖峰 + List compressed = compressRows(rows, MAX_TREND_POINTS, meta.field()); // 将压缩后的每行映射为趋势点,过滤时间或值为空的点 List points = compressed.stream() .map(row -> buildTrendPoint(row.getRecodeTime(), extractMetricValue(row, meta.field()))) @@ -1411,7 +1473,7 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { // 遍历 TOP6 设备,构建每台设备的趋势曲线 for (DeviceMetricProfile profile : topProfiles) { // 压缩当前设备的采样数据到 MAX_TREND_POINTS 以内 - List compressed = compressRows(grouped.get(profile.monitorId()), MAX_TREND_POINTS); + List compressed = compressRows(grouped.get(profile.monitorId()), MAX_TREND_POINTS, metricField); // 将压缩后的每行映射为趋势点,过滤时间或值为空的点 List points = compressed.stream() .map(row -> buildTrendPoint(row.getRecodeTime(), extractMetricValue(row, metricField))) @@ -1460,7 +1522,7 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { // 创建趋势点实例 VibrationTrendPageVo.TrendPointItem item = new VibrationTrendPageVo.TrendPointItem(); // 设置时间字符串,格式为 yyyy-MM-dd HH:mm:ss - item.setTime(formatDate(time, "yyyy-MM-dd HH:mm:ss")); + item.setTime(formatDate(time, VibrationMathUtils.FMT_DATETIME)); // 设置指标值,保留两位小数 item.setValue(value.setScale(2, RoundingMode.HALF_UP)); // 返回构建好的趋势点 @@ -1483,7 +1545,7 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { continue; } // 提取小时数字符串(如 "08"、"14") - String hour = formatDate(row.getRecodeTime(), "HH"); + String hour = formatDate(row.getRecodeTime(), VibrationMathUtils.FMT_HOUR); // 将指标值追加到对应小时桶中 bucketMap.computeIfAbsent(hour, key -> new ArrayList<>()).add(value); } @@ -1591,26 +1653,75 @@ public class VibrationBoardServiceImpl implements IVibrationBoardService { } /** - * 桶中位数压缩:按等宽分桶后取每桶中间点,保留数据分布形态。 - * 比简单的“每 N 点取一个”更能保留峰谷特征,避免抽样后趋势变形。 + * 峰值保真压缩:按桶保留 min+max,优先防止故障尖峰在抽样时被抹平。 */ - private List compressRows(List rows, int maxPoints) { + private List compressRows(List rows, int maxPoints, String metricField) { // 空列表或数据量未超过上限时直接返回原始数据,无需压缩 if (CollUtil.isEmpty(rows) || rows.size() <= maxPoints) { return rows; } - // 计算每个桶的大小:向上取整确保桶数 <= maxPoints - int bucketSize = (int) Math.ceil((double) rows.size() / maxPoints); + // 每桶最多输出两个点(min + max),因此桶数上限按 maxPoints/2 计算,确保最终点数可控 + 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) { // 截取当前桶的子列表,最后一个桶可能不足 bucketSize List bucket = rows.subList(index, Math.min(rows.size(), index + bucketSize)); - // 取桶中间位置的元素作为代表点,比取首/尾更能保留峰谷特征 - result.add(bucket.get(bucket.size() / 2)); + RecordIotenvInstant minRow = null; + RecordIotenvInstant maxRow = null; + BigDecimal minValue = null; + BigDecimal maxValue = null; + for (RecordIotenvInstant row : bucket) { + BigDecimal value = extractMetricValue(row, metricField); + 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; } + + /** + * 记录先后比较:先比时间,再用 objid 兜底,确保同一时刻下也有稳定顺序。 + */ + 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; + } } diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationAnomalyAnalyzer.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationAnomalyAnalyzer.java index 4f07f55..d6e2883 100644 --- a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationAnomalyAnalyzer.java +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationAnomalyAnalyzer.java @@ -6,8 +6,6 @@ import org.dromara.ems.report.domain.vo.vibrationboard.VibrationAnomalyPageVo; import java.math.BigDecimal; import java.math.RoundingMode; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; @@ -56,7 +54,7 @@ public class VibrationAnomalyAnalyzer { // 设置指标值,保留两位小数 item.setValue(metricExtractor.apply(row).setScale(2, RoundingMode.HALF_UP)); // 设置记录时间 - item.setRecodeTime(row.getRecodeTime()); + item.setRecodeTime(formatDateTime(row.getRecodeTime())); // 返回构建好的事件项 return item; }) @@ -101,7 +99,7 @@ public class VibrationAnomalyAnalyzer { // 设置差值,保留两位小数 item.setDiff(diff.setScale(2, RoundingMode.HALF_UP)); // 设置发生时间(取当前点的时间) - item.setRecodeTime(monitorRows.get(i).getRecodeTime()); + item.setRecodeTime(formatDateTime(monitorRows.get(i).getRecodeTime())); // 将事件添加到结果列表 result.add(item); } @@ -167,9 +165,9 @@ public class VibrationAnomalyAnalyzer { // 设置设备名称(取超标段起始点的设备名) item.setMonitorName(monitorRows.get(startIndex).getMonitorName()); // 设置超标段开始时间 - item.setStartTime(monitorRows.get(startIndex).getRecodeTime()); + item.setStartTime(formatDateTime(monitorRows.get(startIndex).getRecodeTime())); // 设置超标段结束时间 - item.setEndTime(monitorRows.get(endIndex).getRecodeTime()); + item.setEndTime(formatDateTime(monitorRows.get(endIndex).getRecodeTime())); // 设置超标段内的峰值,保留两位小数 item.setMaxValue(maxValue.setScale(2, RoundingMode.HALF_UP)); // 设置超标段内的采样点数 @@ -210,7 +208,7 @@ public class VibrationAnomalyAnalyzer { continue; } // 将记录时间格式化为小时级别的时间桶(如 "2026-03-26 14:00:00") - String hourBucket = formatDate(row.getRecodeTime(), "yyyy-MM-dd HH") + ":00:00"; + String hourBucket = formatDateHourBucket(row.getRecodeTime()); // 拼接"设备ID_小时桶"作为分桶 key,确保不同设备同一小时不会混淆 String key = row.getMonitorId() + "_" + hourBucket; // 将指标值追加到对应桶中,桶不存在时自动创建 @@ -309,15 +307,8 @@ public class VibrationAnomalyAnalyzer { * 比 Service 层的 avg(2 位)精度更高,因为此处作为标准差计算的中间步骤。 */ private BigDecimal avg(List values) { - // 空列表返回零值(保留两位小数),避免上层做空判断 - if (CollUtil.isEmpty(values)) { - // 返回 0.00 作为默认均值 - return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); - } - // 对列表所有元素求和,初始值为零 - BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); - // 求和除以元素个数得到均值,保留6位小数(比展示层多,减少标准差计算的累积截断误差) - return sum.divide(BigDecimal.valueOf(values.size()), 6, RoundingMode.HALF_UP); + // 标准差前置计算需要更高精度,统一用 6 位小数均值减少累计截断误差 + return VibrationMathUtils.avg(values, 6); } /** @@ -352,12 +343,11 @@ public class VibrationAnomalyAnalyzer { return current.setScale(scale, RoundingMode.HALF_UP); } - /** - * 日期格式化工具:统一使用系统时区,确保与数据库存储时区一致。 - */ - private String formatDate(java.util.Date date, String pattern) { - // 将 Date 转为系统时区的 LocalDateTime,再按指定模式格式化为字符串返回 - return DateTimeFormatter.ofPattern(pattern) - .format(date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); + private String formatDateTime(java.util.Date date) { + return VibrationMathUtils.formatDate(date, VibrationMathUtils.FMT_DATETIME); + } + + private String formatDateHourBucket(java.util.Date date) { + return VibrationMathUtils.formatDate(date, VibrationMathUtils.FMT_DATE_HOUR) + ":00:00"; } } diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationDistributionAggregator.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationDistributionAggregator.java index 9979830..57905b0 100644 --- a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationDistributionAggregator.java +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationDistributionAggregator.java @@ -6,8 +6,6 @@ import org.dromara.ems.report.domain.vo.vibrationboard.VibrationDistributionPage import java.math.BigDecimal; import java.math.RoundingMode; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -172,7 +170,7 @@ public class VibrationDistributionAggregator { continue; } // 将记录时间格式化为"年-月-日"字符串作为日期桶的 key - String statDate = formatDate(row.getRecodeTime(), "yyyy-MM-dd"); + String statDate = formatDate(row.getRecodeTime(), VibrationMathUtils.FMT_DATE); // 将指标值追加到对应日期桶中,桶不存在时自动创建 bucketMap.computeIfAbsent(statDate, key -> new ArrayList<>()).add(value); } @@ -189,8 +187,11 @@ public class VibrationDistributionAggregator { // 将当天热力图项添加到结果列表 result.add(item); }); - // 返回按日期排列的日历热力图数据 - return result; + // 多设备查询时原始行序是“设备→时间”,这里必须按日期重排, + // 否则前端用首尾日期推 calendar range 时会截断热力图范围。 + return result.stream() + .sorted((left, right) -> left.getStatDate().compareTo(right.getStatDate())) + .toList(); } /** @@ -210,7 +211,7 @@ public class VibrationDistributionAggregator { continue; } // 将记录时间格式化为"年-月-日"字符串 - String statDate = formatDate(row.getRecodeTime(), "yyyy-MM-dd"); + String statDate = formatDate(row.getRecodeTime(), VibrationMathUtils.FMT_DATE); // 提取记录时间的小时数(0~23) Integer hour = parseHour(row.getRecodeTime()); // 使用"日期_小时"拼接作为桶 key,确保不同天同一小时的数据不会混淆 @@ -233,8 +234,16 @@ public class VibrationDistributionAggregator { // 将热力图项添加到结果列表 result.add(item); }); - // 返回按日期×小时排列的热力图数据 - return result; + // 统一按“日期升序 + 小时升序”返回,保证前端二维热力图坐标稳定。 + return result.stream() + .sorted((left, right) -> { + int dateCompare = left.getStatDate().compareTo(right.getStatDate()); + if (dateCompare != 0) { + return dateCompare; + } + return left.getStatHour().compareTo(right.getStatHour()); + }) + .toList(); } /** 构建四分位区间项——纯工厂方法,简化上层调用 */ @@ -270,31 +279,20 @@ public class VibrationDistributionAggregator { * 保留两位小数与前端热力图色值对齐。 */ private BigDecimal avg(List values) { - // 空列表返回零值(保留两位小数),避免上层做空判断 - if (CollUtil.isEmpty(values)) { - // 返回 0.00 作为默认均值 - return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); - } - // 对列表所有元素求和,初始值为零 - BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); - // 求和除以元素个数得到均值,保留两位小数并四舍五入 - return sum.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_UP); + return VibrationMathUtils.avg(values, 2); } /** * 日期格式化工具:统一使用系统时区,确保与数据库存储时区一致。 */ - private String formatDate(java.util.Date date, String pattern) { - // 将 Date 转为系统时区的 LocalDateTime,再按指定模式格式化为字符串返回 - return DateTimeFormatter.ofPattern(pattern) - .format(date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); + private String formatDate(java.util.Date date, java.time.format.DateTimeFormatter formatter) { + return VibrationMathUtils.formatDate(date, formatter); } /** * 提取小时数(0~23):用于小时热力图的“日期×小时”二维聚合 key。 */ private Integer parseHour(java.util.Date date) { - // 将 Date 转为系统时区的 ZonedDateTime,提取小时数(0~23)返回 - return date.toInstant().atZone(ZoneId.systemDefault()).getHour(); + return VibrationMathUtils.parseHour(date); } } diff --git a/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationMathUtils.java b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationMathUtils.java new file mode 100644 index 0000000..7dfdc6d --- /dev/null +++ b/ruoyi-ems/src/main/java/org/dromara/ems/report/service/impl/support/VibrationMathUtils.java @@ -0,0 +1,70 @@ +package org.dromara.ems.report.service.impl.support; + +import cn.hutool.core.collection.CollUtil; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.List; + +/** + * 振动报表通用数学/时间工具。 + * + *

聚合器、异常分析器和主服务都会用到均值与时间格式化, + * 统一收口后可避免多处重复实现导致的口径漂移。

+ */ +public final class VibrationMathUtils { + + public static final DateTimeFormatter FMT_DATETIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + public static final DateTimeFormatter FMT_DATE_HOUR = DateTimeFormatter.ofPattern("yyyy-MM-dd HH"); + public static final DateTimeFormatter FMT_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + public static final DateTimeFormatter FMT_HOUR = DateTimeFormatter.ofPattern("HH"); + + private static final ZoneId SYSTEM_ZONE_ID = ZoneId.systemDefault(); + + private VibrationMathUtils() { + } + + /** + * 均值计算:空列表返回指定精度的 0,避免业务层反复空判断。 + */ + public static BigDecimal avg(List values, int scale) { + if (CollUtil.isEmpty(values)) { + return BigDecimal.ZERO.setScale(scale, RoundingMode.HALF_UP); + } + BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + return avg(sum, values.size(), scale); + } + + /** + * 均值计算:用 sum/count 直接求值,便于单 pass 累加场景复用。 + */ + public static BigDecimal avg(BigDecimal sum, int count, int scale) { + if (count <= 0) { + return BigDecimal.ZERO.setScale(scale, RoundingMode.HALF_UP); + } + return sum.divide(BigDecimal.valueOf(count), scale, RoundingMode.HALF_UP); + } + + /** + * 日期格式化:统一系统时区,避免各模块自行创建 formatter 造成 GC 压力。 + */ + public static String formatDate(Date date, DateTimeFormatter formatter) { + if (date == null || formatter == null) { + return null; + } + return formatter.format(date.toInstant().atZone(SYSTEM_ZONE_ID).toLocalDateTime()); + } + + /** + * 提取小时数(0~23)。 + */ + public static Integer parseHour(Date date) { + if (date == null) { + return null; + } + return date.toInstant().atZone(SYSTEM_ZONE_ID).getHour(); + } +}