
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。下面给一张精简的核心表设计参考字段只列关键项表名关键字段用途说明userid, username, password(BCrypt), role, dormitory_id存储三类账号role 区分 student / admin / rootbuildingid, name, manager_name楼栋基础信息dormitoryid, building_id, room_no, bed_count房间信息bed_count 用于按人均摊水电费electricity_recordid, dormitory_id, month, last_reading, current_reading, unit_price, amount每月电费抄表数据amount 由度数乘以单价计算water_recordid, dormitory_id, month, last_reading, current_reading, unit_price, amount同电费单位按吨计payment_orderid, user_id, dormitory_id, order_no, total_amount, status(0/1), pay_time缴费单状态 0 未缴 1 已缴repair_orderid, dormitory_id, reporter_id, category_id, description, images, status(0-4), assignee, finish_time报修工单状态机下面细讲repair_categoryid, name, response_timeout报修类型比如水龙头、电路、门窗operation_logid, 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependencyJPA 和 MyBatis-Plus 二选一就可以。JPA 的优势是实体类之间关联关系清晰自动建表省心MyBatis-Plus 的优势是复杂 SQL 写起来更顺手、代码生成器可以一键生成 CRUD。我个人在这个项目里用了 JPA因为表关联嵌套关系多楼栋→宿舍→账单用注解映射比手写 SQL 直观。application.yml 里有一个关键配置必须处理——时间字段的时区问题。MySQL 默认时区是 UTC中国是 UTC8不设置的话时间字段会差 8 小时。我的配置习惯是spring: datasource: url: jdbc:mysql://localhost:3306/dormitory_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 MapInteger, ListInteger 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) { ListInteger 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 status1, versionversion1 WHERE id? AND version?。第二个请求进来时版本号已经变了更新影响行数为 0直接提示订单已处理请勿重复操作。这是我强烈建议任何涉及确认支付类业务都必须加的兜底方案。4. 前端 Vue 页面设计与联调4.1 项目搭建Vue CLI 还是 Vite创建前端项目时我遇到了选型问题最后用了 Vite。原因很简单Vite 冷启动速度比 Webpack 快一个量级而且内置了对 Vue 3 的一等支持。项目开发时改一个组件文件热更新几乎是秒级体验非常好。创建命令也很简单npm create vitelatest dormitory-web -- --template vue cd dormitory-web npm install npm install vue-router4 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.jsexport default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })前端请求 /api/login 时Vite 会把请求转发到 http://localhost:8080/loginCORS 问题完全规避掉。注意后端接口不要写 /api 前缀否则 rewrite 掉前缀后会路径不匹配。后端如果确实需要开启跨域比如某些客户端绕过代理直连后端可以在 Spring Boot 里配置全局 CORSConfiguration 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 小时数据库连接串未设置 serverTimezoneJDBC URL 显式加 serverTimezoneAsia/Shanghai前端显示时间格式混乱后端返回 LocalDateTime 序列化为数组全局配置 Jackson 日期格式化返回 yyyy-MM-dd HH:mm:ss金额累加结果有误差使用 double/float 计算金额统一 BigDecimal除法指定精度和舍入模式金额显示过长小数BigDecimal 未设置 scalesetScale(2, RoundingMode.HALF_UP)前端金额精度丢失后端返回数字类型后端转字符串返回前端只做展示不做运算6. 实操心得与扩展建议整个系统从零到落地前后大概花了三周时间。要是让我重新做一遍第一件事一定是先把状态机和金额精度问题理清楚而不是一上来就开始写 CRUD——这两个问题在项目后期返工的成本最高。前端联调阶段最值得投入时间的是接口文档。我推荐直接用 Apifox 或 Swagger 生成在线接口文档前端不用反复问后端这个字段啥意思后端不用反复解释你传的是 JSON 还是 FormData。团队协作效率提升非常明显。这个系统后续还有几个可以扩展的方向供大家参考接入校园一卡通或微信支付缴费单生成后直接在线支付省掉线下收费环节。加入宿舍评分模块把报修响应速度、宿舍卫生检查结果汇总成评分和文明宿舍评比联动。用 ECharts 做能耗趋势分析按楼栋、按月展示水电用量曲线帮助后勤部门发现异常能耗比如某栋楼深夜用水异常可能存在漏水。最后分享一个我在实际项目里坚持的习惯把抄表录入和缴费结算的权限分开。录入数据的人不能同时审核账单至少要在系统里区分两个管理员角色哪怕实际只有一个人管理也要预留这个权限边界。这样万一出现数据错误追溯流程是清晰的不会出现自己录错、自己确认、自己背锅的局面。系统再小权限设计的底线不能丢。