尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

企业级宿舍维修管理系统:SpringBoot+Vue+MyBatis实战拆解

企业级宿舍维修管理系统:SpringBoot+Vue+MyBatis实战拆解 “企业级宿舍维修管理系统”这个标题不少同学第一眼看到会觉得宿舍维修不就是学生报修、维修工改状态前后端各写几个页面拼起来完事吗但真正把项目做落地之后你会发现难点从来不是增删改查而是“企业级”背后的一整套东西——多角色权限、工单状态机、并发抢单、操作审计、数据权限、报表统计以及前后端分离部署后的一系列坑。这篇我不讲虚的直接拆解一版可运行的 SpringBoot Vue MyBatis MySQL 实现把核心模块、关键代码和踩坑经验全部分享出来。1. 项目到底解决了什么问题1.1 宿舍维修的典型痛点在真实校园场景里一次报修涉及的绝不只是“学生和维修工”两个人。学生洗完澡发现热水器坏了要的是“赶紧有人来修”宿管阿姨那边同时收到一堆微信报修得确认是哪个楼栋、哪个房间、联系谁维修工手上同时挂着好几个工单得判断哪个更急后勤主管还要看整学期的维修及时率、返修率、报修分类统计。如果做的是一个单一角色的 CRUD校方验收时大概率过不了关。这套系统的核心价值就是把“报修—审核—派单—接单—完工—评价”这条完整链路固化到程序里。每一步操作都有状态、有经手人、有时间戳、有记录而不是靠微信群消息和 Excel 人工登记。一旦流程跑通维修响应时间从“几天没人管”压缩到小时级跟踪每一个超时节点都能追溯到具体责任人。1.2 为什么选 SpringBoot Vue MyBatis MySQL技术栈选型不是越花哨越好。SpringBoot 负责快速构建后端服务Vue 负责前端交互体验MyBatis 把 SQL 控制权留给你自己MySQL 作为业务数据库成熟、稳定、成本低。这套组合对团队技术水平要求不算高接手成本低也方便二次开发。很多同学会问都这个年代了怎么不用 Spring Cloud 微服务怎么不配个 Elasticsearch我的观点是宿舍维修业务本质上就是一群人的工单数据单机 SpringBoot MySQL 完全能扛住几千学生同时报修。真正的性能瓶颈根本不是框架而是索引设计、连接池参数、慢 SQL 有没有人管。在核心模块上做扎实比堆一堆中间件更有价值。所谓“企业级”多半也指这件事稳定、可维护、权限清晰、出问题能排查。2. 整体架构与工程结构拆解2.1 前后端分离的边界前端跑在浏览器通过 HTTP 接口访问后端后端只返回 JSON不做任何页面渲染。这样做的好处是前端可以独立开发、独立部署后端也可以把鉴权、日志、限流、统一异常处理集中起来两边通过接口文档对齐契约。接口设计上我强烈建议统一前缀比如所有接口都走/api。后端 Controller 映射/api/repair/**、/api/auth/**前端所有请求都以/api开头。这样做的好处后面会体现出来开发环境可以用 Vue CLI 代理生产环境可以用 Nginx 反向代理跨域问题能从根本上规避。2.2 后端工程目录源码版源码采用标准的 SpringBoot 单模块结构没有拆微服务因为宿舍维修系统这个业务体量拆微服务只会增加部署和排查成本。工程目录大致如下dorm-repair-server ├── src/main/java/com/dorm/repair │ ├── controller │ ├── service │ │ └── impl │ ├── mapper │ ├── entity │ ├── dto │ ├── common │ │ ├── Result.java │ │ ├── ResultCode.java │ │ └── PageResult.java │ ├── config │ │ ├── WebMvcConfig.java │ │ └── InterceptorConfig.java │ ├── interceptor │ │ ├── AuthInterceptor.java │ │ └── PermissionInterceptor.java │ ├── annotation │ │ └── RequirePermission.java │ └── exception │ ├── BizException.java │ └── GlobalExceptionHandler.java ├── src/main/resources │ ├── mapper │ ├── application.yml │ └── db/schema.sql └── pom.xml我习惯把 Controller / Service / Mapper 严格分层。Controller 只负责参数接收和返回ResultService 处理所有业务状态变化Mapper 老老实实写 SQL。很多项目死在“懒惰式编码”上在 Controller 里直接调 Mapper或者把状态更新逻辑散落在多个接口里后期权限校验没法统一状态流转也没法收敛改一处崩一片。2.3 前端工程目录前端我用 Vue CLI 初始化Element UI 做组件库Axios 做请求Vue Router 做路由配合 Vuex 管理登录状态和权限数据。目录结构如下dorm-repair-ui ├── public ├── src │ ├── api │ │ ├── auth.js │ │ └── repair.js │ ├── assets │ ├── components │ │ ├── ImageUpload.vue │ │ └── StatusTag.vue │ ├── router/index.js │ ├── store/index.js │ ├── utils/request.js │ ├── views │ │ ├── login.vue │ │ ├── dashboard.vue │ │ ├── repair/RepairList.vue │ │ ├── repair/RepairAudit.vue │ │ ├── repair/RepairDispatch.vue │ │ └── system/UserManage.vue │ ├── main.js │ └── permission.js ├── vue.config.js └── package.json前端各目录不要滥用共享组件。像ImageUpload、StatusTag这种复用价值高的可以抽出来但纯粹为了“组件化”而抽组件会给维护增加无谓成本。这套目录的好处是后端开发人员也能一眼看到页面和 API 的对应关系联调时效率很高。3. 数据库设计没有一张表是无用的3.1 核心表设计盘点数据库是整个系统的地基。为了支撑多角色、工单流转和权限控制至少要有这些表表名作用sys_user用户表区分学生、宿管、维修工、管理员sys_role角色表sys_user_role用户角色关联表sys_menu菜单和权限码表sys_role_menu角色权限关联表repair_category维修分类表如水电、门窗、热水器building_info楼栋表dorm_room宿舍房间表repair_order工单主表repair_order_log工单操作日志表repair_evaluation评价表如果你还需要记录维修用料、维修照片附件、通知消息可以再扩展repair_material、sys_file、sys_notice等表。但最小可用版本里上面这一组已经足够把业务闭环跑通。3.2 工单主表的关键字段repair_order是核心中的核心字段设计要能满足查询、派单、统计、审计四个维度的需求。下面是一份精简版字段清单字段类型说明idbigint主键order_novarchar业务编号如 BX20250601001student_idbigint报修人用户 IDbuilding_novarchar楼栋编号room_novarchar宿舍房号category_idbigint维修分类 IDdescriptiontext故障描述image_urlsvarchar图片路径逗号分隔statustinyint当前工单状态auditor_idbigint审核人 IDhandler_idbigint维修工 IDappoint_timedatetime预约上门时间finish_timedatetime完工时间create_timedatetime创建时间update_timedatetime更新时间versionint乐观锁版本号这里有一个容易忽略的坑order_no一定要唯一而且不要直接用自增 ID 当工单号展示给用户。一是不好看二是不安全。生成规则我建议用“日期 当天流水号”比如BX20250601001。流水号可以从数据库查当天的最大单号再加一也可以借助一张流水号表注意加唯一索引兜底防止极端并发下生成重复号。3.3 为什么必须要有工单日志表很多新手设计表时只有一张工单表状态改了就改成当前状态完全不关心历史。等系统真正上线问题就来了学生对你说“我明明昨天就报修了为什么现在才来”维修工说“这个单子今天才派给我”宿管说“我早就审核了”三方各执一词后台又查不到记录这个责任没法追溯。repair_order_log就是解决这个问题的。每条日志记录一次动作操作人、操作人角色、动作编码如SUBMIT、AUDIT_PASS、DISPATCH、ACCEPT、FINISH、EVALUATE、操作前状态、操作后状态、备注、操作时间。整个工单生命周期在任何时刻都能完整回放排障时打开这张表就是一条证据链。我做过的几个管理类项目里这张日志表是性价比最高的一张表强烈建议新增字段时不省。4. 后端核心逻辑实现4.1 统一返回体与全局异常处理前后端分离之后最忌讳的就是每个接口的返回格式都不一样。今天 A 接口返回Map明天 B 接口返回String前端看到数据直接崩溃。我在项目里统一用ResultT对象public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message 操作成功; r.data data; return r; } public static T ResultT error(ResultCode resultCode) { ResultT r new Result(); r.code resultCode.getCode(); r.message resultCode.getMessage(); return r; } }配合RestControllerAdvice做全局异常处理。业务层主动抛BizException(当前工单状态不允许审核)由全局异常处理器统一转成 JSON。前端拿到非 200 的code就弹错误提示而不是每次都要去控制台看网络请求。4.2 登录认证与权限校验登录逻辑很简单根据用户名查出用户和角色校验密码后签发 JWT把userId、roleCode写进 token claims返回给前端。前端后续请求在请求头带上Authorization: Bearer xxx后端用拦截器解析 token并设置当前用户上下文。为什么没用 Spring Security 全家桶因为宿舍维修场景角色少、权限模型简单自定义拦截器 注解实现反而更轻、更容易被团队接手。权限模型采用 RBAC用户-角色-菜单权限。sys_menu表里存权限码比如repair:audit、repair:dispatch、repair:accept登录时把当前用户拥有的权限码集合加载到内存接口做一次Set.contains()校验。看一个权限注解的用法Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequirePermission { String value(); }比如宿管调用“审核通过”接口PostMapping(/audit) RequirePermission(repair:audit) public ResultVoid audit(RequestBody AuditDto dto) { repairService.audit(dto.getOrderId(), dto.getPass()); return Result.success(null); }拦截器在进入 Controller 前解析 token、校验权限码。这里的权限码并不是拍脑袋写的字符串它对应sys_menu表的perms字段管理员可以在页面上给角色分配权限。当需要做到“宿管只能审核自己楼栋的工单”时只需在数据层加一个范围条件这就是“企业级”和“玩具项目”拉开差距的地方。4.3 工单状态机让流转不失控工单状态我定义成一组枚举常量状态码状态名称含义0待审核学生提交报修后1待派单宿管审核通过等待派单2待接单已派单给维修工等待接单3维修中维修工已接单4待确认维修工完工等待学生或宿管确认5已完成已评价或确认完成-1已驳回宿管驳回-2已取消学生取消最忌讳的设计是给前端提供updateStatus(orderId, status)这种通用接口。因为状态之间不是随便跳的待审核的工单不能直接变成已完工维修工不能绕过派单直接修改结果。我在 Service 层把状态流转收敛成一个个业务方法每个方法开头先做状态前置校验public void audit(Long orderId, boolean pass) { RepairOrder order repairOrderMapper.selectById(orderId); if (order null) { throw new BizException(工单不存在); } if (order.getStatus() ! RepairStatusEnum.PENDING_AUDIT.getCode()) { throw new BizException(当前状态不允许审核); } int newStatus pass ? RepairStatusEnum.PENDING_DISPATCH.getCode() : RepairStatusEnum.REJECTED.getCode(); repairOrderMapper.updateStatus(orderId, newStatus, order.getVersion()); repairOrderLogMapper.insert(buildLog(null, order.getStatus(), newStatus, audit)); }同时每发生一次状态变更都往日志表里写一条记录。这样从学生提交到最后完成每一步都有迹可循。做状态机时还要注意一点不要把状态流转逻辑写在多个不同的 service 方法外面尽量统一到一个入口否则后续新增一个“超时自动取消”状态时你会找不到所有需要修改的地方。4.4 抢单模式下的并发控制宿舍维修系统里常见的一种玩法是工单不强制指派给某个人而是“抢单”。宿管审核通过后维修工端能看到一批待抢单的工单谁先点谁抢到。抢单最容易出问题的是并发。两个维修工同时看到一张单同时点抢单如果代码是“先查状态再 update”大概率会把同一张单派给两个人。解决方案很简单用一条带状态条件的原子更新UPDATE repair_order SET handler_id #{handlerId}, status 2, version version 1 WHERE id #{orderId} AND status 1 AND handler_id IS NULL判断affected rows等于 1说明抢单成功等于 0说明已经被别人抢走了。这比先 select 再 update 可靠得多也比引入 Redis 分布式锁更简单。需要说明的是抢单成功后还要考虑“抢到不干”的情况。我建议加一个定时任务扫描长时间停留在“待接单”状态的工单超时自动释放回派单池并记录一条“因超时自动释放”的日志。没有这个兜底抢单功能只是把排队问题换成了抢完不干的问题。4.5 文件上传与图片预览报修单里图片上传是个高频需求。存储方案上我一般不推荐把图片转 base64 存数据库读出来难处理数据库还容易被撑爆。更稳妥的方式是把文件落盘到服务器专用目录数据库只保存访问路径比如/upload/2025/06/01/xxx.jpg。前端通过 URL 直接预览Nginx 也能直接指向这个目录不用每次都经过 Java 层读文件。后端需要做静态资源映射spring: web: resources: static-locations: classpath:/static/, file:${upload.path}同时在上传接口里限制文件大小和类型日志里记录上传人、文件路径、文件大小。遇到“为什么传完图片页面不显示”的问题可以直接从日志和实际文件路径排查而不是盲猜。4.6 MyBatis 动态 SQL 支撑多角色查询工单列表是我这个项目里查询最复杂的接口学生要能看自己报的单宿管要能看本楼栋的单维修工要能看派给自己的单。如果不做处理就会写好几个查询接口每个接口复制一份 SQL。MyBatis 的动态 SQL 可以很好解决这个问题。select idselectOrderPage resultTypecom.dorm.repair.entity.RepairOrder SELECT id, order_no, student_id, building_no, room_no, category_id, description, status, handler_id, create_time, update_time FROM repair_order where if teststatus ! null AND status #{status} /if if testbuildingNo ! null and buildingNo ! AND building_no #{buildingNo} /if if testroleCode STUDENT AND student_id #{userId} /if if testroleCode REPAIRER AND handler_id #{userId} /if /where ORDER BY create_time DESC /selectwhere标签会自动去掉多余的AND有效避免字符串拼接 SQL 导致的注入风险。数据权限是角色通过条件动态叠加而不是每种角色写一套查询。这个模式在后期扩展新角色时特别有用。关于 MyBatis 的缓存这里也提醒一句默认一级缓存作用域是 SqlSession事务结束就失效问题不大二级缓存在多表关联和复杂查询场景下容易读到脏数据工单这种实时性强的表我建议干脆不开启把缓存留给真正的高频只读数据比如维修分类字典。5. Vue 前端实现要点5.1 Axios 统一请求封装前端最容易乱的地方就是请求。如果每个页面都单独写一个 axiostoken 过期了每个页面都要单独处理代码会越写越乱。我在utils/request.js里创建一个统一实例import axios from axios import { Message } from element-ui 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) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } Message.error(error.message || 网络异常) return Promise.reject(error) } ) export default service这样后端返回业务错误或 401 时前端能统一处理不需要每个页面复制粘贴错误处理逻辑。这里有个关键约定baseURL: /api开发环境靠 Vue CLI 代理生产环境靠 Nginx 转发所以后端接口前缀绝对不能乱改。5.2 路由守卫与按钮权限前端权限控制分两层。第一层是路由级Vue Router 的beforeEach判断用户是否已登录未登录一律重定向到/login。登录成功后根据后端返回的菜单列表动态添加路由避免学生通过手输 URL 进入管理页面。第二层是按钮级。我封装了一个自定义指令Vue.directive(permission, { inserted(el, binding) { const permissions store.state.user.permissions if (permissions !permissions.includes(binding.value)) { el.parentNode el.parentNode.removeChild(el) } } })使用方式el-button v-permissionrepair:audit clickonAudit审核/el-button前端权限只控制“看不看得到”真正的安全校验必须依赖后端这一点项目文档里我会反复强调。前后端两层都做权限既提升用户体验也守住安全底线。5.3 工单列表页不同角色看到不同操作工单列表是整个前端里最容易写乱的页面。学生看自己报修的单宿管看本楼栋的单维修工看自己的工单管理员看全部。我的思路是列表查询接口通过roleCode和userId动态叠加数据权限前端只需要根据当前角色的roleCode渲染按钮。学生角色显示“取消报修”“去评价”宿管角色显示“审核”“指派”“驳回”维修工角色显示“抢单”“接单”“完工”管理员角色显示“删除”“强制状态修正”状态展示建议统一封装一个StatusTag组件例如“待审核”对应 warning 标签“维修中”对应 primary 标签“已完成”对应 success 标签。状态颜色集中在组件里维护比每个页面一堆v-if修改起来省事得多。6. 部署、联调与问题排查6.1 从源码到能跑起来拿到源码后按四步走建库、配连接、启动后端、启动前端。数据库建表脚本放在src/main/resources/db/schema.sql里面包含建库、建表、初始化管理员账号。MySQL 连接配置在application.ymlspring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/dorm_repair?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true这里有几个高频坑点不写serverTimezoneAsia/ShanghaiJava 拿到的数据库时间会少 8 小时MySQL 8 连接还需要allowPublicKeyRetrievaltrue否则会报 “Public Key Retrieval is not allowed”map-underscore-to-camel-case必须开启否则create_time映射不到实体类的createTime字段。后端构建启动命令mvn clean package -DskipTests java -jar target/dorm-repair-server.jar前端开发环境npm install npm run serve6.2 跨域与前端代理开发环境下前端默认跑在http://localhost:8080后端默认http://localhost:8081。浏览器直接请求后端接口会被 CORS 拦住。解决办法不是在后端写一个允许所有来源的CrossOrigin而是在 Vue CLI 里配置代理// vue.config.js module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } }这样前端请求/api/xxx会被开发服务器转发到后端后端业务代码里不需要处理跨域。生产环境用 Nginx 做同源转发location /api/ { proxy_pass http://127.0.0.1:8081/api/; }只要约定好/api前缀开发和生产两套环境都用同一个规则跨域问题基本不会出现。6.3 常见问题速查表我把这个项目运行过程中被问到最多的问题整理成一张速查表遇到问题可以直接对照排查现象可能原因解决方案后端启动报Public Key Retrieval is not allowedMySQL 8 驱动安全限制连接串加allowPublicKeyRetrievaltrue接口返回时间比北京时间少 8 小时数据库时区或 Jackson 时区未设置配置serverTimezoneAsia/Shanghai并设置日期格式前端登录后刷新页面 404history 路由模式未配置 Nginx生产环境配置try_files $uri $uri/ /index.html工单更新状态不生效缺少乐观锁 version 条件更新 SQL 带上version失败重试或提示刷新上传图片后无法访问静态资源映射未配置配置/upload/**映射到上传目录页面白屏且控制台 CORS 报错生产环境前后端跨域用 Nginx 将/api反代到后端保持同源MyBatis 查询结果全是 null下划线和驼峰映射未开启设置map-underscore-to-camel-case: truenpm install时依赖报错Node 版本过高或过低优先使用 Node 16 或 18 LTS 版本这些坑大多不是源码本身的问题而是环境差异导致的。遇到问题先看日志、再看配置、最后看 SQL通常比自己瞎改快得多。最后分享一个我做这套源码时特别深的体会与其花时间研究怎么把代码写得“高大上”不如先把工单状态流转和权限校验写严。我之前做一个管理系统时业务人员今天提一个状态需求明天提一个数据权限需求底层状态机如果写得不干净每改一次都要头大。宿舍维修系统虽然业务不大但把状态机、数据权限、操作日志、并发冲突这些基本功练扎实了后面再做更大系统能省掉很多返工。谁能在状态和权限上少挖坑谁就能少熬夜。
返回列表