Java电费管理系统:抄表计费缴费业务闭环拆解 简介这是一份面向计算机专业毕业设计学习者与Java Web入门开发者的电费管理系统项目源码包围绕居民小区或企业电费计算、收取、统计与提醒等业务场景提供一套可拆解的完整实践方案。压缩包共158个文件约1.78MB以52个Java源文件、24个JavaScript脚本、19个HTML页面、14个CSS样式及10个XML配置为主另含字体、图片、Maven构建脚本和项目说明能较完整呈现后端接口、前端页面与资源配置。系统采用MVC思路涉及Spring Boot、MyBatis、MySQL、权限控制以及用户管理、阶梯或峰谷计费、在线缴费、报表统计和消息提醒等模块适合用来理解分层结构、数据库设计与业务逻辑衔接。已有117人学习或下载可作为毕业设计选题参考、课程项目改造或Java Web综合练习素材便于读者从源码中梳理功能边界与实现路径。1. 一个能直接答辩的电费管理系统长什么样去年帮 A 同学看他的电费管理系统界面点得动一点「生成账单」就报 500数据库里抄表记录和账单表压根对不上号。这是毕业设计项目最常见的状态页面凑齐了业务链路是断的。这份 Java 版电费管理系统把住户、电表、抄表、阶梯计价、账单、缴费这几条线真正串了起来前端是服务端渲染的管理后台后端按 Controller-Service-Mapper 分层MySQL 落数据Maven 管依赖属于那种解压、建库、改一行配置就能跑起来的类型。适合三类人赶毕业设计需要一套能讲清业务闭环的底座、刚学完 Spring Boot 想找个完整 CRUD 之外的项目练手、以及要拿它改成小型物业收费系统的人。它不炫技但每个模块都能落到表和字段上答辩时被追问「阶梯电费怎么算的」不至于卡壳。2. 技术栈与模块地图这套 Java 电费系统里到底装了什么拿到一个压缩包先别急着点启动类。我一般会花十分钟把 pom 和目录扫一遍搞清楚它用的是哪套组合、分了几层、表之间怎么连。这一步做完后面改需求时才知道该往哪个包里塞代码而不是满项目搜关键字。这套系统的骨架属于国内毕业设计里最主流的一档Spring Boot 打底MyBatis 做持久层MySQL 存业务数据页面用 Thymeleaf 模板加 Bootstrap 拼出来前端不搞前后端分离那一套省掉了跨域、打包、部署三个坑对答辩现场演示特别友好。2.1 选型逻辑为什么是 Spring Boot MyBatis 而不是 Servlet JSP常见做法是用 Spring Boot 2.x 配 JDK 8这两个版本组合在高校机房里兼容性最好不会因为 JDK 17 的模块化限制把老依赖顶掉。持久层选 MyBatis 而不是 JPA原因很实际电费系统里的查询大多是「按小区 账期 缴费状态」这种多条件拼接还要做月度用量汇总、欠费排行这类统计写原生 SQL 比硬掰 JPA 的方法名推导舒服得多答辩时也更容易讲清楚每条 SQL 在干什么。页面层用 Thymeleaf 直接渲染好处是登录、列表、表单这些页面不需要单独起一个 Node 服务一个java -jar或者 IDE 里点一下就能全站可用。代价是交互偏传统翻页、弹窗都靠页面刷新和少量 jQuery 完成。如果后期想升级成 Vue 前后端分离Service 层几乎不用动把 Controller 的返回值从ModelAndView换成ResultT就行这也是我建议保留现有分层的理由。依赖层面除了spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java通常还会带lombok省 getter/setter、hutool日期和字符串工具、poi导出 Excel 报表。看到 pom 里有poi就说明项目带了导出功能这条线索比翻代码快。2.2 目录结构照着包名就能定位要改的地方把压缩包解开src/main下大致是这么一棵树。这个结构不算精致但胜在直白每个包名对应一类职责找代码不用猜。elec-fee-system/ ├── pom.xml ├── sql/ │ ├── elec_fee.sql # 建库建表 初始数据 │ └── mock_reading.sql # 可选批量造抄表记录压测和演示用 └── src/main/ ├── java/com/example/elecfee/ │ ├── ElecFeeApplication.java # 启动类 │ ├── config/ # 登录拦截器、MyBatis 配置、全局异常 │ ├── controller/ # HouseController、MeterController、BillController... │ ├── service/ # 接口 │ ├── service/impl/ # BillServiceImpl、FeeCalcServiceImpl... │ ├── mapper/ # MyBatis 接口 │ ├── entity/ # House、Meter、Reading、Bill、Payment │ ├── dto/ # 查询条件、报表 VO │ └── common/ # 统一返回、常量、工具类 └── resources/ ├── application.yml ├── mapper/ # 与 mapper 接口同名的 XML ├── templates/ # Thymeleaf 页面 └── static/ # css、js、bootstrap看这棵树有两个信息值得注意。第一service和service/impl是分开的说明作者是按接口编程写的改造时新增实现类不影响调用方。第二resources/mapper下是 XML意味着 SQL 和 Java 代码分离调账单统计这类复杂查询时改 XML 就行不用重新编译 Java。反过来如果某个项目把所有 SQL 都写成注解塞在 Mapper 接口上稍长一点的统计语句会非常难读这是判断代码可维护性的一个快速信号。2.3 数据模型六张表撑起整条业务链电费系统的复杂度全在表关系上院校项目里常见的是下面这组核心表加两张辅助表。字段名各版本会有出入但语义基本一致。表名作用关键字段与其他表的关系t_house住户/房屋档案id、house_no、owner_name、phone、area一个住户可绑定一块或多块电表t_meter电表档案id、meter_no、house_id、install_date、status外键指向住户t_reading抄表记录id、meter_id、period、prev_kwh、curr_kwh、read_time外键指向电表账期唯一t_price_rule阶梯电价规则id、start_kwh、end_kwh、price、effective_date独立配置表被计费逻辑读取t_bill账单id、meter_id、period、usage_kwh、amount、status由抄表记录生成t_payment缴费流水id、bill_id、pay_amount、pay_type、pay_time外键指向账单t_user登录账号id、username、password、role管理员/抄表员/住户三种角色t_notice公告id、title、content、create_time首页展示可选这套模型里最关键的一条约束是t_reading上(meter_id, period)的联合唯一索引。抄表是按月来的同一块表同一个账期只能有一条记录把这个规则交给数据库而不是靠 Java 里if判断能挡掉重复提交和重复导入这两类最烦人的脏数据。同理t_bill上也应该建(meter_id, period)唯一索引账单不允许重复生成。另一个容易忽略的点是金额字段的类型。amount、pay_amount一律用decimal(10,2)Java 侧对应BigDecimal。用double存钱是毕业设计里的高频翻车点0.1 0.2 算出 0.30000000000000004 这种事在答辩投影上被看见就很难解释。3. 跑起来从建库到登录首页的最小闭环项目跑不起来后面聊什么架构都是空的。这一章按我自己的习惯顺序走先确认环境再建库导数据然后改配置、启动、走一遍主流程。每一步都给出可复制的命令和验证方法卡住了知道该看哪一行日志。3.1 环境准备三样东西版本别乱配需要 JDK 8、Maven 3.6 以上、MySQL 5.7 或 8.0。JDK 不建议上 17很多高校项目的依赖树里还挂着老版本的 MyBatis 和 Lombok直接升到高版本会遇到注解处理器不生效、javax包名找不到这类问题排查起来很浪费时间。MySQL 8 的话注意连接串要带上时区和useSSL参数否则启动时报The server time zone value ... is unrecognized。验证环境用三条命令都能输出版本号再往下走。java -version mvn -v mysql --versionMaven 第一次拉依赖会比较慢如果卡在某个仓库不动检查~/.m2/settings.xml里有没有配镜像。这一步没什么技巧但依赖下不全的话后面 IDEA 里会满屏飘红容易让人误以为是代码问题。3.2 建库导数据先跑 SQL 再改配置解压后先找sql目录里面通常是一个完整的elec_fee.sql。用命令行导入别用图形化工具一个个点出错了不好回滚。# 登录 MySQL mysql -u root -p # 建库并指定字符集utf8mb4 能存下生僻字和 emoji CREATE DATABASE elec_fee DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 退出后导入注意路径换成你自己的 mysql -u root -p elec_fee sql/elec_fee.sql导入完成后必须验证两件事表建全了没有、初始账号在不在。USE elec_fee; SHOW TABLES; -- 预期能看到 t_house、t_meter、t_reading、t_bill、t_payment 等 SELECT id, username, role FROM t_user; -- 初始管理员账号一般在这里密码是加密后的串如果SHOW TABLES是空的八成是导入时没指定库名数据进了默认库。这种问题看着低级但每年都有人踩。另外注意 SQL 文件里如果自带CREATE DATABASE和USE语句上面那条建库命令可以省掉直接整文件导入即可。3.3 改配置与启动四个参数改对就能起打开application.yml需要动的地方集中在一个datasource块和一个服务端口。密码、库名按你本机的情况填。server: port: 8080 # 被占用就改成 8081注意同步改前端里的接口地址 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/elec_fee?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 你的密码 thymeleaf: cache: false # 开发期关缓存改页面不用重启 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 下划线字段自动映射驼峰属性 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印 SQL调试必备map-underscore-to-camel-case这个开关值得单独说一句数据库里是house_no实体类里是houseNo开了它就不用每个字段写Results映射关掉的话查询结果会是一片 null而且不报错属于典型的黑匣子问题。log-impl打开后每条 SQL 都打在控制台参数和结果都能看见联调阶段基本靠它。启动方式有两种IDE 里直接跑启动类或者命令行打包。# 在项目根目录执行跳过测试加快速度 mvn clean package -DskipTests # 启动打出来的 jar java -jar target/elec-fee-system-1.0.0.jar看到Started ElecFeeApplication in x.xxx seconds就是起来了。浏览器访问http://localhost:8080用刚才查到的管理员账号登录。3.4 冒烟测试按业务顺序走一遍别乱点登录进去别急着看页面好不好看按数据流向走一遍能最快暴露配置问题。推荐这条路径住户管理新增一条住户 → 电表管理给该住户绑一块表记下初始读数 → 抄表管理录入本月读数 → 账单管理生成账单 → 查看金额对不对 → 点缴费 → 回到账单列表确认状态变成已缴。每一步都对应一个可能的失败点。新增住户报错多半是字段长度或者必填校验绑表失败检查外键和house_id是否存在抄表报错优先看唯一索引冲突生成账单没数据说明上一步的抄表记录账期和你填的不一致金额算错就去查t_price_rule里的阶梯区间。走完这一圈项目基本就摸熟了后面改代码心里有底。4. 核心链路拆解抄表、计费、缴费是怎么串起来的这套系统真正有价值的代码不在增删改查里而在「一次抄表如何变成一张账单」这条链路上。它横跨四个模块涉及事务、幂等、精度三类工程问题也是答辩时最容易被追问的部分。下面按调用顺序拆每个环节给一段能直接对照源码看的代码。4.1 抄表录入先校验再落库抄表是最前端的入口脏数据一旦进来后面计费全错。合格的做法是在 Service 层做三重校验电表是否存在且在用、本期读数不能小于上期、该账期不能已有记录。Service public class ReadingServiceImpl implements ReadingService { Resource private MeterMapper meterMapper; Resource private ReadingMapper readingMapper; Override Transactional(rollbackFor Exception.class) public void addReading(ReadingDTO dto) { // 1. 电表必须存在且处于启用状态 Meter meter meterMapper.selectById(dto.getMeterId()); if (meter null || meter.getStatus() ! 1) { throw new BizException(电表不存在或已停用); } // 2. 上期读数从库里取不信任前端传值 BigDecimal prev readingMapper.selectLastReading(dto.getMeterId(), dto.getPeriod()); if (prev null) { prev meter.getInitKwh(); // 首次抄表用装表初始读数 } if (dto.getCurrKwh().compareTo(prev) 0) { throw new BizException(本期读数不能小于上期读数 prev); } // 3. 组装实体用量在这里算好避免下游重复计算 Reading reading new Reading(); reading.setMeterId(dto.getMeterId()); reading.setPeriod(dto.getPeriod()); // 格式 yyyy-MM reading.setPrevKwh(prev); reading.setCurrKwh(dto.getCurrKwh()); reading.setUsageKwh(dto.getCurrKwh().subtract(prev)); reading.setReadTime(LocalDateTime.now()); readingMapper.insert(reading); } }参数上要留意period的格式全项目必须统一成yyyy-MM不然2024-05和2024-5会被当成两个账期唯一索引拦不住账单就会重复。prevKwh特意从库里查而不是用前端传的是因为页面上的上期读数可能是打开页面时的旧值多个人同时操作就会算错。最后一句insert依赖数据库的唯一索引兜底就算并发重复提交也会抛DuplicateKeyException事务回滚不会产生两条记录。Transactional(rollbackFor Exception.class)这里的rollbackFor不能省。Spring 默认只对RuntimeException回滚自定义的业务异常如果继承的是Exception不加这个参数就会出现「报错了但数据已经写进去」的诡异现象我第一次遇到时查了一下午。4.2 阶梯计费一个循环把分段电量算干净电价规则存在t_price_rule里按起始度数升序排列比如 0–180 度 0.5 元、180–400 度 0.55 元、400 度以上 0.8 元。计费的核心是把总用量按区间切开逐段乘单价累加。Service public class FeeCalcServiceImpl implements FeeCalcService { Resource private PriceRuleMapper priceRuleMapper; Override public BigDecimal calcFee(BigDecimal usage) { if (usage null || usage.compareTo(BigDecimal.ZERO) 0) { return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); } // 规则按 start_kwh 升序最后一条的 end_kwh 存一个足够大的值如 999999 ListPriceRule rules priceRuleMapper.selectOrderByStart(); BigDecimal remain usage; // 还没分配完的电量 BigDecimal total BigDecimal.ZERO; for (PriceRule rule : rules) { if (remain.compareTo(BigDecimal.ZERO) 0) { break; } // 当前档位能容纳的度数 BigDecimal span rule.getEndKwh().subtract(rule.getStartKwh()); // 取剩余电量和档位容量的较小值就是落在这一档的量 BigDecimal inThisLevel remain.min(span); total total.add(inThisLevel.multiply(rule.getPricePerKwh())); remain remain.subtract(inThisLevel); } // 规则表没覆盖到的部分按最高档单价兜底避免金额算少 if (remain.compareTo(BigDecimal.ZERO) 0) { BigDecimal lastPrice rules.get(rules.size() - 1).getPricePerKwh(); total total.add(remain.multiply(lastPrice)); } return total.setScale(2, RoundingMode.HALF_UP); } }这段逻辑的关键是remain.min(span)这一句。举例说明用量 500 度第一档容量 180取 min(500,180)180金额 90剩余 320第二档容量 220400 减 180取 min(320,220)220金额 121剩余 100第三档容量巨大取 min(100, 999999-400)100金额 80。合计 291 元。整个过程只遍历一次规则表时间复杂度 O(档位数)档位通常不超过五档性能上完全没问题。RoundingMode.HALF_UP是四舍五入别用默认的HALF_EVEN银行家舍入后者在 2.5 这种边界值上会舍成 2和普通人理解的算法不一致对账时容易引起争议。金额只在最后一步做 scale中间过程保留精度否则每段都舍入一次误差会累积。4.3 账单生成事务包住「查抄表 算钱 落库」账单是抄表和计费的汇合点也是整个系统里最需要保证一致性的地方。这段逻辑要么全部成功要么全部回滚中间不能出现「账单写了但明细没写」的半成品。Service public class BillServiceImpl implements BillService { Resource private ReadingMapper readingMapper; Resource private BillMapper billMapper; Resource private FeeCalcService feeCalcService; Override Transactional(rollbackFor Exception.class) public Bill generate(Long meterId, String period) { // 幂等已生成过就直接返回不重复扣费 Bill exists billMapper.selectByMeterAndPeriod(meterId, period); if (exists ! null) { return exists; } Reading reading readingMapper.selectByMeterAndPeriod(meterId, period); if (reading null) { throw new BizException(该账期没有抄表记录无法生成账单); } BigDecimal amount feeCalcService.calcFee(reading.getUsageKwh()); Bill bill new Bill(); bill.setMeterId(meterId); bill.setPeriod(period); bill.setUsageKwh(reading.getUsageKwh()); bill.setAmount(amount); bill.setStatus(0); // 0 未缴1 已缴2 已作废 bill.setCreateTime(LocalDateTime.now()); billMapper.insert(bill); return bill; } }开头那次「查了再插」是应用层幂等配合数据库唯一索引才是双保险。如果只靠查询判断两个请求同时进来都查到 null还是会产生两条账单有了唯一索引第二个请求会抛异常回滚。批量生成给整个小区一次生成时建议按meterId循环调用这个方法不要写一条大 SQL 一把梭因为单条失败要能单独定位是哪块表出的问题。status字段用数字而不是字符串查询时索引效率更好但要在common包下定义常量类别在代码里到处写0和1否则三个月后自己都忘了 2 代表什么。4.4 缴费与欠费统计流水和状态必须同时改缴费要动两张表往t_payment插一条流水把t_bill的状态改成已缴。这两步必须在一个事务里否则会出现「钱记了但账单还是欠费」的对账灾难。Override Transactional(rollbackFor Exception.class) public void pay(Long billId, BigDecimal payAmount, String payType) { Bill bill billMapper.selectById(billId); if (bill null) { throw new BizException(账单不存在); } if (bill.getStatus() 1) { throw new BizException(该账单已缴费请勿重复操作); } // 允许分次缴费时用累加这里按一次性缴清处理金额必须一致 if (payAmount.compareTo(bill.getAmount()) ! 0) { throw new BizException(缴费金额与账单金额不一致); } Payment payment new Payment(); payment.setBillId(billId); payment.setPayAmount(payAmount); payment.setPayType(payType); // 现金 / 微信 / 支付宝仅作记录 payment.setPayTime(LocalDateTime.now()); paymentMapper.insert(payment); bill.setStatus(1); billMapper.updateById(bill); }欠费统计就简单了一条带status 0的聚合 SQL 就够按小区或者按账期分组。-- 按账期统计欠费户数和金额 SELECT period, COUNT(*) AS unpaid_count, SUM(amount) AS unpaid_amount FROM t_bill WHERE status 0 GROUP BY period ORDER BY period DESC;这类统计查询写在 XML 里比注解清晰得多尤其是后面还要加小区筛选、时间范围筛选的时候。SUM的结果记得在 Java 侧用BigDecimal接用Double接会在金额较大时丢精度。5. 避坑与排查五个让新手卡半天的地方下面这几条都是我实际调试时真金白银踩过的按现象、原因、解决三段写遇到类似症状可以对着查。5.1 启动报时区错误连接串少了一个参数现象控制台抛java.sql.SQLException: The server time zone value 中国标准时间 is unrecognized应用起不来。原因MySQL 8 的驱动要求连接串显式声明时区不写就按服务器本地时区解析中文时区名驱动识别不了。解决在 JDBC URL 后面加serverTimezoneAsia/Shanghai同时补上useSSLfalse本地开发不需要 SSL 握手。改完重启如果还报错检查是不是application.yml里有多份 datasource 配置改到了没生效的那份。5.2 页面查询全变 null字段映射没开现象列表页能查出条数但每一行的住户姓名、房号都是空白后端也不报错。原因数据库字段是下划线命名实体类属性是驼峰MyBatis 默认不做转换结果集对不上字段名。解决确认mybatis.configuration.map-underscore-to-camel-case: true已开启。如果只有个别字段为空那就是 XML 里手写了resultMap但漏了列去resources/mapper下对应文件补上即可。排查技巧是把 SQL 日志打开看返回的列名和实体属性名对不对得上。5.3 账单金额多了几毛钱金额字段用了 double现象手工核算 291 元系统算出 290.99999998或者两个住户同样的用量金额差一分。原因实体类里金额字段定义成double或float二进制浮点无法精确表示小数多次运算后误差累积。解决全链路换成BigDecimal数据库字段用decimal(10,2)运算时用add/subtract/multiply而不是运算符最后统一setScale(2, RoundingMode.HALF_UP)。已经入库的脏数据要写个脚本重算一遍别指望显示层四舍五入能糊过去导出 Excel 时误差会原形毕露。5.4 重复点击生成按钮账单翻倍现象网络慢的时候用户连点两下「生成账单」列表里出现两条同账期记录缴费时不知道缴哪条。原因只在 Service 里做了「先查再插」并发下两个请求都查到空双双插入成功。解决在t_bill上建(meter_id, period)唯一索引把并发控制交给数据库前端按钮点击后立即置灰防住大部分误操作。已经重复的数据先按 id 保留最小的一条其余逻辑删除再补索引顺序反了会建不上。5.5 中文变问号字符集三处要一致现象新增住户填中文姓名保存后变成???或者页面显示乱码。原因数据库、表、连接串三处字符集不一致常见是建库时用了默认的 latin1。解决按顺序检查SHOW VARIABLES LIKE character_set%;、SHOW CREATE TABLE t_house;和 JDBC URL 里的characterEncodingutf8。建库阶段就用utf8mb4别等数据进去了再改ALTER TABLE CONVERT在老数据上不一定能救回来。Thymeleaf 页面头部也要有meta charsetUTF-8。6. 二次开发把「毕业设计」改成能交付的版本这套代码作为起点是够用的但直接拿去给物业用会露怯。我一般按三条线改优先级从高到低。第一条是把阶梯电价真正做成可配置。现在t_price_rule虽然有表但很多版本在代码里硬编码了 180、400 这些分界值改价就得改代码重新打包。正确做法是让规则带上effective_date计费时按账单账期匹配当时生效的那一版规则历史账单不受调价影响。验证方法很直接造两组不同生效日期的规则分别生成调价前后的账单金额应该各按各的算。-- 调价新增一条生效规则不覆盖旧规则 INSERT INTO t_price_rule (start_kwh, end_kwh, price_per_kwh, effective_date) VALUES (0, 180, 0.52, 2024-07-01), (180, 400, 0.57, 2024-07-01);第二条是补对账能力。缴费流水和账单状态理论上永远一致但线上跑久了总会有人手工改库。加一个定时任务或者后台按钮跑一条比对 SQL把「账单已缴但无流水」和「有流水但账单未缴」的记录揪出来输出成列表。-- 账单显示已缴但查不到缴费流水属于异常数据 SELECT b.id, b.meter_id, b.period, b.amount FROM t_bill b LEFT JOIN t_payment p ON p.bill_id b.id WHERE b.status 1 AND p.id IS NULL;第三条是权限和导出。三个角色现在多半只是登录后跳不同页面接口层没拦住户改个 URL 就能看别人的账单。加一个拦截器按role校验house_id归属比在每个 Controller 里写 if 靠谱。导出用 POI 把账单列表写成 xlsx注意流要关、大数据量分批写别一次查十万条塞内存。改造完怎么验证我的习惯是准备一份固定数据集三户人家、三种用量低于第一档、跨两档、超最高档、两个账期算好期望金额写在纸上每次改完计费逻辑就跑一遍对账。这套数据集比任何单元测试都直观答辩演示也能直接用。说个教训。早年我改这类项目习惯直接在生产库上试 SQL觉得反正数据是假的。有一次UPDATE t_bill SET status 1忘了加WHERE整表账单全变成已缴演示前一天晚上重造了两小时数据。从那以后我每次动数据前都强制先SELECT一遍确认影响行数改结构和批量更新一律先备份。希望帮到你。本文还有配套的精品资源点击获取