最近把一个基于SpringBoot+Vue的公寓报修管理系统从设计到实现完整做了一遍,整个过程比预想中磨人不少。表面上看,这套系统就是一个“提交报修单 + 处理报修单”的CRUD,但真开工以后才发现,光是角色权限、状态流转、前后端联调、打包部署这几件事,就够踩掉一层皮。这篇文章就是整个项目的完整复盘,写给正在做类似毕设、或者打算给公寓/宿舍物业落地一套报修闭环的同学。技术栈就是标题里说的Java、SpringBoot、Vue、MySQL、MyBatis,范围不贪大,但闭环要完整。
1. 需求梳理与系统边界:一张报修单的完整生命周期
1.1 三类角色,权限不能靠前端隐藏按钮
公寓报修系统涉及三种角色:住户、维修工、管理员。住户是报修单的发起者,维修工是执行者,管理员是分派者和统计者。一开始我以为只要做增删改查就够了,真正动手才发现,权限边界才是第一优先级:住户能看全部工单吗?明显不行。维修工能改别人的单子吗?也不行。管理员能代替住户评价吗?同样不行。所以从建表第一天起,就要把“谁能操作什么”固定下来,否则后面接口一多就乱套。
| 角色 | 核心操作 | 数据可见范围 |
|---|---|---|
| 住户 | 提交报修、查看自己的单据、取消待受理单、完成后评价 | 本人创建的单 |
| 维修工 | 查看被分配的单、开始维修、填写处理结果、完成工单 | 分配给自己的单 |
| 管理员 | 维护楼栋房间和账号、派单、关闭异常单、统计报表 | 全部单据 |
有了这张表,后端接口怎么写就非常清晰:凡是维修工操作的接口,要么路径上带维修工ID,要么在Service里校验当前登录用户是不是工单的assignee_id;凡是住户查询接口,必须自动拼上当前用户ID作为过滤条件。很多同学做项目时习惯用前端v-if把按钮藏起来,以为这样权限就安全了,其实前端只是展示层,真正的权限控制必须发生在后端,否则绕过界面直接调接口就能看到别人的数据。这个意识要从第一行代码开始贯彻,不能等到联调了才想起来补。
1.2 报修单状态流转:用状态机代替散落的if
每套报修系统都必须回答一个问题:一张报修单到底走哪些状态。我采用的方案是六个状态:待受理、已派单、维修中、已完成、已评价、已取消。前四个是主干流程,已评价是整条链路的闭环,已取消是用户主动退出。有人喜欢用简单的“处理中/已完成”两个状态,那样虽然省事,但对真实业务来说粒度太粗,管理员根本分不清“派了没有”“维修工开始动手了没有”。
| 当前状态 | 可执行操作 | 下一个状态 | 操作人 |
|---|---|---|---|
| 待受理 | 管理员派单 | 已派单 | 管理员 |
| 待受理 | 用户取消 | 已取消 | 住户 |
| 已派单 | 开始维修 | 维修中 | 维修工 |
| 维修中 | 完成维修 | 已完成 | 维修工 |
| 已完成 | 用户评价 | 已评价 | 住户 |
这里要特别强调,状态流转关系不能只画一张流程图贴在文档里,要在代码里做防呆。后端更新状态时,先校验当前状态是否是目标状态的合法前驱,不是就直接抛业务异常。否则用户从接口层面把已派单改成已评价,或者维修工把别人的单子从待受理直接改成已完成,数据库里就会出现脏状态。把这些判断收敛到一个专门的校验方法里,后面加页面、加接口都不容易漏。
1.3 功能范围怎么定:别一上来就做数据大屏
很多人在项目初期喜欢把功能堆满,智能推荐、实时大屏、消息推送都想上。我这次冷静了一下,报修系统的核心价值是“闭环”,先把闭环跑通,再谈外围。最终功能范围定为:账号与角色管理、楼栋房间管理、报修单管理、派单与处理流转、评价反馈、统计报表六个模块。统计报表可以做得简单点,按楼栋、按报修类别统计总量和完成率,够用就行。这个范围既适合作为毕业设计答辩的完整系统,也适合在实际公寓里小范围试用。
我知道不少人会觉得“没有通知提醒算什么报修系统”,但通知功能需要额外的推送通道和网络配置,会明显拉高项目复杂度。我的建议是,第一版不主动推送,只让用户登录后看到状态变化;等核心链路稳定了,再接入消息通知。这相当于先把一条业务链路做深做透,而不是同时铺开十个半成品,后者的维护成本远高于收益。
2. 技术选型与工程结构:为什么这一套组合最顺手
2.1 后端选SpringBoot+MyBatis,不是跟风
SpringBoot成为Java单体应用的主流选择,自然有它的道理。它最大的优势是约定优于配置:内嵌Tomcat、自动装配依赖、打包即运行,非常适合单人维护的小中型管理系统。换成传统SSH框架的话,光配置XML就要折腾半天,Spring与MyBatis的整合同样费劲,显然没必要。至于为什么不选Spring Data JPA,我的理由是:报修系统里有大量联表和统计SQL,JPA虽然也能写原生SQL,但动态条件处理起来远不如MyBatis直观。标题里写明MyBatis,那就老老实实手写Mapper XML,不要私下换成MyBatis-Plus。
这里多说一句:MyBatis-Plus确实方便,BaseMapper能省掉大量单表CRUD,但答辩时老师多半会问“MyBatis和MyBatis-Plus的区别”。如果你的项目实际用的是Plus,讲原理时却按MyBatis讲,容易露馅。反过来,手写一遍单表CRUD也就几十行SQL,完全可控,还能在毕业论文里多写一节“数据访问层设计”,怎么看都不亏。
2.2 前端用Vue,开发效率体现在组件化思维
Vue在国内前端生态里足够成熟,组件库、路由、状态管理、脚手架一应俱全。这次我用了Vue 3配合Vite和Element Plus,开发体验确实比Vue 2加Webpack快不少。但如果目标机器的浏览器版本较老,或者本机Node版本不新,Vue 2 + Element UI会更稳妥。两种选择都不影响核心业务设计,只要前后端接口返回结构保持一致就行。
前端工程里最关键的其实不是组件选哪个,而是把“数据流”理清楚。报修单列表页、详情页、提交页、管理后台都在读写同一份数据,如果每个页面各自维护一套请求逻辑,项目很快就会失控。我的做法是:api目录统一放接口定义,views目录只负责页面渲染,登录用户信息放store里。组件之间尽量不要跨多层传对象,能通过事件或store传递就用它们,避免出现父子组件props套三层导致维护困难的状况。
2.3 目录结构:给看源码的人留一条清晰的路
“完整源码”合不合格,可读性比很多人想象的更重要。我自己接过别人留的代码,最怕的就是所有类堆在一个包里,没有分层、没有规范。这次项目,后端明确划分了controller、service、mapper、entity、dto、config、common七个包,前端则是router、api、views、components、store、utils六个目录。有人觉得分层会多写很多无关代码,但实际收益很大:出问题的时候,先查controller的参数接收,再看service的业务判断,最后查mapper里的SQL,定位速度非常快。
下面这个简化版后端包结构,可以直接当作项目骨架使用。
com.example.repair ├── controller // 接收请求,参数校验,返回Result ├── service // 业务逻辑,状态流转校验 ├── mapper // MyBatis接口类,对应resources/mapper/*.xml ├── entity // 与数据库表对应的实体 ├── dto // 前端传入的请求对象 ├── vo // 返回给前端的视图对象 ├── config // 跨域、拦截器、MyBatis配置 └── common // 统一返回、异常、常量枚举、工具类前端目录可以这样组织:
src ├── api // 所有axios请求统一放在这里 ├── router // 路由定义与登录守卫 ├── views // 页面组件,一个路由对应一个页面 ├── components // 列表、表单、图片上传等通用组件 ├── store // 登录用户信息、全局状态 └── utils // axios实例、时间格式化等工具这套骨架不局限于公寓报修系统,任何前后端分离的管理类项目都可以照搬。它的价值在于把职责边界划清楚,让协作和复用变得容易。
3. 数据库设计与状态机接口:核心功能落地的全过程
3.1 核心表设计:报修单必须单独建一张主表
数据库是整个系统的地基。我需要先明确有哪些表:用户表、楼栋表、房间表、维修工表、报修单表、处理记录表、评价信息表。其中报修单表最核心,我单独拿出来讲。单号、报修人、房间、类别、描述、图片、状态、分配人、提交时间、更新时间、完成时间、评分、评价内容,都要能查到。图片字段存相对路径,多个图片用逗号分隔,不要直接存Base64,否则数据库会迅速膨胀。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(32) | 业务单号,如BX202501010001 |
| user_id | bigint | 报修人ID |
| room_id | bigint | 房间ID |
| category | varchar(20) | 报修类别:水、电、门窗、设备 |
| description | varchar(500) | 问题描述 |
| images | varchar(1000) | 图片路径,逗号分隔 |
| status | tinyint | 0待受理 1已派单 2维修中 3已完成 4已取消 5已评价 |
| assignee_id | bigint | 维修工ID |
| create_time | datetime | 提交时间 |
| update_time | datetime | 最近更新时间 |
| finish_time | datetime | 完成时间 |
| score | tinyint | 评分,1到5分 |
| comment | varchar(500) | 评价内容 |
| is_deleted | tinyint | 逻辑删除标记 |
这张表基本覆盖所有业务流程。统计报表按create_time分组、按楼栋和类别聚合即可,不需要额外建冗余的统计表。这里有个容易被忽略的细节:索引。常见查询是“按用户查自己的单”“按状态查待办单”“按时间范围导出台账”,所以至少要给user_id、status、create_time建联合索引。否则数据量到几千条以后,列表查询会肉眼可见地变慢。
3.2 状态流转在接口层怎么落地
后端接口可以按角色分成四类:住户端、维修工端、管理员端、公共接口。对报修单状态的操作,最好统一收敛到Service层,不要在Controller里直接拼SQL。以管理员派单为例,调用链是:Controller接收参数,Service校验维修工是否存在,校验订单状态是否为待受理,更新assignee_id和status,再写一条处理记录。每一步都不能跳过,因为单个接口看似只是一个update,组合起来就是一条完整业务流程。
下面用一段Java伪代码展示“开始维修”这个动作的核心校验逻辑,帮助理解状态机落地方式:
public void startRepair(Long orderId, Long loginUserId) { RepairOrder order = repairOrderMapper.selectById(orderId); if (order == null) { throw new BizException("报修单不存在"); } if (!order.getAssigneeId().equals(loginUserId)) { throw new BizException("只能操作分配给你的工单"); } if (!RepairStatus.WAIT_START.getCode().equals(order.getStatus())) { throw new BizException("当前状态不允许开始维修"); } order.setStatus(RepairStatus.REPAIRING.getCode()); repairOrderMapper.updateById(order); repairLogMapper.insertLog(orderId, "维修工开始维修", loginUserId); }我把它叫“防呆校验”,目的不是增加代码量,而是防止脏状态进入数据库。实际开发中,前端按钮可能因为网络延迟被连点两次,不同角色的页面可能同时打开,如果没有这层限制,状态就会被随意覆盖。把“只能从某个状态变到某个状态”的规则固化下来,系统才真正可靠。处理记录表也很有用,每次状态变更都插入一条日志,出了问题能回溯是谁在什么时间改的,排查效率高很多。
3.3 统一返回与全局异常:少写80%的try-catch
前后端分离项目要保持接口返回格式一致。我定义了一个Result类,字段是code、message、data。成功时code为200,业务失败时code为固定错误码。前端axios拦截器根据code统一弹消息,如果每个接口自己拼JSON,前端就没办法做统一处理。后端做法是:Service层抛出BizException,Controller层使用@RestControllerAdvice统一捕获并转换成Result返回。这样Controller里不会塞满try-catch,代码看上去干净不少,出错时的日志定位也更直接。
Java时间类型的坑也必须提前处理:LocalDateTime默认序列化出来的格式往往不直观。我通常在application.yml里配置全局时间格式,或者写一个Jackson配置类,让所有LocalDateTime输出为yyyy-MM-dd HH:mm:ss。这个配置看起来不起眼,但联调阶段几乎每个刚接触前后端分离的人都会踩一次,不如一开始就定好。
接口清单建议做成下面这样的简单表格,方便开发时对照,也方便写进论文:
| 接口 | 说明 | 访问角色 |
|---|---|---|
| POST /api/repair/submit | 提交报修 | 住户 |
| GET /api/repair/myList | 我的报修分页列表 | 住户 |
| PUT /api/repair/assign | 管理员派单 | 管理员 |
| PUT /api/repair/start | 开始维修 | 维修工 |
| PUT /api/repair/finish | 完成维修 | 维修工 |
| PUT /api/repair/cancel | 取消报修 | 住户 |
| POST /api/repair/evaluate | 评价工单 | 住户 |
3.4 统计SQL:聚合要下推到数据库
管理员端大概率需要“按楼栋统计维修量”之类的报表。我见过有人把订单全部查出来,再在Java里用stream做groupBy,数据量小的时候没问题,数据一多就会卡。正确做法是把聚合操作交给MySQL,一条GROUP BY就能完成。下面这条SQL可以统计每个楼栋的报修总数和完成率:
SELECT b.name AS building_name, COUNT(r.id) AS total_count, SUM(CASE WHEN r.status IN (3, 5) THEN 1 ELSE 0 END) AS finished_count, ROUND(SUM(CASE WHEN r.status IN (3, 5) THEN 1 ELSE 0 END) / COUNT(r.id) * 100, 1) AS finish_rate FROM repair_order r LEFT JOIN room rm ON r.room_id = rm.id LEFT JOIN building b ON rm.building_id = b.id GROUP BY b.id ORDER BY total_count DESC;答辩时如果能主动讲清楚“聚合为什么下推到数据库,而不是在应用层做”,会是一个自然的加分项。列表查询同理,建议使用分页插件或者手写LIMIT,不要一次把全表数据加载到内存再截取,那是非常危险的做法,也是不专业的体现。
4. Vue前端实现与前后端联调的隐藏细节
4.1 登录态与路由守卫:角色页面分开管理
前端第一件事是把登录和路由权限搞定。用户登录成功后,后端返回token和基本信息,前端把token存入本地,后续请求在axios请求拦截器里统一把token加到请求头。路由守卫负责拦截未登录访问,并按角色meta过滤页面。比如住户默认看不到管理员管理页,维修工只看到工单工作台,管理员可以看到全部分区。需要特别注意的是,角色信息只在登录时写入前端,后端每次接口请求都必须重新解析token获取真实角色,不能轻信前端传过来的userId或roleId。
4.2 请求封装:错误提示统一弹,页面少写重复代码
前端统一封装一个request.js非常有必要。以axios为例,设置baseURL为 /api,设置超时时间,请求拦截器从localStorage取token,响应拦截器先判断HTTP状态,再解析后端返回的Result对象。如果code不是200,直接弹出错误提示;如果HTTP状态是401,则清除登录态并跳回登录页。这样页面业务代码只需要关心成功后怎么刷新数据,出错提示全部交给全局处理,整体代码量能减少三分之一。
import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )这段代码是前后端分离项目的通用骨架,可以直接拿来改。它的核心思想是把跨页面的重复逻辑集中起来,避免每个页面都写一遍message提示和token处理。
4.3 时间格式和大整数精度:联调被咬的两个坑
联调时最容易翻车的两个问题,一个是时间显示成“2025-05-01T10:30:00”这种带T的字符串,另一个是身份ID数值过大的精度丢失。时间问题靠后端全局Jackson配置解决,前端再用dayjs二次格式化。ID问题发生在后端使用雪花ID或自增ID达到一定数量级时,JavaScript的Number类型无法安全表示超过2^53的整数,前端拿到的ID会变成错误数字。解决方法是后端在返回给前端时把Long类型的ID转成String,或者干脆在项目里使用稳定的自增ID且业务单号单独生成。这一条属于经验之谈,很多项目上线之后才暴露。
4.4 图片上传与跨域:前后端各管各的
报修单通常需要上传现场照片。后端提供一个通用上传接口,接收MultipartFile,把文件保存到指定目录,返回相对路径,比如 /uploads/20250501/xxx.jpg。前端上传成功后,把相对路径拼到表单里提交;详情页展示图片时,根据当前运行环境拼接完整URL。不要往数据库里存完整http地址,因为域名或端口一换就失效,相对路径最灵活。开发环境的跨域用Vite代理解决,生产环境用Nginx反向代理,后端不额外开启CORS反而更安全。
4.5 部署与刷新404:Nginx配置的两个关键点
系统真正交付时一般用Nginx托前端打包产物,反向代理后端接口。下面这个简化配置可以直接用:
server { listen 80; server_name localhost; root /opt/repair/dist; location /api/ { proxy_pass http://127.0.0.1:8080/api/; } location /uploads/ { alias /opt/repair/uploads/; } location / { try_files $uri $uri/ /index.html; } }这里有两个核心点:一是 /api 开头的请求反向代理到后端jar,二是最后那个 try_files 解决前端history路由刷新404。很多同学只写前两行,结果启动服务后首页能打开,一点子路由再刷新就白屏,原因就是少了index.html回退。这类部署细节,开发环境永远遇不到,必须自己按生产模式完整跑一遍才会暴露。
5. 源码完整度与项目扩展:交工之前最值得做的事
5.1 什么才算“完整源码”
我理解的完整源码,不是把网页代码文件打包发给对方就结束。它至少包含:项目源码、数据库建表脚本、必要的初始化数据、README启动文档、演示账号。数据库脚本尤其重要,很多人会忘记给,结果对方拿到代码根本跑不起来。脚本里要包含建库建表语句和基础测试数据,演示账号至少要有住户、维修工、管理员三个角色。README要写清楚JDK版本、Maven版本、Node版本、数据库连接配置、前端启动命令和后端启动命令。这些补齐,整套系统的可复现性和可信度会大幅提升。
配置文件也要注意不能把数据库密码写死。可以保留application-example.yml,里面的密码用占位符,同时注释说明如何修改;真正部署时再创建application-local.yml使用实际配置。这样做的好处是,源码即使被其他人看到,也不会泄露真实环境信息,还能顺便养成一个良好的配置管理习惯。
5.2 演示流程设计:按一条完整业务主线走
系统做完之后,最好自己按一条完整链路走一遍:管理员登录,创建楼栋房间和维修工账号;住户登录,提交一个带图片的报修单;管理员看到待受理单,派给维修工;维修工开始维修,填写处理说明,完成工单;住户评价。把这条链路走顺,答辩或者交给甲方试用时心里就有底。同时,在数据库里预置一些状态各异的数据,比如待受理、维修中、已完成各两条,列表页就不会空荡荡。
本地打包验证也很重要。前端npm run build,后端mvn package,再按生产方式启动jar并挂载dist目录,确认图片可访问、刷新不404。这套全链路验证比单纯在开发环境跑通更有价值,因为实际使用系统时大概率是部署状态,不是开发状态。
5.3 可扩展方向:从“能用”到“好用”
如果时间允许,这套系统的扩展方向很清晰。第一个是消息通知,在状态变化时给住户发送短信或微信提醒;第二个是维修工抢单模式,管理员不再手动派单,让维修工看到待受理列表后自主认领;第三个是备件管理,维修工填单时选择消耗了哪些备件,方便统计成本;第四个是数据大屏,用图表展示各楼栋报修趋势和完成率,答辩时的视觉效果很好。这些方向都能基于现有代码扩展,不需要推翻重来。
我个人最推荐优先做消息通知和维修工抢单,因为这两个功能对真实场景的体验提升非常明显,技术实现难度也适中,还能作为毕设里的创新点来讲。数据大屏虽然好看,本质是查数据再展示,做完讲不出太多深度,更适合作为已有系统的锦上添花。
做完整套项目复盘下来,我最想提醒后来人的一点是:代码量其实不大,难点全在状态流转和权限控制。不要一上来就埋头写CRUD,先在纸上把状态机表、角色权限表、数据库字段表各画一遍,画清楚再动手,效率会高很多。最后分享一个小技巧:对外展示的单号不要直接用数据库自增主键,用“日期前缀加自增序列”更规范,并发量不大的时候在Java层加个锁生成就行,没必要一上来就引入Redis分布式锁。希望这篇复盘能帮你少走一些弯路。