简介:基于Java的羽毛球馆管理系统设计与实现文档,定位是毕业设计/课程设计类参考资源,适合计算机专业学生、初级开发人员及体育场馆信息化建设者阅读。内容围绕场地预约、资源分配、订单支付等业务场景,梳理了从需求分析到系统实现的全流程思路,并涉及用户管理、提醒通知、数据统计、安全控制等关键模块设计。压缩包内共1个docx文件,大小约942KB,包含中英文摘要、关键词及正文内容,结构完整。目前已有446人浏览/学习。透过这份文档,读者可以提炼出完整的系统设计方案、数据库表结构设计思路、前端交互逻辑与后端接口组织方式,还能借鉴论文撰写结构与排版规范,适合快速完成同类管理系统设计或相关学业文档。
1. 羽毛球馆管理系统:一个看起来不起眼的 Java 单体项目,为什么我还要写一篇长文
周末陪朋友去球馆订场地,前台小姑娘还在用 Excel 手动记场次,撞单了就给顾客打电话道歉。这个场景我相信很多人不陌生,羽毛球馆这种中小型场馆,场地少则四片、多则十来片,按小时切时段运营,账目复杂度和酒店不相上下,但因为单价低、管理粗放,多数老板还在靠手记账本和微信群接龙过日子。基于 Java 的羽毛球馆管理系统,就是把这套并行的高并发订场、会员充值、计费结算搬到系统里,让顾客能查场次、订场、办卡,让老板能看报表、锁场地、控价格。
这篇笔记按我实际做这类系统的思路来讲:先立技术选型,再讲数据库怎么拆表,然后落到核心预订和计费代码,最后一章讲部署和运维。适合准备做课程设计、毕设,或者想给自家球馆做低成本管理系统的 Java 从业者。
2. 技术选型与工程结构:Spring Boot + MyBatis-Plus 这套组合怎么搭才不臃肿
2.1 为什么不选微服务,单体项目怎么定位
羽毛球馆管理系统本质是一个低并发、高事务一致性的业务系统,用户量级撑死在几千个会员、几十个管理员。微服务那套注册中心、配置中心、网关在这里纯属负重训练。选 Spring Boot 单体应用就够了,这是业界的共识做法,也符合这类系统的真实落地成本。
技术栈我用的是 Spring Boot + MyBatis-Plus + MySQL + Redis。有人会问,既然并发不高,Redis 还需要吗?需要。订场这事的并发峰值一般都出现在晚上八点整——会员卡着点抢周末黄金时段,或者早上十点整开放新一天的预订。MySQL 行锁能扛,但响应时间会波动,加上 Redis 做一层预订资格预检和分布式锁,能明显减少数据库锁等待。
还有一个工程上的考虑:MyBatis-Plus 可以根据实体类自动生成 CRUD,省掉大量重复 Mapper XML。但我要先泼一盆冷水:这类系统的核心是预约时间片校验(后面细讲),这种定制逻辑 MyBatis-Plus 帮不上忙,必须手写 SQL。所以选型思路要清晰——简单的增删改查交给它,复杂的业务查询自己控制 SQL。
2.2 包结构划分:按领域拆包,不按层拆包
很多同学拿到项目就按 controller / service / mapper 三层建包,结果改一个预订流程要在五个包里来回跳。我常用的是按领域拆包,每个领域内部再分三层,对应代码如下。
com.example.badminton ├── common // 统一响应体、全局异常、枚举 ├── config // Redis、MyBatis、跨域等配置 ├── member // 会员领域 │ ├── controller │ ├── service │ ├── mapper │ └── entity ├── venue // 场地领域 │ ├── controller │ ├── service │ ├── mapper │ └── entity ├── order // 订单领域 │ ├── controller │ ├── service │ ├── mapper │ └── entity └── report // 报表统计这个结构的好处是需求变更发生在领域内部,比如给会员增加一个实名认证字段,只动 member 包;要调整订场规则,只动 order 包,隔离性比按层分包强很多。如果未来系统长大,按领域拆包天然就是拆微服务的雏形。
2.3 统一响应体和全局异常:接口层不写 try-catch
羽毛球馆的前端可能是小程序、H5 或者管理后台,接口风格必须统一。我习惯在 common 包下定义一个 Result 类,所有接口返回这个结构。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> error(Integer code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } }逻辑说明:code 表示业务状态码,200 是成功,4001 是参数错误,5002 是库存不足,5003 是重复请求。message 给前端直接弹窗提示用,data 才是真正的响应数据。这样设计的好处是前端拿到 Result 只需要判断 code 是否为 200,错误提示不用后端写文字再拼接 HTML。
Service 层不必每个方法都包 try-catch,交给全局异常处理器统一兜底。我一般配合 @RestControllerAdvice 使用,捕获业务异常类 BizException、参数校验异常和兜底 Exception,这样代码里只需要在业务失败的地方 throw new BizException("该时间段已被预订"),可读性和维护性都提升一个档次。
3. 数据库设计:会员、场地、订单与场次时间片到底怎么拆表
3.1 核心表结构:六张表讲清楚羽毛球馆的账
羽毛球馆的管理对象主要是会员、场地、订单和计费流水。我习惯拆成六张表:会员表、场地表、场地时段表、预约订单表、充值记录表、消费流水表。
会员表很简单,关键字段是余额 balance 和等级 level,等级决定折扣率。场地表记录场地编号和类型,比如 1 号场、2 号场,类型区分塑胶和木地板。时段表是最关键的表,它把场地按小时切片,比如 1 号场 18:00-19:00 是一行,19:00-20:00 是另一行,每行有价格和状态。订单表关联会员、时段。流水表记录每一笔充值或消费。
这个设计对应到现实场景就是:羽毛球馆按「片场 × 小时」卖时间,不是卖场地本身。如果只建场地表和订单表,每次去数据库用场地ID和时间段查冲突,SQL 写起来既复杂又容易漏索引。把时间段做成独立表,就等于提前把可售商品(每个时段)物化了,预订行为变成对商品行的状态变更,这是这个系统设计上最关键的一个决策。
3.2 场次时间片表:一个场地一天多少片,怎么生成
时段表字段设计如下。
CREATE TABLE `venue_slot` ( `id` bigint NOT NULL AUTO_INCREMENT, `venue_id` bigint NOT NULL COMMENT '场地ID', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `price` decimal(10,2) NOT NULL COMMENT '该时段价格', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态 0空闲 1锁定 2已售', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (`id`), KEY `idx_venue_time` (`venue_id`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场地时段表';逻辑说明:venue_id 加 start_time 建联合索引,查询某个场地某天的可订时段直接走索引。status 三个状态,0 表示还没被预订,1 表示用户正在支付流程中暂时锁定,2 表示支付完成。version 字段是给乐观锁用的,后面说并发时会用到。价格放时段表而不是场地表,因为黄金时段和闲时价格不一样,这样运营调价只需改一行数据。
每天凌晨要生成未来 N 天的时段数据,我定义了一个定时任务。常见做法是用 Spring @Scheduled 每天跑一次,查出每个场地未来第 N+1 天的日期,按营业时间切成小时段批量 insert。生成时要注意跨天,比如球馆营业到凌晨 1 点,那么 23:00-24:00 和 00:00-01:00 两个时段日期标注不同但先后连续,后面订场校验时不能只按日期过滤。
3.3 会员余额与流水表:为什么不做成一只表
会员的余额更新和充值流水、消费流水必须分开。原因在于余额是一个可变状态,流水是不可变记录,混在一张表里每次查询都要统计所有历史记录,数据量大之后性能堪忧,而且对账困难。充值记录表保存充值金额和赠送金额,消费流水表保存每次订场扣了多少钱、余额变动前后快照。
CREATE TABLE `member_account_flow` ( `id` bigint NOT NULL AUTO_INCREMENT, `member_id` bigint NOT NULL, `order_id` bigint DEFAULT NULL COMMENT '关联订单,充值为空', `change_amount` decimal(10,2) NOT NULL COMMENT '变动金额,正负表示', `balance_after` decimal(10,2) NOT NULL COMMENT '变动后余额', `type` tinyint NOT NULL COMMENT '1充值 2消费 3退款', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_member_time` (`member_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员资金流水';逻辑说明:balance_after 存变动后余额,是给对账用的快照。这样哪怕某条流水被误删,还可以从快照反推。订单表里面有余金额和实付金额,退款时新增一条 type=3 的流水并把钱加回余额。整套设计保证了账目可追溯,不会出现余额越扣越乱的翻车事故。
3.4 并发订场的两把锁:Redis 预检 + 数据库乐观锁
订场这类操作天然存在并发:几十个人同时抢同一片场地同一时段。我在系统里做了两道防线。第一道是 Redis 分布式锁,key 设计为 slot:lock:{slotId},抢锁成功的用户才能进入下单流程,这样直接把并发请求串行化,避免同时读到 status=0。第二道是数据库乐观锁,在更新时段表时带上 version 条件,防止在业务时间窗过长时覆盖状态。
这里有个细节值得注意:分布式锁的 key 粒度一定要到具体的 slotId,而不是整个场馆加锁,否则一片场地被人锁住,整个球馆的订场操作都得排队,这完全不现实。Redis 锁要设置过期时间,我一般设为 10 秒,正常下单流程在局域网环境下 1 秒内就能完成,10 秒足够兜底。过期时间太短会在慢查询时误杀正常请求,太长会在服务宕机时拖住其他用户——10 秒是一个实战出来的平衡值。
4. 核心业务实现:从预订场地到会员扣费的完整代码链路
4.1 预订接口:从参数校验到锁库更新的完整逻辑
订场是系统的心脏。我以一个「会员预订场地时段」的接口为例,把代码按业务顺序拆开讲。这里刻意把事务处理和分布式锁分开,先抢锁再开事务,防止事务未提交时锁已释放。
@Transactional(rollbackFor = Exception.class) public Order createOrder(Long memberId, Long slotId, Long venueId) { // 1. 分布式锁预检 String lockKey = "slot:lock:" + slotId; boolean locked = redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException("系统繁忙,该时段正在被其他人预订"); } try { // 2. 查询时段并校验状态 VenueSlot slot = venueSlotMapper.selectById(slotId); if (slot == null || slot.getStatus() != 0) { throw new BizException("该时段已售出或已锁定"); } // 3. 校验会员余额 Member member = memberMapper.selectById(memberId); BigDecimal price = slot.getPrice().multiply(discount(member.getLevel())); if (member.getBalance().compareTo(price) < 0) { throw new BizException("余额不足,请先充值"); } // 4. 乐观锁更新时段 int rows = venueSlotMapper.updateStatusByVersion(slotId, 2, slot.getVersion()); if (rows == 0) { throw new BizException("该时段已被抢购,请更换时间"); } // 5. 创建订单 + 扣减余额 + 写流水 Order order = buildOrder(memberId, slotId, price); orderMapper.insert(order); memberMapper.deductBalance(memberId, price); memberAccountFlowMapper.insert(buildFlow(memberId, order.getId(), price.negate())); return order; } finally { redisLock.unlock(lockKey); } }逻辑说明:步骤 1 先抢 Redis 锁,没抢到直接返回繁忙提示,避免大量请求打到 MySQL。步骤 2 和 3 是前置校验,此时还在锁内,数据状态是安全的。步骤 4 是关键转折点——用 updateStatusByVersion 这个自定义 SQL 同时判断状态和版本号,更新成功返回行数 1,失败说明有其他请求先改了数据,直接报错。步骤 5 的订单、扣余额、写流水在一个事务里,任何一个失败全部回滚,不会出现扣了钱没订单的尴尬。
自定义 SQL 在 Mapper 里对应这么一段。
@Update("UPDATE venue_slot SET status = #{status}, version = version + 1 " + "WHERE id = #{slotId} AND status = 0 AND version = #{version}") int updateStatusByVersion(@Param("slotId") Long slotId, @Param("status") Integer status, @Param("version") Integer version);参数说明:status 传 2 表示已售,version 传查询时的旧值。更新时指定 status = 0,是为了让数据库层面再兜一道,防止程序逻辑漏判导致重复售卖。version + 1 让每次更新都改变版本号,后续并发请求即使拿到旧版本也无法覆盖。
4.2 会员充值与优惠折扣:计算放在哪一层
充值和折扣的逻辑要简单粗暴但不出错。充值接口我支持充 100 送 10、充 300 送 50 这种阶梯规则,在代码里用一个规则表或枚举维护,避免硬编码在业务代码中。
折扣这里有一个容易踩的坑:场地标价是 100 元,黄金会员 8 折,最终金额是 80 元。如果用 BigDecimal 的 multiply 方法乘 0.8,得到 80.000,数据库 decimal(10,2) 会四舍五入存储,但如果在计算过程中不调用 setScale(2, RoundingMode.HALF_UP),可能出现 80.001 这种精度问题,导致余额扣减后对不上账。我的统一做法是:金额计算全部用 BigDecimal,乘完折扣立刻 setScale(2, RoundingMode.HALF_UP),最后再比较余额。
充值流水的另一个细节是赠送金额是否可退。很多球馆的规则是充值赠送部分不能退还现金,只能在场内消费。这需要在流水表加一个 amount_type 字段区分本金和赠送金,退款时优先退本金,这类业务逻辑虽然小,但漏了后面跟老板对账时哭都来不及。
4.3 管理端报表:按天按场馆统计营收
老板最关心的报表无非三个数:今天卖了多少场、充了多少钱、哪些时段卖得最差。写一个按天统计的查询接口,把订单表按日期分组统计。
@Select("SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, " + "COUNT(*) AS order_count, SUM(pay_amount) AS revenue " + "FROM `order` " + "WHERE create_time BETWEEN #{startTime} AND #{endTime} " + "GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') " + "ORDER BY day DESC") List<DailyReport> selectDailyReport(@Param("startTime") String startTime, @Param("endTime") String endTime);逻辑说明:按天分组的报表在数据量几千行的规模下性能完全没问题。如果未来要统计每片场地利用率,再加一个 GROUP BY venue_id 就行。统计口径上要注意 pay_amount 是实付金额,不是订单原价,因为会员有折扣,对账一律以实付为准。这个规则要和财务说清楚,否则月底对不上账就变成玄学问题。
5. 避坑指南:羽毛球馆系统最容易翻车的五个场景和解决方案
5.1 并发超额预订:状态检查与更新不是同一个事务导致卖重
现象:同一个时段被两个会员同时下单,两个请求都查到 status = 0,然后都成功创建了订单,场馆实际卖出了两倍。原因:查询状态和更新状态之间有时间窗口,如果程序里是「先 select 判断,再 update」,天然存在竞态条件。解决:要么在事务里用 SELECT FOR UPDATE 锁行,要么像第 4 章那样用乐观锁配合版本号更新,两条路都能解决。我选乐观锁因为不用长时间占用数据库连接,性能更好。血泪经验是:不要相信单机应用的「低并发」而省略并发控制,抢黄金时段这种场景,并发量比你想的高得多。
5.2 跨天时段:只按日期查时段导致凌晨场次凭空消失
现象:球馆营业到凌晨 1 点,用户在系统里看不到 00:00-01:00 的场次。原因:定时生成时段时,把凌晨的时段归属到了前一天还是后一天,规则不统一;查询时又只过滤了当天,两个逻辑打架。解决:统一规则——所有时段归属于「开场日」,即 00:00-01:00 这个时段归属到开场那天的日期,生成和查询都用 start_time 的日期做过滤条件。这是一个设计约定,写进代码注释里,后面接手的人才能看懂,不然这就是一个查一次翻一次车的经典大坑。
5.3 支付回调与本地事务不一致:用户付了钱,订单还是待支付
现象:用户在微信支付里扣款成功,但系统订单状态还是「待支付」,或者订场成功但回调没更新。原因:支付回调是异步的,如果回调处理中数据库操作失败,本地事务回滚但支付平台那边钱已经扣了。解决:回调处理不能直接在回调方法里写业务逻辑,必须是先落一条支付回调记录,再异步处理状态变更。常见做法是:回调进来先幂等校验,查询订单当前状态,如果已经是已支付,直接返回成功;如果是待支付,更新状态并执行后续扣款操作,整个动作包在事务里,失败则由定时任务轮询未完成订单进行补偿。这类问题一旦发生,用户投诉力度是最大的,因为牵扯到钱。
5.4 金额精度:浮点数计算让余额不翼而飞
现象:会员充值 100 元,打完 8 折订了一场 80 元的场地,余额显示 20 元,但过几天再看变成了 19.99 元。原因:用了 float 或 double 类型存储金额,小数在二进制中无法精确表示,叠加多次运算误差累积。解决:数据库字段一律 decimal(10,2),Java 实体的成员变量用 BigDecimal,禁止 float/double;所有计算必须经过 BigDecimal 并在每个乘法后 setScale 指定精度。这不是一个要不要做的选项,而是做这类计费系统的最低底线。
5.5 事务注解的坑:同类调用导致 @Transactional 失效
现象:createOrder 方法里自己调了本类的另一个 @Transactional 方法,结果中途抛出异常,前面的数据库操作没有回滚。原因:Spring 的事务是基于代理实现的,同类内部的 this 调用绕过了代理,注解完全不生效。解决:事务方法必须通过代理对象调用,常见的做法是把需要事务的方法放到单独的 Service 类中,或者注入自身代理。一个更稳的策略是:事务边界尽量设置在 Controller 调用的最外层 Service 方法上,内层方法不加事务注解,只依赖外层统一回滚。这样结构清晰,也不会出现内部调用失效的问题。
6. 部署与配置:把系统放到服务器上稳定跑起来的三个关键细节
毫秒级订场响应、MySQL 连接池、备份策略、日志检查,这几件事构成了系统上线后是否稳定的分水岭。我通常会先解决部署方式和连接池配置,再谈备份和监控。
部署用常见做法就行:Maven 打成 jar 包,放到一台 4 核 8G 的云服务器上,nohup 启动。这里有一个容易忽略的点——生产环境的 JVM 参数。我一般会显式设置初始堆和最大堆。
nohup java -Xms1024m -Xmx2048m -XX:+UseG1GC \ -jar badminton-system.jar \ --spring.profiles.active=prod \ --server.port=8080 \ > /data/logs/app.log 2>&1 &启动参数说明:-Xms 和 -Xmx 设置堆内存,服务刚启动时默认按需扩张,如果不显式设置,有时会遇到高峰期 GC 频繁导致订场接口变慢,这算是一种运行环境上的隐性问题。-XX:+UseG1GC 是 JDK 8 之后比较稳妥的垃圾回收器选择。日志输出到 /data/logs/app.log,配合 logback 按天滚动,排查问题时不至于翻一个几 GB 的巨型文件。
生产环境的数据库账号绝对不能复用 root,我建一个名为 badminton 的账号,只授予业务库的增删改查权限,最大连接数配 50。Spring Boot 的 HikariCP 连接池参数中有两个值得单独调:maximum-pool-size 配 20 就够了,minimum-idle 配 5。这种业务规模下,连接数配再大也只是空占数据库资源,反而是连接池过小会在定时任务批量写时段时拖慢接口响应。
最后要养成的检查习惯是每天看一眼 /data/logs/app.log 里的 WARN 和 ERROR 数量,以及数据库里有没有大事务长时间锁表。备份用 mysqldump 加 crontab 每天凌晨全量备份,保留 7 天。曾经有一回我发现生产库的 venue_slot 表被误删了未来三天的时段数据,如果不是有备份,整晚都别想睡了。从那之后我意识到,这类系统的安全感和复杂度没有关系,它就来自最土的备份机制。
羽毛球馆管理系统做到这个程度,已经可以支撑一家场馆的完整线上预订和计费业务了。用户在小程序里看场次、下单、扣费,管理员在后台改价、锁场、看报表,每天凌晨定时任务自动生成未来时段的库存。有时候也会想这套系统有没有可能加上 AI 预测未来时段价格,但作为一线工程师,我更倾向于先把基础的数据可靠性和并发订场做扎实——这些才是用户真正会感知的部分。希望这篇文章能帮你在做同类系统时少走几段弯路。
本文还有配套的精品资源,点击获取