ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot博物馆预约系统:分时预约、防超卖与并发控制实战

SpringBoot博物馆预约系统:分时预约、防超卖与并发控制实战 把“SpringBoot博物馆预约管理系统”这个题目翻来覆去看了几个月你会发现它真正的价值其实不在代码行数而在你愿不愿意把“预约”这件事讲清楚。这个选题在计算机毕业设计里属于典型的“看着不难、做起来全是细节”你要做用户注册登录、场馆时段管理、预约单生成、门票核销、后台统计还要处理重复提交、超卖、时间冲突这些程序课里不常讲干净的问题。文章面向两类人一类是打算拿这个题目做毕设的同学另一类是项目已经写了一半、但总觉得逻辑不太顺、想在答辩前把系统改得更耐打的人。分时预约并不是简单“点个按钮存一行记录”。它背后是一套资源调度逻辑一天被切成若干个时段每个时段有总容量用户来了先选时间再抢名额后台要进行容量校验和状态流转。做得好答辩老师会觉得你有工程思维做得糙顶多是个“增删改查管理系统”。下面按我实际做这类项目的顺序把设计思路、核心实现、常见翻车点一次说透。1. 内容整体设计与思路拆解1.1 分时预约到底解决什么问题先认清需求再谈写码博物馆行业的痛点很直接热门展馆不控制人流既影响观展体验又存在安全隐患。分时预约要解决的不是“让你能在线订票”而是“在不同时间段内把参观人数压到合理阈值”。因此系统核心对象不是订单而是“时段”这个资源。我建议你把题目拆成三条业务线来看访客线注册登录、查看场馆、当日可约时段、提交预约、查看门票、取消预约。管理线配置场馆、维护时段、设置每日容量、查看预约明细、核销门票、统计人流量。系统线防重复提交、防止约满后超卖、过期自动失效、时段状态自动切换。这三条线理清了数据库表和接口设计就顺了。比如“访客线”需要用户表和预约记录表“管理线”需要时段表和场馆表“系统线”则是贯穿所有写操作的事务逻辑。很多同学做这个题目时最大的失误是把预约记录表设计成“用户ID 参观日期 随便一段起止时间”然后所有判断都在Service层里用if拼。这么做不是不能用而是你没法说清楚“这一个时段最多多少人”这个核心命题。分时预约的本质是“仓位管理”先把时间段抽象成资源再谈预约。1.2 技术栈怎么选为什么要用 SpringBoot MyBatis Plus这年头毕设选型SpringBoot已经是默认答案了不是因为它最潮而是因为它把SSH时代那种手工配一堆XML的折腾砍掉了。自动装配让数据源、事务、Web容器都变成“约定大于配置”你只要关注业务代码。SpringBoot的starter机制也很适合毕设加一个依赖就引入一套能力比如spring-boot-starter-web负责Webspring-boot-starter-validation负责参数校验。持久层我推荐MyBatis Plus而不是纯MyBatis。毕业设计不是让你表演手写XML映射MyBatis Plus的QueryWrapper和LambdaQueryWrapper可以把单表查询写得很短分页插件一个配置就能用。当然论文里可以把MyBatis Plus写成“在MyBatis基础上增强的持久层框架内置通用Mapper与条件构造器”显得你做过对比。版本选择是很多人栽跟头的地方。如果你用的IDE是2023年以后的版本新建项目默认给你SpringBoot 3.x配合JDK 17能用但你要是引用了老版本的第三方依赖就容易遇到javax.servlet和jakarta.servlet不兼容的问题。我的建议是毕设求稳用SpringBoot 2.7.18 JDK 8或11配合MyBatis Plus 3.5.x。别追新追新是给自己埋雷。选型点推荐方案主要理由后端框架SpringBoot 2.7.x稳定、资料多、兼容老依赖JDK版本8或11与SpringBoot 2.7匹配避免语法坑持久层MyBatis Plus 3.5.xCRUD开箱即用条件构造器好用数据库MySQL 5.7或8.0免费、Navicat可视化、答辩演示方便前端Vue2 ElementUI 或 直接用Thymeleaf前后端分离好展示模板引擎省时间缓存Redis可选加分项但需要解释缓存一致性如果你不是特别想展示“可扩展性”前端用Thymeleaf套页面也行系统照样完整。但考虑到现在大多数毕设都是前后端分离的演示形态我默认按Vue SpringBoot的分离结构来讲。1.3 模块划分与数据库设计先画表后写代码我的习惯是先画模块图再建表表结构定了接口也就定了一半。这个项目至少要有这些表sys_user用户表包含手机号、密码、昵称、角色普通用户/管理员。museum博物馆/场馆表如果需要支持多馆这张表必不可少。museum_time_slot时段表字段包括开馆日期、开始时间、结束时间、总容量、已预约数、是否启用。reservation_record预约记录表包含用户ID、场馆ID、预约日期、时段ID、预约单号、状态、创建时间。ticket_code或直接用预约单状态来模拟门票不单独建表也行但建一个更完整。关键表的字段设计要克制不要一个表塞二十个字段。预约记录表的核心字段长这样字段名类型说明idbigint主键user_idbigint预约人museum_idbigint场馆IDreserve_datedate预约日期time_slot_idbigint预约时段IDstatusvarchar(20)已预约/已取消/已入场/已过期create_timedatetime提交时间这里有个很容易忽略的细节预约记录表要建一个唯一索引比如uk_user_date_slot(user_id, reserve_date, time_slot_id)。数据库层面的唯一约束是你防止一个人重复占位的最强兜底比你在Service里写再多的if都可靠。2. 核心细节解析与实操要点2.1 时段表设计把“一天”拆成一排可预约的资源时段表是整个系统的核心。我见过不少项目在预约记录里直接存“2025-05-20 09:00-11:00”这样的字符串结果统计时段容量时无从下手。正确做法是先把可预约的时间段定义好。假如某博物馆周二到周日开放每天可预约时段是09:00-10:3011:00-12:3014:00-15:3016:00-17:30那就在museum_time_slot里插入四条记录每条记录关联一个museum_id再加上一个slot_date字段表示“哪一天开放”。注意这里有个设计分支如果你固定每周的时段都一样可以做成按星期匹配的逻辑但毕业设计建议直接生成“日期 时段”的排期表逻辑更直白。建表SQL我常用这样一段CREATE TABLE museum_time_slot ( id bigint NOT NULL AUTO_INCREMENT, museum_id bigint NOT NULL, slot_date date NOT NULL COMMENT 可预约日期, begin_time time NOT NULL, end_time time NOT NULL, total_num int NOT NULL DEFAULT 0 COMMENT 总容量, booked_num int NOT NULL DEFAULT 0 COMMENT 已预约数, enable_flag tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_museum_date_begin (museum_id, slot_date, begin_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;强调一下slot_date和begin_time分开存别合并成一个datetime。因为你需要按日期查整天的排期也需要按时间判断是否在可预约范围内拆开存索引和代码都省事。2.2 预约状态机别把 status 当摆设预约记录的状态一定要显式管理。常见状态我用四个已预约CONFIRMED、已取消CANCELLED、已入场USED、已过期EXPIRED。状态机的作用是让业务流程有边界。比如用户提交预约后状态为CONFIRMED。用户取消状态改为CANCELLED同时要把时段表的booked_num减回去。用户到馆核销状态改为USED。当天结束后定时任务把所有状态仍为CONFIRMED的记录改成EXPIRED。没有状态机你后面做统计时根本分不清“一个时段到底入场了多少人”。我就是在这个地方吃过亏一开始只用了两个字段is_cancel和is_used结果每次查数据都要拼“并且没取消并且没核销”的条件麻烦得很后来重构成了status字段。取消预约的规则也要定清楚。为了贴近真实场馆我建议限制“开场前4小时不可取消”。实现起来不复杂比较当前时间和预约日期时段开始时间少于4小时就直接返回“已过可取消时间”。这个规则在答辩时还能引出“为什么这么设计”的讨论是一个很自然的业务思考点。2.3 防超卖的三种姿势从简单到进阶博物馆预约系统能不能经受住“剧烈点击”是答辩时的高频问题。这里的核心矛盾是多个用户同时预约同一个时段的最后一个名额怎么保证不超卖最朴素的方案是“先select再update”先查booked_num total_num再更新。但并发情况下两个请求同时查到一样的结果就会超卖。解决思路有三种第一种数据库条件更新我个人最推荐。核心是一行SQLUPDATE museum_time_slot SET booked_num booked_num 1 WHERE id #{slotId} AND booked_num total_num若影响行数为1说明扣减成功影响行数为0说明已约满。这相当于把“检查容量和扣减容量”合并成一个原子操作不用额外加锁天然防超卖。第二种Redis缓存加预扣减。如果你想让系统看起来更“现代”可以先把时段剩余量放进Redis每次预约先decr再落库。但这会引入缓存一致性风险比如Redis数据没了怎么办、数据库更新失败怎么办。真要在答辩时讲清楚你得额外补不少设计。如果时间不够就别轻易在代码里加。第三种分布式锁。用Redisson或者ZooKeeper实现对毕设来说属于过度设计。面试官或答辩老师其实更想听你对比优劣而不是看你堆了一堆中间件。用生活化的例子解释这个逻辑传统排队方案是“到窗口问一句还有没有票再决定买不买”条件更新则是“每个窗口一手交钱一手扣库存货架上剩几件就只卖几件”。数据库行锁天然帮我们做到了一致性。2.4 缓存和定时任务给系统加一点“智慧”的点缀很多毕设题目里会提到“智慧”“智能”这类词落在代码上最常见的体现就是缓存热点数据和定时任务。比如首页要显示当前日期每个时段的余票情况这一步如果每天被大量用户刷新每次都查MySQL也能跑但不够“聪明”。我的做法是用一个SpringBoot定时任务每30秒把当天的时段剩余量刷新到Redis缓存查询接口先读缓存读不到再查库并回填。这个方案实现快解释也清楚。定时任务还要处理过期预约。我写过一个Scheduled(cron 0 10 0 * * ?)的任务每天零点十分把前一天还没入场的CONFIRMED记录批量改成EXPIRED。这样管理后台看到的“未到场人数”就是真实数据不会一直挂着“未处理”。关于“SpringBoot整合Flink”这种关键词我多说一句Flink是流计算框架适合做实时日志分析、异常检测这类场景。一个毕设项目如果仅仅是“整合了Flink”但没有实际的大数据量支撑答辩老师一眼就能看出来是硬凑。要体现“实时人流分析”用定时任务聚合加上Redis计数足够了别为了热词给自己挖坑。3. 实操过程与核心环节实现3.1 从零搭建项目环境准备与配置避坑我在IDEA里新建工程的步骤一般是选择Spring InitializrGroup填com.exampleArtifact填museum-reservation。依赖勾选Spring Web、MySQL Driver、Lombok如果是非MyBatis Plus的模板再手动往pom里加依赖。如果生成的SpringBoot版本是3.x而你不想折腾就在pom.xml里把父依赖版本改成2.7.18。一个相对稳的依赖声明片段parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesapplication.yml里我最关心的几项是端口、数据源、时区、MyBatis Plus配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/museum_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里要把serverTimezoneAsia/Shanghai加上否则连接数据库时经常报时区相关的错。SpringBoot的自动装配原理在答辩里也常被问到你可以这样解释启动类上的SpringBootApplication组合了EnableAutoConfiguration它会根据classpath下的依赖和application.yml配置自动注册相应的Bean。3.2 预约核心代码把“先查再扣再插入”写成事务预约创建接口是整个系统的“心脏”。先梳理接口入参用户ID可以从登录态获取前端传reserveDate、timeSlotId、visitorNum一般默认1人。返回结果是预约单号和状态。Service层代码我是一个事务方法搞定核心顺序是“校验用户、判断重复、扣减容量、插入记录”。看到这里你先记一件事重复预约判断和容量扣减必须放在同一个事务里这样任何一步抛异常前面扣掉的容量才会回滚。下面这段代码是我实际项目里的简化版本重点在思路Override Transactional(rollbackFor Exception.class) public R createReservation(CreateReservationDTO dto) { // 1. 校验时段是否存在并启用 MuseumTimeSlot slot museumTimeSlotMapper.selectById(dto.getTimeSlotId()); if (slot null || slot.getEnableFlag() ! 1) { return R.fail(该时段不可预约); } // 2. 判断日期是否合理 LocalDate today LocalDate.now(); if (dto.getReserveDate().isBefore(today)) { return R.fail(只能预约今天及之后的日期); } // 3. 判断是否重复预约同一用户同一天同一时段 LambdaQueryWrapperReservationRecord repeatWrapper Wrappers.lambdaQuery(); repeatWrapper.eq(ReservationRecord::getUserId, dto.getUserId()) .eq(ReservationRecord::getReserveDate, dto.getReserveDate()) .eq(ReservationRecord::getTimeSlotId, dto.getTimeSlotId()) .eq(ReservationRecord::getStatus, CONFIRMED); if (reservationRecordMapper.selectCount(repeatWrapper) 0) { return R.fail(您已预约该时段请勿重复提交); } // 4. 条件扣减容量只有 booked_num total_num 时才加1 LambdaUpdateWrapperMuseumTimeSlot updateWrapper Wrappers.lambdaUpdate(); updateWrapper.eq(MuseumTimeSlot::getId, dto.getTimeSlotId()) .eq(MuseumTimeSlot::getEnableFlag, 1) .apply(booked_num total_num) .setSql(booked_num booked_num 1); if (museumTimeSlotMapper.update(null, updateWrapper) 0) { return R.fail(该时段已约满); } // 5. 生成预约单号并插入 ReservationRecord record new ReservationRecord(); record.setUserId(dto.getUserId()); record.setMuseumId(slot.getMuseumId()); record.setReserveDate(dto.getReserveDate()); record.setTimeSlotId(dto.getTimeSlotId()); record.setReservationNo(generateReservationNo()); record.setStatus(CONFIRMED); reservationRecordMapper.insert(record); return R.ok(预约成功, record.getReservationNo()); }注意第4步的.apply(booked_num total_num)这是防超卖的关键。如果你用的是MyBatis Plus这个写法直接生成到SQL条件里作用就是原子地完成“判断并更新”。加上事务注解只要后续插入失败前面的容量扣减会自动回滚不会出现“票没订上但名额少了”的情况。3.3 门票核销与查询管理端操作实现管理端的核心操作有两个查看预约列表和核销门票。按日期查看预约列表可以用LambdaQueryWrapper加日期条件再用MyBatis Plus分页插件封装分页结果。核销的逻辑要严谨一些我实现的思路是用户在前台出示预约单号或二维码。管理员输入单号后台查询记录。判断状态是否为CONFIRMED再判断预约日期是否为当天然后改为USED。核销代码大概长这样LambdaQueryWrapperReservationRecord wrapper Wrappers.lambdaQuery(); wrapper.eq(ReservationRecord::getReservationNo, dto.getReservationNo()); ReservationRecord record reservationRecordMapper.selectOne(wrapper); if (record null) { return R.fail(预约单不存在); } if (!CONFIRMED.equals(record.getStatus())) { return R.fail(该预约单不可核销); } if (!record.getReserveDate().equals(LocalDate.now())) { return R.fail(只能核销当天的预约); } record.setStatus(USED); record.setCheckTime(LocalDateTime.now()); reservationRecordMapper.updateById(record);这里要注意核销状态变更和前台的“取消预约”都要保证在同一事务里避免改了状态但没改容量。最容易被忽略的是取消预约要同时执行“状态改成CANCELLED”和“时段表的booked_num减1”这两步必须打包在一个事务里。把这个细节写进论文是加分项。3.4 前端打包放进SpringBoot里最终演示形态毕设最终交付通常是一个能跑起来的完整系统。如果你用Vue写了前端打包后就是一个dist目录里面的文件可以直接放进SpringBoot项目的src/main/resources/static下。这样启动SpringBoot后浏览器访问http://localhost:8080就能看到页面不用再单独启一个前端服务。这里有两个常见问题。第一个是接口跨域。如果你在开发时用npm run serve起前端、端口是8081而后端是8080那前端Ajax访问会跨域。最简单的方法是在Controller里加CrossOrigin或者写一个全局配置类。但打包部署后前端和接口同源跨域问题就不存在了。第二个是Vue Router的history模式。如果路由用了history模式你直接刷新http://localhost:8080/reservation后端会返回404因为SpringBoot找不到/reservation这个资源。解决办法有两个一是改用hash模式URL里会多一个#刷新不会404二是在后端写一个转发Controller把非/api开头的路径转发到index.html。我倾向第二种因为URL看起来更干净。Controller public class IndexController { RequestMapping(value {/, /index, /reservation, /admin, /login}) public String index() { return forward:/index.html; } }如果路由很多你也写一个通用匹配规则但要注意别拦截/api下的接口。这个细节实操价值很高每年都有同学在答辩前夜卡在这里。4. 常见问题与排查技巧实录4.1 为什么预约数经常“对不上”先查这5个地方我在运行这个系统的过程中最常被问的就是“后台显示的预约数和时段表里的booked_num对不上”。排查时先按顺序检查可能原因现象解决办法取消预约没回滚容量已取消的记录仍在占用名额在取消事务里同时减booked_num插入预约失败但容量已扣时段显示约满但查询没有记录扣减容量和插入记录必须在同一事务事务没生效方法内异常后部分操作成功确认Transactional加在public方法上且没有被同类方法内部调用绕过定时任务重复执行同一批预约被改成过期两次更新条件加status CONFIRMED重复预约拦截失效同一个人一天约了多次同一时段数据库加唯一索引兜底再补充一个特别隐蔽的点如果你的reservationRecord有逻辑删除配置查询时MyBatis Plus会自动追加deleted0条件但更新统计时如果没注意可能把逻辑删除的数据也算进去了。这是配置层面的坑不是业务逻辑错很容易排查半天。4.2 时间与时区坑演示前一天发现时间全部显示成“昨天”这个坑我踩过而且印象极深。本地开发一切正常打包部署到服务器后页面上的预约时间整体差了8小时。原因多半是MySQL连接串没有指定时区或者后端用了Date类型直接返回给前端Jackson序列化时又用了UTC。我的统一方案是数据库连接串加serverTimezoneAsia/Shanghai。后端日期字段优先用LocalDate和LocalDateTime不要用java.util.Date。前端传日期参数时一律传字符串比如2025-01-15 09:00:00不要改成时间戳再传。Jackson全局配置时区为GMT8application.yml里我已经写了spring.jackson.time-zone: GMT8。还有个细节是前端用ElementUI的date-picker组件默认返回的value-format如果不设置可能是Date对象。我建议设置value-formatyyyy-MM-dd这样提交的就是纯日期字符串省去一系列转换。4.3 关于“SpringBoot版本太高”和依赖冲突的解决思路“SpringBoot版本太高”这个热词背后其实是两个实际问题一个是新版本不兼容老包另一个是你自己手滑引用了不匹配的依赖版本。举个真实场景你在网上抄了一段MyBatis Plus的配置导的是mybatis-plus-boot-starter 3.4.2结果项目是SpringBoot 3.2.1运行后就报ClassNotFoundException: javax.servlet.Filter之类。原因就是SpringBoot 3.x把Java EE规范从javax.*换成了jakarta.*老MyBatis Plus版本还在用javax的API自然过不去。解决方式很简单把SpringBoot降级到2.7.x或者把MyBatis Plus升到3.5.9以上。Maven依赖冲突的排查工具我还提一句IDEA“Maven Helper”插件可以直接搜依赖并在弹窗里排除冲突项。最常见的冲突场景是同时引入了多个包含jackson的starter类加载时版本不一致。排除动作有点像“挑出哪个依赖里带着多余的东西指定只用某一边的版本”。4.4 答辩突发状况演示前15分钟怎么保底答辩翻车通常不是代码错而是现场环境问题。我给自己定的规矩是演示必须全部本地化不依赖外网。包括MySQL本地跑、Redis本地跑如果要用、前端打包进SpringBoot的静态目录。不要现场访问一个不知道什么时候会挂的云服务器。演示的路径我建议固定为“注册用户 - 查看今日余票 - 选择时段提交 - 在后台看到预约记录 - 输入单号核销 - 在统计页看到数据变化”。这条链路能覆盖80%的功能点而且能展示状态变化比单纯打开一堆页面截图有说服力。答辩老师常问的几个问题和参考思路我列一下问题参考回答思路系统如何避免超卖数据库条件更新booked_num total_num是一个原子操作MySQL行锁保证并发安全为什么用SpringBoot自动装配减少配置、内嵌Web容器、生态成熟适合快速开发状态字段为什么这么设计显式状态机让业务流转清晰便于定时任务处理和统计如果访问量特别大怎么办加Redis缓存时段余量、引入消息队列削峰、水平扩展实例预约时间冲突怎么处理唯一索引兜底 业务层校验重复预约最后再分享一个我实操中的体会这个项目最值钱的部分不是代码写得多炫而是你把“预约”的约束条件想清楚了。时间、容量、状态、重复性每一类约束都对应一个技术点。把这些约束在代码里展现出来论文里解释清楚整套毕业设计就站得住。后面想做扩展的话还可以往二维码门票、短信提醒、客流量热力图这些方向加功能骨架不变内容自然变厚。
返回列表