简介这套基于SpringBoot与Vue技术栈的博物馆预约管理系统定位为Java方向的毕业设计实战项目主要面向计算机相关专业的学生以及需要快速搭建预约类系统的初学者。后端部分利用SpringBoot构建稳定服务前端采用Vue完成组件化页面展示与交互系统涵盖用户注册登录、展品分类浏览、在线预约、个人预约单管理、后台审核处理等功能模块能够帮助学习者理解预约流程中的权限控制、时段冲突校验、订单状态变更等核心业务逻辑。资源包整体约54.65MB以zip格式提供代码中包含了前后端对接、接口定义、数据表设计等具体实现细节通过项目源码可以快速掌握前后端分离开发中的环境配置、依赖管理、接口联调等关键步骤。目前已有30人浏览学习适合正在备题或中期开发的毕业生参考也可在此基础上继续扩展统计报表、消息通知、移动端适配等增值功能。1. 博物馆预约系统最难的不是写接口SpringBootVue这个组合在解决什么问题SpringBootVue博物馆(预约)管理系统这个名字看着普通真正动手做起来有三个藏得深的坎同一时段只放出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或11SpringBoot 2.7.x推荐JDK 8/11Maven 3.6IDEA自带Spring Initializr新建项目时选Spring Boot 2.7.18依赖勾选Web、MyBatis-Plus、MySQL Driver、Lombok。前端要求Node 16以上用npm create vitelatest museum-web建Vue3工程装vue-router4、pinia、element-plus、axios。前端装依赖这条命令值得说清楚。npm install vue-router4 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 要用原子更新不能先查再改否则两个请求同时读到 stock1各自减一库存就变成负数了。update iddecrStock 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和判断stock0是同一个原子操作并发请求只会有一个成功另一个影响行数为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 LambdaQueryWrapperTicket() .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 ResultReserveResult 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 :modelform :rulesrules refformRef label-width90px el-form-item propexhibitionId label展览 el-select v-modelform.exhibitionId changeloadTickets placeholder请选择展览 el-option v-foritem in exhibitions :keyitem.id :labelitem.name :valueitem.id / /el-select /el-form-item el-form-item propvisitDate label参观日期 el-date-picker v-modelform.visitDate typedate value-formatYYYY-MM-DD :disabled-datedisabledDate placeholder选择日期 / /el-form-item el-form-item el-button typeprimary :loadingsubmitting clicksubmit立即预约/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 } } /scriptvalue-formatYYYY-MM-DD这个配置必须盯住。不写的话el-date-picker绑定的是Date对象axios序列化时会变成ISO字符串带着T和时区后缀后端LocalDate解析大概率报错。:disabled-date用当天零点做比较避免用户选到过去的日期。submitting在请求期间置trueElement 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里这样查PageReserve page new Page(current, size); LambdaQueryWrapperReserve qw new LambdaQueryWrapperReserve() .eq(StringUtils.hasText(status), Reserve::getStatus, status) .orderByDesc(Reserve::getCreateTime); IPageReserve 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扣减」的经典写法——两个请求同时查到stock1都认为有票都执行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-zoneGMT8 spring.jackson.date-formatyyyy-MM-dd HH:mm:ss spring.datasource.urljdbc:mysql://localhost:3306/museum?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 reservejavap是JDK自带的工具-c反汇编出字节码-p显示私有成员。用grep搜类名或方法名能确认部署包里到底有没有这段逻辑。如果嫌字节码难读jd-gui图形界面能把class还原成可读的Java代码。注意反编译结果里reserve方法不存在说明打包时用的源码分支不对——回git看提交记录git log --oneline对比线上包文件的构建时间比重新部署试错快得多。5. 从本地跑到线上Docker部署SpringBoot、Vue打包与Nginx反向代理5.1 Dockerfile与ComposeSpringBoot后端容器化的最小两件套本地能跑和线上能跑之间隔着环境差异最常见的就是JDK版本和时区。容器化解决的是「本地能跑、服务器跑不起来」这类环境玄学。后端工程先打成jar然后写Dockerfile。FROM openjdk:11-jre-slim ENV TZAsia/Shanghai COPY target/museum-app.jar /app/museum-app.jar EXPOSE 8080 ENTRYPOINT [java, -Xms256m, -Xmx512m, -jar, /app/museum-app.jar, --spring.profiles.activeprod]用11-jre-slim而不是11-jdk运行环境不需要编译器镜像体积小一半。ENV TZAsia/Shanghai把容器内时区固定住解决8小时偏移问题。-Xms256m -Xmx512m限制堆内存防止容器内存溢出被OOM Kill。--spring.profiles.activeprod指定生产环境配置文件。写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:80depends_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}?serverTimezoneAsia/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三样都在这项目就有救缺一样迟早线上翻车。这套SpringBootVue的方案就是按这个底线搭的希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?