简介:这套基于SpringBoot与Vue技术栈的博物馆预约管理系统,定位为Java方向的毕业设计实战项目,主要面向计算机相关专业的学生,以及需要快速搭建预约类系统的初学者。后端部分利用SpringBoot构建稳定服务,前端采用Vue完成组件化页面展示与交互,系统涵盖用户注册登录、展品分类浏览、在线预约、个人预约单管理、后台审核处理等功能模块,能够帮助学习者理解预约流程中的权限控制、时段冲突校验、订单状态变更等核心业务逻辑。资源包整体约54.65MB,以zip格式提供,代码中包含了前后端对接、接口定义、数据表设计等具体实现细节,通过项目源码可以快速掌握前后端分离开发中的环境配置、依赖管理、接口联调等关键步骤。目前已有30人浏览学习,适合正在备题或中期开发的毕业生参考,也可在此基础上继续扩展统计报表、消息通知、移动端适配等增值功能。
1. 博物馆预约系统最难的不是写接口:SpringBoot+Vue这个组合在解决什么问题
SpringBoot+Vue博物馆(预约)管理系统这个名字看着普通,真正动手做起来有三个藏得深的坎:同一时段只放出30个名额,10个人同时点预约,怎么保证不超卖;用户在前端选的参观日期是浏览器本地时间,后端拿到手却按UTC解析,莫名其妙差8小时;前端表单校验全过,后端一碰到并发就抛500。预约系统本质上是带库存约束的订单系统,比普通CRUD复杂在「状态流转」和「并发写」这两件事上。这套技术栈适合两类人:正在为毕设选题发愁的学生,以及要给中小型文化场馆做线上预约的一线开发。把库存扣减、防重、过期释放这三件事捋顺,项目就能从「能跑」变成「能上线」。
2. 选型与工程骨架:为什么前后端分离,SpringBoot和Vue各管哪一段
2.1 SpringBoot自动装配的真相:为什么启动类一行注解就能干活
很多新手第一次用IDEA创建SpringBoot项目时,看着@SpringBootApplication一个注解就启动了Web服务、连上了数据库,觉得这是黑匣子。拆开看并不玄学:SpringBoot把常用的配置整理成「自动配置类」,启动时按classpath下有没有对应依赖来决定要不要生效。你引入了spring-boot-starter-web,它就把DispatcherServlet、内嵌Tomcat、Jackson序列化配好;引入了mybatis-plus-boot-starter,它就把SqlSessionFactory和Mapper扫描配好。这就是SpringBoot自动装配的核心逻辑——条件装配,配合spring.factories或AutoConfiguration.imports文件注册配置类。
理解这一点对预约系统很有用。比如后面排查「数据库连不上」「Mapper没扫描到」,八成不是代码写错,而是自动装配没有满足条件:数据源URL没配、Mapper接口和启动类不在同一个包路径下。预约管理这种典型前后端分离项目,后端职责是「管状态、管库存、管权限」,SpringBoot负责把这些能力快速组装起来,而不是从零写Bean装配。
2.2 Vue在预约页里管什么:路由、组件状态与接口封装
Vue的核心价值是响应式和组件化。放到博物馆预约这个场景,前端要管的事情有三块:一是路由,预约页、我的预约页、后台管理页之间的跳转;二是组件状态,用户选了哪个展览、哪天的票、余票还剩多少、提交按钮是否在loading;三是接口封装,把/api/reserve这类请求统一走axios实例,带上token,统一处理错误码。
常见的做法是Vue3 + Vite + Vue Router + Pinia + Element Plus。如果手里是以前的毕设项目,多半是Vue2 + Element UI,写法差异不大,核心思想一致:预约列表用路由参数传展览ID,日期选择器用组件库的el-date-picker,提交状态用ref或reactive维护。调试时装上Vue Devtools插件,能直接看到data里余票数量的变化,排查「为什么按钮点了没反应」比靠console.log快得多。
2.3 环境准备与工程创建:IDEA建SpringBoot项目与Vue脚手架
动手之前把环境理清楚,能少踩一半坑。后端要求JDK 8或11(SpringBoot 2.7.x推荐JDK 8/11),Maven 3.6+,IDEA自带Spring Initializr,新建项目时选Spring Boot 2.7.18,依赖勾选Web、MyBatis-Plus、MySQL Driver、Lombok。前端要求Node 16以上,用npm create vite@latest museum-web建Vue3工程,装vue-router@4、pinia、element-plus、axios。
前端装依赖这条命令值得说清楚。
npm install vue-router@4 pinia element-plus axiosvue-router负责预约页和后台页的路由,pinia用来存用户登录态和当前选择的展览信息,element-plus提供日期选择、表格、表单校验组件,axios做HTTP请求。安装完成后在main.js里注册,顺序错了会出现「组件找不到」的报错。后端建完工程先加一个自定义banner,启动时能看到标志,方便确认跑的是不是自己打出来的包——后面排查线上问题时这个小细节能省不少事。
3. 把预约流程从数据库写到浏览器:库存表设计、防重接口与页面联调
3.1 三张核心表:展览、票档、预约单的字段取舍
预约系统不要一上来就设计十几张表。核心就三张:展览表、票档表、预约单表。展览表存展览基本信息;票档表按「日期 + 展览」拆行,每行是一个可预约的票档,包含当日总库存;预约单表记录谁在什么时间约了哪个票档。字段宁少勿多,状态字段用Integer不用字符串,日期用DATE不用DATETIME——日期精确到天就够了,时间戳留给创建和更新时间。
CREATE TABLE t_exhibition ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '展览名称', open_time VARCHAR(20) COMMENT '开放时间,如 09:00', close_time VARCHAR(20) COMMENT '闭馆时间,如 17:00', status TINYINT DEFAULT 1 COMMENT '1上架 0下架' ); CREATE TABLE t_ticket ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exhibition_id BIGINT NOT NULL COMMENT '展览ID', visit_date DATE NOT NULL COMMENT '参观日期', price DECIMAL(10,2) DEFAULT 0.00, stock INT NOT NULL DEFAULT 0 COMMENT '当日可预约余量', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_exhibition_date (exhibition_id, visit_date) ); CREATE TABLE t_reserve ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_id BIGINT NOT NULL, user_id BIGINT NOT NULL, visit_date DATE NOT NULL, status TINYINT DEFAULT 0 COMMENT '0已预约 1已检票 2已取消', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_user_date (user_id, visit_date, ticket_id) );这三个表的设计有几个必须说明的点。t_ticket的唯一约束uk_exhibition_date保证同一展览同一天只有一行库存记录,这是并发扣减的前提。t_reserve的唯一约束防止同一个用户在同一天重复预约同一个展览——这是防重的最后一道物理防线,代码写了防重逻辑,数据库也要兜底。version字段留给乐观锁用,后面并发避坑章节会细说。
3.2 防重复预约的最后一层防线:唯一索引与原子扣减
预约的核心操作是两件事:先扣库存、再插预约单。这两步必须放在同一个数据库事务里,否则会出现「库存扣了但预约单没插上」的脏数据。扣库存这条 SQL 要用原子更新,不能先查再改,否则两个请求同时读到 stock=1,各自减一,库存就变成负数了。
<update id="decrStock"> UPDATE t_ticket SET stock = stock - 1, version = version + 1 WHERE id = #{ticketId} AND visit_date = #{visitDate} AND stock > 0 </update>这条SQL的精髓在WHERE条件里的stock > 0。MySQL执行update时会对命中的行加锁,stock减1和判断stock>0是同一个原子操作,并发请求只会有一个成功,另一个影响行数为0。version = version + 1是乐观锁的配套动作,为以后做「预约取消时校验版本号」留后路。影响行数为0时,Service层直接抛业务异常,前端提示「该时段刚被抢完」。
3.3 Service与Controller:把预约流程串成闭门
Service层把查询票档、原子扣减、插入预约单串起来,注意事务边界和异常处理的顺序。
@Transactional(rollbackFor = Exception.class) public ReserveResult reserve(ReserveReq req) { // 1.先查票档,只为给用户友好提示,不承担并发控制 Ticket ticket = ticketMapper.selectOne(new LambdaQueryWrapper<Ticket>() .eq(Ticket::getId, req.getTicketId()) .eq(Ticket::getVisitDate, req.getVisitDate())); if (ticket == null || ticket.getStock() <= 0) { return ReserveResult.fail("该日期已无余票"); } // 2.原子扣减,影响行数为0说明并发下被抢完了 int rows = ticketMapper.decrStock(ticket.getId(), req.getVisitDate()); if (rows == 0) { throw new BizException("手慢了,该时段刚被抢完"); } // 3.插入预约单,唯一索引兜底防重复 Reserve reserve = new Reserve(); reserve.setTicketId(ticket.getId()); reserve.setUserId(req.getUserId()); reserve.setVisitDate(req.getVisitDate()); reserve.setStatus(0); reserve.setCreateTime(LocalDateTime.now()); reserve.setUpdateTime(LocalDateTime.now()); reserveMapper.insert(reserve); return ReserveResult.ok("预约成功,请凭身份证入场"); }这段代码里值得注意的有三处。@Transactional必须带上rollbackFor = Exception.class,否则抛出的是运行时异常还好,一旦后续改成检查异常,事务不会回滚,库存就白白扣了。先查票档再扣减看起来多了一次查询,但目的是把「无余票」这种高频友好提示在真正写库之前拦下来,减少无意义的行锁竞争。reserveMapper.insert如果抛DuplicateKeyException,事务回滚会把扣掉的库存一并还原——这就是唯一索引兜底的价值。
Controller 层保持薄,只做参数校验和响应封装。
@RestController @RequestMapping("/api/reserve") public class ReserveController { @PostMapping public Result<ReserveResult> reserve(@RequestBody @Valid ReserveReq req) { return Result.ok(reserveService.reserve(req)); } }ReserveReq里的ticketId、visitDate、userId都加上@NotNull校验,避免空值打到Service层变成难看的SQL异常。Result是统一响应体,前端拦截器按code字段判断成功失败,而不是用HTTP状态码——这样可以避免404、500这类状态码在前端被浏览器拦截,拿不到响应体里的中文错误信息。
3.4 Vue预约表单联调:日期组件、校验与loading状态
前端预约页是用户直接打交道的界面,三个细节决定体验:日期选择器要禁用过去日期、提交按钮要有loading防重复点击、错误提示要用组件库的Message而不是alert。Vue3 + Element Plus 的写法如下。
<template> <el-form :model="form" :rules="rules" ref="formRef" label-width="90px"> <el-form-item prop="exhibitionId" label="展览"> <el-select v-model="form.exhibitionId" @change="loadTickets" placeholder="请选择展览"> <el-option v-for="item in exhibitions" :key="item.id" :label="item.name" :value="item.id" /> </el-select> </el-form-item> <el-form-item prop="visitDate" label="参观日期"> <el-date-picker v-model="form.visitDate" type="date" value-format="YYYY-MM-DD" :disabled-date="disabledDate" placeholder="选择日期" /> </el-form-item> <el-form-item> <el-button type="primary" :loading="submitting" @click="submit">立即预约</el-button> </el-form-item> </el-form> </template> <script setup> import { reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import api from '@/api' const form = reactive({ exhibitionId: null, visitDate: '' }) const submitting = ref(false) function disabledDate(date) { return date.getTime() < Date.now() - 86400000 } async function submit() { submitting.value = true try { const { data } = await api.post('/api/reserve', form) ElMessage.success(data.msg || '预约成功') } catch (e) { ElMessage.error(e.response?.data?.msg || '预约失败,请重试') } finally { submitting.value = false } } </script>value-format="YYYY-MM-DD"这个配置必须盯住。不写的话,el-date-picker绑定的是Date对象,axios序列化时会变成ISO字符串,带着T和时区后缀,后端LocalDate解析大概率报错。:disabled-date用当天零点做比较,避免用户选到过去的日期。submitting在请求期间置true,Element Plus的按钮会自动显示loading并禁止第二次点击——这是防止重复提交在前端的第一道闸门。
3.5 MyBatis-Plus 分页插件的用法:拉取预约列表的正确姿势
后台管理页要列预约记录,必须分页,否则数据量一上来页面直接卡死。很多人在SpringBoot项目里配了selectPage但发现返回的还是全量数据,原因只有一个:分页拦截器没注册。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个配置类放在启动类可扫描的包下。PaginationInnerInterceptor是MyBatis-Plus的物理分页拦截器,DbType.MYSQL告诉它生成LIMIT语法。注册之后,Service里这样查:
Page<Reserve> page = new Page<>(current, size); LambdaQueryWrapper<Reserve> qw = new LambdaQueryWrapper<Reserve>() .eq(StringUtils.hasText(status), Reserve::getStatus, status) .orderByDesc(Reserve::getCreateTime); IPage<Reserve> result = reserveMapper.selectPage(page, qw);Page构造函数的两个参数是页码和每页条数,前端传来的current和size要转成int,防止SQL注入。LambdaQueryWrapper的条件可以用StringUtils.hasText(status)做动态条件——status为空就不拼这个条件,查询全部状态。selectPage返回的IPage里有total、records、pages,前端表格直接绑定records,分页组件绑定total即可。
4. 预约系统避坑手册:并发超卖、时区偏移与版本升级里的 4 个真实故障
4.1 并发下同一时段超卖:现象、原因、解决
现象是压测时50个线程同时预约同一日期的票,库存30张,成功记录的却是37条。原因是代码用了「先select查库存,再update扣减」的经典写法——两个请求同时查到stock=1,都认为有票,都执行update,库存变成-1。解决方式上面已经写了:把扣减改成带stock > 0条件的原子update,配合事务和唯一索引兜底。
这里有一个容易被忽略的细节:@Transactional默认的隔离级别是REPEATABLE_READ,在「先查后扣」的写法下会出现幻读——第一次查没有重复预约,插入时才发现唯一索引冲突。所以防重不能只靠Service里的判断逻辑判断,t_reserve表上的uk_user_date唯一索引才是可靠的防线,冲突后事务回滚,库存自动恢复。
4.2 前端提交的日期比现实慢 8 小时
现象是用户在前端选了2024-08-20,后端收到请求一看visitDate已经变成了2024-08-19。原因有两层:一是前端传的ISO时间串里带Z时区标记,Jackson反序列化时按UTC解析;二是MySQL连接串里的serverTimezone没设置,驱动按服务器本机时区解释。解决要两头堵。
spring.jackson.time-zone=GMT+8 spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.datasource.url=jdbc:mysql://localhost:3306/museum?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai前端el-date-picker已经用value-format把日期格式化成2024-08-20的纯字符串,后端LocalDate解析不会带时区问题。但如果你用了Date类型接收,或者前端传了带T的ISO格式,这两条配置就是后悔药。排查时先在数据库里SELECT visit_date FROM t_reserve看实际入库值,再判断是接口层解析问题还是JDBC层时区问题,别上来就改代码。
4.3 SpringBoot 3.x 升级后 javax 包全红,以及 2.7.18 的取舍
现象是网上教程都推荐SpringBoot 3.x,照着建项目后引入旧版MyBatis-Plus,启动报ClassNotFoundException: javax.servlet.Filter。原因是SpringBoot 3.0把JavaEE命名空间从javax.*换成了jakarta.*,所有依赖都要跟着升级大版本:MyBatis-Plus要3.5.3以上,Spring Security要6.x。最稳妥的选择是SpringBoot 2.7.18,它处于2.x的最终维护版本,javax命名空间兼容所有旧教程,毕设和中小型项目足够用。
如果一定要上3.x,用spring-boot-starter-parent统一管理依赖版本,不要手动给每个starter写version。常见的翻车现场是:SpringBoot 3.2 + MyBatis-Plus 3.5.3 + 旧版Druid,启动报Failed to configure a DataSource,原因就是Druid版本不兼容SpringBoot 3的自动装配,换成druid-spring-boot-3-starter即可。这类问题看启动日志最前面几行的*号标注,它会明确提示哪个自动配置类失败。
4.4 线上行为和本地不一致:用 jar 反编译核对部署包
现象是本地测试预约流程一切正常,线上却报某个接口404,怀疑是部署的包不是最新代码。最直接的排查方式是把线上跑的jar拿下来反编译对比。常见的用法是jd-gui直接把jar拖进去看源码,或者命令行javap看某个类的字节码。
javap -c -p MuseumApplication.jar | grep "reserve"javap是JDK自带的工具,-c反汇编出字节码,-p显示私有成员。用grep搜类名或方法名,能确认部署包里到底有没有这段逻辑。如果嫌字节码难读,jd-gui图形界面能把class还原成可读的Java代码。注意反编译结果里reserve方法不存在,说明打包时用的源码分支不对——回git看提交记录,git log --oneline对比线上包文件的构建时间,比重新部署试错快得多。
5. 从本地跑到线上:Docker部署SpringBoot、Vue打包与Nginx反向代理
5.1 Dockerfile与Compose:SpringBoot后端容器化的最小两件套
本地能跑和线上能跑之间隔着环境差异,最常见的就是JDK版本和时区。容器化解决的是「本地能跑、服务器跑不起来」这类环境玄学。后端工程先打成jar,然后写Dockerfile。
FROM openjdk:11-jre-slim ENV TZ=Asia/Shanghai COPY target/museum-app.jar /app/museum-app.jar EXPOSE 8080 ENTRYPOINT ["java", "-Xms256m", "-Xmx512m", "-jar", "/app/museum-app.jar", "--spring.profiles.active=prod"]用11-jre-slim而不是11-jdk,运行环境不需要编译器,镜像体积小一半。ENV TZ=Asia/Shanghai把容器内时区固定住,解决8小时偏移问题。-Xms256m -Xmx512m限制堆内存,防止容器内存溢出被OOM Kill。--spring.profiles.active=prod指定生产环境配置文件。
写docker-compose时把MySQL和前端都编排进去,本地一键起整套环境:
services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: museum TZ: Asia/Shanghai ports: - "3306:3306" backend: build: ./backend depends_on: - mysql environment: SPRING_PROFILES_ACTIVE: prod TZ: Asia/Shanghai ports: - "8080:8080" frontend: image: nginx:1.24-alpine volumes: - ./frontend/dist:/usr/share/nginx/html - ./nginx.conf:/etc/nginx/conf.d/default.conf ports: - "80:80"depends_on保证MySQL容器先启动,但要注意它只控制启动顺序,不保证MySQL已就绪——后端容器可能在MySQL还没监听3306时就开始连接。实际部署时给后端启动加个重试,或者用healthcheck做健康检查,不然第一启动大概率报Communications link failure。
5.2 Vue构建产物与Nginx反向代理 /api
前端打包前先改接口地址。开发环境用/api代理到localhost:8080,生产环境把/api请求转发到后端容器。Nginx配置是前后端联调的最后一公里:
server { listen 80; server_name museum.example.com; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html这行是Vue Router history模式的关键。刷新预约页时,浏览器请求的是/reserve这个前端路由,服务器上没有这个文件,不写try_files就会404。写成回退到index.html,由Vue Router接管路径。proxy_pass里末尾的/api/要和前端axios的baseURL保持一致,请求/api/reserve转发到后端/api/reserve,路径不会多一层。
5.3 环境配置分离:dev/prod 与容器内外参数
SpringBoot的application.yml按环境拆成三个文件:公共配置、application-dev.yml、application-prod.yml。公共配置放不变的部分,比如MyBatis-Plus的驼峰映射;dev环境放本地数据库连接和全量日志;prod环境放线上地址、精简日志级别。启动时用SPRING_PROFILES_ACTIVE环境变量切换,而不是改jar包里的配置文件——这样同一个jar在不同环境跑出不同行为,打包产物不变,部署可以随时切换。
生产环境有一点特别容易漏:数据库密码和Redis密码绝对不能明文写在application-prod.yml里。用环境变量占位符。
spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?serverTimezone=Asia/Shanghai username: ${DB_USER} password: ${DB_PASSWORD}Docker Compose里通过environment注入这几个变量。这样配置文件进了git仓库也不怕泄露密码,服务器登录的人只能看到环境变量里注入的实际值,看不到明文。密码里面如果有特殊字符,比如@或#,记得URL编码,不然连数据库一路报错到怀疑人生。
6. 压测验证与下一步:怎么证明预约功能在并发下没做崩
6.1 用 bash 脚本做并发冒烟测试:能证明「不超卖」才叫做完
功能跑通不等于是做完了,预约系统至少要证明一件事:并发下不超卖。不用上JMeter那么重的工具,一个bash循环就能做冒烟测试。先把t_ticket的库存手动改成10,然后并发发20个预约请求。
for i in $(seq 1 20); do curl -s -X POST http://localhost:8080/api/reserve \ -H "Content-Type: application/json" \ -d "{\"ticketId\":1,\"visitDate\":\"2024-08-20\",\"userId\":$i}" \ > /tmp/res_$i.json & done wait grep -l '"success"' /tmp/res_*.json | wc -l20个请求并发打过去,统计成功的响应数。如果库存是10,成功数必须是10,多一个都说明防重逻辑有漏洞。再查一下数据库:
SELECT COUNT(*) FROM t_reserve WHERE ticket_id = 1 AND visit_date = '2024-08-20'; SELECT stock FROM t_ticket WHERE id = 1 AND visit_date = '2024-08-20';预约单数量和库存扣减量加起来要等于原始库存。这个验证脚本可以留着,每次改完库存相关代码跑一遍,比人眼看着点按钮可靠。
6.2 向票务系统演进的三个扩展点:Redis计数、延迟队列与MinIO附件
预约管理做到能上线只是起点,往真实票务系统演进时三个方向优先级最高。第一个是Redis预减库存:把热点日期库存预热到Redis,请求先走DECR原子操作,低于阈值再落库,扛住秒杀级并发。第二个是取消预约的延迟释放:用户预约后30分钟未检票自动取消,用Quartz的@Scheduled每分钟扫一次过期单并回补库存,比用户手动取消可靠得多。第三个是身份证照片、参观凭证这类附件接入MinIO,对象存储独立于应用服务器,重启不丢文件,数据库只存URL路径。
我现在接手任何一个预约类项目,第一件事就是看表结构里有没有唯一索引、扣减SQL带不带stock > 0、事务有没有配rollbackFor,三样都在,这项目就有救;缺一样,迟早线上翻车。这套SpringBoot+Vue的方案就是按这个底线搭的,希望帮到你。
本文还有配套的精品资源,点击获取