这段时间帮一位朋友调试一套基于Java+SpringBoot的篮球场馆预订系统,从最初跑通源码再到把调试文档、设计文档(LW)、讲解资料整套整理完,前后花了挺长时间。说实话,这类场地预约平台在课程设计和毕业设计里几乎成了“标配”选题,但大多数同学拿到手的源码要么缺数据库表,要么配置文件对不上,要么遇到并发抢订时直接乱套。今天我不打算复述那种“抄一遍说明书”的内容,而是完全按我实际调这个项目的过程,把篮球馆订场系统的数据库建模、核心业务流程、并发防超卖、文档交付这些关键点逐一拆开,希望能给正在做同类项目的读者一些真正能落地的参考。
这个项目本身不复杂,但麻雀虽小五脏俱全:用户端要浏览场地、按日期选时段、创建订单、支付(哪怕是模拟支付),管理端要维护场地和时段、查看订单、处理退款,中间还夹着预订冲突、超时未支付、重复提交等一堆“坑”。理解了这套系统怎么从零到一搭起来,再去看别的预订类项目,基本一通百通。
1. 为什么选择SpringBoot做篮球场地预约平台:从实际痛点说起
1.1 这类系统解决的真实痛点
在没有线上系统之前,球馆管理员的日常是:Excel记录预约,微信群里口头确认,打电话约场。一到周末晚高峰,经常出现同一个场地同一时段被两个人同时记住的情况,管理员自己都分不清谁先谁后。篮球场馆预订系统要解决的核心问题,就是把“场地-时段-用户”三者的关系线上化,用订单状态把每一个时段的“空闲/锁定/已订/使用完成”管理起来。
所以你看,需求本质上非常朴素:用户打开页面,看到今天还有哪些场地哪些时间段能订,选好后下单,支付完成后到点去打球。管理员在后台能看到每一天的排场情况、订单收入,也能手动关闭某个时段的预订。听上去不难,但真正实现的时候,如何保证“同一个时段不会出现两份订单”就成了最考验设计能力的点。
1.2 技术选型的取舍与理由
为什么选Java+SpringBoot?首先这是一个极其成熟的组合,SpringBoot自动装配大大减少了配置工作量,适合快速搭建单体应用。更重要的是,这套组合的生态资料丰富,遇到问题基本都能搜到答案,对于需要交付源码和讲解项目的场景很友好。
我这次使用的具体技术栈如下:
- 后端:Java 8 + Spring Boot 2.7.x + MyBatis-Plus
- 数据库:MySQL 8.0
- 缓存/锁:Redis 5.x(用于并发控制和未支付订单超时处理)
- 前端:服务端渲染模板Thymeleaf + AdminLTE后台模板
- 工具:Maven、Lombok、Hutool(常用工具,非必须)
这里不选前后端分离是因为:课程设计和毕设场景下,服务端渲染可以少写一套Vue/React,接口调试成本也低,展示的时候直接从浏览器进后台更直观。如果你的目标是学习Spring Boot接口开发,也可以做成前后端分离,本质逻辑是一样的。
2. 从业务需求到数据库模型:场地、时段、订单之间怎么关联
2.1 先画清楚状态流转,再建表
很多同学一上来就建表,结果字段越加越乱。正确顺序应该是先想清楚“一个订单从创建到结束有哪些状态,这些状态之间允许怎样跳转”。我最终定义订单状态为:
- 0:待支付
- 1:已支付
- 2:已取消
- 3:已使用(核销)
- 4:退款中/已退款
时段(排场)状态更简单,就三个:
- 0:空闲可订
- 1:已被锁定或已下单(待支付/已支付都算占用)
- 2:已禁用(管理员手动关闭)
状态为什么用int不用字符串?因为状态流转时比较大小、做条件判断更方便,而且枚举值含义在代码里定义清楚,不担心拼写错误。当然,可读性稍微差一点,所以我会在枚举类上写注释。
2.2 核心表结构与DDL
这张图在脑子里过一遍:用户表(user)、场地表(venue)、时段表(time_slot)、订单表(booking_order)。其中时段表是关键,它把一个抽象的“篮球场”和具体的“某天某段时间”绑定起来。
我的建表语句(简化版,去掉了多余字段):
CREATE TABLE `venue` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '场地名称,如A区1号场', `location` varchar(100) DEFAULT NULL COMMENT '位置描述', `price_per_hour` decimal(10,2) NOT NULL COMMENT '基准价/小时', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='篮球场地表'; CREATE TABLE `time_slot` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `venue_id` bigint(20) NOT NULL COMMENT '场地ID', `book_date` date NOT NULL COMMENT '预约日期', `start_time` time NOT NULL COMMENT '开始时间', `end_time` time NOT NULL COMMENT '结束时间', `price` decimal(10,2) NOT NULL COMMENT '实际售价', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0空闲 1占用 2禁用', PRIMARY KEY (`id`), UNIQUE KEY `uk_venue_date_time` (`venue_id`,`book_date`,`start_time`,`end_time`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场地时段表'; CREATE TABLE `booking_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `user_id` bigint(20) NOT NULL, `slot_id` bigint(20) NOT NULL, `amount` decimal(10,2) NOT NULL COMMENT '订单金额', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已取消 3已使用 4已退款', `pay_time` datetime DEFAULT NULL, `created_at` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_slot_id` (`slot_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预订订单表';这里值得注意的点:
- time_slot表的唯一索引
uk_venue_date_time保证了同一场地同一日期同一时间段只能有一条记录。这就是防重的最底层屏障。 - 为什么不把日期和时间直接写在订单表里?因为时段要先被创建出来,用户只能从已有的时段中选择。这样做的好处是:天然避免了“两个订单时间段交叉重叠”的复杂判断。
- price字段在时段表里冗余了一份,方便做活动价、早鸟价,也避免场地调价后历史订单算错账。
2.3 数据初始化:给场地批量生成一天的时段
启动项目后,一般需要一个“生成排场”的功能,管理员选择某个场地和日期,系统自动按固定时长拆分成多个时段。这个逻辑很像电影院排片:先把可售的“座位时间片”创建出来,用户再买。
最简单的拆分方法:从早上10点到晚上22点,按2小时一段生成。代码示意:
public void generateSlots(Long venueId, String date) { LocalDate bookDate = LocalDate.parse(date); LocalTime start = LocalTime.of(10, 0); LocalTime end = LocalTime.of(22, 0); List<TimeSlot> list = new ArrayList<>(); while (start.isBefore(end)) { LocalTime slotStart = start; LocalTime slotEnd = start.plusHours(2); TimeSlot slot = new TimeSlot(); slot.setVenueId(venueId); slot.setBookDate(bookDate); slot.setStartTime(slotStart); slot.setEndTime(slotEnd); slot.setPrice(venueService.getById(venueId).getPricePerHour() * 2); slot.setStatus(0); list.add(slot); start = slotEnd; } timeSlotService.saveBatch(list); }这段代码看着简单,但有几个容易错的地方:一定要判断这个场地在指定日期是否已经生成过时段,否则重复插入会撞唯一索引;时段跨天问题也要考虑,例如23:00到次日1:00的场次,日期归属应该算开始日期。我这个项目没有做深夜场,所以暂不处理。
3. 用户端核心流程实现:浏览、下单、支付、取消的编码要点
3.1 接口先规划好,不要边写边想
我规划的主要接口如下:
| 功能 | 请求方式 | 接口路径 | 说明 |
|---|---|---|---|
| 场地列表 | GET | /venue/list | 只返回启用状态的场地 |
| 某场地某日时段 | GET | /venue/slots?venueId=&date= | 返回空闲时段 |
| 创建订单 | POST | /order/create | 入参:slotId,userId |
| 模拟支付 | POST | /order/pay | 入参:orderNo |
| 取消订单 | POST | /order/cancel | 待支付或已支付均可取消 |
| 管理员禁用时段 | POST | /admin/slot/disable | 需要管理员权限 |
如果你做了登录,前端还要有注册登录接口。我这里为了突出预订流程,假设已经通过拦截器把当前用户信息放进了ThreadLocal。
3.2 创建订单的事务与行锁
创建订单是整套系统最关键的一个方法。典型错误写法是:先查询时段状态是否为0,再插入订单,再更新时段状态。这样写并发下必出问题。两个请求同时查询到状态为0,然后都插入订单,时段状态被更新两次,但两个订单都关联了同一个时段。
正确做法是把“检查状态+更新状态”合成一条原子SQL,利用受影响行数判断是否抢订成功:
@Transactional(rollbackFor = Exception.class) public Long createOrder(Long slotId, Long userId) { TimeSlot slot = timeSlotMapper.selectById(slotId); if (slot == null || slot.getStatus() != 0) { throw new RuntimeException("该时段不可预订"); } // 原子更新:仅当状态为0时更新为1 int rows = timeSlotMapper.updateStatusByIdAndStatus(slotId, 1, 0); if (rows == 0) { throw new RuntimeException("手慢了,该时段已被抢走"); } // 创建订单 BookingOrder order = new BookingOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setSlotId(slotId); order.setAmount(slot.getPrice()); order.setStatus(0); orderMapper.insert(order); return order.getId(); }对应的Mapper方法用注解实现:
@Update("UPDATE time_slot SET status = 1 WHERE id = #{id} AND status = 0") int updateStatusByIdAndStatus(@Param("id") Long id, @Param("newStatus") int newStatus, @Param("oldStatus") int oldStatus);这里再强调一下:为什么这条原子更新能防超卖?因为它把“读状态”和“改状态”合并为一条数据库指令,InnoDB会锁住这一行直到事务提交。两个事务同时执行时,只有一个成功,另一个更新行数为0,直接抛出“手慢了”的提示。
3.3 支付与取消的状态流转校验
订单创建成功后,用户跳转到支付页面。项目里没有接真实的微信/支付宝,而是用“模拟支付”按钮代替。真正的支付流程应该有回调,但作为学习项目,模拟支付足够展示状态流转。
支付方法同样需要注意原子性:只有“待支付”状态才能变成“已支付”,不能出现已取消的订单被支付成功的情况。
@Transactional public void pay(String orderNo) { int rows = orderMapper.updateStatusByOrderNo(orderNo, 1, 0); if (rows == 0) { throw new RuntimeException("订单状态异常,无法支付"); } // 扣减库存在这里其实已经在创建订单时完成了 orderMapper.updatePayTimeByOrderNo(orderNo, new Date()); }取消订单逻辑要区分是否已经支付:
- 待支付订单取消:直接把时段状态改回0,订单状态改为已取消。
- 已支付订单取消:需要走退款逻辑,把订单变成“退款中/已退款”,同时释放时段。
这里一个小细节:已支付订单取消时,如果这个时段已经开始(比如现在是晚上8点,订单订的是19:00-21:00),应该不允许取消。所以取消前要判断book_date+end_time与当前时间的关系。
4. 并发抢订的防超卖设计:从数据库锁到Redis分布式锁
4.1 为什么synchronized不可靠
很多同学第一步会想到加synchronized锁住createOrder方法。在单实例部署下,它确实能解决同一个JVM内部的线程竞争,但存在两个问题:一是性能差,整个方法被串行化,高峰期所有用户挤在一把锁上;二是只要以后部署多个实例,synchronized就失效了,因为不同Tomcat进程之间互相不感知。
对于单体教学项目,用数据库原子更新已经够用。但如果你想把项目写得更有亮点,或者确实有多实例部署的需求,可以考虑Redis分布式锁。
4.2 Redis分布式锁实现方案
用Redis实现“时段锁定”的典型做法:直接在 set 命令上加锁,释放时用Lua脚本保证原子性。网上有很多现成封装,但要注意不能只依赖SETNX后DEL,因为中间逻辑异常会导致锁永远不释放。
我这里给一个更贴合场景的替代方案:把“锁定”操作放到Redis的value里,key为slot:lock:{slotId},value为订单号。创建订单前,尝试执行:
SET slot:lock:123 5d0e...f9 NX EX 120- NX:只有当key不存在时才能设置成功,相当于抢锁。
- EX 120:锁120秒自动过期,防止持有者崩溃后锁不释放。
- value存订单号,可以在解锁时校验是不是自己的锁。
拿到锁之后,再去查数据库状态、创建订单、更新数据库状态,最后释放锁。释放锁要先用Lua比对value,再删除:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end这种做法比数据库原子更新更“轻”吗?其实没有更快,因为仍然要查一次库、更新一次库,但它把等待锁的冲突前置到了Redis,避免了数据库行锁等待放大。对课程设计来说,能在答辩时讲清楚这两种方案的区别,已经是很不错的加分项。
4.3 验证并发结果:同一时段只放行一个请求
我自己用JMeter做了个简单压测:线程组设置100个并发,全部请求同一个slotId创建订单。结果数据库里只有一条订单记录,时段状态为1,其余99个请求返回“该时段已被抢走”。这个结果就是防超卖最直观的证据。
不过压测时有个容易忽略的坑:如果createOrder方法没有加@Transactional,那条原子更新虽然成功,后面插入订单失败时不会回滚,时段被占用却没有对应订单。所以创建订单这类涉及多表写操作的方法,务必加上事务,并且让RuntimeException触发回滚。
5. 交付前的文档准备:调试文档、LW设计文档、讲解思路
5.1 调试文档写的不是操作流程,而是“环境真相”
很多人觉得调试文档就是“安装JDK、导入数据库、运行Application”,实际交付时才发现根本不够。我这次整理的调试文档包含这几块:
- 开发环境精确版本:JDK 8u202、Maven 3.6.3、MySQL 8.0.27、Redis 6.2。
- 数据库初始化:提供
schema.sql和data.sql,并写清楚如果要重新生成排场数据,需要在后台点哪个按钮,或调用哪个接口。 - 配置文件说明:application.yml里的数据库账号、Redis密码、端口,每一个都标注用途。
- 启动步骤:先启Redis,再启MySQL,最后运行main方法;如果使用IDEA,要说明Lombok插件必须安装,否则编译直接报错。
- 常见错误对照表:例如
Access denied for user、Unknown database、Unable to find a @SpringBootConfiguration分别是什么原因。
调试文档最大的意义是让接手者不必“猜”。你写的时候应该假设对方完全不了解你的项目结构,把坑提前填平。
5.2 LW设计文档的结构要领
LW(设计说明文档)往往比源码本身更影响最终成绩。结构上我按这样的顺序写:
- 项目背景与意义:说明球馆预约手工管理混乱,线上化能提高效率。
- 需求分析:角色分析、功能需求、性能需求(并发人数、响应时间)。
- 系统设计:模块划分、技术架构图、数据库E-R图、表结构说明。
- 详细设计:每个模块的核心类、关键方法、时序逻辑。
- 系统实现与测试:每个功能截图,附上核心代码片段,写清测试用例和结果。
这里要特别注意:不要贴大段完整代码,老师更愿意看到“为什么这么写”。比如并发控制部分,可以画一个抢订时序的表格,对比“不加锁”“数据库原子更新”“Redis锁”三种情况的结果,这比堆代码有说服力得多。
5.3 项目讲解时怎么讲“亮点”
答辩或项目展示时,不要平铺直叙地过功能。我的思路是准备一个“三分钟亮点”:
- 第一分钟:讲项目价值,直击球场管理痛点。
- 第二分钟:讲技术挑战,重点讲并发抢订、状态机一致性。
- 第三分钟:现场演示一个“极端场景”,比如开两个浏览器同时抢同一时段,展示只有一个能成功。
如果你能现场口头画出订单状态流转图(待支付→已支付→已使用/退款),并解释每一步怎么防止不一致,基本就能证明你是真正做过这个项目,而不是只跑了别人的源码。
6. 部署到服务器上常见环境坑与排查路径
6.1 打包启动时容易卡住的三个点
如果你用Maven打包,最先遇到的大概率是测试跑挂。可以跳过测试:
mvn clean package -DskipTests打包成功后,把jar包丢到服务器。启动命令我一般用:
java -jar basketball-venue-system.jar --spring.profiles.active=prod但这里有个经典问题:比如把打包后的jar放到/opt/app/下,如果配置文件里有相对路径(如文件上传目录./upload),Windows下和Linux下的行为会不一样。最好在application.yml里用绝对路径或通过启动参数传入。
还有一个小坑:Spring Boot 2.x默认内嵌Tomcat,不需要额外安装Tomcat。如果你的服务器80端口被占用,记得改配置文件里的server.port。
6.2 数据库无外网连接、时区问题、Redis启动失败
我在调试中碰到最典型的问题是服务器上MySQL端口没对外网开放,或者防火墙限制,导致本地连不上。排查命令:
netstat -tlnp | grep 3306发现3306监听仅供本地后,需要确认安全组和防火墙规则。如果是学习环境,可以暂时简单放行。
另一个高频率问题是时区:MySQL连接串必须加上serverTimezone=Asia/Shanghai,否则会报The server time zone value的异常。同时,保存日期时尽量用LocalDateTime,避免 java.util.Date 在不同时区下出现8小时偏差。
Redis启动失败也很常见,尤其是redis.conf里设置daemonize yes后没有给日志文件权限。启动后立刻用redis-cli ping验证是否返回PONG。
6.3 日志是唯一可信的现场
很多人出了Bug第一步是问别人,其实更高效的手段是看日志。Spring Boot默认日志级别是INFO,如果怀疑某个SQL有问题,可以在命令行加:
--logging.level.com.example.mapper=debug这样MyBatis映射的SQL和参数会直接打印出来。我之前排查一个“查询时段总是空”的问题,就是靠日志发现book_date参数传成了字符串,MySQL做了隐式转换后没走索引,导致索引失效。
汇总一下常见问题对照:
| 现象 | 原因 | 解决 |
|---|---|---|
启动报Access denied for user | 数据库账号或密码错误 | 核对application.yml,测试mysql -u root -p |
| 中文乱码 | 数据库字符集不是utf8mb4 | 建库指定CHARACTER SET utf8mb4,连接串加参数 |
| 刷新页面没数据 | 时段没有生成 | 先调用排场生成接口,确认time_slot表有数据 |
| 点击支付失败 | 订单状态已被取消 | 检查是否设置超时自动取消,看日志中状态值 |
最后说点个人体会
这类基于Java+SpringBoot的篮球场馆预订系统,代码量并不大,但真正把它调试到能稳定运行、文档写到可以让别人接手,其实很考验细节。我最大的体会是:不要跳过并发这一关。哪怕是课程设计,只要你把数据库原子更新那行SQL的原理搞清楚,整个项目的含金量立刻不一样。
如果时间充裕,你还可以继续做这些扩展:把模拟支付换成实际接口、增加场地图片上传、加入用户信用积分、用Quartz定时生成每日排场、甚至把管理端拆成独立的后台。这套系统本质上就是一个“预约+订单+支付”的模型,换到羽毛球馆、健身房、会议室预订,完全能复用。
如果你正在做类似项目,建议先从数据库和状态机开始,不要一上来就写控制层。把“时段状态”和“订单状态”这两条线的流转理清楚,后面写代码会非常顺。遇到并发超卖问题,优先用数据库原子更新,不要迷信复杂分布式方案,简单可靠最重要。