Spring Boot体育场馆管理系统:从库表设计到并发控制实践
简介面向体育场馆运营管理场景的 Spring Boot 实战型毕业设计/课程项目源码包适合 Java 学习者、毕设学生或初创场馆信息化人员参考。系统覆盖场地信息维护、预订/取消/改期、会员注册与会员卡管理以及场地利用率、会员活跃度等统计看板业务链路完整。压缩包共 1076 个文件其中 259 个 Java 源码文件与 81 个 Vue 组件构成前后端主体94 个 html 页面与 53 个 css 样式支撑界面展示另含 SQL 数据库脚本、部署文档、启动脚本及演示视频整体约 118.65MB。已有 131 人浏览学习。资源内附一键安装、运行与构建批处理配合部署文档和操作演示可有效降低环境搭建门槛便于快速跑通流程并在此项目基础上扩展二次开发。1. 一套 Spring Boot 做出来的体育场馆系统到底在管哪些事你搜这个标题基本是两种情况要么手上正拿着一份带源码、部署文档和演示视频的毕设或实训项目包想搞清楚里面每一层是怎么串起来的要么是要在一个月内从零搭一套能拿去答辩或上线试运营的场馆管理系统。先说结论——这类系统的技术难度不在 Spring Boot 本身而在业务状态的组织一块场地从可预订到被锁定再到完成核销中间任何一个环节没管住就会出现超卖、重复预约、对不上账。体育场馆运营管理的核心对象很固定场馆、场地、时段、订单、会员、课程、设备、统计报表。系统要回答的问题也只有一个——谁在什么时间用了哪块场地花了多少钱还剩下多少可售时段。围绕这个核心Spring Boot 承担的角色是接口服务、事务控制、权限拦截和定时任务前端通常是一套 Vue 或 Thymeleaf 后台管理页面数据库则用 MySQL配合 Redis 做时段锁和热点缓存。这套组合在中小企业、学校场馆和毕设场景里是最稳的选型资料多、排错容易、部署成本低。这篇博文不讲空话直接按「业务建模 → 库表设计 → 核心预订流程 → 权限控制 → 部署交付 → 二次开发」这条线走。你拿到任何一份同题的源码包都能用这里的思路快速定位它的代码结构你要自己写也能按下面的步骤把最小可用版本跑起来。2. 库表设计与订单状态机先把场地和时段的数据模型立住2.1 业务实体拆解不是每个表都叫「场馆」浏览很多同题项目最容易犯的错是把「场馆」和「场地」混成一个表。实际上这两个是父子关系一个体育场馆SportsVenue下有多个场地VenueSite比如一个综合体育馆下有羽毛球区、篮球区、乒乓球区每个场地又按时间切成时段TimeSlot比如 8:00-9:00、9:00-10:00。订单BookingOrder挂在场地下而不是场馆下。场馆 1 ── 场地 N ── 时段 N └── 订单 N这张关系图决定了查询路径用户选场馆 → 查该馆下的场地 → 查某场地的未来时段 → 选时段下单。如果你把时段做成场地的一个字段那并发场景下根本没法控制如果你把订单直接挂在场馆下统计场地利用率时会多写一堆聚合代码。按上面四层去建表后续的报表、排班、计费逻辑都在同一套关系里省事得多。2.1.1 表结构最小集一张建表 SQL 说清楚下面这套 SQL 是我会在一开始就落地的核心五张表不含会员卡和课程表那两张等主流程跑通再补。-- 场馆表 CREATE TABLE sports_venue ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, name VARCHAR(64) NOT NULL COMMENT 场馆名称, address VARCHAR(255) DEFAULT NULL COMMENT 地址, open_time TIME NOT NULL DEFAULT 08:00:00 COMMENT 开始营业时间, close_time TIME NOT NULL DEFAULT 22:00:00 COMMENT 结束营业时间, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态 1-营业 0-停业, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT体育场馆表; -- 场地表 CREATE TABLE venue_site ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, venue_id BIGINT NOT NULL COMMENT 所属场馆ID, name VARCHAR(64) NOT NULL COMMENT 场地名称如1号羽毛球场地, item_type VARCHAR(32) NOT NULL COMMENT 项目类型BADMINTON/BASKETBALL/TABLE_TENNIS, price DECIMAL(10,2) NOT NULL COMMENT 小时单价, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-可用 0-停用, PRIMARY KEY (id), KEY idx_venue_id (venue_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场地表; -- 时段表 CREATE TABLE time_slot ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, site_id BIGINT NOT NULL COMMENT 场地ID, start_time TIME NOT NULL COMMENT 开始时间, end_time TIME NOT NULL COMMENT 结束时间, slot_date DATE NOT NULL COMMENT 日期, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-可订 1-已锁 2-已售 3-停用, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_site_date_time (site_id, slot_date, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT时段表; -- 订单表 CREATE TABLE booking_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL COMMENT 用户ID, site_id BIGINT NOT NULL COMMENT 场地ID, slot_id BIGINT NOT NULL COMMENT 时段ID, amount DECIMAL(10,2) NOT NULL COMMENT 订单金额, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已取消, use_status TINYINT NOT NULL DEFAULT 0 COMMENT 0-未使用 1-已进场 2-已完成 3-爽约, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_slot_id (slot_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预订订单表;这段 SQL 里有三个设计点要说明。第一个是time_slot表把日期和时间分开存slot_date加start_time的唯一键保证同一个场地同一天同一开始时间不会出现两条记录这是防超卖的第一道闸。第二个是booking_order对slot_id建了唯一索引意思是一个时段只能对应一条订单程序逻辑即使有 bug数据库也会拒绝重复下单。第三个是time_slot里的version字段配合 JPA 或 MyBatis-Plus 的乐观锁插件使用是后面处理并发预订的关键。2.2 订单状态机别用一堆 if else 管订单场馆系统的订单状态比电商简单但拆成状态机写代码会清爽得多。常见的流转是待支付 → 已支付 → 已进场 → 已完成待支付超时 → 已取消已支付后可申请退款 → 已退款。用一张枚举表加一个状态机服务统一管理public enum OrderStatus { PENDING_PAY(0, 待支付), PAID(1, 已支付), ENTERED(2, 已进场), FINISHED(3, 已完成), CANCELLED(4, 已取消), REFUNDED(5, 已退款); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } public static boolean canTransit(int from, int to) { // 只允许固定流转0-1, 0-4, 1-2, 1-5, 2-3 return (from 0 to 1) || (from 0 to 4) || (from 1 to 2) || (from 1 to 5) || (from 2 to 3); } }canTransit方法把非法跳转直接挡在入口比如从待支付直接跳到已完成是不可能的。这样做的收益在后面写接口时特别明显——你不用在每个 Service 方法里重复判断当前状态而是在一个公共入口做统一校验。拿到别人的源码时去找这个枚举类或等价的status字段判断基本就能看懂整套订单逻辑的主线。提示订单状态字段优先用TINYINT存数字不要存字符串。MySQL 对数字字段的索引和比较效率更高而且配合枚举类转换代码可读性不会受影响。3. 场地预订的并发控制从乐观锁到单机版 Redis 锁3.1 先想明白并发发生在哪场馆预订系统的并发压力和电商秒杀不同——它的写操作集中在同一块场地、同一个未来时段的锁定上。假设 10 个人同时点开周六 18:00 的羽毛球 1 号场页面展示的都是「可订」然后一起下单。如果不用任何锁数据库里time_slot的status会被更新 10 次booking_order会插入 10 条但其中 9 条应该被拒绝。这就是超卖。解决的思路有三条路。第一条是数据库唯一索引硬挡即 2.1 节里uk_slot_id的做法能挡住重复插入但用户会看到「下单失败」而不是「已被锁定」体验一般。第二条是乐观锁用UPDATE ... SET status 1, version version 1 WHERE id ? AND status 0 AND version ?的方式更新影响行数为 0 时说明被别人抢了回滚事务并提示用户。第三条是 Redis 分布式锁在 Java 代码里先抢锁再操作数据库适合集群部署场景。单机部署时乐观锁已经够用但很多演示项目为了展示技术栈会用 Redis两者我都讲。3.2 乐观锁下单的完整代码MyBatis-Plus 的乐观锁插件只需要给实体类加Version注解然后在配置类里注册插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }实体类TimeSlot里对应字段加注解Data TableName(time_slot) public class TimeSlot { TableId(type IdType.AUTO) private Long id; private Long siteId; private LocalTime startTime; private LocalTime endTime; private LocalDate slotDate; private Integer status; Version private Integer version; }下单 Service 方法Transactional(rollbackFor Exception.class) public BookingOrder createOrder(Long userId, Long slotId, Long siteId) { // 1. 锁定时段乐观锁更新 TimeSlot slot timeSlotMapper.selectById(slotId); if (slot null || slot.getStatus() ! 0) { throw new BusinessException(时段不存在或已被预订); } TimeSlot update new TimeSlot(); update.setId(slotId); update.setStatus(1); // 已锁 update.setVersion(slot.getVersion()); // 携带旧版本号 int rows timeSlotMapper.updateById(update); if (rows 0) { throw new BusinessException(手速慢了该时段刚刚被预订); } // 2. 生成订单 BookingOrder order new BookingOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setSiteId(siteId); order.setSlotId(slotId); order.setAmount(siteMapper.selectById(siteId).getPrice()); order.setPayStatus(0); order.setUseStatus(0); orderMapper.insert(order); // 3. 发布订单创建事件可扩展通知、日志 eventPublisher.publishEvent(new OrderCreatedEvent(order.getId())); return order; }这段代码的关键在timeSlotMapper.updateById(update)。MyBatis-Plus 的Version插件会自动把 SQL 改写成UPDATE time_slot SET status 1, version version 1 WHERE id ? AND version 旧版本号所以当两个人同时带着版本号 0 去更新时数据库层面只有一个人能成功另一个rows 0直接抛出友好异常。事务注解必须加因为时段更新和订单插入要么一起成功要么一起回滚否则会出现时段被锁但订单没生成的状态。3.3 引入 Redis 锁单机版与集群版的分界线如果你的系统要部署多个实例或者演示时为了好看用了 Redisson那就在createOrder外层套一层分布式锁Autowired private RedissonClient redissonClient; public BookingOrder createOrderWithLock(Long userId, Long slotId, Long siteId) { String lockKey lock:slot: slotId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { // waitTime 10s, leaseTime 30s, 单位秒 locked lock.tryLock(10, 30, TimeUnit.SECONDS); if (!locked) { throw new BusinessException(系统繁忙请稍后重试); } return createOrder(userId, slotId, siteId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException(系统繁忙请稍后重试); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }锁的粒度要落在slotId上而不是整个场馆或整个场地。lock:slot:123只阻塞抢 123 时段的人其他时段正常下单这样并发能力不会被一把大锁拖垮。leaseTime设置 30 秒是兜底防业务执行到一半宕机导致锁不释放实际下单逻辑几十毫秒就能跑完30 秒不会误伤正常请求。注意乐观锁和 Redis 锁二选一即可不需要叠两层。两者解决的同一类问题叠多了只会让代码难排查。单机部署用乐观锁多实例部署用 Redisson。判断标准是spring-boot:run起几个进程。4. 权限与安全普通用户不能调管理员的接口4.1 用一个基于 RBAC 的 Spring Security 方案体育场馆运营系统天然有两种角色前台用户订场、查看订单、充值和场馆管理员管理场地、处理订单、查看报表、审核退款。如果项目里还有「超级管理员」角色通常也只多一个用户管理菜单主 RBAC 模型不用变。用 Spring Security JWT 是这类系统的主流组合比 Shiro 生态更完整社区也更活跃。以 Spring Boot 2.7.x 为例引入依赖后在配置类里定义三样东西UserDetailsService查库加载用户、PasswordEncoderBCrypt 加密、SecurityFilterChain放行白名单 JWT 过滤器。Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/**, /api/venues/**, /api/sites/**).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }/api/venues/**和/api/sites/**放行因为游客也要看场地信息才能决定是否注册/api/admin/**强制 ADMIN 角色其余接口都要登录。EnableGlobalMethodSecurity(prePostEnabled true)开启方法级权限注解这样可以在 Service 方法上用PreAuthorize(hasRole(ADMIN))做二次校验防止路径遗漏。JWT 过滤器是所有请求都必须经过的它的职责只有一个从请求头Authorization: Bearer xxx里解析 token如果有效就把用户信息放进SecurityContext让后续鉴权能读到Authentication对象。4.2 接口鉴权的细粒度设计路径级权限够用但防不住「用户 A 查用户 B 订单」这种越权问题。/api/order/list这类接口必须拿到当前登录用户而不是让前端传userId。一种通用做法是写个SecurityUtils.getCurrentUserId()工具方法public static Long getCurrentUserId() { Authentication authentication SecurityContextHolder.getContext().getAuthentication(); if (authentication null || !(authentication.getPrincipal() instanceof LoginUser)) { throw new BusinessException(未登录); } LoginUser loginUser (LoginUser) authentication.getPrincipal(); return loginUser.getUserId(); }Service 层查订单永远用where user_id getCurrentUserId()。管理员看全部订单走/api/admin/order/list角色不同走不同的查询实现。这样做还有个好处审计日志里可以直接记录操作人 ID不用前端额外上报。4.3 安全配置的三个必查点第一个是默认密码强度。演示项目里常见的admin/123456一定要在部署文档里写明要求修改源码里可以用application.yml配置一个初始化默认密码但生产环境要强制首次登录改密。第二个是JWT 密钥泄露。jwt.secret不能写死在代码里至少放到环境变量里否则反编译 jar 包就能伪造任意用户 token。第三个是 Spring Boot Actuator 端点。如果项目引入了spring-boot-starter-actuator记得把health之外的端点关掉或加上认证否则/actuator/heapdump可能把内存里的敏感信息泄露出去。线上环境我一般这样配置management: endpoints: web: exposure: include: health,info endpoint: health: show-details: never其他端点不是不能用而是要明确知道暴露面。heapdump端点尤其危险它会把 JVM 堆内存整个导出来token、密码、数据库连接串全在里面。如果确实要排错建议临时开启、抓完 dump 立刻关。注意spring-boot-starter-security一旦引入所有接口默认都要认证最早期的 bug 往往是把SecurityFilterChain配好后忘了放行登录接口导致登录自己也 401。调试时先用浏览器隐式窗口直连/api/auth/login确认拿到 token 后再排查其他接口。5. 从源码交付到上线部署一套文档该有的工程结构5.1 部署文档的标准骨架拿到带「源码 部署文档 演示视频」的项目包最该先看的是部署文档。一份能照着做成功的部署文档不应该只是一句「用 IDEA 打开改一下数据库密码运行」。我整理过一个清单你可以拿来对照手头的文档缺了什么章节必含内容检验方式环境准备JDK 版本1.8 还是 17、Maven 版本、MySQL 版本、Redis 是否必须按文档装完能通过java -version等命令数据库初始化SQL 脚本路径sql/init.sql、字符集、时区配置脚本执行零报错配置文件修改application.yml的端口、数据库 URL、Redis 地址、JWT 密钥不遗漏任何连接项启动步骤先启动 Redis 还是后端前端如何构建按顺序执行能起服务演示账号管理员账号、普通用户账号、每个角色的核心操作路径登录后能看到对应菜单排错指引端口被占、数据库连接失败、前端 404能顺着日志定位问题Spring Boot 项目最常见的启动失败是数据库连接串配错尤其是serverTimezoneAsia/Shanghai没加时报时区错误。另一类典型是 JDK 版本不匹配——Spring Boot 2.7 用 JDK 8 没问题但 3.x 强制要求 JDK 17。看到UnsupportedClassVersionError就别浪费时间查代码了先确认 JDK 版本。5.2 打包和部署的三种方式把源码变成一个能对外服务的系统按团队规模有三种走法。第一种是本地直接包适合毕设演示或小企业内网。在项目根目录执行mvn clean package -DskipTests ls target/*.jar nohup java -jar target/venue-system.jar --spring.profiles.activeprod -DskipTests跳过测试省时间--spring.profiles.activeprod指定生产配置让测试库和正式库分开。nohup ... 让进程在 SSH 断开后继续跑日志会写入nohup.out。第二种是Docker Compose 编排适合多模块项目后端 Redis MySQL 前端 nginx。docker-compose.yml里定义四个服务后端镜像用 Maven 构建前端用 nginx 托管静态文件并反代/api到后端容器version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: venue_db volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - 3306:3306 redis: image: redis:7-alpine ports: - 6379:6379 backend: build: ./backend depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod ports: - 8080:8080 frontend: image: nginx:alpine volumes: - ./frontend/dist:/usr/share/nginx/html - ./nginx.conf:/etc/nginx/conf.d/default.conf ports: - 80:80 depends_on: - backend第三种是云服务器手动部署前端构建产物和后端 jar 包分别上传nginx 静态目录指向前端文件location /api/反向代理到 8080 端口。这套方式最直观也最容易排错新手建议从这里开始。三种方式没有优劣之分按场景选。毕设答辩用第一种足够团队协作选第二种有运维需求再上云。5.3 演示视频应该录什么如果你要自己录演示视频别照着页面从头点到尾评委或领导只想看三件事管理员创建场地和时段、用户完成下单支付、订单状态流转后的场地状态变化。按这三个场景录每段 30 秒到 1 分钟总时长控制在 5 分钟以内。录制时把数据库的time_slot表打开下单成功后立即切到表里看status从 0 变成 1这个细节比任何口头解释都有说服力。提示部署文档里一定要写清楚 Redis 依赖。很多场馆系统启动时不会显式报缺 Redis但下单接口会超时或抛Unable to connect to Redis。文档里说「先装 Redis 再启动后端」能省掉一大半环境问题。6. 二次开发的两个验证技巧模拟支付与并发压测拿到源码包后别急着改功能先做两步验证确认这套系统是真的能扛住业务而不只是页面能打开。第一步是验证超卖防护是否生效。用 JMeter 或 Postman 的 Runner 功能对「锁定时段 创建订单」接口发 50 个并发请求参数全指向同一个slotId。跑完查两个数time_slot表里该slotId的status必须是 1booking_order表里关联该slot_id的订单数必须是 1。如果订单数超过 1说明代码里的事务或锁没生效问题大概率出在Transactional没被 Spring 代理比如同类里 this 调用或者乐观锁版本号没传对。第二步是检查支付模拟的闭环。很多毕设项目的支付是直接调一个pay()接口把pay_status改为 1不走真实支付网关。这种设计没有错但要注意下单后是否有时效逻辑比如 15 分钟未支付自动取消。实现方式是 Spring 的Scheduled定时任务扫描超时订单取消并把时段状态回滚Scheduled(cron 0 */5 * * * ?) Transactional(rollbackFor Exception.class) public void cancelExpiredOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListBookingOrder expiredOrders orderMapper .selectList(new LambdaQueryWrapperBookingOrder() .eq(BookingOrder::getPayStatus, 0) .lt(BookingOrder::getCreateTime, deadline)); for (BookingOrder order : expiredOrders) { order.setPayStatus(2); orderMapper.updateById(order); // 回滚时段状态 timeSlotMapper.rollbackSlotStatus(order.getSlotId()); } }cron表达式0 */5 * * * ?表示每 5 分钟执行一次扫描所有待支付且创建时间超过 15 分钟的订单。这个逻辑在演示视频里可以专门展示下一个订单不支付等定时任务跑完后刷新页面订单变已取消场地重新变可订。整个过程不用真的等 15 分钟本地调试时把cron改成每 10 秒执行一次即可。最后提一个很现实的经验这类系统最容易被答辩老师或验收方追问的是「如果你挂了怎么办」。你至少要能答出两点——数据库和 Redis 有备份策略订单表和时段表的状态可以通过脚本对账。知道这两条兜底路径比功能堆得再多都有说服力。本文还有配套的精品资源点击获取