
自习室预约系统这种题目如果你在毕业设计选题列表里见过它或者正打算拿它练手那这篇文章就是写给你的。我做这套系统的时候核心选了 SpringBoot Vue 的组合后端用 MyBatis 操作 MySQL前端用 Vue 渲染座位图和预约流程把 MVC 分层落到了每个模块里。整套源码已经整理到可以拿来直接改的程度但比源码更值钱的是我在预约冲突、座位状态、超时释放这些细节上踩过的坑。这篇就把设计思路和实现链路完整过一遍从建表到接口从前端调用到并发处理尽量说透而不是只给你一张截图。1. 自习室预约系统解决的是什么问题占座、统计与管理的现实痛点1.1 学习场景里最真实的座位矛盾自习室座位这个话题几乎每个学校都绕不开。高峰期的时候图书馆和教学楼自习室的位置要靠抢甚至有人拿书本、水杯、充电器占座人到了中午才出现真正想学习的同学反而找不到位置。如果你去问自习室管理员他们最大的麻烦不是高峰期本身而是根本不知道哪些座位是看起来被占、实际没人的。传统做法是拿一张纸质登记表或者Excel登记谁想用座位就过来签个到管理员只能在晚上闭馆之后统计一天的使用情况等发现问题早就晚了。这套系统的出发点就是解决这三个问题用户可以提前预约座位减少现场抢座的随机性座位在预约后被锁定超时没有签到就自动释放治占座不履约的毛病管理员能从后台看到每个时段、每个自习室的预约量和上座率而不是月底靠翻Excel做总结。1.2 这套系统到底管哪些事我做这个系统的时候把功能收敛成了两个角色、五类操作没有贪多。用户端主要做四件事查看自习室的实时座位图绿色是空闲、红色是占用、灰色是维护中选择一个具体时间段提交预约系统自动判断是否冲突查看我的预约列表支持签到和主动退座收到预约成功和超时提醒超时释放这种能力靠后端定时任务实现管理员端主要做三件事维护自习室和座位的基础数据比如新增自习室、调整座位状态为维护中查看所有预约流水可以手动取消异常的预约记录查看数据看板当日预约数、上座率、高峰时段分布功能看似不多但把一个预约场景从提交到释放的完整状态流转做通比堆十个增删改查页面有价值得多。很多课设项目就是死在看起来页面多、实际上逻辑浅这上面我这个项目宁可少做两个页面也要把预约冲突检测和超时释放这两个核心逻辑做扎实。1.3 什么人适合拿它当参考如果你是准备毕设或者课设的学生这套系统的技术栈组合非常适合做开题报告里的创新点——SpringBoot负责后端接口、Vue负责前端交互、MyBatis作为ORM层、MySQL做数据持久化MVC分层清晰每一层都能在答辩的时候展开讲两句。如果你是自学的初学者那么这个项目的核心价值在于你能看到一张座位表和一个预约表是怎么通过Service层协调起来的而不是一上来就面对残缺的半成品。当然也要说实话这种管理系统在业务上并不复杂它真正考验人的是边界情况——时间重叠怎么判断、并发抢同一个座位怎么办、用户超时不签到怎么释放座位。这些才是源码之外值得仔细琢磨的地方。2. 技术栈落位SpringBoot Vue MyBatis 在 MVC 三层中的分工2.1 为什么是 SpringBoot MyBatis而不是 SSM 或 JPA选型这个问题答辩老师大概率会问你自己心里也得有数。SSMSpring SpringMVC MyBatis技术栈不是不能用但配置太繁琐光一个XML配置文件就能绕晕初学者。SpringBoot把自动配置做好了内嵌Tomcat一个main方法就能启动项目省下来的精力可以全部放在业务代码上。ORM层我用MyBatis而不是JPA原因很实际这类系统里有不少多表关联查询和动态条件查询比如查某个时间段内某间自习室的所有可用座位MyBatis的SQL是直接手写的可控性很强一眼就能看出查询条件是什么。JPA虽然封装程度高但在这种报表类查询里反而不直观——你写的是方法名推导的规则出了问题定位起来绕圈子。从面试角度讲MyBatis也是Java后台岗位面试里的高频考点一级缓存、二级缓存、#{}和${}的区别、动态SQL这些都能在这个项目里找到对应代码。做课设的同时把面试题顺便复习了这笔账是赚的。2.2 前后端目录结构长什么样后端我用标准的Controller-Service-Mapper三层去组织代码这也是MVC里MModel、VView、CController的落地形态。前端Vue负责的其实是View层职责后端Controller暴露JSON接口给Vue调用。后端核心目录如下src/main/java/com/studyroom/ ├── controller/ │ ├── UserController.java │ ├── RoomController.java │ ├── SeatController.java │ └── ReservationController.java ├── service/ │ ├── ReservationService.java │ └── impl/ │ └── ReservationServiceImpl.java ├── mapper/ │ ├── UserMapper.java │ ├── RoomMapper.java │ ├── SeatMapper.java │ └── ReservationMapper.java ├── entity/ │ ├── User.java │ ├── Room.java │ ├── Seat.java │ └── Reservation.java ├── config/ │ └── CorsConfig.java └── StudyroomApplication.javaController只做参数接收和结果返回不写业务逻辑。Service层负责预约校验、状态流转这类核心规则。Mapper层只负责SQL和执行。这样拆开之后哪怕后面要换掉前端后端接口完全不用动这就是分层的好处。前端我用Vue配合Element UI库写管理界面路由分了两条线src/ ├── api/ │ ├── reservation.js │ └── user.js ├── views/ │ ├── login.vue │ ├── seatMap.vue │ ├── myReservations.vue │ └── admin/ │ ├── dashboard.vue │ └── reservationManage.vue ├── router/ │ └── index.js └── store/ └── index.js路由用懒加载的方式引入页面组件这样构建出来的JS不会挤在一个大文件里首屏加载会快一些。2.3 版本搭配与环境准备版本搭配看起来是小问题但坑起来真要命。我实测下来这套组合是最稳的组件推荐版本说明JDK1.8SpringBoot 2.x 生态最成熟SpringBoot2.7.x与 JDK8 兼容性最好MySQL5.7 或 8.08.0 注意配置 serverTimezoneMyBatis3.5.x配合 mybatis-spring-boot-starterNode.js14前端构建环境Vue2.6 / 3.x配 Element UI 或 Element Plus如果你用的SpringBoot版本太高比如3.x那你需要JDK17起步MyBatis的starter也要换成新坐标很多老教程里的配置会失效。我在项目里锁定在Java 8 SpringBoot 2.7这个组合上就是为了让新手照着一模一样的步骤也能跑起来不折腾版本环境。3. 数据库建模与冲突检测座位状态与预约记录为什么必须分开3.1 四张表的字段设计与外键关系数据库是整个系统的地基。我设计了四张表用户表、自习室表、座位表、预约记录表。很多初学者会犯一个错误——把座位状态直接做成座位表的一个字段然后预约记录里再存一份座位状态两张表互相打架。正确的做法是座位表只维护座位的物理属性和当前可用的粗状态预约记录表负责记录每一次预约的明细和生命周期状态。这就像餐厅的桌台管理桌台本身存在空桌/在用/维护三种状态但每一拨客人的预订、入座、离席应该单独记流水账。如果你把这桌被谁订了直接写在桌台表里那历史记录、统计分析全都无从谈起。核心建表SQL如下CREATE TABLE tb_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role TINYINT DEFAULT 2 COMMENT 1管理员 2普通用户, nickname VARCHAR(50), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE tb_room ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, location VARCHAR(200), open_time TIME DEFAULT 08:00:00, close_time TIME DEFAULT 22:00:00, description VARCHAR(500) ); CREATE TABLE tb_seat ( id INT PRIMARY KEY AUTO_INCREMENT, room_id INT NOT NULL, seat_no VARCHAR(20) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0空闲 1占用 2维护, row_num INT, col_num INT, UNIQUE KEY uk_room_seat (room_id, seat_no) ); CREATE TABLE tb_reservation ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, seat_id INT NOT NULL, reserve_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待签到 1已签到 2已完成 3爽约 4已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_date (user_id, reserve_date), KEY idx_seat_date (seat_id, reserve_date) );预约表里我加了两个普通索引idx_user_date和idx_seat_date。这两个索引对应两个高频查询场景一个用户查自己某天的预约记录系统查某个座位某天是否有冲突预约。没有索引的话数据量一旦上千查询就会开始变慢答辩的时候如果被问到你的系统能支撑多大并发索引就是你能拿出来的第一个优化点。3.2 预约时间冲突的判断逻辑预约系统最核心的一段逻辑就是冲突检测。场景是这样的座位S在2024-06-01这一天已经有一个09:00-10:00的预约记录现在有一个新用户想约09:30-10:30系统应该拒绝他因为时间重叠了。这里的关键在于条件判断的边界。很多人第一次写会写成start_time #{startTime} AND end_time #{endTime}这只能判断新预约完全包含在旧预约内部这一种情况漏掉了部分重叠的场景。正确写法是select idcountConflict resultTypeint SELECT COUNT(*) FROM tb_reservation WHERE seat_id #{seatId} AND reserve_date #{date} AND status IN (0, 1) AND start_time lt; #{endTime} AND end_time gt; #{startTime} /selectXML里的小于号必须转义成lt;否则MyBatis会把它当成XML标签的开头启动直接报错。这个细节我在项目里第一次跑的时候踩过报错信息还是那种看不懂的解析异常排查了半天才反应过来。条件start_time #{endTime} AND end_time #{startTime}表示两个区间[旧开始, 旧结束]和[新开始, 新结束]存在交集。做一个简单的验算旧区间09:00-10:00新区间10:00-11:00那么旧开始09:00 新结束11:00成立旧结束10:00 新开始10:00不成立整体条件不满足说明这两个区间可以无缝衔接连续预约互不干扰。这个边界处理一定要想清楚否则用户正好卡着结束时间点约下一场就会被误判成冲突。3.3 座位状态的冗余设计座位表的status字段和预约表的status字段是一对冗余与同步的关系。座位表的0空闲/1占用本质上是当前是否有有效预约的缓存值。这样做的好处是用户打开座位图时只需要查座位表就能拿到一个座位的当前状态不需要去预约表里做复杂的聚合判断坏处是如果只更新预约表而忘记更新座位表两张表的数据就会不一致。我在项目里规定了一组严格的同步规则提交预约成功 → 座位表status置为1占用用户主动退座或超时释放 → 座位表status置为0空闲管理员把座位设为维护 → 座位表status置为2维护同时取消该座位未来时段的待签到预约规则简单但每一条都要在同一个事务里执行。这也是为什么预约接口必须要加Transactional的原因插入预约记录和更新座位状态是两件事任何一个失败都要一起回滚否则就会出现预约记录存在但座位显示空闲或者反过来座位被占但查不到预约的诡异状态。4. 核心预约流程的实现链路从选座提交到超时自动释放4.1 用户从前端点击到落库的完整调用链预约是最核心的流程我从前端到后端完整串一遍。用户打开座位图之后看到的是由座位表数据渲染出来的格子视图。点击一个空闲座位弹出时间选择器选完时段点提交这时前端Vue会向后端接口发送一个POST请求。// vue页面里的核心方法 async function submitReservation(seatId, date, startTime, endTime, userId) { const res await axios.post(/api/reservation, { seatId: seatId, date: date, startTime: startTime, endTime: endTime, userId: userId }, { headers: { Authorization: localStorage.getItem(token) } }) if (res.data.code 200) { ElMessage.success(预约成功请按时签到) refreshSeatMap() } else { ElMessage.error(res.data.msg) } }后端Controller接到请求后把参数封装成一个DTO对象交给Service层处理。Service层的核心校验逻辑我直接贴出来Override Transactional(rollbackFor Exception.class) public Result createReservation(ReservationDTO dto) { // 1. 校验座位存在且当前为空闲状态 Seat seat seatMapper.selectById(dto.getSeatId()); if (seat null || !0.equals(seat.getStatus())) { return Result.error(座位不存在或已被占用); } // 2. 校验时间段合法性 if (dto.getStartTime().isAfter(dto.getEndTime())) { return Result.error(开始时间不能晚于结束时间); } // 3. 并发冲突检测 int count reservationMapper.countConflict( dto.getSeatId(), dto.getDate(), dto.getStartTime(), dto.getEndTime()); if (count 0) { return Result.error(该座位在此时间段已被预约); } // 4. 插入预约记录状态为待签到 Reservation r new Reservation(); r.setUserId(dto.getUserId()); r.setSeatId(dto.getSeatId()); r.setReserveDate(dto.getDate()); r.setStartTime(dto.getStartTime()); r.setEndTime(dto.getEndTime()); r.setStatus(0); reservationMapper.insert(r); // 5. 更新座位状态为占用 seatMapper.updateStatus(dto.getSeatId(), 1); return Result.success(r.getId()); }这套逻辑看起来简单但每一步都有它的目的。第一步先查座位状态是为了快速拦截明显不可用的座位减少后面无谓的数据库计算。第三步查冲突是为了防止时间重叠。第四步和第五步必须放在同一个事务里保证数据一致性。4.2 并发场景下如何防止重复预约开门见山说结论如果你的系统只是课设或者小范围内部使用上面这段逻辑加上事务基本够用。但如果你要面对几十上百人同时抢座位的场景单纯的事务是不够的因为两个请求可能同时在第三步查出没有冲突然后同时插入预约记录导致同一个座位同一时间段被预约了两次。我做了两重加固。第一重是数据库层面的唯一约束思路——虽然TIME类型没法简单做唯一索引但可以在应用层维护一个座位日期开始时间结束时间状态的复合约束提示考虑到状态会变化这个方案只能作为辅助。第二重是SQL层面的条件更新更新座位状态时不是简单地UPDATE tb_seat SET status 1 WHERE id ?而是加上条件AND status 0UPDATE tb_seat SET status 1 WHERE id #{seatId} AND status 0如果影响行数为0说明座位已经被别人抢先占用事务回滚这个更新语句就天然成了一个乐观锁。同时在Service层把这个条件更新的返回值作为并发控制的依据。这个思路不复杂但是效果很直接避免了引入分布式锁这种重型方案。4.3 签到、超时释放与状态机流转预约记录的状态不能只有预约成功和预约结束两个我在表设计里列了五个状态它们之间的流转关系是这样的状态码含义触发时机0待签到用户提交预约成功后1已签到用户到馆确认签到2已完成预约时段自然结束3爽约超过开始时间30分钟仍未签到4已取消用户主动退座或管理员取消签到功能我做了简化用户在我的预约列表里点签到后端校验当前时间是否在预约开始时间前后30分钟范围内在范围内就把状态改为1。这比二维码扫码方案简单但核心逻辑一样能展示。超时释放是这套系统最有价值的地方。它靠SpringBoot的定时任务实现每5分钟扫描一次Component public class ReservationTask { Scheduled(cron 0 */5 * * * *) public void autoClearExpired() { // 预约开始时间已过30分钟状态仍然为待签到则标记为爽约 ListReservation expiredList reservationMapper .selectExpired(LocalTime.now().minusMinutes(30)); for (Reservation r : expiredList) { reservationMapper.updateStatus(r.getId(), 3); seatMapper.updateStatus(r.getSeatId(), 0); } } }这里的SQL要查询开始时间小于当前时间减30分钟且状态为0的记录。用LocalTime.now().minusMinutes(30)是在应用层算好时间再传入SQL这样SQL里不需要做复杂的时间函数换算逻辑更直观。爽约之后座位释放下一个人马上就能看到这个座位变成空闲占座不签到的行为自然就被制度约束了。5. 管理员侧功能设计角色校验、数据看板与高峰时段分析5.1 登录拦截与角色权限管理员功能不能直接裸奔给普通用户我用一个后端拦截器实现了简单的角色控制。用户在登录成功后后端签发一个Token前端把Token存在localStorage里每次请求都带上。后端拦截器从Header里解析Token拿到用户信息后判断角色public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); User user tokenService.parseToken(token); if (user null) { response.setStatus(401); return false; } // 管理员接口校验角色 String uri request.getRequestURI(); if (uri.startsWith(/admin/) !1.equals(user.getRole())) { response.setStatus(403); return false; } request.setAttribute(currentUser, user); return true; } }这个方案对课设来说是够用的也不复杂。如果要做更完善的控制可以把Token换成JWT在拦截器里直接验签不过核心思路还是一样的先认证、再鉴权、最后才放行业务请求。5.2 预约记录管理与退座处理管理员端的预约管理列表我做了几种实用的筛选条件按日期、按自习室、按状态。这样管理员在上座率低的时候可以快速找出哪些座位长期空闲上座率高的时候又能定位哪些预约频繁爽约后续可以针对这些用户做限制。退座这块要特别说一个设计用户主动退座时需要把预约状态改成4已取消同时把座位释放回空闲。我在Service层单独写了cancelReservation方法Transactional(rollbackFor Exception.class) public Result cancelReservation(Long reservationId, Long userId) { // 只能取消自己的预约 Reservation r reservationMapper.selectById(reservationId); if (r null || !r.getUserId().equals(userId)) { return Result.error(预约不存在或无权操作); } // 只有待签到/已签到状态可以退座 if (r.getStatus() ! 0 r.getStatus() ! 1) { return Result.error(当前状态不可取消); } reservationMapper.updateStatus(reservationId, 4); seatMapper.updateStatus(r.getSeatId(), 0); return Result.success(); }注意这里有个容易被忽略的规则状态为2已完成或3爽约的预约不能再取消否则历史数据的含义就全乱套了。状态机一旦定下来所有入口都要遵守同一套流转规则。5.3 用分组统计支撑自习室运营决策数据看板不是花架子它是管理员端和用户端功能的价值证明。我做了两张统计图一是最近7天每日预约量的柱状图二是各时段预约热度的饼图。对应的SQL分别是-- 最近7天预约量 SELECT reserve_date, COUNT(*) AS cnt FROM tb_reservation WHERE reserve_date BETWEEN #{startDate} AND #{endDate} AND status IN (1, 2) GROUP BY reserve_date ORDER BY reserve_date; -- 各时段预约热度 SELECT HOUR(start_time) AS hour, COUNT(*) AS cnt FROM tb_reservation WHERE reserve_date #{date} GROUP BY HOUR(start_time) ORDER BY hour;第二张图的用途很有意思。管理员看到某几个时段预约热度特别高就可以在对应时段多开几间自习室看到某些时段几乎没人约就可以安排保洁和消杀工作。这个统计分析能力让系统从登记工具升级成了管理工具答辩的时候讲这个点比单纯说我做了一个CRUD系统有说服力得多。前端我只是用ECharts的折线图和饼图展示数据来源就是上面这两个HTTP接口。Vue侧的代码量不大核心是把接口返回的数组转成ECharts需要的格式。6. 实测踩坑记录时间格式、跨域、并发场景下的三个大坑6.1 LocalDateTime 序列化前端显示一串数字这是前后端分离项目里最低频也最经典的坑。后端实体类的createTime字段如果用LocalDateTimeSpringBoot默认的Jackson序列化会把日期转成时间戳格式前端收到数据后发现日期字段是一串秒数比如1717230000这种直接没法展示。我当时在预约列表页调试了半天最后发现后端返回的JSON里时间字段全是数字。解决办法有两个任选其一。第一种在实体类字段上加注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime createTime;第二种配置全局Jackson序列化规则这样所有时间字段都不用一个个加注解了Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder - builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); } }我最后用了第二种方案因为系统里时间字段不止一个全局配置省心。另外提醒一句如果你用了LocalDate日期类型不带时间也要单独配一个LocalDate的序列化器格式一般是yyyy-MM-dd。6.2 跨域配置前后端联调第一道坎前端开发服务器跑在8080端口后端跑在8081端口两者端口不同浏览器的同源策略就会拦截请求。我第一次联调的时候打开浏览器控制台满屏的CORS错误第一反应是接口写错了后来才意识到是跨域问题。解决方式有两种。第一种是在后端写一个CORS配置类允许前端跨域访问Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }第二种是前端在vue.config.js里配置代理把/api开头的请求转发到后端服务器这样浏览器看到的还是同一个源不触发跨域module.exports { devServer: { proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: } } } } }两种方案我都试过个人建议在开发阶段用前端的代理方案因为不用动后端代码也不会暴露接口给外部部署上线时如果后端和前端在同一台服务器、同一个域下其实没有跨域问题。如果你要用CORS配置记得allowCredentials(true)和allowedOriginPatterns(*)要配套使用只写一个会出现响应头缺失的怪问题。6.3 事务失效与并发重复提交事务失效这个坑表面上看代码没问题实际上执行结果不对。我在写退座逻辑的时候一开始把cancelReservation方法里的两个更新操作分开了想着只要每个SQL都执行成功就行。后来测试发现如果座位表更新成功但预约表更新失败数据就出现了不一致。加上Transactional之后我以为万事大吉结果又踩了另一个坑——同一个类内部调用带事务的方法事务不生效。原因在于Spring的事务是基于AOP代理实现的。调用this.cancelReservation()时调用的是当前对象的原始方法而不是经过代理包装的方法事务注解自然不生效。解决办法是把事务方法拆到另一个类里或者自己注入自己Service public class ReservationService { Autowired private ReservationService self; public Result cancelReservation(reservationId, userId) { // 省略校验 return self.doCancel(reservationId); } Transactional(rollbackFor Exception.class) public Result doCancel(Long reservationId) { reservationMapper.updateStatus(reservationId, 4); seatMapper.updateStatus(r.getSeatId(), 0); return Result.success(); } }这个问题在写代码的时候很难发现因为它不报错只是在特定场景下数据对不上。我的建议是凡是涉及两张表以上更新的操作统一放到Service层独立的事务方法里不要在Controller里写业务更新逻辑这样从源头上减少事务失效的可能。至于并发重复提交我上面已经写了用条件更新UPDATE tb_seat SET status 1 WHERE id ? AND status 0做乐观锁。这个方法虽然简单但需要配套一个细节当SQL影响行数为0时一定要主动抛出异常或者返回错误结果让事务回滚。如果写代码的时候忽略了这里的返回值判断这个乐观锁就形同虚设。做这类系统最大的收获不是把SpringBoot和Vue的语法跑通而是把需求拆成规则时间重叠怎么定义、状态怎么流转、并发冲突怎么兜底、异常数据怎么回滚。这些规则每一条都在数据库表设计和Service层代码里找到对应实现的时候你才算真正把这个项目吃透了。源码拿去改很容易但如果你能把第二章节的冲突SQL、第四章节的乐观锁、第六章节的事务失效这三个点讲明白那这套系统在你手里才算真正发挥了价值。