feat(steady): 新增数据校验功能并优化稳态趋势查询

- 在 AddLedgerLineMapper.xml 中添加 lineInterval 字段映射
- 在 AddLedgerLinePathVO 中添加 lineInterval 属性用于存储统计间隔
- 为稳态趋势查询服务添加详细的执行日志记录和性能监控
- 重构 InfluxDB 查询组件,添加诊断信息构建方法和异常处理
- 限制谐波次数最大展示数量从 6 个调整为 3 个
- 新增数据校验相关组件、控制器和服务实现
- 实现数据连续性检查和缺失数据统计功能
- 添加数据校验查询参数和返回结果的数据结构定义
- 完善相关单元测试确保功能正确性
This commit is contained in:
2026-05-27 08:04:49 +08:00
parent e5369fef5a
commit 66d351afe4
24 changed files with 1260 additions and 7 deletions

View File

@@ -0,0 +1,75 @@
package com.njcn.gather.steady.checksquare.component;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareSegmentVO;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 数据校验连续性计算组件。
*/
@Component
public class SteadyChecksquareCalculator {
public static final String STATUS_NORMAL = "NORMAL";
public static final String STATUS_MISSING = "MISSING";
private static final DateTimeFormatter OUTPUT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public List<SteadyChecksquareSegmentVO> buildSegments(List<LocalDateTime> slots, Set<LocalDateTime> actualSlots,
int intervalMinutes) {
List<SteadyChecksquareSegmentVO> result = new ArrayList<SteadyChecksquareSegmentVO>();
if (slots == null || slots.isEmpty()) {
return result;
}
String currentStatus = resolveStatus(slots.get(0), actualSlots);
LocalDateTime segmentStart = slots.get(0);
LocalDateTime previousSlot = slots.get(0);
int pointCount = 1;
for (int i = 1; i < slots.size(); i++) {
LocalDateTime slot = slots.get(i);
String status = resolveStatus(slot, actualSlots);
if (!currentStatus.equals(status)) {
result.add(buildSegment(segmentStart, previousSlot, currentStatus, pointCount, intervalMinutes));
segmentStart = slot;
pointCount = 0;
currentStatus = status;
}
previousSlot = slot;
pointCount++;
}
result.add(buildSegment(segmentStart, previousSlot, currentStatus, pointCount, intervalMinutes));
return result;
}
public int maxContinuousMissingMinutes(List<SteadyChecksquareSegmentVO> segments) {
int result = 0;
if (segments == null) {
return result;
}
for (SteadyChecksquareSegmentVO segment : segments) {
if (segment != null && STATUS_MISSING.equals(segment.getStatus()) && segment.getDurationMinutes() != null) {
result = Math.max(result, segment.getDurationMinutes());
}
}
return result;
}
private SteadyChecksquareSegmentVO buildSegment(LocalDateTime startTime, LocalDateTime endTime, String status,
int pointCount, int intervalMinutes) {
SteadyChecksquareSegmentVO segment = new SteadyChecksquareSegmentVO();
segment.setStartTime(OUTPUT_TIME_FORMATTER.format(startTime));
segment.setEndTime(OUTPUT_TIME_FORMATTER.format(endTime));
segment.setStatus(status);
segment.setMissingPointCount(STATUS_MISSING.equals(status) ? pointCount : 0);
segment.setDurationMinutes(pointCount * intervalMinutes);
return segment;
}
private String resolveStatus(LocalDateTime slot, Set<LocalDateTime> actualSlots) {
return actualSlots != null && actualSlots.contains(slot) ? STATUS_NORMAL : STATUS_MISSING;
}
}

View File

@@ -0,0 +1,201 @@
package com.njcn.gather.steady.checksquare.component;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.steady.datavie.config.SteadyInfluxDbProperties;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URLEncoder;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.Set;
/**
* 数据校验 InfluxDB 查询组件。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SteadyChecksquareInfluxQueryComponent {
private static final DateTimeFormatter INFLUX_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final SteadyInfluxDbProperties properties;
public Set<LocalDateTime> queryExistingSlots(SteadyTrendResolvedFieldBO field, LocalDateTime startTime,
LocalDateTime endTime, int intervalMinutes) {
validateConfig();
String query = buildChecksquareQuery(field, startTime, endTime);
long startMillis = System.currentTimeMillis();
log.info("数据校验 InfluxDB 查询开始measurement={}field={}lineId={}phase={}statType={}query={}",
field.getMeasurement(), field.getField(), field.getLineId(), field.getPhase(), field.getStatType(), query);
try {
String body = executeQuery(query);
Set<LocalDateTime> slots = parseExistingSlots(body, intervalMinutes);
log.info("数据校验 InfluxDB 查询结束slotCount={}costMs={}", slots.size(), System.currentTimeMillis() - startMillis);
return slots;
} catch (RuntimeException ex) {
log.warn("数据校验 InfluxDB 查询异常costMs={}error={}", System.currentTimeMillis() - startMillis, ex.getMessage());
throw ex;
}
}
public String buildChecksquareQuery(SteadyTrendResolvedFieldBO field, LocalDateTime startTime, LocalDateTime endTime) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT \"").append(field.getField()).append("\" AS \"value\"");
sql.append(" FROM \"").append(field.getMeasurement()).append("\"");
sql.append(" WHERE time >= '").append(INFLUX_TIME_FORMATTER.format(startTime)).append("'");
sql.append(" AND time <= '").append(INFLUX_TIME_FORMATTER.format(endTime)).append("'");
sql.append(" AND \"line_id\" = '").append(escapeTagValue(field.getLineId())).append("'");
sql.append(" AND \"phasic_type\" = '").append(escapeTagValue(field.getPhase())).append("'");
if (hasValueTypeTag(field.getMeasurement())) {
sql.append(" AND \"value_type\" = '").append(resolveValueType(field.getStatType())).append("'");
}
sql.append(" ORDER BY time ASC");
return sql.toString();
}
private Set<LocalDateTime> parseExistingSlots(String body, int intervalMinutes) {
try {
JsonNode root = OBJECT_MAPPER.readTree(body);
JsonNode values = root.path("results").path(0).path("series").path(0).path("values");
Set<LocalDateTime> result = new HashSet<LocalDateTime>();
if (!values.isArray()) {
return result;
}
for (JsonNode value : values) {
if (value.size() < 2 || value.get(1).isNull()) {
continue;
}
LocalDateTime time = parseInfluxTime(value.get(0).asText());
if (time != null) {
result.add(alignToPreviousSlot(time, intervalMinutes));
}
}
return result;
} catch (IOException ex) {
throw fail("InfluxDB 返回结果解析失败:" + ex.getMessage());
}
}
private LocalDateTime alignToPreviousSlot(LocalDateTime time, int intervalMinutes) {
LocalDateTime minuteFloor = time.withSecond(0).withNano(0);
int minuteOfDay = minuteFloor.getHour() * 60 + minuteFloor.getMinute();
int remainder = minuteOfDay % intervalMinutes;
return minuteFloor.minusMinutes(remainder);
}
private LocalDateTime parseInfluxTime(String value) {
try {
return OffsetDateTime.parse(value).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
} catch (RuntimeException ex) {
return null;
}
}
private String executeQuery(String query) {
HttpURLConnection connection = null;
try {
URL url = new URL(buildQueryUrl(query));
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(properties.getConnectTimeoutMs());
connection.setReadTimeout(properties.getReadTimeoutMs());
int status = connection.getResponseCode();
InputStream stream = status >= 200 && status < 300 ? connection.getInputStream() : connection.getErrorStream();
String body = readBody(stream);
if (status < 200 || status >= 300) {
throw fail("InfluxDB 查询失败:" + body);
}
return body;
} catch (IOException ex) {
throw fail("InfluxDB 查询异常:" + ex.getMessage());
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private String buildQueryUrl(String query) throws IOException {
StringBuilder url = new StringBuilder(trimRightSlash(properties.getUrl())).append("/query?");
url.append("db=").append(encode(properties.getDatabase()));
if (properties.getUsername() != null && !properties.getUsername().trim().isEmpty()) {
url.append("&u=").append(encode(properties.getUsername().trim()));
}
if (properties.getPassword() != null && !properties.getPassword().trim().isEmpty()) {
url.append("&p=").append(encode(properties.getPassword()));
}
url.append("&q=").append(encode(query));
return url.toString();
}
private void validateConfig() {
if (properties.getUrl() == null || properties.getUrl().trim().isEmpty()) {
throw fail("InfluxDB 地址未配置");
}
if (properties.getDatabase() == null || properties.getDatabase().trim().isEmpty()) {
throw fail("InfluxDB database 未配置");
}
}
private String readBody(InputStream stream) throws IOException {
if (stream == null) {
return "";
}
BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8));
StringBuilder body = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
return body.toString();
}
private String escapeTagValue(String value) {
return value == null ? "" : value.replace("\\", "\\\\").replace("'", "\\'");
}
private String resolveValueType(String statType) {
if (statType == null || statType.trim().isEmpty()) {
return "AVG";
}
return statType.trim().toUpperCase();
}
private boolean hasValueTypeTag(String measurement) {
return !"data_flicker".equals(measurement) && !"data_fluc".equals(measurement) && !"data_plt".equals(measurement);
}
private String trimRightSlash(String value) {
String text = value.trim();
while (text.endsWith("/")) {
text = text.substring(0, text.length() - 1);
}
return text;
}
private String encode(String value) throws IOException {
return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
}
private BusinessException fail(String message) {
return new BusinessException(CommonResponseEnum.FAIL, message);
}
}

View File

@@ -0,0 +1,43 @@
package com.njcn.gather.steady.checksquare.controller;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.common.utils.LogUtil;
import com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareQueryVO;
import com.njcn.gather.steady.checksquare.service.SteadyChecksquareService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 数据校验接口。
*/
@Slf4j
@Api(tags = "数据校验")
@RestController
@RequestMapping("/steady/data-view/checksquare")
@RequiredArgsConstructor
public class SteadyChecksquareController extends BaseController {
private final SteadyChecksquareService checksquareService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询数据校验结果")
@PostMapping("/query")
public HttpResult<SteadyChecksquareQueryVO> query(@RequestBody SteadyChecksquareQueryParam param) {
String methodDescribe = getMethodDescribe("query");
LogUtil.njcnDebug(log, "{}开始查询数据校验结果param={}", methodDescribe, param);
SteadyChecksquareQueryVO result = checksquareService.query(param);
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, result, methodDescribe);
}
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.steady.checksquare.pojo.param;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 数据校验查询参数。
*/
@Data
@ApiModel("数据校验查询参数")
public class SteadyChecksquareQueryParam implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("监测点 ID")
private String lineId;
@ApiModelProperty("指标编码")
private List<String> indicatorCodes;
@ApiModelProperty("开始时间,格式 yyyy-MM-dd HH:mm:ss")
private String timeStart;
@ApiModelProperty("结束时间,格式 yyyy-MM-dd HH:mm:ss")
private String timeEnd;
@ApiModelProperty("谐波次数,谐波指标按请求次数查询")
private List<Integer> harmonicOrders;
}

View File

@@ -0,0 +1,62 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 数据校验总览项。
*/
@Data
@ApiModel("数据校验总览项")
public class SteadyChecksquareItemVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("校验项唯一键")
private String itemKey;
@ApiModelProperty("指标编码")
private String indicatorCode;
@ApiModelProperty("指标名称")
private String indicatorName;
@ApiModelProperty("谐波次数")
private Integer harmonicOrder;
@ApiModelProperty("当前校验项统计间隔,单位分钟")
private Integer intervalMinutes;
@ApiModelProperty("时间范围内是否存在任意数据")
private Boolean hasData;
@ApiModelProperty("期望点数")
private Integer expectedPointCount;
@ApiModelProperty("实际点数")
private Integer actualPointCount;
@ApiModelProperty("缺失点数")
private Integer missingPointCount;
@ApiModelProperty("缺失率")
private BigDecimal missingRate;
@ApiModelProperty("缺失率文本")
private String missingRateText;
@ApiModelProperty("最大连续缺失时长,单位分钟")
private Integer maxContinuousMissingMinutes;
@ApiModelProperty("统计类型摘要")
private List<SteadyChecksquareStatSummaryVO> statSummaries = new ArrayList<SteadyChecksquareStatSummaryVO>();
@ApiModelProperty("统计类型明细")
private List<SteadyChecksquareStatDetailVO> statDetails = new ArrayList<SteadyChecksquareStatDetailVO>();
}

View File

@@ -0,0 +1,37 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 数据校验查询结果。
*/
@Data
@ApiModel("数据校验查询结果")
public class SteadyChecksquareQueryVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("监测点 ID")
private String lineId;
@ApiModelProperty("监测点名称")
private String lineName;
@ApiModelProperty("开始时间")
private String timeStart;
@ApiModelProperty("结束时间")
private String timeEnd;
@ApiModelProperty("统计间隔,单位分钟")
private Integer intervalMinutes;
@ApiModelProperty("校验项")
private List<SteadyChecksquareItemVO> items = new ArrayList<SteadyChecksquareItemVO>();
}

View File

@@ -0,0 +1,32 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 数据校验连续性区间。
*/
@Data
@ApiModel("数据校验连续性区间")
public class SteadyChecksquareSegmentVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("开始时间")
private String startTime;
@ApiModelProperty("结束时间")
private String endTime;
@ApiModelProperty("状态NORMAL/MISSING")
private String status;
@ApiModelProperty("缺失点数")
private Integer missingPointCount;
@ApiModelProperty("持续时长,单位分钟")
private Integer durationMinutes;
}

View File

@@ -0,0 +1,28 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 数据校验统计类型明细。
*/
@Data
@ApiModel("数据校验统计类型明细")
public class SteadyChecksquareStatDetailVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("统计类型")
private String statType;
@ApiModelProperty("是否支持")
private Boolean supported;
@ApiModelProperty("连续性区间")
private List<SteadyChecksquareSegmentVO> segments = new ArrayList<SteadyChecksquareSegmentVO>();
}

View File

@@ -0,0 +1,45 @@
package com.njcn.gather.steady.checksquare.pojo.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 数据校验统计类型摘要。
*/
@Data
@ApiModel("数据校验统计类型摘要")
public class SteadyChecksquareStatSummaryVO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("统计类型")
private String statType;
@ApiModelProperty("是否支持")
private Boolean supported;
@ApiModelProperty("是否存在数据")
private Boolean hasData;
@ApiModelProperty("期望点数")
private Integer expectedPointCount;
@ApiModelProperty("实际点数")
private Integer actualPointCount;
@ApiModelProperty("缺失点数")
private Integer missingPointCount;
@ApiModelProperty("缺失率")
private BigDecimal missingRate;
@ApiModelProperty("缺失率文本")
private String missingRateText;
@ApiModelProperty("最大连续缺失时长,单位分钟")
private Integer maxContinuousMissingMinutes;
}

View File

@@ -0,0 +1,12 @@
package com.njcn.gather.steady.checksquare.service;
import com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareQueryVO;
/**
* 数据校验服务。
*/
public interface SteadyChecksquareService {
SteadyChecksquareQueryVO query(SteadyChecksquareQueryParam param);
}

View File

@@ -0,0 +1,349 @@
package com.njcn.gather.steady.checksquare.service.impl;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.steady.checksquare.component.SteadyChecksquareCalculator;
import com.njcn.gather.steady.checksquare.component.SteadyChecksquareInfluxQueryComponent;
import com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareItemVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareQueryVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareSegmentVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareStatDetailVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareStatSummaryVO;
import com.njcn.gather.steady.checksquare.service.SteadyChecksquareService;
import com.njcn.gather.steady.datavie.component.SteadyTrendIndicatorCatalog;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendIndicatorDefinitionBO;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendSeriesFieldBO;
import com.njcn.gather.tool.adddata.component.AddDataTimeSlotCalculator;
import com.njcn.gather.tool.addledger.pojo.constant.AddLedgerConst;
import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO;
import com.njcn.gather.tool.addledger.service.AddLedgerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 数据校验服务实现。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SteadyChecksquareServiceImpl implements SteadyChecksquareService {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String EMPTY_TEXT = "-";
private static final int FLICKER_SHORT_INTERVAL_MINUTES = 10;
private static final int FLICKER_LONG_INTERVAL_MINUTES = 120;
private final SteadyTrendIndicatorCatalog indicatorCatalog;
private final SteadyChecksquareInfluxQueryComponent influxQueryComponent;
private final SteadyChecksquareCalculator calculator;
private final AddDataTimeSlotCalculator timeSlotCalculator;
private final AddLedgerService addLedgerService;
@Override
public SteadyChecksquareQueryVO query(SteadyChecksquareQueryParam param) {
validateParam(param);
String lineId = trimToNull(param.getLineId());
LocalDateTime startTime = parseRequiredTime(param.getTimeStart(), "开始时间不能为空");
LocalDateTime endTime = parseRequiredTime(param.getTimeEnd(), "结束时间不能为空");
if (startTime.isAfter(endTime)) {
throw fail("开始时间不能大于结束时间");
}
AddLedgerLinePathVO linePath = requireLinePath(lineId);
int intervalMinutes = resolveIntervalMinutes(linePath);
SteadyChecksquareQueryVO result = new SteadyChecksquareQueryVO();
result.setLineId(lineId);
result.setLineName(trimToNull(linePath.getLineName()) == null ? EMPTY_TEXT : linePath.getLineName());
result.setTimeStart(param.getTimeStart());
result.setTimeEnd(param.getTimeEnd());
result.setIntervalMinutes(intervalMinutes);
long startMillis = System.currentTimeMillis();
List<String> indicatorCodes = normalizeTextList(param.getIndicatorCodes());
List<Integer> harmonicOrders = normalizeHarmonicOrders(param.getHarmonicOrders());
log.info("数据校验查询开始lineId={}indicatorCount={}timeStart={}timeEnd={}intervalMinutes={}",
lineId, indicatorCodes.size(), startTime, endTime, intervalMinutes);
for (String indicatorCode : indicatorCodes) {
SteadyTrendIndicatorDefinitionBO indicator = requireIndicator(indicatorCode);
int itemIntervalMinutes = resolveIndicatorIntervalMinutes(indicator, intervalMinutes);
List<LocalDateTime> itemSlots = timeSlotCalculator.buildTimeSlots(startTime, endTime, itemIntervalMinutes);
result.getItems().addAll(buildIndicatorItems(lineId, indicator, harmonicOrders, startTime, endTime, itemSlots, itemIntervalMinutes));
}
log.info("数据校验查询结束lineId={}itemCount={}costMs={}", lineId, result.getItems().size(), System.currentTimeMillis() - startMillis);
return result;
}
private List<SteadyChecksquareItemVO> buildIndicatorItems(String lineId, SteadyTrendIndicatorDefinitionBO indicator,
List<Integer> harmonicOrders,
LocalDateTime startTime, LocalDateTime endTime,
List<LocalDateTime> slots, int intervalMinutes) {
List<SteadyChecksquareItemVO> result = new ArrayList<SteadyChecksquareItemVO>();
if (Boolean.TRUE.equals(indicator.getHarmonic())) {
for (Integer order : requireValidHarmonicOrders(indicator, harmonicOrders)) {
result.add(buildItem(lineId, indicator, order, startTime, endTime, slots, intervalMinutes));
}
return result;
}
result.add(buildItem(lineId, indicator, null, startTime, endTime, slots, intervalMinutes));
return result;
}
private SteadyChecksquareItemVO buildItem(String lineId, SteadyTrendIndicatorDefinitionBO indicator, Integer harmonicOrder,
LocalDateTime startTime, LocalDateTime endTime,
List<LocalDateTime> slots, int intervalMinutes) {
SteadyChecksquareItemVO item = new SteadyChecksquareItemVO();
item.setItemKey(buildItemKey(lineId, indicator, harmonicOrder));
item.setIndicatorCode(indicator.getIndicatorCode());
item.setIndicatorName(indicator.getName());
item.setHarmonicOrder(harmonicOrder);
item.setIntervalMinutes(intervalMinutes);
int totalExpected = 0;
int totalActual = 0;
int maxContinuousMissingMinutes = 0;
boolean hasData = false;
for (String statType : indicator.getSupportStats()) {
Set<LocalDateTime> actualSlots = queryMergedActualSlots(lineId, indicator, harmonicOrder, statType, startTime, endTime, intervalMinutes);
Set<LocalDateTime> effectiveActualSlots = retainExpectedSlots(slots, actualSlots);
List<SteadyChecksquareSegmentVO> segments = calculator.buildSegments(slots, effectiveActualSlots, intervalMinutes);
SteadyChecksquareStatSummaryVO summary = buildSummary(statType, slots.size(), effectiveActualSlots.size(), segments);
SteadyChecksquareStatDetailVO detail = buildDetail(statType, segments);
item.getStatSummaries().add(summary);
item.getStatDetails().add(detail);
totalExpected += summary.getExpectedPointCount();
totalActual += summary.getActualPointCount();
maxContinuousMissingMinutes = Math.max(maxContinuousMissingMinutes, summary.getMaxContinuousMissingMinutes());
hasData = hasData || Boolean.TRUE.equals(summary.getHasData());
}
item.setHasData(hasData);
item.setExpectedPointCount(totalExpected);
item.setActualPointCount(totalActual);
item.setMissingPointCount(Math.max(0, totalExpected - totalActual));
item.setMissingRate(calculateRate(item.getMissingPointCount(), totalExpected));
item.setMissingRateText(formatRateText(item.getMissingRate()));
item.setMaxContinuousMissingMinutes(maxContinuousMissingMinutes);
return item;
}
private Set<LocalDateTime> queryMergedActualSlots(String lineId, SteadyTrendIndicatorDefinitionBO indicator, Integer harmonicOrder,
String statType, LocalDateTime startTime, LocalDateTime endTime,
int intervalMinutes) {
Set<LocalDateTime> result = new HashSet<LocalDateTime>();
for (String phase : indicator.getPhaseCodes()) {
SteadyTrendResolvedFieldBO field = buildResolvedField(lineId, indicator, harmonicOrder, phase, statType);
result.addAll(influxQueryComponent.queryExistingSlots(field, startTime, endTime, intervalMinutes));
}
return result;
}
private Set<LocalDateTime> retainExpectedSlots(List<LocalDateTime> slots, Set<LocalDateTime> actualSlots) {
Set<LocalDateTime> result = new HashSet<LocalDateTime>();
if (slots == null || actualSlots == null || actualSlots.isEmpty()) {
return result;
}
for (LocalDateTime slot : slots) {
if (actualSlots.contains(slot)) {
result.add(slot);
}
}
return result;
}
private SteadyTrendResolvedFieldBO buildResolvedField(String lineId, SteadyTrendIndicatorDefinitionBO indicator,
Integer harmonicOrder, String phase, String statType) {
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement(indicator.getTableName());
field.setField(resolveField(indicator, harmonicOrder));
field.setLineId(lineId);
field.setIndicatorCode(indicator.getIndicatorCode());
field.setIndicatorName(indicator.getName());
field.setPhase(phase);
field.setStatType(statType);
field.setUnit(indicator.getUnit());
return field;
}
private String resolveField(SteadyTrendIndicatorDefinitionBO indicator, Integer harmonicOrder) {
if (Boolean.TRUE.equals(indicator.getHarmonic())) {
return indicator.getHarmonicFieldPrefix() + "_" + harmonicOrder;
}
List<SteadyTrendSeriesFieldBO> fields = indicator.getSeriesFields();
if (fields == null || fields.isEmpty()) {
throw fail("稳态指标不支持:" + indicator.getIndicatorCode());
}
return fields.get(0).getField();
}
private SteadyChecksquareStatSummaryVO buildSummary(String statType, int expectedCount, int actualCount,
List<SteadyChecksquareSegmentVO> segments) {
SteadyChecksquareStatSummaryVO summary = new SteadyChecksquareStatSummaryVO();
summary.setStatType(statType);
summary.setSupported(true);
summary.setHasData(actualCount > 0);
summary.setExpectedPointCount(expectedCount);
summary.setActualPointCount(actualCount);
summary.setMissingPointCount(Math.max(0, expectedCount - actualCount));
summary.setMissingRate(calculateRate(summary.getMissingPointCount(), expectedCount));
summary.setMissingRateText(formatRateText(summary.getMissingRate()));
summary.setMaxContinuousMissingMinutes(calculator.maxContinuousMissingMinutes(segments));
return summary;
}
private SteadyChecksquareStatDetailVO buildDetail(String statType, List<SteadyChecksquareSegmentVO> segments) {
SteadyChecksquareStatDetailVO detail = new SteadyChecksquareStatDetailVO();
detail.setStatType(statType);
detail.setSupported(true);
detail.setSegments(segments);
return detail;
}
private String buildItemKey(String lineId, SteadyTrendIndicatorDefinitionBO indicator, Integer harmonicOrder) {
if (harmonicOrder == null) {
return lineId + "|" + indicator.getIndicatorCode();
}
return lineId + "|" + indicator.getIndicatorCode() + "|" + harmonicOrder;
}
private void validateParam(SteadyChecksquareQueryParam param) {
if (param == null) {
throw fail("数据校验参数不能为空");
}
if (trimToNull(param.getLineId()) == null) {
throw fail("监测点 ID 不能为空");
}
if (normalizeTextList(param.getIndicatorCodes()).isEmpty()) {
throw fail("指标不能为空");
}
parseRequiredTime(param.getTimeStart(), "开始时间不能为空");
parseRequiredTime(param.getTimeEnd(), "结束时间不能为空");
}
private LocalDateTime parseRequiredTime(String time, String emptyMessage) {
String text = trimToNull(time);
if (text == null) {
throw fail(emptyMessage);
}
try {
return LocalDateTime.parse(text, TIME_FORMATTER);
} catch (DateTimeParseException ex) {
throw fail("时间格式不正确,仅支持 yyyy-MM-dd HH:mm:ss");
}
}
private AddLedgerLinePathVO requireLinePath(String lineId) {
Map<String, AddLedgerLinePathVO> linePathMap = addLedgerService.listLinePathByLineIds(Collections.singletonList(lineId));
AddLedgerLinePathVO linePath = linePathMap.get(lineId);
if (linePath == null) {
throw fail("监测点不存在或不可用");
}
return linePath;
}
private int resolveIntervalMinutes(AddLedgerLinePathVO linePath) {
Integer interval = linePath.getLineInterval();
if (interval == null || interval <= 0) {
return AddLedgerConst.LINE_INTERVAL_DEFAULT;
}
return interval;
}
private int resolveIndicatorIntervalMinutes(SteadyTrendIndicatorDefinitionBO indicator, int lineIntervalMinutes) {
String indicatorCode = indicator == null ? null : indicator.getIndicatorCode();
if ("FLUC".equals(indicatorCode) || "PST".equals(indicatorCode)) {
return FLICKER_SHORT_INTERVAL_MINUTES;
}
if ("PLT".equals(indicatorCode)) {
return FLICKER_LONG_INTERVAL_MINUTES;
}
return lineIntervalMinutes;
}
private SteadyTrendIndicatorDefinitionBO requireIndicator(String indicatorCode) {
SteadyTrendIndicatorDefinitionBO indicator = indicatorCatalog.getIndicator(indicatorCode);
if (indicator == null) {
throw fail("稳态指标不支持:" + indicatorCode);
}
return indicator;
}
private BigDecimal calculateRate(int missingCount, int expectedCount) {
if (expectedCount <= 0) {
return BigDecimal.ZERO.setScale(6, RoundingMode.HALF_UP);
}
return new BigDecimal(missingCount).divide(new BigDecimal(expectedCount), 6, RoundingMode.HALF_UP);
}
private String formatRateText(BigDecimal rate) {
if (rate == null) {
return null;
}
return rate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP).toPlainString() + "%";
}
private List<String> normalizeTextList(List<String> values) {
if (values == null || values.isEmpty()) {
return new ArrayList<String>();
}
Set<String> result = new LinkedHashSet<String>();
for (String value : values) {
String text = trimToNull(value);
if (text != null) {
result.add(text);
}
}
return new ArrayList<String>(result);
}
private List<Integer> normalizeHarmonicOrders(List<Integer> values) {
if (values == null || values.isEmpty()) {
return new ArrayList<Integer>();
}
List<Integer> result = new ArrayList<Integer>();
for (Integer value : values) {
if (value != null && !result.contains(value)) {
result.add(value);
}
}
return result;
}
private List<Integer> requireValidHarmonicOrders(SteadyTrendIndicatorDefinitionBO indicator, List<Integer> harmonicOrders) {
if (harmonicOrders == null || harmonicOrders.isEmpty()) {
throw fail("谐波次数不能为空");
}
for (Integer order : harmonicOrders) {
if (order < indicator.getHarmonicOrderStart() || order > indicator.getHarmonicOrderEnd()) {
throw fail("谐波次数只能在 " + indicator.getHarmonicOrderStart() + "" + indicator.getHarmonicOrderEnd() + " 之间");
}
}
return harmonicOrders;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private BusinessException fail(String message) {
return new BusinessException(CommonResponseEnum.FAIL, message);
}
}

View File

@@ -8,6 +8,7 @@ import com.njcn.gather.steady.datavie.config.SteadyInfluxDbProperties;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import com.njcn.gather.steady.datavie.pojo.vo.SteadyTrendPointVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
@@ -29,6 +30,7 @@ import java.util.List;
/**
* 稳态趋势 InfluxDB 查询组件。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SteadyInfluxQueryComponent {
@@ -43,8 +45,32 @@ public class SteadyInfluxQueryComponent {
LocalDateTime endTime, Integer qualityFlag) {
validateConfig();
String query = buildTrendQuery(field, startTime, endTime, qualityFlag);
String body = executeQuery(query);
return parseTrendPoints(body);
String diagnostic = buildTrendQueryDiagnostic(field, startTime, endTime, qualityFlag);
long startMillis = System.currentTimeMillis();
log.info("稳态趋势 InfluxDB 查询开始,{}query={}", diagnostic, query);
try {
String body = executeQuery(query);
List<SteadyTrendPointVO> points = parseTrendPoints(body);
log.info("稳态趋势 InfluxDB 查询结束,{}pointCount={}costMs={}", diagnostic, points.size(), System.currentTimeMillis() - startMillis);
return points;
} catch (RuntimeException ex) {
log.warn("稳态趋势 InfluxDB 查询异常,{}costMs={}error={}", diagnostic, System.currentTimeMillis() - startMillis, ex.getMessage());
throw ex;
}
}
String buildTrendQueryDiagnostic(SteadyTrendResolvedFieldBO field, LocalDateTime startTime, LocalDateTime endTime,
Integer qualityFlag) {
StringBuilder diagnostic = new StringBuilder();
diagnostic.append("measurement=").append(field.getMeasurement());
diagnostic.append(", field=").append(field.getField());
diagnostic.append(", lineId=").append(field.getLineId());
diagnostic.append(", phase=").append(field.getPhase());
diagnostic.append(", statType=").append(resolveValueType(field.getStatType()));
diagnostic.append(", qualityFlag=").append(qualityFlag);
diagnostic.append(", timeStart=").append(startTime);
diagnostic.append(", timeEnd=").append(endTime);
return diagnostic.toString();
}
public String buildTrendQuery(SteadyTrendResolvedFieldBO field, LocalDateTime startTime, LocalDateTime endTime,

View File

@@ -26,7 +26,7 @@ public class SteadyTrendFieldResolver {
private static final int MAX_LINE_COUNT = 8;
private static final int MAX_INDICATOR_COUNT = 8;
private static final int MAX_SERIES_COUNT = 24;
private static final int MAX_HARMONIC_ORDER_COUNT = 6;
private static final int MAX_HARMONIC_ORDER_COUNT = 3;
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final SteadyTrendIndicatorCatalog indicatorCatalog;
@@ -89,7 +89,7 @@ public class SteadyTrendFieldResolver {
throw fail("谐波次数不能为空");
}
if (orders.size() > MAX_HARMONIC_ORDER_COUNT) {
throw fail("谐波次数最多选择 6 ");
throw fail("谐波次数不允许一次展示超过3");
}
List<SteadyTrendResolvedFieldBO> result = new ArrayList<SteadyTrendResolvedFieldBO>();
for (Integer order : orders) {

View File

@@ -32,6 +32,6 @@ public class SteadyTrendQueryParam {
@ApiModelProperty("质量标识")
private Integer qualityFlag;
@ApiModelProperty("谐波次数,谐波指标必填,最多 6")
@ApiModelProperty("谐波次数,谐波指标必填,默认最多展示 3")
private List<Integer> harmonicOrders = new ArrayList<Integer>();
}

View File

@@ -13,6 +13,7 @@ import com.njcn.gather.steady.datavie.service.SteadyDataViewTrendService;
import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO;
import com.njcn.gather.tool.addledger.service.AddLedgerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@@ -28,6 +29,7 @@ import java.util.Map;
/**
* 稳态趋势查询服务实现。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendService {
@@ -70,11 +72,14 @@ public class SteadyDataViewTrendServiceImpl implements SteadyDataViewTrendServic
result.setSampled(false);
result.setLoadableDays(resolveLoadableDays(startTime, endTime));
int displayPointCount = 0;
long startMillis = System.currentTimeMillis();
log.info("稳态趋势查询开始seriesCount={}timeStart={}timeEnd={}qualityFlag={}", fields.size(), startTime, endTime, param.getQualityFlag());
for (SteadyTrendResolvedFieldBO field : fields) {
List<SteadyTrendPointVO> points = influxQueryComponent.queryTrendPoints(field, startTime, endTime, param.getQualityFlag());
displayPointCount += points.size();
result.getSeries().add(buildSeries(field, points));
}
log.info("稳态趋势查询结束seriesCount={}displayPointCount={}costMs={}", fields.size(), displayPointCount, System.currentTimeMillis() - startMillis);
/*
* 当前 Influx 查询按曲线独立执行,未额外发 count 查询sourcePointCount 保持与实际返回点数一致。
* 后续如需要精确原始点数,可单独增加 count(field) 查询。

View File

@@ -0,0 +1,38 @@
package com.njcn.gather.steady.checksquare.component;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareSegmentVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
/**
* 数据校验缺失区间计算测试。
*/
class SteadyChecksquareCalculatorTest {
@Test
void shouldMergeContinuousMissingSlots() {
SteadyChecksquareCalculator calculator = new SteadyChecksquareCalculator();
List<LocalDateTime> slots = Arrays.asList(
LocalDateTime.of(2026, 5, 1, 0, 0),
LocalDateTime.of(2026, 5, 1, 0, 1),
LocalDateTime.of(2026, 5, 1, 0, 2),
LocalDateTime.of(2026, 5, 1, 0, 3),
LocalDateTime.of(2026, 5, 1, 0, 4)
);
List<SteadyChecksquareSegmentVO> segments = calculator.buildSegments(slots,
new HashSet<LocalDateTime>(Arrays.asList(slots.get(0), slots.get(3))), 1);
Assertions.assertEquals(4, segments.size());
Assertions.assertEquals("MISSING", segments.get(1).getStatus());
Assertions.assertEquals("2026-05-01 00:01:00", segments.get(1).getStartTime());
Assertions.assertEquals("2026-05-01 00:02:00", segments.get(1).getEndTime());
Assertions.assertEquals(Integer.valueOf(2), segments.get(1).getMissingPointCount());
Assertions.assertEquals(Integer.valueOf(2), segments.get(1).getDurationMinutes());
}
}

View File

@@ -0,0 +1,36 @@
package com.njcn.gather.steady.checksquare.component;
import com.njcn.gather.steady.datavie.config.SteadyInfluxDbProperties;
import com.njcn.gather.steady.datavie.pojo.bo.SteadyTrendResolvedFieldBO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
/**
* 数据校验 InfluxQL 构造契约测试。
*/
class SteadyChecksquareInfluxQueryComponentTest {
@Test
void shouldBuildChecksquareQueryWithoutQualityFlag() {
SteadyChecksquareInfluxQueryComponent component = new SteadyChecksquareInfluxQueryComponent(new SteadyInfluxDbProperties());
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_v");
field.setField("rms");
field.setLineId("line-001");
field.setPhase("A");
field.setStatType("AVG");
String query = component.buildChecksquareQuery(field,
LocalDateTime.of(2026, 5, 1, 0, 0, 0),
LocalDateTime.of(2026, 5, 1, 23, 59, 59));
Assertions.assertTrue(query.contains("SELECT \"rms\" AS \"value\""));
Assertions.assertTrue(query.contains("\"line_id\" = 'line-001'"));
Assertions.assertTrue(query.contains("\"phasic_type\" = 'A'"));
Assertions.assertTrue(query.contains("\"value_type\" = 'AVG'"));
Assertions.assertFalse(query.contains("quality_flag"));
Assertions.assertFalse(query.contains("GROUP BY time"));
}
}

View File

@@ -0,0 +1,24 @@
package com.njcn.gather.steady.checksquare.controller;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
/**
* 数据校验接口契约测试。
*/
class SteadyChecksquareControllerTest {
@Test
void shouldExposeChecksquareQueryEndpointInSeparateController() throws Exception {
RequestMapping requestMapping = SteadyChecksquareController.class.getAnnotation(RequestMapping.class);
Assertions.assertArrayEquals(new String[]{"/steady/data-view/checksquare"}, requestMapping.value());
Method method = SteadyChecksquareController.class.getDeclaredMethod("query", com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam.class);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
Assertions.assertArrayEquals(new String[]{"/query"}, postMapping.value());
}
}

View File

@@ -0,0 +1,32 @@
package com.njcn.gather.steady.checksquare.pojo.param;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
/**
* 数据校验查询参数契约测试。
*/
class SteadyChecksquareQueryParamTest {
@Test
void shouldOnlyExposeChecksquareQueryFields() {
Assertions.assertNotNull(field("lineId"));
Assertions.assertNotNull(field("indicatorCodes"));
Assertions.assertNotNull(field("timeStart"));
Assertions.assertNotNull(field("timeEnd"));
Assertions.assertNull(field("qualityFlag"));
Assertions.assertNull(field("statTypes"));
Assertions.assertNull(field("phases"));
Assertions.assertNotNull(field("harmonicOrders"));
}
private Field field(String name) {
try {
return SteadyChecksquareQueryParam.class.getDeclaredField(name);
} catch (NoSuchFieldException ex) {
return null;
}
}
}

View File

@@ -0,0 +1,130 @@
package com.njcn.gather.steady.checksquare.service.impl;
import com.njcn.gather.steady.checksquare.component.SteadyChecksquareCalculator;
import com.njcn.gather.steady.checksquare.component.SteadyChecksquareInfluxQueryComponent;
import com.njcn.gather.steady.checksquare.pojo.param.SteadyChecksquareQueryParam;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareItemVO;
import com.njcn.gather.steady.checksquare.pojo.vo.SteadyChecksquareQueryVO;
import com.njcn.gather.steady.datavie.component.SteadyTrendIndicatorCatalog;
import com.njcn.gather.tool.adddata.component.AddDataTimeSlotCalculator;
import com.njcn.gather.tool.addledger.pojo.vo.AddLedgerLinePathVO;
import com.njcn.gather.tool.addledger.service.AddLedgerService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 数据校验服务测试。
*/
class SteadyChecksquareServiceImplTest {
@Test
void shouldUseFixedFlickerIntervalsPerIndicator() {
SteadyChecksquareInfluxQueryComponent influxQueryComponent = mock(SteadyChecksquareInfluxQueryComponent.class);
AddLedgerService addLedgerService = mock(AddLedgerService.class);
SteadyChecksquareServiceImpl service = new SteadyChecksquareServiceImpl(new SteadyTrendIndicatorCatalog(),
influxQueryComponent, new SteadyChecksquareCalculator(), new AddDataTimeSlotCalculator(), addLedgerService);
AddLedgerLinePathVO linePath = new AddLedgerLinePathVO();
linePath.setLineId("line-001");
linePath.setLineName("进线一");
linePath.setLineInterval(1);
when(addLedgerService.listLinePathByLineIds(eq(Collections.singletonList("line-001"))))
.thenReturn(Collections.singletonMap("line-001", linePath));
when(influxQueryComponent.queryExistingSlots(any(), any(LocalDateTime.class), any(LocalDateTime.class), eq(10)))
.thenReturn(new HashSet<LocalDateTime>(Arrays.asList(
LocalDateTime.of(2026, 5, 1, 0, 0),
LocalDateTime.of(2026, 5, 1, 0, 10))));
when(influxQueryComponent.queryExistingSlots(any(), any(LocalDateTime.class), any(LocalDateTime.class), eq(120)))
.thenReturn(new HashSet<LocalDateTime>(Collections.singletonList(
LocalDateTime.of(2026, 5, 1, 0, 0))));
SteadyChecksquareQueryParam param = new SteadyChecksquareQueryParam();
param.setLineId("line-001");
param.setIndicatorCodes(Arrays.asList("FLUC", "PST", "PLT"));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 02:00:00");
SteadyChecksquareQueryVO result = service.query(param);
Assertions.assertEquals(Integer.valueOf(1), result.getIntervalMinutes());
Assertions.assertEquals(3, result.getItems().size());
assertItemInterval(result.getItems().get(0), "FLUC", 10, 13);
assertItemInterval(result.getItems().get(1), "PST", 10, 13);
assertItemInterval(result.getItems().get(2), "PLT", 120, 2);
}
@Test
void shouldOnlyQueryRequestedHarmonicOrders() {
SteadyChecksquareInfluxQueryComponent influxQueryComponent = mock(SteadyChecksquareInfluxQueryComponent.class);
AddLedgerService addLedgerService = mock(AddLedgerService.class);
SteadyChecksquareServiceImpl service = new SteadyChecksquareServiceImpl(new SteadyTrendIndicatorCatalog(),
influxQueryComponent, new SteadyChecksquareCalculator(), new AddDataTimeSlotCalculator(), addLedgerService);
AddLedgerLinePathVO linePath = new AddLedgerLinePathVO();
linePath.setLineId("line-001");
linePath.setLineName("进线一");
linePath.setLineInterval(1);
when(addLedgerService.listLinePathByLineIds(eq(Collections.singletonList("line-001"))))
.thenReturn(Collections.singletonMap("line-001", linePath));
when(influxQueryComponent.queryExistingSlots(any(), any(LocalDateTime.class), any(LocalDateTime.class), eq(1)))
.thenReturn(new HashSet<LocalDateTime>(Collections.singletonList(
LocalDateTime.of(2026, 5, 1, 0, 0))));
SteadyChecksquareQueryParam param = new SteadyChecksquareQueryParam();
param.setLineId("line-001");
param.setIndicatorCodes(Collections.singletonList("V_HARMONIC"));
param.setHarmonicOrders(Collections.singletonList(5));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 00:01:00");
SteadyChecksquareQueryVO result = service.query(param);
Assertions.assertEquals(1, result.getItems().size());
Assertions.assertEquals(Integer.valueOf(5), result.getItems().get(0).getHarmonicOrder());
}
@Test
void shouldKeepRequestedHarmonicOrdersDistinctAndOrdered() {
SteadyChecksquareInfluxQueryComponent influxQueryComponent = mock(SteadyChecksquareInfluxQueryComponent.class);
AddLedgerService addLedgerService = mock(AddLedgerService.class);
SteadyChecksquareServiceImpl service = new SteadyChecksquareServiceImpl(new SteadyTrendIndicatorCatalog(),
influxQueryComponent, new SteadyChecksquareCalculator(), new AddDataTimeSlotCalculator(), addLedgerService);
AddLedgerLinePathVO linePath = new AddLedgerLinePathVO();
linePath.setLineId("line-001");
linePath.setLineName("进线一");
linePath.setLineInterval(1);
when(addLedgerService.listLinePathByLineIds(eq(Collections.singletonList("line-001"))))
.thenReturn(Collections.singletonMap("line-001", linePath));
when(influxQueryComponent.queryExistingSlots(any(), any(LocalDateTime.class), any(LocalDateTime.class), eq(1)))
.thenReturn(new HashSet<LocalDateTime>());
SteadyChecksquareQueryParam param = new SteadyChecksquareQueryParam();
param.setLineId("line-001");
param.setIndicatorCodes(Collections.singletonList("V_HARMONIC"));
param.setHarmonicOrders(Arrays.asList(7, 5, 7));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 00:01:00");
SteadyChecksquareQueryVO result = service.query(param);
List<SteadyChecksquareItemVO> items = result.getItems();
Assertions.assertEquals(2, items.size());
Assertions.assertEquals(Integer.valueOf(7), items.get(0).getHarmonicOrder());
Assertions.assertEquals(Integer.valueOf(5), items.get(1).getHarmonicOrder());
}
private void assertItemInterval(SteadyChecksquareItemVO item, String indicatorCode, int intervalMinutes, int expectedPointCount) {
Assertions.assertEquals(indicatorCode, item.getIndicatorCode());
Assertions.assertEquals(Integer.valueOf(intervalMinutes), item.getIntervalMinutes());
Assertions.assertEquals(Integer.valueOf(expectedPointCount), item.getExpectedPointCount());
}
}

View File

@@ -73,6 +73,31 @@ class SteadyInfluxQueryComponentTest {
Assertions.assertTrue(query.contains("\"value_type\" = 'AVG'"));
}
@Test
void shouldBuildDiagnosticTextForTrendQuery() {
SteadyInfluxQueryComponent component = new SteadyInfluxQueryComponent(new SteadyInfluxDbProperties());
SteadyTrendResolvedFieldBO field = new SteadyTrendResolvedFieldBO();
field.setMeasurement("data_harmpower_p");
field.setField("p_3");
field.setLineId("f828bc42132841c2aeebc6859f5a9b7c");
field.setPhase("A");
field.setStatType("AVG");
String diagnostic = component.buildTrendQueryDiagnostic(field,
LocalDateTime.of(2026, 5, 1, 0, 0, 0),
LocalDateTime.of(2026, 5, 31, 23, 59, 59),
0);
Assertions.assertTrue(diagnostic.contains("measurement=data_harmpower_p"));
Assertions.assertTrue(diagnostic.contains("field=p_3"));
Assertions.assertTrue(diagnostic.contains("lineId=f828bc42132841c2aeebc6859f5a9b7c"));
Assertions.assertTrue(diagnostic.contains("phase=A"));
Assertions.assertTrue(diagnostic.contains("statType=AVG"));
Assertions.assertTrue(diagnostic.contains("qualityFlag=0"));
Assertions.assertTrue(diagnostic.contains("timeStart=2026-05-01T00:00"));
Assertions.assertTrue(diagnostic.contains("timeEnd=2026-05-31T23:59:59"));
}
@Test
void shouldSkipValueTypeWhenMeasurementHasNoValueTypeTag() {
SteadyInfluxQueryComponent component = new SteadyInfluxQueryComponent(new SteadyInfluxDbProperties());

View File

@@ -109,6 +109,21 @@ class SteadyTrendFieldResolverTest {
Assertions.assertTrue(exception.getMessage().contains("谐波次数不能为空"));
}
@Test
void shouldRejectHarmonicTrendWithMoreThanThreeOrders() {
SteadyTrendQueryParam param = new SteadyTrendQueryParam();
param.setLineIds(Arrays.asList("line-001"));
param.setIndicatorCodes(Arrays.asList("V_HARMONIC"));
param.setStatTypes(Arrays.asList("AVG"));
param.setHarmonicOrders(Arrays.asList(3, 5, 7, 11));
param.setTimeStart("2026-05-01 00:00:00");
param.setTimeEnd("2026-05-01 01:00:00");
BusinessException exception = Assertions.assertThrows(BusinessException.class, () -> resolver.resolveFields(param));
Assertions.assertTrue(exception.getMessage().contains("谐波次数不允许一次展示超过3个"));
}
@Test
void shouldResolveSelectedHarmonicOrdersForAllCatalogPhases() {
SteadyTrendQueryParam param = new SteadyTrendQueryParam();

View File

@@ -34,7 +34,8 @@
equipment.name AS equipmentName,
equipment.mac AS equipmentMac,
line.line_id AS lineId,
line.name AS lineName
line.name AS lineName,
line.line_interval AS lineInterval
FROM cs_line line
INNER JOIN cs_equipment_delivery equipment ON equipment.id = line.device_id
INNER JOIN cs_project project ON project.id = equipment.associated_project
@@ -58,7 +59,8 @@
equipment.name AS equipmentName,
equipment.mac AS equipmentMac,
line.line_id AS lineId,
line.name AS lineName
line.name AS lineName,
line.line_interval AS lineInterval
FROM cs_line line
INNER JOIN cs_equipment_delivery equipment ON equipment.id = line.device_id
INNER JOIN cs_project project ON project.id = equipment.associated_project

View File

@@ -29,4 +29,7 @@ public class AddLedgerLinePathVO implements Serializable {
private String lineId;
private String lineName;
/** 监测点统计间隔,单位分钟。 */
private Integer lineInterval;
}