SpringBoot电费管理系统:抄表计费缴费闭环与毕设实践 简介面向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.xmlparent 一般挂在 SpringBoot 2.7.x 这一档JDK 8这是国内毕设包最常见也最稳的组合JDK 17 上跑会遇到一些老依赖的反射告警。核心依赖就那么几个spring-boot-starter-web 负责 MVC 和内置 Tomcatmybatis-plus-boot-starter 负责持久层并自带 CRUD 和分页mysql-connector-java 连库lombok 省 getter/setter剩下的多半是 hutool 之类的工具包和前端静态资源。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.6/version /parent dependencies !-- Web MVC 内置 Tomcat页面和接口都靠它 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus省掉大量单表 XML分页插件也在里面 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.2/version /dependency !-- MySQL 驱动8.x 版本要配 com.mysql.cj.jdbc.Driver -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.30/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies看到 lombok 和 MyBatis-Plus 这两个依赖基本可以判断代码风格实体类上挂DataService 层继承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 升序排列 ListPriceTier 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天然防止重复缴费 LambdaUpdateWrapperBill 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?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8useSSLfalse username: root password: 你的密码 thymeleaf: cache: false # 开发期关缓存改页面不用重启serverTimezone不写会报时区错误MySQL 8 上尤其明显。useSSLfalse是本地开发省掉证书告警。数据库密码里如果带或#要么用引号包起来要么改密码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 后面补serverTimezoneAsia/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 ...; } }再写一个工厂构造时把ListFeeStrategy注入进来转成 Mapget(code)拿实现。这样加一种计费方式只需要新增一个类主流程一行不改。改造之后的验证方法很土但有效在t_price_config里造三组数据分别对应纯单价、两档阶梯、超最高档用固定读数跑单元测试断言金额等于手算结果。我一般会把这组用例留在src/test里以后谁改计费都得先过这一关。另一个值得加的导出功能用 EasyExcel 把t_bill按账期导成表格列固定为房号、户名、上期读数、本期读数、用电量、金额、状态。注意导出时金额字段传 BigDecimal 并指定格式直接传 double 会看到科学计数法。从那以后我每次接手这类压缩包第一件事不是点运行而是先比对 sql 文件里的库名、表名和 application.yml再把静态资源路径核一遍最后才启动。这个顺序帮我省掉了大量“明明代码没问题却跑不起来”的时间。希望帮到你。本文还有配套的精品资源点击获取