简介:面向计算机类毕设与课程作业的擎联科技智慧能耗管理系统后端完整项目,聚焦物联网设备能耗数据的采集、存储、分析与智能预测,适合需要实战参考的中高级后端学习者。压缩包共481个文件、约53.56MB,以277个Java源文件与171个XML配置为主,另含第三方Jar依赖、properties/yml环境配置、JSP页面以及Jenkinsfile持续集成文件;Java实现业务逻辑与API接口,XML用于框架配置或SQL映射,整体目录按common、dao、service、interface、report等模块划分,便于按层阅读与复用。这些模块分工明确,可帮助理解大型后端工程的拆分思路。目前已有67人学习,内容覆盖需求分析、数据库设计、核心源码、测试用例及部署脚本,可作为毕设答辩和二次开发的完整参考。通过研读可掌握Java后端工程的组织方式、能耗数据建模思路,以及如何将人工智能预测方法嵌入业务链路。
1. 一份毕设后端压缩包,背后是一整套能耗计费与统计的活儿
把《毕设&课程作业_擎联科技智慧能耗管理系统(后端).zip》解压后,你会看到的是一个典型的前后端分离项目中的 Java 后端工程:有 controller、service、mapper 分层,有 MySQL 初始化脚本,有 Postman 接口文档。这类智慧能耗管理系统在真实企业里对应的是「能耗在线监测平台」:每栋楼、每个车间装智能电表,网关定期把读数推到后端,后端负责存数据、按峰平谷电价算电费、算环比同比、给前端大屏喂统计接口。毕设和课设里做这套东西,本质不是发明算法,而是把「读表 -> 计费 -> 统计 -> 展示」这条链路用工程方式跑通。这篇笔记就按这个链路拆:技术栈怎么选、数据库怎么建、计费接口怎么写、联调时哪些坑会让你翻车,最后给一套答辩前能用的回归验证方法。适合正在做能耗类毕设的学生,也适合刚接手企业能耗后台、需要快速理解业务模型的初级后端。
2. 先把后端骨架立起来:Java Spring Boot 还是 Python FastAPI
2.1 为什么 Spring Boot 是这类毕设的默认答案
智慧能耗管理系统的后端,绝大多数毕设会选 Java + Spring Boot + MyBatis-Plus + MySQL,少数小组会用 Python FastAPI。我的建议是:除非你是 Python 栈特别熟,否则闭眼选 Spring Boot。理由不是 Java 更高级,而是这类系统的核心工作量在「定时任务 + 报表统计 + 权限控制」,Spring Boot 在这三件事上的生态成熟度远高于 FastAPI——你要的定时调度有 @Scheduled,报表聚合有 MyBatis-Plus 的 QueryWrapper 和 XML 里的复杂 SQL,权限有 Sa-Token 或 Shiro,每一个坑都有人在前面踩平了。FastAPI 的优势在异步 IO 和快速写接口,但能耗数据是典型的高频写入(每块表几分钟一条读数)、低频复杂查询场景,异步带来的收益被数据库瓶颈抵消大半。而且答辩老师看到 Spring Boot 的项目结构,默认你是按企业主流栈做的,问问题的起点会友好很多。
2.2 最小可运行骨架:依赖、配置与目录结构
解压后的后端工程,你首先要确认是不是标准的 Maven 结构。常见做法是父 pom 管理版本,子模块分 controller、service、mapper、entity、config。先看最小依赖集:
<dependencies> <!-- Web 层 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- ORM 框架 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 定时任务 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies>这套依赖里,web 层负责暴露 REST 接口给前端大屏和后台管理页面;MyBatis-Plus 让你省掉大量单表 CRUD 的 XML;mysql-connector-java 是驱动;最后那个 spring-boot-starter 是为了用 @Scheduled 做定时采集和定时结算。注意我没放 Lombok——毕设项目里因为注解版本不匹配导致编译失败的例子太多了,写 getter/setter 虽然啰嗦,但不会在答辩前一天翻车。
对应的 application.yml 核心配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/energy_mgmt?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: root jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl关键参数只有两个:第一,url 里的 serverTimezone 必须写成 Asia/Shanghai,写成 UTC 会导致数据库时间和你本地时间差 8 小时,前端大屏上的曲线图全部偏移动,这是能耗系统里最隐蔽的玄学问题;第二,mybatis-plus 的 map-underscore-to-camel-case 必须开,因为数据库字段是 create_time,Java 属性是 createTime,不开这个映射,所有查询结果的时间字段全是 null。
2.3 前后端分离下的接口风格与跨域预备
既然是前后端分离,后端不要写页面,只出 JSON。接口路径按资源命名:/api/device、/api/reading、/api/billing、/api/statistics。统一返回体是这类项目的标配,我一般写一个 Result ,里面放 code、message、data 三个字段。跨域问题不用等前端来报,在 config 里提前写好 CorsFilter,允许本地开发端口(常见前端是 8081 或 5173)访问。别用 @CrossOrigin 一个个加在 Controller 上——漏一个就报一次跨域,前端同学会认为你后端水平不行。这块属于后端开发实战里最容易被低估的一环,但放到能耗系统里,前端要跨域请求的接口有十几个,集中配置是唯一不给自己埋雷的做法。
3. 能耗系统的数据模型是命根子:三张核心表与维度设计
3.1 设备、读数、费率:先分清哪些表是主数据,哪些是流水
能耗管理系统和普通 CRUD 项目最大的区别在于:它的核心是「流水表」,不是「台账表」。设备表是台账,几十条、几百条,变化极少;电表读数表是流水,一块表一天 288 条(5 分钟粒度),一个月就是 8640 条,一个车间十块表就是 8 万条。设计表结构时,脑子里必须绷着「这张表会涨到多大」这根弦。标准的三张核心表是:device_info(设备/电表台账)、meter_reading(原始读数流水)、billing_record(结算结果流水)。设备表和费率表是主数据,读数表和账单表是流水数据,这个区分决定了你建索引和写 SQL 的方式——流水表绝不能全表扫。
-- 设备台账:每个智能电表一条 CREATE TABLE device_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, device_code VARCHAR(32) UNIQUE COMMENT '电表编号,如 MTR-2024-001', device_name VARCHAR(64) COMMENT '设备名称', location VARCHAR(128) COMMENT '安装位置,车间/楼层/房间', rated_power DECIMAL(10,2) COMMENT '额定功率(kW)', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_location (location) ) COMMENT '能耗设备台账'; -- 原始读数流水:高频写入,按设备+时间建唯一索引 CREATE TABLE meter_reading ( id BIGINT AUTO_INCREMENT PRIMARY KEY, device_id BIGINT NOT NULL, reading_value DECIMAL(12,2) COMMENT '累计电能(kWh)', reading_time DATETIME NOT NULL COMMENT '采集时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE INDEX uk_device_time (device_id, reading_time), INDEX idx_reading_time (reading_time) ) COMMENT '电表读数流水'; -- 费率配置:区分峰平谷与尖峰 CREATE TABLE tariff_config ( id BIGINT AUTO_INCREMENT PRIMARY KEY, tariff_type VARCHAR(16) COMMENT 'peak/flat/valley/sharp', tariff_name VARCHAR(32), unit_price DECIMAL(8,4) COMMENT '单价(元/kWh)', start_time TIME NOT NULL COMMENT '时段起点,如 08:00', end_time TIME NOT NULL COMMENT '时段终点,如 11:00', effective_date DATE COMMENT '生效日期', UNIQUE INDEX uk_tariff_period (tariff_type, effective_date) ) COMMENT '分时电价费率';meter_reading 表上的联合唯一索引 uk_device_time 是这张表最重要的设计:它保证了同一块表同一时刻只有一条读数,重复推送时用 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE 就能幂等地去重。网关设备断网重连后经常重推过去几小时的数据包,没有这个索引,重推一次就涨一份脏数据。tariff_config 表按 effective_date 区分版本,是为了支持电价调整——如果你的毕设场景里不需要历史费率追溯,可以去掉 effective_date,但保留 start_time/end_time 按天判断。
3.2 读数表要存累计值还是增量值:一个影响后续所有 SQL 的决定
这是能耗管理系统里最需要想清楚的一个点。智能电表上报的原始数据是「累计电能」,就是这块表从装表到现在一共走了多少度电。如果你把累计值直接存库,那么要算某段时间的用电量,就得用「期末读数 - 期初读数」,SQL 里需要查两条记录做减法;如果你在入库时就把增量算好(本次读数 - 上次读数),查询会简单,但网关重推数据时增量会全部错位。我的做法是:meter_reading 表存累计值,增量和计费都在 service 层算,这样数据永远可回溯——任何时候发现某段时间算错了,重新跑一遍计算逻辑就能修复,不需要找网关要历史数据。这个决策对应到工程上就是一句话:数据库只存事实,计算结果都派生。
3.3 统计维度表:按部门/区域聚合时省去前端大量计算
除了三张核心表,这类项目一般还会有一张维度表 energy_dimension,把设备归属到车间、楼栋、产线等层级。前端大屏要的「一楼能耗占比」「A 车间今日用电」这些指标,如果维度信息只埋在 device_info 的 location 字段里,后端聚合时就得 LIKE 匹配,性能差且容易误配。正规做法是建一张独立的区域层级表,设备表里用 region_id 外键关联。毕设不一定要做到这么重,但你至少要在 device_info 里把 location 字段按固定格式维护,比如「1号楼/A车间/3号产线」,这样后端用 LEFT 前缀匹配就能稳定聚合,不用每次和前端对口径。
4. 核心接口这样写:抄表入库、分时计费与趋势统计的后端实现
4.1 抄表入库接口:幂等去重与批量写入
网关推送读数的接口是整个系统的入口,它的质量直接决定数据能不能用。接口设计上要接收一个设备编码 + 一组读数序列,不能是一块表一条地推送,否则高并发时数据库连接会被打满。批量接口的核心逻辑是「先查设备 ID,再批量插入,靠唯一索引去重」:
@PostMapping("/api/reading/batch") public Result<?> batchReadings(@RequestBody BatchReadingRequest request) { // 1. 按 deviceCode 查出设备 ID,查不到说明网关推了未注册的设备 DeviceInfo device = deviceMapper.selectOne( new QueryWrapper<DeviceInfo>().eq("device_code", request.getDeviceCode())); if (device == null) { return Result.error("设备未注册: " + request.getDeviceCode()); } // 2. 把请求里的读数序列转成实体,补上 device_id List<MeterReading> readings = request.getItems().stream().map(item -> { MeterReading reading = new MeterReading(); reading.setDeviceId(device.getId()); reading.setReadingValue(item.getReadingValue()); reading.setReadingTime(item.getReadingTime()); return reading; }).collect(Collectors.toList()); // 3. 批量插入,uk_device_time 会拦截重复数据 readingMapper.insertBatchSomeColumn(readings); return Result.success(readings.size()); }这段逻辑的关键在第 3 步:insertBatchSomeColumn 是 MyBatis-Plus 内置的批量插入方法,性能比循环单条 insert 高一个数量级。能这样批量插的前提是表上有 uk_device_time 唯一索引——MySQL 在遇到重复键时默认报错,所以要么用insert ignore变体,要么捕获 DuplicateKeyException 后忽略。我一般在批量插入前先按 (deviceId, readingTime) 查一遍已有最大时间,只插入比它新的数据,从源头减少冲突;对网关重推的旧数据,捕获到重复键异常直接跳过,不影响本次请求的其他数据。reading_time字段的格式必须和前端约定死——统一用yyyy-MM-dd HH:mm:ss,如果前端传来的是时间戳,后端要负责转成标准格式再入库,绝不能让数据库隐式转换。
4.2 分时计费:峰平谷时段判定与跨天处理
分时计费是能耗管理系统里最有技术含量的一块。规则是按 tariff_config 表里配置的峰平谷时段,把一段时间的用电量拆到不同费率下计算。核心难点有两个:时段判定和跨天切分。先看时段判定的实现:
private List<BillingItem> calcBilling(MeterReading prev, MeterReading curr, List<TariffConfig> tariffs) { List<BillingItem> items = new ArrayList<>(); // 本次用电量 = 期末累计值 - 期初累计值 BigDecimal totalEnergy = curr.getReadingValue().subtract(prev.getReadingValue()); if (totalEnergy.compareTo(BigDecimal.ZERO) <= 0) { return items; // 读数回退或未走字,跳过 } // 把两个读数之间的时间段按小时切块,逐小时判断属于哪个费率时段 LocalDateTime start = prev.getReadingTime(); LocalDateTime end = curr.getReadingTime(); Map<String, BigDecimal> energyByTariff = new HashMap<>(); // 按整点切分,每个切片独立匹配费率时段 while (start.isBefore(end)) { LocalDateTime sliceEnd = start.plusHours(1).isAfter(end) ? end : start.plusHours(1); long sliceMinutes = Duration.between(start, sliceEnd).toMinutes(); if (sliceMinutes <= 0) break; String tariffType = matchTariff(start.toLocalTime(), tariffs); BigDecimal sliceEnergy = totalEnergy .multiply(BigDecimal.valueOf(sliceMinutes)) .divide(BigDecimal.valueOf(Duration.between(prev.getReadingTime(), curr.getReadingTime()).toMinutes()), 4, RoundingMode.HALF_UP); energyByTariff.merge(tariffType, sliceEnergy, BigDecimal::add); start = sliceEnd; } // 按费率算出电费并组装明细 // ... 省略 return items; }这个「按时间比例分摊」的算法做了一个关键假设:在两次读数之间,用电功率是均匀的。实际工厂里这个假设不成立——一台机器启动瞬间功率是稳态的几倍——但对毕设和课程设计来说,这是行业里可接受的口径,答辩时把这个假设主动说出来反而是加分项。真正容易出错的是跨天:如果上一次读数是昨晚 23:50,下一次是今天 00:10,这段用电跨越了峰时段和谷时段,代码里的while (start.isBefore(end))循环会天然把这段切成 23:50-00:00 和 00:00-00:10 两个切片,分别匹配前一天的费率和后一天的费率。这也是我把effective_date放进费率表的原因:匹配费率时要同时带上日期判断,否则周末和非周末的峰谷时段不同(部分地区周末全段按谷段计费)时,账会算错。
4.3 趋势统计接口:环比同比不写死 SQL,用扩展接口聚合
前端大屏最常要的接口是「今日用电曲线」「本月与上月对比」「去年同月对比」。这些统计接口如果一个个在 Controller 里硬写 SQL,半个月后加一个新维度就得改后端代码。我习惯把这层做成一个通用的能耗统计接口,由前端传聚合维度(day / month / year)和时间范围,后端动态拼 SQL:
@GetMapping("/api/statistics/trend") public Result<?> trend(@RequestParam String granularity, @RequestParam String start, @RequestParam String end) { // granularity: day / month / year // start/end: 形如 2024-01-01 List<Map<String, Object>> result = readingMapper.selectTrend(granularity, start, end); return Result.success(result); }<select id="selectTrend" resultType="java.util.Map"> SELECT DATE_FORMAT(reading_time, #{granularity}) AS period, MAX(reading_value) - MIN(reading_value) AS energy FROM meter_reading WHERE reading_time BETWEEN #{start} AND #{end} GROUP BY period ORDER BY period </select>这段 SQL 的关键是MAX(reading_value) - MIN(reading_value):因为存的是累计值,一段时间内的用电量等于期末最大累计值减期初最小累计值。按天聚合时用电量就是当天 23:59 的累计值减去 00:00 的累计值,这就是日用电量。要注意 DATE_FORMAT 的粒度参数依赖读数的采集频率:如果电表是每 5 分钟一条,按小时聚合没问题;如果采集频率不固定(有的设备断了几个小时),按太细的粒度聚合会出现负值或跳变,这时就要在 service 层做插值。这个统计接口是我见过的毕设里最容易出 bug 的地方,主要是前端传的 granularity 是字符串,直接拼进 DATE_FORMAT 有 SQL 注入风险,必须在后端做白名单校验,只允许 day、month、year 三个值,其他一律拒绝。
5. 后端联调与交付避坑:跨域、时区、精度与演示数据的 5 个血泪经验
5.1 前后端跨域:CORS 配置漏一个接口就联调卡一天
现象:前端页面在 8081 端口跑,后端在 8080,所有接口用 Postman 测试都正常,但页面上 axios 请求全部报CORS policy: No 'Access-Control-Allow-Origin'或者干脆只报net::ERR_FAILED。原因:浏览器安全机制拦截跨域请求,Postman 没有这个机制所以测不出来。解决:在后端写一个全局 CorsFilter,不要用 @CrossOrigin 注解一个个加。常见做法是注册一个 CorsFilter Bean,允许指定来源(本地开发放行 localhost 的常用端口,非对称的*在生产环境不要放开),允许 GET/POST/PUT/DELETE 和 OPTIONS 预检请求。能耗系统这种接口数量多的项目,集中配置一次是唯一不会漏的方案。我曾经因为漏了/api/statistics/**这个路径前缀,前端大屏的曲线图整整白屏了一天,最后是看浏览器 Network 面板发现只有那一个请求被预检拦截。
5.2 时间精度:数据库秒级,JSON 毫秒级,前端再转一次就错位
现象:前端表格里显示的抄表时间是2024-06-01 08:00,但柱状图里这根柱子被画到了2024-06-01 07:00,所有数据都向前偏了一格。原因:数据库 DATETIME 是秒级精度,后端用 Jackson 序列化时如果没配格式,默认输出的是2024-06-01T08:00:00.000+08:00这种带毫秒和时区的格式,前端拿到后直接new Date(str)再做本地化,设备所在地域时区一变就错位。解决:在 application.yml 里配死 Jackson 的 date-format 为yyyy-MM-dd HH:mm:ss,同时 time-zone 指定Asia/Shanghai,让后端吐出去的时间就是纯字符串,前端不做任何转换直接展示。这一点既是配置问题也是约定问题:后端接口文档里要写明所有时间字段统一使用该格式,前端拿到后禁止toLocaleString()。
5.3 数据精度:用 BigDecimal 而不是 double 算电费
现象:一笔用电量 123.45 度,单价 0.8253 元/度,算出来的电费是101.87999999999999元,前端展示时四舍五入成 101.88 没问题,但后端的账单表里存的就是那个 16 位小数。原因:double 的二进制浮点表示无法精确表达 0.8253 这类十进制小数。解决:所有涉及金额和电量的字段一律用 DECIMAL 类型,Java 侧用 BigDecimal,除法时指定精度和舍入模式。具体到分时计费那段代码,divide方法必须传MathContext或RoundingMode.HALF_UP,否则在 BigDecimal 除不尽时会抛ArithmeticException: Non-terminating decimal expansion——这是计费接口最容易在运行时翻车的异常。
5.4 演示数据:手工造的数据曲线太完美,答辩时一眼假
现象:系统里全是模拟数据,每天的用电量几乎完全相同,曲线是一条直线。原因:很多人用 SQL 的RAND()或者脚本循环生成随机数,但没做工作日/周末差异和时段波动,导致数据没有任何特征。解决:生成演示数据时要模拟真实负载的特征,比如:白天 8:00-18:00 用电量高且波动大(幅度 ±15%),夜间低且平稳(幅度 ±3%),周末整体比工作日低 30%;再叠加一个缓慢的长期趋势(每月增长 2%)。这类带特征的数据才经得起老师追问「为什么这里有个峰」「为什么周末降下来了」。具体做法可以写一段 Java 或 Python 脚本,基于基准功率按小时系数生成读数序列,再反推出累计值,保证读数单调递增。这个脚本属于项目的一部分,可以放进 doc 目录,答辩时说是「模拟网关数据生成器」,反而是展示工程能力的亮点。
5.5 分页与性能:MyBatis-Plus 分页插件查大表越来越慢
现象:meter_reading 表到 10 万条时,前端翻到第 100 页,接口响应 3 秒以上。原因:分页插件跑COUNT(*)时对全表扫描,且ORDER BY reading_time没走索引。解决:第一,确认 uk_device_time 索引存在,并且查询条件里带上 device_id 让索引生效;第二,把optimizeCountSql打开,让分页插件用SELECT COUNT(*) FROM (子查询)的优化模式;第三,和产品经理确认业务上是否需要跳到任意页——能耗系统的列表查询一般只看最近几页,超过一定深度的翻页可以用「只查上一页下一项」的键集分页替代。毕设里做到前两点就足够,但答辩时能说出第三点说明你真想过生产环境的问题。
6. 让答辩评委信服的验证技巧:用接口脚本做一轮可复现回归
这一章分享一个我自己每次做完整后端项目都会做的收尾动作:把核心链路写成一串可重复执行的脚本,而不是靠 Postman 手工点。手工点的问题是答辩现场网络不稳定、数据库状态被前一轮演示改过、某个参数记错了,翻车概率很高。脚本验证的另一个好处是能证明你的接口是「设计过的」而不是「调通的」。
具体做法分三步。第一步,准备一个 clean 状态的数据库:写一个reset.sql,删掉所有业务表数据、重置自增 ID、插入固定的设备台账和费率配置。第二步,写一个 Python 脚本顺序调用抄表接口、计费接口、统计接口,每个接口的请求参数和响应断言写在脚本里:
import requests import json BASE = "http://localhost:8080" def test_batch_reading(): payload = { "deviceCode": "MTR-2024-001", "items": [ {"readingValue": 0.0, "readingTime": "2024-06-01 00:00:00"}, {"readingValue": 10.5, "readingTime": "2024-06-01 00:05:00"} ] } r = requests.post(f"{BASE}/api/reading/batch", json=payload) assert r.json()["code"] == 0, r.text print("[PASS] batch reading") def test_billing(): r = requests.get(f"{BASE}/api/billing/calc?deviceCode=MTR-2024-001&start=2024-06-01&end=2024-06-01") data = r.json() assert data["code"] == 0 assert float(data["data"]["totalEnergy"]) == 10.5 assert data["data"]["totalFee"] == "8.66" print("[PASS] billing calc")第三步,把reset.sql和 Python 脚本写进一个 README,说明「任何时候想重新验证系统,按顺序执行这两步」。这比截图和录屏可靠得多——评委现场问「再跑一遍看看」,你不需要紧张地回忆操作步骤,跑脚本就行。
我自己的习惯是:每个核心接口都留一个最小请求样例和期望返回值,放在docs/api-samples/目录下。这不仅是给评委看的,也是给自己留的后悔药——两个月后想改计费规则,跑一遍回归就知道改动有没有破坏原有逻辑。希望这个技巧帮你在答辩时少一点紧张,多一点底气。
本文还有配套的精品资源,点击获取