SpringBoot+Vue图书馆座位预约系统:从并发控制到数据库设计实战 简介面向Java全栈学习者与图书馆信息化管理场景的完整项目包基于SpringBootVue实现座位预约、用户管理、预约记录等核心功能适合课程设计、毕业设计及前后端分离开发实践。压缩包共729个文件约30.77MB涵盖Java后端源码、Vue前端组件、HTML页面、CSS样式、数据库脚本及项目配置文件其中svg/png/jpg等图片资源用于界面展示js/vue/css支撑前端交互与样式java/xml/yml构成后端服务与配置另附讲解文件与bat运行脚本便于理解整体设计并快速启动项目。已有149人学习项目融合人工智能理念提供智能座位推荐思路并配套数据库建表语句与运行说明。通过完整源码和目录结构可快速搭建环境、掌握从需求分析到接口联调的全流程无论用于课程作业还是企业实践都能直接复用是一份兼具实用性与教学价值的参考资料。1. 图书馆座位预约系统它到底在解决谁的什么问题期末周的图书馆永远是早上七点排队、八点抢座、十点开始有人为“这个座位我预约过”争执不休。拿座位的觉得先到先得预约的觉得系统白装管理员两头安抚却拿不出一个准确的座位状态图。这个基于 SpringBoot Vue 的图书馆座位预约系统就是把“座位占用”这件事变成一条可查询、可流转、可追溯的数据记录用户提前选座、到馆签到、离馆释放管理员在后台看实时占用率和预约流水。适合三类人被占座问题折磨的图书馆管理员、正在做毕业设计或课程设计的学生、以及想从单体 CRUD 往“带状态机和并发控制”走一步的后端新人。它不算大系统但登录、预约、定时任务、事务控制这些模块都齐全恰好是你能完整跑通并改造的最小样本。2. SpringBoot Vue 选型为什么这套组合能扛住百人同时预约2.1 后端用 SpringBoot 的真实理由事务、并发与生态座位预约系统的核心难点不在页面而在“多人同时预约同一个座位”时数据不能出错。SpringBoot 之所以是这套项目的主流后端选择首先是因为它把 Spring 繁琐的 XML 配置简化成了注解和自动装配你不需要像早期 SSM 项目那样维护一堆配置文件一个application.yml就管住了数据源、端口、连接池和日志。真正让 SpringBoot 适合这类系统的是它对事务和并发的支持。预约座位涉及两步操作检查座位状态、插入预约记录。两步之间如果有并发请求穿插就可能出现“超卖”。SpringBoot 里用Transactional圈住事务边界再配合数据库行锁或乐观锁就能把并发的窗口压缩到可控范围。加上 Spring Data JPA 或 MyBatis-Plus 这类持久层框架分页查询、条件构造都省了很多事。生态成熟是另一个原因。数据库连接池默认用 HikariCP性能和应用稳定性都不用操心定时清理过期预约可以用ScheduledJWT 登录有现成的拦截器写法。这些在 GitHub 和开源社区里都有大量踩坑记录遇到问题搜一下就有答案不用自己从零摸索。2.2 前端用 Vue 的真实理由状态管理、路由与组件复用这个系统前端页面不少登录页、座位选择页、我的预约页、管理后台的座位管理和流水页。如果全部用原生 JavaScript 写 DOM 操作页面一多就会陷入“数据变了但界面没更新”的泥潭。Vue 的核心价值在于响应式数据绑定——你把座位列表数据赋值给data页面上的座位格子会自动更新不用手动操作 DOM。路由也很关键。用户端和管理端是两个不同的入口Vue Router 可以用路由守卫做权限隔离未登录用户访问管理页直接踢回登录页。组件复用则体现在座位格子、预约卡片这类重复出现的 UI 上写一次组件多个页面引用。对新手来说Vue 的上手曲线比 React 平缓。模板语法接近 HTML数据流是单向的配合 Vue Devtools 插件调试时可以直观看到每个组件的状态和数据来源。这个项目的前端部分不管是你自己重写还是基于现成源码改用 Vue 都能最快把页面搭起来。2.3 项目结构规划从 module 拆分到目录约定开工前先定目录结构。后端按 Controller-Service-Mapper 三层拆前端按视图和 API 请求分层。我习惯的划分方式如下library-seat-system/ ├── backend/ # SpringBoot 后端工程 │ ├── src/main/java/com/library/ │ │ ├── controller/ # 接口层只做参数接收和结果返回 │ │ ├── service/ # 业务逻辑层事务边界在这里 │ │ ├── mapper/ # 数据库访问层MyBatis-Plus 的 Mapper │ │ ├── entity/ # 表对应的实体类 │ │ ├── config/ # 拦截器、跨域配置、定时任务配置 │ │ └── utils/ # JWT 工具类、日期处理工具类 │ └── src/main/resources/ │ ├── application.yml # 端口、数据库、连接池、日志配置 │ └── mapper/ # XML 文件复杂 SQL 放这里 ├── frontend/ # Vue 前端工程 │ ├── src/ │ │ ├── views/ # 页面级组件Login.vue、SeatMap.vue │ │ ├── components/ # 复用组件SeatItem.vue、Pagination.vue │ │ ├── router/ # 路由配置与守卫 │ │ ├── api/ # axios 请求封装 │ │ └── store/ # Vuex 或 Pinia管理用户状态 │ └── package.json # 前端依赖与启动脚本 └── database/ └── init.sql # 建库建表脚本和初始数据后端三层划分的关键在于 Controller 不写业务只做参数校验和结果包装Service 层承担事务和业务规则Mapper 层只负责 SQL。前端api/目录单独抽出来的好处是后端接口路径调整时只改一处不用在页面里到处找axios.get。3. 数据模型与数据库设计预约状态机与防重复预约的唯一索引3.1 座位、用户、预约三张核心表先看这个系统最核心的三张表用户表、座位表、预约表。用户表管登录和角色座位表管图书馆物理座位的信息预约表记录每一次预约行为及其状态变化。CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 学号/工号, password varchar(100) NOT NULL COMMENT BCrypt 加密后的密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, role tinyint(4) NOT NULL DEFAULT 1 COMMENT 0-管理员 1-普通用户, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE seat ( id bigint(20) NOT NULL AUTO_INCREMENT, seat_no varchar(20) NOT NULL COMMENT 座位编号如 A-101, floor varchar(20) DEFAULT NULL COMMENT 所在楼层或区域, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0-空闲 1-占用 2-禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_seat_no (seat_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT座位表; CREATE TABLE reservation ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, seat_id bigint(20) NOT NULL COMMENT 座位ID, reserve_date date NOT NULL COMMENT 预约日期, period tinyint(4) NOT NULL COMMENT 时段如 1-上午 2-下午 3-晚上, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0-待签到 1-已签到 2-已释放 3-爽约, create_time datetime DEFAULT CURRENT_TIMESTAMP, checkin_time datetime DEFAULT NULL COMMENT 签到时间, release_time datetime DEFAULT NULL COMMENT 释放时间, PRIMARY KEY (id), UNIQUE KEY uk_seat_period (seat_id, reserve_date, period), KEY idx_user_date (user_id, reserve_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约记录表;这几张表的设计有几个关键点。sys_user的密码字段要能容纳 BCrypt 加密后的 60 位字符串别用 32 位定长seat.status和reservation.status用tinyint而不是字符串省空间且方便查询时用数字判断。预约表的唯一索引uk_seat_period是整个系统防重复预约的第一道防线这一条索引直接决定了数据库层面能不能拦住“同一个座位同一天同一个时段被预约两次”。3.2 状态机与唯一索引防止同一座位被重复预约预约记录不是一条静止的数据它会经历“待签到 → 已签到 → 已释放”或“待签到 → 爽约”的流转。设计状态机时把状态定义为数值常量在 Service 层判断当前状态是否允许迁移。状态值含义可流转到0待签到已签到、已释放、爽约1已签到已释放2已释放终点3爽约终点唯一索引uk_seat_period是数据库层面的兜底。哪怕代码里忘记做存在性检查只要插入重复的(seat_id, reserve_date, period)数据库会直接抛DuplicateKeyException。这也是我后来在这个系统里踩过最深的一个坑早期只靠 Service 层的if判断查重高并发下两条请求同时通过检查两边都插入成功最后就出现了同一个座位被两个人预约。索引是“后悔药”但异常要捕获后转成友好的提示“该座位此时间段已被预约”。3.3 初始化 SQL 与测试数据怎么写才不坑拿到数据库脚本后第一件事不是直接执行而是先检查字符集和引擎。字符集用utf8mb4引擎用InnoDB否则没法用事务和行锁。初始化数据时可以用一条 SQL 快速生成座位的测试数据INSERT INTO seat (seat_no, floor, status) SELECT CONCAT(A-, LPAD(n, 3, 0)), 3层, 0 FROM ( SELECT 1 AS n UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 -- 这里用 UNION 连续造出 1 到 100 的数字序列实际可用存储过程或数字辅助表 ) AS nums; INSERT INTO sys_user (username, password, real_name, role) VALUES (admin, $2a$10$..., 管理员, 0);造测试数据时注意两条。一是初始密码不要写明文用 BCrypt 加密后的字符串否则启动登录时密码校验永远失败二是预约表的测试数据不要造未来日期否则定时任务一执行就把“未来预约”当成超时未签到处理了。4. 核心功能落地登录、预约、释放、签到的接口与前端交互4.1 JWT 登录与用户上下文拦截器里如何拿到当前用户登录功能用 JWT 实现。用户提交用户名密码服务端校验通过后生成一个带用户 ID 和角色的 token 返回前端前端每次请求都在 header 里带上后端拦截器解析 token 后把用户信息塞进请求上下文。public class JwtUtil { // 密钥至少 32 字节生产环境放到配置文件中不要硬编码在代码里 private static final String SECRET your-secret-key-change-it-in-production; private static final long EXPIRE_TIME 24 * 60 * 60 * 1000; // 24 小时过期 public static String generateToken(Long userId, Integer role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } }拦截器的作用是统一鉴权和注入上下文。在preHandle里取出Authorizationheader解析 token把 userId 放到ThreadLocal里后续 Service 层就能直接拿到当前登录人。需要注意的是ThreadLocal用完要移除否则线程池复用时会串号。4.2 预约与释放事务边界和行锁怎么配合预约接口是这个系统的主战场。逻辑并不复杂查座位是否空余查该时段是否已有预约插入预约记录把座位状态改为占用。但并发下每步都有竞态正确做法是事务内用悲观锁锁住座位行Transactional public ReservationResult reserve(Long seatId, String date, Integer period) { // 1. 锁住座位行其他预约请求会在这里等待 Seat seat seatMapper.selectByIdForUpdate(seatId); if (seat null || seat.getStatus() 2) { return ReservationResult.fail(座位不存在或已禁用); } // 2. 查该时段是否已有预约记录靠唯一索引兜底 Reservation exist reservationMapper.selectExist(seatId, date, period); if (exist ! null) { return ReservationResult.fail(该座位此时间段已被预约); } // 3. 插入预约记录若唯一索引冲突则捕获异常 Reservation reservation new Reservation(); reservation.setUserId(CurrentUser.get().getUserId()); reservation.setSeatId(seatId); reservation.setReserveDate(date); reservation.setPeriod(period); reservation.setStatus(0); try { reservationMapper.insert(reservation); } catch (DuplicateKeyException e) { return ReservationResult.fail(手慢了座位被抢走了); } // 4. 更新座位状态 seatMapper.updateStatus(seatId, 1); return ReservationResult.ok(); }selectByIdForUpdate是SELECT ... FOR UPDATE的 Mapper 写法事务提交前该行被锁住其他事务的相同查询会阻塞等待。事务边界在Transactional上一定要放在 Service 层而不是 Controller 层。释放座位的逻辑同理校验预约记录属于当前用户把状态改为已释放更新座位为空闲。这里要避免出现“释放时发现预约早被释放过了”的情况更新 SQL 里带上status 0条件即可。4.3 签到与爽约判定定时任务和状态流转签到是用户到馆后在手机端点击“签到”后端把预约状态从待签到改为已签到同时记录签到时间。爽约处理就不能靠用户主动触发了需要定时任务扫描那些“过了签到截止时间还没签到”的预约记录自动置为爽约并释放座位。Component public class ReservationTask { // 每 5 分钟执行一次扫描超过截止时间的待签到预约 Scheduled(cron 0 */5 * * * ?) public void releaseExpiredReservations() { // 假设预约当天 10:00 前需签到超过时间自动释放 ListReservation expiredList reservationMapper.selectExpired(10:00:00); for (Reservation r : expiredList) { reservationMapper.updateStatus(r.getId(), 3); // 置为爽约 seatMapper.updateStatus(r.getSeatId(), 0); // 释放座位 } } }Scheduled注解的 cron 表达式要确认清楚字段顺序秒、分、时、日、月、周。0 */5 * * * ?表示每 5 分钟触发一次。这里有个隐蔽的问题定时任务默认单线程执行如果某个任务执行时间过长比如数据库慢查询后续任务会被阻塞堆积。要改配置给任务单独配线程池。4.4 Vue 前端页面如何对接这些接口路由参数与请求封装前端对接接口的逻辑比较直接。用户登录后拿到 token存到 localStorage后续 axios 请求通过拦截器统一附带 token。管理端和用户端的页面通过 Vue Router 区分路由守卫拦截未登录访问。// api/request.js import axios from axios const request axios.create({ baseURL: /api, // 配合 Vue devServer 代理避免跨域 timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { router.push(/login) } return Promise.reject(error) } ) // 预约座位参数通过路由 query 传递更直观 export function reserveSeat(seatId, date, period) { return request.post(/reservation/reserve, { seatId, date, period }) }前端页面通过 Vue Router 的route.query接收参数比如从座位选择页跳转到预约确认页时带上座位号和时间段。这套接口对接方案的关键是统一拦截器好处是 token 失效时全局跳转登录不用每个页面单独处理。5. 避坑指南联调部署时最常见的 5 个翻车点5.1 前端端口跨域与代理配置现象前端跑在 8080 端口后端跑在 8081 端口浏览器发请求直接被 CORS 拦截接口一个都调不通。原因浏览器同源策略只允许同协议、同域名、同端口的请求。你直接在前端代码里写axios.get(http://localhost:8081/...)必然跨域。解决开发阶段用 Vue CLI 的 devServer 代理把请求转发到后端浏览器看到的始终是同源的/api地址// vue.config.js module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: } } } } }注意代理配置里pathRewrite的作用是去掉/api前缀如果你的后端 Controller 的RequestMapping本身没带/api前缀就必须重写。踩坑经验只配了target忘了配changeOrigin: true部分环境下请求还是会出问题这个参数的值必须理解成“让后端感觉请求来自它自己”。5.2 数据库时区与连接池导致的诡异报错现象预约记录的时间和本地时间差 8 小时明明下午预约的数据库里存的是凌晨。原因MySQL 连接串里没指定时区默认用了数据库服务器的系统时区通常是 UTC而本地是东八区。解决JDBC 连接串显式指定时区同时检查连接池参数spring: datasource: url: jdbc:mysql://localhost:3306/library_seat?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000连接池参数里最容易出问题的是maximum-pool-size设得太小高峰期 50 个人同时预约连接不够用请求排队等待直到超时。至少要给到数据库实例能承受的上限一般 20 起步这也是很多人忽略的性能瓶颈。5.3 加了锁还是超卖防重复预约的最后一层保险现象用 JMeter 开 100 个线程同时预约同一个座位结果数据库里出现了两条成功预约记录加锁好像没生效。原因锁放错了位置。如果Transactional加在 Controller 层或者 Mapper 里的FOR UPDATE没写对锁实际没生效。另一种情况是事务隔离级别下两个事务各自读取了快照都没有锁住真实行。解决把事务注解放到 Service 实现类上确认 Mapper 里是真正的SELECT ... FOR UPDATE而不是普通查询。再加上唯一索引作为物理兜底ALTER TABLE reservation ADD UNIQUE KEY uk_seat_period (seat_id, reserve_date, period);这个索引的存在意味着哪怕代码有 bug数据库也会拒绝重复插入。捕获DuplicateKeyException转成友好提示是防超卖的最后防线。5.4 定时任务不执行注解扫描与线程池配置现象Scheduled方法写好了服务重启后却一次都没执行。原因主启动类忘了加EnableScheduling或者定时任务类没有被 Spring 扫描到包路径不在主类同级或子包下。解决主类加上注解同时给定时任务配独立的线程池SpringBootApplication EnableScheduling public class LibrarySeatApplication { public static void main(String[] args) { SpringApplication.run(LibrarySeatApplication.class, args); } }EnableScheduling开启后Spring 容器会扫描所有带Scheduled方法的 Bean。如果任务有多个最好在配置文件里声明TaskScheduler默认单线程容易排队堵塞前一个任务卡住后面的全不执行。5.5 上传文件路径失效二维码图片打开就 404现象本机开发时预约成功后生成的签到二维码能正常打开部署到服务器后图片全挂了。原因开发时把文件存到了项目相对路径重启或换环境后路径变了或者存了绝对路径/home/upload但 Nginx 和 Spring Boot 的静态资源配置没对上。解决统一用配置项管理上传路径并映射静态资源访问app: upload-dir: /data/library-seat/upload # 独立于项目目录的持久路径再用一个配置类将这个目录映射为可访问的资源路径。部署时注意该目录的写权限和备份策略。如果你有对象存储条件直接接入 MinIO 这类服务上传文件走接口访问走 URL彻底摆脱本地路径问题。6. 验收与进阶让系统从“能跑”到“能扛住”6.1 用 JMeter 做一次并发冒烟测试系统上线前至少用 JMeter 对预约接口做一次冒烟测试。线程组设 50 个并发、循环 2 次统一预约同一个座位观察接口错误率和响应时间。如果错误率不为 0先查数据库有没有重复预约记录再查接口返回的异常信息。响应时间超过 500 毫秒就需要关注很大概率是数据库连接池不够或 SQL 缺索引。6.2 冷热数据分离与读写拆分等系统跑过一学期预约表数据会膨胀。一个很实用的进阶方案是把“明日预约”和“历史预约”拆开存储。明日预约是用户主要访问的数据放在主表历史流水归档到另一张表查询时只查归档表。更进一步的方案是用 Redis 做当天的座位状态缓存预约时先操作缓存后台异步落库读请求全部走缓存数据库压力降低一大截。6.3 座位利用率报表与后续优化系统沉淀的数据至少有三类价值哪些座位最抢手、哪些时段使用率低、哪些用户经常爽约。用一条 SQL 就能算出座位利用率SELECT seat_id, COUNT(*) AS total, SUM(CASE WHEN status 1 THEN 1 ELSE 0 END) AS used, SUM(CASE WHEN status 3 THEN 1 ELSE 0 END) AS no_show FROM reservation WHERE reserve_date BETWEEN 2025-01-01 AND 2025-01-31 GROUP BY seat_id ORDER BY used DESC;报表做出来后可以反向优化预约规则对高频座位设置预约上限对低峰时段做引导优惠对爽约用户限制预约频次。这套系统的价值不在于页面多好看而在于它能把之前模糊的“占座”问题变成清晰的数据让管理员有据可依。我做过一个类似系统上线第一周就出了重复预约事故。当时第一反应是并发量太大查了半天才发现是唯一索引没建纯靠代码里的判断去兜。从那以后我养成了两个习惯凡是涉及“禁止重复”的业务规则建索引兜底凡是涉及状态的变更先在状态机里画清楚。这个项目你可以先跑通再加一张报表、加一个缓存一步步把边界摸透。希望帮到你。本文还有配套的精品资源点击获取