☰
SpringBoot电费管理系统:抄表计费缴费闭环与毕设实践
2026/10/29 2:35:27 网站建设 项目流程

简介:面向Java毕业设计与课程设计场景的电费管理系统项目源码,适合计算机专业学生及Java初学者参考。项目围绕小区或企业电费业务,覆盖用户注册登录与权限管理、阶梯与峰谷电价计算、在线缴费与账单查询、月度汇总和欠费统计报表、短信邮件催缴提醒等模块,采用Spring Boot、MyBatis、MySQL与Spring Security,按MVC组织业务逻辑与页面交互。压缩包共158个文件、约1.78MB,以52个Java源码和24个JS、19个HTML、14个CSS前端资源为主,另含XML配置、properties参数及少量字体、图片和构建脚本,便于分层查阅。已有117人学习下载,可参考其项目结构、计费算法、JWT鉴权、支付接入、POI报表与定时提醒等实现,用于课程设计搭建、功能扩展和答辩准备。

1. 电费管理系统能干什么:一份 Java 毕设包里的真实业务闭环

某园区后勤的老电工,每个月末拿着手抄本挨个楼层看电表,回办公室用 Excel 算电费,微信里挨个催缴,月底一对账总差那么几百块。这套 Java 毕业设计电费管理系统解决的正是这条链路:电表档案维护、每月抄表录入、按单价或阶梯规则自动算出应收金额、生成账单、登记缴费、统计欠费与用电趋势。它是个典型的 SpringBoot + MyBatis + MySQL 单体应用,页面走服务端渲染加静态资源,压缩包解开、建好库、改一行数据库密码就能在本地跑起来。适合两类人:拿它当毕设底子、需要一套能演示完整业务流程的骨架的学生;以及接手小型园区、宿舍、商铺电费台账,想要一个比自己写的 Excel 宏更靠谱的工具的开发者。

2. 技术栈与目录结构:一份 SpringBoot + MyBatis 的典型分层

先把包拆开看骨架,再谈业务。这类毕设包的工程结构高度趋同,认清了分层,后面改哪个功能就知道往哪个包里钻,不用满工程搜关键字。

2.1 从 pom.xml 反推技术选型

打开 pom.xml,parent 一般挂在 SpringBoot 2.7.x 这一档,JDK 8,这是国内毕设包最常见也最稳的组合,JDK 17 上跑会遇到一些老依赖的反射告警。核心依赖就那么几个:spring-boot-starter-web 负责 MVC 和内置 Tomcat,mybatis-plus-boot-starter 负责持久层并自带 CRUD 和分页,mysql-connector-java 连库,lombok 省 getter/setter,剩下的多半是 hutool 之类的工具包和前端静态资源。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.6</version> </parent> <dependencies> <!-- Web MVC + 内置 Tomcat,页面和接口都靠它 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus:省掉大量单表 XML,分页插件也在里面 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency> <!-- MySQL 驱动,8.x 版本要配 com.mysql.cj.jdbc.Driver --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

看到 lombok 和 MyBatis-Plus 这两个依赖,基本可以判断代码风格:实体类上挂@Data,Service 层继承ServiceImpl,单表查询直接用LambdaQueryWrapper拼条件,只有涉及多表关联的报表才写 XML。这个判断很重要,它决定了你后面加功能时是补一个 Mapper 方法还是补一段 XML——单表加字段改实体就行,跨表统计才需要动 resources/mapper 下的文件。

选型上没有 Redis、没有消息队列、没有前后端分离的 token 机制,会话靠 HttpSession。这不是偷懒,对电费管理这种日活几十人的内部系统来说,Session 足够,还省掉了跨域和 token 续期的麻烦。翻车点在于:如果你后面想改成前后端分离,登录这块是要重写的,别指望在拦截器上打个补丁就完事。

2.2 目录树里每个包负责什么

主类一般放在com.xxx.electric下,同级平铺 controller、service、mapper、entity、config、common 几个包。前后端不分离的包还会多一个 resources/static 放 layui 或 bootstrap 的静态文件,resources/templates 放 Thymeleaf 页面。

src/main/java/com/example/electric ├── ElectricApplication.java # 启动类,@MapperScan 在这里 ├── config │ ├── MybatisPlusConfig.java # 分页插件、自动填充 │ └── WebMvcConfig.java # 注册登录拦截器、静态资源放行 ├── controller │ ├── LoginController.java │ ├── MeterController.java # 电表档案 │ ├── ReadingController.java # 抄表录入 │ ├── BillController.java # 账单与缴费 │ └── StatController.java # 统计报表 ├── service │ └── impl # 业务实现,计费逻辑在这里 ├── mapper # 继承 BaseMapper 的接口 ├── entity # 与表一一对应的实体 └── common ├── Result.java # 统一返回体 └── BizException.java # 业务异常 src/main/resources ├── application.yml ├── mapper # 只有复杂 SQL 才放这里 ├── static # layui/css/js └── templates # 页面

这个结构里最值得关注的是common包和config包。前者决定了接口返回格式是否统一,后者决定了权限和分页能不能正常工作。我见过不少改崩的案例,都是把WebMvcConfig里的静态资源放行路径改没了,结果页面样式全丢、登录页白屏,还以为是前端文件损坏。

2.3 数据表设计与实体映射

电费系统的表不多,但关系必须理清,否则算出来的钱是错的。核心是五张表:用户表、房间(或表位)表、电表表、抄表记录表、账单表,外加一张电价配置表。

表名关键字段作用
t_userid, username, password, role登录与角色,role 区分管理员、抄表员、住户
t_roomid, room_no, owner_name, phone房间/表位档案,账单归属对象
t_meterid, meter_no, room_id, status电表,一个房间可挂多块表
t_readingid, meter_id, last_reading, current_reading, read_time每期抄表记录,用电量由此算出
t_billid, room_id, period, usage_amount, fee, status账单,status 表示未缴/已缴
t_price_configid, tier_start, tier_end, price阶梯电价区间与单价

实体类对应关系很直白,注意金额字段一律用 BigDecimal 而不是 Double,这一点后面在计费那节还会展开。实体上标注表名和主键策略:

@Data @TableName("t_bill") public class Bill { @TableId(type = IdType.AUTO) private Long id; private Long roomId; private String period; // 账期,格式 2024-06 private BigDecimal usageAmount; // 用电量 kWh private BigDecimal fee; // 应收金额 private Integer status; // 0 未缴 1 已缴 private LocalDateTime createTime; }

@TableName别写错,MyBatis-Plus 默认把类名转下划线去匹配表,Bill会去找bill表,跟t_bill对不上就报“表不存在”。另外period用字符串存账期比用日期好排查,按月统计时直接group by period,不用做日期函数截取,这是个很实用的小习惯。

3. 核心业务链路:抄表、计费、缴费怎么串起来

业务链路只有三步,但每一步都有校验和状态要维护。这段是整个项目最值得逐行读的部分,改需求十有八九动的是这里。

3.1 抄表录入:从电表读数到本周期用电量

抄表不是简单存两个数。要点有三:上期读数必须从该电表最近一条记录里取,不能信前端传的;本期读数必须大于等于上期,否则出现负电量;用电量算完之后要立刻落账单,不能等到查询时现算,否则电价一改历史账单全变。

@Transactional(rollbackFor = Exception.class) public void saveReading(ReadingForm form) { // 1. 电表必须存在且处于启用状态 Meter meter = meterMapper.selectById(form.getMeterId()); if (meter == null || meter.getStatus() == 0) { throw new BizException("电表不存在或已停用"); } // 2. 上期读数从库里取最近一条,不信任前端传值 BigDecimal last = readingMapper.selectLastReading(form.getMeterId()); if (last == null) { last = BigDecimal.ZERO; } // 3. 本期读数不能小于上期,这是最常见的脏数据来源 if (form.getCurrentReading().compareTo(last) < 0) { throw new BizException("本期读数不能小于上期读数 " + last); } BigDecimal usage = form.getCurrentReading().subtract(last); // 4. 落抄表记录 Reading reading = new Reading(); reading.setMeterId(form.getMeterId()); reading.setLastReading(last); reading.setCurrentReading(form.getCurrentReading()); reading.setUsageAmount(usage); reading.setReadTime(LocalDateTime.now()); readingMapper.insert(reading); // 5. 同事务内生成账单,计费规则见下一节 billService.createBill(meter.getRoomId(), usage); }

参数上form.getCurrentReading()是前端输入的本次表盘读数,单位 kWh,允许两位小数。selectLastReading用order by read_time desc limit 1实现,一定要按时间倒序,不能按 id 倒序——如果补录历史数据,id 顺序和时间顺序是反的,这是血泪经验。整个方法加了@Transactional,因为抄表记录和账单必须同生共死,否则会出现有读数没账单、月底对不上的情况。

3.2 计费:单价与阶梯两种算法怎么落地

计费是这类项目里最容易写错的地方。单价的版本就是用电量乘单价,一行搞定;阶梯的版本要按区间拆分,还要处理超出最高档的部分。多数包默认写死单价,少数实现阶梯。不管你手上这份是哪种,都建议把算法收敛到一个方法里,别散落在 Controller。

public BigDecimal calcFee(BigDecimal usage) { // 用电量为空或非正,直接返回 0,避免负电量算出负电费 if (usage == null || usage.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); } // 阶梯配置从 t_price_config 读出,已按 tier_start 升序排列 List<PriceTier> tiers = priceTierMapper.selectOrderByStart(); BigDecimal fee = BigDecimal.ZERO; BigDecimal remain = usage; for (PriceTier tier : tiers) { if (remain.compareTo(BigDecimal.ZERO) <= 0) { break; } // 本档可容纳电量 = 结束 - 开始 BigDecimal span = tier.getTierEnd().subtract(tier.getTierStart()); // 实际落入本档的电量取剩余量与档位容量的较小值 BigDecimal inTier = remain.min(span); fee = fee.add(inTier.multiply(tier.getPrice())); remain = remain.subtract(inTier); } // 超出最高档的部分,按最高档单价继续计 if (remain.compareTo(BigDecimal.ZERO) > 0) { fee = fee.add(remain.multiply(tiers.get(tiers.size() - 1).getPrice())); } // 统一保留两位,四舍五入 return fee.setScale(2, RoundingMode.HALF_UP); }

关键参数说明:tier.getTierEnd()和tier.getTierStart()是档位的上下限,单位和用电量一致;tier.getPrice()是该档单价。remain.min(span)这一步是核心,它保证每一档只吃自己容量内的电量,比如 180 到 400 这一档,容量是 220,剩余 300 度时只能吃 220,剩下 80 度流到下一档。最后一步兜底不能省,很多人只写循环不写兜底,用户用电超过最高档上限时,超出部分直接不计费,金额少算一截,测试数据小的时候看不出来,真实数据一跑就露馅。

3.3 缴费与账单状态:一个订单状态机的最小实现

账单状态只有未缴和已缴两种,但更新时必须做幂等,否则用户点两次“缴费”就记两笔。最小实现是条件更新:只在当前状态为未缴时才更新,影响行数为 0 就说明已经被处理过。

public boolean pay(Long billId) { Bill bill = billMapper.selectById(billId); if (bill == null) { throw new BizException("账单不存在"); } // 条件更新:status = 0 才允许改为 1,天然防止重复缴费 LambdaUpdateWrapper<Bill> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(Bill::getId, billId) .eq(Bill::getStatus, 0) .set(Bill::getStatus, 1) .set(Bill::getPayTime, LocalDateTime.now()); int rows = billMapper.update(null, wrapper); if (rows == 0) { throw new BizException("该账单已缴费,请勿重复操作"); } // 缴费流水单独落表,便于对账 paymentMapper.insert(new Payment(billId, bill.getFee(), LocalDateTime.now())); return true; }

eq(Bill::getStatus, 0)这个条件就是并发下的锁,不需要额外的分布式锁。update返回的rows是判断依据,别写成select判断再update,两步之间有空窗,并发时会漏。状态流转建议在数据库注释里写清楚:0 未缴、1 已缴、2 已作废,作废状态给管理员用,住户端看不到这个按钮。

4. 部署与权限:把系统跑通前必须处理的三件事

代码看懂之后就要动手跑。这一步卡住的人最多,配置项、建库脚本、拦截器三处任何一处不对,表现都是“页面打不开”,信息量很低。

4.1 数据库配置与建库脚本

压缩包里一般带一个xxx.sql,在 resources 或项目根目录。先建库再导数据,字符集选 utf8mb4,排序规则用 utf8mb4_general_ci,别用默认的 latin1,中文户名会变问号。

# 建库并导入,注意库名要和 application.yml 里的 url 一致 mysql -uroot -p -e "CREATE DATABASE electric_fee DEFAULT CHARSET utf8mb4;" mysql -uroot -p electric_fee < sql/electric_fee.sql

配置项里最常改的是这四行:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/electric_fee?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false username: root password: 你的密码 thymeleaf: cache: false # 开发期关缓存,改页面不用重启

serverTimezone不写会报时区错误,MySQL 8 上尤其明显。useSSL=false是本地开发省掉证书告警。数据库密码里如果带@或#,要么用引号包起来,要么改密码,YAML 会把特殊字符当语法。

4.2 登录拦截器与角色数据隔离

登录走 Session,拦截器统一拦截后台路径。写的时候最容易犯的错是把静态资源也拦了,导致登录页没样式。

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从 Session 取登录用户,没有就回登录页 Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }

注册时一定要excludePathPatterns放行登录接口和静态资源,路径分别是/login、/static/**、/css/**、/js/**、/layui/**。少写一个,页面就是白板。角色隔离在拦截器里做第二层判断:从 Session 拿 role,住户访问/admin/**直接打回;抄表员只能进抄表页面,账单管理页面对他不可见。更严的做法是在 Service 层再按room_id过滤一次,防止住户改 URL 里的 id 看到别人家的账单,这个漏洞在毕设包里非常普遍。

4.3 启动与首页验证

启动类跑起来之后,别急着点功能,按顺序验证四件事:控制台有没有打印 Tomcat started on port;浏览器访问http://localhost:8080能不能看到登录页且样式正常;用脚本里预置的账号能不能登进去;进去之后左侧菜单是否按角色渲染。这四步全过,说明配置和权限没问题,再测抄表和计费。

初始化数据通常自带一个管理员账号,密码在 sql 文件里是明文或者 MD5,登不进去就用select * from t_user看一眼,实在不行手动插一条。这一步别嫌土,比重装三遍环境快。

5. 常见问题排查:五个让我反复翻车的坑

下面这几条都是这类包在真实环境里高频出现的,现象看着五花八门,根因往往就一行配置或一个类型。

5.1 启动与环境类

坑一:启动报“Communications link failure”。现象是应用起不来,控制台刷一屏连接异常。原因是数据库没启动、端口不是 3306、或者 url 里的库名和实际建的库名差一个字母。解决顺序是先mysql -uroot -p手动连一次,确认库存在,再核对 application.yml 里的 url、用户名、密码三处,最后看 MySQL 是否只监听了 localhost 而你写的是 127.0.0.1 之外的地址。

坑二:页面能开但样式全丢,登录框挤在左上角。现象是 HTML 出来了,CSS 和 JS 全是 404。原因基本是拦截器把静态资源也拦了,或者静态文件放的目录不是resources/static。解决是把addPathPatterns("/**")配合excludePathPatterns把静态目录全部放行,同时确认浏览器 F12 里 404 的路径,按路径反推文件应该放哪。

坑三:MySQL 8 启动报驱动类找不到。现象是ClassNotFoundException: com.mysql.jdbc.Driver。原因是老配置沿用了 5.x 的驱动类名,而依赖是 8.x。解决是把driver-class-name改成com.mysql.cj.jdbc.Driver,同时在 url 后面补serverTimezone=Asia/Shanghai,否则就算连上也会在时间字段上报错。

5.2 业务与数据类

坑四:电费金额出现 0.30000000000000004 这种小数。现象是账单金额末尾一长串,导出的 Excel 尤其难看。原因是中途用 double 参与了乘加运算。解决是全链路改成 BigDecimal,乘法之后立刻setScale(2, RoundingMode.HALF_UP),数据库对应字段用decimal(10,2)而不是double。改的时候注意BigDecimal比较大小必须用compareTo,用equals会把 1.0 和 1.00 判成不相等。

坑五:列表查出来某几个字段是 null。现象是实体里roomId有值,roomName永远是空。原因是数据库字段是room_name,实体是roomName,而 MyBatis 的下划线转驼峰没开。解决是在 application.yml 里加mybatis-plus.configuration.map-underscore-to-camel-case: true,或者干脆在实体字段上补@TableField("room_name")。顺便检查一下 SQL 里有没有起别名,别名和实体字段对不上同样映射不进去。

6. 二次开发:把写死的计费规则抽成可配置策略

毕设包最大的问题是计费写死在 Service 里,一旦物业说要改成分时电价或者商用峰谷价,就得动主流程。我的做法是先把计费抽成策略接口,用 Spring 把实现类收进 Map,调用方只认 code。

public interface FeeStrategy { String code(); // SINGLE / STEP / PEAK BigDecimal calc(BigDecimal usage, FeeContext ctx); // ctx 里带房型、时段等上下文 } @Service public class StepFeeStrategy implements FeeStrategy { @Override public String code() { return "STEP"; } @Override public BigDecimal calc(BigDecimal usage, FeeContext ctx) { // 复用上一节的阶梯算法,配置从库里读,不再写死 return ...; } }

再写一个工厂,构造时把List<FeeStrategy>注入进来转成 Map,get(code)拿实现。这样加一种计费方式只需要新增一个类,主流程一行不改。改造之后的验证方法很土但有效:在t_price_config里造三组数据,分别对应纯单价、两档阶梯、超最高档,用固定读数跑单元测试,断言金额等于手算结果。我一般会把这组用例留在src/test里,以后谁改计费都得先过这一关。

另一个值得加的导出功能,用 EasyExcel 把t_bill按账期导成表格,列固定为房号、户名、上期读数、本期读数、用电量、金额、状态。注意导出时金额字段传 BigDecimal 并指定格式,直接传 double 会看到科学计数法。

从那以后我每次接手这类压缩包,第一件事不是点运行,而是先比对 sql 文件里的库名、表名和 application.yml,再把静态资源路径核一遍,最后才启动。这个顺序帮我省掉了大量“明明代码没问题却跑不起来”的时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询