简介:面向高校计算机相关专业毕业设计与课程设计场景的教室预约管理系统论文文档,依托 Spring Boot 框架、Java 语言、MySQL 数据库与微信小程序技术栈,围绕教室资源紧张、人工排课与预约冲突等实际问题,展开从选题背景、研究现状、需求与可行性分析,到系统设计、后端实现与测试的完整论述。文档结构包含绪论、开发工具及关键技术介绍、系统分析等章节,并附中英文摘要与关键词,可对照查阅微信开发者工具、小程序目录结构、数据库表结构与索引设计等具体环节的实现思路,同时讨论系统平台后期的维护、升级与安全等可操作性内容。压缩包内共 1 个 doc 文件,约 4.65MB,为论文全文,可直接用于格式参考、章节框架借鉴与答辩材料整理。目前已有 132 人学习下载,适合需要完成同类选题、梳理技术实现脉络或参考论文写作结构的学生与开发者。
1. 从纸质登记本到小程序:这套系统真正要解决的那一个矛盾
开学第一周,教务处的桌子前总会排起队:有人在群里问 A 楼 301 下午三四节还空不空,有人拿着纸质登记本挨个教室贴条,还有人明明看到系统显示「空闲」,走过去发现门锁着——登记本和实际占用是两套账。高校教室预约管理系统要处理的矛盾就这一个:把分散在各学院、各社团、各教务口径的占用信息,收敛成一份实时可查的账。用 SpringBoot 做后端、微信小程序做前端,是这几年毕设和校内在用系统里最常见的组合:学生不用装 App,扫码或搜小程序就能查教室、选节次、提交申请;教务端在后台审核、导出统计、锁定考试周。它适合两类人:想把毕业设计做成真能跑起来的系统的同学,以及校内信息化岗想替换纸质登记的人。下面按数据建模、SpringBoot 接口、小程序端、并发与超时释放四段推进,每个环节都给能直接抄的建表语句和代码。
2. 高校教室预约管理系统的表结构:从时段粒度到并发占位
先把库设计对,后面写接口和写论文都会轻松很多。教室预约最怕的不是功能少,而是「同一间教室同一节课出现两条有效记录」。这个问题在数据层解决,比在业务代码里写一堆 if 判断可靠得多。
2.1 四张核心表怎么切分
常见的做法是拆成四张:classroom存教室档案,reservation存预约单主体,room_slot存「某教室某天第几节」的占位记录,sys_user存用户与角色。拆出room_slot是这套设计的关键,很多人图省事只在reservation里存起止节次,结果冲突判定只能写成区间重叠查询,索引用不上,并发下还挡不住重复插入。
reservation记录的是「谁、为什么、什么状态」,属于业务单据;room_slot记录的是「资源被占了」,属于资源账本。两者一对多,一张预约单占 3 节就落 3 行 slot。账本一旦独立,唯一索引才能建得干净利落。
| 表名 | 作用 | 关键字段 | 量级预估 |
|---|---|---|---|
| classroom | 教室档案 | room_no、building、capacity、status | 百级 |
| reservation | 预约单主体 | user_id、room_id、reserve_date、status | 万级/学期 |
| room_slot | 节次占位账本 | room_id、reserve_date、slot | 十万级/学期 |
| sys_user | 用户与角色 | openid、role、violate_count | 万级 |
2.2 时段粒度:按「节」还是按小时,直接决定冲突判定
粒度选不对,后面全是补丁。高校作息表本身就是按节走的,上午 1-2 节、3-4 节,下午 5-6 节、7-8 节,晚上 9-11 节。按小时建模会出现「预约 8:00-9:40 到底是 1-2 节还是 1-3 节」的扯皮,教务排课也对不上。
所以slot直接取 1 到 12 的整数,一节课一个值。好处有三个:唯一索引可以建在(room_id, reserve_date, slot)上;小程序端用单选框让用户点节次,交互和作息表一致;统计「哪间教室利用率高」时直接GROUP BY room_id, slot就行。
教室一天最多 12 条 slot,一学期按 120 天算,500 间教室就是 72 万行,MySQL 单表完全扛得住,不需要分表。
2.3 用唯一索引把并发抢座变成一次插入失败
这是整套系统里最值得写进论文的一段。两个学生同一秒点下「提交」,如果业务代码里是「先查有没有冲突,没有就插入」,两步之间必然有间隙,两人都能查到「无冲突」。
正确做法是让数据库来判:room_slot上建唯一索引,插入冲突时数据库直接抛DuplicateKeyException,事务回滚。这样并发问题从「应用层判断」变成了「存储层保证」,无论多少个请求同时进来,同一间教室同一节只会成功一次。
代价是异常控制的写法要规范:捕获后必须重新抛出一个运行时异常,不能吞掉,否则事务不会回滚,会出现主表有单子、账本没占位的脏数据。这一点在@Transactional方法里特别容易踩。
2.4 建表语句与索引
-- 教室档案 CREATE TABLE classroom ( id BIGINT NOT NULL AUTO_INCREMENT, building VARCHAR(32) NOT NULL COMMENT '教学楼,如 逸夫楼', room_no VARCHAR(16) NOT NULL COMMENT '教室编号,如 A301', capacity INT NOT NULL DEFAULT 0 COMMENT '座位数', has_projector TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否有投影', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可用 0停用', PRIMARY KEY (id), UNIQUE KEY uk_room (building, room_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 预约单主体 CREATE TABLE reservation ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, room_id BIGINT NOT NULL, reserve_date DATE NOT NULL, slot_start TINYINT NOT NULL COMMENT '起始节次 1-12', slot_end TINYINT NOT NULL COMMENT '结束节次 1-12', purpose VARCHAR(128) NOT NULL COMMENT '用途,如 社团例会', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审 1通过 2驳回 3取消 4已签到 5爽约', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_date (user_id, reserve_date), KEY idx_status_date (status, reserve_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 节次占位账本:并发控制的核心 CREATE TABLE room_slot ( id BIGINT NOT NULL AUTO_INCREMENT, room_id BIGINT NOT NULL, reserve_date DATE NOT NULL, slot TINYINT NOT NULL COMMENT '第几节 1-12', reservation_id BIGINT NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_room_date_slot (room_id, reserve_date, slot), KEY idx_reservation (reservation_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_room_date_slot是整套系统的安全阀,不要省。idx_reservation用于取消预约时按单据回删占位。idx_status_date用于教务端按状态筛选和定时任务扫描。
reservation.status用 TINYINT 而不是字符串,是为了后面写状态机方便:允许的流转是 0→1、0→2、1→3、1→4、1→5,其余一律拒绝。取消时要记得把对应的room_slot行删掉,否则时段被永久锁死——这是线上最容易出的「幽灵占用」问题。
3. SpringBoot 侧:预约接口的事务、校验与状态流转
后端只干三件事:鉴权、事务里的占位、状态流转。想做毕设加分项,可以把教室空闲查询挂 Redis,但下单路径一定要走数据库。
3.1 依赖选型:版本、ORM 和 starter 怎么配
用idea创建 SpringBoot 项目时,最容易出问题的是版本。SpringBoot 3.x 要求 JDK 17,包名从javax.*换成jakarta.*,很多老教程里的 JPA 注解会直接编译不过;学校机房里装的还是 JDK 8 的话,老老实实用 2.7.x 更省事,spring-boot-starter-web、mybatis-plus-boot-starter、spring-boot-starter-data-redis、spring-boot-starter-validation四个就够。ORM 推荐 MyBatis-Plus,insert抛出的DuplicateKeyException语义清晰,比 JPA 的DataIntegrityViolationException好识别。
<!-- pom.xml 关键依赖,JDK8 场景 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>SpringBoot 的自动装配会扫描application.yml里的spring.datasource配置并注入DataSource,MyBatis-Plus 的 starter 再把它包装成SqlSessionFactory,所以只要有配置类和 mapper 接口,不用手写 XML。想确认装配是否生效,启动时加--debug看自动配置报告里MybatisPlusAutoConfiguration是否为 positive matches。
3.2 接口清单与统一返回结构
| 方法 | 路径 | 说明 | 需要的角色 |
|---|---|---|---|
| POST | /api/auth/login | 小程序 code 换 token | 匿名 |
| GET | /api/classroom/free | 按日期+节次查空闲教室 | 学生 |
| POST | /api/reservation | 提交预约 | 学生 |
| GET | /api/reservation/mine | 我的预约列表 | 学生 |
| PUT | /api/reservation/{id}/audit | 审核通过/驳回 | 教务 |
| PUT | /api/reservation/{id}/cancel | 取消并释放节次 | 学生本人 |
统一返回{code, msg, data},code=0 表示成功。小程序端只判断 code,不要把 HTTP 状态码当业务码用,否则 401 和业务失败混在一起非常难排查。
3.3 预约接口的完整实现
@Service public class ReservationService { @Resource private ReservationMapper reservationMapper; @Resource private RoomSlotMapper roomSlotMapper; @Resource private UserMapper userMapper; /** * 提交预约:主表落单 + 账本占位,靠唯一索引兜住并发 */ @Transactional(rollbackFor = Exception.class) public Long reserve(ReserveCmd cmd, Long userId) { if (cmd.getSlotEnd() < cmd.getSlotStart()) { throw new BizException("节次区间不合法"); } // 爽约次数超限的账号禁止再约,教务可手动解除 if (userMapper.selectById(userId).getViolateCount() >= 3) { throw new BizException("爽约次数过多,请到教务处处理"); } Reservation r = new Reservation(); r.setUserId(userId); r.setRoomId(cmd.getRoomId()); r.setReserveDate(cmd.getReserveDate()); r.setSlotStart(cmd.getSlotStart()); r.setSlotEnd(cmd.getSlotEnd()); r.setPurpose(cmd.getPurpose()); r.setStatus(0); // 待审核 reservationMapper.insert(r); // 回填自增 id try { for (int slot = cmd.getSlotStart(); slot <= cmd.getSlotEnd(); slot++) { RoomSlot s = new RoomSlot(); s.setRoomId(cmd.getRoomId()); s.setReserveDate(cmd.getReserveDate()); s.setSlot(slot); s.setReservationId(r.getId()); roomSlotMapper.insert(s); // 撞唯一索引即失败 } } catch (DuplicateKeyException e) { // 必须重抛运行时异常,否则事务不回滚,会留下脏单 throw new BizException("该时段已被预约,请换一个时段"); } return r.getId(); } }逻辑上有两处值得说明。第一,先插主表再插账本,顺序不能反:账本需要reservation_id,主表自增 id 是回填的。第二,catch里抛出的是自定义BizException,它继承RuntimeException,配合rollbackFor = Exception.class能保证整笔操作原子回滚。如果这里写成打印日志然后return null,主表就留下一条永远不会被占位的幽灵单子。
参数侧用@Validated加注解约束,比在方法里手写if干净:@NotNull管空值,@Min(1) @Max(12)管节次范围,@FutureOrPresent管日期不能选过去。
3.4 参数校验、状态机与失败排查
状态流转单独抽一个方法,别散落在各个接口里。
private static final Map<Integer, Set<Integer>> FLOW = Map.of( 0, Set.of(1, 2), // 待审 -> 通过 / 驳回 1, Set.of(3, 4, 5), // 通过 -> 取消 / 签到 / 爽约 2, Set.of(), 3, Set.of(), 4, Set.of(), 5, Set.of() ); public void transfer(Reservation r, int target) { if (!FLOW.getOrDefault(r.getStatus(), Set.of()).contains(target)) { throw new BizException("状态不允许从 " + r.getStatus() + " 变为 " + target); } r.setStatus(target); reservationMapper.updateById(r); if (target == 2 || target == 3) { roomSlotMapper.delete(new LambdaQueryWrapper<RoomSlot>() .eq(RoomSlot::getReservationId, r.getId())); // 驳回或取消必须释放节次 } }失败排查按这个顺序看:报「时段已被预约」但数据库里查不到冲突行,检查是否用了readOnly = true的事务导致回滚失效;报主键冲突但代码里没写 id,检查room_slot是不是建了id自增;列表查出来有重复时段,检查取消逻辑有没有删room_slot。这三条覆盖了九成以上的现场问题。
4. 微信小程序端:登录换 token、节次单选与提交联调
前端这层最能体现「体验差一点、投诉多一倍」。学生只用三个页面:教室列表、预约表单、我的预约。做薄做稳比做花哨重要。
4.1 登录:wx.login 拿 code,后端换 openid 和 token
小程序的登录不能在前端拿 openid,必须把wx.login返回的临时 code 发给后端,由后端携带 AppID 和 AppSecret 去换取 openid 和 session_key,再签发自己的 token 返回给小程序。token 存wx.setStorageSync,后续请求放Authorization头。
// utils/auth.js export async function login() { // 1. 前端只拿 code,不接触 AppSecret const { code } = await wx.login(); // 2. code 换 token,baseUrl 走 https,且已配置到 request 合法域名 const res = await wx.request({ url: `${getApp().globalData.baseUrl}/api/auth/login`, method: 'POST', data: { code } }); if (res.data.code !== 0) throw new Error(res.data.msg); wx.setStorageSync('token', res.data.data.token); return res.data.data; }wx.login的 code 只能用一次,五分钟过期;接口返回 401 时不要直接跳登录页,先静默重登一次再重试原请求,否则弱网下会出现「点一下闪回首页」的体验问题。基础库 2.10.2 之后wx.request、wx.login支持 Promise 化写法,低于这个版本要用success/fail回调,这是联调期最常见的兼容坑之一。
4.2 页面结构、自定义导航栏高度与节次单选框
app.json里注册三个页面,pages数组第一项即首页。用自定义导航栏时高度必须动态算,写死 44px 在刘海屏和安卓上会错位:状态栏高度用wx.getWindowInfo().statusBarHeight取,胶囊位置用wx.getMenuButtonBoundingClientRect()取,导航栏高度等于胶囊底部到状态栏顶部的距离乘以 2 再减去状态栏高度。这套算法在所有机型上都稳。
节次选择直接用radio-group,一次只选一个起始节次和结束节次,比滑块控件容易点准,也方便后端校验。
<!-- pages/reserve/reserve.wxml 片段 --> <view class="nav" style="height: {{navHeight}}px; padding-top: {{statusBarHeight}}px;"> {{room.building}}{{room.roomNo}} </view> <radio-group bindchange="onSlotChange">// pages/reserve/reserve.js 提交逻辑 async submit() { if (this.data.submitting) return; // 前端防连点 if (!this.data.slotStart || !this.data.slotEnd) { return wx.showToast({ title: '请选择节次', icon: 'none' }); } this.setData({ submitting: true }); try { const res = await wx.request({ url: `${getApp().globalData.baseUrl}/api/reservation`, method: 'POST', header: { Authorization: `Bearer ${wx.getStorageSync('token')}` }, data: { roomId: this.roomId, reserveDate: this.data.date, // 必须是 yyyy-MM-dd 字符串 slotStart: this.data.slotStart, slotEnd: this.data.slotEnd, purpose: this.data.purpose } }); if (res.data.code === 0) { wx.showToast({ title: '已提交,等待审核' }); setTimeout(() => wx.navigateBack(), 800); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); } } finally { this.setData({ submitting: false }); } }日期不要用new Date('2025-03-01 08:00')这种写法在 iOS 上解析,不同机型结果不一致,日期选择器直接返回yyyy-MM-dd字符串透传给后端最省事。submitting标志位只是防手抖,真正的一致性保证仍在后端的唯一索引上,前端拦截永远不能当作并发方案。
4.4 联调阶段最常见的 5 个报错
| 现象 | 原因 | 处理 |
|---|---|---|
| request 域名不合法 | 未在后台配置合法域名,或用了 IP | 开发阶段勾「不校验合法域名」,上线前配 https 域名 |
| 基础库版本过低 | Promise 化 API 不可用 | 后台把最低基础库调到 2.10.2 以上 |
| 页面白屏、无报错 | 分包或lazyCodeLoading配置不当 | 先关掉lazyCodeLoading排查,再按需开启 |
| 上传后首次进页面空白 | 未在app.json注册该页面路径 | 补齐pages数组并重新上传 |
| 提交后一直转圈 | 后端事务未提交或连接池耗尽 | 看后端慢 SQL 日志与连接池活跃数 |
上线前用hbuilderx或开发者工具的「上传」走一遍体验版流程,重点验证真机上的自定义导航栏和日期选择,模拟器正常不代表真机正常。
5. 进阶:把空闲查询压到 Redis,再让超时占位自己释放
系统跑起来之后,压力往往不在提交,而在「查空闲教室」这个动作上——一学期开学前两天,学生反复刷新列表,每次都是全表扫room_slot。把这块的结果缓存住,收益立竿见影。
5.1 Redis 缓存教室空闲位
key 按「日期+节次」切分,value 存空闲教室 id 列表,redis在springboot中的使用最常见的就是这种读多写少的场景。
public List<Long> listFreeRooms(LocalDate date, int slot) { String key = "free:" + date + ":" + slot; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseArray(cached, Long.class); } List<Long> ids = roomSlotMapper.selectFreeRoomIds(date, slot); // 缓存 60 秒:足够挡住刷新,又不会让新预约长时间不可见 redisTemplate.opsForValue().set(key, JSON.toJSONString(ids), Duration.ofSeconds(60)); return ids; }关键是失效策略而不是缓存本身。任何一次预约成功、取消、驳回,都要删掉受影响的 key,或者干脆只依赖 60 秒的短过期——短过期实现简单,代价是列表最多滞后一分钟,对教室预约这个场景完全可以接受。用「先更新数据库再删缓存」的顺序,别用「更新缓存」,省得并发下写出旧值。
5.2 定时任务释放超时未签到的占位
预约通过后学生没来,时段不能一直锁着。加一个每 10 分钟跑一次的定时任务,把「状态为已通过、且开始时间已过 15 分钟仍未签到」的单子置为爽约,并释放room_slot。
@Scheduled(cron = "0 */10 * * * ?") public void releaseTimeout() { List<Reservation> list = reservationMapper.selectTimeout( LocalDateTime.now().minusMinutes(15)); for (Reservation r : list) { reservationService.transfer(r, 5); // 5 = 爽约 userMapper.incrViolateCount(r.getUserId()); // 累计爽约,三次进黑名单 } }transfer复用第 3 章的共用方法,驳回、取消、爽约三条路径都走同一处释放逻辑,避免出现「取消释放了、爽约没释放」的不一致。任务要加分布式锁或单实例部署约束,多副本环境下不加锁会导致同一单子被处理两次,爽约次数翻倍。
5.3 用并发脚本验证「超卖」是否真的被挡住
论文里写「解决了高并发问题」是空的,给一组可复现的数据才有说服力。开两个线程池,对同一间教室同一天同一节发 200 个请求,统计成功数。
# 前提:token 已写入环境变量,接口地址为本机 seq 1 200 | xargs -P 50 -I{} curl -s -X POST http://localhost:8080/api/reservation \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"roomId":1,"reserveDate":"2025-06-01","slotStart":3,"slotEnd":4,"purpose":"压测"}' \ | grep -o '"code":[0-9]*' | sort | uniq -c预期结果是"code":0只有一条,其余全部是时段冲突。如果出现两条成功,去查room_slot的唯一索引是不是没建成功——用SHOW INDEX FROM room_slot确认uk_room_date_slot的Non_unique为 0。再补一个反向验证:并发提交 200 个不同教室的请求,成功率应该接近 100%,说明失败来自资源冲突而不是锁竞争。把这两组数字连同AB或JMeter的聚合报告截图放进论文的测试章节,比任何形容词都管用。
最后提一个上线前必做的动作:在room_slot上按reserve_date建分区或定期归档上学期数据,一个学期十万行不痛不痒,积累三年后空闲查询的扫描行数会翻十倍,那时再改表就要停机了。
本文还有配套的精品资源,点击获取