Java羽毛球馆管理系统实战:Spring Boot + MyBatis Plus 设计与避坑指南

发布时间:2026/10/9 1:09:18
Java羽毛球馆管理系统实战:Spring Boot + MyBatis Plus 设计与避坑指南
简介一个docx格式的完整技术文档主题为基于Java羽毛球馆管理系统的设计与实现。文档面向计算机相关专业学生、毕业设计或课程设计撰写者以及Web系统开发初学者重点解决羽毛球馆场地资源调度冲突、预约流程繁琐、运营管理效率不高等问题。方案基于Web技术栈前端采用Vue.js构建交互页面后端利用ThinkPHP框架和PHP处理业务逻辑MySQL存储数据围绕用户管理、场地预约、在线支付、订单管理、通知提醒和数据统计分析等模块展开并对权限控制、并发处理与系统安全做了分析。资源包含1个docx文件压缩包大小942KB已有446人学习/下载。读者可通过这份文档快速理清系统整体结构、业务流程与核心功能划分了解前后端交互与数据表设计方案也可作为同类管理信息系统设计说明、技术报告撰写或答辩准备的参考资料。1. 一个 java 羽毛球馆管理系统值不值得自己写羽毛球馆的日常管理比大多数想象中更依赖一套趁手的系统。场地要按小时排期、会员卡要记录剩余时长、教练档期要和场地绑定、每笔订单得算清明码标价和折扣光靠 Excel 或者前台手写登记高峰期一来就是一团乱麻。这里说的 java 羽毛球馆管理系统本质就是一套基于 Java 技术栈的 Web 管理后台把场地、会员、订单、教练这几条核心业务线串成一个可操作的闭环。它的典型场景是前台在电脑上查询今晚 7 点到 9 点哪片场地空闲、帮会员预约并扣费、教练下课之后核销课时管理员在后台看营收和场地利用率报表。适合的人群很明确——正在做 Java 课程设计或毕业设计的同学以及想用最小成本给自家球馆上一套数字化管理的小型场馆经营者。这类系统的难点从来不在增删改查而在“同一个时段不能被两个人订走”和“会员余额在并发下不能算错”这两条才是设计和实现的真正分水岭。2. 技术选型和项目骨架从 Spring Boot 到 MyBatis Plus 的落地组合2.1 为什么不用 JSP Servlet而选 Spring Boot MyBatis Plus早年间的 Java Web 课程设计几乎都是 JSP Servlet JDBC 三件套现在回头看这套组合的硬伤非常明显JSP 里写 Java 代码导致前后端耦合严重JDBC 手动写 ResultSet 遍历既啰嗦又容易漏关连接更别提事务控制全靠手工 commit/rollback。对于羽毛球馆管理系统这种以“预订 结算”为核心的业务一旦在并发预订时出现数据错乱排查成本会直接盖过开发成本。常见的做法是选用 Spring Boot MyBatis Plus MySQL Thymeleaf 这套组合。Spring Boot 负责把配置自动化内嵌 Tomcat 省去单独部署的麻烦MyBatis Plus 提供现成的 CRUD 方法和条件构造器单表操作基本不需要手写 XMLThymeleaf 做服务端渲染适合这种不需要复杂前端交互的管理后台。这套选型的直接好处是核心业务代码可以集中在 Service 层事务注解一加并发问题交给数据库和事务机制去兜底。2.2 初始化项目最小依赖和配置项说明第一步先建一个标准的 Spring Boot 项目pom.xml 里引入必要的依赖。注意不要贪多用到什么加什么否则依赖冲突会让你在启动阶段就翻车。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里 MyBatis Plus 的版本选择 3.5.x 是稳妥的它内置了分页插件和多租户支持后续如果需要扩展也方便。Lombok 用来减少实体类的 getter/setter 样板代码但要注意 IDE 必须装 Lombok 插件否则编译直接报错。然后是 application.yml 的核心配置数据源和 MyBatis Plus 的设置是重点spring: datasource: url: jdbc:mysql://localhost:3306/badminton_arena?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml 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: 0url 里的 serverTimezone 必须显式指定为 Asia/Shanghai否则 MySQL 驱动会用 JVM 默认时区导致时间字段偏移八小时这个问题在场地预订场景里是致命的。logic-delete-field 这段配置先留着后面讲逻辑删除坑的时候会细说。2.3 让 MyBatis Plus 直接从实体类生成建表语句很多人在这一步会去手工写 CREATE TABLE其实 MyBatis Plus 提供了一个冷门但好用的能力根据实体类自动生成建表语句。这个思路特别适合课程设计和快速原型阶段先写 Java 实体再反向生成表结构省去反复修改 SQL 的时间。Data TableName(venue) public class Venue { TableId(type IdType.AUTO) private Long id; private String name; private Integer type; private BigDecimal pricePerHour; private String description; private Integer status; TableLogic private Integer deleted; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; }要让它真正生成表需要单独写一个测试类或者启动时执行的初始化组件调用 MyBatis Plus 的 DDL 生成接口。常见做法是引入 mybatis-plus-generator 的 DDL 模块但这部分配置比较多。如果你不想引入额外的依赖更直接的方式是让实体类保持和表结构一一对应然后用 Navicat 或 IDEA 的 Database 工具从实体反推建表。实际项目里我一般会把 DDL 手工写好放在项目的sql/init.sql里方便其他人直接导入数据库实体类只负责映射。这里的关键是TableName注解显式指定表名避免类名和表名的驼峰转换规则猜错。3. 数据建模把场馆业务规则翻译成表和字段3.1 六张核心表场地、会员、教练、订单、订单明细、操作日志羽毛球馆的业务不算复杂但表结构设计的好坏会直接决定后面写业务代码的顺畅程度。核心表至少有六张场地表venue、会员表member、教练表coach、订单表order_info、订单明细表order_item、操作日志表operation_log。订单主表和明细表分开是必须的因为一个订单可能包含多片场地或多个时间段比如会员一次订两片场地打三个小时这必须拆成多条明细才能准确记录。场地表的核心字段是场地编号、场地类型塑胶/木地板、每小时价格、状态空闲/占用/维护中。这里要注意价格字段建议用 BigDecimal不要用 float 或 double否则结算时会出现 19.999999 这种让人血压升高的数字。会员表的核心字段是姓名、手机号、卡类型、余额剩余时长、开卡时间。余额这个字段尤其要注意它承载的是储值金额还是剩余时长必须在设计阶段就定清楚这份系统我建议按“剩余金额 单位时间价格”来算而不是直接存时长这样遇到调价规则比较好处理。3.2 计费规则设计常规价、黄金时段价和会员折扣场馆定价一般不是单一价格工作日上午和周末晚上往往是两个价。一张计费规则表pricing_rule可以这样设计关联场地类型、生效开始时间、生效结束时间、单价、是否会员价。这样设置的灵活性在于新增一个“周一至周五 18:00-22:00 黄金时段加价 20 元”的规则不需要改任何业务代码只需要往表里插一条记录。查询计费时核心逻辑是“先定位时间点命中的规则再叠加会员折扣”。常见做法是写一个PriceCalculator组件输入场地类型 开始时间 是否会员输出单价。这块的逻辑虽然简单但要注意时间段跨规则的情况比如预订 17:00-20:0018:00 前后是两个价格这时候要么按最高价整单计算要么按小时拆分计费前者是绝大多数小场馆的做法后者则更精细但实现复杂度上了一个台阶。设计阶段建议按“整单统一价”处理用预订开始时间命中的规则作为整单价格避免细拆分带来的大量边界判断。3.3 订单状态机把“待支付、已支付、已取消、已完成、已退款”写进代码里订单状态是这类系统的核心状态机它不能用两个字段凑合也不能让 Controller 里到处 set 状态。我见过太多翻车案例订单在“已取消”状态下还能被调起支付回调或者“已完成”的订单又被管理员误操作改成了“待支付”。正确做法是给订单状态定义一个枚举并在 Service 层限制合法的状态迁移路径。public enum OrderStatus { PENDING_PAY(0, 待支付), PAID(1, 已支付), CANCELLED(2, 已取消), COMPLETED(3, 已完成), REFUNDED(4, 已退款); private final int code; private final String desc; }状态迁移不要在每个 Service 方法里复制粘贴判断逻辑而是抽一个OrderStatusTransition类统一管理哪些迁移是合法的。比如 PENDING_PAY 可以到 CANCELLED 和 PAIDPAID 只能到 COMPLETED 或 REFUNDED其他路径一律抛出业务异常。这个设计看似的多了几个类但在后面排查“为什么订单状态不对”的时候能节省大量时间属于典型的“前期多写一百行后期少排三天错”的投资。4. 核心业务代码场地查询、预订下单、结算扣费4.1 查询可用场地日期 时段 场地状态的三重过滤羽毛球馆的预订逻辑起点是“查询某天某时段哪些场地可用”。这句话听起来简单但落到 SQL 上要同时过滤场地状态和订单占用情况。不能只看场地表里的 status 字段因为场地本身可能是空闲的但已经被某个订单占用了 19:00-20:00 这个时段。所以查询必须排除掉在目标时段内“存在冲突订单”的场地。public ListVenue listAvailableVenues(LocalDate date, LocalTime startTime, LocalTime endTime) { LambdaQueryWrapperVenue venueQuery new LambdaQueryWrapper(); venueQuery.eq(Venue::getStatus, 1); // 仅查询启用状态的场地 ListVenue allVenues venueMapper.selectList(venueQuery); // 找出目标时段内已被占用的场地 ID 列表 LambdaQueryWrapperOrderItem conflictQuery new LambdaQueryWrapper(); conflictQuery.eq(OrderItem::getBookDate, date) .and(wrapper - wrapper .lt(OrderItem::getStartTime, endTime) .gt(OrderItem::getEndTime, startTime)) .in(OrderItem::getStatus, Arrays.asList(1, 2)); // 只有已支付或待支付才算占用 ListLong occupiedVenueIds orderItemMapper.selectList(conflictQuery) .stream().map(OrderItem::getVenueId).distinct().toList(); return allVenues.stream() .filter(v - !occupiedVenueIds.contains(v.getId())) .toList(); }这段代码里的核心是冲突判断条件lt(startTime, endTime).gt(endTime, startTime)。两个时间段交叉的充分必要条件是已有订单的开始时间早于目标结束时间且已有订单的结束时间晚于目标开始时间。这个边界条件很多人会写错写成了startTime 查询开始 AND endTime 查询结束那是“完全包含”而不是“存在重叠”会漏掉大量冲突。另一个参数要点是状态过滤只有待支付和已支付状态才占用场地已取消和已完成的订单不应该参与冲突判定。否则用户取消了一个订单之后场地还是“被占用”的这就是典型的逻辑 bug排查起来非常恼火。4.2 下单流程事务编排和防超卖的关键代码预订下单是整个系统的核心事务扣减会员余额或生成待支付单、生成订单主表记录、生成订单明细记录、把场地状态更新为占用。这四个操作必须在一个事务里任何一个失败都要回滚否则会出现“钱扣了但场地没订上”这种能让前台被顾客骂哭的事故。Transactional(rollbackFor Exception.class) public OrderInfo createOrder(OrderCreateRequest request) { // 1. 检查场地在该时段是否仍可用防并发超卖 int conflictCount orderItemMapper.countConflict( request.getVenueId(), request.getBookDate(), request.getStartTime(), request.getEndTime()); if (conflictCount 0) { throw new BusinessException(该场地在所选时段已被预订请重新选择); } // 2. 计算订单金额 BigDecimal unitPrice priceCalculator.calculate( request.getVenueType(), request.getBookDate(), request.getStartTime(), request.isMember()); BigDecimal totalAmount unitPrice.multiply( BigDecimal.valueOf(Duration.between(request.getStartTime(), request.getEndTime()).toHours())); // 3. 写入订单主表 OrderInfo order new OrderInfo(); order.setMemberId(request.getMemberId()); order.setTotalAmount(totalAmount); order.setStatus(OrderStatus.PENDING_PAY.getCode()); orderInfoMapper.insert(order); // 4. 写入订单明细 OrderItem item new OrderItem(); item.setOrderId(order.getId()); item.setVenueId(request.getVenueId()); item.setBookDate(request.getBookDate()); item.setStartTime(request.getStartTime()); item.setEndTime(request.getEndTime()); item.setUnitPrice(unitPrice); item.setStatus(OrderStatus.PENDING_PAY.getCode()); orderItemMapper.insert(item); return order; }注意第一步的冲突检查是防超卖的第一道防线但它只是一个预检真正兜底的是第二步查出来的冲突在并发情况下仍然可能被穿透。要彻底解决并发预订同一时段的问题必须在数据库层面加唯一约束或者锁。常见做法是在 order_item 表上建一个唯一索引字段组合为(venue_id, book_date, start_time)。这样即便两个请求同时通过了业务层检查数据库的索引也会强制第二个插入失败ALTER TABLE order_item ADD UNIQUE INDEX uk_venue_time (venue_id, book_date, start_time);这里有个实际取舍MySQL 的唯一索引在多个数据库版本里对 NULL 的处理有差异。如果你的 order_item 表里存在软删除记录逻辑删除字段 deleted 标记删除唯一索引依然会被已删除的记录阻塞这是后面避坑章节会展开讲的经典问题。4.3 会员结算余额扣减和事务一致性会员储值卡结算的逻辑是从会员账户余额里扣除本次消费金额同时把订单状态从待支付更新为已支付。这里最关键的是扣减操作必须使用数据库层面的原子更新不能在 Java 代码里先查询余额再计算新余额再更新。Transactional(rollbackFor Exception.class) public void settleByBalance(Long orderId, Long memberId) { // 1. 原子扣减余额条件带上 balance amount 防止扣成负数 int rows memberMapper.deductBalance(memberId, orderAmount); if (rows 0) { throw new BusinessException(会员余额不足请先充值); } // 2. 更新订单状态 LambdaUpdateWrapperOrderInfo updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(OrderInfo::getId, orderId) .eq(OrderInfo::getStatus, OrderStatus.PENDING_PAY.getCode()) .set(OrderInfo::getStatus, OrderStatus.PAID.getCode()); orderInfoMapper.update(null, updateWrapper); // 3. 写入流水记录 accountLogMapper.insert(buildLog(memberId, orderId, orderAmount.negate())); }对应的 SQL 在 MemberMapper 里是这样写的update iddeductBalance UPDATE member SET balance balance - #{amount} WHERE id #{memberId} AND balance #{amount} /update这里的关键细节是SET balance balance - #{amount}这是数据库行级原子操作天然避免并发扣款时的丢失更新问题。更新语句影响行数为 0 时说明余额不足或会员不存在直接抛异常让事务回滚。LambdaUpdateWrapper里的eq(OrderInfo::getStatus, PENDING_PAY)是乐观锁的廉价替代方案只有订单还是待支付状态时才允许更新为已支付防止重复支付回调把订单状态搞乱。5. 避坑与排查羽毛球馆管理系统最常见的 5 个翻车现场5.1 时间错位场地明明订的是 20:00库里却存成了 12:00现象用户在前端选择 20:00-22:00提交后后台查到的 start_time 变成了 12:00整整差了八个小时导致场地预订全部错位。原因JDBC 连接串没有指定 serverTimezoneMySQL 驱动默认使用 JVM 时区而数据库连接会话时区又是 UTC前端传来的 LocalDateTime 被转换时发生了八小时的偏差。这类问题在容器部署Docker 时区为 UTC时更隐蔽因为本机开发正常一上服务器就出问题。解决在 JDBC 连接串里显式加上serverTimezoneAsia/Shanghai并且 MySQL 容器启动时挂载/etc/localtime。另外一个排查技巧在配置里打开 MyBatis SQL 日志log-impl: StdOutImpl看 SQL 里实际绑定的参数值一秒就能看出时间有没有偏移。这是这类系统里最防不胜防的玄学问题遇到时间对不上先查时区不要急着怀疑业务代码。5.2 并发预订同一片场地同一时段被两个人同时订走现象线上预订开放瞬间两个用户同时提交同一片场地 19:00-20:00 的订单两个都提示预订成功数据库里出现了两条冲突的明细记录。原因业务代码只做了“先查询再插入”的应用层检查这个检查不是原子的。两个并发请求同时查到没有冲突然后同时插入数据库默认隔离级别可重复读下这个“幻读”窗口是真实存在的。解决给order_item表加上(venue_id, book_date, start_time)的唯一索引让数据库在物理层面拒绝第二条冲突记录。插入时捕获DuplicateKeyException转成“该时段已被预订”的友好提示。这里要特别强调业务层的冲突检查不能删除它承担的是提前发现冲突、减少无效写入的作用唯一索引才是最终防线。5.3 逻辑删除和唯一索引互相打架订单被取消后场地永远订不了现象用户取消了某笔订单管理员在后台能看到订单状态已经变成“已取消”但重新预订同一个场地同一个时段系统提示“该时段已被预订”。原因项目开启了 MyBatis Plus 的逻辑删除取消订单不是物理 DELETE而是把deleted字段置 1。但唯一索引仍然把这条逻辑已删除的记录算作存在新的插入请求在数据库层面被这条“幽灵记录”卡住了。解决最简单的方案是从设计上绕开唯一索引不要建在可能被软删除的业务表上而是建在一张独立的“场地排期表”venue_schedule上预订成功时插入一条排期记录取消和完成都走物理删除或状态更新。如果非要在 order_item 上做那就把逻辑删除改成物理删除只保留订单主表的状态为“已取消”。从长期维护角度看业务表尽量别用逻辑删除审计需求交给独立的 operation_log这才是干净的做法。5.4 金额精度10 元三次计费算出 30.000000000000004现象一笔三个小时的订单单价 10 元total_amount 显示成 30.000000000000004数据库里存了 30.00但 Java 里计算过程中出现了浮点误差。原因代码里用 double 或 float 做金额计算。10.0 * 3在二进制浮点表示下不是精确值累加多次后误差就显现了。解决所有金额字段在 Java 里一律用 BigDecimal数据库字段用DECIMAL(10, 2)计算时用BigDecimal.multiply并指定精度和舍入模式。这条属于老生常谈但每次查这种问题都要花大半天教训是从实体类定义的第一天就坚持 BigDecimal不要在代码里出现任何double price的写法。5.5 跨天时段23:00-01:00 的订单到底算哪天现象有用户预订 23:00 到次日 01:00 的场地下单界面显示成功但查询“明天的订单列表”时找不到这条记录。原因book_date存的是开始日期但结束时间已经跨到第二天。所有按日期查询的逻辑只用book_date 今天过滤跨天订单的明细落在了“过去时间”从而被过滤掉。解决在业务规则上直接定死预订的结束时间不能超过当天 24:00即不允许跨天预订。如果确实要支持夜场连打那查询条件不能只按book_date而要改为(book_date 查询日期 AND start_time 查询结束时间)或者用“开始时间 结束时间”同时做边界判断。从实际场馆运营出发绝大多数球馆 23:00 之后是不营业的直接禁止跨天预订是性价比最高的选择在前端表单做校验即可无需为极小概率场景把查询逻辑复杂化。6. 最后聊一个很多人忽略但非常实用的收尾技巧给你的系统加上一个简单的操作日志切面。用 Spring AOP 拦截 Controller 层的写操作把操作人、操作类型、参数摘要和结果异步写入 operation_log 表。这个方法不需要引入任何额外的中间件代码量在三十行以内却能在验收演示和运维排障时提供巨大帮助——比如有人误改了会员余额你可以在日志里看到是谁、什么时间、改了哪些字段。在课程设计和采购验收时评委或甲方问“这个系统有没有审计能力”你能当场翻出记录这种细节带来的信任感远超任何功能列表。另外一个被问得很多的点是验证方法不要只在浏览器里点两下就算测完。我一般会写一个 JUnit 集成测试模拟两个线程并发预订同一片场地断言最终只有一条成功。这个测试不仅能在答辩时说清楚系统的并发能力更重要的是它能持续防止后续改动把防超卖逻辑改坏。测试跑通之后再手动验证三条核心路径会员余额支付、前台现金结账、取消订单退款这三条路径覆盖了这个系统的全部资金流转只要它们都是对的系统的骨架就是稳的。这个习惯是我在某次上线前夜查出一个跨天订单 bug 之后养成的从此每个管理类项目都会先写好这几个用例再往下走。希望帮到你。本文还有配套的精品资源点击获取