
做宿舍管理系统这类全栈项目我的经验是它比图书管理、购物商城更能覆盖日常业务的真实复杂度又比企业级ERP轻量得多。这套SpringBootVue3MyBatis的前后端分离方案几乎就是当前中小型管理系统的标准范式——后端负责业务逻辑和数据处理前端通过接口交互MySQL做持久化存储非常适合用来练手、做毕设或者作为面试时拿得出手的项目经历。我按照实际开发顺序把这套系统的核心设计思路、数据库建模、后端接口实现、前端页面对接以及部署上线和排查问题的方法完整拆开讲一遍。里面有我踩过的坑也有不少常规文档不会写到的细节希望能帮正准备做类似项目的朋友少走弯路。1. 项目整体拆解与设计思路1.1 为什么选这套技术栈组合先说技术选型。SpringBoot在Java后端开发里已经是绝对的主流它内置Tomcat容器通过Maven或Gradle管理依赖配合starter机制几分钟就能搭起一个可运行的Web服务开发效率比传统SSH架构高出一大截。Vue3现在也成了前端框架的热门选择特别是Composition API和script setup语法出来以后组件的逻辑复用和代码组织都比Vue2时代清晰很多配合Vite构建工具冷启动速度非常快。MyBatis作为持久层框架半ORM的特性让我可以手写SQL这对于宿舍管理这类业务逻辑多变、查询条件组合灵活的系统来说反而比全自动的JPA更好用——因为复杂查询的SQL执行计划是可以精确掌控的。这套组合的核心优势在于“分工明确、协作简单”前端团队和后端团队可以并行开发只要提前约定好接口文档。就算是一个人在做前后端分离也意味着改前端页面不用重启Java服务改后端接口不用重新打包前端资源调试体验比单体应用舒服得多。1.2 业务模块怎么拆分最合理宿舍管理的业务流程站在管理员、宿管员、学生三个视角去看需求是完全不一样的。学生需要在线查看宿舍分配信息、提交报修申请、浏览公告宿管员要负责房间分配与调整、水电费记录、来访登记系统管理员则要维护用户、角色、宿舍楼栋、房间床位等基础数据。我在设计时把系统拆成了五个核心模块系统管理模块用户管理、角色管理、菜单权限管理宿舍管理模块宿舍楼栋管理、房间管理、床位分配与调整学生管理模块学生信息录入、入住登记、退宿办理报修管理模块报修工单提交、派单处理、工单状态流转、评价反馈通知公告模块公告发布、查看、置顶、归档模块划分遵循一个原则高内聚、低耦合。每个模块的增删改查逻辑集中在各自的Service层里模块之间的交互只通过接口方法调用不直接操作对方的Mapper。比如报修工单要关联学生信息和宿舍房间信息但报修的Service不会直接修改学生表的数据只做查询引用。这样后期维护或者扩展功能时改动面能控制在最小范围。1.3 角色权限模型的设计权限控制我采用RBAC模型即“用户-角色-菜单”三层结构。系统内置三个角色系统管理员拥有全部菜单权限宿管员拥有宿舍管理、报修处理、学生查询的权限学生只能看到自己的信息、提交报修、查看公告。前端根据登录用户返回的菜单列表动态渲染导航栏后端在接口层通过RequiresPermissions之类的自定义注解做权限拦截。我实测下来单纯靠前端隐藏按钮是不可靠的有人可以直接调接口绕过页面。所以权限校验必须后端做前端只是方便交互展示。2. 数据库设计与后端实现细节2.1 核心表结构设计与关系梳理数据库是整个系统的基础表结构设计的好坏直接决定后续开发难度。我设计这套系统时核心表大概有这些用户表sys_user、角色表sys_role、用户角色关联表sys_user_role、宿舍楼表dorm_building、房间表dorm_room、入住记录表dorm_record、学生信息表stu_student、报修表repair_order、公告表notice_info。以房间表为例字段设计如下CREATE TABLE dorm_room ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, building_id bigint(20) NOT NULL COMMENT 所属楼栋ID, room_number varchar(20) NOT NULL COMMENT 房间号如301, floor tinyint(4) NOT NULL COMMENT 所在楼层, bed_count int(11) NOT NULL COMMENT 床位总数, used_bed_count int(11) NOT NULL DEFAULT 0 COMMENT 已占用床位, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1正常 0停用, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_building (building_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT宿舍房间表;这里有个很关键的设计点房间表里加了一个used_bed_count冗余字段用来记录已占用的床位数量。每次学生入住或退宿时对这个字段做增减更新。虽然理论上可以通过统计入住记录表来实时算出占用数但那种方式在列表页展示房间状态时每展示一个房间就要做一次关联统计数据量大一点就会很卡。用冗余字段做“空间换时间”查询性能显著提升代价是需要在事务里保证字段同步更新。在MyBatis的XML里对应更新床位数量的SQL我这样写update idupdateUsedBedCount UPDATE dorm_room SET used_bed_count used_bed_count #{delta} WHERE id #{roomId} AND used_bed_count #{delta} gt; 0 AND used_bed_count #{delta} lt; bed_count /update那个used_bed_count #{delta}的条件就是防止并发入住导致超员比如房间只有4个床位第5个人同时入住时这个更新要么阻塞要么影响行数为0事务回滚从数据库层面杜绝了超员问题。2.2 后端接口设计与分页查询方案后端接口统一遵循RESTful风格。宿舍楼列表、房间列表、报修工单列表这些查询接口统一采用GET请求参数用pageNum、pageSize、keyword、buildingId这样的查询条件。新增操作用POST修改用PUT删除用DELETE。我把Controller层设计得尽量薄只负责接收参数、校验基础格式、调用Service真正的业务逻辑全部下沉到Service层。比如分配房间的流程是这样前端提交学生信息和目标房间IDService层先校验学生是否已入住已入住直接抛出业务异常再校验房间是否存在、状态是否正常、床位是否已满一切通过后开启事务写入入住记录、更新房间床位占用数、修改学生入住状态事务提交返回房间号和床位信息分页这块我用的PageHelper插件用法非常简单。在Service里调用查询前一写PageHelper.startPage(pageNum, pageSize)紧接着的第一次MyBatis查询就会自动带上LIMIT语句返回结果再封装成PageInfo对象里面包含了总数、页码、总页数等分页信息直接返回给前端就行。这里有个容易踩坑的细节startPage只对紧随其后的第一条SQL查询生效如果中间穿插了其他查询分页就可能失效或者作用在错误的查询上。所以规范写法是确保startPage和真正要分页的查询之间不要插入任何其他MyBatis查询操作。2.3 登录鉴权的完整流程用户登录这块我采用了Spring Security结合JWT的方案无状态的特点很契合前后端分离架构。流程是这样的用户提交用户名密码后端校验通过后生成一个包含用户ID、用户名、角色标识的JWT Token有效期设置为2小时Token返回给前端后前端存储到localStorage并在每次axios请求时放入请求头Authorization: Bearer token后端通过Spring Security的过滤器链拦截所有非放行接口解析并校验Token的签名和有效期校验通过后把用户信息存入SecurityContext后续业务代码可以通过工具类直接获取当前登录用户密码加密必须用BCrypt不能存明文或简单MD5。BCrypt的特点是同一个密码每次加密的结果都不同自带随机盐即使数据库泄露彩虹表攻击也基本无效。Spring Security的BCryptPasswordEncoder直接用就行校验时调用matches(rawPassword, encodedPassword)方法。Token过期处理也值得说一下。我在拦截器里检查Token的剩余有效期如果少于10分钟就顺手签发一个新Token放在响应头里前端axios的响应拦截器如果发现有新Token就自动替换存储。这样用户连续操作时基本感知不到登录过期体验会好很多。3. MyBatis使用技巧与SQL安全性3.1 #{}和${}的区别很多面试题都考这个MyBatis的#{}预编译占位符会先替换成?然后通过PreparedStatement设置参数这个过程能有效防止SQL注入。而${}是字符串直接拼接把传入的值原样嵌入SQL语句存在注入风险。在宿舍管理系统的开发过程中我给自己定了一条规矩所有用户可输入的参数一律用#{}接收。只有极少数场景才允许用${}比如动态排序的字段名和排序方向——因为ORDER BY子句后面不能使用参数占位符语法上不允许。这种情况我会在接口层面做白名单校验比如前端传了sortOrder和sortColumn传给后端后后端对照排序字段白名单映射后再拼接绝不直接拿用户传的字段名拼SQL。3.2 动态SQL让组合查询不再噩梦宿舍管理系统的查询条件非常灵活学生列表查询可能需要按姓名模糊搜索也可能要按学院、班级精确匹配还可能按入住状态筛选。如果用Java代码逐层拼接SQL字符串不仅代码冗长还特别容易出空格或AND拼接错误。MyBatis的where标签帮我解决到了这个问题select idselectStudentList resultTypecom.example.entity.StudentVO SELECT s.id, s.student_name, s.student_no, s.college, d.room_number FROM stu_student s LEFT JOIN dorm_record r ON s.id r.student_id AND r.status 1 LEFT JOIN dorm_room d ON r.room_id d.id where if teststudentName ! null and studentName ! AND s.student_name LIKE CONCAT(%, #{studentName}, %) /if if testcollege ! null and college ! AND s.college #{college} /if if teststatus ! null AND r.status #{status} /if /where ORDER BY s.create_time DESC /selectwhere标签会在第一个条件成立时自动去掉前面的AND如果所有条件都不成立整个WHERE子句也不会生成。配合set标签做动态更新foreach做批量插入或IN查询日常开发里的动态SQL场景基本都能覆盖到。3.3 二级缓存官方文档提过但生产环境要慎用MyBatis的二级缓存是个很容易被忽略的坑。一级缓存默认开启作用在同一个SqlSession内这没什么问题。二级缓存作用在Mapper级别跨SqlSession共享在多节点部署时不同节点之间的缓存是互相隔离的数据一致性根本无法保证。我这个系统涉及到宿舍分配、报修状态变更这类高频写操作如果开二级缓存可能出现学生已经退宿、但房间列表还在缓存里显示该学生的情况。我的建议是查询频率极高、数据变更极少的配置类数据比如宣传公告、宿舍楼栋列表可以考虑开启二级缓存。其余业务数据一律不用。简单粗暴点说拿不准的就不开MySQL查询本来就不慢别为了几毫秒的性能引入一大堆数据一致性隐患。4. 前端Vue3实现的关键环节4.1 环境搭建与基础依赖前端我用的Vite创建项目命令是npm create vitelatest dorm-ui -- --template vue。Vite基于ESModule的开发模式启动速度比Webpack快很多改代码的热更新也是秒级反馈。项目创建完我安装了这些核心依赖vue-router前端路由负责页面跳转和菜单映射pinia状态管理替代VuexAPI更简洁TypeScript支持更好element-plusUI组件库表格、表单、对话框、消息提示这些现成的组件能省下大量样式开发时间axiosHTTP请求库统一封装请求和响应拦截sass样式预处理器方便写嵌套样式和复用变量项目结构我会按模块划分目录src/ ├── api/ # 接口请求定义按模块拆分文件 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── stores/ # pinia状态定义 ├── views/ # 页面组件 │ ├── system/ # 系统管理模块页面 │ ├── dorm/ # 宿舍管理模块页面 │ ├── student/ # 学生管理模块页面 │ ├── repair/ # 报修管理模块页面 │ └── notice/ # 公告模块页面 └── utils/ # 工具函数4.2 axios封装避免每次请求重复写配置axios如果直接在组件里到处axios.get()会导致请求代码分散、难以维护。我统一封装了一个请求模块核心代码如下import axios from axios import { ElMessage } from element-plus import { useUserStore } from ../stores/user import router from ../router const service axios.create({ baseURL: /api, timeout: 15000 }) // 请求拦截器自动携带token service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers[Authorization] Bearer userStore.token } return config }) // 响应拦截器统一处理业务错误和HTTP错误 service.interceptors.response.use( response { const res response.data // 业务状态码200表示成功 if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { // token失效跳转登录页 userStore.resetToken() router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } )有了这个封装页面里调用接口就非常清爽比如获取学生列表只需要写import { getStudentList } from /api/student const fetchData async () { const res await getStudentList({ pageNum: currentPage.value, pageSize: pageSize.value, keyword: keyword.value }) tableData.value res.data.list total.value res.data.total }4.3 动态菜单与页面权限控制的实现动态菜单是管理系统常用功能里稍显复杂的一项。后端登录接口返回的UserInfo里附带当前用户拥有的菜单列表前端拿到后动态添加到路由中。我用的是router.addRoute()方法配合后端返回的菜单树这样不同角色登录后看到的侧边栏完全不一样学生登录不会看到用户管理入口管理员登录才能操作全部功能。页面级别的权限控制则用Vue Router的beforeEach导航守卫。在路由跳转前做三件事判断是否已登录未登录且目标路由需要认证就强制跳转到登录页已登录但访问登录页直接跳回首页判断当前用户角色是否有目标路由的访问权限无权限就展示403页面这些逻辑看着不复杂但属于前后端分离系统的“骨架”部分提前做好了后面加页面只是配置路由和菜单的事。5. 部署打包与常见问题排查5.1 前后端分离项目怎么部署最省心这套项目我实际部署的服务器配置是2核4G的轻量云主机运行CentOS 7以上即可部署方案比较常规前端打包后生成dist目录托管在Nginx下Nginx监听80端口所有/api开头的请求反向代理到后端服务端口后端SpringBoot项目打成jar包使用java -jar命令运行监听8080端口MySQL部署在同一台机器或者用云数据库实例Nginx配置里最关键的是location代理规则写错会导致接口404或者跨域报错location / { root /opt/dorm-front/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }注意proxy_pass http://127.0.0.1:8080/;末尾的斜杠它会把/api前缀去掉转发。也就是说前端请求/api/dorm/building/list后端实际收到的是/dorm/building/list所以后端的Controller路径不需要加/api前缀。这个规则搞错了接口能连通后端但是404排查起来有点迷惑人。5.2 开发环境跨域报错怎么解决前后端分离开发时前端跑在5173端口后端跑在8080端口浏览器会因跨域限制拦截请求。最方便的方案是在Vite的配置文件里设置代理server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这个配置的开发体验和一个前后端都在同一域名的生产环境几乎一致。我建议不要在后端开启全局CORS配置因为生产环境用Nginx同源部署后后端开CORS等于多此一举还可能带来安全隐患。5.3 我踩过的几个坑第一个坑是MySQL时区问题。项目运行时提示The server time zone value CST is unrecognized报错原因是新版JDBC驱动要求显式指定时区。解决办法我采用了在连接串加参数jdbc:mysql://localhost:3306/dorm?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8useSSLfalse。同时数据库连接的useSSLfalse也很重要本地测试环境没必要用SSL握手否则启动时会一直弹证书警告白白增加连接耗时。第二个坑是分页查询的COUNT语句性能。PageHelper执行分页时会自动生成一条COUNT查询如果关联的表多、条件复杂这个COUNT可能比较慢。排查方法是在MySQL慢查询日志里观察耗时然后通过对关联字段建索引解决。比如学生表的student_name、入住记录表的room_id字段我都加了索引查询速度有质的提升。第三个坑是前后端时间字段格式不一致。MySQL的datetime类型通过MyBatis映射到Java的LocalDateTime序列化成JSON后默认是一个数组类型的LocalDateTime对象前端拿到根本不是想要的2026-03-01 10:30:00格式。我在application.yml里配置了全局Jackson序列化规则spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这个配置前端的element-plus日期组件才能正确显示和回传。5.4 后续可以怎么继续扩展这套系统做完之后扩展路径其实很清晰。我建议优先加两个模块一个是访问日志功能用Spring AOP切面记录所有接口请求的用户、操作时间、请求参数、响应结果存到一张日志表。这个模块不影响业务但后续排查问题、做数据统计时价值很大。实现起来也不复杂切面类用Around注解环绕目标接口方法记录System.currentTimeMillis()的差值做耗时统计。另一个是数据导出功能学生名单、报修工单、入住统计这些数据用EasyExcel工具类实现导出Excel。给列表页加一个“导出”按钮后端接口接收查询条件生成Excel流返回前端通过window.open或者Blob方式下载。这个功能在实际使用中宿管员老师几乎是必用的需求。我个人做这套系统的体会是技术选型不是越新越好而是看能不能把业务撑起来、让后续维护者看得懂。SpringBootVue3MyBatis这套组合正好是当前Java岗位招聘里出现频率最高的技术栈关键词学完这套系统再去面Java开发、全栈开发相关的岗位很多问题都能直接拿实际项目里的经验来回答。最后留个建议项目做完之后一定要自己从头到尾部署一遍把Nginx代理、jar包启动、数据库初始化这些环节走通这比写一万行业务代码学到的运维知识都扎实得多。