尧图精选

SpringBoot+Vue宿舍水电费报修管理系统开发实践

🕒 发布时间:2026/10/1 3:15:10 📁 来源:尧图网络
宿舍管理员在期末统计水电费时的表情和对着Excel崩溃的程序员是一样的。这类场景催生了一大批“宿舍管理系统”毕设与课设但真正能把业务逻辑讲清楚、能把SpringBoot和Vue玩明白的项目并不多。今天这个「SpringBoot学生宿舍水电费报修管理系统」名字虽然很长但拆开来看就是两条业务主线水电费计量与收缴、报修工单流转技术上就是经典的SpringBoot Vue前后端分离。这篇文章不打算只讲“做了什么”而是把整个项目从需求拆解到核心代码再到部署踩坑按我实际动手做的顺序完整摊开。先说清楚这套系统适合谁。如果你是准备做Java方向课程设计或毕业设计的学生这个项目是很标准的参考范式如果你想快速了解一个真实业务系统里JWT鉴权、工单状态机、账单分期结算该怎么落地这篇也能给你足够多的细节。我不会贴完整代码库但会把最容易卡住你的设计思路和数据表关系讲透并配上真正在本机跑通的关键代码片段。1. 项目整体拆解这个系统到底在解决什么问题1.1 从标题里读出的需求画像项目标题里有一个很关键的标识“0s7h5”这是很多在线代码生成平台自动生成的唯一标识说明原型出自自动化生成工具。这类项目通常包含三个默认角色学生、宿舍管理员、系统管理员这是宿舍管理SaaS的标配三角色模型。我按照这个题目实践时自己追加了“维修工”角色因为报修工单没人接单、维修进度无人更新的话这个系统就只是个漂亮的前端壳子。真实场景的痛点是这样的宿舍水电费如果靠人工抄表再录入Excel一到月底就混乱表底数记错、单价算错、个别宿舍拒缴全是扯皮现场。报修流程更麻烦学生打电话报修没有留痕维修人员是否上门、是否修完全靠问。这套系统要解决的就是把这些线下流程搬到线上做到“账单有记录、工单有状态、人人都能查”。由此拆解出来的核心功能模块就很清晰了用户管理三角色登录注册管理员管理学生和宿舍信息。水电费管理录入每个宿舍的水电表当前读数系统自动算出周期用量和应缴金额学生在线查看账单并标记已缴费。报修管理学生提交报修工单宿舍号、问题描述、预约时间维修工接单、处理、完工管理员全程可见。数据统计宿舍楼栋的用水用电趋势图表维修处理时效统计。1.2 为什么非要用SpringBoot Vue这套组合这个选择几乎不用思考是当前Java后台管理系统的最主流组合。SpringBoot负责把SSM框架繁琐的XML配置全部干掉内嵌Tomcat容器打个jar包就能跑Vue负责前端页面的数据驱动渲染配合Element UI这类组件库后台管理系统的表格、表单、弹窗写起来比JSP时代快一个量级。拿实际体验来说用JSP写这个项目的时候页面和后端代码纠缠在一起每次改个按钮都要重启服务热部署还得装插件。换成前后端分离之后前端用Vite起本地开发服务器后端用DevTools热重启两边并行开发互不干扰联调时前端把我本地后端接口地址配进.env.development文件里就行体验完全不在一个水平上。选型时也考虑过一个重点项目复杂度。宿舍水电报修这类系统并发量几乎没有不需要微服务不需要Redis集群SpringBoot单应用加MySQL关系型数据库就是最优解。你非要上Spring Cloud除了给答辩添堵没有别的用处。1.3 业务模块划分与数据模型设计开始写代码前先把数据表设计出来这步做扎实了后面能省一半时间。我设计的核心表有这些。用户表sys_user字段名类型说明idbigint主键usernamevarchar(50)登录名学生用学号passwordvarchar(200)BCrypt加密后的密文rolevarchar(20)枚举STUDENT/MANAGER/REPAIRERreal_namevarchar(50)真实姓名dormitory_idbigint学生关联的宿舍IDphonevarchar(20)手机号宿舍表dormitory字段名类型说明idbigint主键building_novarchar(20)楼栋号room_novarchar(20)房间号floorint楼层meter_novarchar(50)水表号/电表号max_peopleint可住人数statustinyint0空闲1入住账单表bill字段名类型说明idbigint主键dormitory_idbigint宿舍IDbill_typetinyint1水费2电费periodvarchar(20)账期如2025-03last_readingdecimal(10,2)上次读数current_readingdecimal(10,2)本次读数usage_amountdecimal(10,2)用量unit_pricedecimal(10,2)单价total_amountdecimal(10,2)应缴金额statustinyint0未缴1已缴create_timedatetime创建时间报修工单表repair_order字段名类型说明idbigint主键order_novarchar(50)工单编号student_idbigint报修人dormitory_idbigint报修宿舍descriptiontext问题描述categoryvarchar(50)类别水/电/家具/其他prioritytinyint优先级 1高 2中 3低statusvarchar(20)PENDING/ACCEPTED/PROCESSING/DONE/CANCELEDrepairer_idbigint维修工create_timedatetime提交时间accept_timedatetime接单时间finish_timedatetime完成时间关键表关系学生通过dormitory_id关联宿舍账单通过dormitory_id绑定到具体宿舍而不是个人报修订单通过student_id关联报修人、通过repairer_id关联维修工。状态用字符串枚举而不是数字是为了可读性代码里写order.getStatus().equals(ACCEPTED)比猜status 2直观得多。数据表设计阶段最容易犯的错误是给账单表加一个student_id字段按个人分摊费用。实际业务上费用是按宿舍结算的一个宿舍住4个人水电表只有一个明细到个人没有意义。这个说清楚答辩时能少很多质疑。2. 后端核心实现认证、账务与工单流转后端我用的是SpringBoot 2.7.x MyBatis-Plus MySQL 8.0 Hutool工具库。MyBatis-Plus相比原版MyBatisServiceImpl里自带getOne、lambdaQuery、分页插件写这种CRUD为主的系统效率高很多不用写大量重复的ResultMap和手写SQL。2.1 用户体系与JWT认证会话管理有两个方案可选传统Session和JWT。Session在分布式部署时要做黏性会话或引入Redis共享JWT天然无状态前端拿到Token后存进LocalStorage每次请求在拦截器里把Token塞进请求头后端拦截器里解析校验前后端分离项目里这个方案最顺。依赖引入dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependencyJWT工具类核心代码public class JwtUtil { private static final String SECRET your-secret-key-change-in-production; private static final long EXPIRE 1000 * 60 * 60 * 24; // 一天 public static String createToken(Long userId, String role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }登录接口逻辑先根据用户名查用户再用BCrypt校验密码密码校验通过后生成JWT返回给前端。密码为什么不能明文存因为一旦数据库泄露所有用户的学号加123456就全暴露了BCrypt每次加密都会混入随机盐哪怕两个用户密码相同密文也不同造价成本极高。这个点是面试官和答辩老师非常喜欢问的。JWT拦截器里我额外做了管理员操作校验。比如普通学生调删除用户的接口理论上前端不显示按钮就够但懂行的人直接拿PostMan调接口一样能删。所以后端必须有对应的权限断言Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; // 跨域预请求直接放行 } String token request.getHeader(Authorization); if (token null || token.isEmpty()) { response.setStatus(401); return false; } try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } }注意这里返回401而不是403原因是语义上你没有提供有效的认证凭证而不是有凭证但权限不足。403的语义用于处理已登录但越权访问的场景区分清楚对调试和前端提示都有帮助。2.2 水电费账单金额计算与幂等设计水费电费的记账逻辑不复杂核心是每个账期管理员录入各个宿舍的当前表底数系统自动减去上次表底数算出用量再乘单价得到账单。这里最容易出病的细节是录入重复了。管理员不小心点两次提交或者同一个宿舍在同一个账期录了两次当前读数系统就会生成两张账单。我给bill表加了一个唯一索引ALTER TABLE bill ADD UNIQUE KEY uk_dormitory_period_type (dormitory_id, period, bill_type);同时在Service层写幂等判断插入前先查一遍账期是否已有记录。前端也做了相应处理提交成功后按钮禁用。累死累活的调试经历告诉我后端幂等永远不能依赖前端按钮的vb状态这是第一条铁律。账单状态流转设计成待缴费 - 已缴费。缴费方式在毕设阶段我做得比较简单管理员可以标记“已收款”或者学生在页面上点击“模拟支付”并填入支付流水号。如果接入真实支付宝/微信支付需要企业资质和回调验签复杂度上升不是一个量级展示阶段不必要。2.3 报修工单状态机的设计与流转报修工单是让这个项目有真实业务感的关键模块。我定义了五个状态PENDING学生提交工单等待接单。ACCEPTED维修工接单此时工单锁定其他维修工不可抢。PROCESSING维修工点击“开始维修”应对的是维修工到达现场后进行的实际操作。DONE维修完成学生有“确认完工”的入口防止骗工时。CANCELED学生或者管理员取消需要填写取消原因。状态流转在Service层做校验不能用Controller里散落的if判断。我封装了一个状态机核心方法public void transition(Long orderId, String targetStatus, Long operatorId) { RepairOrder order getById(orderId); if (order null) { throw new BizException(工单不存在); } SetString allowed allowedTransitions.get(order.getStatus()); if (!allowed.contains(targetStatus)) { throw new BizException(String.format(工单状态不允许从%s流转到%s, order.getStatus(), targetStatus)); } // 操作人身份校验 ... order.setStatus(targetStatus); updateById(order); }这个写法的好处在于状态流转的规则在一个Map当前状态, 允许流转到的目标状态集合里统一维护逻辑一眼能看全后续新增状态只需要改这个Map别处代码不用动。同时关键节点时间戳要记录下来。工单创建时间、接单时间、完成时间这三项是后续统计维修时效的数据基础。比如“平均接单响应时长”就是AVG(accept_time - create_time)“平均完单时长”就是AVG(finish_time - create_time)没有这些时间字段统计报表就是一个空壳。2.4 通知推送与定时任务这个功能属于加分项但对真实宿舍管理很实用维修工提交“完成”之后学生端应该收到通知管理员录入新账期账单后学生也应该收到提醒。我的做法是设计一张notification表在每个业务节点往里写入通知流水学生登录后拉取未读通知。定时任务我加了一个没睡醒但半年没用上的功能每月1号自动生成上个月的水电账单。用SpringBoot自带的Scheduled注解实现即可不需要引入xxl-job这种分布式调度框架。代码这样写Scheduled(cron 0 30 0 1 * ?) // 每月1号0点30分执行 public void generateMonthlyBills() { String lastMonth LocalDate.now().minusMonths(1).toString().substring(0, 7); ListDormitory dormitories dormitoryService.list(); for (Dormitory d : dormitories) { // 构建上个月的账单并保存 ... } }不过要提醒一点自动生成账单的前提是管理员已经录入各宿舍的当前表底数。如果没录生成的账单金额就是0没有意义。所以实际的定时任务应该先查表底数是否完整不完整的宿舍跳过并记录日志。这正是业务逻辑的复杂之处项目的难度和价值往往就体现在这些边缘情况的处理上。3. 前端核心实现Vue3 Element Plus的工程化落地前端我没有用Vue2直接上了Vue3。Vue3的Composition API配合script setup写业务页面时比Options API直观得多。UI组件库用Element Plus这是Vue3生态下最成熟的桌面端后台组件库。3.1 项目脚手架与代码组织用Vite创建项目比对Webpack的创建速度和构建速度一个词舒服。npm create vitelatest dormitory-frontend -- --template vue cd dormitory-frontend npm install element-plus element-plus/icons-vue npm install vue-router4 pinia axios目录结构上我按业务模块拆分而不是按文件类型堆叠。实际开发维护的时候按功能域组织比按“views、components、utils”三层扁平堆叠舒服得多src/ ├── api/ # 接口请求模块按业务拆分 │ ├── auth.js │ ├── bill.js │ └── repair.js ├── views/ # 页面级组件 │ ├── dashboard/ │ ├── bill/ │ ├── repair/ │ └── system/ ├── router/ # 路由配置 ├── store/ # Pinia状态管理 ├── components/ # 公共组件 └── utils/ # 工具函数如request.js封装的axios实例3.2 路由守卫与权限控制前端路由权限和后端JWT校验是两套互补的防线。后端负责接口安全前端负责页面可见性。学生的侧边栏只显示“我的宿舍、账单查询、我要报修”管理员的侧边栏才显示“宿舍管理、账单生成、工单派发、数据大屏”。这套权限最直观的落地方式就是动态路由 路由守卫。Vue3中的路由守卫写法router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.path /login) { next() return } if (!token) { next(/login) return } if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) return } next() })另外每次请求如果返回401状态码axios拦截器里要清掉本地Token强制跳转登录页。否则Token过期之后用户还停留在页面上点任何按钮都是静默失败体验很差。3.3 核心页面拆解列表页、表单页、看板页后台管理系统里的页面翻来覆去就是三类列表页、表单页、看板页。这个项目把三类页面都覆盖了。账单列表页表格 搜索 分页 状态筛选。ElTable自带的筛选和排序功能够用但“账期”这一列要用日期范围选择器而不是文本框。后端接收两个参数periodStart和periodEnd然后在SQL里做between查询。前端传递参数// api/bill.js export function getBillPage(params) { return request({ url: /api/bill/page, method: get, params }) }分页参数统一叫pageNum和pageSize后端用MyBatis-Plus分页插件接收。不要搞出一套currentPage一套page前后端约定不一致是最低级且最常见的联调翻车原因。报修表单页从学生点“提交报修”到选择宿舍下拉框、填写问题描述、上传故障照片核心是表单校验。Element Plus的el-form配合rules规则做校验注意照片上传用的是el-upload选择照片后要调后端接口返回URL表单里存的是URL字段而不是File对象否则提交表单时序列化会出问题。数据看板页宿舍水电费用量趋势折线图 报修状态占比饼图。用ECharts安装echarts依赖后引入组件template div refchart stylewidth:100%; height:400px;/div /template script setup import * as echarts from echarts import { onMounted, ref } from vue const chart ref(null) onMounted(() { const myChart echarts.init(chart.value) myChart.setOption({ xAxis: { type: category, data: [1月, 2月, 3月] }, yAxis: { type: value }, series: [{ type: line, data: [120, 132, 150], smooth: true }] }) }) /script这套数据看板的接口要单独说一句不要在页面加载时发三个并行请求然后把数据放在各自的变量里再拼图表直接让后端提供一个聚合接口GET /api/statistics/dashboard返回一个整体DTO前端一次请求全部渲染。聚合逻辑放后端SQL里做前端保持简单。3.4 与后端的跨域联调开发环境下前端跑在5173端口后端跑在8080端口跨域是必然的。两个方案让后端加CrossOrigin注解或配置CORS或者在Vite配置代理。我建议走Vite代理这个方案前端独立可控不用麻烦后端同事或者说不用麻烦你自己改两处代码。在vite.config.js里配置export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/bill/page会被代理转发到http://localhost:8080/api/bill/page浏览器的地址栏一直是5173不存在跨域问题。生产环境部署时如果用Nginx做反向代理同样配置一个location /api { proxy_pass http://后端地址; }即可这个模式从前端开发一直沿用到了生产。跨域配置里有一个坑必须点名代理生效的前提是后端Controller的接口路径确实是/api开头且你的axios实例里baseURL也要设置成/api。有些人后端接口没有统一前缀举报进来发现代理配了但不生效就是路径前缀没对上。4. 实操中的坑与排查实录这个项目我从零跑通到部署踩了不少能让你怀疑人生的坑按踩坑顺序写下来这是全文最值得细看的部分。4.1 账单金额精度丢失水电费账单的total_amount是decimal(10,2)但Java实体类如果用float或double接收会在小数位出现精度问题比如0.1 0.2计算结果可能是0.30000000000000004。这不只是理论风险我自己就遇到过55.10元的账单在页面上显示成55.099999999999994的情况。这是教科书级教训金额相关的实体字段一律用BigDecimal数据库字段用decimal前后端交互传输的是字符串或数字精度由后端控制。MyBatis-Plus会把数据库decimal映射为BigDecimal前端拿到的是JSON数字金额小数点后两位不会有偏差。计算时也避免连续乘除的精度丢失BigDecimal usage currentReading.subtract(lastReading); BigDecimal amount usage.multiply(unitPrice).setScale(2, RoundingMode.HALF_UP);4.2 时区与时间格式化问题数据库的create_time我定义为datetimeMyBatis返回的是java.time.LocalDateTime序列化给前端的时候默认格式是一长串数组或ISO格式页面上直接渲染会很难看。统一在application.yml里配置JSON序列化格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai另外数据库连接串务必带上serverTimezoneAsia/Shanghai否则如果MySQL和Java服务时区不一致拿到的create_time会差8个小时。这个坑在初次部署时非常隐蔽表现为工单提交时间是14点但列表里显示22点几乎所有人第一时间都怀疑是MySQL的问题。4.3 跨域与SESSION、JWT的坑前后端分离项目用JWT替代Session隐含一个使用习惯的转变后端不能再依赖HttpSession取登录用户。我第一次改造时有几处还在用session.getAttribute(userId)联调时发现前端的session始终是空的数据一直报 null 指针。后来统一改成从请求头的Token里解析用户ID在Controller通过RequestAttribute(userId) Long userId取问题才算根治。如果你不希望每次方法里都写一遍解析逻辑可以用HandlerMethodArgumentResolver自定义解析器把“从请求头取用户”的逻辑收敛到一个注解上。不过毕设阶段直接在需要的方法里用RequestAttribute就够了别过度设计。4.4 报修列表的N1查询问题申报修列表时页面要显示学生姓名、宿舍号、维修工姓名第一版我用MyBatis-Plus的page()直接查repair_order表然后循环查用户表。数据量小的时候看不出问题但智能宿舍全校几百单的时候一次列表加载慢得肉眼可见。排查方法很简单开启MyBatis-Plus的SQL日志观察控制台输出如果一次页面请求对应了几百条SQL那就是N1。后来用Select写联表查询或者在lambdaQuery后手动补充关联字段把SQL从13条降到3条整个页面秒开。对于毕设答辩老师来说主动提“我优化了SQL的N1问题”比你堆砌再多功能都有说服力。4.5 打包与部署的环境差异本地开发一切正常打包部署就404最常见的两类原因一是前端路由用了createWebHistory默认开启HTML5的history模式刷新页面时如果Nginx没有配置try_files回退到index.html就会出现404。部署时要么改成createWebHash要么在Nginx配置里加上location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }二是后端接口路径在代理转发时丢失了前缀。SpringBoot的Controller如果统一带/api前缀而Nginx的proxy_pass转发到后端时也配置了location /api两者叠加就会变成/api/api。这个报错特征非常明确看后端日志里的请求路径就知道了发现自己犯过这种低级错误时心里第一反应是想穿越回去把当时的自己敲醒。打包命令方面前端执行npm run build生成dist目录后端执行mvn clean package -DskipTests生成jar包。部署到Linux服务器上前台用java -jar测试确认无误后用nohup java -jar xxx.jar 守护进程即可。写在最后的一点实在话这个项目做下来我最大的感受是业务系统的复杂度不在技术而在对规则的理解。水电费账单的幂等、工单状态机的流转约束、不同角色可见数据的范围这些才是真正花时间的地方。SpringBoot和Vue只是工具工具不会过时过时的是只背API不理解业务的开发者。如果你正在做类似项目我的建议是先把业务规则一条条写清楚再动手建表、写接口、画页面。不要一上来就敲代码不然到联调阶段会发现前后端各自为政接口字段一堆对不上。针对这个宿舍水电费报修系统往后还可以把微信小程序端接上学生在手机上报修、查账单体验会比浏览器端更好。后端接口设计时留好复用余地小程序端其实就是另一个Vue前端项目的活儿。祝你一次跑通少踩我踩过的那些坑。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →