十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Spring Boot场馆预订系统:并发锁场与超时订单自动释放

Spring Boot场馆预订系统:并发锁场与超时订单自动释放 简介这是一份基于Spring Boot构建的体育场馆运营管理系统完整项目资源面向Java学习者、毕业设计人员及体育场馆信息化管理从业者可用于快速搭建场地预订、会员管理等运营后台。系统围绕场地管理、预订管理、会员管理和数据统计等核心模块展开能够帮助读者理解从后端接口到前端页面的全栈实现思路。压缩包共1076个文件大小约118.65MB主要包含java源码、vue前端页面、html模板、css样式、sql数据库脚本及部署配置等类型目录结构清晰可按模块查阅部署文档与演示视频。目前已有131人学习下载适合作为课程设计、毕业设计或中小型体育场馆信息化建设的起步模板。借助完整源码与配套文档读者可掌握Spring Boot与Vue的整合方式、会员预订流程设计以及常见环境部署与运维细节。1. 场地预订的并发冲突与锁场问题体育场馆运营管理系统这类 Spring Boot 项目真正难的点不在场地信息的 CRUD而在「预订」这个动作同一个羽毛球场地两个会员同时在手机端选 18:00-19:00系统得保证只有一个能占住另一个收到「该时段已被预订」。这套基于 Spring Boot Vue 的源码把业务拆成了场地管理、预订管理、会员管理、数据统计四个模块预订环节用「待支付预占 15 分钟超时释放」解决占场不付款的问题会员卡扣款也做了防超扣处理。适合要接手场馆预订系统、做毕业设计二次开发或者想看看一个完整预订流程在 Spring Boot 里怎么落地的人。2. Spring Boot Vue 的模块拆法与核心库表设计打开项目结构会发现前端是 Vue 组件.vue文件后端是 Spring Boot API 服务典型的 springboot vue 前后端分离结构。前端资源包里能看到IndexMain.vue.bak、IndexHeader.vue.bak、IndexAsideStatic.vue.bak这类备份文件说明主布局和静态侧边栏是改过版的update-password.vue.bak是会员改密组件的旧版备份。这些.bak文件在排错时很有用比如改完侧边栏之后菜单渲染异常可以用 diff 工具对比两个版本找回原来的写法。2.1 为什么 Spring Boot 只做 API不用 JSP 渲染页面前后端分离是这几年场馆类管理系统的默认选择运营端需要 PC 管理后台未来还可能要接小程序、H5 或自助机后端 API 可以原样复用。Spring Boot 的自动装配原理决定了它的起步成本低——引入spring-boot-starter-web后内嵌 Tomcat打包成 jar 就能直接跑不需要单独部署 Web 容器。持久层常见做法是 MyBatis-Plus场馆类的增删改查不用写太多样板代码复杂统计直接用Select注解写原生 SQL比 JPA 更容易控制执行计划。对五年以上的人来说这个结构的意义在于部署拓扑简单后端一个 Java 进程前端一份静态资源服务器上不需要再装额外的 Web 容器。如果哪天要把内部管理系统暴露到公网只需要在最前面加一层 Nginx 做 SSL 终止和反向代理。2.2 业务模块与核心表结构这套系统的业务核心可以压成四张表场地、会员、会员卡、预订订单。把预订订单拆出来而不是在场地表上加预订字段是为了让一个订单能挂多个时间片和支付记录。实际项目的字段会比下面多但核心关系是这样业务模块表名关键字段作用场地管理venueid, name, type, open_start, open_end, price_per_hour, status场地基础信息和开放时段预订管理booking_orderid, order_no, venue_id, member_id, start_time, end_time, amount, status, expire_time预订订单与预占过期时间会员管理memberid, name, phone, card_no, level, created_at会员档案会员卡member_cardid, member_id, balance, version, status余额和乐观锁版本号其中booking_order的建表脚本我是这样设计的CREATE TABLE booking_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, venue_id BIGINT NOT NULL, member_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已完成 3已取消, expire_time DATETIME NOT NULL COMMENT 预占过期时间, pay_time DATETIME, cancel_reason VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_venue_time (venue_id, start_time, end_time) );这里有两个关键设计。uk_order_no唯一索引配合order_no做幂等前端重复提交时数据库直接报重复键不会生成两张单idx_venue_time联合索引给后面的时间重叠查询用否则数据量起来之后每次预订都要扫全表。expire_time字段一开始就要留好后面做定时释放任务会用到如果不加这个字段到时候只能靠create_time 15 分钟推算逻辑会很别扭。2.3 预订状态机与异常分支预订状态一共有四个待支付、已支付、已完成、已取消。正常路径是会员选好场地和时段创建订单此时状态为 0场地被预占前端倒计时 15 分钟支付成功变成 1到场核销后变成 2。异常分支有三个15 分钟未支付由定时任务改成 3会员在支付前主动取消也走 3如果会员支付后想改期需要先做退款再把余额加回会员卡。这里最容易翻车的是并发场景两个请求同时创建同一场地同一时间片的订单如果只「查一次有没有重叠记录」然后插入第二次查询发生时第一条事务还没提交两个订单就都建出来了。所以实现上必须先锁场地行再查重叠记录这个细节下一章展开。3. 预订与会员模块落地冲突检测、扣款与 Vue 组件交互这一章直接看代码。预订模块是整个系统里事务最复杂的部分涉及场地行锁、时间冲突检测、会员卡余额三个操作必须放在同一个事务里。会员卡扣款的防超扣逻辑也值得单独拿出来说。3.1 创建预订行锁 时间重叠区间判断Service public class BookingService { Autowired private VenueMapper venueMapper; Autowired private BookingOrderMapper bookingMapper; Autowired private MemberCardMapper memberCardMapper; Transactional public BookingResult createBooking(BookingCreateRequest req) { // 1. 锁定场地行防止并发下同一场地时间片被重复创建 Venue venue venueMapper.selectByIdForUpdate(req.getVenueId()); if (venue null) { return BookingResult.fail(场地不存在); } // 2. 校验预订时间是否在场地开放时段内 if (req.getStartTime().isBefore(venue.getOpenStart()) || req.getEndTime().isAfter(venue.getOpenEnd())) { return BookingResult.fail(预订时段不在场地开放时间范围内); } // 3. 查同一时间段是否有已占用订单待支付和已支付都算占用 long overlap bookingMapper.countOverlap( req.getVenueId(), req.getStartTime(), req.getEndTime()); if (overlap 0) { return BookingResult.fail(该时段已被预订或预占中); } // 4. 校验会员卡余额 MemberCard card memberCardMapper.selectByMemberId(req.getMemberId()); if (card null || card.getBalance().compareTo(req.getAmount()) 0) { return BookingResult.fail(会员卡余额不足); } // 5. 生成待支付订单预占场地默认 15 分钟后过期 BookingOrder order new BookingOrder(); order.setOrderNo(BK System.currentTimeMillis()); order.setVenueId(req.getVenueId()); order.setMemberId(req.getMemberId()); order.setStartTime(req.getStartTime()); order.setEndTime(req.getEndTime()); order.setAmount(req.getAmount()); order.setStatus(BookingStatus.PENDING.getCode()); order.setExpireTime(LocalDateTime.now().plusMinutes(15)); bookingMapper.insert(order); return BookingResult.ok(order); } }selectByIdForUpdate是这段逻辑的关键。它把场地这一行数据在事务里锁住第二个并发请求走到这里会阻塞直到第一个事务提交或回滚。这样步骤 3 的countOverlap查询结果才是可信的。注意Transactional必须在如果漏了事务注解行锁在方法结束时就会释放后面的检查依然存在竞态。时间重叠查询的 SQL 也要写对很多人在这一步用start_time ? AND end_time ?这是错的Select(SELECT COUNT(*) FROM booking_order WHERE venue_id #{venueId} AND status IN (0, 1) AND start_time #{endTime} AND end_time #{startTime}) long countOverlap(Param(venueId) Long venueId, Param(startTime) LocalDateTime startTime, Param(endTime) LocalDateTime endTime);区间重叠的判断逻辑是「已有订单的开始时间早于新订单的结束时间且已有订单的结束时间晚于新订单的开始时间」这个条件能覆盖包含、交叉、相邻边缘等所有情况。status IN (0, 1)表示待支付和已支付的订单都算占场已取消和已完成的不算。这里还有个细节待支付订单也算占用这就是「预占」的含义否则会出现 A 创建了订单没付款B 又把同一时段订走的情况。3.2 会员卡支付带 version 的条件更新防超扣支付环节的并发风险不在余额查询而在扣款动作本身。两个请求同时读到余额 100 元各自扣 60如果直接用balance - 60更新最终余额会变成 -20而不是正确的结果。所以扣款必须用版本号乐观锁Transactional public PayResult payOrder(String orderNo) { BookingOrder order bookingMapper.selectByOrderNoForUpdate(orderNo); if (order null || order.getStatus() ! BookingStatus.PENDING.getCode()) { return PayResult.fail(订单不存在或状态已变化); } if (order.getExpireTime().isBefore(LocalDateTime.now())) { order.setStatus(BookingStatus.CANCELLED.getCode()); order.setCancelReason(超时未支付场次自动释放); bookingMapper.updateById(order); return PayResult.fail(订单已超时请重新预订); } MemberCard card memberCardMapper.selectByMemberId(order.getMemberId()); // 带 version 条件更新返回 0 说明出现并发扣款事务回滚 int rows memberCardMapper.deductBalance( card.getId(), order.getAmount(), card.getVersion()); if (rows 0) { return PayResult.fail(扣款冲突请重试); } order.setStatus(BookingStatus.PAID.getCode()); order.setPayTime(LocalDateTime.now()); bookingMapper.updateById(order); return PayResult.ok(order); }对应的 Mapper 更新语句长这样Update(UPDATE member_card SET balance balance - #{amount}, version version 1 WHERE id #{cardId} AND version #{version} AND balance #{amount}) int deductBalance(Param(cardId) Long cardId, Param(amount) BigDecimal amount, Param(version) Integer version);WHERE条件里的version #{version}保证了只有版本号没变时才能更新成功balance #{amount}保证余额够扣。这两个条件缺一不可。返回的rows为 0 时整个事务回滚包括前面的状态修改也不会提交。这里用的是乐观锁适合会员卡这种并发冲突不频繁的场景如果后续做秒杀级高并发才需要考虑悲观锁或 Redis 分布式锁。3.3 前端预订组件的交互逻辑前端预订页的核心就两个状态选时段、提交中。代码骨架如下template div classbooking-panel el-date-picker v-modeldateRange typedatetimerange range-separator至 start-placeholder开始时间 end-placeholder结束时间 / el-button :loadingsubmitting typeprimary clicksubmitBooking 提交预订 /el-button /div /template script import axios from axios export default { data() { return { dateRange: [], venueId: null, submitting: false } }, methods: { async submitBooking() { if (!this.dateRange || this.dateRange.length ! 2) { this.$message.warning(请选择完整的预订时段) return } this.submitting true try { const { data } await axios.post(/api/booking/create, { venueId: this.venueId, memberId: this.$store.state.member.id, startTime: this.dateRange[0].getTime(), endTime: this.dateRange[1].getTime() }) if (data.code 0) { this.$message.success(预订成功请在 15 分钟内完成支付) this.$router.push(/order/detail/ data.data.orderNo) } else { this.$message.error(data.msg) } } finally { this.submitting false } } } } /scriptsubmitting标志位在请求期间锁定按钮避免用户快速点击提交多个重复订单。后端有order_no唯一索引兜底即使前端没拦住也不会生成重复数据。这里只是演示交互骨架实际项目中memberId应该从登录态的 token 里解析而不是像示例这样直接读 store。4. 部署文档里的构建脚本与上线排错资源包里带了三个 bat 脚本和部署文档这是整个项目最容易被忽略但实际价值很高的部分。很多人在本机能跑起来换一台机器就各种起不来问题几乎都出在环境匹配和脚本路径上。4.1 安装、运行、构建三个脚本分别做了什么三个脚本的命名对应三个阶段的动作。首次拿到源码先跑1-install.bat它做的事是后端 Maven 依赖安装和前端 npm 依赖安装:: 1-install.bat首次拉代码后先执行装依赖 cd /d %~dp0 call mvn -f backend/pom.xml clean install -DskipTests cd frontend call npm install pause%~dp0表示当前脚本所在目录这样不管从哪个路径执行都能定位到项目根目录。-DskipTests跳过测试避免打包时因为测试环境问题中断。Windows 下mvn和npm需要提前配好环境变量否则会提示「不是内部或外部命令」。开发环境启动用2-run.bat它同时拉起后端和前端两个进程:: 2-run.bat开发环境一键起前后端 start backend cmd /k cd /d %~dp0backend mvn spring-boot:run start frontend cmd /k cd /d %~dp0frontend npm run servestart命令会打开两个新的命令行窗口互不阻塞。后端窗口日志打印 Spring Boot 启动横幅和端口号前端窗口打印 Vite 或 Webpack 的编译进度。如果前端是 Vue CLI 创建的项目默认端口是 8080后端如果也配了 8080 就会冲突部署文档里通常会把后端的server.port改成 8081。生产部署用3-build.bat:: 3-build.bat打生产包 call mvn -f backend/pom.xml clean package -DskipTests cd frontend call npm run build copy /y ..\backend\target\*.jar ..\dist\ pause前端打完包后npm run build会生成frontend/dist目录里面是纯静态文件。这里把后端 jar 也复制到dist下一层方便统一上传服务器。实际部署时有两种方式一种是用 Spring Boot 直接托管前端静态资源把 dist 内容拷到src/main/resources/static再重新打 jar另一种是服务器上装 Nginx静态文件交给 Nginx 处理/api路径反向代理到后端的 8081。前者适合项目小、访问量低的场景后者适合要上 HTTPS 证书的场景。4.2 部署文档里最容易翻车的环境匹配问题配置位置常见坑处理方式application.yml 的 server.port端口被本机其他进程占用netstat -ano | findstr 8080查 PID 后结束进程spring.datasource.urlMySQL 连接不上或报时区错误连接串加useSSLfalseserverTimezoneAsia/Shanghai跨域配置前端 8080 调后端 8081 被浏览器拦截后端加 CorsFilter或 Nginx 配置反向代理同源转发数据库密码密码含、、#等特殊字符连不上在 url 里对特殊字符做 URL 编码Spring Boot 3.x老项目代码里javax.servlet包找不到3.x 已迁移到jakarta.servlet需全局替换 import其中数据库连接串是报错率最高的地方。MySQL 8.x 的驱动要求显式指定时区不配置serverTimezone会直接启动失败。如果本地装的是 MySQL 5.7驱动类名和连接串参数又有细微差别需要对照 pom 里引用的mysql-connector-java版本调整。部署文档里给的是一个能在某个环境下跑通的配置换环境后要检查的是这四项MySQL 版本、JDK 版本、Maven 镜像源、npm registry 地址。4.3 典型报错快速定位跑这个项目时遇到最多的报错就几类按出现频率排一下后端启动报Failed to configure a DataSource直接原因是没有正确读到数据库配置优先检查application.yml里的spring.datasource.url和启动时的工作目录。前端能打开登录页但点登录后没反应打开浏览器 F12 看 Network 面板里的 XHR 请求基本是跨域被拦或 token 没带上。输入正确的账号密码但登录接口返回 401去后端日志里查 JWT 或 Shiro 的过滤器链抛出的具体异常。生产环境打包后前端资源 404检查 Spring Boot 是否配置了静态资源路径或者 Nginx 的location是否指向了正确的 dist 目录。还有一个安全细节容易被忽略如果 pom 里引了spring-boot-starter-actuator生产环境要收窄 exposure 配置否则heapdump、env等端点会暴露内存快照和环境变量这就属于 springboot heapdump 敏感信息泄露漏洞的典型场景。最简单的做法是配置management.endpoints.web.exposure.includehealth,info只开放健康检查。5. 用 Spring Boot 定时任务自动释放超时订单预占机制有一个天然的副作用用户创建了待支付订单但不付款场地时间片一直被占着其他会员无法预订。解决这个问题的做法不是让用户手动取消而是用 Spring Boot 内置的Scheduled定时任务自动扫描。不引入 Quartz 的原因很简单系统只有一个轻量级批处理需求没必要多加依赖和维护成本。先在启动类或配置类上开启定时任务支持SpringBootApplication EnableScheduling public class GymApplication { public static void main(String[] args) { SpringApplication.run(GymApplication.class, args); } }然后实现任务组件Component public class BookingExpireTask { private static final long EXPIRE_MINUTES 15; Autowired private BookingOrderMapper bookingMapper; Scheduled(fixedRate 60000) Transactional public void releaseExpiredBooking() { LocalDateTime expireLine LocalDateTime.now().minusMinutes(EXPIRE_MINUTES); ListBookingOrder expiredOrders bookingMapper.selectPendingCreatedBefore(expireLine); for (BookingOrder order : expiredOrders) { order.setStatus(BookingStatus.CANCELLED.getCode()); order.setCancelReason(超时未支付系统自动释放); bookingMapper.updateById(order); log.info(自动取消超时预订订单{}, order.getOrderNo()); } } }对应的 Mapper 查询条件很简单订单状态是待支付且创建时间早于当前时间减去 15 分钟。这里的expire_time字段可以替代create_time做判断但要注意两者的一致性如果业务上允许某些时段延长预占时长直接用expire_time NOW()更准确。fixedRate 60000表示每隔 60 秒执行一次不管上次任务是否结束。这个任务本身很轻量正常情况不会执行超过一秒所以用fixedRate没问题。如果任务里有批量退款、发送通知等耗时操作建议改成fixedDelay 60000保证上一次执行完再过 60 秒才跑下一次避免任务重叠。定时任务的执行时间要跟订单生成时的过期时间逻辑保持一致不能在创建订单时写死「15 分钟后过期」但任务扫描用「20 分钟」两边对不上就会产生过期订单迟迟不释放的问题。验证定时任务是否生效最直接的方法是手工造一条过期数据然后看日志INSERT INTO booking_order(order_no, venue_id, member_id, start_time, end_time, amount, status, expire_time, create_time) VALUES(TEST202501010001, 1, 1, NOW() INTERVAL 1 HOUR, NOW() INTERVAL 2 HOUR, 60.00, 0, NOW() INTERVAL 15 MINUTE, NOW() - INTERVAL 20 MINUTE);这条记录的create_time在 20 分钟前但expire_time在未来 15 分钟后如果任务按create_time判断会被取消按expire_time判断就不会。插进去之后等下一次任务执行观察日志是否出现「自动取消超时预订订单TEST202501010001」再查一下booking_order表里的status是否变成了 3。确认没问题后去前端场地列表刷新对应时间片应该已经亮回来了其他会员可以正常提交预订。本文还有配套的精品资源点击获取
返回列表