宿舍管理是每个高校都绕不开的后勤场景。新生入学要在几十个表格里翻名字找宿舍,宿管手里的纸质台账换了一轮又一轮,维修师傅接报修电话接到手软。这些场景我观察了很久,所以决定用一个前后端分离的项目把流程固化下来:后端用 Spring Boot,前端用 Vue,做成一个包含源码、数据库脚本和说明文档的完整学生宿舍管理系统。项目覆盖了学生管理、房间分配、入住退宿、报修工单、公告通知这些最常见的业务。
这篇文章不按教科书的路子来,我按自己真实做项目的顺序写:先梳理需求,再定技术栈,然后设计表结构、写后端接口、搭前端页面,最后总结部署步骤和踩过的坑。如果你正在准备课程设计,或者刚接触前后端分离想找个完整项目练手,这篇里的每一步都可以直接复现。
1. 需求梳理:宿舍管理场景里的真实痛点与功能清单
1.1 从宿管员的日常工作推导角色和场景
做项目之前,我专门找宿管阿姨聊过,也观察过新生入住那几天的现场。宿舍管理的核心矛盾是“人、房、事”三者的关系要在短时间内频繁变化。人指学生,房指楼栋和房间,事指入住、退宿、报修、卫生检查这些动作。只要把这三类数据的关系理清楚,系统的主干就出来了。
系统里我设计了三个角色。学生可以查看自己的宿舍信息、在线提交报修、查看卫生检查结果、浏览公告。宿管员要处理学生房间分配、办理入住和退宿、登记报修并指派维修、记录卫生评分。系统管理员负责维护楼栋和房间数据、管理宿管账号、查看统计报表。这三个角色的动作有重叠,但操作边界不同,权限模型在需求阶段就要定好,避免后端接口做出来之后再来补权限。
1.2 功能模块拆解:每个动作都要形成业务闭环
我看了不少类似的课程设计,最常见的毛病是功能堆得很全,但业务之间没有闭环。比如学生退宿之后,房间的已住人数没有回填;比如报修单显示“已完成”了,但维修结果没人确认,数据挂在那里没人管。这些都是需求分析阶段没有把状态流转想清楚导致的。
我在拆功能的时候给自己定了一条底线:每个用户动作都必须产生可追踪的状态变化。基于这个原则,最终功能划分成五个模块:
- 基础数据管理:学生信息、宿舍楼栋、房间类型、宿管员账号。
- 住宿业务:分配宿舍、换宿、退宿、宿舍余量实时统计。
- 报修业务:学生提交报修、宿管查看并指派、维修完成、学生确认评价。
- 宿舍评比:卫生检查打分、按楼栋和房间排名。
- 系统辅助:公告发布、密码修改、登录日志。
这套功能已经能撑起一个完整的演示项目,后续想扩展还可以加分晚归登记、访客预约、水电费抄表,但核心先聚焦在住宿和报修上,不然项目初稿会陷入无尽的细节调整里出不来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型复盘:为什么锁定 Spring Boot + Vue 这个组合
2.1 前后端分离到底比 JSP 单体好在哪
现在很多高校的课程设计还在用 JSP + Servlet + JDBC 这套组合,它的核心问题不是技术旧,而是前后端代码高度耦合。前端页面和后端逻辑放在同一个工程里,改一个页面样式要重启整个应用,多人协作时还经常出现代码冲突。我这次明确选了前后端分离方案:后端只负责提供 JSON 接口,前端用 Vue 独立工程开发。这样做的好处是,前后端可以并行开发,后端写完接口用 Swagger 或者 Postman 自测,前端可以用 mock 数据先跑页面,最后联调时再对接真实接口。
我也对比过其他几种组合,整理成了一张表:
| 方案 | 后端 | 前端 | 适用场景 |
|---|---|---|---|
| 方案A | Spring Boot | Vue | 前后端分离,接口清晰,适合系统化管理项目 |
| 方案B | Spring Boot | Thymeleaf 模板 | 后端渲染页面,适合快速出小工具 |
| 方案C | SSM | JSP | 经典课程设计路线,但代码耦合度高 |
| 方案D | Node.js Express | Vue | 后端生态在 Java 企业场景中不如 Spring Boot 普及 |
从技能匹配度来看,Spring Boot 是 Java 后端岗位面试绕不开的内容,Vue 又是前端主流框架之一,选这个组合既能用来交项目,过程中也能把依赖注入、自动配置、路由守卫、响应式原理这些考点过一遍。我最终锁定了方案A。
2.2 版本选择的坑:不能盲目追新
版本踩坑是新手最痛的部分。我项目里用的是 Spring Boot 2.7.x + JDK 1.8 + MyBatis-Plus 3.5.x + MySQL 5.7,前端是 Vue 2.6 + Element UI 2.15 + Axios。为什么不上 Spring Boot 3?因为 Spring Boot 3 强制要求 JDK 17,并且把包名从 javax.* 改为 jakarta.*,很多学校机房和云服务器还停留在 JDK 1.8,你写的时候用再新的特性,部署环境不支持还是白搭。
前端选 Vue 2 而不是 Vue 3,理由类似。Element UI 对 Vue 2 的支持最成熟,相关的排错资料也最丰富。如果你已经熟悉 Vue 3 生态,可以换成 Element Plus,核心业务逻辑差异并不大,只是组件导入方式和响应式 API 写法不同。做项目求的是稳定交付,不是帮框架测新特性。
2.3 工程目录结构:一个清晰的起点
后端工程我按 Maven 标准结构组织,包名用 com.campus.dorm,主要分包如下:
code复制com.campus.dorm
├── config # 跨域、MyBatis-Plus、JWT 拦截器配置
├── controller # 接口层,只做参数接收和结果返回
├── service # 业务层,处理核心逻辑和事务
├── mapper # 数据访问层,MyBatis-Plus 的 Mapper 接口
├── entity # 数据库实体
├── dto # 接口入参和出参对象
├── common # 通用返回结果、常量、异常处理
└── utils # JWT、密码加密等工具类
前端则按 views、components、router、store、api、utils 分层。前后端都严格分层,最大的好处是改一个模块不用动其他模块,排查问题也能从上到下顺着链路走,不会出现“改一行代码崩三处页面”的尴尬。
3. 数据库模型设计:关系拆分、冗余字段与状态流转
3.1 楼栋、房间、学生之间的关联方式
数据库设计是整个系统最值得花时间的部分。我第一版设计了九张表,后来砍掉了两张,因为像“宿舍类型字典表”这种设计属于过度设计。现实中房间类型就三种,四人间、六人间、八人间,直接在房间表里加一个枚举字段就够了,拆成字典表反而让查询多一次关联。
最终我保留了几张核心表。楼栋是父表,房间表通过 building_id 关联楼栋,学生住宿关系通过住宿记录表关联。看两段核心建表语句:
sql复制CREATE TABLE `dorm_building` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`building_no` varchar(20) NOT NULL COMMENT '楼栋编号',
`building_name` varchar(50) NOT NULL COMMENT '楼栋名称',
`floors` int(11) DEFAULT NULL COMMENT '层数',
`manager_name` varchar(30) DEFAULT NULL COMMENT '负责人',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_building_no` (`building_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宿舍楼栋表';
sql复制CREATE TABLE `dorm_room` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`building_id` bigint(20) NOT NULL COMMENT '所属楼栋',
`room_no` varchar(20) NOT NULL COMMENT '房间号',
`room_type` tinyint(4) DEFAULT NULL COMMENT '1-四人间 2-六人间 3-八人间',
`bed_count` int(11) NOT NULL COMMENT '总床位数',
`used_count` int(11) DEFAULT 0 COMMENT '已住人数',
`status` tinyint(4) DEFAULT 1 COMMENT '1-可入住 0-停用',
PRIMARY KEY (`id`),
KEY `idx_building_id` (`building_id`),
KEY `idx_room_no` (`room_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宿舍房间表';
这里 used_count 是一个冗余字段,因为查空床位时如果每次都用 count(*) 去统计入住表中的记录,数据量大了之后性能会明显下降。用冗余字段换取查询速度在业务上很常见,但代价是每次入住或者退宿时,service 层必须同步更新这个数字,否则数据就对不上。这就是前面说的业务闭环在表结构上的体现。
3.2 住宿轨迹:退宿不是删除,而是状态变更
学生表字段不复杂,关键在于设置一个住宿状态,1 表示当前在住,0 表示已退宿。入住时分配宿舍并生成一条住宿记录,退宿时不做物理删除,只把状态置为 0,保留历史。很多课程设计习惯在学生表里直接加一个宿舍外键,退宿就把这条记录删掉。这样做虽然直观,但后续要查“某学生以前住过哪个宿舍”就查不到了,统计分析也没有数据支撑。
我加了一张住宿记录表,记录入住时间、退宿时间、变更原因。宿管在后台可以查看一个学生的完整住宿轨迹,比如大一住在 3 号楼 201,大二搬到 5 号楼 302,这些历史记录对管理来说很有价值。
支付状态和业务状态之类的东西,最好统一用整型常量存储,不要存字符串。整型比较效率高,而且可以通过常量类统一管理,不会出现“已处理”和“已完成”两种写法的混乱。
3.3 报修工单的状态推进
报修表的核心是状态机。我定义了四个状态:待处理(0)到处理中(1)到已完成(2)到已确认(3)。每个状态变更都在后端校验合法性,比如“已完成”的单子不能再回到“待处理”,防止前后端因逻辑不一致把数据搞乱。
时间字段统一用 datetime 类型,每张业务表都带 gmt_create 和 gmt_modified 两个通用时间字段,后续做审计或者数据修复时不用再补历史表。这类设计虽然不是核心功能,但属于那种做项目一开始不觉得重要、后来要查数据时才发现关键的东西。
4. 后端核心模块实现:鉴权、住宿办理和报修流转
4.1 统一返回结果与全局异常处理
接口层如果每次返回不同结构的 JSON,前端处理起来就会非常痛苦。我用一个通用泛型类统一包装返回数据,业务接口全部返回 Result<T>。
java复制public class Result<T> implements Serializable {
private Integer code; // 200 表示成功,非 200 表示失败
private String message;
private T data;
public static <T> Result<T> ok(T data) {
Result<T> r = new Result<>();
r.setCode(200);
r.setMessage("success");
r.setData(data);
return r;
}
public static <T> Result<T> error(String message) {
Result<T> r = new Result<>();
r.setCode(500);
r.setMessage(message);
return r;
}
}
同时还用 @RestControllerAdvice 做了全局异常捕获,不管是参数校验失败还是数据库异常,都转换成业务提示返回给前端。这套组合做好之后,前后端联调的效率提升非常明显,前端拿到的永远是“code + message + data”的固定结构,遇到问题直接看 message 就能定位方向。
4.2 JWT 登录鉴权的落地细节
登录模块我用 JWT 做无状态认证。用户登录后,后端生成包含用户 ID、角色、过期时间的 token,前端存在本地,每次请求在请求头带上 Authorization: Bearer <token>,后端通过拦截器统一校验。
生成 token 的核心代码:
java复制String token = Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("role", user.getRole())
.setExpiration(new Date(System.currentTimeMillis() + 3600_000L))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
这里特别提醒:JWT 默认只是 Base64 编码,并没有加密,payload 里的内容可以直接解码出来。所以 token 里绝对不能放密码、身份证号这类敏感字段。我统一只放 userId、role、过期时间这三个信息,所有敏感数据都通过数据库查询获取,不在 token 里做缓存。
4.3 分配宿舍的核心逻辑:并发控制是关键
分配宿舍表面上就是“选个房间把学生放进去”,实际写起来要考虑一个很实际的问题:两个管理员同时操作,刚好只剩最后一个床位,不能让两个人同时分进去。我在分配接口里做了这样几步:
- 校验学生当前状态,确认可以分配或者允许换宿。
- 查询目标房间,判断
used_count < bed_count且房间处于可用状态。 - 用一条带条件的原子更新语句扣减余量。
- 插入住宿记录。
- 更新学生住宿状态。
第 3 步是整段逻辑的核心,SQL 类似这样:
sql复制UPDATE dorm_room
SET used_count = used_count + 1
WHERE id = #{roomId}
AND used_count < bed_count
AND status = 1
如果这条 UPDATE 返回的影响行数是 0,说明房间已经住满,接口直接返回“房间已满”的提示。这种方式比先查询再比较再做更新要安全得多,避免了并发场景下的超卖问题。即便只是做演示项目,代码里体现“考虑过并发”这一点,也会让看代码的人觉得你对业务有真实理解。
4.4 报修工单接口设计与权限控制
报修相关的接口我按资源路径来规划,学生和宿管共用一套数据表,通过角色区分操作权限:
POST /api/repair:学生创建报修单GET /api/repair/list:按角色查询工单列表PUT /api/repair/{id}/process:宿管标记处理中PUT /api/repair/{id}/finish:维修人员完成维修PUT /api/repair/{id}/confirm:学生确认并评价
角色权限通过自定义注解配合 AOP 拦截,不在每个接口里写 if/else 判断。接口层只负责接收参数和调用 service 层,业务规则全部下沉到 service,这样多个接口复用一个逻辑时不容易出现不一致。
5. Vue 前端的搭建过程:路由守卫、请求封装与列表页实战
5.1 项目初始化和目录规划
前端用 Vue CLI 创建,vue create dorm-web,选择 Babel + Router + Vuex。目录做了统一规划,views 放页面组件,components 放通用组件,api 文件夹按模块拆封接口请求,utils 里放工具函数。
Element UI 组件库使用按需引入,不在 main.js 里全量导入。做法是在 babel.config.js 里配置,这样打包的时候只打包实际用到的组件,首屏加载体积会小很多。这个小优化在开发阶段感觉不明显,部署到服务器用浏览器打开第一次加载时,能明显感觉到速度差异。
5.2 路由守卫与权限控制
前端不能只依赖后端接口权限,路由层面也要控制。我在 router.beforeEach 里做两件事:检查本地是否有 token,没有就跳转到登录页;有 token 但当前路由标记了 meta.requiresAdmin,再检查本地存储的角色信息,不是管理员就提示无权限。
路由守卫本身不复杂,但容易忽略 session 过期的情况。我在 axios 响应拦截器里统一监听 401 状态码,一旦发现就清空本地 token 并跳回登录页。否则用户在一个已经失效的页面上操作,每点一个按钮都是报错,体验很不好。
5.3 Axios 封装的统一拦截
axios 封装是前端工程化的基础。我封装了一个 request.js,统一设置 baseURL、超时时间、请求拦截器和响应拦截器。核心代码:
javascript复制service.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
}, error => Promise.reject(error))
service.interceptors.response.use(
response => {
const res = response.data
if (res.code === 200) return res
Message.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
},
error => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token')
router.push('/login')
}
return Promise.reject(error)
}
)
统一拦截的好处是,业务页面里不需要每个请求都写错误处理,只关心拿到数据后怎么渲染。错误提示统一弹出 toast,整个项目交互风格保持一致。
5.4 宿舍房间列表页的实现
宿舍列表页是典型的管理后台页面,用 el-table 展示数据,点击房间号弹出该房间的学生列表。核心代码不复杂,但有几个细节值得注意:
vue复制<template>
<div class="room-list">
<el-table :data="roomList" stripe>
<el-table-column prop="buildingName" label="楼栋" width="120" />
<el-table-column prop="roomNo" label="房间号" width="100" />
<el-table-column prop="roomTypeName" label="类型" width="100" />
<el-table-column prop="usedCount" label="已住" width="80" />
<el-table-column prop="bedCount" label="床位" width="80" />
<el-table-column label="操作">
<template slot-scope="scope">
<el-button type="text" @click="showStudents(scope.row)">查看学生</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
这里踩过的一个坑是:后端某个接口返回的字段名是下划线风格,另一处又返回驼峰风格,前端取值时经常对不上。后来我强制要求所有接口响应字段统一使用驼峰命名,前端也统一按驼峰读取,这个问题就彻底消失了。这种约定看似小事,但在前后端并行开发时特别重要。
6. 本地启动、部署与踩坑记录
6.1 环境准备和启动步骤
如果你拿到这套源码,想在自己电脑上跑起来,推荐环境如下:
| 软件 | 版本建议 |
|---|---|
| JDK | 1.8 或 17 |
| Maven | 3.6 及以上 |
| Node.js | 14.x 或 16.x |
| MySQL | 5.7 或 8.0 |
| 开发工具 | IDEA 或 VSCode |
后端启动步骤:先创建数据库 dormitory_db,导入源码中 sql 目录下的脚本,然后修改 application.yml 里的数据库地址和密码,最后运行启动类的 main 方法。前端启动步骤:npm install 安装依赖,然后 npm run serve 启动开发服务器。前端默认端口是 8080,后端是 8081,前端通过开发服务器自带的 proxy 配置把 /api 请求代理到后端,这样开发阶段就不会被跨域问题卡住。
6.2 数据库初始化建议
源码里附带的 SQL 脚本不只有建表语句,还包含测试账号和基础楼栋房间数据。建议直接导入脚本,不要手动建表。因为表之间有关联关系,比如楼栋表没有数据,房间表就无从挂载。测试账号也需要用脚本里已经加密过的密码,手动插入的用户如果没做同样加密,登录就会失败。
6.3 我实际遇到过的四个经典坑
第一个坑是 MySQL 8.0 驱动类名变了。8.x 版本的驱动是 com.mysql.cj.jdbc.Driver,老版本的 com.mysql.jdbc.Driver 会导致报错。同时连接地址要加 serverTimezone=Asia/Shanghai,否则系统时间和数据库时间会出现时区偏移。
第二个坑是跨域问题。开发环境可以用 proxy 解决,但前端打包部署到 Nginx 之后,后端如果不配置 CORS,浏览器会拒绝跨域请求。我在后端 config 包下写了一个跨域配置类,明确允许前端域名访问。
第三个坑是 MyBatis-Plus 条件构造器查询时,参数为空还直接传进 eq 方法会不符合预期。我后来统一要求,所有可能为空的查询条件都使用带 condition 的重载方法:
java复制lambdaQueryWrapper.eq(StringUtils.isNotBlank(req.getName()), DormRoom::getRoomNo, req.getName());
第四个坑是日期格式不一致。同样的 LocalDateTime 类型,本地开发正常,部署到 Linux 服务器后返回给前端的变成了 T 字分隔的 ISO 格式。原因是没配置全局 Jackson 序列化规则。加一个配置类统一格式就能解决。
6.4 项目的不足与后续扩展方向
这套系统能跑通完整流程,但和生产级系统之间还有距离。比如报修状态变化后,学生必须刷新页面才能看到最新结果,后续可以引入 WebSocket 做状态推送。学生名单目前主要还是手动录入,如果有几千人规模的新生数据,做一个 Excel 批量导入模块会节省大量时间。权限模型目前粒度只到角色,如果将来要细分到某个楼栋或某栋管理员只能看自己负责的数据,还需要在权限上再扩展一层。
这些扩展方向我在做文档的时候也写进去了。一个项目交付之后,把遗留问题和优化方向记录下来,比代码本身更能体现工程思维。后续要不要继续做,取决于这个项目在你计划里的定位,但至少下一次提到它时,你能很清晰地讲出哪里做得好、哪里可以更好。
