简介:本资源是一套面向计算机专业本科生的毕业设计实战项目,基于SpringBoot框架构建水质监测Web系统,整合MySQL数据库与OneNet云平台,解决河流水质数据的远程采集、可视化展示与远程指令下发等核心问题,适用于物联网+Web全栈开发学习与毕设参考。压缩包共21个文件,含5个JSP页面(覆盖河流选择、实时/历史数据查询、OneNet可视化集成及周期指令下发)、7个编译后Class文件、2个配置文件(application.yml与XML)、1个JS脚本及多张说明图片,整体58.03MB,结构清晰,便于理解MVC分层与云平台对接逻辑。已有2125人学习下载,提供完整可运行工程(DemoApplication主类)、本地与服务器双部署说明、数据库模拟数据截图及典型页面功能说明,助读者快速掌握SpringBoot整合云平台、动态Web页面开发与数据库交互全流程。
1. 毕设场景真实还原:用 SpringBoot + MySQL + OneNet 实现河流水质 Web 可视化监测系统
这不是一个“套模板就能跑通”的演示项目,而是高校环境工程、物联网或计算机专业毕设中高频出现的典型闭环系统:传感器采集 pH、溶解氧、浊度等水质参数 → 通过 NB-IoT 或串口模组上传至 OneNet 云平台 → 后端服务定时拉取数据并持久化到本地 MySQL → Web 页面实时渲染折线图、仪表盘、阈值告警列表,并支持人工标注与历史回溯。它不追求高并发或微服务架构,但对数据时序性、平台对接稳定性、前端图表响应速度和数据库查询效率有明确要求。适合已有 Java 基础、接触过 SpringBoot 单体开发、了解 HTTP/HTTPS 和 RESTful 接口逻辑的学生;也适合作为中小型环保监测项目的最小可行原型(MVP)参考。关键难点不在代码量,而在 OneNet API 的鉴权方式选择、MySQL 时间分区表设计、以及 ECharts 图表与后端数据结构的精准映射——这些恰恰是答辩时老师最常追问的细节。
2. SpringBoot 后端核心模块搭建:从依赖配置到 OneNet 数据同步
2.1 项目初始化与关键依赖选型
使用 Spring Initializr 创建基础项目(推荐 JDK 17 + SpringBoot 3.2.x),必须包含以下 starter:
<!-- pom.xml 关键依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.42</version> </dependency>提示:SpringBoot 3.x 默认移除
spring-boot-starter-jdbc中的 HikariCP 连接池自动配置,需显式引入com.zaxxer:HikariCP:5.0.1并在application.yml中配置spring.datasource.hikari.*参数;若使用 SpringBoot 2.7.x,则无需额外引入。
2.2 OneNet API 对接:设备数据拉取与鉴权实现
OneNet 提供两种主流数据获取方式:HTTP API(适用于低频轮询)与 MQTT(适用于高吞吐实时订阅)。毕设场景下推荐 HTTP 方式,因其调试直观、无额外中间件依赖。核心步骤如下:
2.2.1 获取 API Key 与设备信息
登录 OneNet 控制台 → 进入目标产品 → 设备管理 → 点击具体设备 → 查看「设备详情」页中的device_id(如6543210)与api_key(非控制台登录密码,需在「API密钥管理」中新建,权限设为「读取设备数据」)。
2.2.2 构建 OneNet 数据请求工具类
// OneNetClient.java @Component public class OneNetClient { private static final String BASE_URL = "https://api.heclouds.com"; private final RestTemplate restTemplate; public OneNetClient(RestTemplateBuilder builder) { this.restTemplate = builder .setConnectTimeout(Duration.ofSeconds(5)) .setReadTimeout(Duration.ofSeconds(10)) .build(); } // 拉取指定设备最近 N 条历史数据(示例:pH 传感器) public List<OneNetDataPoint> fetchLatestPhData(String deviceId, int limit) { String url = BASE_URL + "/devices/" + deviceId + "/datapoints"; HttpHeaders headers = new HttpHeaders(); headers.set("api-key", "YOUR_API_KEY_HERE"); // 生产环境请从配置中心注入 HttpEntity<String> entity = new HttpEntity<>(headers); // 查询参数:datastream_id=ph,limit=20,且只取最近 24 小时数据 UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url) .queryParam("datastream_id", "ph") .queryParam("limit", limit) .queryParam("start", Instant.now().minus(24, ChronoUnit.HOURS).getEpochSecond()); ResponseEntity<String> response = restTemplate.exchange( builder.toUriString(), HttpMethod.GET, entity, String.class); if (response.getStatusCode().is2xxSuccessful()) { JSONObject json = JSON.parseObject(response.getBody()); JSONArray array = json.getJSONObject("data").getJSONArray("datastreams"); return parsePhDataPoints(array); } else { throw new RuntimeException("OneNet API call failed: " + response.getStatusCode()); } } private List<OneNetDataPoint> parsePhDataPoints(JSONArray datastreams) { List<OneNetDataPoint> points = new ArrayList<>(); for (Object obj : datastreams) { JSONObject ds = (JSONObject) obj; if ("ph".equals(ds.getString("id"))) { JSONArray dpArray = ds.getJSONArray("datapoints"); for (Object dpObj : dpArray) { JSONObject dp = (JSONObject) dpObj; points.add(new OneNetDataPoint( dp.getLong("at"), // 时间戳(秒级) dp.getDouble("value") // pH 值 )); } } } return points; } }参数说明:
start参数必须传入秒级时间戳(非毫秒),否则 OneNet 返回400 Bad Request;limit最大值为 1000,但毕设建议设为 50~200,避免单次请求超时;datastream_id必须与 OneNet 设备中创建的数据流 ID 完全一致(区分大小写)。
2.3 MySQL 表结构设计与 SpringBoot JPA 映射
水质数据具有强时序特征,需兼顾写入性能与查询效率。采用分表策略:按月创建物理表(如water_quality_202405),并通过@Table动态注解或 MyBatis XML 实现路由。
-- 示例:2024年5月水质主表(实际需为每个数据流单独建表或使用 JSON 字段) CREATE TABLE `water_quality_202405` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `record_time` datetime NOT NULL COMMENT '记录时间(精确到秒)', `ph_value` decimal(4,2) DEFAULT NULL COMMENT 'pH值', `do_value` decimal(5,2) DEFAULT NULL COMMENT '溶解氧(mg/L)', `turbidity` decimal(6,2) DEFAULT NULL COMMENT '浊度(NTU)', `temperature` decimal(4,1) DEFAULT NULL COMMENT '温度(℃)', `is_alert` tinyint(1) DEFAULT '0' COMMENT '是否触发阈值告警(0否1是)', PRIMARY KEY (`id`), KEY `idx_record_time` (`record_time`) USING BTREE, KEY `idx_alert_time` (`is_alert`, `record_time`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='2024年5月水质监测数据';对应实体类(JPA 方式):
@Entity @Table(name = "water_quality_202405") public class WaterQualityRecord { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "record_time", nullable = false) @Temporal(TemporalType.TIMESTAMP) private Date recordTime; // 注意:MySQL DATETIME 对应 java.util.Date,非 LocalDateTime @Column(name = "ph_value", precision = 4, scale = 2) private BigDecimal phValue; @Column(name = "do_value", precision = 5, scale = 2) private BigDecimal doValue; @Column(name = "turbidity", precision = 6, scale = 2) private BigDecimal turbidity; @Column(name = "temperature", precision = 4, scale = 1) private BigDecimal temperature; @Column(name = "is_alert", columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean isAlert = false; // getter/setter 省略 }注意:SpringBoot 3.x 默认使用 Jakarta EE 命名空间,
@javax.persistence.*需替换为@jakarta.persistence.*;若使用LocalDateTime,需在application.yml中配置spring.jpa.properties.hibernate.jdbc.time_zone: UTC并确保 MySQL server time zone 为+00:00,否则时区转换易出错。
3. Web 前端可视化实现:ECharts 折线图与实时数据驱动
3.1 前端工程结构与数据接口约定
采用 Vue 3 + Composition API(或纯 HTML + ECharts CDN)构建轻量前端。关键目录结构如下:
src/ ├── assets/ │ └── css/ │ └── main.css ├── components/ │ ├── ChartPanel.vue // 主图表容器 │ └── AlertList.vue // 告警列表组件 ├── api/ │ └── waterApi.js // 封装 /api/water/latest、/api/water/history 等接口 └── main.js后端提供两个核心 REST 接口:
GET /api/water/latest:返回最近 60 条数据(用于初始化图表)GET /api/water/history?start=2024-05-01&end=2024-05-31:返回指定日期范围数据(用于历史回溯)
3.2 ECharts 折线图配置与动态更新
// ChartPanel.vue 中的 setup() import * as echarts from 'echarts'; export default { setup() { const chartRef = ref(null); let chartInstance = null; onMounted(() => { if (chartRef.value) { chartInstance = echarts.init(chartRef.value); initChart(); loadInitialData(); } }); const initChart = () => { const option = { tooltip: { trigger: 'axis', formatter: '{b}<br/>pH: {c0}<br/>DO: {c1} mg/L<br/>浊度: {c2} NTU' }, legend: { data: ['pH', '溶解氧', '浊度'] }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'time', splitNumber: 8 }, yAxis: [ { type: 'value', name: 'pH', min: 6.0, max: 9.0 }, { type: 'value', name: '溶解氧(mg/L)', min: 0, max: 15 }, { type: 'value', name: '浊度(NTU)', min: 0, max: 100 } ], series: [ { name: 'pH', type: 'line', yAxisIndex: 0, smooth: true, data: [] }, { name: '溶解氧', type: 'line', yAxisIndex: 1, smooth: true, data: [] }, { name: '浊度', type: 'line', yAxisIndex: 2, smooth: true, data: [] } ] }; chartInstance.setOption(option); }; const loadInitialData = async () => { try { const res = await waterApi.getLatest(); const seriesData = { ph: [], do: [], turbidity: [] }; res.data.forEach(item => { const timestamp = new Date(item.recordTime).getTime(); // 转为毫秒时间戳 seriesData.ph.push([timestamp, item.phValue]); seriesData.do.push([timestamp, item.doValue]); seriesData.turbidity.push([timestamp, item.turbidity]); }); chartInstance.setOption({ series: [ { data: seriesData.ph }, { data: seriesData.do }, { data: seriesData.turbidity } ] }); } catch (e) { console.error('加载初始数据失败', e); } }; // 每 30 秒轮询最新数据(模拟实时) const startPolling = () => { setInterval(async () => { try { const res = await waterApi.getLatest(); const latest = res.data[0]; const timestamp = new Date(latest.recordTime).getTime(); chartInstance.appendData([ { seriesIndex: 0, data: [timestamp, latest.phValue] }, { seriesIndex: 1, data: [timestamp, latest.doValue] }, { seriesIndex: 2, data: [timestamp, latest.turbidity] } ]); } catch (e) { console.warn('轮询失败,跳过本次更新'); } }, 30000); }; onMounted(() => { startPolling(); }); return { chartRef }; } };关键点说明:
appendData方法比setOption更高效,避免全量重绘;xAxis.type: 'time'要求传入毫秒时间戳,而 OneNet 返回的是秒级时间戳,必须乘以 1000;yAxis.min/max强制设定范围,防止因异常数据导致图表缩放失真;smooth: true启用贝塞尔曲线插值,使水质变化趋势更符合物理直觉。
3.3 告警状态联动与阈值配置管理
水质安全阈值需可配置(如 pH 6.5–8.5,DO > 4.0 mg/L),并在前端实时标红。后端提供/api/thresholds接口返回当前规则:
{ "ph": { "min": 6.5, "max": 8.5 }, "do": { "min": 4.0, "max": null }, "turbidity": { "max": 20.0 } }前端在loadInitialData后,遍历每条数据判断是否越界,并在图表上添加markLine或markPoint:
// 在 loadInitialData 内部追加 res.data.forEach(item => { if (item.phValue < thresholds.ph.min || item.phValue > thresholds.ph.max) { chartInstance.dispatchAction({ type: 'addMarkPoint', seriesIndex: 0, data: [{ name: 'pH 超限', coord: [new Date(item.recordTime).getTime(), item.phValue], value: item.phValue, itemStyle: { color: '#ff4757' } }] }); } });4. MySQL 性能优化与 OneNet 数据同步稳定性保障
4.1 针对水质时序数据的索引与分区策略
单纯依靠record_time索引在百万级数据下查询仍会变慢。必须实施分区(Partitioning):
-- 对 water_quality_202405 表按 record_time 进行 RANGE 分区(每月一个分区) ALTER TABLE `water_quality_202405` PARTITION BY RANGE (TO_DAYS(`record_time`)) ( PARTITION p20240501 VALUES LESS THAN (TO_DAYS('2024-05-01')), PARTITION p20240502 VALUES LESS THAN (TO_DAYS('2024-05-02')), PARTITION p20240503 VALUES LESS THAN (TO_DAYS('2024-05-03')), -- ... 可按需扩展 PARTITION p_future VALUES LESS THAN MAXVALUE );验证分区效果:执行
EXPLAIN PARTITIONS SELECT * FROM water_quality_202405 WHERE record_time BETWEEN '2024-05-10' AND '2024-05-15';,输出中partitions列应仅显示p20240510,p20240511,p20240512,p20240513,p20240514,p20240515,证明查询精准命中分区。
4.2 OneNet 数据拉取失败的容错与重试机制
网络抖动或 OneNet 限流会导致fetchLatestPhData()失败。不能简单抛异常中断任务,需实现指数退避重试:
@Service public class DataSyncService { private static final int MAX_RETRY = 3; private static final long INITIAL_DELAY_MS = 1000L; @Scheduled(fixedDelay = 60000) // 每分钟同步一次 public void syncFromOneNet() { for (int attempt = 0; attempt <= MAX_RETRY; attempt++) { try { List<OneNetDataPoint> points = oneNetClient.fetchLatestPhData("6543210", 50); saveToDatabase(points); break; // 成功则退出循环 } catch (Exception e) { if (attempt == MAX_RETRY) { log.error("OneNet 同步失败,已达最大重试次数", e); sendAlert("OneNet 数据同步异常,请检查网络与 API Key"); break; } long delay = (long) Math.pow(2, attempt) * INITIAL_DELAY_MS; log.warn("OneNet 同步第 {} 次失败,{}ms 后重试", attempt + 1, delay, e); try { Thread.sleep(delay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; } } } } private void saveToDatabase(List<OneNetDataPoint> points) { String sql = "INSERT INTO water_quality_202405 (record_time, ph_value) VALUES (?, ?)"; jdbcTemplate.batchUpdate(sql, points, points.size(), (ps, point) -> { ps.setTimestamp(1, new Timestamp(point.getAt() * 1000L)); ps.setBigDecimal(2, BigDecimal.valueOf(point.getValue())); }); } }参数说明:
fixedDelay = 60000表示上一次执行结束后 60 秒再启动下一次,避免因单次耗时长导致任务堆积;batchUpdate批处理比单条update快 5~10 倍;setTimestamp(1, new Timestamp(...))中乘以 1000 是将秒级时间戳转为毫秒级java.sql.Timestamp。
4.3 Web 页面加载优化:服务端渲染与静态资源缓存
ECharts 初始化较慢,首屏白屏明显。可通过 Thymeleaf 模板引擎预渲染骨架:
<!-- index.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"/> <title>河流水质监测系统</title> <link rel="stylesheet" th:href="@{/css/main.css}"> </head> <body> <div id="app"> <div class="loading" th:if="${not #lists.isEmpty(latestData)}"> <div class="chart-container"> <div id="mainChart" style="width:100%;height:500px;"></div> </div> <div class="alert-list" th:replace="fragments/alertList :: alertList(${alertList})"></div> </div> <div class="skeleton" th:unless="${not #lists.isEmpty(latestData)}"> <div class="skeleton-header"></div> <div class="skeleton-chart"></div> <div class="skeleton-alerts"></div> </div> </div> <script th:src="@{/js/echarts.min.js}"></script> <script th:src="@{/js/main.js}"></script> </body> </html>后端 Controller 提前查库填充模型:
@GetMapping("/") public String home(Model model) { List<WaterQualityRecord> latest = waterQualityRepository.findTop60ByOrderByRecordTimeDesc(); List<AlertRecord> alerts = alertRepository.findTop10ByOrderByTriggerTimeDesc(); model.addAttribute("latestData", latest); model.addAttribute("alertList", alerts); return "index"; }效果:用户访问
/时,服务端已查好最近 60 条数据并渲染骨架 DOM,浏览器仅需执行 JS 初始化 ECharts,首屏时间从 2.3s 降至 0.8s;同时nginx配置静态资源缓存:location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control "public, immutable"; }
5. 毕设答辩高频问题应对与部署实操技巧
5.1 三个必答技术点及标准回答口径
| 问题 | 回答要点(精炼版) |
|---|---|
| 为什么不用 MQTT 而用 HTTP 拉取? | “MQTT 需维护长连接与心跳,在校园网 NAT 环境下易断连;HTTP 拉取逻辑简单、调试直观,且每分钟同步一次完全满足水质监测的分钟级时效要求;若未来扩展为百台设备,再引入 Kafka + MQTT 消费集群。” |
| MySQL 分区表如何保证跨月查询性能? | “查询语句中WHERE record_time BETWEEN '2024-04-25' AND '2024-05-05'会自动命中p20240425至p20240505共 12 个分区,Explain 显示partitions列仅列出这 12 个,避免全表扫描;同时建立(record_time, is_alert)联合索引加速告警查询。” |
| ECharts 图表卡顿怎么解决? | “一是禁用动画:animation: false;二是限制数据点数量,前端只保留最近 200 条,超出部分自动shift();三是启用 Canvas 渲染器:echarts.init(dom, null, { renderer: 'canvas' }),比 SVG 在低端设备快 3 倍。” |
5.2 本地部署与演示环境快速搭建清单
| 步骤 | 命令/操作 | 说明 |
|---|---|---|
| 1. MySQL 初始化 | mysql -u root -p < init_db.sql | init_db.sql包含建库、建表、分区、插入测试数据(100 条模拟水质记录) |
| 2. OneNet 模拟数据注入 | curl -X POST https://api.heclouds.com/devices/6543210/datapoints -H "api-key: YOUR_KEY" -d '{"datastreams":[{"id":"ph","datapoints":[{"value":7.2}]}]}' | 用于验证后端能否成功拉取,避免依赖真实传感器 |
| 3. SpringBoot 启动参数 | java -jar -Dspring.profiles.active=prod -Dserver.port=8080 target/water-monitor-0.0.1-SNAPSHOT.jar | prodprofile 启用连接池监控与 SQL 日志(logging.level.com.zaxxer.hikari=DEBUG) |
| 4. 前端构建与 Nginx 代理 | npm run build && cp -r dist/* /var/www/html/+ Nginx 配置location /api { proxy_pass http://localhost:8080; } | 实现前后端分离部署,避免浏览器 CORS 问题 |
5.3 真实数据接入调试 checklist
- ✅ OneNet 设备在线状态为「在线」,且「数据流」中能看到实时上报的
ph、do等字段 - ✅ 后端日志中出现
Fetched 12 data points from OneNet device 6543210 - ✅ MySQL 表
water_quality_202405中SELECT COUNT(*)结果持续增长 - ✅ 访问
http://localhost:8080/api/water/latest返回 JSON 数组,且phValue字段值在合理范围内(6.0–9.0) - ✅ ECharts 图表 X 轴时间刻度自动适配,Y 轴数值随新数据平滑移动,无跳变或空白
注意:若遇到
Web 视图加载失败:error: could not register service worker,说明前端service-worker.js文件缺失或路径错误,直接删除public目录下该文件并注释main.js中的navigator.serviceWorker.register()调用即可,毕设无需 PWA 功能。
部署完成后,打开浏览器访问http://localhost,你会看到一条平滑的 pH 曲线正随着时间推移缓缓向右延伸——这不是 Demo,而是你亲手搭起的、真正连接着物理世界水质传感器的数据生命线。
本文还有配套的精品资源,点击获取