Spring Boot+Vue教室预约系统:从需求设计到部署上线的完整复盘
教学楼里的教室其实比很多人想象的更抢手。平时上课用的是排课系统锁定的固定教室但到了晚上、周末、考试周想临时找一间能投影、能坐得下50个人的教室全靠碰运气。很多学校到现在还靠一个纸质登记本 一个微信群在运作管理员每天手动改表格学生问教室只能挨个去楼道看门牌。springboot教学楼日常教室预约管理系统vue这套项目本质上就是把哪间教室空着、谁能用、什么时候用、用完没这件事做成一个所有人都能实时看到答案的系统。这篇文章不是课程式教程更像是我做完一套类似校园教室预约系统之后的完整复盘包括需求边界、表结构、预约状态机、前端交互、冲突校验、并发预约这几个硬骨头以及从本机打包部署到服务器时踩过的那些坑。适合正在做毕设、课程设计或者想从零搭一套Spring Boot Vue全栈项目的人参考。我会尽量把每一步为什么这么设计讲清楚而不是只贴代码。1. 需求梳理教学楼教室预约到底要管什么开始动手写代码之前我先花了两天去观察教学楼里的真实预约场景。你会发现教室预约不是一个简单的填表单 插入数据库的事情它有很强的角色差异和时间规则。1.1 三种角色看到的三种世界第一个角色是普通学生。学生视角里只有一件事今晚有没有一间空教室能上自习或开小组会。他要看的是今天晚上6点到9点哪几间教室没人预约然后选一间容量差不多、最好有投影的。他不需要关心这间教室明天后天被谁占了也不关心审批流程。第二个角色是教师或辅导员。他们的需求往往和课程相关比如补课、答疑、开班会。这些人需要提前几天预约而且要一个稳定的时间段不希望被临时活动冲掉。所以他们提交的预约通常需要管理员确认。第三个角色是教务或后勤管理员。管理员每天面对的是大量重复咨询X教室晚上能用吗周五下午有没有大教室。他要能快速看到某个教室在任意时间段的使用情况能手动添加临时占用比如考试封楼也能撤销无效预约、处理已经过期的数据。这三类需求放一起系统的功能边界就出来了用户管理、教室管理、预约提交、预约审核、教室占用日历、个人信息和记录查询。我在设计时没有做复杂的审批流因为日常教室使用不需要走三级审批学生预约但是否需要管理员审核不需要否则管理员会被累死。但教师教室预约需要审核这就在同一个预约实体上拉开了状态差异。1.2 从纸质登记表到在线预约要跨过的坎传统纸质登记最大的问题不是慢是信息不透明。管理员手里有一本总账但任何人想知道某个教室的占用情况都得去问他。在线系统第一步要做的就是把总账变成所有角色都能同时看到的共享日历。这个转变带来的第一个技术问题是时间模型。纸质表上写的是周二7-8节或者晚上7点到9点看起来好像清楚但一旦涉及查询现在这间教室能不能用就需要把课程节数转成绝对时间。我在系统里直接用date time做预约时间段而不是用第几节课这种节数概念。节数是给排课系统用的教室预约系统的时间粒度用分钟更灵活可以精确到18:30到20:00这种非整节的时间段。第二个问题是冲突检测不能靠人眼。纸质时代两个人都申请同一时间段的概率不高一旦发生管理员翻本子也能发现。但线上系统如果不好好设计冲突校验就会出现明明已经有人约了页面上还显示可预约的尴尬。这个我在后面专门写。1.3 技术栈选型Spring Boot Vue为什么是稳妥答案现在的全栈开发方案太多了光前端就有React、Vue、Next.js后端有Spring Boot、Django、Go。为什么我选了Spring Boot和Vue选Spring Boot核心原因是生态成熟。教室预约系统需要身份认证、数据库访问、接口权限控制、定时任务这些在Spring Boot里都有非常稳定的解决方案。JWT处理登录、MyBatis-Plus或Spring Data JPA处理数据库、Spring Security做权限拦截全是现成的。对一个单体应用来说这一套搭配学习成本低、排错资料多。更重要的是Spring Boot自带的事务管理和声明式事务让并发控制写在代码里很优雅这在预约场景里是硬需求。选Vue是因为这类系统是典型的管理端 用户端模式页面量大但交互不复杂。Vue的组件化在页面越来越多的时候特别省心比如一个教室卡片组件可以在列表页、日历页、搜索页复用。加上Vite构建、Element Plus组件库大部分管理后台界面都不用自己从零画CSS。电脑端使用场景为主Vue也完全够用。我不建议这类型项目一上来就搞微服务、Redis、消息队列。预约系统在校园场景里最多就是几千人同时访问一台服务器、一个MySQL、一个Spring Boot应用完全扛得住。把复杂度留给真正有挑战的冲突检测并发问题比盲目分布式靠谱多了。2. 后端设计三张核心表与一套状态流转逻辑后端是整个系统的地基。我一般会先画表结构再写接口。教室预约系统的表不需要太多但每一张表都要能回答一个问题数据如何被可靠地记录和追溯。2.1 表结构设计中的关键字段用户表user存基本的学号/工号、姓名、手机号、角色标识。角色用整数存会比字符串更省空间1代表学生2代表教师3代表管理员。密码不要明文存用BCrypt加密这个是Spring Security自带的能力。教室表classroom要设计得比想象中更细。除了教学楼名称、房间号、容量外我建议加上equipment这类JSON字段或者单独的has_projector、has_computer布尔字段。原因很实际学生选教室时一定会过滤有没有投影教师预约时会看是不是多媒体教室。把这些信息拆出来查询时直接在SQL里过滤不用把JSON取出来再解析。教室还有一个状态字段代表正常开放 / 临时封闭 / 考试占用这跟单个预约无关是教室本身的可用状态。最核心的是预约表reservation。一个容易忽略的字段是purpose预约用途这个字段看似只是一个字符串实际上决定了管理员的审核判断。比如写着考试封楼的预约就应该被管理员立刻批准写着社团活动的可能要再确认一下。预约时间段我用reserve_date存日期、start_time和end_time存时间而不是干脆合并成一个datetime。因为查询3月5日这天所有预约时按日期字段分组效率更高索引也更简单。每个预约必须带有状态字段status我用 0待审核、1已通过、2已生效、3已取消、4已过期。你可能会想已通过和已生效有什么区别区别在时间维度教师提交预约后管理员通过了但实际还没到那一天这是已通过系统定时任务发现预约日期到了自动将其转成已生效等预约彻底结束时间过去后再转成已过期。这个状态的用途有两个一是前端实时显示进行中/未开始/已结束二是方便统计教室使用率。2.2 预约状态机保证每条数据都走合理路径状态不能随便跳。我在代码里用一个状态机枚举来约束流转路径待审核只能变成已通过或已取消已通过可以变成已生效或已取消已生效只能变成已过期所有非终态都可以被管理员强制置为已取消。这样设计带来的好处是业务逻辑不会被一堆if-else淹没后人维护代码时也看得明白。我用定时任务完成状态轮转而不是每次查询时现算。Spring Boot自带Scheduled注解每天凌晨跑一次把当天已通过且日期等于今天的预约置为已生效把 end_time 小于当前时间的置为已过期。写定时任务时一定要考虑时区问题这点后面部署部分会细说。2.3 JWT与权限控制的落地写法登录接口我返回一个JWT令牌令牌里只放userId和role不放其他敏感信息。前端拿到令牌后存在localStorage里每次请求通过请求头带回来。后端用一个基于HandlerInterceptor的拦截器统一鉴权比每个Controller里手写判断优雅得多。一个需要注意的细节是学生、教师、管理员能看的接口范围是不同的。学生只能查空闲教室和提交自己的预约教师可以提交需要审核的预约管理员可以调用审核接口、删除教室、封禁异常用户。我在Controller上通过自定义注解RequireRole(1)做方法级控制这样代码读起来一目了然。权限这块经常有人问Spring Security那么复杂为什么不用我的意见是教室预约系统通常不需要复杂的Spring Security配置手动拦截器加JWT反而更可控。如果项目要作为课程设计演示手写拦截器也更好讲清楚原理。3. Vue前端把找空闲教室这个操作做到最简单后端提供能力前端决定体验。教室预约系统最核心的交互场景只有一个用户想知道哪个时间段有哪间教室空着。前端要做的不是塞给用户一堆高级组件而是把教室状态用最直观的方式展示出来。3.1 页面规划与组件复用页面不多但颗粒度要拆好。我划分了这几个页面登录页、首页工作台、教室查看页、预约提交页、我的预约页、管理后台页。看似简单真正动手时发现组件复用很关键比如教室卡片标签、时间段状态格、状态标签这类小东西要在多个页面反复出现。我把教室列表做成一个可复用的ClassroomCard组件卡片上展示教室名、楼栋、容量、设备标签。组件内部不写业务逻辑只接收props并emit事件方便列表页、搜索页各自使用。所有权限相关的显示比如审核按钮是否出现由父组件根据当前用户角色传入。管理后台的教室日历是我认为整个前端最值得做好的部分。用一个周视图横轴是星期几纵轴是时间段格子被预约了就高亮显示。这个视觉效果远比表格直观可以通过CSS Grid实现不需要引第三方日历库。3.2 时间段选择器的交互细节预约提交页的时间选择是另一个容易被做恶心的地方。用户要选几月几号 开始时间 结束时间Vue自带的日期选择器能解决日期但时间段需要自己处理。我的方案是用户先点教室卡片再弹出一个面板面板里把一天的可用时间段按30分钟分片渲染成方格。点击一个方格选中开始点击另一个方格选中结束中间的所有方格高亮。这个交互在手机上很好用在电脑上也不违和。实现上我用一个selectedTimeRange响应式对象记录{ start, end }切换教室时自动清空。这里有个人机交互的细节如果用户只选了一个开始时间就点了提交要给出明确提示请选择结束时间而不是让接口去报参数错。3.3 接口层与加载态处理前端和后端通过RESTful接口对接我用axios封装了一个request工具类统一注入JWT令牌、统一拦截401响应。写页面时不直接依赖Vuex而是用Service层把业务请求集中管理类似ClassroomService.getAvailable(date)。这样做的理由是教室预约系统的全局共享状态不多角色信息放进Pinia就够了没必要把所有接口返回都存全局狗尾续貂还容易出bug。异步加载一定不能省略加载态。教室列表从后端拉取时可能有一两百毫秒延迟我在页面上用v-loading指令加上加载动画避免用户手快重复点击。另外请求失败时要有统一错误提示不能把后端返回的异常堆栈直接扔给用户。4. 硬骨头冲突检测与并发预约这是教室预约系统真正体现技术含量的部分。网上很多项目Demo都栽在这一块表面上能预约实际上根本没有做冲突检测或者只有一个简单的 时间段不能重叠 查询忽略了并发场景。4.1 区间重叠判断的正确姿势判断两段时间是否冲突最直观的想法是开始时间在对方时间段内或者结束时间在对方时间段内。但如果两个时间段恰好首尾相连比如一个预约是18:00-19:00另一个是19:00-20:00这两段不重叠开始时间在对方时间段内判断不出来。如果用BETWEEN去查很容易把恰好相邻的记录误判成冲突。数据库查询应该用区间重叠的数学条件现有预约区间的开始时间必须早于新预约的结束时间且现有预约区间的结束时间必须晚于新预约的开始时间。写成SQL就是SELECT COUNT(*) FROM reservation WHERE classroom_id ? AND reserve_date ? AND status IN (1, 2) AND start_time ? AND end_time ?注意最后一个参数是新预约的end_time倒数第二个是新预约的start_time。这个条件用生活类比就是两段线段A线段开始于a1、结束于a2B线段开始于b1、结束于b2只要满足a1 b2 AND a2 b1就说明存在重叠区域。这个条件要记牢我见过不少刚入行的同事用反了导致查不到冲突。4.2 同一瞬间两个人抢同一间教室怎么办SQL冲突校验写好了单机单请求下没问题。但校园场景里可能出现两个学生在同一秒分别提交了同一个教室的预约。如果两个请求同时通过查询发现当前没有冲突然后同时插入数据库冲突就发生了。这就是经典的先查后插并发缺陷。解决思路有三层。第一层在预约表上加上一个数据库层面的约束比如对(classroom_id, reserve_date, start_time, end_time, status)建一个唯一索引。但这样有个问题假若两个预约时间段部分重叠但不完全相同比如18:00-19:00和18:30-19:30唯一索引无法约束因为这四个字段组合值根本不同。所以唯一索引只能兜底不能作为主方案。第二层使用数据库行锁。我在插入预约前先对对应教室的记录行执行一次SELECT ... FOR UPDATE。这个操作会锁住教室那一行后到的请求必须等前一个事务提交后才能执行冲突检测。因为同一时间操作同一间教室的请求最终都落到同一行教室记录上串行化之后冲突检测就准确了。第三层也是我实际采用的方案把锁教室行 冲突检测 插入预约放在同一个事务里用隔离级别兜底。代码大致逻辑是Transactional public Long createReservation(ReservationCreateDTO dto) { // 1. 锁住教室行 ClassroomEntity classroom classroomMapper.selectByIdForUpdate(dto.getClassroomId()); // 2. 在事务内做冲突检测 int overlapCount reservationMapper.countOverlap(dto.getClassroomId(), dto.getReserveDate(), dto.getStartTime(), dto.getEndTime()); if (overlapCount 0) { throw new ReservationConflictException(该教室在同一时间段已被预约); } // 3. 插入预约 ReservationEntity reservation new ReservationEntity(); // 省略赋值... reservationMapper.insert(reservation); return reservation.getId(); }使用FOR UPDATE锁的是教室记录行而不是预约表本身因此并发量不高时性能完全可以接受。如果真要支持极高并发可以引入Redis分布式锁或乐观锁版本号但对校园教室预约来说数据库行锁是简单高效的方案。我建议不要在依赖Redis之前就引入中间件那是过度设计。4.3 边界测试把并发场景用代码敲出来写完并发控制代码后我写过一个测试脚本用JUnit模拟50个线程同时预约同一间教室同一个时间段运行结果应该只有1个成功、49个失败。如果测试时发现成功数量大于1说明锁没生效。有一个常见坑selectByIdForUpdate必须写在事务里且必须使用同一个数据库连接才能真正锁住。如果方法标注了TransactionalSpring会帮助我们把整个方法包进一个事务但如果手滑把冲突检测写在另一个方法里而那个方法没有事务锁就会被提前释放。排查这类问题最简单的方式是开启MyBatis的SQL日志观察FOR UPDATE是否出现在事务内。5. 打包部署从开发机到服务器的最后一公里开发环境跑得欢部署到服务器上各种问题就会冒头。这个项目的前后端是分开构建的需要分别处理。5.1 构建产物与目录规划后端的Spring Boot项目使用Maven打包命令是mvn clean package -DskipTests生成的可执行jar包在target目录。我把这个jar包放到服务器的/opt/classroom/app.jar用一个systemd服务管理启动。前端Vue项目运行npm run build构建产物在dist目录里面是纯静态文件。我将整个dist目录上传到nginx的/usr/share/nginx/html/classroom下然后配置nginx将访问流量中的API请求转发到后端端口。这一步很多同学会忽略前端构建后不能再用dev server热更新那套方式必须用nginx或者任意静态服务器托管。5.2 Nginx代理配置与跨域问题如果你的前端和后端域名、端口不同就一定会遇到跨域问题。解决跨域没有必要在后端写一堆CORS配置更优雅的办法是让nginx做代理让浏览器以为请求是同源的。我的配置是这样server { listen 80; server_name example.com; location / { root /usr/share/nginx/html/classroom; index index.html; 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_pass末尾的斜杠。如果写http://127.0.0.1:8080请求/api/auth/login会被原样转发给后端如果写成http://127.0.0.1:8080/nginx会把/api前缀去掉转发为/auth/login。到底用哪一种取决于后端的Controller是否带/api前缀。我建议后端统一在Controller上使用/api前缀nginx保留原样转发逻辑最简单。5.3 部署后最常见的三个生产问题第一个是时区问题。MySQL连接串里忘记指定时区的话Spring Boot默认用的时区可能和服务器不一样导致定时任务在错误的时间把预约状态翻过去了。我统一在JDBC连接串上加serverTimezoneAsia/Shanghai并在Spring Boot配置文件里设置spring.jackson.time-zoneAsia/Shanghai。第二个是内存问题。Spring Boot应用默认堆内存可能较大服务器如果只有2G内存建议在启动参数里固定堆大小例如-Xms512m -Xmx512m防止和其他进程抢内存。用systemd服务启动时我写成ExecStart/usr/bin/java -Xms512m -Xmx512m -jar /opt/classroom/app.jar第三个是静态资源更新问题。前端代码更新后重新npm run build但nginx默认会对静态文件做缓存用户浏览器拿到旧文件页面表现就是“怎么改了跟没改一样”。我在nginx静态资源配置中加了一段location ~* \.(js|css)$ { expires 1h; }短缓存足够日常更新使用。6. 复盘与几个值得继续扩展的方向做完这套Spring Boot Vue的教室预约系统后我最深的感受是这类管理系统最难的从来不是增删改查而是对业务规则的抽象能力。状态机设计好不好直接决定后续维护的复杂度冲突检测和并发控制好不好直接决定用户敢不敢信任这个系统。一个人要在开发时多想一步比如同一时刻两个人一起提交、管理员把已通过的状态又改成取消这些边界情况才是系统质量的分水岭。最后分享一个我自己踩过的小坑。教室表里记录的时间字段我一开始用varchar存储比如 18:30-20:00。这个设计在展示层很方便但到了冲突检测SQL里根本没法用因为字符串没法在数据库里被高效比较。后来所有时间字段全部改成time类型才算理顺。这个教训也提醒我设计表结构时一定要从查询和维护的角度去思考而不是从展示方便的角度。如果你也想在这个项目上继续扩展我建议关注这几个方向一是做教室使用率的统计报表用每周的预约数据生成柱状图帮助教务调整教室资源二是加入Excel导入导出管理员可以一键把课表导入系统自动生成固定不可预约时间段三是小程序端的适配把Vue页面改成移动端友好布局或者单独做一个二维码签到功能。任何一个方向做扎实了都会让这个项目的完成度和简历厚度明显提高。先把核心的预约链路打磨干净再考虑这些锦上添花的能力这条路是我验证过比较稳的。