水文数据均值对比查询Bug修复全记录:从问题分析到完整修复

导读

今天我要给大家分享一个在广东水文项目中遇到的实际案例——水文数据均值对比查询功能的Bug修复。这个问题涉及到水文数据库的特殊存储约定、MyBatis XML映射文件的SQL编写、以及Java时间处理逻辑的综合考量。通过这个案例,我将从问题分析、根本原因定位、到最终修复方案的完整过程,希望能给有类似开发经验的朋友一些参考和启发。

前言

在水利信息化系统中,水文数据的统计分析是核心功能之一。今天在项目中遇到了均值对比查询API的异常——MONTH(月均值)和XUN(旬均值)类型的查询返回结果条数错误、时间对齐偏差、STTDRCD值错误等多个问题。这些问题单独看都不复杂,但组合在一起就需要对水文数据的存储机制有深入理解才能彻底解决。

让我先介绍一下背景:广东水文项目使用的是规范的水文数据库,其中ST_RVAV_R表存储着各种统计周期的均值数据,包括日均值、旬均值、月均值等。而问题恰恰出在对这些数据存储约定的理解偏差上。

问题背景与现象

用户反馈的问题

用户在调用均值对比查询API(/api/water/comparison/average)时,发现以下异常:

问题1:MONTH类型查询条数错误

  • 请求:2026年4月7日到5月7日的月均值数据
  • 期望返回:1条(只有4月份的月均值)
  • 实际返回:2条(4月和5月)

问题2:时间对齐错误

  • 前端收到的数据时间点显示为00:00:00
  • 但水文数据标准存储时间应该是08:00:00

问题3:旬均值时间映射完全错误

  • 上旬显示为1日,实际应该是11日
  • 中旬显示为11日,实际应该是21日
  • 下旬显示为21日,实际应该是下月1日

问题4:STTDRCD编码错误

  • 旬均值查询使用了STTDRCD=’3’(候均值,5天),而非’4’(旬均值)
  • 月均值查询使用了STTDRCD=’4’,而非’5’

影响范围评估

功能 状态 影响
日均值对比查询 可用
月均值对比查询 不可用
旬均值对比查询 不可用
月/旬历史对比 不可用

水文数据库核心知识

ST_RVAV_R存储约定说明图
图1:ST_RVAV_R表的数据存储时间点规则

在开始修复之前,我们必须先理解ST_RVAV_R表的存储约定。这是水文行业数据库的标准规范:

STTDRCD编码定义

统计类型 STTDRCD值 说明
日均值 ‘1’ 每天08:00作为水文日分界
候均值 ‘3’ 每5天一个统计周期
旬均值 ‘4’ 上中下旬各一个统计周期
月均值 ‘5’ 每月一个统计周期

数据存储时间点规则

关键理解:水文数据的时间点是”代表时段”而非”自然日期”。

统计类型 存储时间点 示例
日均值 当天08:00 4月5日均值 → 04-05 08:00
旬均值(上旬) 11日08:00 上旬结束于10日,11日08:00出数
旬均值(中旬) 21日08:00 中旬结束于20日,21日08:00出数
旬均值(下旬) 下月1日08:00 下旬结束于月末,下月1日08:00出数
月均值 下月1日08:00 4月均值 → 05-01 08:00

预计算表结构

st_river_multi_year_avg表存储多年月均值预计算数据:

  • period_type='MONTH'
  • month列代表实际月份(如month=4表示4月)
  • 查询时需要将month转换为ST_RVAV_R对应的时间点

问题根因分析

问题1:MONTH月份范围查询逻辑错误

原代码使用简单的月份范围查询:

1
2
3
4
5
6
// 原逻辑(错误)
averageData = riverMultiYearAvgMapper.selectMonthAvgByMonthRange(
query.getStcd(),
startDate.getMonthValue(), // 4
endDate.getMonthValue() // 5
);

这种方式会返回4月和5月两条记录,但实际上5月的数据点(06-01 08:00)根本不在请求的时间范围内。

问题2-5:时间对齐与映射规则错误

原代码存在以下问题:

  • 时间使用00:00:00而非水文标准的08:00:00
  • 旬均值映射:上旬→1日(应为11日),中旬→11日(应为21日),下旬→21日(应为下月1日)
  • 月均值映射:month=4→4月1日(应为5月1日,因为月均值存储在下月1日)

问题6:查询范围未自动扩展

双路径查询架构
图4:预计算表路径与回退路径的双路径查询架构

用户请求4月1日到4月30日的月均值,但4月的月均值存储在5月1日08:00,超出查询范围导致数据遗漏。用户需要手动填下月日期才能正常工作。

问题7:MONTH类型startTime未偏移

请求startTime=2026-01-01 08:00时,12月均值(存储在01-01 08:00)被错误包含进来,因为第一个数据点实际上代表的是上个月的数据。

问题8:循环终止条件错误

历史均值的月份遍历循环使用endDate作为终止条件,但最后一个代表月的数据点在下月1日,导致遗漏最后一个月份的数据。

完整修复方案

Step 1:修正STTDRCD值

文件:WaterAverageDataMapper.xml

修改前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 旬均值使用了错误的'3' -->
<select id="selectXunAverageData" resultType="map">
SELECT ...
FROM ST_RVAV_R
WHERE STCD = #{stcd}
AND STTDRCD = '3' <!-- 错误:这是候均值 -->
</select>

<!-- 月均值使用了错误的'4' -->
<select id="selectMonthAverageData" resultType="map">
SELECT ...
FROM ST_RVAV_R
WHERE STCD = #{stcd}
AND STTDRCD = '4' <!-- 错误:这是旬均值 -->
</select>

修改后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 修正为正确的'4' -->
<select id="selectXunAverageData" resultType="map">
SELECT ...
FROM ST_RVAV_R
WHERE STCD = #{stcd}
AND STTDRCD = '4' <!-- 正确:旬均值 -->
</select>

<!-- 修正为正确的'5' -->
<select id="selectMonthAverageData" resultType="map">
SELECT ...
FROM ST_RVAV_R
WHERE STCD = #{stcd}
AND STTDRCD = '5' <!-- 正确:月均值 -->
</select>

Step 2:新增按月份列表查询方法

为了解决月份范围查询的问题,我新增了selectMonthAvgByMonthList方法:

文件:RiverMultiYearAvgMapper.java

1
2
3
4
5
6
7
8
9
10
11
/**
* 根据月份列表查询多年月平均值
*
* @param stcd 测站代码
* @param months 月份列表(如[4, 5, 6]代表4月、5月、6月)
* @return 多年月平均值列表
*/
List<Map<String, Object>> selectMonthAvgByMonthList(
@Param("stcd") String stcd,
@Param("months") List<Integer> months
);

文件:RiverMultiYearAvgMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 根据月份列表查询多年月平均值 -->
<select id="selectMonthAvgByMonthList" resultType="map">
SELECT
month,
avg_water_level as water_level,
avg_flow as flow,
start_year,
end_year,
sample_count,
data_count
FROM st_river_multi_year_avg
WHERE stcd = #{stcd}
AND period_type = 'MONTH'
AND month IN
<foreach collection="months" item="month" open="(" separator="," close=")">
#{month}
</foreach>
ORDER BY month
</select>

Step 3:自动调整查询范围

这是修复的核心部分——需要根据数据点的存储规则自动调整查询范围:

文件:WaterComparisonServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 旬/月均值存储在下一旬/月的第一天(如4月下旬存在5月1日08:00),
// 需要自动调整查询范围:startTime跳过代表上个月的数据点,endTime扩展以捕获最后一个时段
Date currentDataStart = currentStart;
Date currentDataEnd = currentEnd;
if ("MONTH".equalsIgnoreCase(query.getAvgPeriod())) {
LocalDate sd = startDateTime.toLocalDate();
// 第一个代表startDate所在月份的数据点,位于下月1日08:00
LocalDate adjustedStart = sd.withDayOfMonth(1).plusMonths(1);
currentDataStart = Date.from(adjustedStart.atTime(8, 0).atZone(ZoneId.systemDefault()).toInstant());

LocalDate extendedEnd = endDateTime.toLocalDate().plusMonths(1).withDayOfMonth(2);
currentDataEnd = Date.from(extendedEnd.atStartOfDay(ZoneId.systemDefault()).toInstant());
} else if ("XUN".equalsIgnoreCase(query.getAvgPeriod())) {
LocalDate sd = startDateTime.toLocalDate();
// 跳过代表上个月下旬的数据点(当月1日08:00)
if (sd.getDayOfMonth() <= 1) {
LocalDate adjustedStart = sd.withDayOfMonth(2);
currentDataStart = Date.from(adjustedStart.atTime(0, 0).atZone(ZoneId.systemDefault()).toInstant());
}
LocalDate extendedEnd = endDateTime.toLocalDate().plusMonths(1).withDayOfMonth(2);
currentDataEnd = Date.from(extendedEnd.atStartOfDay(ZoneId.systemDefault()).toInstant());
}

逻辑说明

  • MONTH的startTime必须偏移到下月1日08:00,否则会把上月的月均值错误包含进来
  • MONTH的endTime需要扩展到下月2日,才能捕获最后一个代表月份的数据点
  • XUN需要在月初跳过代表上月下旬的数据点

Step 4:MONTH预计算查询改为数据点遍历

月份数据点遍历逻辑流程图
图3:数据点遍历逻辑——4月7日到5月7日只返回4月

原逻辑直接提取月份范围,新逻辑改为按数据点遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
} else if ("MONTH".equalsIgnoreCase(avgPeriod)) {
Set<Integer> monthsToQuery = new HashSet<>();
LocalDate firstDataPoint = startDate.withDayOfMonth(1).plusMonths(1);
LocalDate loopEnd = endDate.withDayOfMonth(1).plusMonths(1);
LocalDate dataPoint = firstDataPoint;
while (!dataPoint.isAfter(loopEnd)) {
int representMonth = dataPoint.minusMonths(1).getMonthValue();
monthsToQuery.add(representMonth);
dataPoint = dataPoint.plusMonths(1);
}
if (!monthsToQuery.isEmpty()) {
averageData = riverMultiYearAvgMapper.selectMonthAvgByMonthList(
query.getStcd(),
new ArrayList<>(monthsToQuery)
);
} else {
averageData = new ArrayList<>();
}

关键逻辑

  • 计算第一个数据点:startDate.withDayOfMonth(1).plusMonths(1)
  • 遍历所有数据点,每个数据点代表前一个月份
  • 按代表月份列表查询预计算表

例如请求4月7日~5月7日:

  • 数据点:5月1日 → 代表4月
  • 6月1日 > loopEnd(6月1日=) → 不包含
  • 结果:只有4月

Step 5:时间映射修正

时间映射修正对比图
图2:修正前与修正后的时间映射规则对比

convertPreCalcDataToTimeSeries方法的时间映射:

修改前

1
2
3
4
5
6
7
8
9
10
if ("DAY".equalsIgnoreCase(periodType) || "DAY1".equalsIgnoreCase(periodType)) {
int day = ((Number) row.get("day")).intValue();
time = LocalDateTime.of(queryStartDate.getYear(), month, day, 0, 0, 0);
} else if ("XUN".equalsIgnoreCase(periodType)) {
int xun = ((Number) row.get("xun")).intValue();
int day = (xun == 1) ? 1 : (xun == 2) ? 11 : 21;
time = LocalDateTime.of(queryStartDate.getYear(), month, day, 0, 0, 0);
} else { // MONTH
time = LocalDateTime.of(queryStartDate.getYear(), month, 1, 0, 0, 0);
}

修改后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if ("DAY".equalsIgnoreCase(periodType) || "DAY1".equalsIgnoreCase(periodType)) {
int day = ((Number) row.get("day")).intValue();
time = LocalDateTime.of(queryStartDate.getYear(), month, day, 8, 0, 0);
} else if ("XUN".equalsIgnoreCase(periodType)) {
int xun = ((Number) row.get("xun")).intValue();
if (xun == 1) {
time = LocalDateTime.of(queryStartDate.getYear(), month, 11, 8, 0, 0);
} else if (xun == 2) {
time = LocalDateTime.of(queryStartDate.getYear(), month, 21, 8, 0, 0);
} else {
time = LocalDateTime.of(queryStartDate.getYear(), month, 1, 8, 0, 0).plusMonths(1);
}
} else { // MONTH - 月均值存储在下月1日,如4月均值对应5月1日
time = LocalDateTime.of(queryStartDate.getYear(), month, 1, 8, 0, 0).plusMonths(1);
}

Step 6:回退路径同步修正

系统有两条查询路径:预计算表路径(快)和回退路径(慢,从ST_RIVER_R实时计算)。回退路径同样存在时间映射问题,需要同步修正。

queryHistoricalDailyAverage

1
2
3
4
// 修正前
LocalDateTime avgTime = LocalDateTime.of(startDate.getYear(), month, day, 0, 0, 0);
// 修正后
LocalDateTime avgTime = LocalDateTime.of(startDate.getYear(), month, day, 8, 0, 0);

queryHistoricalXunAverage

1
2
3
4
5
6
7
8
9
10
11
12
13
// 修正前
int startDay = (xun == 1) ? 1 : (xun == 2) ? 11 : 21;
LocalDateTime avgTime = LocalDateTime.of(startDate.getYear(), month, startDay, 0, 0, 0);

// 修正后
LocalDateTime avgTime;
if (xun == 1) {
avgTime = LocalDateTime.of(startDate.getYear(), month, 11, 8, 0, 0);
} else if (xun == 2) {
avgTime = LocalDateTime.of(startDate.getYear(), month, 21, 8, 0, 0);
} else {
avgTime = LocalDateTime.of(startDate.getYear(), month, 1, 8, 0, 0).plusMonths(1);
}

queryHistoricalMonthAverage

1
2
3
4
// 修正前
LocalDateTime avgTime = LocalDateTime.of(startDate.getYear(), entry.getKey(), 1, 0, 0, 0);
// 修正后
LocalDateTime avgTime = LocalDateTime.of(startDate.getYear(), entry.getKey(), 1, 8, 0, 0).plusMonths(1);

同时,月份集合遍历循环终止条件也需要修正:

1
2
3
4
5
6
// 修正前
while (!dataPoint.isAfter(endDate)) { ... }

// 修正后
LocalDate loopEnd = endDate.withDayOfMonth(1).plusMonths(1);
while (!dataPoint.isAfter(loopEnd)) { ... }

代码修改汇总

文件 改动 说明
WaterComparisonServiceImpl.java +110/-42 查询范围调整、月份遍历、时间映射
RiverMultiYearAvgMapper.java +12/-2 新增selectMonthAvgByMonthList方法
RiverMultiYearAvgMapper.xml +22/-2 新增按月份列表查询SQL
WaterAverageDataMapper.xml +2/-2 STTDRCD值修正

测试验证结果

测试验证结果展示图
图5:修复后的测试验证结果

修复完成后,经过全面测试验证:

  • 请求4月7日到5月7日MONTH类型,comparisonSeriesMap返回1条(4月)
  • 旬均值时间点正确:上旬=11日08:00,中旬=21日08:00,下旬=下月1日08:00
  • 月均值时间点正确:4月均值=5月1日08:00
  • XUN/MONTH查询范围自动扩展,用户无需手动填下月日期
  • MONTH的startTime正确偏移,不包含上月月均值
  • STTDRCD值正确:旬=’4’,月=’5’

核心经验教训

1. 水文数据库的STTDRCD编码必须严格对照标准文档

不能靠猜测或类推。旬均值是’4’,月均值是’5’,这是水文行业的标准约定。代码中原来写反了,导致查询不到正确的数据。

2. 涉及”数据点代表时段”的查询,必须按数据点位置遍历

原代码将查询范围的日期直接映射为月份,而没有考虑数据点实际代表的月份。水文数据的时间点不是”自然日期”而是”代表时段”——月均值存储在下月1日08:00,旬均值中的下旬存储在下月1日08:00。

3. 水文日从08:00开始,所有时间构造必须使用08:00

而非默认的00:00。这一点在所有6处涉及时间构造的代码中都需要修正。

4. 修复必须覆盖双路径

系统有预计算表路径和回退路径,两条路径都存在相同的时间映射问题,需要分别修复才能确保一致性。

前端对接要点

修复完成后,前端对接需要注意以下要点:

  1. 请求格式:只需提供自然日期范围,如查询1月1日到4月30日的月均值,传入startTime=2026-01-01 08:00endTime=2026-04-30 08:00

  2. 返回时间格式yyyy-MM-dd HH:mm:ss,时间为08:00

  3. 月均值时间点:下月1日(如4月均值显示为05-01 08:00

  4. 旬均值时间点

    • 上旬:11日08:00
    • 中旬:21日08:00
    • 下旬:下月1日08:00

结语

这次Bug修复虽然涉及的文件不多,但问题的根因却隐藏在水文数据的存储约定中。如果没有深入理解”数据点代表时段”这一核心概念,就只能治标不治本——表面修好了,时间对了,但数据仍然可能匹配不上。

通过这次修复,我深刻体会到:

  • 领域知识的重要性:水文数据的存储规则不是通用知识,需要专门学习
  • 双路径验证的必要性:预计算路径和回退路径必须同步修复
  • 测试验证的全面性:边界条件(如月末、月初)必须覆盖

希望这篇博客记录对遇到类似问题的朋友有所帮助。如果有任何疑问,欢迎在评论区留言交流。


配套音频

导读(成熟女声)

今天,让我们一起走进水文数据的Bug修复之旅。从ST_RVAV_R表的存储约定出发,探索数据点代表时段的奥秘,见证一位工程师如何抽丝剥茧,从问题分析到完整修复,为水文信息化系统扫清隐患。这不仅是一次技术问题的解决,更是对领域知识重要性的深刻验证。让我们开始吧。

配套BGM

(温暖励志的科技博客背景音乐,轻快的电子琴旋律,融入水波涟漪的意象)


相关Git提交

  • 8f9d9f2 - 修复均值对比查询:历史均值条数、时间对齐及自动扩展查询范围
  • e72e23f - 修复月查询(补充startTime偏移和循环终止条件修复)