
我前阵子帮学校后勤处撸了一套宿舍管理系统技术栈就是现在最主流的SpringBoot Vue MyBatis MySQL全家桶。这类项目在毕设和中小型企业内部管理系统里出现频率极高网上虽然源码一大把但真正能跑通、能部署、能应付并发和数据关联查询的并不多。我把整套系统的设计思路、核心代码、踩坑记录全盘整理出来从表结构设计到前后端联调再到打包部署希望能帮你少走几个月的弯路。先说清楚这套系统能干什么宿舍楼栋管理、宿舍房间分配、学生入住登记与调宿、退宿审批、日常报修工单、水电费记录、公告通知发布、权限分级管理员、宿管员、学生这八类核心功能全部覆盖。适合正在做毕设的同学直接参考复现也适合学校后勤或企业行政团队拿来二次开发甚至可以作为新手练手的第一套全栈项目。1. 项目整体设计与思路拆解1.1 为什么选 SpringBoot Vue 这套组合你可能也发现了近几年的管理系统项目几乎被这个组合霸屏。原因其实很朴素SpringBoot 解决了后端配置地狱Vue 解决了前端 DOM 操作的繁琐两者结合正好踩在中小型系统开发效率的平衡点上。拿宿舍管理这个场景来说业务逻辑并不复杂但涉及的关联查询很多——学生要关联宿舍、宿舍要关联楼栋、报修要关联宿舍和学生如果用传统的 JSP Servlet 来写光配置一堆 XML 和监听器就够头疼了。SpringBoot 的自动配置机制把这些繁琐的东西全部消化掉我只需要关注业务代码本身。而 MyBatis 在复杂 SQL 上的灵活性远胜 JPA尤其是在多表关联查询、动态条件拼接这类场景下写起来是真的顺手。Vue 这边单页应用的组件化开发模式让页面切起来没有刷新感表格、弹窗、表单校验这些宿舍管理系统的高频交互做起来效率极高。我实测下来同样一套页面用原生 JS 写至少要多花一倍时间。1.2 功能模块划分先画清楚边界再动手任何项目动手写代码之前第一件事是画功能脑图。宿舍管理系统的功能边界如果划不清楚后面写代码的时候很容易陷入这个功能该放哪个模块的纠结。我按用户角色把系统切成三个视角超级管理员负责楼栋和宿舍的增删改查、宿舍类型维护四人间、六人间、八人间、学生入住审核、宿舍分配调整、系统公告发布、管理员账号管理。宿管员负责日常报修工单的接单和派单、来访登记、晚间查寝记录、水电表读数录入。学生提交入住申请、查看自己的宿舍信息、提交报修申请、查看公告、在线缴水电费对接虚拟支付先做记录功能。这三个角色对应的权限边界在后端的拦截器层面就要控制住不是靠前端隐藏按钮来控制。这个思路直接决定了系统的安全性上限千万不能偷懒。1.3 数据库表结构设计八张表搞定核心业务我见过不少新手一上来就照搬网上那些三四十张表的大而全设计结果光维护表关系就花了大半时间。宿舍管理系统的核心业务其实八张表就能覆盖我按照这个方案设计表名用途关键字段t_user用户表含角色id, username, password, role, statust_building楼栋表id, building_name, floor_count, room_count, addresst_dormitory宿舍表id, building_id, room_no, room_type, max_people, current_peoplet_student学生信息表id, user_id, student_no, name, gender, phone, dormitory_idt_checkin入住记录表id, student_id, dormitory_id, check_in_date, check_out_date, statust_repair报修工单表id, student_id, dormitory_id, content, status, create_time, handle_timet_announcement公告表id, title, content, create_time, publisher_idt_water_electricity水电费表id, dormitory_id, water_usage, electricity_usage, month, amount设计这八张表的时候有几个容易踩的坑我说一下我的处理方式学生表为什么要单独建一张不直接合并到用户表因为学生信息有学号、性别、所属专业这类属性而用户表只负责登录认证两者职责不同混在一起会导致字段冗余后面扩展时也很被动。我见过有人把学号和密码放同一张表里结果改专业信息的时候把登录账号也搞丢了。宿舍表里为什么要冗余一个 current_people 字段这个是典型的空间换时间思路。如果不冗余每次查询宿舍空闲床位都要先查入住记录表再统计一个楼栋十几间宿舍就要十几条子查询。冗余之后只需要在入住和退宿时维护这一个字段查询性能提升明显。代价是必须在事务里保证这个字段和入住记录的一致性所以在入住接口里我加了Transactional注解确保这两张表要么同时成功要么同时回滚。水电费表为什么要记录月份字段因为宿舍水电通常是按月抄表结算字段如果只存金额后续查历史趋势就没办法聚合。加一个 month 字段后用一条简单的 GROUP BY 就能按月汇总报表方便宿管员月底统一核算。2. 核心后端实现登录认证、权限拦截与动态SQL2.1 JWT 登录认证前后端分离的正确打开方式传统管理系统喜欢用 Session 做会话管理但前后端分离架构下Session 方案有几个绕不过去的麻烦跨域请求要配allowCredentials、集群部署要搞 Session 共享。所以我选用了 JWTJSON Web Token方案实测下来简单可靠尤其在 Vue 打包后放在 SpringBoot 静态目录下的部署模式下JWT 的免重启认证优势非常明显。JWT 的核心原理就是服务端用密钥对用户信息签名生成一个 token后续请求只要带上这个 token服务端验签通过就放行不需要在内存里存 Session。具体的工具类代码如下Component public class JwtUtil { // 这里用的是HMAC-SHA256签名算法 private static final String SECRET your-secret-key-change-in-production; private static final long EXPIRE_TIME 24 * 60 * 60 * 1000; // 24小时 public String generateToken(Integer userId, String role) { Date now new Date(); Date expireDate new Date(now.getTime() EXPIRE_TIME); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }这里有几个细节你必须注意密钥绝对不能硬编码在代码里我这是为了演示方便。生产环境应该放到application.yml里通过ConfigurationProperties注入或者放到环境变量中。过期时间根据业务场景定。学生端如果希望保持登录状态可以设置 7 天管理端建议 2 小时以内减少 token 泄露的风险。token 里只放必要信息别把密码什么的塞进去因为 JWT 的 Payload 部分只是 Base64 编码不是加密别人拿到可以解开看。登录接口的逻辑比较标准先按用户名查用户然后用 BCrypt 比对密码哈希比对成功后生成 token 返回。这里强调一下密码存储必须用 BCrypt 加密new BCryptPasswordEncoder().encode(password)生成的结果自带随机盐就算两个用户密码相同密文也不同。2.2 拦截器实现角色权限控制JWT 只解决了你是谁的问题还得解决你能干什么的问题。我用了 Spring 的HandlerInterceptor来实现统一的角色鉴权逻辑Component public class AuthInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和注册接口 String uri request.getRequestURI(); if (uri.startsWith(/api/auth/)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } try { Claims claims jwtUtil.parseToken(token.substring(7)); request.setAttribute(userId, claims.getSubject()); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } } }拦截器只做登录态的校验细粒度的角色控制我写在 Controller 层。比如宿管的报修处理接口PostMapping(/repair/handle) public Result handleRepair(RequestBody RepairHandleDTO dto, HttpServletRequest request) { // 从拦截器设置的属性里拿角色 String role (String) request.getAttribute(role); if (!ROLE_ADMIN.equals(role) !ROLE_DORM_MANAGER.equals(role)) { return Result.error(权限不足只有宿管员和管理员可以处理报修); } repairService.handleRepair(dto); return Result.success(); }当然更好的做法是用 Spring Security 配合注解PreAuthorize但考虑到系统的体量用拦截器 手动判断能让代码更直观也方便新手理解。如果后续系统扩权再迁移到 Spring Security 也不迟业务代码改动量很小。2.3 MyBatis 动态 SQL让条件查询告别 SQL 拼接噩梦宿舍管理系统的查询接口多种多样学生查询可能按学号、姓名、性别、宿舍楼栋组合筛选报修工单查询可能按状态、时间区间、宿舍号组合筛选。如果每个条件都写一段固定 SQL那代码量会爆炸。MyBatis 的where和if标签就是为这种场景设计的它会自动处理动态条件还能智能去掉多余的 AND 或 OR。下面是学生条件分页查询的写法select idselectStudentList resultTypecom.dorm.entity.StudentVO SELECT s.*, d.room_no, b.building_name FROM t_student s LEFT JOIN t_dormitory d ON s.dormitory_id d.id LEFT JOIN t_building b ON d.building_id b.id where if teststudentNo ! null and studentNo ! AND s.student_no LIKE CONCAT(%, #{studentNo}, %) /if if testname ! null and name ! AND s.name LIKE CONCAT(%, #{name}, %) /if if testbuildingId ! null and buildingId ! AND b.id #{buildingId} /if if testgender ! null AND s.gender #{gender} /if /where ORDER BY s.id DESC /select这段 SQL 如果你直接手写拼接一旦条件组合多了代码可读性直接崩掉。用where标签后XML 会自动处理第一个条件前面的AND没有条件时也能生成不带 WHERE 的语句兼顾了灵活性和正确性。相关联的查询还有一个比较经典的点分配宿舍时要判断宿舍是否满员。这个逻辑放在 SQL 里可以一条搞定不必查出来再用 Java 做判断select idgetAvailableDormitory resultTypecom.dorm.entity.Dormitory SELECT * FROM t_dormitory WHERE building_id #{buildingId} AND current_people ![CDATA[]] max_people AND status 1 ORDER BY current_people ASC LIMIT 1 /select注意那个CDATA包着的符号XML 里直接写小于号会和标签冲突新手经常在这里报错记得用 CDATA 或者转义。2.4 MyBatis 缓存机制二级缓存对报表查询的加速效果MyBatis 的缓存分两级一级缓存是 SqlSession 级别的默认开启用同一个 SqlSession 做相同查询时直接命中缓存不查数据库。二级缓存是 Mapper 级别的需要手动开启。宿舍管理系统里楼栋和宿舍这些基础数据的查询频率极高而且很少改动我就在对应的 Mapper XML 里开启了二级缓存cache evictionLRU flushInterval600000 size512 readOnlytrue/配置说明evictionLRU即最近最少使用策略缓存满了自动淘汰最久没用的。flushInterval600000表示每 10 分钟自动清空一次避免宿舍分配状态更新后缓存里的旧数据影响判断。readOnlytrue表示缓存对象只读性能更好。但注意如果你的查询结果对象会被手动修改必须设为 false否则修改会污染缓存。实测下来在楼栋列表和宿舍列表页面二级缓存开启后接口响应时间从 80ms 降到 20ms 左右体感上就是页面瞬开。但有个坑要提醒你开了二级缓存后对这个 Mapper 的任何 INSERT/UPDATE/DELETE 操作都会清空缓存所以高写入的场景比如报修工单频繁更新状态其实不适合开二级缓存。我的取舍是基础数据和工单分开两个 Mapper 文件管理各自独立的缓存策略。3. 前端 Vue 实现动态路由、Axios 封装与组件化页面3.1 Vue Router 动态路由根据角色生成菜单前端路由如果写死就会出现一个尴尬现象学生登录后依然能看到管理员的路由地址虽然接口有拦截但点进去页面上全是报错信息体验极差。更安全的做法是用动态路由用户登录后根据角色从后端拿菜单权限数据然后通过addRoute动态注册路由。路由的初始配置只保留登录页和 404 页// router/index.js import Vue from vue import VueRouter from vue-router Vue.use(VueRouter) const router new VueRouter({ mode: history, routes: [ { path: /login, component: () import(/views/Login.vue) }, { path: /404, component: () import(/views/NotFound.vue) } ] }) // 这里存一个动态添加路由的方法供登录后调用 export function addDynamicRoutes(routes) { routes.forEach(route { router.addRoute(route) }) } export default router登录接口返回的数据里带上角色和菜单列表前端根据角色动态构建路由// 在登录处理函数里 const roleMenus { ROLE_ADMIN: [ { path: /building, component: () import(/views/Admin/BuildingManage.vue) }, { path: /dormitory, component: () import(/views/Admin/DormitoryManage.vue) }, { path: /student, component: () import(/views/Admin/StudentManage.vue) } ], ROLE_DORM_MANAGER: [ { path: /repair, component: () import(/views/Manager/RepairManage.vue) }, { path: /electric, component: () import(/views/Manager/ElectricRecord.vue) } ], ROLE_STUDENT: [ { path: /my-dorm, component: () import(/views/Student/MyDormitory.vue) }, { path: /apply-repair, component: () import(/views/Student/ApplyRepair.vue) } ] } const routes roleMenus[loginRes.role] || [] addDynamicRoutes(routes)很多人问为什么要用history模式而不是默认的hash模式。这里有个需要关注的点hash模式地址栏会带#虽然部署简单后端不用任何配置但看起来不专业而且分享链接时#后面的参数变化不会触发浏览器请求对 SEO 也不友好。history模式的问题是刷新页面时后端必须把所有不存在的路径都转发到index.html否则会 404。这个问题在部署章节我详细说。3.2 Axios 封装拦截器统一处理 token 和错误提示前后端联调时最容易出现的重复代码就是每个接口都要手动带上 token、手动处理 401、手动弹错误提示。把 Axios 封装一层是必须的我提供一个经过验证的封装模板// utils/request.js import axios from axios import { Message } from element-ui import router from /router const service axios.create({ // 开发环境走代理生产环境走同源 baseURL: process.env.VUE_APP_BASE_API || /api, timeout: 10000 }) // 请求拦截器自动带token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }, error { return Promise.reject(error) }) // 响应拦截器统一处理业务错误和登录失效 service.interceptors.response.use(response { const res response.data if (res.code 200) { return res } else { Message.error(res.message || 系统异常) return Promise.reject(new Error(res.message)) } }, error { if (error.response error.response.status 401) { Message.error(登录已过期请重新登录) localStorage.removeItem(token) router.push(/login) } else { Message.error(网络异常请稍后重试) } return Promise.reject(error) }) export default service封装之后页面里的接口调用就变得非常干净import request from /utils/request // 报修工单列表查询 export function getRepairList(params) { return request({ url: /repair/list, method: get, params }) }这里有个很多人容易忽视的细节后端接口返回的 code 约定要统一。我定的是code200表示成功code500表示业务失败401状态码交给 HTTP 层处理。前后端开发时必须要先把这个约定写在接口文档里不然联调时天天吵你写的 code 是 0前端判断的是 200排查到深夜才发现是字段含义不一致。3.3 表格与表单页面的组件化拆解宿舍管理系统的页面属性其实高度相似一个筛选区、一张数据表格、一个新增/编辑弹窗。这类页面如果每个都从零开始写工作量巨大且难以维护。我用 Element UI 的组件做了一套通用模板以学生管理页面为例template div classapp-container !-- 筛选区 -- el-card classfilter-card el-form :inlinetrue :modelqueryParams el-form-item label学号 el-input v-modelqueryParams.studentNo placeholder请输入学号 clearable / /el-form-item el-form-item label姓名 el-input v-modelqueryParams.name placeholder请输入姓名 clearable / /el-form-item el-form-item el-button typeprimary clickhandleQuery查询/el-button el-button clickresetQuery重置/el-button /el-form-item /el-form /el-card !-- 表格区 -- el-card el-table :datastudentList v-loadingloading border el-table-column propstudentNo label学号 width120 / el-table-column propname label姓名 width100 / el-table-column propgender label性别 width60 / el-table-column propbuildingName label楼栋 / el-table-column proproomNo label宿舍号 / el-table-column label操作 width180 template slot-scopescope el-button typetext clickhandleEdit(scope.row)编辑/el-button el-button typetext classdanger clickhandleDelete(scope.row)删除/el-button /template /el-table-column /el-table !-- 分页 -- el-pagination size-changehandleSizeChange current-changehandleCurrentChange :current-pagequeryParams.pageNum :page-sizes[10, 20, 30, 50] :page-sizequeryParams.pageSize layouttotal, sizes, prev, pager, next, jumper :totaltotal /el-pagination /el-card /div /template页面的新增和编辑弹窗用el-dialog封装表单校验规则放在data里的rules对象中。这里有个经验表单校验最好做两层前端必填校验 后端业务校验。前端校验保证交互友好后端校验防止恶意请求绕过前端。我见过有人在注册接口里没做后端校验结果学生把学号留空也能提交成功最后在宿舍分配的时候炸了锅。3.4 前后端联调Vite/Webpack 代理配置开发环境最大的痛点是跨域。Vue 前端跑在 8080后端跑在 8081前端直接调用后端接口必然被浏览器的同源策略拦截。解决办法是在vue.config.js里配置代理让前端服务器帮忙转发请求// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: /api } // 按需调整 } } } }配置完成后前端的/api/login请求就会被代理转发到http://localhost:8081/api/loginbrowser 看到的是同源的自然就没有跨域报错了。这个方案比后端加CrossOrigin要干净得多——生产环境部署的时候前后端同源根本不需要 CORS而开发环境的代理只在开发期有效不会留下安全隐患。强烈建议开发期全部用代理。我曾经见过有人为了省事在后端统一配置了cors.allowedOriginPatterns*结果上线后浏览器依然能跨域访问被安全扫描工具标成了中危漏洞。开发联动用代理生产用同源部署这是最稳妥的方案。4. 数据库与 SQL 优化让列表查询不再卡顿4.1 表关系与索引设计的最佳实践宿舍管理系统的数据量虽然不如电商系统那么夸张但伴随时间累积入住记录、报修工单这类流水表的数据量也会涨到几十万条如果索引建得不对翻页查询会越来越慢。我在这几张核心表上建了这些索引-- 学生表按学号查询是高频操作 ALTER TABLE t_student ADD INDEX idx_student_no (student_no); -- 入住记录按学生查入住历史、按宿舍查入住情况是高频查询 ALTER TABLE t_checkin ADD INDEX idx_student_id (student_id); ALTER TABLE t_checkin ADD INDEX idx_dormitory_id (dormitory_id); -- 报修工单按状态和创建时间筛选是高频操作 ALTER TABLE t_repair ADD INDEX idx_status (status); ALTER TABLE t_repair ADD INDEX idx_create_time (create_time); -- 水电费表按宿舍和月份查询 ALTER TABLE t_water_electricity ADD INDEX idx_dormitory_month (dormitory_id, month);这里有一个很容易被忽略的原则索引不是越多越好。每个索引都会拖慢 INSERT/UPDATE 的速度因为写入时除了更新数据还要同步维护索引树。像t_student表里的gender字段这种区分度极低只有男女两种值的列建了索引也不会优化查询反而增加维护成本不如不建。4.2 分组统计报表水电费月度汇总一条 SQL宿管员月底要按楼栋、按月份汇总水电费这个报表用一条分组查询就能完成SELECT b.building_name, we.month, COUNT(DISTINCT we.dormitory_id) AS dorm_count, SUM(we.water_usage) AS total_water, SUM(we.electricity_usage) AS total_electricity, SUM(we.amount) AS total_amount FROM t_water_electricity we LEFT JOIN t_dormitory d ON we.dormitory_id d.id LEFT JOIN t_building b ON d.building_id b.id WHERE we.month #{month} GROUP BY b.building_name, we.month ORDER BY b.building_name这里用COUNT(DISTINCT ...)而不是COUNT(*)是为了避免因为一对多关联导致重复计数。写这类聚合 SQL 的时候记得先用小数据量跑一遍校验结果数字对不上通常就是关联导致的数据重复问题。4.3 分页查询的深翻页性能坑MyBatis 最常用的分页插件是 PageHelper用法极简PageHelper.startPage(pageNum, pageSize); ListStudentVO list studentMapper.selectStudentList(params); PageInfoStudentVO pageInfo new PageInfo(list);用法虽简单但深翻页的性能问题很多人没注意。PageHelper 生成的分页 SQL 底层是LIMIT offset, size当页码很大时比如LIMIT 200000, 10MySQL 还是要扫过前面 20 万条记录才能返回结果耗时显著上升。如果系统将来数据量大了可以考虑把LIMIT offset, size改成LIMIT size配合WHERE id ?的方式也就是键集分页。实测在百万级数据下表的效果查询时间从几百毫秒降到几十毫秒。不过这个优化要配合前端传上一页的最后一条记录 ID改动量稍大前期可以不加但心里要有这根弦。5. 部署配置application.yml、跨域与 Vue 打包进 SpringBoot5.1 一份能直接上生产环境的 application.yml很多开源项目里的application.yml都是开发配置数据库密码明文、日志全开直接拿到生产用简直是裸奔。我整理了一份适合发布到生产环境的配置清单spring: datasource: url: jdbc:mysql://localhost:3306/dorm_manage?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.dorm.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl server: port: 8081几个配置项的用意我展开说明一下数据库密码用环境变量${DB_PASSWORD}注入不要写死在 yml 里。虽然麻烦一点但至少防住了代码泄露连带数据库裸奔的最常见事故。map-underscore-to-camel-case: true这个配置必须开它能把数据库的student_no自动映射为 Java 的studentNo省去在 XML 里写一大堆column别名映射的体力活。allowPublicKeyRetrievaltrue是 MySQL 8.0 连接时的必填项如果不加偶尔会报Public Key Retrieval is not allowed错误很多人被这个坑过。serverTimezoneAsia/Shanghai解决时区问题。MySQL 8 的默认时区和系统时区不一致时查出来的时间会差 8 小时这个参数直接把它固定为北京时间。5.2 MySQL 8.0 环境搭建时的避坑清单Maven 依赖配了mysql-connector-java结果启动报ClassNotFoundException或者连不上数据库——这类问题几乎每个用 MySQL 8 的人都碰到过。我整理一份避坑清单驱动类别变了。MySQL 8 用com.mysql.cj.jdbc.Driver旧版的com.mysql.jdbc.Driver在 8.0 里已经废弃了如果你的pom.xml依赖的是mysql-connector-java5.x记得升级到 8.x。SSL 连接报错。连接串里的useSSLfalse要显式加上否则 MySQL 8 默认尝试加密连接如果你的 JDK 证书库里没有对应 CA就会报SSL connection error。utf8 和 utf8mb4 的区别。MySQL 8 建库时推荐直接用utf8mb4因为它兼容完整的 Unicode 字符集能存 emoji 表情。如果建库时用了老的utf8学生申请表里的表情符号存入时会报Incorrect string value。安装 MySQL 时的密码认证插件。MySQL 8 默认用的是caching_sha2_password旧版驱动不支持报Authentication plugin caching_sha2_password cannot be loaded。解决方案就是把驱动升级到 8.x或者在创建用户时指定mysql_native_password。5.3 Vue 打包后放进 SpringBoot 的同源部署开发环境前后端分离生产环境其实没有必要搞两个服务。把 Vue 项目构建后的静态文件直接复制到 SpringBoot 的静态资源目录下一个 JAR 包就搞定了部署成本最低也不存在跨域问题。Vue 打包执行npm run build构建完成后dist目录里就是纯静态页面文件。把这个目录下的所有内容复制到项目的src/main/resources/static/下然后重新mvn clean package打包mvn clean package -DskipTests启动java -jar dorm-manage.jar浏览器访问http://localhost:8081就能直接打开前端页面而且前端请求/api路径的接口天然同源不需要任何跨域配置。这里有个历史路由的坑必须处理。前端如果启用了history模式直接访问http://localhost:8081/student刷新页面会报 404因为后端没有这个路径的路由处理。解决办法是实现一个路由转发控制器Controller public class PageForwardController { // 把所有非 /api 开头的路径都转发到 index.html RequestMapping(value {/, /{path:[^\\.]*}, /{path:^(?!api$).*$/}}) public String forward() { return forward:/index.html; } }这段代码的匹配规则是访问根路径或任何不带扩展名的路径都转发到/index.html让 Vue Router 接管前端路由但/api开头的请求不受影响会正常走后端 Controller。注意这个控制器必须在 SpringBoot 对静态资源处理之后生效实测顺序没有问题。6. 常见问题与排查技巧实录6.1 Maven 依赖冲突导致的启动异常一个典型场景引入spring-boot-starter全家桶后又手动引入了某个第三方库结果启动时疯狂报NoSuchMethodError或者ClassNotFoundException。这种问题八成是依赖版本冲突。排查思路# 查看完整依赖树 mvn dependency:tree # 定位冲突版本 mvn dependency:tree -Dverbose -Dincludescom.fasterxml.jackson.core:jackson-databind找到冲突后在pom.xml里用exclusions排除多余版本dependency groupIdcom.example/groupId artifactIdsome-lib/artifactId version1.0/version exclusions exclusion groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /exclusion /exclusions /dependency6.2 MyBatis 查询结果字段为 null 的排查页面表格数据显示没问题但导出 Excel 时某些字段全是 null——这是典型的驼峰映射没生效。先检查application.yml里是否开了map-underscore-to-camel-case再看实体类的属性名是否和数据库列名对应比如student_no对应studentNo。如果开了映射还是查出来 null检查 Mapper XML 的resultType是不是写成了别的类型。我遇到过把resultTypeint写在查询列表的方法上最后列表拿到一堆空的映射对象。6.3 跨域问题排查先分清是开发环境还是生产环境开发环境出现跨域报错先看vue.config.js代理是否配好代理路径和前端请求路径是否完全匹配。生产环境出现跨域报错先确认是不是同一个端口提供服务如果确实要跨域后端配置 CORS 时注意不要用*要把具体的域名写到allowedOriginPatterns里。6.4 数据库连接池连接耗尽的处理宿管系统在开学季可能面临高并发报修申请如果连接池参数配置得太大或太小都可能导致Connection is not available, request timed out。我的处理方式spring: datasource: hikari: maximum-pool-size: 20 connection-timeout: 30000另外需要给关键接口加上合理的超时控制。数据库连接池满了不一定是池太小很可能是有慢 SQL 占着连接不放排查问题时先跑一遍SHOW PROCESSLIST看看哪些查询长时间没有结束快速定位到慢 SQL 再针对性优化。6.5 前端打包后接口 404 或者白屏Vue 打包进 SpringBoot 后最常见的两个问题白屏检查publicPath。默认 Vite 打包的静态资源路径是/如果 JAR 包部署在某个子路径下比如http://server:8081/dorm/资源路径就不对了。在vue.config.js里设置module.exports { publicPath: process.env.NODE_ENV production ? ./ : / }刷新 404就是上面说的history路由问题用那个转发控制器解决。如果你们对前端路由的形态没有特别要求也可以改回hash模式一劳永逸地规避刷新 404 的坑。6.6 数据一致性问题入住与退宿的原子性操作宿舍分配的current_people字段和入住记录t_checkin必须保证原子性。我一开始只做了简单判断没用事务结果测试时发现并发操作下current_people可能比实际人数少宿舍明明满了还能继续分配。后来在 Service 层加了Transactional并进行了行级锁处理Transactional public void assignRoom(CheckinDTO dto) { // 悲观锁锁定宿舍行防止并发超卖 Dormitory dorm dormitoryMapper.selectByIdForUpdate(dto.getDormitoryId()); if (dorm.getCurrentPeople() dorm.getMaxPeople()) { throw new BusinessException(宿舍已满); } dormitoryMapper.updatePeopleCount(dorm.getId(), 1); checkinMapper.insert(new Checkin(dto)); }对应的 SQLselect idselectByIdForUpdate resultTypeDormitory SELECT * FROM t_dormitory WHERE id #{id} FOR UPDATE /selectFOR UPDATE会锁住这条宿舍记录直到事务提交后才释放。实际场景并发量不大这个方案足够了不用上 Redis 分布式锁也别为了炫技把系统搞复杂了。7. 项目扩展思路与个人体会7.1 还能往哪些方向延伸宿舍管理系统虽然是经典 CRUD 项目但扩展空间其实不小。如果后续要做点差异化我建议从这两个方向下手接入消息通知报修工单状态变更时通过 WebSocket 推送消息给学生端或者在宿舍分配成功后推送通知。SpringBoot 自带 WebSocket 支持Vue 端用原生 WebSocket 监听消息改动量不大但体验提升明显。做一个小型数据看板用 ECharts 在管理端首页展示宿舍入住率、各楼栋报修数量趋势、月度水电消耗对比图。现在的系统里数据都有唯一的工作量在于前端写统计图表。这块视觉冲击力强演示效果也好。7.2 我个人在实际操作中的体会踩了这么多坑之后我最想分享的一点是不要一上来就找现成源码改先自己画一遍表结构设计。网上流传的很多宿舍管理系统源码数据库表设计得一塌糊涂有的把楼栋、宿舍、学生全塞一张表里还有的连管理员和学生的角色字段都没有直接改起来反而比从零写更痛苦。我自己在接到这个需求时花了一整天时间重新梳理表关系、设计接口协议、定好返回码规范真正写业务代码反而很快。表设计得干净后面的 controller、service、mapper 写起来都顺表设计得乱后面每加一个功能都要补一堆关联字段改一处崩三处。另外实践下来发现早做联调非常重要。前端 mock 数据虽然跑得通但和后端真实接口对接时字段命名、类型、嵌套层级经常对不上。最好是前后端接口文档先定死然后并行开发每天下班前联调一轮这样问题当天就能暴露。如果你打算基于这个方案自己动手建议顺序是先把数据库表建好把 MyBatis 的 CRUD 跑通再写后端的登录和权限最后再碰前端页面。前端页面是工作量最大的部分但有了清晰的数据结构和接口协议支撑写起来也只是时间问题。做完后再根据实际需求把页面打磨打磨就是一个能拿出来展示、能答辩、能实际跑的完整项目了。