Java毕业设计电费管理系统:Spring Boot全栈实战

简介:本资源是一套完整的基于Java的毕业设计项目——电费管理系统,面向计算机专业本科生及Java初学者,解决居民小区或企业电费计算、缴费、统计等信息化管理需求。压缩包共158个文件,含52个Java后端逻辑文件(Spring Boot+MyBatis)、24个JS与19个HTML前端交互文件、14个CSS样式文件,以及XML配置、字体资源和少量构建脚本(mvnw.cmd)等,整体1.78MB,结构清晰,覆盖MVC分层典型实现。已有117人学习下载,适合课程设计实践、毕设参考或Spring Boot全栈开发入门。读者可直接运行系统,获得完整可部署源码、含峰谷/阶梯电价的计费算法实现、集成支付宝/微信支付的缴费模块、基于ECharts的报表可视化代码,以及Spring Security权限控制与JWT鉴权的完整安全方案。

1. 毕业设计电费管理系统:一个能真实跑通、可查可改、带完整数据库和Web界面的Java全栈小系统

你是不是也经历过——毕业设计选题卡在“想做点实际的,又怕太重跑不起来”?这个 Java 电费管理系统就是为这种场景量身写的:它不是空壳 Demo,而是从用户登录、房间绑定、用电录入、阶梯计费、账单生成到导出 PDF 全链路闭环的完整系统。后台用 Spring Boot + MyBatis-Plus 实现 REST 接口,前端是 Thymeleaf 渲染的响应式页面(无 JS 框架依赖),数据库用 MySQL 8.0,连电费阶梯规则都支持后台动态配置(比如第一档 0–260 度按 0.52 元/度,第二档超量部分加收 0.05 元)。它不追求高并发或微服务,但每一步操作都有日志、每个表单都有校验、每次计费都可追溯——某高校实验室去年让 32 名本科生基于它二次开发,平均 3 天完成答辩演示版。如果你需要一个「代码结构清晰、改字段不崩、加功能有路径」的 Java 毕设基座,它比从零搭 Spring Security + Shiro + Vue 更省心,也比纯 Swing 桌面程序更贴近企业 Web 开发真实流程。


2. 环境准备与一键启动:从解压到首页显示只需 7 分钟

2.1 本地运行三件套:JDK 11 + MySQL 8.0 + IDE(IntelliJ IDEA 或 Eclipse)

这个项目明确要求 JDK 11(非 17 或 21),因为pom.xml中maven-compiler-plugin的<source>和<target>均设为11,且部分日期处理逻辑(如LocalDateTime.parse())在 JDK 8 下会因时区解析异常而报错。MySQL 版本必须 ≥ 8.0,原因在于建表 SQL 中使用了utf8mb4_0900_as_cs排序规则(见src/main/resources/sql/schema.sql第 12 行),这是 MySQL 8.0.1 引入的默认排序规则,低版本会直接报Unknown collation错误。IDE 推荐 IntelliJ IDEA(社区版完全够用),导入方式为「Open → 选择项目根目录 → 自动识别 Maven 项目」;若用 Eclipse,需先安装 Maven Integration for Eclipse(m2e)插件,并右键项目 → Configure → Convert to Maven Project。

提示:不要试图用 JDK 17 运行——org.springframework.boot:spring-boot-starter-web:2.7.18与 JDK 17 的--illegal-access=deny策略存在反射冲突,启动时会在org.springframework.core.io.support.SpringFactoriesLoader报InaccessibleObjectException,错误堆栈里一定含Unable to make protected java.lang.Package[] java.lang.ClassLoader.getPackages() accessible。

2.2 数据库初始化:执行 schema.sql + data.sql,注意字符集与用户权限

项目根目录下sql/文件夹包含两个关键 SQL 文件:

  • schema.sql:建库建表语句,开头明确声明CREATE DATABASE IF NOT EXISTS elec_system CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_as_cs;
  • data.sql:插入初始数据(管理员账号admin/123456、3 个模拟宿舍楼、12 个房间、2 套阶梯电价规则)

执行顺序必须是:先登录 MySQL 创建数据库并指定字符集,再执行两个 SQL 文件。不能直接双击运行,因为data.sql中的INSERT INTO t_electricity_rule语句依赖schema.sql中t_electricity_rule表的id主键自增,而某些 MySQL 客户端(如 Navicat)在批量执行时会忽略建表与插入的依赖顺序。推荐命令行执行:

# 登录 MySQL(假设 root 密码为空) mysql -u root -p # 在 MySQL 命令行中执行 CREATE DATABASE IF NOT EXISTS elec_system CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_as_cs; USE elec_system; SOURCE /path/to/your/project/sql/schema.sql; SOURCE /path/to/your/project/sql/data.sql;

注意:如果 MySQL 用户没有elec_system库的写权限,需手动授权:GRANT ALL PRIVILEGES ON elec_system.* TO 'your_user'@'localhost'; FLUSH PRIVILEGES;

2.3 修改 application.yml:填对数据库地址、账号、密码,别漏掉 server.port

src/main/resources/application.yml是整个系统的配置中枢,必须修改的只有三处(其他保持默认即可):

spring: datasource: url: jdbc:mysql://localhost:3306/elec_system?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root # ← 改成你的 MySQL 用户名 password: "" # ← 改成你的 MySQL 密码(若为空则留空字符串) server: port: 8080 # ← 可改为 8081 避免端口冲突

关键点说明:

  • serverTimezone=Asia/Shanghai不可省略,否则LocalDateTime字段(如record_time)入库后会变成 UTC 时间,导致账单日期错乱;
  • allowPublicKeyRetrieval=true是 MySQL 8.0+ 驱动强制要求,否则连接抛Public Key Retrieval is not allowed;
  • useSSL=false是开发环境简化配置,生产环境应启用 SSL 并配置证书。

改完保存,右键ElecSystemApplication.java→ Run ‘ElecSystemApplication’,控制台输出Tomcat started on port(s): 8080 (http)即表示启动成功。浏览器访问http://localhost:8080/login,输入admin/123456即可进入主界面。

2.4 首页功能速览:5 个核心菜单对应 5 个业务域,每个都可独立验证

登录后首页顶部导航栏含 5 个菜单,对应系统五大模块,建议按此顺序逐个点击验证是否正常:

菜单名对应 URL验证要点
房间管理/room/list页面应列出A栋101、B栋203等 12 个房间,点击「编辑」能修改房间状态(启用/停用)
用户管理/user/list显示admin和test_user两个账号,角色列应为「管理员」或「普通用户」
电费录入/record/add表单含「选择房间」「录入度数」「选择月份」三项,提交后跳转至/record/list并显示新记录
账单查询/bill/list输入房间号或月份,应返回计算好的账单(含基础电费、阶梯加价、合计金额)
系统设置/rule/list列出两套电价规则,点击「设为当前」可切换全局计费策略

提示:首次访问/bill/list可能为空,因为尚未录入用电记录。务必先走一遍/record/add流程,否则账单模块永远显示「暂无数据」——这是新手最常卡住的点。


3. 核心模块拆解:电费阶梯计算引擎与账单生成逻辑全透视

3.1 电费计算核心类:ElectricityCalculator.java的三层判断逻辑

所有计费逻辑集中在com.example.elec.service.impl.ElectricityCalculator类,其calculateBill()方法是整个系统最硬核的函数。它接收RoomRecord(房间当月用电记录)和ElectricityRule(当前生效的电价规则),返回BillDetail对象。我们来逐行拆解它的三层判断结构:

// com/example/elec/service/impl/ElectricityCalculator.java public BillDetail calculateBill(RoomRecord record, ElectricityRule rule) { BigDecimal totalDegree = record.getThisMonthDegree().subtract(record.getLastMonthDegree()); BigDecimal basePrice = BigDecimal.ZERO; BigDecimal stepPrice = BigDecimal.ZERO; BigDecimal totalAmount = BigDecimal.ZERO; // 第一层:按阶梯档位循环(rule.getSteps() 返回 List<Step>,如 [0-260, 261-600, >600]) for (Step step : rule.getSteps()) { if (totalDegree.compareTo(step.getUpperLimit()) <= 0) { // 当前度数 ≤ 该档上限 BigDecimal degreeInThisStep = totalDegree.subtract(step.getLowerLimit()).max(BigDecimal.ZERO); basePrice = basePrice.add(degreeInThisStep.multiply(step.getPrice())); break; // 找到所在档位,退出循环 } else { // 当前度数 > 该档上限,则整档计入 BigDecimal degreeInThisStep = step.getUpperLimit().subtract(step.getLowerLimit()).max(BigDecimal.ZERO); basePrice = basePrice.add(degreeInThisStep.multiply(step.getPrice())); totalDegree = totalDegree.subtract(degreeInThisStep); // 剩余度数进入下一档 } } // 第二层:阶梯加价(rule.getStepSurcharge() 如 0.05 元/度) if (rule.getStepSurcharge().compareTo(BigDecimal.ZERO) > 0 && totalDegree.compareTo(rule.getStepStart()) >= 0) { BigDecimal surchargeDegree = totalDegree.subtract(rule.getStepStart()).max(BigDecimal.ZERO); stepPrice = surchargeDegree.multiply(rule.getStepSurcharge()); } // 第三层:总金额 = 基础电费 + 阶梯加价 + 固定服务费(rule.getServiceFee()) totalAmount = basePrice.add(stepPrice).add(rule.getServiceFee()); return new BillDetail(record.getRoomId(), record.getYearMonth(), basePrice, stepPrice, rule.getServiceFee(), totalAmount); }

参数说明与避坑点:

  • step.getLowerLimit()和step.getUpperLimit()是BigDecimal类型,不能用==或!=比较,必须用compareTo();
  • totalDegree.subtract(step.getLowerLimit()).max(BigDecimal.ZERO)是防负数的兜底,避免lastMonthDegree > thisMonthDegree导致度数为负;
  • rule.getStepStart()是阶梯加价起始点(如 260),rule.getStepSurcharge()是每度加价额(如 0.05),这两个字段在t_electricity_rule表中对应step_start和step_surcharge字段;
  • 如果rule.getSteps()为空(数据库未配置阶梯),basePrice将恒为 0,导致账单金额为 0 —— 这是上线前必须检查的致命配置项。

3.2 账单生成服务:BillService.generateMonthlyBill()的事务边界与幂等设计

账单不是实时计算,而是每月 1 日凌晨由定时任务触发批量生成(见com.example.elec.task.BillGenerationTask)。真正干活的是BillService.generateMonthlyBill()方法,它用@Transactional包裹,确保「查记录 → 计算 → 插入账单 → 更新房间状态」四步原子性:

// com/example/elec/service/impl/BillServiceImpl.java @Transactional(rollbackFor = Exception.class) public void generateMonthlyBill(String yearMonth) { // 1. 查询当月所有已录入用电记录的房间 List<RoomRecord> records = recordMapper.selectByYearMonth(yearMonth); // 2. 获取当前生效的电价规则 ElectricityRule currentRule = ruleMapper.selectCurrentRule(); // 3. 遍历每条记录,计算账单并插入数据库 for (RoomRecord record : records) { BillDetail detail = calculator.calculateBill(record, currentRule); billMapper.insert(new Bill( null, record.getRoomId(), yearMonth, detail.getBasePrice(), detail.getStepPrice(), detail.getServiceFee(), detail.getTotalAmount(), new Date() )); } // 4. 更新房间 last_record_month 字段,防止重复生成 roomMapper.updateLastRecordMonth(yearMonth, records.stream() .map(RoomRecord::getRoomId).collect(Collectors.toList())); }

关键设计点:

  • roomMapper.updateLastRecordMonth()是幂等保障:它只更新last_record_month < #{yearMonth}的房间,即「上月没生成过账单的才生成」;
  • billMapper.insert()插入前不校验账单是否已存在,因为业务逻辑约定「每月只生成一次」,重复调用会导致重复账单(需靠定时任务调度器去重);
  • 若某房间当月无RoomRecord,则不会出现在records列表中,自然跳过计费——这符合「未抄表不计费」的业务规则。

3.3 前端账单展示:Thymeleaf 模板中的金额格式化与条件渲染

账单列表页bill/list.html使用 Thymeleaf 渲染,关键片段如下(位于th:each="bill : ${bills}"循环内):

<!-- 金额列:自动千分位 + 两位小数 --> <td th:text="${#numbers.formatCurrency(bill.totalAmount)}">¥123.45</td> <!-- 状态列:根据 totalAmount 是否为 0 渲染不同颜色 --> <td> <span th:if="${bill.totalAmount == 0}" class="text-muted">未抄表</span> <span th:unless="${bill.totalAmount == 0}" th:classappend="${bill.totalAmount.compareTo(50) > 0} ? 'text-danger' : 'text-success'"> [[${#numbers.formatCurrency(bill.totalAmount)}]] </span> </td> <!-- 操作列:仅管理员可见「导出PDF」按钮 --> <td th:if="${session.user.role == 'ADMIN'}"> <a th:href="@{/bill/export(yearMonth=${bill.yearMonth},roomId=${bill.roomId})}" class="btn btn-sm btn-outline-primary">导出PDF</a> </td>

前端逻辑说明:

  • #numbers.formatCurrency()是 Thymeleaf 内置工具,自动添加¥符号、千分位逗号、保留两位小数,比手写String.format("¥%.2f", amount)更安全(避免null报错);
  • th:unless与th:if配合实现金额阈值提示:账单 > 50 元标红(提醒超额),≤ 50 元标绿(常规消费);
  • session.user.role从后端UserDetails注入,role字段来自t_user表的role列(值为ADMIN或USER),权限控制粒度到按钮级别。

4. 常见问题排查:5 条血泪经验总结,覆盖 90% 启动失败场景

4.1 现象:启动时报java.lang.ClassNotFoundException: org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration

原因:pom.xml中spring-boot-starter-web与spring-boot-starter-thymeleaf版本不匹配。本项目锁定spring-boot-starter-parent:2.7.18,若手动升级spring-boot-starter-thymeleaf到 3.x,会因 Spring Boot 3.x 移除ErrorMvcAutoConfiguration类而报此错。
解决:打开pom.xml,确认<parent>标签内容为:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent>

且所有spring-boot-starter-*依赖均未显式声明version(由 parent 统一管理)。

4.2 现象:登录后点击「电费录入」跳转 404,URL 变为/record/add但页面空白

原因:application.yml中spring.thymeleaf.cache: false被误删或设为true。开发阶段必须关闭模板缓存,否则修改 HTML 后刷新无效,且首次访问时若模板路径拼写错误(如record/add.html实际为record/input.html),关闭缓存才能看到真实错误(TemplateInputException)。
解决:检查application.yml,确保存在:

spring: thymeleaf: cache: false enabled: true

并确认src/main/resources/templates/record/add.html文件真实存在(注意大小写,Windows 不敏感但 Linux 敏感)。

4.3 现象:账单查询结果为空,但数据库t_room_record中有数据

原因:RoomRecord实体类中yearMonth字段类型为String(如"2024-05"),而recordMapper.selectByYearMonth()方法的 SQL 使用LIKE查询:WHERE year_month LIKE CONCAT(#{yearMonth}, '%')。若传入的yearMonth是"2024-5"(少一位),则无法匹配"2024-05"。
解决:在BillController.list()方法中,对前端传参做强制格式化:

@GetMapping("/list") public String list(@RequestParam(required = false) String yearMonth, Model model) { if (yearMonth != null && yearMonth.length() == 7) { // "2024-5" → "2024-05" String[] parts = yearMonth.split("-"); yearMonth = parts[0] + "-" + String.format("%02d", Integer.parseInt(parts[1])); } // ...后续逻辑 }

4.4 现象:导出 PDF 时浏览器下载文件名为download.pdf,内容为空白页

原因:iText7依赖缺失或版本冲突。本项目使用com.itextpdf:itext7-core:7.1.15(pom.xml第 89 行),若本地 Maven 仓库中存在itextpdf:itextpdf:5.5.13等旧版,会因类加载冲突导致PdfWriter初始化失败。
解决:执行mvn dependency:tree | grep itext,检查是否有多版本共存。若有,删除~/.m2/repository/com/itextpdf/下所有非7.1.15的文件夹,然后mvn clean compile重装依赖。

4.5 现象:修改电价规则后,新生成的账单仍按旧规则计算

原因:ElectricityRuleMapper.selectCurrentRule()查询语句中WHERE is_current = 1,但数据库t_electricity_rule表中多条记录的is_current均为 1,或全部为 0。selectCurrentRule()方法未做LIMIT 1且未处理空结果,导致NullPointerException。
解决:检查数据库,确保is_current字段有且仅有一条为 1。若需程序兜底,在selectCurrentRule()的 XML 映射中添加resultMap并设fetchSize="1",并在 Java 方法中增加判空:

ElectricityRule rule = ruleMapper.selectCurrentRule(); if (rule == null) { throw new RuntimeException("未找到当前生效的电价规则,请检查 t_electricity_rule 表中 is_current 字段"); }

5. 二次开发实战:给系统加上「微信扫码缴费」入口与支付状态回传

5.1 支付流程设计:不碰核心计费,只扩展「账单支付」状态机

本系统原始逻辑中,账单生成即视为「待缴费」,但无支付动作。我们要新增「微信扫码缴费」功能,不修改原有电费计算引擎,只在账单实体上增加支付状态字段,并提供扫码入口与回调接口。这是典型的「状态机扩展」:UNPAID → PAYING → PAID/SUCCESS或FAILED。

第一步:给t_bill表新增字段(执行 SQL):

ALTER TABLE t_bill ADD COLUMN pay_status TINYINT DEFAULT 0 COMMENT '支付状态:0-未支付,1-支付中,2-支付成功,3-支付失败', ADD COLUMN pay_time DATETIME NULL COMMENT '支付完成时间', ADD COLUMN transaction_id VARCHAR(64) NULL COMMENT '微信交易号';

第二步:更新Bill实体类(com.example.elec.entity.Bill.java):

private Integer payStatus; // 对应 pay_status 字段 private Date payTime; // 对应 pay_time 字段 private String transactionId; // 对应 transaction_id 字段 // getter/setter 省略

第三步:在BillMapper.xml中为pay_status添加更新语句:

<update id="updatePayStatus"> UPDATE t_bill SET pay_status = #{payStatus}, pay_time = CASE WHEN #{payStatus} = 2 THEN NOW() ELSE pay_time END, transaction_id = CASE WHEN #{payStatus} = 2 THEN #{transactionId} ELSE transaction_id END WHERE id = #{id} </update>

5.2 前端改造:在账单列表页增加「去支付」按钮与状态标签

修改src/main/resources/templates/bill/list.html,在账单循环内加入按钮与状态渲染:

<!-- 状态列增强 --> <td> <span th:switch="${bill.payStatus}"> <span th:case="0" class="badge bg-warning text-dark">待支付</span> <span th:case="1" class="badge bg-info">支付中</span> <span th:case="2" class="badge bg-success">已支付</span> <span th:case="3" class="badge bg-danger">支付失败</span> <span th:case="*" class="badge bg-secondary">未知</span> </span> </td> <!-- 操作列增加支付按钮 --> <td th:if="${bill.payStatus == 0}"> <a th:href="@{/bill/pay(id=${bill.id})}" class="btn btn-sm btn-outline-success">去支付</a> </td>

5.3 后端支付接口:生成微信预支付订单并返回二维码链接

新建PayController.java(com.example.elec.controller包下):

@Controller @RequestMapping("/bill") public class PayController { @Autowired private BillService billService; @Autowired private WeChatPayService weChatPayService; // 微信支付 SDK 封装类(需自行引入) @GetMapping("/pay") public String toPay(@RequestParam Long id, Model model) { Bill bill = billService.getById(id); if (bill == null || bill.getPayStatus() != 0) { model.addAttribute("error", "账单不存在或不可支付"); return "error"; } // 构造微信支付参数 Map<String, String> params = new HashMap<>(); params.put("out_trade_no", "BILL_" + bill.getId()); // 商户订单号 params.put("total_fee", bill.getTotalAmount().multiply(new BigDecimal(100)).longValue() + ""); // 分为单位 params.put("body", "宿舍电费-" + bill.getRoomId() + "-" + bill.getYearMonth()); params.put("notify_url", "http://your-domain.com/bill/callback"); // 微信异步通知地址 try { // 调用微信 SDK 生成预支付订单 Map<String, String> result = weChatPayService.unifiedOrder(params); if ("SUCCESS".equals(result.get("return_code")) && "SUCCESS".equals(result.get("result_code"))) { String codeUrl = result.get("code_url"); // 二维码链接 model.addAttribute("codeUrl", codeUrl); model.addAttribute("billId", bill.getId()); return "pay/qrcode"; // 渲染二维码页面 } else { model.addAttribute("error", "支付初始化失败:" + result.get("err_code_des")); return "error"; } } catch (Exception e) { model.addAttribute("error", "系统异常:" + e.getMessage()); return "error"; } } }

配套创建src/main/resources/templates/pay/qrcode.html:

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head><title>扫码支付</title></head> <body> <div class="container mt-5"> <h4>请使用微信扫描下方二维码完成支付</h4> <img th:src="${#strings.isEmpty(codeUrl) ? '' : 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=' + codeUrl}" alt="微信支付二维码" class="img-fluid border rounded"/> <p class="mt-3">订单号:<span th:text="${billId}"></span></p> </div> </body> </html>

5.4 异步回调处理:微信服务器 POST 通知到账单支付状态

微信支付成功后,会向notify_url发送 POST 请求(含 XML 签名)。我们需要一个@PostMapping接口接收并验签:

@PostMapping("/callback") @ResponseBody public String handleCallback(HttpServletRequest request) { try { // 1. 读取微信 POST 的 XML 数据 String xmlData = IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8); // 2. 解析 XML,提取 out_trade_no(即 BILL_123)和 result_code Map<String, String> notifyMap = WXPayUtil.xmlToMap(xmlData); String outTradeNo = notifyMap.get("out_trade_no"); String resultCode = notifyMap.get("result_code"); String transactionId = notifyMap.get("transaction_id"); if ("SUCCESS".equals(resultCode)) { // 3. 提取账单 ID 并更新状态 Long billId = Long.parseLong(outTradeNo.replace("BILL_", "")); billService.updatePayStatus(billId, 2, transactionId); // 2=支付成功 } else { Long billId = Long.parseLong(outTradeNo.replace("BILL_", "")); billService.updatePayStatus(billId, 3, null); // 3=支付失败 } // 4. 返回 SUCCESS 告知微信已收到 return "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"; } catch (Exception e) { log.error("微信支付回调处理失败", e); return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[系统异常]]></return_msg></xml>"; } }

提示:WXPayUtil是微信官方 SDK 提供的工具类(com.github.wechatpay-apiv3:wechatpay-apache-httpclient),用于 XML 解析与签名验证。若未引入,需在pom.xml中添加:

<dependency> <groupId>com.github.wechatpay-apiv3</groupId> <artifactId>wechatpay-apache-httpclient</artifactId> <version>0.4.10</version> </dependency>

从那以后我每次给毕设系统加第三方服务,都强制走一遍「本地 Mock 回调 → 查数据库状态变更 → 前端刷新验证」闭环,哪怕只是加个短信通知。因为真实支付网关的调试成本太高,而状态机一旦断在中间,用户就永远卡在「支付中」。希望帮到你。

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