1
0

9 Commity a0cf107a26 ... bf10005a59

Autor SHA1 Správa Dátum
  Kazerin bf10005a59 feat(water): complete monitoring data source integration (#8) 2 dní pred
  Kazerin 06496ecb20 feat(cockpit): add read-only foundation API (#18) 2 dní pred
  Kazerin d3cbb42242 Merge remote-tracking branch 'origin/master' 2 dní pred
  Kazerin 8c56b04d13 docs(water): correct monitoring fixture count 2 dní pred
  Kazerin 3f1a5d4bfd test(water): expand canonical monitoring fixtures 2 dní pred
  Kazerin acbb7c4e7a fix(water): use canonical monitoring type ids 2 dní pred
  Kazerin 0b86e9960f fix(water): stabilize monitoring trend and export permissions (#8) 3 dní pred
  Kazerin 2ff42d31fb fix(water): scope supply monitoring to water device tree (#8) 3 dní pred
  Kazerin d564a63feb feat(water): deliver supply monitoring (#8) 3 dní pred
29 zmenil súbory, kde vykonal 2269 pridanie a 370 odobranie
  1. 259 0
      docs/cockpit/foundation-demo.html
  2. 112 6
      pipe-network-service/CONTEXT.md
  3. 20 0
      pipe-network-service/sql/migration_monitoring_device_code_length.sql
  4. 240 0
      pipe-network-service/sql/water_supply_monitoring_test_data.sql
  5. 23 6
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/FlowMonitoring/FlowMonitoringController.java
  6. 23 6
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/LeakageMonitoring/LeakageMonitoringController.java
  7. 23 6
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/PressureMonitoring/PressureMonitoringController.java
  8. 47 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitFoundationController.java
  9. 40 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitAuthenticationEntryPointTest.java
  10. 107 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFoundationControllerTest.java
  11. 24 1
      pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/config/SecurityConfig.java
  12. 52 0
      pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/security/handle/CockpitAuthenticationEntryPoint.java
  13. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/Monitoring/service/MonitoringExportService.java
  14. 118 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/Monitoring/service/impl/MonitoringExportServiceImpl.java
  15. 3 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/MonitoringService.java
  16. 404 342
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/MonitoringServiceImpl.java
  17. 15 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitClockConfiguration.java
  18. 6 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorCategory.java
  19. 31 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorPayload.java
  20. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationAdapter.java
  21. 38 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationResponse.java
  22. 47 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationService.java
  23. 17 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationSnapshot.java
  24. 25 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFreshness.java
  25. 29 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitMapFrame.java
  26. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitReadException.java
  27. 11 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/EmptyCockpitFoundationAdapter.java
  28. 73 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/Monitoring/MonitoringExportServiceContractTest.java
  29. 452 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/service/impl/MonitoringServiceImplTest.java

+ 259 - 0
docs/cockpit/foundation-demo.html

@@ -0,0 +1,259 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit API Foundation Demo</title>
+  <style>
+    body {
+      margin: 0;
+      min-height: 100vh;
+      display: grid;
+      place-items: center;
+      background: #0b1622;
+      color: #e8f1f8;
+      font: 15px/1.5 "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
+    }
+
+    main {
+      width: min(920px, calc(100vw - 32px));
+      padding: 24px;
+      border: 1px solid #21465f;
+      background: #102030;
+    }
+
+    h1 {
+      margin: 0 0 4px;
+      font-size: 22px;
+    }
+
+    .endpoint {
+      margin: 0 0 18px;
+      color: #78a2b8;
+      font-family: Consolas, monospace;
+    }
+
+    .toolbar {
+      display: flex;
+      gap: 8px;
+      flex-wrap: wrap;
+      margin-bottom: 18px;
+    }
+
+    button {
+      border: 1px solid #35708d;
+      border-radius: 4px;
+      padding: 7px 12px;
+      color: #dff0fa;
+      background: #173a4d;
+      cursor: pointer;
+    }
+
+    button:hover {
+      background: #1d4d67;
+    }
+
+    .state {
+      display: none;
+      border: 1px solid #2a5a75;
+      padding: 16px;
+      background: #0d1922;
+    }
+
+    .state.active {
+      display: block;
+    }
+
+    .state h2 {
+      margin: 0 0 10px;
+      font-size: 18px;
+    }
+
+    .state.success h2 {
+      color: #61d095;
+    }
+
+    .state.unauthorized h2 {
+      color: #f0ad4e;
+    }
+
+    .state.server-error h2 {
+      color: #ff6b6b;
+    }
+
+    dl {
+      margin: 0 0 12px;
+      display: grid;
+      grid-template-columns: 140px 1fr;
+      gap: 4px 12px;
+    }
+
+    dt {
+      color: #8fb3c7;
+    }
+
+    dd {
+      margin: 0;
+      overflow-wrap: anywhere;
+    }
+
+    pre {
+      margin: 0;
+      padding: 12px;
+      max-height: 320px;
+      overflow: auto;
+      background: #07111a;
+      color: #c9e7f7;
+      font-size: 13px;
+    }
+  </style>
+</head>
+<body>
+<main>
+  <h1>城市生命线驾驶舱基础契约演示</h1>
+  <p class="endpoint">GET /api/cockpit/v1/foundation</p>
+
+  <div class="toolbar">
+    <button type="button" data-mode="success">成功空地图</button>
+    <button type="button" data-mode="unauthorized">未认证 401</button>
+    <button type="button" data-mode="server-error">服务端错误 500</button>
+    <button type="button" data-mode="live">请求真实接口</button>
+  </div>
+
+  <section id="state" class="state">
+    <h2 id="state-title"></h2>
+    <dl id="state-fields"></dl>
+    <pre id="state-payload"></pre>
+  </section>
+</main>
+
+<script>
+  var states = {
+    'success': {
+      title: '成功:空地图帧',
+      className: 'state success',
+      payload: {
+        code: 200,
+        msg: '操作成功',
+        data: {
+          requestId: 'demo-foundation-empty',
+          apiVersion: 'v1',
+          readOnly: true,
+          mapFrame: { state: 'EMPTY', renderable: false, reason: '驾驶舱基础数据为空' },
+          freshness: {
+            status: 'FRESH',
+            serverTime: '2026-09-02T10:00:00+08:00',
+            dataTime: '2026-09-02T10:00:00+08:00'
+          }
+        }
+      }
+    },
+    'unauthorized': {
+      title: '失败:驾驶舱身份未认证',
+      className: 'state unauthorized',
+      payload: {
+        code: 401,
+        msg: '驾驶舱接口需要认证',
+        data: {
+          category: 'UNAUTHORIZED',
+          message: '驾驶舱接口需要认证',
+          requestId: 'demo-foundation-unauthorized',
+          serverTime: '2026-09-02T10:00:01+08:00'
+        }
+      }
+    },
+    'server-error': {
+      title: '失败:基础数据服务暂不可用',
+      className: 'state server-error',
+      payload: {
+        code: 500,
+        msg: '驾驶舱基础数据暂不可用',
+        data: {
+          category: 'SERVER_ERROR',
+          message: '驾驶舱基础数据暂不可用',
+          requestId: 'demo-foundation-server-error',
+          serverTime: '2026-09-02T10:00:02+08:00'
+        }
+      }
+    }
+  };
+
+  function render(mode, payload, title, className) {
+    var section = document.getElementById('state');
+    var response = payload || states[mode].payload;
+    var fields = {
+      'HTTP 状态': String(response.code),
+      '消息': response.msg,
+      'requestId': response.data && response.data.requestId,
+      'readOnly': response.data && response.data.readOnly,
+      '地图帧': response.data && response.data.mapFrame && response.data.mapFrame.state,
+      '数据新鲜度': response.data && response.data.freshness && response.data.freshness.status,
+      '错误类型': response.data && response.data.category
+    };
+    var list = document.getElementById('state-fields');
+    list.innerHTML = '';
+    Object.keys(fields).forEach(function (key) {
+      if (fields[key] === undefined || fields[key] === null) {
+        return;
+      }
+      var term = document.createElement('dt');
+      term.textContent = key;
+      var value = document.createElement('dd');
+      value.textContent = String(fields[key]);
+      list.appendChild(term);
+      list.appendChild(value);
+    });
+    document.getElementById('state-title').textContent =
+      title || (states[mode] && states[mode].title) || '接口返回';
+    section.className = className || (states[mode] && states[mode].className) || 'state';
+    document.getElementById('state-payload').textContent = JSON.stringify(response, null, 2);
+    section.classList.add('active');
+  }
+
+  function loadLive() {
+    fetch('/api/cockpit/v1/foundation', { headers: { 'X-Request-Id': 'demo-foundation-live' } })
+      .then(function (response) {
+        return response.json().then(function (payload) {
+          render('success', payload,
+            response.ok ? '真实接口:' + payload.msg : '真实接口 HTTP ' + response.status,
+            response.ok ? 'state success' : (response.status === 401 ? 'state unauthorized' : 'state server-error'));
+        });
+      })
+      .catch(function (error) {
+        render('server-error', {
+          code: 500,
+          msg: '驾驶舱基础数据暂不可用',
+          data: {
+            category: 'SERVER_ERROR',
+            message: String(error),
+            requestId: 'demo-foundation-live',
+            serverTime: ''
+          }
+        });
+      });
+  }
+
+  document.querySelectorAll('button[data-mode]').forEach(function (button) {
+    button.addEventListener('click', function () {
+      var mode = button.getAttribute('data-mode');
+      if (mode === 'live') {
+        loadLive();
+      } else {
+        render(mode);
+      }
+    });
+  });
+
+  var initialMode = location.search.indexOf('mode=unauthorized') >= 0 ? 'unauthorized'
+    : location.search.indexOf('mode=server-error') >= 0 ? 'server-error'
+    : location.search.indexOf('mode=live') >= 0 ? 'live'
+    : 'success';
+
+  if (initialMode === 'live') {
+    loadLive();
+  } else {
+    render(initialMode);
+  }
+</script>
+</body>
+</html>

+ 112 - 6
pipe-network-service/CONTEXT.md

@@ -1,11 +1,72 @@
 # 管网业务服务
 
-该上下文承载城市生命线中的管网业务。当前词汇表聚焦供水安全运行监测,覆盖供水设施、监测设备、监测报警和工单处置。
+该上下文承载“城市地下管网及设施建设改造智能化系统项目”软件平台中“地下管网管控平台”的管网业务。当前交付边界聚焦供水子系统“供水管网安全运行监测系统”,覆盖供水设施、监测设备、运行监测、监测报警和工单处置;供水前端与管理端后端共同构成该子系统的完整业务体验。
+
+## Product Boundary
+
+**供水管网安全运行监测系统**:地下管网管控平台中的供水业务子系统,覆盖首页、供水设施管理、监测设备管理、运行监测、监测报警及其关联的查询、统计、GIS、报警和工单操作。
+_Avoid_:把供水子系统等同于单一的报警页面,或把监测设备等同于供水设施
+
+## 供水子系统功能需求结构
+
+以下结构描述供水管网安全运行监测系统的业务能力边界,并与已确认的菜单层级保持一致。供水系统的设备台账、异常、监测和工单页面只处理设备类型树判定为“供水”的对象;设备类型未知或未归属时不得静默归入供水。
+
+清单、查询和统计页面不各自嵌入 GIS 联动定位;设施、监测设备和报警的定位与空间详情分别集中在本组的 GIS 一张图中。批量导出属于各清单或统计能力的一部分,但不改变 GIS 集中承载规则。
+
+### 1. 供水设施管理
+
+1.1 **管网信息查询**:清单化展示供水管线和管点,支持按管材、管径、建设年代、所属区域及关键字进行多条件查询和批量导出。
+
+1.2 **水厂信息查询**:提供水厂信息的关键字和分类查询,以及批量导出。
+
+1.3 **泵站信息查询**:提供泵站信息的分类查询和批量导出。
+
+1.4 **水源地信息查询**:提供水源地信息的分类查询和批量导出。
+
+1.5 **用水户信息查询**:面向居民、工业、商业、行政事业等全部用水户,提供多维分类查询和批量导出。
+
+1.6 **管网数据统计分析**:对供水管网数据进行多维统计分析,以柱状图、饼图、表格等形式展示,并支持结果下载导出。
+
+1.7 **供水设施 GIS 一张图**:在同一地图中展示水源地、水厂、泵站、供水管线和用水户,支持设施搜索、定位和详情查看;本功能范围不包含消火栓。
+
+### 2. 监测设备管理
+
+2.1 **设备台账管理**:对供水监测设备进行台账化管理,提供分类统计、查询和批量导出。
+
+2.2 **设备异常管理**:集中展示当前运行异常的供水监测设备,提供异常分类查询、处置状态跟踪和批量导出;可从异常设备进入故障维修工单派发,但本页面的主要职责是异常态势和台账管理。
+
+2.3 **设备维修派单**:面向已判定运行故障且不存在适用未结束同类工单的供水监测设备,创建并派发 `1-故障维修` 工单。
+
+2.4 **设备维修记录**:管理设备历史维修记录,支持多维查询、修改、删除、批量导入和批量导出。
+
+2.5 **监测设备 GIS 一张图**:在地图中展示各类供水监测设备及其监测点位,支持设备搜索、定位和详情查看。
+
+### 3. 运行监测
+
+3.1 **流量监测**:接入既有流量计监测数据,清单化展示流量监测点位,支持多条件查询和批量导出。
+
+3.2 **压力监测**:接入既有压力计监测数据,清单化展示压力监测点位,支持多条件查询和批量导出。
+
+3.3 **漏失监测**:接入新建漏失声波监测数据,清单化展示漏失监测点位,支持多条件查询和批量导出。
+
+### 4. 监测报警
+
+4.1 **报警管理**:依据各类监测设备的阈值规则和其他自动报警规则触发报警,展示报警等级、设备报警状态、报警记录状态及关联工单;报警记录进入工单处置流程时生成不指定责任人的待派单故障维修工单,本页面可发起责任人派发但不承载后续完整处置。不同报警类型可形成不同工单;同一设备同一报警类型同时只能存在一个未结束的同类报警工单。
+
+4.2 **报警审核**:提供报警审核或解除结果、意见和附件录入,并跟踪报警关联工单的完整处置流程;在供水系统内,该入口重点承载供水设备故障维修工单的派发、接单、处理、提交验收、验收、驳回和结案。
+
+4.3 **阈值管理**:支持按传感器分类批量设置阈值、设置分时阈值并查询已配置点位的阈值详情。
+
+4.4 **监测报警一张图**:在地图中展示供水各类报警点位,按报警级别以一级红色、二级橙色、三级蓝色、四级绿色突出显示,支持报警搜索、定位和详情查看。
+
+### 工单视图分工
+
+统一工单视图集中检索和处置所有供水类型工单,包括 `1-故障维修`、`2-日常巡检` 和 `3-设备保养`;报警审核视图聚焦供水故障维修工单。巡检或保养工单即使由其他系统派发给供水用户,仍可在统一工单视图中继续接单、处理和验收。
 
 ## 供水设施
 
 **供水设施**:
-构成供水系统并被登记、查询、统计或在地图上展示的业务对象,包括水源地、水厂、泵站、供水管线和用水户。
+构成供水系统并被登记、查询、统计或在地图上展示的业务对象,包括水源地、水厂、泵站、供水管线和用水户;水厂、泵站等是设施,不是监测设备
 _Avoid_:把监测设备统称为供水设施
 
 **水源地**:
@@ -17,13 +78,18 @@ _Avoid_:水源、水源点
 _Avoid_:供水厂、制水厂
 
 **泵站**:
-通过水泵为供水过程提供输送或增压能力的设施
+通过水泵为输水过程提供输送或增压能力的设施。泵站为供水/排水共用对象,物理表 `app_user.pump_station`(由 `water_pump_station` 迁移改名)以 `station_type` 区分:`water_supply`=供水泵站、`drainage`=排水泵站;各系统只读写本系统类型的行,类型由后端按入口强制写入(ADR-0007)
 _Avoid_:加压站
+_规则_:查询、统计、GIS、回收站与导入导出必须带 `station_type` 归属条件;禁止出现"一张泵站表两个系统共用却不过滤"的查询。
 
 **供水管线**:
 连接供水设施并输送水的线性设施。
 _Avoid_:管网、管道(指单条设施时)
 
+**管点**:
+供水管线上的节点或点状设施,作为管网查询、统计和空间展示的独立业务对象。
+_Avoid_:监测点(指监测设备所在位置时)
+
 **用水户**:
 接受供水服务的居民、工业、商业或行政事业主体。
 _Avoid_:大用水户(除非明确指满足特定用水量或重要性标准的子集)、用户
@@ -32,12 +98,32 @@ _Avoid_:大用水户(除非明确指满足特定用水量或重要性标准
 持久化表 `water_user_info` 及其 `user_*` 字段均表示“用水户”,覆盖居民、工业、商业和
 行政事业等全部类型。为保持现有接口与数据兼容,这些技术标识继续保留,不表示“大用水户”子集。
 
+**契约缺口**:
+当前五类供水设施实体、查询服务签名和 KingbaseES DDL 均未定义 `region`/所属区域字段;在字段和数据来源确认前,不得为区域筛选臆造数据库列或查询条件。区域筛选需另行完成 REST、模型和数据库契约评审。
+
 ## 运行监测
 
+**驾驶舱事件等级展示颜色**:
+面向城市生命线驾驶舱的预警/报警事件展示统一使用 1 级红色、2 级黄色、3 级蓝色、4 级绿色;该展示约定不改变后端等级数值的业务含义,也不等同于监测点主图标的正常/预警/报警/工单/离线状态色。
+_Avoid_:将历史预警颜色配置直接当作驾驶舱点位状态色
+
+**驾驶舱地图摘要接口**:
+面向 UE/大屏只读展示的分页接口,返回监测点、关联设备摘要和未关联设备摘要,以及后端解析后的点位状态、原因、统计、新鲜度和快照时间。专用接口 JSON 字段统一使用驼峰命名,包括 `deviceCode`、`pointCode`、`pointStatus` 和 `snapshotAt`。默认分页大小为 500,最大 1000;UE 不通过拼接管理接口自行计算点位状态。
+
+**驾驶舱设备详情聚合接口**:
+按数据库设备编码 `equipment_base.equipment_code` 查询并以 `deviceCode` 返回设备基础档案、类别、当前状态、最新监测数据、预警、报警、工单当前状态、工单状态时间线和最新设备图片的只读接口。内部可以联查多张表,但对 UE 暴露稳定聚合契约。
+
+**驾驶舱监测点详情聚合接口**:
+按 `point_code` 返回监测点基础信息、坐标校验、所属管网、点位状态、关联设备摘要及关联异常说明的只读接口。管理端监测点接口不自动等同于驾驶舱契约。
+
 **监测设备**:
-用于采集供水运行数据的设备,例如水位、压力或流量监测设备;它与被监测的供水设施是不同业务对象。
+用于采集供水运行数据的带传感器智能设备,例如水位、压力或流量监测设备;带传感器的井盖也属于监测设备。它与被监测的供水设施是不同业务对象。
 _Avoid_:供水设施、监测点(指设备本身时)
 
+**设备编码**:
+平台内监测设备的唯一业务标识,格式为“区域-类型-YYYYMMDD+4位流水号”。区域取 CN/CB/TC,业务类型取 RQ/GS/PS/YJ,排水可再细分为 YS/WS;例如 CN-GS-202609020001、TC-PS-YS-202609020012。设备编码用于跨系统定位设备,不承载设备当前状态或报警语义。
+_Avoid_:数据库主键、报警编号、工单编号
+
 **设备异常**:
 监测设备当前处于报警或维修状态的统称,可由一个或多个报警记录或处置过程造成。
 _Avoid_:报警记录(指一次事件时)、故障(原因尚未确认时)
@@ -70,18 +156,30 @@ _Avoid_:直接清警、工单验收
 运行系统确认设备的所有有效报警原因均已消除后,将设备报警状态从报警恢复为正常;任一报警原因仍有效时不得清警。
 _Avoid_:工单结案、报警记录已处理
 
+**报警转工单**:
+报警记录进入工单处置流程时生成待派单的故障维修工单,生成时不指定接单责任人;后续派发和处置由工单流程完成。
+_Avoid_:报警派单、报警处理
+
 ## 工单处置
 
 **工单**:
-对设备故障、日常巡检或设备保养进行分派、执行和跟踪的统一处置载体。
+对设备故障、日常巡检或设备保养进行分派、执行和跟踪的跨系统统一处置载体;供水、排水、燃气、窨井等系统通过关联设备类型区分业务归属,共享工单生命周期
 _Avoid_:维修任务、报警工单(作为独立于工单的概念)
 
+**工单系统归属**:
+工单所属系统由关联设备的设备类型树确定,而不是由前端页面名称或单独的“报警工单”概念确定;未知或未归属设备不得被静默归入某个系统。
+_Avoid_:用页面入口、工单标题或报警记录状态推断系统归属
+
 **故障维修工单**:
 用于核实并修复设备故障的工单,工单类型为 `1`;由设备维修需求或监测报警转入的处置都属于此类型。
 _Avoid_:维修任务、报警工单
 
+**同类报警工单**:
+同一设备针对同一报警类型建立的故障维修工单;同一设备同一报警类型同时只能存在一个未结束的同类报警工单。
+_Avoid_:同一报警记录、同设备全部工单
+
 **工单派发**:
-用户针对报警或异常设备创建故障维修工单并指定后续处置责任人的活动;可从设备维修派单、设备异常管理或报警管理入口发起。
+为待派单工单指定唯一接单责任人的活动;可从设备维修派单、设备异常管理或报警管理入口进入,但报警转工单本身不等同于派发。
 _Avoid_:报警处理、报警审核
 
 **工单处置**:
@@ -92,6 +190,14 @@ _Avoid_:报警审核(指工单后续操作时)、报警记录已处理
 对工单处置结果是否满足要求进行确认的活动;验收通过可以使工单结案,但不直接清除设备报警状态。
 _Avoid_:报警审核、自动清警
 
+**工单处置证据**:
+与工单处置阶段关联的说明和图片,至少区分处理前与处置后,用于责任人处理、验收审核和历史追溯。
+_Avoid_:把处置证据等同于报警记录状态或设备当前状态
+
+**随同修复**:
+一次故障维修同时消除了同一设备其他有效报警原因的处置结果;设备是否清警仍由运行系统复核有效报警原因决定,相关工单记录继续保留并按各自流程验收结案。
+_Avoid_:自动结案、重复维修
+
 **日常巡检工单**:
 用于执行计划内或临时巡查的工单,工单类型为 `2`。
 _Avoid_:巡检任务

+ 20 - 0
pipe-network-service/sql/migration_monitoring_device_code_length.sql

@@ -0,0 +1,20 @@
+-- 运行监测设备编码长度迁移(KingbaseES)
+-- 目标:
+--   1. 将 app_user.radar_data.device_code 长度扩展为 varchar(50)
+--   2. 将 app_user.telemetry_data.device_code 长度扩展为 varchar(50)
+-- 说明:
+--   设备编码遵循“区域-类型-YYYYMMDD+4位流水号”规范,长度可达 17 位;
+--   原测量表 device_code 为 varchar(10),无法承载完整编码,导致运行监测页面无法匹配设备。
+-- 可重复执行;不删除任何业务数据。
+
+ALTER TABLE app_user.radar_data ALTER COLUMN device_code TYPE varchar(50);
+ALTER TABLE app_user.telemetry_data ALTER COLUMN device_code TYPE varchar(50);
+
+COMMENT ON COLUMN app_user.radar_data.device_code IS '雷达流量计设备编码,关联 equipment_base.equipment_code';
+COMMENT ON COLUMN app_user.telemetry_data.device_code IS '遥测终端设备编码,关联 equipment_base.equipment_code';
+
+SELECT table_name, column_name, data_type, character_maximum_length
+FROM information_schema.columns
+WHERE table_schema = 'app_user'
+AND table_name IN ('radar_data', 'telemetry_data')
+AND column_name = 'device_code';

+ 240 - 0
pipe-network-service/sql/water_supply_monitoring_test_data.sql

@@ -0,0 +1,240 @@
+-- ============================================================
+-- 供水管网 T4 运行监测 测试数据脚本(KingbaseES)
+-- 说明:
+--   1. 测试主键均使用 WS-TEST- 前缀,可重复执行;设备编码遵循新命名规范。
+--   2. 设备编码遵循“区域-类型-YYYYMMDD+4位流水号”规范。
+--   3. 本脚本只初始化测试行,不创建监测业务表;执行前请确认以下表已存在:
+--      app_user.equipment_type、app_user.equipment_base、app_user.radar_data、
+--      app_user.telemetry_data、app_user.firefighting_pressure、app_user.noise_info
+--      如测量表 device_code 仍为 varchar(10),请先执行 migration_monitoring_device_code_length.sql。
+--   4. 覆盖场景:雷达流量、遥测流量、压力、漏失、无数据设备、
+--      非供水设备排除、数值范围过滤、时间范围过滤与近24小时之外的旧趋势点。
+-- ============================================================
+
+-- ------------------------------------------------------------
+-- 0. 清理历史测试数据(先清测量值,再清设备;不删除生产设备类型)
+-- ------------------------------------------------------------
+DELETE FROM app_user.radar_data WHERE id LIKE 'WS-TEST-%';
+DELETE FROM app_user.telemetry_data WHERE id LIKE 'WS-TEST-%';
+DELETE FROM app_user.firefighting_pressure WHERE id LIKE 'WS-TEST-%';
+DELETE FROM app_user.noise_info WHERE encode LIKE 'WS-TEST-%' OR encode LIKE 'CB-GS-20260902000%' OR encode LIKE 'TC-GS-20260902000%';
+DELETE FROM app_user.equipment_base WHERE equipment_id LIKE 'WS-TEST-EQ-%';
+
+-- ------------------------------------------------------------
+-- 1. 复用既有设备类型:id=1/type_id=water_supply,监测子类型严格挂在 parent_type_id=1
+-- ------------------------------------------------------------
+DO $$
+DECLARE
+    v_type_count integer;
+BEGIN
+    SELECT count(*) INTO v_type_count
+      FROM app_user.equipment_type
+     WHERE id = '1' AND type_id = 'water_supply' AND parent_type_id = '0';
+    IF v_type_count <> 1 THEN
+        RAISE EXCEPTION '缺少规范供水顶级设备类型:id=1,type_id=water_supply,parent_type_id=0';
+    END IF;
+    SELECT count(*) INTO v_type_count
+      FROM app_user.equipment_type
+     WHERE type_id IN ('ws_flow', 'ws_pressure', 'ws_leak')
+       AND parent_type_id = '1';
+    IF v_type_count <> 3 THEN
+        RAISE EXCEPTION '缺少规范供水监测子类型:ws_flow/ws_pressure/ws_leak 必须 parent_type_id=1';
+    END IF;
+    SELECT count(*) INTO v_type_count
+      FROM app_user.equipment_type
+     WHERE id <> '1' AND type_id NOT IN ('water_supply', 'ws_flow', 'ws_pressure', 'ws_leak');
+    IF v_type_count = 0 THEN
+        RAISE EXCEPTION '缺少非供水设备类型,无法构造边界排除样本';
+    END IF;
+END $$;
+
+INSERT INTO app_user.equipment_base
+    (equipment_id, equipment_code, equipment_name, equipment_model, equipment_type_id,
+     longitude, latitude, equipment_location, maintainer, maintainer_phone,
+     ownership_unit, remark, create_time, update_time, del_flag, district)
+VALUES
+    ('WS-TEST-EQ-FLOW-RADAR', 'CN-GS-202609020001', '测试雷达流量监测点', 'RADAR-FLOW-TEST',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.28000000, 23.12000000, '供水测试大道1号',
+     '供水测试维护员', '13800000001', '供水测试公司', '供水T4运行监测测试数据',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-TELEMETRY', 'CN-GS-202609020002', '测试遥测终端流量监测点', 'TELEMETRY-FLOW-TEST',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.29000000, 23.13000000, '供水测试大道2号',
+     '供水测试维护员', '13800000002', '供水测试公司', '供水T4运行监测测试数据',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-PRESSURE', 'CB-GS-202609020001', '测试压力监测点', 'PRESSURE-TEST',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_pressure' AND parent_type_id = '1' LIMIT 1), 113.30000000, 23.14000000, '供水测试大道3号',
+     '供水测试维护员', '13800000003', '供水测试公司', '供水T4运行监测测试数据',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-LEAKAGE', 'TC-GS-202609020001', '测试漏失监测点', 'LEAKAGE-TEST',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_leak' AND parent_type_id = '1' LIMIT 1), 113.31000000, 23.15000000, '供水测试大道4号',
+     '供水测试维护员', '13800000004', '供水测试公司', '供水T4运行监测测试数据',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-EMPTY', 'CN-GS-202609020003', '测试无测量流量监测点', 'FLOW-EMPTY-TEST',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.32000000, 23.16000000, '供水测试大道5号',
+     '供水测试维护员', '13800000005', '供水测试公司', '供水T4运行监测无数据测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-003', 'CN-GS-202609020004', '测试流量监测点03', 'FLOW-TEST-003',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.34000000, 23.18000000, '供水测试大道7号',
+     '供水测试维护员', '13800000007', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-004', 'CN-GS-202609020005', '测试流量监测点04', 'FLOW-TEST-004',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.35000000, 23.19000000, '供水测试大道8号',
+     '供水测试维护员', '13800000008', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-005', 'CN-GS-202609020006', '测试流量监测点05', 'FLOW-TEST-005',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.36000000, 23.20000000, '供水测试大道9号',
+     '供水测试维护员', '13800000009', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-006', 'CN-GS-202609020007', '测试流量监测点06', 'FLOW-TEST-006',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.37000000, 23.21000000, '供水测试大道10号',
+     '供水测试维护员', '13800000010', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-007', 'CN-GS-202609020008', '测试流量监测点07', 'FLOW-TEST-007',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.38000000, 23.22000000, '供水测试大道11号',
+     '供水测试维护员', '13800000011', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-008', 'CN-GS-202609020009', '测试流量监测点08', 'FLOW-TEST-008',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.39000000, 23.23000000, '供水测试大道12号',
+     '供水测试维护员', '13800000012', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-009', 'CN-GS-202609020010', '测试流量监测点09', 'FLOW-TEST-009',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.40000000, 23.24000000, '供水测试大道13号',
+     '供水测试维护员', '13800000013', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-010', 'CN-GS-202609020011', '测试流量监测点10', 'FLOW-TEST-010',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.41000000, 23.25000000, '供水测试大道14号',
+     '供水测试维护员', '13800000014', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-011', 'CN-GS-202609020012', '测试流量监测点11', 'FLOW-TEST-011',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.42000000, 23.26000000, '供水测试大道15号',
+     '供水测试维护员', '13800000015', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-FLOW-012', 'CN-GS-202609020013', '测试流量监测点12', 'FLOW-TEST-012',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_flow' AND parent_type_id = '1' LIMIT 1), 113.43000000, 23.27000000, '供水测试大道16号',
+     '供水测试维护员', '13800000016', '供水测试公司', '供水T4流量分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-PRESSURE-002', 'CB-GS-202609020002', '测试压力监测点02', 'PRESSURE-TEST-002',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_pressure' AND parent_type_id = '1' LIMIT 1), 113.44000000, 23.28000000, '供水测试大道17号',
+     '供水测试维护员', '13800000017', '供水测试公司', '供水T4压力分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-PRESSURE-003', 'CB-GS-202609020003', '测试压力监测点03', 'PRESSURE-TEST-003',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_pressure' AND parent_type_id = '1' LIMIT 1), 113.45000000, 23.29000000, '供水测试大道18号',
+     '供水测试维护员', '13800000018', '供水测试公司', '供水T4压力分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-LEAKAGE-002', 'TC-GS-202609020002', '测试漏失监测点02', 'LEAKAGE-TEST-002',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_leak' AND parent_type_id = '1' LIMIT 1), 113.46000000, 23.30000000, '供水测试大道19号',
+     '供水测试维护员', '13800000019', '供水测试公司', '供水T4漏失分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-LEAKAGE-003', 'TC-GS-202609020003', '测试漏失监测点03', 'LEAKAGE-TEST-003',
+     (SELECT id FROM app_user.equipment_type WHERE type_id = 'ws_leak' AND parent_type_id = '1' LIMIT 1), 113.47000000, 23.31000000, '供水测试大道20号',
+     '供水测试维护员', '13800000020', '供水测试公司', '供水T4漏失分页测试设备',
+     now() - interval '7 days', now(), '0', '供水测试区'),
+    ('WS-TEST-EQ-OTHER-FLOW', 'CN-RQ-202609020001', '测试非供水流量监测点', 'OTHER-FLOW-TEST',
+     (SELECT id FROM app_user.equipment_type
+       WHERE id <> '1' AND type_id NOT IN ('water_supply', 'ws_flow', 'ws_pressure', 'ws_leak')
+       LIMIT 1), 113.33000000, 23.17000000, '其他系统测试大道6号',
+     '供水测试维护员', '13800000006', '供水测试公司', '供水T4非供水设备边界测试数据',
+     now() - interval '7 days', now(), '0', '供水测试区');
+
+-- ------------------------------------------------------------
+-- 2. 雷达流量计数据
+--    数值范围 20-40 时:最新 120 不符合,应回退到 30。
+--    5 天前数据用于验证趋势时间范围过滤。
+-- ------------------------------------------------------------
+INSERT INTO app_user.radar_data
+    (id, system_identifier, device_code, meter1_instant_flow, flow_speed, create_time)
+VALUES
+    ('WS-TEST-RADAR-FLOW-001', 'WS-TEST-SYSTEM', 'CN-GS-202609020001', '30', '1.20',
+     now() - interval '3 hours'),
+    ('WS-TEST-RADAR-FLOW-002', 'WS-TEST-SYSTEM', 'CN-GS-202609020001', '120', '3.50',
+     now() - interval '1 hour'),
+    ('WS-TEST-RADAR-FLOW-003', 'WS-TEST-SYSTEM', 'CN-GS-202609020001', '18', '0.80',
+     now() - interval '5 days'),
+    ('WS-TEST-RADAR-FLOW-004', 'WS-TEST-SYSTEM', 'CN-GS-202609020004', '25', '1.00', now() - interval '1 hour'),
+    ('WS-TEST-RADAR-FLOW-005', 'WS-TEST-SYSTEM', 'CN-GS-202609020005', '35', '1.10', now() - interval '2 hours'),
+    ('WS-TEST-RADAR-FLOW-006', 'WS-TEST-SYSTEM', 'CN-GS-202609020006', '45', '1.20', now() - interval '3 hours'),
+    ('WS-TEST-RADAR-FLOW-007', 'WS-TEST-SYSTEM', 'CN-GS-202609020007', '55', '1.30', now() - interval '4 hours'),
+    ('WS-TEST-RADAR-FLOW-008', 'WS-TEST-SYSTEM', 'CN-GS-202609020008', '65', '1.40', now() - interval '5 hours'),
+    ('WS-TEST-RADAR-OTHER-FLOW-001', 'WS-TEST-SYSTEM', 'CN-RQ-202609020001', '66', '2.00',
+     now() - interval '1 hour');
+
+-- ------------------------------------------------------------
+-- 3. 遥测终端流量数据(device_code 与 equipment_base.equipment_code 关联)
+-- ------------------------------------------------------------
+INSERT INTO app_user.telemetry_data
+    (id, system_identifier, device_code, meter1_instant_flow, meter1_positive_total,
+     flow_speed, temperature, create_time)
+VALUES
+    ('WS-TEST-TELEMETRY-001', 'WS-TEST-SYSTEM', 'CN-GS-202609020002', '42', '12345',
+     '1.80', '24.50', now() - interval '2 hours'),
+    ('WS-TEST-TELEMETRY-002', 'WS-TEST-SYSTEM', 'CN-GS-202609020002', '36', '12300',
+     '1.50', '24.00', now() - interval '4 hours'),
+    ('WS-TEST-TELEMETRY-003', 'WS-TEST-SYSTEM', 'CN-GS-202609020002', '20', '12200',
+     '0.90', '23.50', now() - interval '5 days');
+INSERT INTO app_user.telemetry_data
+    (id, system_identifier, device_code, meter1_instant_flow, meter1_positive_total,
+     flow_speed, temperature, create_time)
+VALUES
+    ('WS-TEST-TELEMETRY-004', 'WS-TEST-SYSTEM', 'CN-GS-202609020009', '18', '10008', '0.80', '23.80', now() - interval '1 hour'),
+    ('WS-TEST-TELEMETRY-005', 'WS-TEST-SYSTEM', 'CN-GS-202609020010', '28', '10009', '0.90', '23.90', now() - interval '2 hours'),
+    ('WS-TEST-TELEMETRY-006', 'WS-TEST-SYSTEM', 'CN-GS-202609020011', '38', '10010', '1.00', '24.10', now() - interval '3 hours'),
+    ('WS-TEST-TELEMETRY-007', 'WS-TEST-SYSTEM', 'CN-GS-202609020012', '48', '10011', '1.10', '24.20', now() - interval '4 hours'),
+    ('WS-TEST-TELEMETRY-008', 'WS-TEST-SYSTEM', 'CN-GS-202609020013', '58', '10012', '1.20', '24.30', now() - interval '5 hours');
+
+-- ------------------------------------------------------------
+-- 4. 消防压力数据(telemetering_station 与 equipment_base.equipment_code 关联)
+-- ------------------------------------------------------------
+INSERT INTO app_user.firefighting_pressure
+    (id, central_station, telemetering_station, sending_time, observed_time,
+     pressure_value, create_time)
+VALUES
+    ('WS-TEST-PRESSURE-001', 'WS-TEST-CENTER', 'CB-GS-202609020001',
+     now() - interval '3 hours', now() - interval '3 hours', 0.42, now() - interval '3 hours'),
+    ('WS-TEST-PRESSURE-002', 'WS-TEST-CENTER', 'CB-GS-202609020001',
+     now() - interval '1 hour', now() - interval '1 hour', 0.95, now() - interval '1 hour'),
+    ('WS-TEST-PRESSURE-003', 'WS-TEST-CENTER', 'CB-GS-202609020001',
+     now() - interval '5 days', now() - interval '5 days', 0.20, now() - interval '5 days');
+INSERT INTO app_user.firefighting_pressure
+    (id, central_station, telemetering_station, sending_time, observed_time,
+     pressure_value, create_time)
+VALUES
+    ('WS-TEST-PRESSURE-004', 'WS-TEST-CENTER', 'CB-GS-202609020002',
+     now() - interval '2 hours', now() - interval '2 hours', 0.55, now() - interval '2 hours'),
+    ('WS-TEST-PRESSURE-005', 'WS-TEST-CENTER', 'CB-GS-202609020003',
+     now() - interval '1 hour', now() - interval '1 hour', 0.75, now() - interval '1 hour');
+
+-- ------------------------------------------------------------
+-- 5. 漏失噪声数据(encode 与 equipment_base.equipment_code 关联)
+-- ------------------------------------------------------------
+INSERT INTO app_user.noise_info
+    (encode, power, gain, rms, center_frequency, ts, create_time, update_time)
+VALUES
+    ('TC-GS-202609020001', 12.00, 2.00, 120, 800.00,
+     to_char(now() - interval '2 hours', 'YYYY-MM-DD HH24:MI:SS'),
+     now() - interval '2 hours', now() - interval '2 hours'),
+    ('TC-GS-202609020001', 9.00, 2.00, 100, 780.00,
+     to_char(now() - interval '4 hours', 'YYYY-MM-DD HH24:MI:SS'),
+     now() - interval '4 hours', now() - interval '4 hours'),
+    ('TC-GS-202609020001', 5.00, 1.00, 80, 700.00,
+     to_char(now() - interval '5 days', 'YYYY-MM-DD HH24:MI:SS'),
+     now() - interval '5 days', now() - interval '5 days');
+INSERT INTO app_user.noise_info
+    (encode, power, gain, rms, center_frequency, ts, create_time, update_time)
+VALUES
+    ('TC-GS-202609020002', 15.00, 2.50, 125, 820.00,
+     to_char(now() - interval '1 hour', 'YYYY-MM-DD HH24:MI:SS'),
+     now() - interval '1 hour', now() - interval '1 hour'),
+    ('TC-GS-202609020003', 18.00, 3.00, 130, 840.00,
+     to_char(now() - interval '2 hours', 'YYYY-MM-DD HH24:MI:SS'),
+     now() - interval '2 hours', now() - interval '2 hours');
+
+-- ------------------------------------------------------------
+-- 快速核验:
+--   规范 ws_flow 流量设备共13台(其中12台有 radar/telemetry 测量记录,1台无数据);
+--   流量分页(默认最新值):12台有测量设备各返回1条;雷达流量点最新值120,遥测流量点最新值42;
+--   流量分页 flowMin=20&flowMax=40:符合范围的雷达/遥测点共7条(含回退到雷达30、遥测36);
+--   压力分页 pressureMin=0.30&pressureMax=0.60:0.42、0.55,共2条;
+--   漏失分页 powerMin=8&powerMax=15:12、15,共2条;
+--   CN-GS-202609020003 无测量数据,不应出现在任何监测页。
+--   CN-RQ-202609020001 如数据库存在其他系统类型则带入该类型;不在供水规范子类型内,不应出现在监测页。
+-- ============================================================

+ 23 - 6
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/FlowMonitoring/FlowMonitoringController.java

@@ -1,9 +1,12 @@
 package com.zksy.web.controller.WaterSupply.FlowMonitoring;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.WaterSupply.Monitoring.service.MonitoringExportService;
 import com.zksy.base.domain.vo.FlowMonitoringVO;
 import com.zksy.base.service.MonitoringService;
+import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.enums.BusinessType;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiParam;
@@ -13,6 +16,10 @@ import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
@@ -26,6 +33,9 @@ public class FlowMonitoringController {
     @Autowired
     private MonitoringService monitoringService;
 
+    @Autowired
+    private MonitoringExportService monitoringExportService;
+
     @GetMapping("/page")
     @ApiOperation("流量监测分页查询")
     @PreAuthorize("@ss.hasPermi('waterSupply:monitoring:flow:list')")
@@ -48,25 +58,32 @@ public class FlowMonitoringController {
     @GetMapping("/trend/{equipmentId}")
     @ApiOperation("流量近24小时趋势数据")
     @PreAuthorize("@ss.hasPermi('waterSupply:monitoring:flow:list')")
-    public AjaxResult getFlowTrend24h(@PathVariable String equipmentId) {
-        List<Map<String, Object>> trend = monitoringService.getFlowTrend24h(equipmentId);
+    public AjaxResult getFlowTrend(@PathVariable String equipmentId,
+                                    @ApiParam("开始时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+                                    @ApiParam("结束时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
+        List<Map<String, Object>> trend = monitoringService.getFlowTrend(equipmentId, startTime, endTime);
         return AjaxResult.success(trend);
     }
 
     @GetMapping("/export")
     @ApiOperation("流量监测数据导出")
     @PreAuthorize("@ss.hasPermi('waterSupply:monitoring:flow:export')")
-    public AjaxResult exportFlowMonitorData(
+    @Log(title = "供水流量监测", businessType = BusinessType.EXPORT)
+    public void exportFlowMonitorData(HttpServletResponse response,
             @ApiParam("设备名称") @RequestParam(required = false) String equipmentName,
             @ApiParam("设备编码") @RequestParam(required = false) String equipmentCode,
             @ApiParam("安装位置") @RequestParam(required = false) String equipmentLocation,
             @ApiParam("开始时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
             @ApiParam("结束时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
             @ApiParam("流量最小值") @RequestParam(required = false) Double flowMin,
-            @ApiParam("流量最大值") @RequestParam(required = false) Double flowMax) {
+            @ApiParam("流量最大值") @RequestParam(required = false) Double flowMax) throws IOException {
         Page<FlowMonitoringVO> page = monitoringService.getFlowMonitorPage(
-                1, 10000, equipmentName, equipmentCode, equipmentLocation,
+                1, Integer.MAX_VALUE, equipmentName, equipmentCode, equipmentLocation,
                 startTime, endTime, flowMin, flowMax);
-        return AjaxResult.success(page.getRecords());
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        response.setHeader("Content-Disposition", "attachment;filename="
+                + URLEncoder.encode("流量监测数据.xlsx", StandardCharsets.UTF_8.name()));
+        monitoringExportService.writeFlow(response.getOutputStream(), page.getRecords());
     }
 }

+ 23 - 6
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/LeakageMonitoring/LeakageMonitoringController.java

@@ -1,9 +1,12 @@
 package com.zksy.web.controller.WaterSupply.LeakageMonitoring;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.WaterSupply.Monitoring.service.MonitoringExportService;
 import com.zksy.base.domain.vo.LeakageMonitoringVO;
 import com.zksy.base.service.MonitoringService;
+import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.enums.BusinessType;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiParam;
@@ -13,6 +16,10 @@ import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
@@ -26,6 +33,9 @@ public class LeakageMonitoringController {
     @Autowired
     private MonitoringService monitoringService;
 
+    @Autowired
+    private MonitoringExportService monitoringExportService;
+
     @GetMapping("/page")
     @ApiOperation("漏失监测分页查询")
     @PreAuthorize("@ss.hasPermi('waterSupply:monitoring:leakage:list')")
@@ -48,25 +58,32 @@ public class LeakageMonitoringController {
     @GetMapping("/trend/{equipmentId}")
     @ApiOperation("漏失近24小时趋势数据")
     @PreAuthorize("@ss.hasPermi('waterSupply:monitoring:leakage:list')")
-    public AjaxResult getLeakageTrend24h(@PathVariable String equipmentId) {
-        List<Map<String, Object>> trend = monitoringService.getLeakageTrend24h(equipmentId);
+    public AjaxResult getLeakageTrend(@PathVariable String equipmentId,
+                                      @ApiParam("开始时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+                                      @ApiParam("结束时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
+        List<Map<String, Object>> trend = monitoringService.getLeakageTrend(equipmentId, startTime, endTime);
         return AjaxResult.success(trend);
     }
 
     @GetMapping("/export")
     @ApiOperation("漏失监测数据导出")
     @PreAuthorize("@ss.hasPermi('waterSupply:monitoring:leakage:export')")
-    public AjaxResult exportLeakageMonitorData(
+    @Log(title = "供水漏失监测", businessType = BusinessType.EXPORT)
+    public void exportLeakageMonitorData(HttpServletResponse response,
             @ApiParam("设备名称") @RequestParam(required = false) String equipmentName,
             @ApiParam("设备编码") @RequestParam(required = false) String equipmentCode,
             @ApiParam("安装位置") @RequestParam(required = false) String equipmentLocation,
             @ApiParam("开始时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
             @ApiParam("结束时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
             @ApiParam("功率最小值") @RequestParam(required = false) Double powerMin,
-            @ApiParam("功率最大值") @RequestParam(required = false) Double powerMax) {
+            @ApiParam("功率最大值") @RequestParam(required = false) Double powerMax) throws IOException {
         Page<LeakageMonitoringVO> page = monitoringService.getLeakageMonitorPage(
-                1, 10000, equipmentName, equipmentCode, equipmentLocation,
+                1, Integer.MAX_VALUE, equipmentName, equipmentCode, equipmentLocation,
                 startTime, endTime, powerMin, powerMax);
-        return AjaxResult.success(page.getRecords());
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        response.setHeader("Content-Disposition", "attachment;filename="
+                + URLEncoder.encode("漏失监测数据.xlsx", StandardCharsets.UTF_8.name()));
+        monitoringExportService.writeLeakage(response.getOutputStream(), page.getRecords());
     }
 }

+ 23 - 6
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/PressureMonitoring/PressureMonitoringController.java

@@ -1,9 +1,12 @@
 package com.zksy.web.controller.WaterSupply.PressureMonitoring;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.WaterSupply.Monitoring.service.MonitoringExportService;
 import com.zksy.base.domain.vo.PressureMonitoringVO;
 import com.zksy.base.service.MonitoringService;
+import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.enums.BusinessType;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiParam;
@@ -13,6 +16,10 @@ import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
@@ -26,6 +33,9 @@ public class PressureMonitoringController {
     @Autowired
     private MonitoringService monitoringService;
 
+    @Autowired
+    private MonitoringExportService monitoringExportService;
+
     @GetMapping("/page")
     @ApiOperation("压力监测分页查询")
     @PreAuthorize("@ss.hasPermi('waterSupply:monitoring:pressure:list')")
@@ -48,25 +58,32 @@ public class PressureMonitoringController {
     @GetMapping("/trend/{equipmentId}")
     @ApiOperation("压力近24小时趋势数据")
     @PreAuthorize("@ss.hasPermi('waterSupply:monitoring:pressure:list')")
-    public AjaxResult getPressureTrend24h(@PathVariable String equipmentId) {
-        List<Map<String, Object>> trend = monitoringService.getPressureTrend24h(equipmentId);
+    public AjaxResult getPressureTrend(@PathVariable String equipmentId,
+                                       @ApiParam("开始时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+                                       @ApiParam("结束时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
+        List<Map<String, Object>> trend = monitoringService.getPressureTrend(equipmentId, startTime, endTime);
         return AjaxResult.success(trend);
     }
 
     @GetMapping("/export")
     @ApiOperation("压力监测数据导出")
     @PreAuthorize("@ss.hasPermi('waterSupply:monitoring:pressure:export')")
-    public AjaxResult exportPressureMonitorData(
+    @Log(title = "供水压力监测", businessType = BusinessType.EXPORT)
+    public void exportPressureMonitorData(HttpServletResponse response,
             @ApiParam("设备名称") @RequestParam(required = false) String equipmentName,
             @ApiParam("设备编码") @RequestParam(required = false) String equipmentCode,
             @ApiParam("安装位置") @RequestParam(required = false) String equipmentLocation,
             @ApiParam("开始时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
             @ApiParam("结束时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
             @ApiParam("压力最小值(Mpa)") @RequestParam(required = false) Double pressureMin,
-            @ApiParam("压力最大值(Mpa)") @RequestParam(required = false) Double pressureMax) {
+            @ApiParam("压力最大值(Mpa)") @RequestParam(required = false) Double pressureMax) throws IOException {
         Page<PressureMonitoringVO> page = monitoringService.getPressureMonitorPage(
-                1, 10000, equipmentName, equipmentCode, equipmentLocation,
+                1, Integer.MAX_VALUE, equipmentName, equipmentCode, equipmentLocation,
                 startTime, endTime, pressureMin, pressureMax);
-        return AjaxResult.success(page.getRecords());
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        response.setHeader("Content-Disposition", "attachment;filename="
+                + URLEncoder.encode("压力监测数据.xlsx", StandardCharsets.UTF_8.name()));
+        monitoringExportService.writePressure(response.getOutputStream(), page.getRecords());
     }
 }

+ 47 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitFoundationController.java

@@ -0,0 +1,47 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitFoundationService;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+@RequestMapping("/api/cockpit/v1")
+public class CockpitFoundationController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitFoundationService foundationService;
+
+    public CockpitFoundationController(CockpitFoundationService foundationService) {
+        this.foundationService = foundationService;
+    }
+
+    @GetMapping("/foundation")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> foundation(@RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(foundationService.load(resolveRequestId(requestId))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        CockpitErrorPayload payload = foundationService.serverError(resolveRequestId(request.getHeader(REQUEST_ID_HEADER)));
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(AjaxResult.error(payload.getMessage(), payload));
+    }
+
+    private String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+}

+ 40 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitAuthenticationEntryPointTest.java

@@ -0,0 +1,40 @@
+package com.zksy.web.controller.cockpit;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.zksy.framework.security.handle.CockpitAuthenticationEntryPoint;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.BadCredentialsException;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CockpitAuthenticationEntryPointTest {
+
+    @Test
+    void unauthenticatedCockpitRequestReturnsUnauthorizedHttpStatusAndTypedError() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cockpit/v1/foundation");
+        request.addHeader("X-Request-Id", "foundation-request-401");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        CockpitAuthenticationEntryPoint entryPoint = new CockpitAuthenticationEntryPoint(
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+
+        entryPoint.commence(request, response, new BadCredentialsException("invalid token"));
+
+        assertEquals(401, response.getStatus());
+        assertTrue(response.getContentType().startsWith("application/json"));
+        JSONObject body = JSON.parseObject(response.getContentAsString());
+        assertEquals(401, body.getIntValue("code"));
+        assertEquals("驾驶舱接口需要认证", body.getString("msg"));
+        assertEquals("UNAUTHORIZED", body.getJSONObject("data").getString("category"));
+        assertEquals("驾驶舱接口需要认证", body.getJSONObject("data").getString("message"));
+        assertEquals("foundation-request-401", body.getJSONObject("data").getString("requestId"));
+        assertEquals("2026-09-02T10:00:00+08:00", body.getJSONObject("data").getString("serverTime"));
+    }
+}

+ 107 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFoundationControllerTest.java

@@ -0,0 +1,107 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitFoundationAdapter;
+import com.zksy.cockpit.foundation.CockpitFoundationService;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class CockpitFoundationControllerTest {
+
+    private CockpitFoundationAdapter adapter;
+    private CockpitFoundationController controller;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitFoundationAdapter.class);
+        controller = new CockpitFoundationController(
+                new CockpitFoundationService(adapter, Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC)));
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    void foundationResponseUsesAjaxResultCamelCaseFreshnessAndReadOnlyContract() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/foundation").header("X-Request-Id", "foundation-request-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("foundation-request-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.mapFrame.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.mapFrame.renderable").value(false))
+                .andExpect(jsonPath("$.data.freshness.status").value("FRESH"))
+                .andExpect(jsonPath("$.data.freshness.serverTime").value("2026-09-02T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.freshness.dataTime").value("2026-09-02T10:00:00+08:00"));
+
+        verify(adapter).load();
+        verifyNoMoreInteractions(adapter);
+    }
+
+    @Test
+    void serverFailureReturnsTypedErrorInsteadOfPretendingToBeEmpty() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get("/api/cockpit/v1/foundation").header("X-Request-Id", "foundation-request-002"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.msg").value("驾驶舱基础数据暂不可用"))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱基础数据暂不可用"))
+                .andExpect(jsonPath("$.data.requestId").value("foundation-request-002"))
+                .andExpect(jsonPath("$.data.serverTime", notNullValue()));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post("/api/cockpit/v1/foundation"))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void foundationContractOnlyExposesAuthenticatedReadOperations() {
+        assertTrue(Arrays.stream(controller.getClass().getDeclaredMethods())
+                .filter(method -> method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class))
+                .allMatch(CockpitFoundationControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(Arrays.stream(controller.getClass().getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(org.springframework.security.access.prepost.PreAuthorize.class)
+                        && method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value().equals("isAuthenticated()")));
+    }
+
+    private static boolean isReadOnlyMappedMethod(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
+    }
+}

+ 24 - 1
pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/config/SecurityConfig.java

@@ -4,23 +4,30 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.http.HttpMethod;
+import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
 import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.authentication.ProviderManager;
 import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
 import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 import org.springframework.security.web.authentication.logout.LogoutFilter;
 import org.springframework.web.filter.CorsFilter;
 import com.zksy.framework.config.properties.PermitAllUrlProperties;
 import com.zksy.framework.security.filter.JwtAuthenticationTokenFilter;
+import com.zksy.framework.security.handle.CockpitAuthenticationEntryPoint;
 import com.zksy.framework.security.handle.AuthenticationEntryPointImpl;
 import com.zksy.framework.security.handle.LogoutSuccessHandlerImpl;
 
+import java.util.LinkedHashMap;
+
 /**
  * spring security配置
  * 
@@ -42,6 +49,12 @@ public class SecurityConfig
     @Autowired
     private AuthenticationEntryPointImpl unauthorizedHandler;
 
+    /**
+     * 驾驶舱专用未认证处理
+     */
+    @Autowired
+    private CockpitAuthenticationEntryPoint cockpitUnauthorizedHandler;
+
     /**
      * 退出处理类
      */
@@ -104,7 +117,7 @@ public class SecurityConfig
                 headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin());
             })
             // 认证失败处理类
-            .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
+            .exceptionHandling(exception -> exception.authenticationEntryPoint(delegatingAuthenticationEntryPoint()))
             // 基于token,所以不需要session
             .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
             // 注解标记允许匿名访问的url
@@ -136,4 +149,14 @@ public class SecurityConfig
     {
         return new BCryptPasswordEncoder();
     }
+
+    private DelegatingAuthenticationEntryPoint delegatingAuthenticationEntryPoint()
+    {
+        LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>();
+        entryPoints.put(new AntPathRequestMatcher("/api/cockpit/**"), cockpitUnauthorizedHandler);
+        DelegatingAuthenticationEntryPoint delegatingEntryPoint =
+                new DelegatingAuthenticationEntryPoint(entryPoints);
+        delegatingEntryPoint.setDefaultEntryPoint(unauthorizedHandler);
+        return delegatingEntryPoint;
+    }
 }

+ 52 - 0
pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/security/handle/CockpitAuthenticationEntryPoint.java

@@ -0,0 +1,52 @@
+package com.zksy.framework.security.handle;
+
+import com.alibaba.fastjson2.JSON;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.common.constant.HttpStatus;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+@Component
+public class CockpitAuthenticationEntryPoint implements AuthenticationEntryPoint {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+    private static final String UNAUTHORIZED_MESSAGE = "驾驶舱接口需要认证";
+    private static final DateTimeFormatter BUSINESS_TIME = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+
+    private final Clock clock;
+
+    public CockpitAuthenticationEntryPoint(Clock clock) {
+        this.clock = clock;
+    }
+
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response,
+            AuthenticationException authenticationException) throws IOException {
+        String requestId = request.getHeader(REQUEST_ID_HEADER);
+        if (StringUtils.isEmpty(requestId)) {
+            requestId = IdUtils.fastSimpleUUID();
+        }
+        String serverTime = BUSINESS_TIME.format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+        CockpitErrorPayload payload = new CockpitErrorPayload(
+                CockpitErrorCategory.UNAUTHORIZED,
+                UNAUTHORIZED_MESSAGE,
+                requestId,
+                serverTime);
+        AjaxResult result = new AjaxResult(HttpStatus.UNAUTHORIZED, UNAUTHORIZED_MESSAGE, payload);
+        response.setStatus(HttpStatus.UNAUTHORIZED);
+        response.setContentType("application/json");
+        response.setCharacterEncoding("UTF-8");
+        response.getWriter().print(JSON.toJSONString(result));
+    }
+}

+ 18 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/Monitoring/service/MonitoringExportService.java

@@ -0,0 +1,18 @@
+package com.zksy.WaterSupply.Monitoring.service;
+
+import com.zksy.base.domain.vo.FlowMonitoringVO;
+import com.zksy.base.domain.vo.LeakageMonitoringVO;
+import com.zksy.base.domain.vo.PressureMonitoringVO;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+public interface MonitoringExportService {
+
+    void writeFlow(OutputStream outputStream, List<FlowMonitoringVO> records) throws IOException;
+
+    void writePressure(OutputStream outputStream, List<PressureMonitoringVO> records) throws IOException;
+
+    void writeLeakage(OutputStream outputStream, List<LeakageMonitoringVO> records) throws IOException;
+}

+ 118 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/Monitoring/service/impl/MonitoringExportServiceImpl.java

@@ -0,0 +1,118 @@
+package com.zksy.WaterSupply.Monitoring.service.impl;
+
+import com.zksy.WaterSupply.Monitoring.service.MonitoringExportService;
+import com.zksy.base.domain.vo.FlowMonitoringVO;
+import com.zksy.base.domain.vo.LeakageMonitoringVO;
+import com.zksy.base.domain.vo.PressureMonitoringVO;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+@Service
+public class MonitoringExportServiceImpl implements MonitoringExportService {
+
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private static final String[] FLOW_HEADERS = {"设备ID", "设备编码", "设备名称", "安装位置", "设备子类型", "数据来源", "瞬时流量(m³/h)", "流速(m/s)", "正向累计流量", "温度", "采集时间", "经纬度"};
+    private static final String[] PRESSURE_HEADERS = {"设备ID", "设备编码", "设备名称", "安装位置", "设备子类型", "压力值(MPa)", "采集时间", "经纬度"};
+    private static final String[] LEAKAGE_HEADERS = {"设备ID", "设备编码", "设备名称", "安装位置", "设备子类型", "功率(kW)", "增益", "RMS", "中心频率", "采集时间", "经纬度"};
+
+    @Override
+    public void writeFlow(OutputStream outputStream, List<FlowMonitoringVO> records) throws IOException {
+        try (Workbook workbook = new XSSFWorkbook()) {
+            Sheet sheet = workbook.createSheet("流量监测");
+            writeHeader(sheet, FLOW_HEADERS);
+            int rowIndex = 1;
+            for (FlowMonitoringVO record : records) {
+                Row row = sheet.createRow(rowIndex++);
+                writeCell(row, 0, record.getEquipmentId());
+                writeCell(row, 1, record.getEquipmentCode());
+                writeCell(row, 2, record.getEquipmentName());
+                writeCell(row, 3, record.getEquipmentLocation());
+                writeCell(row, 4, record.getEquipmentTypeName());
+                writeCell(row, 5, record.getDataSource());
+                writeCell(row, 6, record.getInstantFlow());
+                writeCell(row, 7, record.getFlowSpeed());
+                writeCell(row, 8, record.getPositiveTotal());
+                writeCell(row, 9, record.getTemperature());
+                writeCell(row, 10, formatTime(record.getMonitorTime()));
+                writeCell(row, 11, record.getGps());
+            }
+            workbook.write(outputStream);
+        }
+    }
+
+    @Override
+    public void writePressure(OutputStream outputStream, List<PressureMonitoringVO> records) throws IOException {
+        try (Workbook workbook = new XSSFWorkbook()) {
+            Sheet sheet = workbook.createSheet("压力监测");
+            writeHeader(sheet, PRESSURE_HEADERS);
+            int rowIndex = 1;
+            for (PressureMonitoringVO record : records) {
+                Row row = sheet.createRow(rowIndex++);
+                writeCell(row, 0, record.getEquipmentId());
+                writeCell(row, 1, record.getEquipmentCode());
+                writeCell(row, 2, record.getEquipmentName());
+                writeCell(row, 3, record.getEquipmentLocation());
+                writeCell(row, 4, record.getEquipmentTypeName());
+                writeNumber(row, 5, record.getPressureValue());
+                writeCell(row, 6, formatTime(record.getMonitorTime()));
+                writeCell(row, 7, record.getGps());
+            }
+            workbook.write(outputStream);
+        }
+    }
+
+    @Override
+    public void writeLeakage(OutputStream outputStream, List<LeakageMonitoringVO> records) throws IOException {
+        try (Workbook workbook = new XSSFWorkbook()) {
+            Sheet sheet = workbook.createSheet("漏失监测");
+            writeHeader(sheet, LEAKAGE_HEADERS);
+            int rowIndex = 1;
+            for (LeakageMonitoringVO record : records) {
+                Row row = sheet.createRow(rowIndex++);
+                writeCell(row, 0, record.getEquipmentId());
+                writeCell(row, 1, record.getEquipmentCode());
+                writeCell(row, 2, record.getEquipmentName());
+                writeCell(row, 3, record.getEquipmentLocation());
+                writeCell(row, 4, record.getEquipmentTypeName());
+                writeNumber(row, 5, record.getPower());
+                writeNumber(row, 6, record.getGain());
+                writeNumber(row, 7, record.getRms());
+                writeNumber(row, 8, record.getCenterFrequency());
+                writeCell(row, 9, formatTime(record.getMonitorTime()));
+                writeCell(row, 10, record.getGps());
+            }
+            workbook.write(outputStream);
+        }
+    }
+
+    private void writeHeader(Sheet sheet, String[] headers) {
+        Row row = sheet.createRow(0);
+        for (int i = 0; i < headers.length; i++) {
+            row.createCell(i).setCellValue(headers[i]);
+        }
+    }
+
+    private void writeCell(Row row, int index, String value) {
+        row.createCell(index).setCellValue(value == null ? "" : value);
+    }
+
+    private void writeNumber(Row row, int index, Number value) {
+        if (value == null) {
+            row.createCell(index).setCellValue("");
+        } else {
+            row.createCell(index).setCellValue(value.doubleValue());
+        }
+    }
+
+    private String formatTime(java.time.LocalDateTime value) {
+        return value == null ? "" : TIME_FORMATTER.format(value);
+    }
+}

+ 3 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/MonitoringService.java

@@ -29,9 +29,9 @@ public interface MonitoringService {
                                                     LocalDateTime startTime, LocalDateTime endTime,
                                                     Double powerMin, Double powerMax);
 
-    List<Map<String, Object>> getFlowTrend24h(String equipmentId);
+    List<Map<String, Object>> getFlowTrend(String equipmentId, LocalDateTime startTime, LocalDateTime endTime);
 
-    List<Map<String, Object>> getPressureTrend24h(String equipmentId);
+    List<Map<String, Object>> getPressureTrend(String equipmentId, LocalDateTime startTime, LocalDateTime endTime);
 
-    List<Map<String, Object>> getLeakageTrend24h(String equipmentId);
+    List<Map<String, Object>> getLeakageTrend(String equipmentId, LocalDateTime startTime, LocalDateTime endTime);
 }

+ 404 - 342
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/MonitoringServiceImpl.java

@@ -20,18 +20,27 @@ import com.zksy.base.radar.mapper.RadarDataMapper;
 import com.zksy.base.service.MonitoringService;
 import com.zksy.base.telemetry.domain.TelemetryData;
 import com.zksy.base.telemetry.mapper.TelemetryDataMapper;
-import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.time.LocalDateTime;
 import java.util.*;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
-@Slf4j
 @Service
 public class MonitoringServiceImpl implements MonitoringService {
 
+    private static final String RADAR_SOURCE = "雷达流量计";
+    private static final String TELEMETRY_SOURCE = "遥测终端";
+
+    private static final String WATER_SUPPLY_ROOT_ID = "1";
+    private static final String WATER_SUPPLY_ROOT_TYPE_ID = "water_supply";
+    private static final String WATER_SUPPLY_ROOT_PARENT_ID = "0";
+    private static final String FLOW_TYPE_ID = "ws_flow";
+    private static final String PRESSURE_TYPE_ID = "ws_pressure";
+    private static final String LEAKAGE_TYPE_ID = "ws_leak";
+
     @Autowired
     private EquipmentBaseMapper equipmentBaseMapper;
 
@@ -52,438 +61,491 @@ public class MonitoringServiceImpl implements MonitoringService {
 
     @Override
     public Page<FlowMonitoringVO> getFlowMonitorPage(long pageNum, long pageSize,
-                                                      String equipmentName, String equipmentCode,
-                                                      String equipmentLocation,
-                                                      LocalDateTime startTime, LocalDateTime endTime,
-                                                      Double flowMin, Double flowMax) {
-        LambdaQueryWrapper<EquipmentBase> baseWrapper = new LambdaQueryWrapper<>();
-        baseWrapper.like(StrUtil.isNotBlank(equipmentName), EquipmentBase::getEquipmentName, equipmentName)
-                .like(StrUtil.isNotBlank(equipmentCode), EquipmentBase::getEquipmentCode, equipmentCode)
-                .like(StrUtil.isNotBlank(equipmentLocation), EquipmentBase::getEquipmentLocation, equipmentLocation)
-                .orderByDesc(EquipmentBase::getCreateTime);
-        constrainToMonitoringTypes(baseWrapper, "flow");
-
-        Page<EquipmentBase> basePage = equipmentBaseMapper.selectPage(new Page<>(pageNum, pageSize), baseWrapper);
-        Page<FlowMonitoringVO> resultPage = new Page<>(pageNum, pageSize, basePage.getTotal());
-
-        if (CollUtil.isEmpty(basePage.getRecords())) {
-            resultPage.setRecords(new ArrayList<>());
-            return resultPage;
-        }
-
-        List<EquipmentBase> devices = basePage.getRecords();
-        List<String> deviceCodes = devices.stream()
-                .map(EquipmentBase::getEquipmentCode).filter(StrUtil::isNotBlank).collect(Collectors.toList());
-        List<String> typeIds = devices.stream()
-                .map(EquipmentBase::getEquipmentTypeId).filter(StrUtil::isNotBlank).distinct().collect(Collectors.toList());
-
-        Map<String, EquipmentType> typeMap = new HashMap<>();
-        if (CollUtil.isNotEmpty(typeIds)) {
-            List<EquipmentType> types = equipmentTypeMapper.selectBatchIds(typeIds);
-            typeMap = types.stream().collect(Collectors.toMap(EquipmentType::getTypeId, t -> t, (a, b) -> a));
-        }
-
-        Map<String, RadarData> radarLatestMap = new HashMap<>();
-        Map<String, TelemetryData> telemetryLatestMap = new HashMap<>();
-
-        if (CollUtil.isNotEmpty(deviceCodes)) {
-            List<RadarData> radarDataList = radarDataMapper.selectList(
-                    new LambdaQueryWrapper<RadarData>().in(RadarData::getDeviceCode, deviceCodes)
-                            .ge(startTime != null, RadarData::getCreateTime, startTime)
-                            .le(endTime != null, RadarData::getCreateTime, endTime)
-                            .orderByDesc(RadarData::getCreateTime)
-            );
-            if (CollUtil.isNotEmpty(radarDataList)) {
-                Set<String> seen = new HashSet<>();
-                for (RadarData d : radarDataList) {
-                    if (seen.add(d.getDeviceCode())) {
-                        radarLatestMap.put(d.getDeviceCode(), d);
-                    }
-                }
-            }
-
-            List<TelemetryData> telemetryDataList = telemetryDataMapper.selectList(
-                    new LambdaQueryWrapper<TelemetryData>().in(TelemetryData::getDeviceCode, deviceCodes)
-                            .ge(startTime != null, TelemetryData::getCreateTime, startTime)
-                            .le(endTime != null, TelemetryData::getCreateTime, endTime)
-                            .orderByDesc(TelemetryData::getCreateTime)
-            );
-            if (CollUtil.isNotEmpty(telemetryDataList)) {
-                Set<String> seen = new HashSet<>();
-                for (TelemetryData d : telemetryDataList) {
-                    if (seen.add(d.getDeviceCode())) {
-                        telemetryLatestMap.put(d.getDeviceCode(), d);
-                    }
-                }
-            }
+                                                     String equipmentName, String equipmentCode,
+                                                     String equipmentLocation,
+                                                     LocalDateTime startTime, LocalDateTime endTime,
+                                                     Double flowMin, Double flowMax) {
+        List<EquipmentBase> devices = selectMonitoringDevices(equipmentName, equipmentCode, equipmentLocation, "flow");
+        if (CollUtil.isEmpty(devices)) {
+            return emptyPage(pageNum, pageSize);
         }
 
-        List<FlowMonitoringVO> voList = new ArrayList<>();
-        for (EquipmentBase device : devices) {
-            FlowMonitoringVO vo = new FlowMonitoringVO();
-            vo.setEquipmentId(device.getEquipmentId());
-            vo.setEquipmentCode(device.getEquipmentCode());
-            vo.setEquipmentName(device.getEquipmentName());
-            vo.setEquipmentLocation(device.getEquipmentLocation());
-            vo.setEquipmentTypeId(device.getEquipmentTypeId());
-            EquipmentType type = typeMap.get(device.getEquipmentTypeId());
-            if (type != null) {
-                vo.setEquipmentTypeName(type.getTypeName());
-            }
-            vo.setLongitude(device.getLongitude());
-            vo.setLatitude(device.getLatitude());
-            if (device.getLongitude() != null && device.getLatitude() != null) {
-                vo.setGps(device.getLongitude() + "," + device.getLatitude());
-            }
+        Map<String, EquipmentType> typeMap = selectTypeMap(devices);
+        Map<String, RadarData> radarMap = selectLatestRadar(devices, startTime, endTime, flowMin, flowMax);
+        Map<String, TelemetryData> telemetryMap = selectLatestTelemetry(devices, startTime, endTime, flowMin, flowMax);
 
+        List<FlowMonitoringVO> allRows = new ArrayList<>();
+        for (EquipmentBase device : devices) {
             String code = device.getEquipmentCode();
-            boolean matched = false;
+            RadarData radar = radarMap.get(code);
+            TelemetryData telemetry = telemetryMap.get(code);
+            if (radar == null && telemetry == null) {
+                continue;
+            }
 
-            RadarData radar = radarLatestMap.get(code);
-            if (radar != null && isFlowInRange(radar.getMeter1InstantFlow(), flowMin, flowMax)) {
-                vo.setDataSource("雷达流量计");
+            FlowMonitoringVO vo = new FlowMonitoringVO();
+            fillBaseFields(vo, device, typeMap.get(device.getEquipmentTypeId()));
+            if (radar != null && (telemetry == null
+                    || compareTime(radar.getCreateTime(), telemetry.getCreateTime()) >= 0)) {
+                vo.setDataSource(RADAR_SOURCE);
                 vo.setInstantFlow(radar.getMeter1InstantFlow());
                 vo.setFlowSpeed(radar.getFlowSpeed());
                 vo.setMonitorTime(radar.getCreateTime());
-                matched = true;
-            }
-
-            if (!matched) {
-                TelemetryData telemetry = telemetryLatestMap.get(code);
-                if (telemetry != null && isFlowInRange(telemetry.getMeter1PositiveTotal(), flowMin, flowMax)) {
-                    vo.setDataSource("遥测终端");
-                    vo.setPositiveTotal(telemetry.getMeter1PositiveTotal());
-                    vo.setTemperature(telemetry.getTemperature());
-                    vo.setFlowSpeed(telemetry.getFlowSpeed());
-                    vo.setMonitorTime(telemetry.getCreateTime());
-                    matched = true;
-                }
-            }
-
-            if (matched || (flowMin == null && flowMax == null)) {
-                voList.add(vo);
+            } else {
+                vo.setDataSource(TELEMETRY_SOURCE);
+                vo.setInstantFlow(telemetry.getMeter1InstantFlow());
+                vo.setPositiveTotal(telemetry.getMeter1PositiveTotal());
+                vo.setTemperature(telemetry.getTemperature());
+                vo.setFlowSpeed(telemetry.getFlowSpeed());
+                vo.setMonitorTime(telemetry.getCreateTime());
             }
+            allRows.add(vo);
         }
-
-        resultPage.setRecords(voList);
-        return resultPage;
-    }
-
-    private boolean isFlowInRange(String flowStr, Double min, Double max) {
-        if (min == null && max == null) {
-            return true;
-        }
-        if (StrUtil.isBlank(flowStr)) {
-            return false;
-        }
-        try {
-            double val = Double.parseDouble(flowStr);
-            if (min != null && val < min) {
-                return false;
-            }
-            if (max != null && val > max) {
-                return false;
-            }
-            return true;
-        } catch (NumberFormatException e) {
-            return false;
-        }
+        return pageOf(allRows, pageNum, pageSize);
     }
 
     @Override
     public Page<PressureMonitoringVO> getPressureMonitorPage(long pageNum, long pageSize,
-                                                              String equipmentName, String equipmentCode,
-                                                              String equipmentLocation,
-                                                              LocalDateTime startTime, LocalDateTime endTime,
-                                                              Double pressureMin, Double pressureMax) {
-        LambdaQueryWrapper<EquipmentBase> baseWrapper = new LambdaQueryWrapper<>();
-        baseWrapper.like(StrUtil.isNotBlank(equipmentName), EquipmentBase::getEquipmentName, equipmentName)
-                .like(StrUtil.isNotBlank(equipmentCode), EquipmentBase::getEquipmentCode, equipmentCode)
-                .like(StrUtil.isNotBlank(equipmentLocation), EquipmentBase::getEquipmentLocation, equipmentLocation)
-                .orderByDesc(EquipmentBase::getCreateTime);
-        constrainToMonitoringTypes(baseWrapper, "pressure");
-
-        Page<EquipmentBase> basePage = equipmentBaseMapper.selectPage(new Page<>(pageNum, pageSize), baseWrapper);
-        Page<PressureMonitoringVO> resultPage = new Page<>(pageNum, pageSize, basePage.getTotal());
-
-        if (CollUtil.isEmpty(basePage.getRecords())) {
-            resultPage.setRecords(new ArrayList<>());
-            return resultPage;
-        }
-
-        List<EquipmentBase> devices = basePage.getRecords();
-        List<String> deviceCodes = devices.stream()
-                .map(EquipmentBase::getEquipmentCode).filter(StrUtil::isNotBlank).collect(Collectors.toList());
-        List<String> typeIds = devices.stream()
-                .map(EquipmentBase::getEquipmentTypeId).filter(StrUtil::isNotBlank).distinct().collect(Collectors.toList());
-
-        Map<String, EquipmentType> typeMap = new HashMap<>();
-        if (CollUtil.isNotEmpty(typeIds)) {
-            List<EquipmentType> types = equipmentTypeMapper.selectBatchIds(typeIds);
-            typeMap = types.stream().collect(Collectors.toMap(EquipmentType::getTypeId, t -> t, (a, b) -> a));
+                                                             String equipmentName, String equipmentCode,
+                                                             String equipmentLocation,
+                                                             LocalDateTime startTime, LocalDateTime endTime,
+                                                             Double pressureMin, Double pressureMax) {
+        List<EquipmentBase> devices = selectMonitoringDevices(equipmentName, equipmentCode, equipmentLocation, "pressure");
+        if (CollUtil.isEmpty(devices)) {
+            return emptyPage(pageNum, pageSize);
         }
 
-        Map<String, FirefightingPressure> pressureLatestMap = new HashMap<>();
-        if (CollUtil.isNotEmpty(deviceCodes)) {
-            List<FirefightingPressure> pressureList = firefightingPressureMapper.selectList(
-                    new LambdaQueryWrapper<FirefightingPressure>().in(FirefightingPressure::getTelemeteringStation, deviceCodes)
+        Map<String, EquipmentType> typeMap = selectTypeMap(devices);
+        Map<String, FirefightingPressure> pressureMap = new HashMap<>();
+        List<String> deviceCodes = deviceCodes(devices);
+        if (!deviceCodes.isEmpty()) {
+            List<FirefightingPressure> records = firefightingPressureMapper.selectList(
+                    new LambdaQueryWrapper<FirefightingPressure>()
+                            .in(FirefightingPressure::getTelemeteringStation, deviceCodes)
                             .ge(startTime != null, FirefightingPressure::getCreateTime, startTime)
                             .le(endTime != null, FirefightingPressure::getCreateTime, endTime)
-                            .ge(pressureMin != null, FirefightingPressure::getPressureValue, pressureMin)
-                            .le(pressureMax != null, FirefightingPressure::getPressureValue, pressureMax)
-                            .orderByDesc(FirefightingPressure::getCreateTime)
-            );
-            if (CollUtil.isNotEmpty(pressureList)) {
-                Set<String> seen = new HashSet<>();
-                for (FirefightingPressure p : pressureList) {
-                    if (seen.add(p.getTelemeteringStation())) {
-                        pressureLatestMap.put(p.getTelemeteringStation(), p);
-                    }
-                }
-            }
+                            .orderByDesc(FirefightingPressure::getCreateTime));
+            pressureMap = latestBy(records, FirefightingPressure::getTelemeteringStation,
+                    record -> inRange(record.getPressureValue(), pressureMin, pressureMax, startTime, endTime, record.getCreateTime()));
         }
 
-        List<PressureMonitoringVO> voList = new ArrayList<>();
+        List<PressureMonitoringVO> allRows = new ArrayList<>();
         for (EquipmentBase device : devices) {
-            PressureMonitoringVO vo = new PressureMonitoringVO();
-            vo.setEquipmentId(device.getEquipmentId());
-            vo.setEquipmentCode(device.getEquipmentCode());
-            vo.setEquipmentName(device.getEquipmentName());
-            vo.setEquipmentLocation(device.getEquipmentLocation());
-            vo.setEquipmentTypeId(device.getEquipmentTypeId());
-            EquipmentType type = typeMap.get(device.getEquipmentTypeId());
-            if (type != null) {
-                vo.setEquipmentTypeName(type.getTypeName());
-            }
-            vo.setLongitude(device.getLongitude());
-            vo.setLatitude(device.getLatitude());
-            if (device.getLongitude() != null && device.getLatitude() != null) {
-                vo.setGps(device.getLongitude() + "," + device.getLatitude());
-            }
-
-            FirefightingPressure pressure = pressureLatestMap.get(device.getEquipmentCode());
-            if (pressure != null) {
-                vo.setPressureValue(pressure.getPressureValue());
-                vo.setMonitorTime(pressure.getCreateTime());
+            FirefightingPressure pressure = pressureMap.get(device.getEquipmentCode());
+            if (pressure == null) {
+                continue;
             }
-            voList.add(vo);
+            PressureMonitoringVO vo = new PressureMonitoringVO();
+            fillBaseFields(vo, device, typeMap.get(device.getEquipmentTypeId()));
+            vo.setPressureValue(pressure.getPressureValue());
+            vo.setMonitorTime(pressure.getCreateTime());
+            allRows.add(vo);
         }
-
-        resultPage.setRecords(voList);
-        return resultPage;
+        return pageOf(allRows, pageNum, pageSize);
     }
 
     @Override
     public Page<LeakageMonitoringVO> getLeakageMonitorPage(long pageNum, long pageSize,
-                                                            String equipmentName, String equipmentCode,
-                                                            String equipmentLocation,
-                                                            LocalDateTime startTime, LocalDateTime endTime,
-                                                            Double powerMin, Double powerMax) {
-        LambdaQueryWrapper<EquipmentBase> baseWrapper = new LambdaQueryWrapper<>();
-        baseWrapper.like(StrUtil.isNotBlank(equipmentName), EquipmentBase::getEquipmentName, equipmentName)
-                .like(StrUtil.isNotBlank(equipmentCode), EquipmentBase::getEquipmentCode, equipmentCode)
-                .like(StrUtil.isNotBlank(equipmentLocation), EquipmentBase::getEquipmentLocation, equipmentLocation)
-                .orderByDesc(EquipmentBase::getCreateTime);
-        constrainToMonitoringTypes(baseWrapper, "leakage");
-
-        Page<EquipmentBase> basePage = equipmentBaseMapper.selectPage(new Page<>(pageNum, pageSize), baseWrapper);
-        Page<LeakageMonitoringVO> resultPage = new Page<>(pageNum, pageSize, basePage.getTotal());
-
-        if (CollUtil.isEmpty(basePage.getRecords())) {
-            resultPage.setRecords(new ArrayList<>());
-            return resultPage;
-        }
-
-        List<EquipmentBase> devices = basePage.getRecords();
-        List<String> deviceCodes = devices.stream()
-                .map(EquipmentBase::getEquipmentCode).filter(StrUtil::isNotBlank).collect(Collectors.toList());
-        List<String> typeIds = devices.stream()
-                .map(EquipmentBase::getEquipmentTypeId).filter(StrUtil::isNotBlank).distinct().collect(Collectors.toList());
-
-        Map<String, EquipmentType> typeMap = new HashMap<>();
-        if (CollUtil.isNotEmpty(typeIds)) {
-            List<EquipmentType> types = equipmentTypeMapper.selectBatchIds(typeIds);
-            typeMap = types.stream().collect(Collectors.toMap(EquipmentType::getTypeId, t -> t, (a, b) -> a));
+                                                           String equipmentName, String equipmentCode,
+                                                           String equipmentLocation,
+                                                           LocalDateTime startTime, LocalDateTime endTime,
+                                                           Double powerMin, Double powerMax) {
+        List<EquipmentBase> devices = selectMonitoringDevices(equipmentName, equipmentCode, equipmentLocation, "leakage");
+        if (CollUtil.isEmpty(devices)) {
+            return emptyPage(pageNum, pageSize);
         }
 
-        Map<String, NoiseInfo> noiseLatestMap = new HashMap<>();
-        if (CollUtil.isNotEmpty(deviceCodes)) {
-            List<NoiseInfo> noiseList = noiseInfoMapper.selectList(
-                    new LambdaQueryWrapper<NoiseInfo>().in(NoiseInfo::getEncode, deviceCodes)
+        Map<String, EquipmentType> typeMap = selectTypeMap(devices);
+        Map<String, NoiseInfo> noiseMap = new HashMap<>();
+        List<String> deviceCodes = deviceCodes(devices);
+        if (!deviceCodes.isEmpty()) {
+            List<NoiseInfo> records = noiseInfoMapper.selectList(
+                    new LambdaQueryWrapper<NoiseInfo>()
+                            .in(NoiseInfo::getEncode, deviceCodes)
                             .ge(startTime != null, NoiseInfo::getCreateTime, startTime)
                             .le(endTime != null, NoiseInfo::getCreateTime, endTime)
-                            .ge(powerMin != null, NoiseInfo::getPower, powerMin)
-                            .le(powerMax != null, NoiseInfo::getPower, powerMax)
-                            .orderByDesc(NoiseInfo::getCreateTime)
-            );
-            if (CollUtil.isNotEmpty(noiseList)) {
-                Set<String> seen = new HashSet<>();
-                for (NoiseInfo n : noiseList) {
-                    if (seen.add(n.getEncode())) {
-                        noiseLatestMap.put(n.getEncode(), n);
-                    }
-                }
-            }
+                            .orderByDesc(NoiseInfo::getCreateTime));
+            noiseMap = latestBy(records, NoiseInfo::getEncode,
+                    record -> inRange(record.getPower(), powerMin, powerMax, startTime, endTime, record.getCreateTime()));
         }
 
-        List<LeakageMonitoringVO> voList = new ArrayList<>();
+        List<LeakageMonitoringVO> allRows = new ArrayList<>();
         for (EquipmentBase device : devices) {
-            LeakageMonitoringVO vo = new LeakageMonitoringVO();
-            vo.setEquipmentId(device.getEquipmentId());
-            vo.setEquipmentCode(device.getEquipmentCode());
-            vo.setEquipmentName(device.getEquipmentName());
-            vo.setEquipmentLocation(device.getEquipmentLocation());
-            vo.setEquipmentTypeId(device.getEquipmentTypeId());
-            EquipmentType type = typeMap.get(device.getEquipmentTypeId());
-            if (type != null) {
-                vo.setEquipmentTypeName(type.getTypeName());
-            }
-            vo.setLongitude(device.getLongitude());
-            vo.setLatitude(device.getLatitude());
-            if (device.getLongitude() != null && device.getLatitude() != null) {
-                vo.setGps(device.getLongitude() + "," + device.getLatitude());
-            }
-
-            NoiseInfo noise = noiseLatestMap.get(device.getEquipmentCode());
-            if (noise != null) {
-                vo.setPower(noise.getPower());
-                vo.setGain(noise.getGain());
-                vo.setRms(noise.getRms());
-                vo.setCenterFrequency(noise.getCenterFrequency());
-                vo.setMonitorTime(noise.getCreateTime());
+            NoiseInfo noise = noiseMap.get(device.getEquipmentCode());
+            if (noise == null) {
+                continue;
             }
-            voList.add(vo);
+            LeakageMonitoringVO vo = new LeakageMonitoringVO();
+            fillBaseFields(vo, device, typeMap.get(device.getEquipmentTypeId()));
+            vo.setPower(noise.getPower());
+            vo.setGain(noise.getGain());
+            vo.setRms(noise.getRms());
+            vo.setCenterFrequency(noise.getCenterFrequency());
+            vo.setMonitorTime(noise.getCreateTime());
+            allRows.add(vo);
         }
-
-        resultPage.setRecords(voList);
-        return resultPage;
+        return pageOf(allRows, pageNum, pageSize);
     }
 
     @Override
-    public List<Map<String, Object>> getFlowTrend24h(String equipmentId) {
-        EquipmentBase device = equipmentBaseMapper.selectById(equipmentId);
+    public List<Map<String, Object>> getFlowTrend(String equipmentId, LocalDateTime startTime, LocalDateTime endTime) {
+        EquipmentBase device = selectMonitoringDevice(equipmentId, "flow");
         if (device == null || StrUtil.isBlank(device.getEquipmentCode())) {
             return new ArrayList<>();
         }
 
-        LocalDateTime now = LocalDateTime.now();
-        LocalDateTime before24h = now.minusHours(24);
+        LocalDateTime start = normalizeTrendStart(startTime, endTime);
         String code = device.getEquipmentCode();
-
         List<Map<String, Object>> result = new ArrayList<>();
 
         List<RadarData> radarList = radarDataMapper.selectList(
-                new LambdaQueryWrapper<RadarData>().eq(RadarData::getDeviceCode, code)
-                        .ge(RadarData::getCreateTime, before24h)
-                        .orderByAsc(RadarData::getCreateTime)
-        );
+                new LambdaQueryWrapper<RadarData>()
+                        .eq(RadarData::getDeviceCode, code)
+                        .ge(start != null, RadarData::getCreateTime, start)
+                        .le(endTime != null, RadarData::getCreateTime, endTime)
+                        .orderByAsc(RadarData::getCreateTime));
         if (CollUtil.isNotEmpty(radarList)) {
-            for (RadarData d : radarList) {
-                Map<String, Object> point = new LinkedHashMap<>();
-                point.put("time", d.getCreateTime());
-                point.put("instantFlow", d.getMeter1InstantFlow());
-                point.put("flowSpeed", d.getFlowSpeed());
-                result.add(point);
+            for (RadarData data : radarList) {
+                if (!inTimeRange(data.getCreateTime(), start, endTime)) {
+                    continue;
+                }
+                result.add(flowTrendPoint(device, data.getCreateTime(), data.getMeter1InstantFlow(),
+                        data.getFlowSpeed(), null, null));
             }
             return result;
         }
 
         List<TelemetryData> telemetryList = telemetryDataMapper.selectList(
-                new LambdaQueryWrapper<TelemetryData>().eq(TelemetryData::getDeviceCode, code)
-                        .ge(TelemetryData::getCreateTime, before24h)
-                        .orderByAsc(TelemetryData::getCreateTime)
-        );
-        for (TelemetryData d : telemetryList) {
-            Map<String, Object> point = new LinkedHashMap<>();
-            point.put("time", d.getCreateTime());
-            point.put("positiveTotal", d.getMeter1PositiveTotal());
-            point.put("temperature", d.getTemperature());
-            point.put("flowSpeed", d.getFlowSpeed());
-            result.add(point);
+                new LambdaQueryWrapper<TelemetryData>()
+                        .eq(TelemetryData::getDeviceCode, code)
+                        .ge(start != null, TelemetryData::getCreateTime, start)
+                        .le(endTime != null, TelemetryData::getCreateTime, endTime)
+                        .orderByAsc(TelemetryData::getCreateTime));
+        for (TelemetryData data : telemetryList) {
+            if (!inTimeRange(data.getCreateTime(), start, endTime)) {
+                continue;
+            }
+            result.add(flowTrendPoint(device, data.getCreateTime(), data.getMeter1InstantFlow(),
+                    data.getFlowSpeed(), data.getMeter1PositiveTotal(), data.getTemperature()));
         }
         return result;
     }
 
     @Override
-    public List<Map<String, Object>> getPressureTrend24h(String equipmentId) {
-        EquipmentBase device = equipmentBaseMapper.selectById(equipmentId);
+    public List<Map<String, Object>> getPressureTrend(String equipmentId, LocalDateTime startTime, LocalDateTime endTime) {
+        EquipmentBase device = selectMonitoringDevice(equipmentId, "pressure");
         if (device == null || StrUtil.isBlank(device.getEquipmentCode())) {
             return new ArrayList<>();
         }
 
-        LocalDateTime now = LocalDateTime.now();
-        LocalDateTime before24h = now.minusHours(24);
-
+        LocalDateTime start = normalizeTrendStart(startTime, endTime);
         List<FirefightingPressure> list = firefightingPressureMapper.selectList(
-                new LambdaQueryWrapper<FirefightingPressure>().eq(FirefightingPressure::getTelemeteringStation, device.getEquipmentCode())
-                        .ge(FirefightingPressure::getCreateTime, before24h)
-                        .orderByAsc(FirefightingPressure::getCreateTime)
-        );
+                new LambdaQueryWrapper<FirefightingPressure>()
+                        .eq(FirefightingPressure::getTelemeteringStation, device.getEquipmentCode())
+                        .ge(start != null, FirefightingPressure::getCreateTime, start)
+                        .le(endTime != null, FirefightingPressure::getCreateTime, endTime)
+                        .orderByAsc(FirefightingPressure::getCreateTime));
 
         List<Map<String, Object>> result = new ArrayList<>();
-        for (FirefightingPressure p : list) {
-            Map<String, Object> point = new LinkedHashMap<>();
-            point.put("time", p.getCreateTime());
-            point.put("pressureValue", p.getPressureValue());
+        for (FirefightingPressure pressure : list) {
+            if (!inTimeRange(pressure.getCreateTime(), start, endTime)) {
+                continue;
+            }
+            Map<String, Object> point = trendPoint(device, pressure.getCreateTime());
+            point.put("pressureValue", pressure.getPressureValue());
             result.add(point);
         }
         return result;
     }
 
     @Override
-    public List<Map<String, Object>> getLeakageTrend24h(String equipmentId) {
-        EquipmentBase device = equipmentBaseMapper.selectById(equipmentId);
+    public List<Map<String, Object>> getLeakageTrend(String equipmentId, LocalDateTime startTime, LocalDateTime endTime) {
+        EquipmentBase device = selectMonitoringDevice(equipmentId, "leakage");
         if (device == null || StrUtil.isBlank(device.getEquipmentCode())) {
             return new ArrayList<>();
         }
 
-        LocalDateTime now = LocalDateTime.now();
-        LocalDateTime before24h = now.minusHours(24);
-
+        LocalDateTime start = normalizeTrendStart(startTime, endTime);
         List<NoiseInfo> list = noiseInfoMapper.selectList(
-                new LambdaQueryWrapper<NoiseInfo>().eq(NoiseInfo::getEncode, device.getEquipmentCode())
-                        .ge(NoiseInfo::getCreateTime, before24h)
-                        .orderByAsc(NoiseInfo::getCreateTime)
-        );
+                new LambdaQueryWrapper<NoiseInfo>()
+                        .eq(NoiseInfo::getEncode, device.getEquipmentCode())
+                        .ge(start != null, NoiseInfo::getCreateTime, start)
+                        .le(endTime != null, NoiseInfo::getCreateTime, endTime)
+                        .orderByAsc(NoiseInfo::getCreateTime));
 
         List<Map<String, Object>> result = new ArrayList<>();
-        for (NoiseInfo n : list) {
-            Map<String, Object> point = new LinkedHashMap<>();
-            point.put("time", n.getCreateTime());
-            point.put("power", n.getPower());
-            point.put("gain", n.getGain());
-            point.put("rms", n.getRms());
+        for (NoiseInfo noise : list) {
+            if (!inTimeRange(noise.getCreateTime(), start, endTime)) {
+                continue;
+            }
+            Map<String, Object> point = trendPoint(device, noise.getCreateTime());
+            point.put("power", noise.getPower());
+            point.put("gain", noise.getGain());
+            point.put("rms", noise.getRms());
+            point.put("centerFrequency", noise.getCenterFrequency());
             result.add(point);
         }
         return result;
     }
 
-    /** Restrict the equipment query before pagination so each monitoring page only contains its sub-devices. */
-    private void constrainToMonitoringTypes(LambdaQueryWrapper<EquipmentBase> wrapper, String monitorKind) {
-        String[] keywords;
+    private List<EquipmentBase> selectMonitoringDevices(String equipmentName, String equipmentCode,
+                                                        String equipmentLocation, String monitorKind) {
+        LambdaQueryWrapper<EquipmentBase> wrapper = new LambdaQueryWrapper<>();
+        wrapper.like(StrUtil.isNotBlank(equipmentName), EquipmentBase::getEquipmentName, equipmentName)
+                .like(StrUtil.isNotBlank(equipmentCode), EquipmentBase::getEquipmentCode, equipmentCode)
+                .like(StrUtil.isNotBlank(equipmentLocation), EquipmentBase::getEquipmentLocation, equipmentLocation)
+                .orderByDesc(EquipmentBase::getCreateTime);
+        Set<String> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
+        wrapper.in(EquipmentBase::getEquipmentTypeId,
+                monitoringTypeIds.isEmpty() ? Collections.singleton("__NO_WATER_MONITORING_TYPE__") : monitoringTypeIds);
+        return equipmentBaseMapper.selectList(wrapper);
+    }
+
+    private EquipmentBase selectMonitoringDevice(String equipmentId, String monitorKind) {
+        EquipmentBase device = equipmentBaseMapper.selectById(equipmentId);
+        if (device == null || StrUtil.isBlank(device.getEquipmentCode())) {
+            return null;
+        }
+        Set<String> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
+        return monitoringTypeIds.contains(device.getEquipmentTypeId()) ? device : null;
+    }
+
+    private Map<String, EquipmentType> selectTypeMap(List<EquipmentBase> devices) {
+        boolean anyTypePresent = devices.stream()
+                .map(EquipmentBase::getEquipmentTypeId)
+                .anyMatch(StrUtil::isNotBlank);
+        if (!anyTypePresent) {
+            return Collections.emptyMap();
+        }
+        return equipmentTypeMapper.selectList(new LambdaQueryWrapper<>()).stream()
+                .collect(Collectors.toMap(EquipmentType::getId, Function.identity(), (left, right) -> left));
+    }
+
+    private Map<String, RadarData> selectLatestRadar(List<EquipmentBase> devices,
+                                                      LocalDateTime startTime, LocalDateTime endTime,
+                                                      Double flowMin, Double flowMax) {
+        List<String> deviceCodes = deviceCodes(devices);
+        if (deviceCodes.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<RadarData> records = radarDataMapper.selectList(
+                new LambdaQueryWrapper<RadarData>()
+                        .in(RadarData::getDeviceCode, deviceCodes)
+                        .ge(startTime != null, RadarData::getCreateTime, startTime)
+                        .le(endTime != null, RadarData::getCreateTime, endTime)
+                        .orderByDesc(RadarData::getCreateTime));
+        return latestBy(records, RadarData::getDeviceCode,
+                record -> flowInRange(record.getMeter1InstantFlow(), flowMin, flowMax)
+                        && inTimeRange(record.getCreateTime(), startTime, endTime));
+    }
+
+    private Map<String, TelemetryData> selectLatestTelemetry(List<EquipmentBase> devices,
+                                                             LocalDateTime startTime, LocalDateTime endTime,
+                                                             Double flowMin, Double flowMax) {
+        List<String> deviceCodes = deviceCodes(devices);
+        if (deviceCodes.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<TelemetryData> records = telemetryDataMapper.selectList(
+                new LambdaQueryWrapper<TelemetryData>()
+                        .in(TelemetryData::getDeviceCode, deviceCodes)
+                        .ge(startTime != null, TelemetryData::getCreateTime, startTime)
+                        .le(endTime != null, TelemetryData::getCreateTime, endTime)
+                        .orderByDesc(TelemetryData::getCreateTime));
+        return latestBy(records, TelemetryData::getDeviceCode,
+                record -> flowInRange(record.getMeter1InstantFlow(), flowMin, flowMax)
+                        && inTimeRange(record.getCreateTime(), startTime, endTime));
+    }
+
+    private <T> Map<String, T> latestBy(List<T> records, Function<T, String> codeGetter, Function<T, Boolean> qualifier) {
+        Map<String, T> result = new HashMap<>();
+        for (T record : records) {
+            String code = codeGetter.apply(record);
+            if (StrUtil.isBlank(code) || !Boolean.TRUE.equals(qualifier.apply(record))) {
+                continue;
+            }
+            T current = result.get(code);
+            if (current == null || getTime(record).compareTo(getTime(current)) > 0) {
+                result.put(code, record);
+            }
+        }
+        return result;
+    }
+
+    private int compareTime(LocalDateTime left, LocalDateTime right) {
+        if (left == null && right == null) {
+            return 0;
+        }
+        if (left == null) {
+            return -1;
+        }
+        if (right == null) {
+            return 1;
+        }
+        return left.compareTo(right);
+    }
+
+    private LocalDateTime getTime(Object value) {
+        if (value instanceof RadarData) return ((RadarData) value).getCreateTime();
+        if (value instanceof TelemetryData) return ((TelemetryData) value).getCreateTime();
+        if (value instanceof FirefightingPressure) return ((FirefightingPressure) value).getCreateTime();
+        if (value instanceof NoiseInfo) return ((NoiseInfo) value).getCreateTime();
+        return null;
+    }
+
+    private boolean flowInRange(String value, Double min, Double max) {
+        if (min == null && max == null) {
+            return true;
+        }
+        if (StrUtil.isBlank(value)) {
+            return false;
+        }
+        try {
+            return inRange(Double.parseDouble(value), min, max, null, null, null);
+        } catch (NumberFormatException e) {
+            return false;
+        }
+    }
+
+    private boolean inRange(Double value, Double min, Double max, LocalDateTime startTime,
+                            LocalDateTime endTime, LocalDateTime actualTime) {
+        boolean timeFilterRequired = startTime != null || endTime != null;
+        if (timeFilterRequired && !inTimeRange(actualTime, startTime, endTime)) {
+            return false;
+        }
+        if (min == null && max == null) {
+            return true;
+        }
+        if (value == null) {
+            return false;
+        }
+        return (min == null || value >= min) && (max == null || value <= max);
+    }
+
+    private boolean inTimeRange(LocalDateTime value, LocalDateTime startTime, LocalDateTime endTime) {
+        return value != null
+                && (startTime == null || !value.isBefore(startTime))
+                && (endTime == null || !value.isAfter(endTime));
+    }
+
+    private <T> Page<T> emptyPage(long pageNum, long pageSize) {
+        return pageOf(Collections.emptyList(), pageNum, pageSize);
+    }
+
+    private <T> Page<T> pageOf(List<T> records, long pageNum, long pageSize) {
+        long safePageNum = Math.max(pageNum, 1);
+        long safePageSize = Math.max(pageSize, 1);
+        Page<T> page = new Page<>(safePageNum, safePageSize, records.size());
+        int fromIndex = (int) Math.min((safePageNum - 1) * safePageSize, records.size());
+        int toIndex = (int) Math.min(fromIndex + safePageSize, records.size());
+        page.setRecords(new ArrayList<>(records.subList(fromIndex, toIndex)));
+        return page;
+    }
+
+    private List<String> deviceCodes(List<EquipmentBase> devices) {
+        return devices.stream()
+                .map(EquipmentBase::getEquipmentCode)
+                .filter(StrUtil::isNotBlank)
+                .collect(Collectors.toList());
+    }
+
+    private void fillBaseFields(FlowMonitoringVO vo, EquipmentBase device, EquipmentType type) {
+        vo.setEquipmentId(device.getEquipmentId());
+        vo.setEquipmentCode(device.getEquipmentCode());
+        vo.setEquipmentName(device.getEquipmentName());
+        vo.setEquipmentLocation(device.getEquipmentLocation());
+        vo.setEquipmentTypeId(device.getEquipmentTypeId());
+        if (type != null) vo.setEquipmentTypeName(type.getTypeName());
+        vo.setLongitude(device.getLongitude());
+        vo.setLatitude(device.getLatitude());
+        if (device.getLongitude() != null && device.getLatitude() != null) {
+            vo.setGps(device.getLongitude() + "," + device.getLatitude());
+        }
+    }
+
+    private void fillBaseFields(PressureMonitoringVO vo, EquipmentBase device, EquipmentType type) {
+        vo.setEquipmentId(device.getEquipmentId());
+        vo.setEquipmentCode(device.getEquipmentCode());
+        vo.setEquipmentName(device.getEquipmentName());
+        vo.setEquipmentLocation(device.getEquipmentLocation());
+        vo.setEquipmentTypeId(device.getEquipmentTypeId());
+        if (type != null) vo.setEquipmentTypeName(type.getTypeName());
+        vo.setLongitude(device.getLongitude());
+        vo.setLatitude(device.getLatitude());
+        if (device.getLongitude() != null && device.getLatitude() != null) {
+            vo.setGps(device.getLongitude() + "," + device.getLatitude());
+        }
+    }
+
+    private void fillBaseFields(LeakageMonitoringVO vo, EquipmentBase device, EquipmentType type) {
+        vo.setEquipmentId(device.getEquipmentId());
+        vo.setEquipmentCode(device.getEquipmentCode());
+        vo.setEquipmentName(device.getEquipmentName());
+        vo.setEquipmentLocation(device.getEquipmentLocation());
+        vo.setEquipmentTypeId(device.getEquipmentTypeId());
+        if (type != null) vo.setEquipmentTypeName(type.getTypeName());
+        vo.setLongitude(device.getLongitude());
+        vo.setLatitude(device.getLatitude());
+        if (device.getLongitude() != null && device.getLatitude() != null) {
+            vo.setGps(device.getLongitude() + "," + device.getLatitude());
+        }
+    }
+
+    private Map<String, Object> trendPoint(EquipmentBase device, LocalDateTime time) {
+        Map<String, Object> point = new LinkedHashMap<>();
+        point.put("equipmentId", device.getEquipmentId());
+        point.put("equipmentCode", device.getEquipmentCode());
+        point.put("equipmentName", device.getEquipmentName());
+        point.put("time", time);
+        return point;
+    }
+
+    private Map<String, Object> flowTrendPoint(EquipmentBase device, LocalDateTime time, String instantFlow,
+                                               String flowSpeed, String positiveTotal, String temperature) {
+        Map<String, Object> point = trendPoint(device, time);
+        point.put("instantFlow", instantFlow);
+        point.put("flowSpeed", flowSpeed);
+        point.put("positiveTotal", positiveTotal);
+        point.put("temperature", temperature);
+        return point;
+    }
+
+    private LocalDateTime normalizeTrendStart(LocalDateTime startTime, LocalDateTime endTime) {
+        if (startTime != null) {
+            return startTime;
+        }
+        return endTime != null ? null : LocalDateTime.now().minusHours(24);
+    }
+
+    /** Restrict monitoring pages and trends to the canonical water-supply monitoring types. */
+    private Set<String> selectMonitoringTypeIds(String monitorKind) {
+        String typeId;
         switch (monitorKind) {
             case "pressure":
-                keywords = new String[]{"压力", "pressure", "消防"};
+                typeId = PRESSURE_TYPE_ID;
                 break;
             case "leakage":
-                keywords = new String[]{"漏失", "漏损", "泄漏", "噪声", "音频", "noise"};
+                typeId = LEAKAGE_TYPE_ID;
                 break;
-            default:
-                keywords = new String[]{"流量", "flow", "雷达", "遥测", "telemetry"};
+            case "flow":
+                typeId = FLOW_TYPE_ID;
                 break;
+            default:
+                return Collections.emptySet();
         }
-        List<EquipmentType> types = equipmentTypeMapper.selectList(new LambdaQueryWrapper<EquipmentType>());
-        Set<String> ids = types.stream()
-                .filter(type -> {
-                    String value = ((type.getTypeId() == null ? "" : type.getTypeId()) + " "
-                            + (type.getTypeName() == null ? "" : type.getTypeName())).toLowerCase(Locale.ROOT);
-                    return Arrays.stream(keywords).anyMatch(keyword -> value.contains(keyword.toLowerCase(Locale.ROOT)));
-                })
+        List<EquipmentType> types = equipmentTypeMapper.selectList(new LambdaQueryWrapper<>());
+        boolean hasCanonicalWaterSupplyRoot = types.stream()
+                .anyMatch(type -> WATER_SUPPLY_ROOT_ID.equals(type.getId())
+                        && WATER_SUPPLY_ROOT_TYPE_ID.equals(type.getTypeId())
+                        && WATER_SUPPLY_ROOT_PARENT_ID.equals(type.getParentTypeId()));
+        if (!hasCanonicalWaterSupplyRoot) {
+            return Collections.emptySet();
+        }
+        return types.stream()
+                .filter(type -> typeId.equals(type.getTypeId()))
+                .filter(type -> WATER_SUPPLY_ROOT_ID.equals(type.getParentTypeId()))
                 .map(EquipmentType::getId)
                 .filter(StrUtil::isNotBlank)
                 .collect(Collectors.toSet());
-        // Keep an empty result portable across database drivers that reject IN ().
-        wrapper.in(EquipmentBase::getEquipmentTypeId,
-                ids.isEmpty() ? Collections.singleton("__NO_MONITORING_TYPE__") : ids);
     }
 }

+ 15 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitClockConfiguration.java

@@ -0,0 +1,15 @@
+package com.zksy.cockpit.foundation;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Clock;
+import java.time.ZoneId;
+
+@Configuration
+public class CockpitClockConfiguration {
+    @Bean
+    public Clock cockpitClock() {
+        return Clock.system(ZoneId.of("Asia/Shanghai"));
+    }
+}

+ 6 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorCategory.java

@@ -0,0 +1,6 @@
+package com.zksy.cockpit.foundation;
+
+public enum CockpitErrorCategory {
+    UNAUTHORIZED,
+    SERVER_ERROR
+}

+ 31 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorPayload.java

@@ -0,0 +1,31 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitErrorPayload {
+    private final CockpitErrorCategory category;
+    private final String message;
+    private final String requestId;
+    private final String serverTime;
+
+    public CockpitErrorPayload(CockpitErrorCategory category, String message, String requestId, String serverTime) {
+        this.category = category;
+        this.message = message;
+        this.requestId = requestId;
+        this.serverTime = serverTime;
+    }
+
+    public CockpitErrorCategory getCategory() {
+        return category;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public String getRequestId() {
+        return requestId;
+    }
+
+    public String getServerTime() {
+        return serverTime;
+    }
+}

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationAdapter.java

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.foundation;
+
+public interface CockpitFoundationAdapter {
+    CockpitFoundationSnapshot load();
+}

+ 38 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationResponse.java

@@ -0,0 +1,38 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitFoundationResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final CockpitMapFrame mapFrame;
+    private final CockpitFreshness freshness;
+
+    public CockpitFoundationResponse(String requestId, String apiVersion, boolean readOnly,
+            CockpitMapFrame mapFrame, CockpitFreshness freshness) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.mapFrame = mapFrame;
+        this.freshness = freshness;
+    }
+
+    public String getRequestId() {
+        return requestId;
+    }
+
+    public String getApiVersion() {
+        return apiVersion;
+    }
+
+    public boolean isReadOnly() {
+        return readOnly;
+    }
+
+    public CockpitMapFrame getMapFrame() {
+        return mapFrame;
+    }
+
+    public CockpitFreshness getFreshness() {
+        return freshness;
+    }
+}

+ 47 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationService.java

@@ -0,0 +1,47 @@
+package com.zksy.cockpit.foundation;
+
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+
+@Service
+public class CockpitFoundationService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter BUSINESS_TIME =
+            DateTimeFormatter.ISO_OFFSET_DATE_TIME.withLocale(Locale.CHINA);
+
+    private final CockpitFoundationAdapter adapter;
+    private final Clock clock;
+
+    public CockpitFoundationService(CockpitFoundationAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitFoundationResponse load(String requestId) {
+        CockpitFoundationSnapshot snapshot = adapter.load();
+        if (snapshot == null) {
+            snapshot = CockpitFoundationSnapshot.empty();
+        }
+        String now = businessTime(clock.instant());
+        return new CockpitFoundationResponse(
+                requestId,
+                "v1",
+                true,
+                snapshot.getMapFrame(),
+                new CockpitFreshness("FRESH", now, now));
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        String message = "驾驶舱基础数据暂不可用";
+        return new CockpitErrorPayload(CockpitErrorCategory.SERVER_ERROR, message, requestId, businessTime(clock.instant()));
+    }
+
+    public String businessTime(Instant instant) {
+        return BUSINESS_TIME.format(instant.atZone(BUSINESS_ZONE));
+    }
+}

+ 17 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationSnapshot.java

@@ -0,0 +1,17 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitFoundationSnapshot {
+    private final CockpitMapFrame mapFrame;
+
+    public CockpitFoundationSnapshot(CockpitMapFrame mapFrame) {
+        this.mapFrame = mapFrame;
+    }
+
+    public static CockpitFoundationSnapshot empty() {
+        return new CockpitFoundationSnapshot(CockpitMapFrame.empty());
+    }
+
+    public CockpitMapFrame getMapFrame() {
+        return mapFrame;
+    }
+}

+ 25 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFreshness.java

@@ -0,0 +1,25 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitFreshness {
+    private final String status;
+    private final String serverTime;
+    private final String dataTime;
+
+    public CockpitFreshness(String status, String serverTime, String dataTime) {
+        this.status = status;
+        this.serverTime = serverTime;
+        this.dataTime = dataTime;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public String getServerTime() {
+        return serverTime;
+    }
+
+    public String getDataTime() {
+        return dataTime;
+    }
+}

+ 29 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitMapFrame.java

@@ -0,0 +1,29 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitMapFrame {
+    private final String state;
+    private final boolean renderable;
+    private final String reason;
+
+    public CockpitMapFrame(String state, boolean renderable, String reason) {
+        this.state = state;
+        this.renderable = renderable;
+        this.reason = reason;
+    }
+
+    public static CockpitMapFrame empty() {
+        return new CockpitMapFrame("EMPTY", false, "驾驶舱基础数据为空");
+    }
+
+    public String getState() {
+        return state;
+    }
+
+    public boolean isRenderable() {
+        return renderable;
+    }
+
+    public String getReason() {
+        return reason;
+    }
+}

+ 7 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitReadException.java

@@ -0,0 +1,7 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitReadException extends RuntimeException {
+    public CockpitReadException(String message) {
+        super(message);
+    }
+}

+ 11 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/EmptyCockpitFoundationAdapter.java

@@ -0,0 +1,11 @@
+package com.zksy.cockpit.foundation;
+
+import org.springframework.stereotype.Component;
+
+@Component
+public class EmptyCockpitFoundationAdapter implements CockpitFoundationAdapter {
+    @Override
+    public CockpitFoundationSnapshot load() {
+        return CockpitFoundationSnapshot.empty();
+    }
+}

+ 73 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/Monitoring/MonitoringExportServiceContractTest.java

@@ -0,0 +1,73 @@
+package com.zksy.WaterSupply.Monitoring;
+
+import com.zksy.WaterSupply.Monitoring.service.impl.MonitoringExportServiceImpl;
+import com.zksy.base.domain.vo.FlowMonitoringVO;
+import com.zksy.base.domain.vo.LeakageMonitoringVO;
+import com.zksy.base.domain.vo.PressureMonitoringVO;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.time.LocalDateTime;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class MonitoringExportServiceContractTest {
+
+    @Test
+    void monitoringExportsCreateTypedExcelSheetsWithTraceableDeviceFields() throws Exception {
+        MonitoringExportServiceImpl service = new MonitoringExportServiceImpl();
+
+        ByteArrayOutputStream flowOutput = new ByteArrayOutputStream();
+        FlowMonitoringVO flow = new FlowMonitoringVO();
+        flow.setEquipmentId("flow-id");
+        flow.setEquipmentCode("WS-FLOW-1");
+        flow.setEquipmentName("流量监测点1");
+        flow.setEquipmentLocation("测试位置");
+        flow.setEquipmentTypeName("流量监测");
+        flow.setDataSource("雷达流量计");
+        flow.setInstantFlow("30");
+        flow.setMonitorTime(LocalDateTime.of(2026, 9, 1, 12, 0));
+        service.writeFlow(flowOutput, Collections.singletonList(flow));
+
+        ByteArrayOutputStream pressureOutput = new ByteArrayOutputStream();
+        PressureMonitoringVO pressure = new PressureMonitoringVO();
+        pressure.setEquipmentId("pressure-id");
+        pressure.setEquipmentCode("WS-PR-1");
+        pressure.setEquipmentName("压力监测点1");
+        pressure.setPressureValue(0.42D);
+        pressure.setMonitorTime(LocalDateTime.of(2026, 9, 1, 12, 0));
+        service.writePressure(pressureOutput, Collections.singletonList(pressure));
+
+        ByteArrayOutputStream leakageOutput = new ByteArrayOutputStream();
+        LeakageMonitoringVO leakage = new LeakageMonitoringVO();
+        leakage.setEquipmentId("leakage-id");
+        leakage.setEquipmentCode("WS-LK-1");
+        leakage.setEquipmentName("漏失监测点1");
+        leakage.setPower(12D);
+        leakage.setMonitorTime(LocalDateTime.of(2026, 9, 1, 12, 0));
+        service.writeLeakage(leakageOutput, Collections.singletonList(leakage));
+
+        try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(flowOutput.toByteArray()))) {
+            Sheet sheet = workbook.getSheet("流量监测");
+            assertEquals("设备编码", sheet.getRow(0).getCell(1).getStringCellValue());
+            assertEquals("WS-FLOW-1", sheet.getRow(1).getCell(1).getStringCellValue());
+            assertEquals("采集时间", sheet.getRow(0).getCell(10).getStringCellValue());
+            assertEquals("2026-09-01 12:00:00", sheet.getRow(1).getCell(10).getStringCellValue());
+        }
+        try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(pressureOutput.toByteArray()))) {
+            Sheet sheet = workbook.getSheet("压力监测");
+            assertEquals("WS-PR-1", sheet.getRow(1).getCell(1).getStringCellValue());
+            assertEquals(0.42D, sheet.getRow(1).getCell(5).getNumericCellValue());
+        }
+        try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(leakageOutput.toByteArray()))) {
+            Sheet sheet = workbook.getSheet("漏失监测");
+            assertEquals("WS-LK-1", sheet.getRow(1).getCell(1).getStringCellValue());
+            assertEquals(12D, sheet.getRow(1).getCell(5).getNumericCellValue());
+        }
+    }
+}

+ 452 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/base/service/impl/MonitoringServiceImplTest.java

@@ -0,0 +1,452 @@
+package com.zksy.base.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.audio.domain.NoiseInfo;
+import com.zksy.base.audio.mapper.NoiseInfoMapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.vo.FlowMonitoringVO;
+import com.zksy.base.domain.vo.LeakageMonitoringVO;
+import com.zksy.base.domain.vo.PressureMonitoringVO;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.mapper.EquipmentTypeMapper;
+import com.zksy.base.pressure.domain.FirefightingPressure;
+import com.zksy.base.pressure.mapper.FirefightingPressureMapper;
+import com.zksy.base.radar.domain.RadarData;
+import com.zksy.base.radar.mapper.RadarDataMapper;
+import com.zksy.base.telemetry.domain.TelemetryData;
+import com.zksy.base.telemetry.mapper.TelemetryDataMapper;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.mockito.invocation.InvocationOnMock;
+
+import java.time.LocalDateTime;
+import java.util.Objects;
+import java.util.Collection;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MonitoringServiceImplTest {
+
+    private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 9, 1, 12, 0);
+
+    @BeforeAll
+    static void initializeMybatisPlusLambdaCache() {
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), EquipmentBase.class);
+    }
+
+    private MonitoringServiceImpl service;
+    private EquipmentBaseMapper equipmentBaseMapper;
+    private EquipmentTypeMapper equipmentTypeMapper;
+    private RadarDataMapper radarDataMapper;
+    private TelemetryDataMapper telemetryDataMapper;
+    private FirefightingPressureMapper pressureMapper;
+    private NoiseInfoMapper noiseMapper;
+
+    @BeforeEach
+    void setUp() {
+        service = new MonitoringServiceImpl();
+        equipmentBaseMapper = mock(EquipmentBaseMapper.class);
+        equipmentTypeMapper = mock(EquipmentTypeMapper.class);
+        radarDataMapper = mock(RadarDataMapper.class);
+        telemetryDataMapper = mock(TelemetryDataMapper.class);
+        pressureMapper = mock(FirefightingPressureMapper.class);
+        noiseMapper = mock(NoiseInfoMapper.class);
+
+        ReflectionTestUtils.setField(service, "equipmentBaseMapper", equipmentBaseMapper);
+        ReflectionTestUtils.setField(service, "equipmentTypeMapper", equipmentTypeMapper);
+        ReflectionTestUtils.setField(service, "radarDataMapper", radarDataMapper);
+        ReflectionTestUtils.setField(service, "telemetryDataMapper", telemetryDataMapper);
+        ReflectionTestUtils.setField(service, "firefightingPressureMapper", pressureMapper);
+        ReflectionTestUtils.setField(service, "noiseInfoMapper", noiseMapper);
+
+        when(equipmentTypeMapper.selectList(any())).thenReturn(Arrays.asList(
+                type("1", "water_supply", "供水", "0"),
+                type("11", "ws_flow", "供水流量计", "1"),
+                type("12", "ws_pressure", "供水压力计", "1"),
+                type("13", "ws_leak", "供水漏失监测器", "1")
+        ));
+        when(radarDataMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(telemetryDataMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(pressureMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(noiseMapper.selectList(any())).thenReturn(Collections.emptyList());
+    }
+
+    @Test
+    void flowPaginationTotalsFollowMeasurementFilteringAndKeepDeviceFields() {
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Arrays.asList(
+                device("flow-1", "WS-FLOW-1", "流量监测点1", "11", BASE_TIME),
+                device("flow-2", "WS-FLOW-2", "流量监测点2", "11", BASE_TIME.minusMinutes(1))
+        ));
+        when(radarDataMapper.selectList(any())).thenReturn(Arrays.asList(
+                radar("WS-FLOW-1", "100", BASE_TIME),
+                radar("WS-FLOW-1", "30", BASE_TIME.minusHours(1)),
+                radar("WS-FLOW-2", "5", BASE_TIME.minusMinutes(2))
+        ));
+
+        Page<FlowMonitoringVO> page = service.getFlowMonitorPage(
+                1, 10, null, null, null, null, null, 20D, 40D);
+
+        assertEquals(1, page.getTotal());
+        assertEquals(1, page.getRecords().size());
+        FlowMonitoringVO row = page.getRecords().get(0);
+        assertEquals("flow-1", row.getEquipmentId());
+        assertEquals("WS-FLOW-1", row.getEquipmentCode());
+        assertEquals("流量监测点1", row.getEquipmentName());
+        assertEquals("供水流量计", row.getEquipmentTypeName());
+        assertEquals("30", row.getInstantFlow());
+        assertEquals(BASE_TIME.minusHours(1), row.getMonitorTime());
+    }
+
+    @Test
+    void flowPaginationUsesTheNewestQualifyingMeasurementAcrossDataSources() {
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Collections.singletonList(
+                device("flow-1", "WS-FLOW-1", "流量监测点1", "11", BASE_TIME)
+        ));
+        when(radarDataMapper.selectList(any())).thenReturn(Collections.singletonList(
+                radar("WS-FLOW-1", "30", BASE_TIME.minusHours(2))
+        ));
+        when(telemetryDataMapper.selectList(any())).thenReturn(Collections.singletonList(
+                telemetry("WS-FLOW-1", "35", BASE_TIME.minusHours(1))
+        ));
+
+        Page<FlowMonitoringVO> page = service.getFlowMonitorPage(
+                1, 10, null, null, null, null, null, 20D, 40D);
+
+        assertEquals(1, page.getTotal());
+        FlowMonitoringVO row = page.getRecords().get(0);
+        assertEquals("遥测终端", row.getDataSource());
+        assertEquals("35", row.getInstantFlow());
+        assertEquals(BASE_TIME.minusHours(1), row.getMonitorTime());
+    }
+
+    @Test
+    void pressurePaginationTotalsFollowMeasurementFiltering() {
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Arrays.asList(
+                device("pressure-1", "WS-PR-1", "压力监测点1", "12", BASE_TIME),
+                device("pressure-2", "WS-PR-2", "压力监测点2", "12", BASE_TIME.minusMinutes(1)),
+                device("pressure-3", "WS-PR-3", "压力监测点3", "12", BASE_TIME.minusMinutes(2))
+        ));
+        when(pressureMapper.selectList(any())).thenReturn(Arrays.asList(
+                pressure("WS-PR-1", 0.4D, BASE_TIME),
+                pressure("WS-PR-2", 0.9D, BASE_TIME.minusMinutes(1)),
+                pressure("WS-PR-3", 0.3D, BASE_TIME.minusMinutes(2))
+        ));
+
+        Page<PressureMonitoringVO> page = service.getPressureMonitorPage(
+                1, 10, null, null, null, null, null, 0.35D, 0.5D);
+
+        assertEquals(1, page.getTotal());
+        assertEquals(1, page.getRecords().size());
+        assertEquals("pressure-1", page.getRecords().get(0).getEquipmentId());
+        assertEquals(0.4D, page.getRecords().get(0).getPressureValue());
+    }
+
+    @Test
+    void leakagePaginationExcludesDevicesWithoutQualifyingMeasurements() {
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Arrays.asList(
+                device("leakage-1", "WS-LK-1", "漏失监测点1", "13", BASE_TIME),
+                device("leakage-2", "WS-LK-2", "漏失监测点2", "13", BASE_TIME.minusMinutes(1))
+        ));
+        when(noiseMapper.selectList(any())).thenReturn(Collections.singletonList(
+                noise("WS-LK-1", 12D, BASE_TIME)
+        ));
+
+        Page<LeakageMonitoringVO> page = service.getLeakageMonitorPage(
+                1, 10, null, null, null, null, null, null, null);
+
+        assertEquals(1, page.getTotal());
+        assertEquals(1, page.getRecords().size());
+        assertEquals("leakage-1", page.getRecords().get(0).getEquipmentId());
+        assertEquals(12D, page.getRecords().get(0).getPower());
+        assertEquals(BASE_TIME, page.getRecords().get(0).getMonitorTime());
+    }
+
+    @Test
+    void monitoringPagesReturnAnEmptyPageWhenThereIsNoMeasurementData() {
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Collections.singletonList(
+                device("flow-empty", "WS-FLOW-EMPTY", "无数据监测点", "11", BASE_TIME)
+        ));
+
+        Page<FlowMonitoringVO> flow = service.getFlowMonitorPage(1, 10, null, null, null, null, null, null, null);
+        assertEquals(0, flow.getTotal());
+        assertTrue(flow.getRecords().isEmpty());
+
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Collections.singletonList(
+                device("pressure-empty", "WS-PR-EMPTY", "无数据监测点", "12", BASE_TIME)
+        ));
+        Page<PressureMonitoringVO> pressure = service.getPressureMonitorPage(1, 10, null, null, null, null, null, null, null);
+        assertEquals(0, pressure.getTotal());
+        assertTrue(pressure.getRecords().isEmpty());
+
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Collections.singletonList(
+                device("leakage-empty", "WS-LK-EMPTY", "无数据监测点", "13", BASE_TIME)
+        ));
+        Page<LeakageMonitoringVO> leakage = service.getLeakageMonitorPage(1, 10, null, null, null, null, null, null, null);
+        assertEquals(0, leakage.getTotal());
+        assertTrue(leakage.getRecords().isEmpty());
+    }
+
+    @Test
+    void flowMonitoringUsesCanonicalTypeIdsAndRejectsLegacyRuntimeTypes() {
+        when(equipmentTypeMapper.selectList(any())).thenReturn(Arrays.asList(
+                type("1", "water_supply", "供水", "0"),
+                type("11", "ws_flow", "供水流量计", "1"),
+                type("99", "legacy_flow", "流量监测(旧编码)", "1"),
+                type("TEST-WATER-FLOW", "test_water_flow", "流量监测(旧测试类型)", "1"),
+                type("98", "ws_flow", "错误名称但真实流量编码", "1"),
+                type("97", "ws_flow", "供水流量计但父级错误", "other-root"),
+                type("other-root", "other_root", "其他系统", "0"),
+                type("other-flow", "other_flow", "流量监测", "other-root")
+        ));
+        when(equipmentBaseMapper.selectList(any())).thenAnswer(invocation -> {
+            LambdaQueryWrapper<EquipmentBase> wrapper = invocation.getArgument(0);
+            Set<Object> allowedTypeIds = flattenWrapperValues(wrapper);
+            return Arrays.asList(
+                    device("water-flow-device", "WS-FLOW", "供水流量监测点", "11", BASE_TIME),
+                    device("legacy-flow-device", "WS-LEGACY-FLOW", "旧编码流量监测点", "99", BASE_TIME),
+                    device("old-test-flow-device", "WS-OLD-TEST-FLOW", "旧测试类型流量监测点", "TEST-WATER-FLOW", BASE_TIME),
+                    device("real-id-wrong-name-device", "WS-FLOW-WRONG-NAME", "真实编码流量监测点", "98", BASE_TIME),
+                    device("wrong-parent-flow-device", "WS-FLOW-WRONG-PARENT", "错误父级流量监测点", "97", BASE_TIME),
+                    device("other-flow-device", "OTHER-FLOW", "其他系统流量监测点", "other-flow", BASE_TIME),
+                    device("unknown-type-device", "UNKNOWN-FLOW", "未归属流量监测点", "unknown-type", BASE_TIME)
+            ).stream()
+                    .filter(device -> allowedTypeIds.contains(device.getEquipmentTypeId()))
+                    .collect(Collectors.toList());
+        });
+        when(radarDataMapper.selectList(any())).thenReturn(Arrays.asList(
+                radar("WS-FLOW", "10", BASE_TIME),
+                radar("WS-LEGACY-FLOW", "20", BASE_TIME),
+                radar("WS-OLD-TEST-FLOW", "25", BASE_TIME),
+                radar("WS-FLOW-WRONG-NAME", "30", BASE_TIME),
+                radar("WS-FLOW-WRONG-PARENT", "40", BASE_TIME),
+                radar("OTHER-FLOW", "20", BASE_TIME),
+                radar("UNKNOWN-FLOW", "30", BASE_TIME)
+        ));
+
+        Page<FlowMonitoringVO> page = service.getFlowMonitorPage(
+                1, 10, null, null, null, null, null, null, null);
+
+        assertEquals(2, page.getTotal());
+        assertEquals(
+                Set.of("water-flow-device", "real-id-wrong-name-device"),
+                page.getRecords().stream().map(FlowMonitoringVO::getEquipmentId).collect(Collectors.toSet()));
+    }
+
+    @Test
+    void trendEndpointsRejectDevicesOutsideTheWaterTypeTree() {
+        when(equipmentTypeMapper.selectList(any())).thenReturn(Arrays.asList(
+                type("1", "water_supply", "供水", "0"),
+                type("11", "ws_flow", "供水流量计", "1"),
+                type("other-root", "other_root", "其他系统", "0"),
+                type("other-flow", "other_flow", "流量监测", "other-root")
+        ));
+        EquipmentBase otherDevice = device("other-flow-device", "OTHER-FLOW", "其他系统流量监测点", "other-flow", BASE_TIME);
+        when(equipmentBaseMapper.selectById("other-flow-device")).thenReturn(otherDevice);
+        when(radarDataMapper.selectList(any())).thenReturn(Collections.singletonList(
+                radar("OTHER-FLOW", "20", BASE_TIME)
+        ));
+
+        List<Map<String, Object>> trend = service.getFlowTrend("other-flow-device", null, null);
+
+        assertTrue(trend.isEmpty());
+    }
+
+    @Test
+    void trendEndpointsUseTheRequestedTimeRangeForAllMonitoringTypes() {
+        LocalDateTime start = BASE_TIME.minusHours(2);
+        LocalDateTime end = BASE_TIME;
+
+        EquipmentBase flowDevice = device("flow-1", "WS-FLOW-1", "流量监测点1", "11", BASE_TIME);
+        when(equipmentBaseMapper.selectById("flow-1")).thenReturn(flowDevice);
+        when(radarDataMapper.selectList(any())).thenReturn(Arrays.asList(
+                radar("WS-FLOW-1", "10", BASE_TIME.minusHours(3)),
+                radar("WS-FLOW-1", "20", BASE_TIME.minusHours(1)),
+                radar("WS-FLOW-1", "30", BASE_TIME.plusHours(1))
+        ));
+        List<Map<String, Object>> flow = service.getFlowTrend("flow-1", start, end);
+        assertEquals(1, flow.size());
+        assertEquals("20", flow.get(0).get("instantFlow"));
+        assertEquals(BASE_TIME.minusHours(1), flow.get(0).get("time"));
+        assertEquals("WS-FLOW-1", flow.get(0).get("equipmentCode"));
+
+        EquipmentBase pressureDevice = device("pressure-1", "WS-PR-1", "压力监测点1", "12", BASE_TIME);
+        when(equipmentBaseMapper.selectById("pressure-1")).thenReturn(pressureDevice);
+        when(pressureMapper.selectList(any())).thenReturn(Arrays.asList(
+                pressure("WS-PR-1", 0.1D, BASE_TIME.minusHours(3)),
+                pressure("WS-PR-1", 0.2D, BASE_TIME.minusHours(1)),
+                pressure("WS-PR-1", 0.3D, BASE_TIME.plusHours(1))
+        ));
+        List<Map<String, Object>> pressure = service.getPressureTrend("pressure-1", start, end);
+        assertEquals(1, pressure.size());
+        assertEquals(0.2D, pressure.get(0).get("pressureValue"));
+        assertEquals("WS-PR-1", pressure.get(0).get("equipmentCode"));
+
+        EquipmentBase leakageDevice = device("leakage-1", "WS-LK-1", "漏失监测点1", "13", BASE_TIME);
+        when(equipmentBaseMapper.selectById("leakage-1")).thenReturn(leakageDevice);
+        when(noiseMapper.selectList(any())).thenReturn(Arrays.asList(
+                noise("WS-LK-1", 1D, BASE_TIME.minusHours(3)),
+                noise("WS-LK-1", 2D, BASE_TIME.minusHours(1)),
+                noise("WS-LK-1", 3D, BASE_TIME.plusHours(1))
+        ));
+        List<Map<String, Object>> leakage = service.getLeakageTrend("leakage-1", start, end);
+        assertEquals(1, leakage.size());
+        assertEquals(2D, leakage.get(0).get("power"));
+        assertEquals("WS-LK-1", leakage.get(0).get("equipmentCode"));
+    }
+
+    @Test
+    void trendEndpointWithOnlyAnEndTimeDoesNotApplyTheDefault24HourStart() {
+        EquipmentBase device = device("flow-1", "WS-FLOW-1", "流量监测点1", "11", BASE_TIME);
+        when(equipmentBaseMapper.selectById("flow-1")).thenReturn(device);
+        when(radarDataMapper.selectList(any())).thenReturn(Arrays.asList(
+                radar("WS-FLOW-1", "10", BASE_TIME.minusDays(3)),
+                radar("WS-FLOW-1", "20", BASE_TIME)
+        ));
+
+        List<Map<String, Object>> trend = service.getFlowTrend("flow-1", null, BASE_TIME);
+
+        assertEquals(2, trend.size());
+        assertEquals("10", trend.get(0).get("instantFlow"));
+        assertEquals("20", trend.get(1).get("instantFlow"));
+    }
+
+    @Test
+    void trendQueriesWithOnlyAnEndTimeDoNotBindNullStartTime() {
+        EquipmentBase flowDevice = device("flow-1", "WS-FLOW-1", "流量监测点1", "11", BASE_TIME);
+        EquipmentBase pressureDevice = device("pressure-1", "WS-PR-1", "压力监测点1", "12", BASE_TIME);
+        EquipmentBase leakageDevice = device("leakage-1", "WS-LK-1", "漏失监测点1", "13", BASE_TIME);
+        when(equipmentBaseMapper.selectById("flow-1")).thenReturn(flowDevice);
+        when(equipmentBaseMapper.selectById("pressure-1")).thenReturn(pressureDevice);
+        when(equipmentBaseMapper.selectById("leakage-1")).thenReturn(leakageDevice);
+
+        when(radarDataMapper.selectList(any())).thenAnswer(invocation -> recordsWithoutNullBounds(
+                invocation, Collections.singletonList(radar("WS-FLOW-1", "10", BASE_TIME.minusDays(3)))));
+        when(pressureMapper.selectList(any())).thenAnswer(invocation -> recordsWithoutNullBounds(
+                invocation, Collections.singletonList(pressure("WS-PR-1", 0.2D, BASE_TIME.minusDays(3)))));
+        when(noiseMapper.selectList(any())).thenAnswer(invocation -> recordsWithoutNullBounds(
+                invocation, Collections.singletonList(noise("WS-LK-1", 2D, BASE_TIME.minusDays(3)))));
+
+        assertEquals(1, service.getFlowTrend("flow-1", null, BASE_TIME).size());
+        assertEquals(1, service.getPressureTrend("pressure-1", null, BASE_TIME).size());
+        assertEquals(1, service.getLeakageTrend("leakage-1", null, BASE_TIME).size());
+    }
+
+    @Test
+    void requestedPageBeyondMeasurementResultsKeepsTheFilteredTotal() {
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Collections.singletonList(
+                device("flow-1", "WS-FLOW-1", "流量监测点1", "11", BASE_TIME)
+        ));
+        when(radarDataMapper.selectList(any())).thenReturn(Collections.singletonList(
+                radar("WS-FLOW-1", "10", BASE_TIME)
+        ));
+
+        Page<FlowMonitoringVO> page = service.getFlowMonitorPage(2, 10, null, null, null, null, null, null, null);
+
+        assertEquals(1, page.getTotal());
+        assertTrue(page.getRecords().isEmpty());
+    }
+
+    private static EquipmentType type(String id, String typeId, String name) {
+        return type(id, typeId, name, "1");
+    }
+
+    private static Set<Object> flattenWrapperValues(
+            LambdaQueryWrapper<EquipmentBase> wrapper) {
+        wrapper.getSqlSegment();
+        Set<Object> values = new HashSet<>();
+        for (Object value : wrapper.getParamNameValuePairs().values()) {
+            if (value instanceof Collection<?>) {
+                values.addAll((Collection<?>) value);
+            } else if (value instanceof Object[]) {
+                values.addAll(Arrays.asList((Object[]) value));
+            } else {
+                values.add(value);
+            }
+        }
+        return values;
+    }
+
+    private static <T> List<T> recordsWithoutNullBounds(InvocationOnMock invocation, List<T> records) {
+        @SuppressWarnings("unchecked")
+        LambdaQueryWrapper<T> wrapper = invocation.getArgument(0);
+        assertTrue(wrapper.getParamNameValuePairs().values().stream().noneMatch(Objects::isNull),
+                "trend query must not bind a null start time");
+        return records;
+    }
+
+    private static EquipmentType type(String id, String typeId, String name, String parentTypeId) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeId(typeId);
+        type.setTypeName(name);
+        type.setParentTypeId(parentTypeId);
+        return type;
+    }
+
+    private static EquipmentBase device(String id, String code, String name, String typeId, LocalDateTime createTime) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId(id);
+        device.setEquipmentCode(code);
+        device.setEquipmentName(name);
+        device.setEquipmentTypeId(typeId);
+        device.setEquipmentLocation("测试位置-" + code);
+        device.setCreateTime(createTime);
+        return device;
+    }
+
+    private static RadarData radar(String code, String flow, LocalDateTime time) {
+        RadarData data = new RadarData();
+        data.setDeviceCode(code);
+        data.setMeter1InstantFlow(flow);
+        data.setFlowSpeed("1.2");
+        data.setCreateTime(time);
+        return data;
+    }
+
+    private static TelemetryData telemetry(String code, String flow, LocalDateTime time) {
+        TelemetryData data = new TelemetryData();
+        data.setDeviceCode(code);
+        data.setMeter1InstantFlow(flow);
+        data.setFlowSpeed("1.1");
+        data.setCreateTime(time);
+        return data;
+    }
+
+    private static FirefightingPressure pressure(String code, Double value, LocalDateTime time) {
+        FirefightingPressure data = new FirefightingPressure();
+        data.setTelemeteringStation(code);
+        data.setPressureValue(value);
+        data.setCreateTime(time);
+        return data;
+    }
+
+    private static NoiseInfo noise(String code, Double power, LocalDateTime time) {
+        NoiseInfo data = new NoiseInfo();
+        data.setEncode(code);
+        data.setPower(power);
+        data.setGain(1D);
+        data.setRms(10);
+        data.setCenterFrequency(1000D);
+        data.setCreateTime(time);
+        return data;
+    }
+}