梯级水电站移动查询系统:从弱网适配到离线缓存的工程实践 📅 发布时间:2026/9/19 9:19:09 👁 浏览次数: 简介基于Web App的梯级水电站调度信息移动查询系统设计与实现是一份面向水电行业信息化建设与移动应用开发的技术参考文献。内容聚焦梯级水电站调度信息移动查询需求围绕需求分析、系统设计、系统实现、部署测试等全流程展开重点阐述了Web App技术选型、B/S架构、HTML5与JavaScript前端框架以及Java/JSP和MySQL后端集成方案并给出了长江电力长江上游梯级水电站的实际应用案例有助于解决调度信息整合困难、专业知识壁垒与移动访问不便等实际问题。资源包共1个PDF文件约1.28MB内容完整、字号清晰适合从事水利水电信息化、移动应用开发或相关课题研究的工程技术人员、高校师生作为参考。该资源已有91人学习浏览其系统设计思路与实现细节对后续同类项目具有较高借鉴价值。1. 从调度大屏到手机终端梯级水电站移动查询系统要解决什么值班员半夜被电话叫醒问的不是“全厂总负荷”而是“上游站现在水位多少出库流量大不大”。梯级水电站沿河分布中控大屏和上位机绑在厂站内调度人员离开中控室就看不到实时数据。手机端的调度信息查询系统就是把过去只能在自动化系统图形终端上看的那些数据搬到 Web App 里让具备权限的人用浏览器就能查。这里选 Web App 而不是原生 App不是因为技术能力不足而是梯级电站的权限体系和点位字典经常调整H5 页面改一次发布一次免安装、免上架同时查询类功能不需要调用摄像头、蓝牙等原生能力H5 足够。但移动查询有一个反常规的要求调度大屏网络是内网专线几乎不丢包手机端要面对隧道、大坝廊道、4G/5G 切换的弱网环境。因此系统设计的第一原则不是“做得快”而是“没网时也能把最近状态给用户看”。整个系统的核心有三个接口的权限与数据隔离、实时值的短缓存设计、前端离线缓存。下面按一条“从数据侧到手机侧”的完整链路来讲适用于水电厂自动化工程师和准备做同类移动查询平台的开发团队。2. 系统边界与数据链路梯级调度信息查询系统的分层设计任何移动查询系统都不能直接穿透到生产控制大区。水电厂内SCADA/监控系统所在的安全区与办公网络之间往往有隔离装置移动端可以访问的区域是管理信息大区。常规拓扑是采集程序从实时库镜像或隔离装置后侧的数据镜像读取测点快照写入查询服务缓存查询服务对外提供 HTTPS 接口手机浏览器只和查询服务通信。2.1 三层边界采集适配层、聚合服务层、移动展示层第一层是采集适配层。这一层解决的是“从不同厂家监控系统拿数据”的问题。有的电站用南瑞 NC2000有的用施耐德或 ABB 系统开放接口可能走 Modbus、OPC DA/UA、或者直接读关系库。常见做法是写一个独立适配器轮询各电站镜像数据转成统一测点模型再推给消息队列。轮询周期不是越小越好要根据隔离装置吞吐量和测点数量计算。一个中等梯级约 2000 个测点快照按 50 字节算5 秒轮询一次产生 20KB/s 流量对隔离装置压力可接受如果缩减到 1 秒流量会放大 5 倍设备告警日志也容易被刷掉。第二层是聚合服务层。它维护测点字典、调用权限、缓存和审计。聚合层向外暴露的是电站视角而不是测点视角。比如“查询甲电站总出力”这个请求在聚合层内部实际要聚合该站全部机组有功之和或读取 SCADA 已算好的总加测点。聚合结果要缓存因为多用户同时刷新首页时没必要让每个请求都触发一次总加计算。第三层是移动展示层也就是 Web App 本身。展示层不存储业务原始数据只保留最近一次快照。页面路由、用户会话、离线缓存都在这层处理。三层边界带来的好处是将来要支持 PC 浏览器、大屏、钉钉小程序只需要在展示层增加适配器聚合服务层不用改。2.2 核心数据模型电站、测点、实时值与品质码调度信息查询系统的数据字典比普通 B/S 系统简单但有一个严格要求每个测点必须带时间和品质。下面列出最常用的四张表结构。表名字段说明stationid, name, code, river_system, sort_no电站基础信息sort_no 用于梯级顺序排列point_dictid, station_id, point_code, point_name, unit, data_type, ttl_secondsdata_type 分累计量与瞬时量rt_valuepoint_id, value, quality, ts, source_timesource_time 是 SCADA 侧采集时间不能与服务端时间混用alarm_recordid, station_id, level, message, raise_time, confirm_status告警查询和推送共用rt_value 里的 source_time 是最关键字段。SCADA 在设备侧已经给测点打了时间戳服务端收到后不建议用当前时间覆盖。移动端显示时如果 source_time 早于当前时间超过阈值前端要置灰或打上“迟”标记。quality 码在电力规约里通常用 0 好、1 坏、2 可疑、3 手工置数接口返回数值即可前端做映射。实时库很多用 PI、eDNA 这类历史库但查询服务没必要把历史库直接开放给前端。我的做法是在关系库里保留一份最新的 rt_value历史趋势再调用时序库。下面是一个从镜像关系库读取最新测值的 SQL 示例注意它用窗口函数而不是 group by避免索引失效。SELECT point_dict.point_code, rt_value.value, rt_value.quality, rt_value.source_time FROM ( SELECT point_id, value, quality, source_time, ROW_NUMBER() OVER (PARTITION BY point_id ORDER BY source_time DESC) AS rn FROM rt_value WHERE source_time NOW() - INTERVAL 10 minutes ) rt_value JOIN point_dict ON point_dict.id rt_value.point_id JOIN station ON station.id point_dict.station_id WHERE station.code LSY AND point_dict.point_code IN (level,outflow) AND rn 1;这段 SQL 的分区逻辑是核心按 point_id 分组取 source_time 最新的一行。NOW() - INTERVAL 10 minutes是兜底条件防止因为个别测点长时间不刷新而把历史旧值当成最新值。如果业务上有实时性告警需求可以把这个阈值作为 point_dict.ttl_seconds 传入SQL 中改为按测点动态判断。还要注意rt_value 表要在 (point_id, source_time) 上建组合索引否则电站多、数据量上来后这条查询会扫全表。2.3 接口与推送选型REST 查询 WebSocket/SSE 推送移动端查询系统有两种数据获取方式。主动查询用 REST符合“用户点开一个电站看到当前水位”的语义可以缓存重试简单。被动刷新用 WebSocket 或 SSE。我一般不建议每个页面都建立长连接只对“告警”和“遥信变位”用推送因为推送是事件驱动的流量最省。推送参数设置上WebSocket 心跳间隔设 30 秒超过 45 秒未收心跳则断线重连。移动网络切换后WiFi 转 4G原来的 socket 会失效服务端要监听 close 事件不要保留半开连接。SSE 的优点是自动重连且走 HTTP/2但单向限制让它不适合做“请求-响应”混合模型。小团队从 REST 轮询起步最稳妥。轮询参数要按数据重要性分级水位、闸门开度等核心遥测10 秒轮询机组总有功15 秒告警列表30 秒。如果做不到分级统一 10 秒轮询 2000 并发用户会对查询服务造成每秒 200 次请求配合缓存仍然能扛住但移动设备电量消耗会上升。因此轮询必须在页面不可见时暂停这个逻辑在第四章的代码里会明确处理。3. 服务端实现用 Spring Boot 封装梯级水电站调度数据接口第 2 章讲完了边界和数据组织这一章给出实际可运行的接口代码与缓存、权限设计。因为水电行业现有统一权限和审计平台多是 Java 系服务端直接压成 Spring Boot 项目集成成本最低。3.1 基础接口单站实时信息查询 Controller移动 Web App 的首页是“梯级总览”但接口设计建议按“单站”而非“梯级”做因为梯级总览只是把多个单站结果聚合。这里给出单站多测点实时信息接口。RestController RequestMapping(/api/v1/stations) public class StationRealtimeController { private final RealtimeValueService rtService; public StationRealtimeController(RealtimeValueService rtService) { this.rtService rtService; } GetMapping(/{stationId}/realtime) public ResponseEntityRealtimeResponse getRealtime( PathVariable Long stationId, RequestParam(defaultValue level,inflow,outflow,total_power) String points, RequestParam(defaultValue true) boolean includeQuality) { ListString pointCodes Arrays.asList(points.split(,)); RealtimeResponse response rtService.getLatestValues(stationId, pointCodes, includeQuality); return ResponseEntity.ok(response); } }参数设计有三个细节第一points 的默认值“level,inflow,outflow,total_power”对应梯级调度最常用的“水位、入库、出库、总出力”四类测点编码调用方可以不传参数直接拿到首页数据第二points 用逗号分隔而不是 JSON 数组是为了在 GET 请求里方便拼接 URL减少转码问题第三includeQuality 默认 true如果传 false 则结果里不带品质字段可以省一点流量但坏值会被当成正常值显示所以业务上建议保持默认。实际联调时可以用以下 curl 命令验证接口是否通注意替换 token 和域名。curl -s https://m.example.com/api/v1/stations/172/realtime?pointslevel,inflow,outflow,total_powerincludeQualitytrue \ -H Authorization: Bearer ${ACCESS_TOKEN} \ -H X-Request-Id: 7f3a9c2e-6d4b-4f5a-8c1e-0a1b2c3d4e5f | jq .data.generatedAt, .data.items返回 JSON 中generatedAt 是服务端生成时间items 数组里每项包含 pointCode、value、quality、sourceTime。X-Request-Id这个请求头虽然不是标准参数但对于排障非常重要查询服务日志里会把它打到每一条访问记录和慢查询日志移动端报问题时直接把这个 ID 反馈过来后端就能按 ID 检索到链路。响应头里也建议返回同一个 X-Request-Id方便前后端对齐。3.2 缓存策略按测点分 TTL 避免缓存穿透实时数据接口不能每次查询都回源实时库但也不能用统一 TTL 缓存。水位测点变化慢但关键TTL 设置为 1 秒总出力变化快TTL 设置 3 秒闸门开度在操作时才变化2 秒够了。下面这段代码用 Spring Cache 的 Cache 抽象展示 TTL 如何按测点动态决定。Service public class RealtimeValueService { private final CacheString, CachedValue localCache; private final RealtimeSource realtimeSource; public RealtimeValueService(CacheManager cacheManager, RealtimeSource realtimeSource) { this.localCache cacheManager.getCache(realtime-point-cache); this.realtimeSource realtimeSource; } public RealtimeResponse getLatestValues(Long stationId, ListString pointCodes, boolean includeQuality) { long now System.currentTimeMillis(); ListPointValue values new ArrayList(); for (String pointCode : pointCodes) { String key stationId : pointCode; CachedValue cached localCache.get(key, CachedValue.class); if (cached null || now - cached.savedAt ttlSeconds(pointCode)) { PointValue fresh realtimeSource.fetch(stationId, pointCode); localCache.put(key, new CachedValue(fresh, now)); values.add(fresh); } else { values.add(cached.value); } } return new RealtimeResponse(stationId, now, values, now / 1000); } private int ttlSeconds(String pointCode) { switch (pointCode) { case level: return 1; case total_power: return 3; case gate_open: return 2; default: return 5; } } }这段逻辑的坑在于缓存穿透。当实时源侧测点被删除或网络中断时realtimeSource.fetch返回空直接不缓存会导致每次请求都打到实时库甚至把故障放大。改进方法是fetch 结果为空时也构建一个“空值缓存”TTL 设为 3 秒。另外需要区分“测点不存在”和“测点没有新值”测点不存在要返回 400测点没有新值则返回旧值并把 dataVersion 保持不变前端可以利用 dataVersion 来发现数据停止更新。缓存容量参数也要限制。本地缓存用 Caffeine 时建议设置maximumSize(20000)、expireAfterWrite(10, TimeUnit.SECONDS)。REST 接口返回的X-Cache-Hit头可以用来检查命中率联调阶段如果发现命中率低于 90%先看 TTL 是否被统一设置覆盖再看 pointCodes 是否被前端写死。3.3 查询权限接口级校验与按电站的数据隔离移动查询系统不能只做登录校验还需要在接口里做电站维度校验。原因是 OAuth2 令牌只能证明“你是谁”不能证明“你能看哪个电站”。 Spring Security 中可以用方法级别的注解完成。PreAuthorize(stationPermission.check(#stationId, realtime:query)) GetMapping(/{stationId}/realtime) public ResponseEntityRealtimeResponse getRealtime(...) { ... }在stationPermission.check实现里需要从当前认证用户上下文中取出电站 ID 列表再检查请求的 stationId 是否在列表中。电站列表不一定要缓存在服务端可以存到 Redis 中用户权限变更后通过消息总线刷新。注意不要把角色名硬编码为“admin”因为水电厂权限往往到电站加值班班组粒度只分角色会漏权限。审计日志建议做成异步接口正常响应后发送一条包含 uid、stationId、pointCodes、源 IP 的事件到阻塞队列。队列长度超过 5000 条则丢弃并告警防止审计本身拖垮接口。日志轮转按天切分至少保留 180 天方便追溯“半夜谁查了水位”这类问题。4. 移动 Web App 端实现适配弱网的调度信息查询界面服务端接口就绪后前端实现的核心不是视觉设计而是网络状态管理。这一章给出 Vue 3 实现移动查询系统的三个关键点组件库选型、刷新机制和离线缓存。4.1 前端选型Vue 3 Vant 的工程化配置Web App 开发中移动端组件库选型会直接影响打包体积和交互手感。我推荐 Vue 3 Vant 4按需引入组件后首屏 JS 可以控制在 140KB 左右。Vant 的PullRefresh、List、Popup在触摸滚动时表现稳定且内置了Toast轻提示。由于查询系统页面结构简单不建议引入完整大屏图表库如 ECharts 完整包只需要echarts/core按需注册折线图和仪表盘即可。页面结构建议采用单页应用路由分为/overview、/station/:id、/alarm和/trend/:pointId。梯级总览页顶部是当前时间与数据更新时间中间是电站卡片列表底部是告警角标。电站卡片上只展示四个测点水位、出入库流量、总出力不要放机组级明细否则页面高度太长滑动时容易误触。在构建配置里还需要设置生产环境的接口地址从环境变量注入不能把后端地址写到 JS 源码里。因为 Web App 在手机浏览器部署开发者工具里能看到所有网络请求内网 IP 一旦暴露攻击者就能直接绕过网关扫描其他端口。4.2 页面数据刷新轮询、生命周期与请求竞态查询系统的核心交互是下拉刷新 定时轮询。下面这段代码用组合式 API 实现了一个可复用的useRealtimePolling。import { ref, onMounted, onUnmounted, onActivated, onDeactivated } from vue; import { fetchStationRealtime } from /api/realtime; export function useRealtimePolling(stationId, pointCodes, interval 10000) { const data ref(null); const loading ref(false); let timer null; let requestSeq 0; async function load(showLoading false) { const currentSeq requestSeq; if (showLoading) loading.value true; try { const response await fetchStationRealtime(stationId, pointCodes, { silent: !showLoading }); if (currentSeq requestSeq) { data.value response.data; } } catch (error) { // 网络异常时保留旧数据交给离线缓存处理 } finally { if (currentSeq requestSeq) loading.value false; } } function startPolling() { load(true); timer setInterval(() load(false), interval); } function stopPolling() { if (timer) { clearInterval(timer); timer null; } } onMounted(startPolling); onDeactivated(stopPolling); onUnmounted(stopPolling); return { data, loading, refresh: () load(true) }; }代码里的 requestSeq 是处理竞态的关键。轮询请求如果超过 interval 时间未返回下一次定时器触发时会发起第二个请求上一个请求的响应如果后到达会覆盖新数据。通过序号丢弃旧响应保证页面展示永远是最后一次发出的请求结果。定时器还要跟页面生命周期绑定。在 Vue 的keep-alive场景下onActivated时重启onDeactivated时停掉。如果团队不用 keep-alive那么路由切换后组件卸载onUnmounted中的清理逻辑也能保证请求中断。还需要加一个document.visibilitychange的监听页面从后台切回时立即触发一次load(true)否则手机锁屏再解锁后页面会显示几分钟前的数据。下表给出不同场景的刷新参数建议。场景推荐方式参数设置梯级总览下拉刷新 定时轮询轮询 15 秒下拉刷新立即执行单站详情下拉刷新 长轮询轮询 10 秒核心测点增加 WebSocket 推送告警列表定时轮询 角标轮询 30 秒页面不可见时跳过趋势曲线进入页面时加载离开即丢弃不轮询只加载当前区间4.3 离线缓存把最近一次调度快照存到 IndexedDB弱网是水电站移动查询绕不开的场景。手机在廊道里没信号不能白屏。我的方案是用 IndexedDB 缓存最近一次实时快照和点位字典。const OFF_DB hydro-mobile-cache; const STORE station-snapshot; function openCacheDB() { return new Promise((resolve, reject) { const request indexedDB.open(OFF_DB, 1); request.onupgradeneeded () { const db request.result; if (!db.objectStoreNames.contains(STORE)) { db.createObjectStore(STORE, { keyPath: stationId }); } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } async function cacheSnapshot(snapshot) { const db await openCacheDB(); const tx db.transaction(STORE, readwrite); tx.objectStore(STORE).put(snapshot); } async function readCachedSnapshot(stationId) { const db await openCacheDB(); const tx db.transaction(STORE, readonly); return new Promise((resolve, reject) { const request tx.objectStore(STORE).get(stationId); request.onsuccess () resolve(request.result || null); request.onerror () reject(request.error); }); }在useRealtimePolling的 catch 分支里调用readCachedSnapshot拿到快照后把页面标记为“离线”。页面上的数据卡片要显示“缓存时间”和“坏值”标识否则调度员会因为数据没变化而误判。IndexedDB 容量默认够用但 iOS Safari 在无痕模式下可能抛 QuotaExceededError所以openCacheDB的异常要交给全局错误处理不能中断页面。离线缓存更新时机是每次 REST 请求成功后写入。这里要注意的是写 IndexedDB 是异步操作不能让写库影响请求响应时间。可以在响应处理最后调用cacheSnapshot(snapshot)且不await避免加长关键路径。点位字典也要缓存这样离线时仍能显示中文名称而不是 pointCode。5. 移动查询系统上线前必做的三项验证弱网模拟、数据一致性核查与安全配置5.1 弱网模拟别再拿办公室 WiFi 测调试时我用 Chrome DevTools 的 Network 面板选“Slow 4G”再把 RTT 手动调到 800ms、吞吐量 200kbps模拟隧道场景。只测页面打开还不够要在弱网下执行完“下拉刷新-关掉弹窗-切后台-回前台”的完整操作看是否会出现数据闪烁或旧值覆盖。发现首屏超过 3 秒第一件事不是优化代码而是检查图片是否用了非 WebP 格式、接口是否返回了多余字段。移动查询系统常常因为返回了 history 数组导致流量浪费实际页面只需要最新值。5.2 数据一致性核查以 SCADA 源端时间戳为准在服务端加一个调试接口/api/v1/debug/time-diff?stationId172pointCodelevel返回 SCADA 侧 sourceTime、服务端缓存时间、当前时间三个字段。水位测点的时间差超过 2 秒就要查缓存 TTL 或同步链路。同时要验证 qualitybad 时前端 UI 是否变为灰色并标注“坏值”因为很多事故隐患是从坏值误读开始的。5.3 移动端安全配置证书、令牌与敏感信息上线前检查 HTTPS 证书链是否被手机默认信任自签名证书绝不能用在生产。Web App 内令牌放在 localStorage 还是内存建议放内存配合刷新令牌避免 XSS 偷走长期令牌。禁用所有 HTTP 明文请求并将后端域名配置为https://m.example.com同时确认 Webpack 产物中没有残留内网 IP。生产构建后可用grep -r 192.168 dist/扫描如果搜出内网地址说明环境变量注入没做干净。最后还要验证锁屏恢复机制手机锁屏 10 分钟后解锁页面重新可见时能否立即触发一次实时刷新。可以用visibilitychange事件监听实现确保用户看到的不是锁屏前的旧数据。这一步虽然简单但调度员最常踩的坑恰恰在这里。本文还有配套的精品资源点击获取