1. 项目整体设计与技术选型思路
1.1 核心需求解析:宿舍管理员到底想要什么
做这个系统之前,我特地去和几位高校后勤的老师聊过,发现大家的需求高度一致:宿舍水电费核算太麻烦、报修进度全靠微信群吼、月底对账更是让人头大。
传统的管理方式是每个月底抄表员挨个宿舍抄电表水表,回到办公室用 Excel 登记,再手工计算每间宿舍的费用,最后把账单贴在宿舍楼下。一旦遇到有学生报修,还得打电话催维修师傅,修没修好、什么时候修的,全靠口头沟通。一套流程走下来,不仅效率低,还容易产生纠纷——学生说热水器坏了没人管,维修师傅说早就修好了,双方各执一词,因为没有留痕。
所以我给这个"学生宿舍水电费报修管理系统"定的核心目标很明确:把水电费计算、报修流程、宿舍信息管理三件事全部线上化,并且每一条操作都有记录可追溯。系统面向三类用户:学生(查看自己宿舍的水电用量与费用、提交报修、跟踪进度)、宿管/管理员(录入抄表数据、设置单价、指派维修师傅、审核报修结单)、系统管理员(管理宿舍楼栋、房间、用户账号、角色权限、数据统计)。
1.2 为什么选 Spring Boot + Vue 前后端分离
现在做管理系统,技术选型上其实有不少方案。我最终确定用 Spring Boot 做后端、Vue 做前端,核心考量有三点。
第一是团队协作效率。前后端分离之后,前端小组专心写页面交互,后端小组专心写接口和业务逻辑,两边只需要约定好接口文档就能并行开发。这个项目里我估算了一下,如果做单体 JSP 项目,页面和后端代码混在一起,一个人改页面另一个人就得等他;分离之后整个开发周期至少缩短三分之一。
第二是Spring Boot 的开发效率确实高。自动装配机制省掉了大量 XML 配置,内置 Tomcat 让项目可以一键启动。配合 Spring Data JPA 或者 MyBatis-Plus,连建表到实体映射的工作都简化了。再加上 Spring Security 做登录鉴权,RBAC 权限模型几行配置就能落地,非常适合这种中小型管理系统。
第三是Vue 的组件化开发太适合表单密集型场景了。水电费管理系统的页面大量集中在"表格 + 表单弹窗 + 详情抽屉"这三种形态,Vue 的单文件组件可以把每一块 UI 拆成独立组件复用。比如水费录入表格组件、报修工单状态标签组件,在多个页面里直接引用,维护成本低。
还有一个现实因素:这个项目是典型的教学/毕设/练手项目场景。Spring Boot + Vue 是目前市面上资料最多、遇到问题最容易搜到解决方案的组合,招人门槛也低。换个冷门框架,光踩坑就能劝退不少人。
2. 数据库设计与核心模块拆解
2.1 数据库表结构规划:九张表覆盖全部业务
先把表结构讲清楚,这是整个系统的地基。我按照业务域拆分成三类:
用户与组织域:用户表(user)、宿舍表(dormitory)、楼栋表(building)。这里的核心设计思路是用户和宿舍做关联,学生用户通过"宿舍ID"字段关联到具体的房间;楼栋表则是宿舍表的上层组织,方便按楼栋做数据统计和权限隔离。
费用业务域:电费记录表(electricity_record)、水费记录表(water_record)、缴费单表(payment_order)。水电记录表是用来存放每一次抄表数据的,缴费单表则负责生成学生需要支付的账单。
报修业务域:报修工单表(repair_order)、报修类型表(repair_category)、操作日志表(operation_log)。
下面给一张精简的核心表设计参考(字段只列关键项):
| 表名 | 关键字段 | 用途说明 |
|---|---|---|
| user | id, username, password(BCrypt), role, dormitory_id | 存储三类账号,role 区分 student / admin / root |
| building | id, name, manager_name | 楼栋基础信息 |
| dormitory | id, building_id, room_no, bed_count | 房间信息,bed_count 用于按人均摊水电费 |
| electricity_record | id, dormitory_id, month, last_reading, current_reading, unit_price, amount | 每月电费抄表数据,amount 由度数乘以单价计算 |
| water_record | id, dormitory_id, month, last_reading, current_reading, unit_price, amount | 同电费,单位按吨计 |
| payment_order | id, user_id, dormitory_id, order_no, total_amount, status(0/1), pay_time | 缴费单,状态 0 未缴 1 已缴 |
| repair_order | id, dormitory_id, reporter_id, category_id, description, images, status(0-4), assignee, finish_time | 报修工单,状态机下面细讲 |
| repair_category | id, name, response_timeout | 报修类型,比如水龙头、电路、门窗 |
| operation_log | id, user_id, action, detail, create_time | 关键操作留痕 |
有一个容易被忽略但很重要的设计点:抄表记录里必须同时存上次读数和本次读数,而不是只存本次度数。这样做的目的是可追溯、可复核。学生质疑"这个月度数怎么这么高"时,管理员可以直接看到上月底的底数和这个月底的底数,还能去现场复核表具,避免纠纷。
2.2 水电费计费逻辑:阶梯计价与均摊算法
水电费计算是这个系统的核心业务,计价逻辑不能拍脑袋写死。我在设计时预留了两种计费模式:固定单价模式和阶梯计价模式。
固定单价就是每度电多少钱、每吨水多少钱,直接用(本次读数 - 上次读数) * 单价计算。这个是基础。
阶梯计价稍微复杂一点,需要考虑"用量区间"。比如规定每间宿舍每月基础用电额度是 30 度,30 度以内按 0.5 元/度,超过部分按 0.8 元/度。计算逻辑是:
基础部分:min(用量,阶梯阈值) * 第一档单价 超出部分:max(用量 - 阶梯阈值,0) * 第二档单价 合计金额 = 基础部分 + 超出部分用实际数字演示一下:某宿舍本月用电 42 度,第一档 30 度 0.5 元,第二档 12 度 0.8 元,那么费用 = 30×0.5 + 12×0.8 = 24.6 元。
阶梯阈值和单价我建议放在系统配置表里,不要硬编码在代码中,这样换一届后勤领导调价时,管理员在后台改一下配置就行,不用重新发版。
人均均摊也是一个高频需求。学校宿舍一般按宿舍为单位计费,但有的学校要求按床位分摊到个人。实现方式是 dormitory 表的 bed_count 字段参与计算:人均费用 = 宿舍总费用 / bed_count。缴费单表再把人均费用关联到每个学生的 user_id 上。
这里有一个经验之谈:水电费计算建议做成月度定时任务批量生成,而不是学生在页面上点击"查询"时才实时计算。原因是实时计算会在高峰期造成数据库压力,而且容易受抄表数据未录入的影响生成错误账单。我通常搭配 Spring 的 @Scheduled 定时注解做每月 1 号凌晨自动结算,生成当月所有宿舍的缴费单。
3. 后端核心接口实现与权限控制
3.1 Spring Boot 项目初始化的关键配置
项目骨架我用的是 Spring Initializr 生成,Java 版本选 8 或 11 都没问题,Spring Boot 版本建议选 2.7.x 而不是 3.x。为什么?因为国内大量教程、博客、毕业设计参考代码基于 2.x 版本,javax.servlet 命名空间和 3.x 的 jakarta.servlet 有兼容性差异,一旦用到一些老版本的工具类或代码片段,在 3.x 下会直接编译失败。选 2.7.x 能最大化减少折腾时间。
依赖方面,核心就五个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>JPA 和 MyBatis-Plus 二选一就可以。JPA 的优势是实体类之间关联关系清晰,自动建表省心;MyBatis-Plus 的优势是复杂 SQL 写起来更顺手、代码生成器可以一键生成 CRUD。我个人在这个项目里用了 JPA,因为表关联嵌套关系多(楼栋→宿舍→账单),用注解映射比手写 SQL 直观。
application.yml 里有一个关键配置必须处理——时间字段的时区问题。MySQL 默认时区是 UTC,中国是 UTC+8,不设置的话时间字段会差 8 小时。我的配置习惯是:
spring: datasource: url: jdbc:mysql://localhost:3306/dormitory_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 jpa: hibernate: ddl-auto: update show-sql: trueddl-auto: update在开发阶段确实方便,实体类改一个字段自动同步到表结构,但上线前一定要改成validate或者直接关闭自动改表,否则不小心删掉字段可能导致数据丢失。
3.2 报修工单状态机设计与流程流转
报修模块最容易做成一坨"散装代码",就是单纯更新 status 字段,没有任何流程约束。学生可以随意改状态,管理员也不知道该处理哪一步。
我给报修工单定义了一个五状态流转模型:
| 状态值 | 状态名称 | 说明 | 进入条件 |
|---|---|---|---|
| 0 | 待受理 | 学生刚提交 | 学生提交报修 |
| 1 | 已受理(待维修) | 管理员审核通过并指派维修师傅 | 管理员点击"受理" |
| 2 | 维修中 | 维修师傅标记开始维修 | 师傅点击"开始维修" |
| 3 | 已完成待确认 | 维修师傅提交完成 | 师傅点击"完成维修" |
| 4 | 已确认关闭 | 学生确认维修结果 | 学生点击"确认" |
状态流转图(文字描述)就是:0→1→2→3→4,任何一个环节学生都可以点击"取消报修"直接置为关闭状态。不能跳步骤,比如从 0 直接跳 3 是禁止的。
后端实现时我用了一个状态机校验工具方法,核心代码如下:
private static final Map<Integer, List<Integer>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(0, Arrays.asList(1, 4)); // 待受理可流转到 已受理 或 已关闭 TRANSITIONS.put(1, Arrays.asList(2, 4)); TRANSITIONS.put(2, Arrays.asList(3, 4)); TRANSITIONS.put(3, Arrays.asList(4)); } public void transition(RepairOrder order, int targetStatus) { List<Integer> allowed = TRANSITIONS.get(order.getStatus()); if (allowed == null || !allowed.contains(targetStatus)) { throw new IllegalStateException("非法状态流转: " + order.getStatus() + " -> " + targetStatus); } // 权限校验:学生只能提交/取消/确认,管理员只能受理/指派 order.setStatus(targetStatus); if (targetStatus == 3) { order.setFinishTime(LocalDateTime.now()); } }这个设计的价值在于:非法操作在业务层就被拦截,而不是等到数据库出异常。前端下拉框能选的选项和后端校验规则保持一致,学生也钻不了空子。
3.3 金额计算精度与并发扣费问题
水电费涉及金额,两个常见的坑必须提前处理。
第一个坑是小数精度。Java 里直接用 float/double 计算金额,0.1×3 的结果可能是 0.30000000000000004。虽然单笔差异小,但多笔累加之后误差会被放大,对账的时候就会对不上。正确做法是金额字段用 BigDecimal,数据库层面用 decimal 类型。
BigDecimal amount = new BigDecimal(degree) .multiply(new BigDecimal(unitPrice)) .setScale(2, RoundingMode.HALF_UP);第二个坑是并发扣费。学生可能同时用电脑和手机登录账号,两个设备都在缴费页点击"确认支付"。如果代码只做了"查询订单状态→如果未支付则更新为已支付"两步操作,在高并发下可能出现两个请求同时读到"未支付",然后都执行更新,最终一个订单被扣两次。
解决办法是在数据库层面加乐观锁。在 payment_order 表加 version 字段:
@Version private Integer version;更新 SQL 变成UPDATE payment_order SET status=1, version=version+1 WHERE id=? AND version=?。第二个请求进来时版本号已经变了,更新影响行数为 0,直接提示"订单已处理,请勿重复操作"。这是我强烈建议任何涉及"确认支付"类业务都必须加的兜底方案。
4. 前端 Vue 页面设计与联调
4.1 项目搭建:Vue CLI 还是 Vite?
创建前端项目时我遇到了选型问题,最后用了 Vite。原因很简单:Vite 冷启动速度比 Webpack 快一个量级,而且内置了对 Vue 3 的一等支持。项目开发时改一个组件文件,热更新几乎是秒级,体验非常好。
创建命令也很简单:
npm create vite@latest dormitory-web -- --template vue cd dormitory-web npm install npm install vue-router@4 pinia axios element-plusUI 组件库方面,我选的是 Element Plus。这个项目需要的表格、表单、步骤条、弹窗组件它都有现成的,而且风格统一,不需要额外调 CSS。大体量的管理系统别自己造 UI 轮子,费时费力还容易出样式 bug,直接站在组件库的肩膀上写业务逻辑才是正解。
项目目录结构我按"页面 + 组件 + 请求封装 + 路由 + 状态管理"五层组织:
src/ api/ // 按模块封装 axios 请求(user.js、repair.js、bill.js) assets/ // 静态资源 components/ // 可复用组件(DormitorySelect、StatusTag、BillTable) router/ // 路由配置文件 store/ // Pinia 状态管理(用户信息、当前宿舍) views/ student/ // 学生端页面:费用查询、报修提交、报修进度 admin/ // 管理端页面:抄表录入、工单处理、账单管理 layout/ // 布局容器请求封装这一块值得多说两句。axios 实例要统一设置拦截器,请求拦截器注入 token,响应拦截器统一处理业务错误码和 401 跳转。否则每个页面都得写一遍"取 token → 塞请求头 → 判断状态码 → 处理异常"的逻辑,代码会非常冗余。
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) }, error => { if (error.response?.status === 401) { router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )4.2 核心页面交互细节:抄表、报修、缴费三块硬骨头
抄表录入页面是最简单也最容易做难用的。管理员的真实使用场景是:拿着抄表本,一个一个宿舍录入数据,动作重复且枯燥。所以这个页面的核心交互诉求是"少点几下、少切几次输入框"。
我的处理方式是用 Element Plus 的 el-table 配合行内编辑:
- 页面默认展示所有宿舍的列表,每行包含"上次读数","本次读数"两个输入框,数字输入框自动聚焦,回车或 Tab 跳到下一行。
- 每行输入完自动计算用量和金额,实时展示在行尾,管理员可以边抄表边核对数是否合理(比如某宿舍用电量突然飙到 200 度,当场就能发现异常)。
- 最后统一点击"保存本月抄表",后端批量插入记录并预生成缴费单。
报修提交页面的重点是富交互:学生提交时要选择报修类型、填写描述、上传图片(拍摄漏水部位或损坏的插座)。图片上传我用的 Element Plus 的 el-upload 组件,后端配合一个上传接口,把文件存到本地服务器或者 MinIO 对象存储,回传 URL 存到工单表的 images 字段。
这里有一个交互细节,报修单详情页一定要有个时间线组件(el-timeline),把"提交时间→受理时间→开始维修时间→完成时间→确认时间"全部展示出来。学生看到时间线就会觉得流程透明,减少催单焦虑;管理员也能一眼看出哪个环节卡住了,方便督办。
缴费页面要注意的是:查询账单接口返回金额时必须使用字符串而不是数字。因为金额超过一定位数时,JavaScript 的 Number 精度会丢失,虽然水电费单笔金额不大,但统计报表里的总金额字段可能达到百万级别,还是可能踩精度坑。前端展示直接用 BigDecimal.toPlainString() 返回的字符串,避免任何中间转换运算。
4.3 路由权限控制:不同角色看到不同菜单
管理系统必须做菜单级权限控制,不然学生登录后能看到"抄表录入"菜单就闹笑话了。
路由配置我采用了"静态路由 + 动态路由"结合的方式:
- 静态路由只包含 /login、/404、/layout 框架页。
- 用户登录成功后,后端根据角色返回该角色可访问的菜单列表和路由组件名,前端用 router.addRoute 动态添加路由。
组件映射这里有个小坑:前端提前用 import.meta.glob 预注册所有页面组件,否则动态路由加载时无法匹配组件。写法如下:
const viewModules = import.meta.glob('../views/**/*.vue') export function buildRoute(menu) { return { path: menu.path, name: menu.name, component: viewModules[`../views/${menu.component}.vue`], meta: { title: menu.title, icon: menu.icon } } }Vite 的 glob 导入在构建时会扫描文件,把匹配的组件打包进产物,运行时通过 key 直接取组件对象,不会出现"找不到模块"的问题。
5. 常见问题与排查技巧实录
5.1 跨域问题:前后端联调第一道坎
开发环境前端跑在 5173 端口,后端跑在 8080 端口,直接请求必然跨越。我有两种解决方式,开发阶段推荐用 Vite 的 proxy 代理,生产环境推荐用 Nginx 反代。
Vite 代理配置(vite.config.js):
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } })前端请求 /api/login 时,Vite 会把请求转发到 http://localhost:8080/login,CORS 问题完全规避掉。注意后端接口不要写 /api 前缀,否则 rewrite 掉前缀后会路径不匹配。
后端如果确实需要开启跨域(比如某些客户端绕过代理直连后端),可以在 Spring Boot 里配置全局 CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600); } }配置跨域后如果前端还报跨域错误,优先检查请求是不是 OPTIONS 预检没过,比如 allowedHeaders 没加 Authorization。
5.2 Spring Boot 版本过高引发的编译报错
在一台电脑上新建项目,依赖自动下载了 Spring Boot 3.2.x,结果一跑就报错。检查发现是引用的 jjwt 0.9.1 在 jakarta 命名空间下无法识别 javax.crypto 相关的类,直接编译失败。
这里给大家两个处理思路:
- 方案一(推荐):手动把 Spring Boot 版本降级到 2.7.x。在 pom.xml 里
spring-boot-starter-parent的 version 明确写 2.7.18,之后 IDE 会自动重新下载对应依赖。 - 方案二:把 jjwt 替换成新版 0.12.x,并将
javax.servlet相关的代码改成jakarta.servlet。但 JPA、Security 的相关 API 在 Spring Boot 3.x 里也有变化,改动量比较大。
我的经验是:非必要不上 Spring Boot 3.x。除非你有虚拟线程、GraalVM 这类新特性的明确需求,否则现有业务系统 2.7.x 完全够用,而且生态兼容性更好。
5.3 Vue 页面数据不更新的疑难杂症
Vue 3 用了 Proxy 响应式,但依然有"数据改了,页面不刷新"的情况。最常见的原因是对象新增属性或者数组按下标赋值。
比如我从后端拿到账单列表后,需要给每个对象临时加一个 selected 属性用于勾选:
// 错误写法 res.data.forEach(item => { item.selected = false })这样写 Vue 的响应系统检测不到新属性,页面绑定 selected 后不会生效。正确写法是用 reactive 或者 ref 把整个数组先包成响应式:
const tableData = ref([]) const loadData = async () => { const res = await getBillList() res.data.forEach(item => { item.selected = false }) tableData.value = res.data }因为 tableData.value 整体被重新赋值了,Vue 的响应式系统会重新解析整个数组,新属性也被代理到。如果用 el-table 勾选列不生效,多半就是这个原因,把整个数组重赋值一次就能解决。
5.4 时间和金额显示问题速查表
| 症状 | 根因 | 解决方案 |
|---|---|---|
| 创建时间显示比实际晚 8 小时 | 数据库连接串未设置 serverTimezone | JDBC URL 显式加 serverTimezone=Asia/Shanghai |
| 前端显示时间格式混乱 | 后端返回 LocalDateTime 序列化为数组 | 全局配置 Jackson 日期格式化,返回 yyyy-MM-dd HH:mm:ss |
| 金额累加结果有误差 | 使用 double/float 计算金额 | 统一 BigDecimal,除法指定精度和舍入模式 |
| 金额显示过长小数 | BigDecimal 未设置 scale | setScale(2, RoundingMode.HALF_UP) |
| 前端金额精度丢失 | 后端返回数字类型 | 后端转字符串返回,前端只做展示不做运算 |
6. 实操心得与扩展建议
整个系统从零到落地,前后大概花了三周时间。要是让我重新做一遍,第一件事一定是先把状态机和金额精度问题理清楚,而不是一上来就开始写 CRUD——这两个问题在项目后期返工的成本最高。
前端联调阶段最值得投入时间的是接口文档。我推荐直接用 Apifox 或 Swagger 生成在线接口文档,前端不用反复问后端"这个字段啥意思",后端不用反复解释"你传的是 JSON 还是 FormData"。团队协作效率提升非常明显。
这个系统后续还有几个可以扩展的方向,供大家参考:
- 接入校园一卡通或微信支付,缴费单生成后直接在线支付,省掉线下收费环节。
- 加入宿舍评分模块,把报修响应速度、宿舍卫生检查结果汇总成评分,和文明宿舍评比联动。
- 用 ECharts 做能耗趋势分析,按楼栋、按月展示水电用量曲线,帮助后勤部门发现异常能耗(比如某栋楼深夜用水异常,可能存在漏水)。
最后分享一个我在实际项目里坚持的习惯:把抄表录入和缴费结算的权限分开。录入数据的人不能同时审核账单,至少要在系统里区分两个管理员角色,哪怕实际只有一个人管理,也要预留这个权限边界。这样万一出现数据错误,追溯流程是清晰的,不会出现"自己录错、自己确认、自己背锅"的局面。系统再小,权限设计的底线不能丢。