做这类教室、图书馆预约管理系统最磨人的往往不是某个技术难点而是把“谁在什么时候能用哪个座位/教室”这件事理清楚。我最近完整做了一套基于 Spring Boot JPA Vue MySQL 的教室图书馆预约管理系统从需求拆解、数据库设计到前后端联调、打包部署踩了个遍。这篇文章把我整个实现过程、技术选型理由、核心业务逻辑和那些文档里不会写的坑全部整理出来适合正在做毕业设计、课设或者刚接触全栈项目想找一份完整参考的开发者。系统本身解决的就是一个很常见的问题教室和图书馆座位有限学生想用管理员需要知道谁在用、什么时候用避免冲突和浪费。纯靠人工登记既容易出错又效率低而预约系统的核心就是一套可靠的时间冲突检测和资源占用管理机制。我把教室和图书馆座位统一抽象成“可预约资源”做了一套从用户登录、资源浏览、预约申请、管理员审核到定时释放过期预约的完整闭环。整个项目用 Java 生态最主流的组合来实现技术栈不复杂但对实战能力的要求一点都不低。1. 项目怎么拆核心需求与技术选型1.1 预约系统的真实痛点拆解动手之前我先把业务场景过了一遍。这个系统表面上只是“选个时间、点个预约”实际上藏着三个非常实际的问题。第一个是资源冲突判断。用户选了今天 14:00-16:00 的教室但有人已经约了 15:00-17:00系统必须拒绝这次预约还得告诉用户为什么被拒绝不能只丢一句“预约失败”。冲突判断不是简单的等值比较而是时间段重叠判断这个逻辑虽然不复杂但很容易写成错误的边界条件。第二个是资源维度多样化。教室是整间预约图书馆是座位预约有的座位还分靠窗、靠墙、是否带插座如果做成两套表、两套接口代码冗余会很严重所以我选择把它们抽象为同一类“资源”用类型字段区分再通过扩展属性字段保存特殊信息。第三个是过期资源释放。学生预约后不去座位就一直被占着需要有一个机制在预约开始一定时间后未签到自动释放该资源。这个问题不做系统实际用起来会被骂死。这三个问题决定了系统不能只是简单的 CRUD。设计上我要优先解决冲突判断、状态流转和自动任务而这三个点也恰好是整套代码里最核心的部分。很多人做这类系统一上来就写 Controller、写增删改查结果业务逻辑没想清楚后面全是补丁代码。1.2 技术栈为什么这么选Spring Boot JPA Vue MySQL技术选型我其实没有太多纠结。Spring Boot 是目前 Java 后端绝对的主流它解决了 Spring 配置繁琐的问题内嵌 Tomcat一个 main 方法就能启动适合这种单体应用。MySQL 不用多说关系型数据模型和预约系统的结构化数据天然匹配事务支持完善。JPA 和 MyBatis 之间的选择需要说一下。标题里既然定了 JPA我就按 JPA 来做但实际项目中我确实也会权衡。JPA 的核心优势是实体关系的对象化管理一张表对应一个实体类写业务代码时基本不用碰 SQL尤其适合这种以实体状态流转为主的业务。缺点是复杂查询不好控制容易写出 N1 查询但预约系统这种场景查询多条件明确用 Specification 和 EntityGraph 完全能覆盖。MyBatis 则更适合团队里重度依赖复杂 SQL、喜欢手写 SQL 的开发者控制力更强但样板代码也多。我的建议是如果你是做课设、毕设、中小型管理系统JPA 的开发效率明显更高如果以后要去大厂写订单、库存这类大量复杂 SQL 的系统MyBatis 更贴近实际。前端选 Vue 同样是从实际体验出发。Vue 对中小型系统非常友好组件化拆起来顺手生态成熟Element Plus 这类组件库能快速搭出像样的管理界面。我用 Vue 3 Vite 做前端配合 vue-router 做路由、axios 做请求、Pinia 做状态管理没有引入太重的工程化配置够用且容易懂。1.3 整体架构与模块划分整个系统是前后端分离的单体应用架构一套后端服务 一个前端工程 MySQL 数据库。后端按经典分层结构组织Controller 负责接口接收和参数校验Service 负责业务逻辑和事务控制Repository 负责数据访问实体类负责映射表结构。前端按页面功能拆成几个子模块互相独立通过接口对接。从功能模块上看我把它分成四个部分用户模块登录注册、角色区分学生/教师/管理员、用户信息维护。资源模块教室和图书馆座位的列表展示、按类型和位置筛选、资源详情。预约模块发起预约、取消预约、管理员审核、预约记录查询、冲突检测。系统辅助模块定时释放过期预约、公告/使用规则维护、统计报表。这套模块划分有一个明显的好处预约模块是核心其他模块都为它服务。开发的时候我可以先完成资源模型和数据表再集中精力写预约流程最后补上用户和辅助功能不会被琐碎需求打乱节奏。2. 数据库设计与实体建模2.1 表结构设计与关键字段解析数据库设计我是围绕“资源”和“预约记录”这两张主表展开的。先看最基础的两类表用户表sys_user主要字段包括id、username、passwordBCrypt 加密后存储、real_name、role枚举值写死成 STUDENT、TEACHER、ADMIN、email、phone、create_time。密码字段长度我设成 60BCrypt 生成的哈希长度就是 60设小了会存不进去。资源表tb_resource是所有可预约物品的统一存储表。字段包括id、name、typeROOM 表示教室SEAT 表示图书馆座位BOOK 表示图书后续扩展类型不用改表、location、capacity、description、statusENABLED 表示可预约DISABLED 表示维护中、ext_infoJSON 字符串存座位是否靠窗、是否带插座这类附加信息。这种设计一开始有人觉得“为什么不直接分两张表”但实际跑下来统一模型的好处是预约逻辑完全不用感知资源类型以后加一个“研讨间”只需要加一个枚举值后端不用改一行代码。预约记录表tb_reservation是整个系统的核心表。字段包括id、user_id、resource_id、reserve_date预约日期、start_time、end_time、statusPENDING 待审核、APPROVED 已通过、REJECTED 已拒绝、CANCELLED 已取消、FINISHED 已完成、reason申请备注、audit_comment审核意见、create_time、update_time、version乐观锁版本号。这里有个设计细节我特别强调时间段要拆成日期加时间两个字段不要用一个 datetime 直接存。原因很简单用户选择预约时通常是“我在某一天某个时间点到某个时间点”拆开之后冲突判断直观而且在reserve_date上做索引和按日期查询都非常高效。如果合并精确查询某一天有没有预约时反而要写 between 判断。2.2 JPA 实体映射的坑与优化表设计好之后映射成 JPA 实体时我踩过几个坑说一个最典型的boolean 字段的映射问题。比如资源表里我想加一个is_available字段表示是否可预约Java 实体里写private boolean available;默认情况下 JPA 会把字段名映射为available而不是is_available。如果数据库列名是is_available启动时会报字段不匹配的错误。解决办法是在属性上显式加Column(name is_available)千万不要依赖自动命名。另一个坑是 LocalDateTime 的映射。JPA 2.2 之后才原生支持java.time包下的类型如果你的 Spring Boot 版本较老或者 JPA 实现版本较老LocalDateTime 会被序列化成数组或者映射失败。我建议直接使用 Spring Boot 2.7 以上版本 Hibernate 5.x然后实体里统一用LocalDateTime和LocalDate避免用老的java.util.Date。用老类型虽然也能跑但 MyBatis 时代遗留的习惯在 JPA 里很容易造成时间精度丢失和格式化问题。我在实体之间的关系映射上做了克制处理。预约记录和用户、资源之间确实是多对一关系但我没有在预约实体里写ManyToOne然后设置fetch FetchType.LAZY因为懒加载在 Service 层一不留神就会触发 LazyInitializationException。我的做法是预约记录实体里只保存userId和resourceId两个普通字段需要用户姓名和资源名称时用 JPQL 或者 Specification join 查询再映射到 VO 对象里返回前端。这样虽然多写了几个 VO 类但边界非常清晰也没有懒加载烦恼。2.3 时段规则与状态机设计预约系统里最容易混乱的是状态机。我把预约状态定为六种待审核、已通过、已拒绝、已取消、已完成、未签到自动释放。状态流转规则我用一张流转表固定下来当前状态触发操作目标状态PENDING管理员通过APPROVEDPENDING管理员拒绝REJECTEDPENDING / APPROVED用户取消CANCELLEDAPPROVED预约时段结束且已签到FINISHEDAPPROVED超过签到时间未签到RELEASED时段规则上我规定同一资源同一时间段只能有一个有效预约PENDING 或 APPROVED 状态。这里有一个业务取舍是否允许学生直接预约成功还是需要管理员审核。我做成可配置的通过系统配置项reservation.needAudit控制。如果是毕业设计建议开启审核流程这样系统里能体现出一个完整的管理员后台功能如果只是内部自用可以关闭审核预约直接通过。3. 后端核心逻辑冲突判断、并发控制与动态查询3.1 冲突检测一个条件判断把边界想清楚预约冲突判断是整个系统业务正确性的第一道防线。我的实现方式是查询目标资源、目标日期下所有状态为 PENDING 或 APPROVED 的预约记录然后逐一判断时间段是否重叠。时间段重叠判断的规则是旧预约时间段存在且满足 新预约开始时间 旧预约结束时间 且 新预约结束时间 旧预约开始时间这个条件实际上判断的是两个区间是否有交集等于号的地方要特别注意。我一开始写成了newStart oldEnd newEnd oldStart结果发现边界测试出问题预约 14:00-16:00 的学生和 16:00-18:00 的学生恰好相差一个点却被判定为冲突。原因就是 16:00 这个结束时间等于 16:00 这个开始时间但两个区间并没有重叠。后来我改成严格小于和严格大于问题解决。这个细节看起来小但如果不写单元测试很容易在验收的时候才暴露。在 Repository 层我用 Specification 实现了这个查询条件SpecificationReservation spec (root, query, cb) - { ListPredicate predicates new ArrayList(); predicates.add(cb.equal(root.get(resourceId), resourceId)); predicates.add(cb.equal(root.get(reserveDate), reserveDate)); predicates.add(cb.in(root.get(status)).value(Arrays.asList(PENDING, APPROVED))); predicates.add(cb.lessThan(root.get(startTime), endTime)); predicates.add(cb.greaterThan(root.get(endTime), startTime)); return cb.and(predicates.toArray(new Predicate[0])); };这里还涉及 query 参数的边界值处理。前端传过来的时间我统一解析成 LocalTime同时做一个参数校验结束时间必须大于开始时间这个校验放在 Controller 层用Validated做掉后端不能依赖前端校验。实际使用的过程中我发现有些人会点 00:00-00:00 这种诡异时段那其实是前端默认时间没初始化好后端校验一挡就没了。3.2 并发控制乐观锁与唯一约束的配合冲突判断不是每次都能拦住问题因为存在并发场景系统只有一百个座位开抢前三十秒几百人同时点预约两个用户在同一毫秒都查到“没有冲突”然后同时插入预约记录就会产生脏数据。针对这一点我用了三层防护按成本从低到高。第一层是业务查询就是上面说的冲突判断挡住绝大多数情况。第二层是数据库约束建表时我在tb_reservation上创建了一个唯一索引组合字段为(resource_id, reserve_date, start_time, end_time)。同一资源同一日期的完全一致时间段数据库层面直接拒绝重复插入。第三层是乐观锁在预约记录实体上加了Version注解对应version字段更新操作时 JPA 会自动带上版本号校验失败会抛出 OptimisticLockException我在 Service 层捕获后返回一个“操作过于频繁请重试”的提示。这三层设计的思路是业务查询拦截可预期的问题唯一索引兜底硬冲突乐观锁解决更新覆盖问题。实际测试下来用 JMeter 模拟 50 个并发预约同一教室大部分请求会被第一层拦截小部分被唯一约束拦截最后只有极少数因为乐观锁失败整体没有出现一条脏数据。这里我特别建议不要过度设计不要一上来就用 Redis 分布式锁单体应用 数据库约束完全够用引入 Redis 反而平白增加部署成本和学习曲线。3.3 JPA 动态查询与分页Specification Pageable管理后台需要按资源名称、预约日期、状态、用户等条件组合查询预约记录而且数量大了之后必须分页。JPA 中我用 Specification 做动态条件拼接配合 Pageable 实现分页。一个典型的管理员查询接口长这样public PageReservationVO queryReservations(Long resourceId, String status, LocalDate date, String keyword, int page, int size) { SpecificationReservation spec (root, query, cb) - { ListPredicate predicates new ArrayList(); if (resourceId ! null) { predicates.add(cb.equal(root.get(resourceId), resourceId)); } if (StringUtils.hasText(status)) { predicates.add(cb.equal(root.get(status), status)); } if (date ! null) { predicates.add(cb.equal(root.get(reserveDate), date)); } // keyword 用于匹配关联用户名称需要子查询略 return cb.and(predicates.toArray(new Predicate[0])); }; return reservationRepository.findAll(spec, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, createTime))); }分页接口返回的PageReservation里包含总数、页数等元数据我直接封装成 VO 返回给前端。这里有一个坑前端用 Element Plus 表格组件时通常直接拿rows数组渲染total单独显示所以接口返回结构设计成{ total: 100, records: [...] }比返回原生 Page 更友好。很多新手不封装一下直接把 JPA 的 Page 对象序列化返回前端看着一堆用不上的字段体验很差。3.4 定时任务与到期释放用 Scheduled 做后台管家前面说的过期资源释放我用 Spring 自带的Scheduled定时任务实现。系统每五分钟扫描一次预约记录把所有状态为 APPROVED、预约开始时间已经超过当前时间一定分钟数、同时没有签到记录的数据自动修改为 RELEASED 状态并把对应资源重新设置为可预约。定时任务方法很简单Scheduled(cron 0 */5 * * * ?) Transactional public void releaseExpiredReservations() { ListReservation expiredList reservationRepository.findExpiredApproved(LocalDateTime.now().minusMinutes(30)); expiredList.forEach(item - { item.setStatus(ReservationStatus.RELEASED.name()); item.setAuditComment(超时未签到系统自动释放); }); }这个逻辑里有两个注意点。第一是扫描时间不能太频繁五分钟一次足够太频繁对数据库压力大第二是“预约开始时间前 30 分钟可以签到超过预约开始时间 30 分钟未签到则释放”这个规则要放到系统配置里而不是写死在代码里。我用了一个sys_config表存这类业务参数管理后台可以改这样不用改代码就能调整规则。4. Vue 侧实现与前后端联调4.1 前端项目结构与路由设计Vue 部分我用的是 Vue 3 组合式 API 写法。项目结构上我最常用的组织方式src/ api/ // 每个模块一个接口文件 assets/ components/ // 公共组件如表单弹窗、状态标签 router/ // 路由文件 store/ // Pinia 状态 views/ // 页面登录、首页、资源列表、预约详情、管理后台 utils/ // axios 实例、工具函数路由设计上我把需要登录才能访问的页面用路由守卫统一拦截。实现方式是在router.beforeEach里判断本地有没有 token没有就跳转/login。管理员的页面单独放在一个带layout的父路由下子路由对应教室管理、预约审核、用户管理等这样侧边栏菜单可以按嵌套路由自动生成。这里要提一个实际开发中的体验路由懒加载一定要开。比如/views/admin/ReservationAudit.vue这种页面在列表里用() import(...)的方式引入首屏加载会快很多。我见过不少课设项目把几十个页面全部同步 import导致首次进入页面白屏好几秒用户体验很差。4.2 axios 封装与 Token 处理前后端分离系统里token 管理是最容易出问题的地方。我的 axios 实例封装里做了三件事const service axios.create({ baseURL: /api, timeout: 15000 }); // 请求拦截器加 token service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }); // 响应拦截器统一处理错误 service.interceptors.response.use( response { const res response.data; if (res.code ! 200 res.code ! 0) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res; }, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } );一个很容易踩的坑是后端接口有自定义响应体{ code, message, data }axios 默认拿到的response.data是被 Spring 包装后的对象如果你用res.data.data才能拿到真实数据很容易在联调时把自己绕晕。我的建议是后端统一用ResultT泛型包装类前端在响应拦截器里就把code判断掉业务代码里直接返回res.data这一层层层剥开来保持清晰。token 过期处理我也踩过一次。后端我用 JWT 做登录态有效期设成 24 小时。过期之后用户仍然停留在页面点任意操作才跳登录体验很生硬。更好的做法是在响应拦截器里判断 401弹一个提示然后清除本地状态跳转登录页。如果项目要求更高还可以做 token 刷新但那种复杂度对这类系统属于过度设计。4.3 预约表单与日历视图的交互实现前端最核心的交互页面是预约页。我做成一个左右分栏布局左边显示资源列表右边是当前选中资源的详情和预约表单。预约表单的核心是一个“日期选择器 时间区间选择器”。我用 Element Plus 的el-date-picker的datetimerange类型吗一开始我确实用了datetimerange以为省事但后端按日期 时间段两个字段存储前端如果用 datetimerange传给后端就必须再拆增加一层转换。后来我改成两个独立的组件日期用el-date-picker的date类型时间段用两个el-time-select分别选开始和结束。这样做的好处是用户可以非常直观地看到“今天是哪天从几点到几点”而且后端字段直接对应不用拆时间。在时间选择上我还加了一个小优化开始时间选项和结束时间选项联动。用户选了 14:00 作为开始时间结束时间的下拉列表自动过滤掉 14:00 之前的选项。这一步虽然只是前端交互的小细节但实际使用中能少很多无效提交用户也不会一脸懵地反复被后端提示“结束时间必须大于开始时间”。预约按钮为了防止重复提交我在前端做了时间戳 状态标记的双重防抖处理点击后按钮立即进入 loading 状态等接口返回再恢复。这个经验是从实际事故里来的有个同学做类似系统前端没做防抖用户手抖点了两下预约按钮结果同一时间段预约了两条数据后端唯一索引和冲突检测都拦不住因为第一层和第二层都通过后事务还没提交第二次请求已经查到旧状态了。这个并发窗口虽然极小但现实中确实可能发生前端防死锁处理并不能完全替代后端幂等设计。4.4 联调中的跨域与接口契约问题前后端分离项目联调跨域问题是每个新手都会遇到的。前端跑在http://localhost:5173后端跑在http://localhost:8080端口不一样浏览器默认会发起 CORS 预检请求常见报错是CORS policy: No Access-Control-Allow-Origin header。解决方式有两种。第一种是在后端写一个全局 CORS 配置类允许指定来源和请求方式。我的做法是后端配置了一个白名单开发环境允许http://localhost:5173生产环境配置成实际线上域名。第二种是前端通过vite.config.js配置 dev server 代理server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这种方式前端请求写/api/loginVite 自动转发到后端http://localhost:8080/api/login浏览器层面没有跨域问题。我个人倾向于第二种放在开发环境生产环境由反向代理或网关处理后端不放得太宽减少安全隐患。这个选择没有标准答案但实际项目中我见过因为后端 CORS 配置了*导致的生产安全小隐患所以我的原则是能不放宽就不放宽。接口契约问题也很值得说。我定的接口风格是RESTful 基础路径 普通 JSON 请求体统一返回ResultT。前端接口文件单独抽出来每个模块一个 JS 文件比如reservation.js里集中写预约相关的所有接口函数。这样前后端接口对接时后端改了路由只需要同步改一个文件的函数路径而不是在几十个组件里找几十个this.$http.get(...)。5. 本地启动与部署全流程5.1 Maven 配置与多环境切换项目后端用 Maven 管理依赖pom.xml里核心依赖就那几个spring-boot-starter-web、spring-boot-starter-data-jpa、mysql-connector-j、spring-boot-starter-validation、JWT 相关库、Lombok。这里有个版本匹配问题要提醒一下Spring Boot 3.x 中javax包名迁移到了jakartaJWT 库和很多老工具类如果不兼容会直接编译报错。如果你跟着我的技术栈做稳妥一点用 Spring Boot 2.7.xJDK 用 8 或者 11 都可以。如果你非得上 Spring Boot 3.x那所有javax.persistence包一律改成jakarta.persistenceLombok 版本要升到 1.18.30 以上。多环境配置文件我按惯例拆成application.yml、application-dev.yml、application-prod.yml三份。开发环境数据库连接、端口、日志级别放在 dev 里生产环境单独配置。启动时用--spring.profiles.activedev参数切换也可以打包后在服务器上用环境变量覆盖比如java -jar reservation-system.jar --spring.profiles.activeprod --server.port8080这种多环境配置的收益在前三天可能看不出来等你上线后有生产环境特有问题要排查而开发环境又没法复现的时候才会感激当初把配置拆开的自己。5.2 数据库初始化与 MySQL 8.0 连接配置要点数据库初始化我写了两个脚本schema.sql建表、data.sql插入默认管理账号和测试数据。默认管理员的用户名密码我写成admin / admin123密码在脚本里直接存 BCrypt 加密后的字符串不能存明文。自己写脚本初始化数据比 JPA 的ddl-auto: update更可靠ddl-auto: update在开发时很方便但团队协作时改字段类型会越来越乱生产环境建议设为none。连接配置里最经典的坑是 MySQL 8.0 的时区问题。MySQL 8.0 之后的驱动连接串必须加时区参数否则报错The server time zone value й is unrecognized。我实际用的连接配置是spring: datasource: url: jdbc:mysql://localhost:3306/reservation_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrievaltrue这个参数也是 MySQL 8.0 特有的不加的话连接时可能报 public key retrieval 相关异常。这两个参数属于不加上就联不上的硬性条件配置时直接写上去就好。5.3 打包部署与常见环境问题前端打包用npm run build产物是一个 dist 目录。后端用mvn clean package -DskipTests打出可执行 jar。实际部署时我的方案是把后端的 jar 和前端打包后的静态文件放在一起用 Nginx 做静态文件托管和 API 反向代理。Nginx 配置的关键点server { listen 80; server_name yourdomain.com; root /opt/reservation/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }这个配置里 vue-router 的 history 模式必须配合try_files ... /index.html否则用户刷新页面时 Nginx 会去找一个真实文件路径找不到就 404。很多新手刚部署时刷新页面就白屏原因就在这。如果不想处理这个问题也可以在 vue-router 里用哈希模式URL 会带一个#号但不太美观。6. 踩坑实录与避坑指南6.1 高频报错及处理方案整个开发过程我整理了十几个报错和对应的解决方案列几个最有代表性的报错场景错误信息特征解决方案启动时 JPA 实体映射失败Caused by: org.hibernate.MappingException检查字段命名和Column注解尤其注意 boolean 字段查询时 N1 问题日志中出现几十条相同结构的查询SQL使用EntityGraph或改为指定字段的 JPQL避免懒加载被触发接口报 500 却能正常查部分数据SQL 执行异常字段名与数据库不一致开启spring.jpa.show-sqltrue查看实际执行的SQL前端联调跨域报错Access to XMLHttpRequest ... CORS policyvite 代理或后端 CORS 配置优先用 vite 代理预约并发产生重复数据数据库唯一约束违反或者状态数据异常检查唯一索引确认乐观锁字段version是否正确更新刷新页面白屏部署后刷新任何非首页路径都 404Nginx 配置try_files $uri $uri/ /index.html;JVM 时区不对预约记录时间差 8 小时连接串加serverTimezoneAsia/Shanghai并确认服务器时区这里面我特别想说 N1 问题。一次查询预约记录列表列表里每个预约都要查一次用户名和资源名称JPA 默认懒加载如果不做处理50 条预约记录会打出 100 多条件SQL数据库压力一下子拉满。解决方式我喜欢用EntityGraph(attributePaths {user, resource})在 Repository 方法上直接声明预加载这样 JPA 一次 JOIN 查询就能把关联实体全部查出简单有效。6.2 性能与细节优化索引设计、缓存与合理取舍系统压测之后我发现两个性能瓶颈。第一个是预约列表查询因为条件多、数据量大一开始每次查询都要全表扫描。解决方式是在tb_reservation上建了组合索引(resource_id, reserve_date)这个索引覆盖了最核心的两个查询条件实际查询时间从几百毫秒降到几十毫秒。第二个是资源列表在首页频繁展示变化频率低我在资源列表接口上加了一层简单的 Spring Cache 缓存缓存时间设成 60 秒。这个系统规模完全没必要上 Redis用 Spring 内置的Cacheable就够配置起来只需要一个注解。缓存这个点很多课设项目都不会做但写上之后评审老师的观感会好很多面试时也可以讲一讲缓存穿透和缓存一致性的基础概念。另一个想提醒的细节是接口统一错误码。我的ResultT类里维护了业务异常码比如 1001 表示参数校验失败1002 表示预约冲突1003 表示资源不存在。这样做的好处是前端可以根据错误码进行不同的交互处理比如 1002 时就弹一个“该时段已被预约”的专门提示而不是统一弹“请求失败”。我在 Service 层写了一个自定义异常类public class BizException extends RuntimeException { private final int code; public BizException(int code, String message) { super(message); this.code code; } }然后在全局异常处理器里统一捕获把code和message返回给前端。这个设计虽然代码量不大但能保证异常提示不会杂乱无章。再分享一个实际心得这个系统的核心业务逻辑——冲突检测——我建议写单元测试。整个模块最复杂最容易出错的就是时间段边界我对应写了五六个用例完全重叠、部分重叠、边界相接、包含关系、完全不相交。写好之后每次改动都能快速回归不用每次手点页面验证省下大量时间。JPA 层的话用DataJpaTest配合 H2 内存库跑虽然 H2 和 MySQL 有细微差异但对于验证 specification 逻辑完全够用。我第一次没写测试的时候改一次定时任务逻辑就要手动跑一遍预约流程后来补上测试轻松非常多。这个项目做完之后我最大的感受是教室图书馆预约系统表面看起来是标准的增删改查但真正拉开差距的地方全在业务细节——时间边界的处理、并发情况的兜底、过期资源的自动回收、前后端接口的稳定性。只要你把这几块想清楚再套上 Spring Boot 和 Vue 这套成熟组合不仅开发过程会很顺写成简历项目也好讲、经得起追问。如果你打算基于这套结构自己动手做一个建议先照着文中的表和状态机把数据库建好再把冲突检测和状态流转跑通最后慢慢补界面和管理功能按这个顺序走下来会比从页面开始做顺手很多。
阅读完成 · 觉得有帮助?