做体育馆预约平台这类项目最怕的不是写代码而是业务逻辑绕来绕去把自己绕晕。尤其是“预约”这个核心场景牵扯到场地状态、用户身份、时段冲突、订单状态流转一环扣一环稍不注意就是一堆bug。我最近正好完成了一个基于SpringBoot2 Vue3 MyBatis-Plus MySQL8.0的体育馆使用预约平台把从0到1的完整过程捋了一遍包括数据库设计、后端核心逻辑、前端页面交互以及中间踩过的那些坑一次性整理出来分享给大家。这套系统说白了就是解决一个很实际的问题让用户能在线看到体育馆还有哪些空闲场次选好场地和时间段直接预约管理员在后台也能高效管理场地和审核预约记录。比起传统的线下登记或人工电话预约这种线上化方式不仅效率高而且所有数据都有记录可查非常适合高校体育场馆、社区健身中心、商业球馆等场景使用。不管你是准备拿来做毕业设计、课程设计还是想练手巩固SpringBoot和Vue3的全栈开发能力这篇文章的内容都够你参考。我会从技术选型的理由讲起逐步拆解数据库建模、预约流程设计、后端接口实现、前端页面交互最后整理一份我在开发过程中遇到的典型问题排查清单。整个项目代码量不算特别大但麻雀虽小五脏俱全覆盖了JWT鉴权、RBAC权限模型、MyBatis-Plus条件构造器、Vue3组合式API等一大堆高频技术点学完绝对不亏。1. 项目整体设计与技术选型思路1.1 核心需求拆解动工之前我习惯先花时间把需求掰开揉碎。体育馆预约平台看起来简单实际操作起来角色和流程都不少我把它拆成了三个主要角色普通用户学生/会员注册登录后可以浏览体育馆内的场地列表查看某个场地在指定日期的空闲时段发起预约申请查看自己的预约记录还能在有必要时取消预约。管理员负责场地信息管理新增场地、编辑场地、设定可预约时段、用户管理、预约记录审核与统计是系统的核心管理者。系统本身需要具备场地时段校验、防重复预约、预约状态流转管理待审核/已通过/已驳回/已取消等底层能力。确定了角色之后功能模块的边界也就清晰了。整体上划分为用户认证模块、场地管理模块、预约管理模块、个人中心模块、后台管理模块。这五个模块贯穿了系统的所有业务线也是后面设计数据库表和接口的蓝图。1.2 技术栈选型与理由我不太喜欢盲目追新但这个项目的技术栈其实挺稳的每一环都经得起推敲直接看下表技术组件版本/方案选型理由后端框架SpringBoot 2.7.x稳定成熟社区生态丰富和MyBatis-Plus配合默契适合快速开发中小型管理系统前端框架Vue 3 Vite组合式APIComposition API写业务逻辑更清爽Vite启动速度快开发体验好持久层框架MyBatis-Plus大幅减少单表CRUD的样板代码内置分页插件和条件构造器效率极高数据库MySQL 8.0性能强劲支持窗口函数、JSON类型等高级特性默认字符集utf8mb4对中文友好鉴权方案JWT Spring AOP无状态鉴权适合前后端分离架构通过AOP实现自定义注解和权限拦截构建工具MavenJava后端最主流的依赖管理和构建工具不折腾这里重点说一下为什么选Vue3而不是Vue2。当前这个时间节点Vue3早就是默认选项了生态里的组件库Element Plus、Vant等都已经全面适配。Vue3的script setup语法写起来比Options API少一大截样板代码特别是用ref和reactive管理状态、用computed处理派生数据逻辑聚合度高阅读性比Vue2好很多。如果你不是被老旧项目绑住新项目直接上Vue3就对了。1.3 系统架构与模块划分整个系统采用经典的前后端分离架构后端只提供RESTful API前端通过HTTP请求异步获取数据。后端我采用的是分层架构controller接收请求、参数校验 ↓ service业务逻辑、事务管理 ↓ mapper数据持久化基于MyBatis-Plus ↓ mysql数据存储各层职责单一开发时能并行推进排查问题时也能顺着调用链快速定位。前端则采用Vue的标准目录结构按业务模块拆分视图组件路由层面用动态路由做权限控制管理员和普通用户登录后看到的菜单是不一样的。2. 数据库设计建模是一切的基础2.1 核心表结构设计数据库设计是整个项目的基石表结构没定好后面写代码会处处别扭。我设计的核心表有五张彼此之间的关联关系非常清晰用户表sys_user存储用户基本信息包含账号、密码BCrypt加密后存储、昵称、手机号、角色标识区分用户和管理员、状态字段。场地信息表venue存储体育馆内的场地信息比如篮球场、羽毛球场、乒乓球台等字段包括场地名称、类型、位置、容纳人数、场地图片、场地介绍。预约记录表reservation_order这是整个系统最核心的表存储每一条预约申请记录。字段包括用户ID、场地ID、预约日期、开始时间、结束时间、状态、预约备注、创建时间。状态字段我用tinyint存储0待审核、1已通过、2已驳回、3已取消。场地时段表venue_slot管理员预设的场地可预约时段比如某篮球场每天8:00-22:00可预约按小时划分时段。操作日志表sys_log记录用户的敏感操作方便排查和数据回溯。2.2 关键字段设计与索引优化数据库设计有几个细节我必须重点提醒都是从实践中踩出来的第一价格和状态字段优先用整数和枚举。价格我直接用decimal(10, 2)避免浮点数计算误差状态字段用tinyint而不是字符串查询效率更高业务代码里用常量或者枚举类做映射。第二时间字段一定要统一。预约日期我用date类型开始和结束时间用time类型后端统一使用LocalDate和LocalTime处理避免java.util.Date在JSON序列化时出现时区偏移问题。这个坑后面细说非常典型。第三索引设计。预约记录表是查询频率最高的表用户查看我的预约需要user_id管理员筛选某天的预约需要venue_id reserve_date这两个组合查询配合索引能大幅提速ALTER TABLE reservation_order ADD INDEX idx_user_reserve (user_id, reserve_date), ADD INDEX idx_venue_reserve (venue_id, reserve_date);2.3 MyBatis-Plus的表映射策略单表CRUD用MyBatis-Plus非常舒服但有几个细节要注意。实体类上加TableName注解指定表名字段上加TableId(type IdType.AUTO)指定主键自增。对于下划线风格的字段名比如reserve_dateMyBatis-Plus默认开启了驼峰映射所以Java实体里直接写成reserveDate就能自动对应上。有一个我现在也坚持的做法是所有表都保留create_time和update_time两个字段配合MyBatis-Plus的MetaObjectHandler做字段自动填充插入和更新时不需要手动set时间值审计信息完整排查问题的时候特别有用。3. 预约核心流程与防冲突设计3.1 预约流程时序拆解这套系统的核心业务流是用户提交预约申请我将完整流程拆解如下前端发起预约请求用户选择场地、日期、时间段点击提交。后端基础参数校验检查场地是否存在、时间段是否在场地可预约范围内。重复预约校验查询该用户在同一日期是否已经预约过该场地防止恶意刷单。库存冲突校验查询该场地在目标时间段是否有状态为已通过或待审核的预约记录交集若有则拒绝。生成预约订单通过所有校验后将预约记录状态置为待审核写入数据库。异步通知管理员可采用事件机制或消息队列项目里用SpringEvent即可通知管理员端刷新待办。3.2 防重复预约的两种核心方案在真正的并发场景下最怕的事情是两个用户同时预约了同一个场地的同一个时间段结果两个人都预约成功了这就是典型的超卖问题。我尝试了两种方案都分享给大家方案一基于数据库唯一索引防重。给预约记录表增加一个联合唯一索引ALTER TABLE reservation_order ADD UNIQUE KEY uk_venue_slot (venue_id, reserve_date, start_time, end_time);这个方案的逻辑是如果同一场地、同一日期、同一时间段的记录已经被插入过第二次插入会被数据库拦截直接报DuplicateKeyException。好处是原子性最强数据库层面兜底坏处是业务逻辑上抛错不够友好需要捕获异常转成业务提示。方案二基于悲观锁或乐观锁控制。在查询空闲时段的SQL上使用SELECT ... FOR UPDATE先锁住场地的时段记录然后再判断是否冲突并插入。这种方式适合并发量极高的场景但在单体应用中会增加锁等待开销。我个人实际采用的是方案一唯一索引 业务前置查重双保险靠前置查重给用户友好提示靠唯一索引做最终兜底两层防护下几乎不可能出现重复预约。3.3 预约时间段冲突判断算法判断两个时间段是否冲突是一个典型的区间重叠判定。假设已存在的预约记录时间段是[existingStart, existingEnd]用户提交的时间段是[newStart, newEnd]两者冲突的条件是newStart existingEnd AND newEnd existingStart用MyBatis-Plus条件构造器写出来很直观// 查找所有状态非已取消且时间段与目标交叉的预约 LambdaQueryWrapperReservationOrder wrapper new LambdaQueryWrapper(); wrapper.eq(ReservationOrder::getVenueId, venueId) .eq(ReservationOrder::getReserveDate, reserveDate) .ne(ReservationOrder::getStatus, 3) // 排除已取消 .lt(ReservationOrder::getStartTime, endTime) // 已有开始时间 新结束时间 .gt(ReservationOrder::getEndTime, startTime); // 已有结束时间 新开始时间这段代码是整个预约链路里最关键的一环。顺带提一句时间段边界值我是用小于/大于而不是小于等于/大于等于这么处理是因为实际场景中常见前一个时段到18:00后一个时段从18:00开始的衔接需求边界完全重合时不应视为冲突。3.4 订单状态机的流转设计预约单不是一套简单的有或无关系而是有一个状态机在控制流转。我设计的流转关系如下待审核0用户刚提交初始状态。已通过1管理员审核通过预约生效。已驳回2管理员驳回说明理由用户可重新预约。已取消3用户在待审核/已通过状态下取消预约已通过的预约取消后释放场地时段。设计状态机的核心原则是状态的每一次变更都要有明确的操作触发和校验规则。比如已驳回的订单不能直接改成已通过已取消的订单不能重复取消。实际开发中我在Service层将所有允许的状态迁移封装成一个校验方法任何非法流转都在这里被拦截避免了上层业务随手乱改状态导致的数据混乱。4. 后端核心业务模块实现要点4.1 JWT鉴权与权限拦截的实现前后端分离项目最常用的鉴权方案就是JWT。我的实现逻辑是这样的用户登录成功后后端生成一个JWT令牌令牌中携带用户ID和角色信息有效期设为24小时。前端把令牌存在localStorage中每次发请求时在Authorization头里带上。后端通过拦截器或AOP切面解析令牌将用户信息存入ThreadLocal供后续业务取用。针对管理员接口增加RequireAdmin自定义注解通过Spring AOP拦截判断当前用户角色是否为管理员。这里要提醒一个实操细节JWT解析失败时必须区分过期和无效两种情况。前者应提示登录已过期请重新登录后者属于非法令牌禁止访问两者返回的HTTP状态码和提示文案不同。不小心处理的话用户明明登录状态正常只是因为令牌过期了一两分钟就会被粗暴地踢下线体验很差。4.2 基于自研注解实现操作日志记录一个管理后台没有操作日志是不完整的。我没有引入额外的AOP框架直接借助Spring自带的AOP实现了一个LogAnnotation注解可以记录操作人、操作类型、请求参数、响应结果和执行时间。实现思路很简单定义注解LogAnnotation(module 场地管理, type 新增)。定义切面类在Around环绕通知中解析注解内容。在环绕方法里记录请求开始时间调用pjp.proceed()执行原方法记录结束时间和返回结果。将日志记录通过Async异步线程池插入数据库避免同步阻塞主业务流程。这个设计既廉价又实用日常排查谁在什么时候改了什么数据一目了然。4.3 MyBatis-Plus条件构造器的一些心得MyBatis-Plus的LambdaQueryWrapper用好了写查询真的是行云流水。但在实际项目中我摸索出几个值得固化的经验多表关联查询不要硬串单表CRUD。虽然MyBatis-Plus提供TableField映射和简单的连表支持但涉及三张以上表关联、临时统计报表时直接在Mapper接口里定义SQL注解或XML反而更清晰可控。比如查询预约列表并带出场地名称和用户昵称这种需求我直接写SQL关联到venue和sys_user表。分页查询只用内置的PaginationInnerInterceptor集成后翻页对象继承PageT即可查出来的数据自带records、total、current、size字段前端分页组件直接对接。警惕select *陷阱。我见过很多新手写代码时直接selectList(null)一把梭把所有字段查出来数据量小的时候没问题字段一多性能就下来了。查询列表时显式选择需要的字段用.select(实体::getField)限定返回列代码可读性和执行效率都能兼顾。4.4 文件上传与图片访问路径处理场地信息里通常会有一张场地图片这里涉及前端上传、后端存储、静态资源映射三个环节。我的做法是后端接收MultipartFile后将文件存储到服务器磁盘的/upload/venue/目录下文件名用UUID重命名防止冲突文件相对路径存入数据库再通过WebMvcConfigurer配置资源映射让/uploads/**请求直接映射到磁盘目录。这个方案的坑在于不同操作系统的路径分隔符。开发环境是Windows路径分隔符是\测试环境是Linux分隔符是/。别硬编码拼接路径记得用File.separator或Paths.get()做跨平台处理否则项目一部署就报找不到文件。5. 前端Vue3实现与交互细节5.1 项目搭建与目录结构前端我使用Vite创建的Vue3项目配合Element Plus组件库和Vue Router路由管理。目录结构按照业务模块划分清晰好维护src/ ├── api/ // 所有接口请求封装按模块拆分文件 ├── assets/ // 静态资源 ├── components/ // 公共组件上传、分页、弹窗等 ├── router/ // 路由配置含动态路由权限控制 ├── store/ // Pinia状态管理用户信息、菜单权限 ├── views/ // 页面视图 │ ├── login/ // 登录页 │ ├── user/ // 用户端页面场地浏览、预约、我的预约 │ └── admin/ // 管理端页面场地管理、预约审核、用户管理 └── utils/ // 工具函数request封装、日期格式化5.2 Axios请求封装与统一错误处理前端和后端交互我封装了一个统一请求工具request.js核心逻辑分为三层请求拦截器从localStorage取出token放到请求头Authorization字段里。响应拦截器判断HTTP状态码若是200则直接返回response.data若是401则清除本地登录信息跳转登录页若是业务错误码则弹出ElMessage提示。统一数据格式后端统一返回{ code, message, data }的结构前端根据code判断业务是否成功而不是HTTP状态码。这样做的好处是页面里的业务代码不需要到处写try/catch请求的错误处理逻辑全部收敛在一个地方维护成本极低。5.3 用户端预约页面的交互设计预约页面是用户体验的核心我做了三个关键交互设计日期选择限制。场地预约通常只开放未来7天所以日期选择器disabled-date函数里排除今天之前的日期和超过未来7天的日期const disabledDate (time) { const today new Date(); today.setHours(0, 0, 0, 0); const maxDate new Date(today.getTime() 7 * 24 * 60 * 60 * 1000); return time today || time maxDate; };时间段动态加载。用户选择场地和日期后前端向后端发送请求获取该场地当天的已预约时段将时间段组件中冲突的时段置灰用户在交互层直接无法选择已被预约的时间从源头上减少了后端冲突校验的压力。状态变更即时反馈。提交预约成功后预约按钮变为加载状态提示提交成功等待管理员审核同时自动跳转到我的预约页让用户清楚看到自己的预约单当前处于什么状态。5.4 管理端的表格与弹窗操作管理端的核心场景是展示预约列表并对预约单做审核处理。表格组件我用了el-table配合el-pagination做服务端分页。审核通过的弹窗里管理员可以填写备注驳回时系统强制要求填写驳回原因这个原因会通过列表展示给用户。在这个环节我踩了一个坑编辑回显时的数据克隆问题。Element Plus的el-dialog初始化时如果将当前行对象直接赋值给表单的reactive数据由于对象引用相同表格里的数据会被弹窗表单直接改掉。处理方式是在打开弹窗时做一次深拷贝const form reactive({ remark: , status: 0 }); const openAuditDialog (row) { // 用展开运算符生成新对象断开引用 Object.assign(form, { id: row.id, remark: row.remark, status: row.status }); dialogVisible.value true; };这个问题如果不处理用户取消弹窗后会发现表格里的数据已经被预修改过了视觉上非常误导。6. 常见问题与排查技巧实录6.1 时区问题日期怎么就多了一天这是我开发中印象最深的一个bug。用户在页面上选择2024-06-15预约结果后端数据库存进去的却是2024-06-14所有预约都提前了一天。排查后发现问题出在JSON序列化和时区设置上。SpringBoot默认使用Jackson序列化LocalDate时如果不配置spring.jackson.time-zoneGMT8时间会按UTC时区处理而前端传过来的日期字符串默认按本地时区解析两个时区一错位日期就偏移了一天。解决办法其实很简单在application.yml里配置spring: jackson: time-zone: GMT8 date-format: yyyy-MM-dd HH:mm:ss排查这类问题时一个关键技巧是先用数据库慢日志或直接查询表数据确认存储值看看问题出在持久层还是传输层从数据链路层层倒查能少走很多弯路。6.2 数据库连接失败Public Key Retrieval is not allowed这个报错几乎每个用MySQL 8.0接SpringBoot的新手都会遇到。连接串里加上allowPublicKeyRetrievaltrue即可解决url: jdbc:mysql://localhost:3306/sports_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue解释一下原因MySQL 8.0默认使用caching_sha2_password加密插件首次连接时需要通过RSA公钥交换密钥JDBC默认不允许自动获取公钥所以需要显式开启。这个参数在安全性要求极高的生产环境也可以开启后用其他加密方式替代但在局域网开发环境里直接加上是最省事的。6.3 跨域问题前端请求被浏览器拦截前后端分离开发时前端跑在localhost:5173后端跑在localhost:8080二者端口不同必然触发浏览器跨域。我在后端加了一个全局配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意这里我用了allowedOriginPatterns而不是allowedOrigins因为allowCredentials(true)配合allowedOrigins(*)在较新版本的Spring里会报错allowedOriginPatterns是更稳妥的选择。6.4 空指针与懒加载跨Session访问子表字段这个问题的典型场景是在Service层查询预约记录列表后在Controller层循环遍历记录希望通过order.getVenue().getName()直接拿到场地名称。结果因为JPA或者MyBatis-Plus的一对多懒加载跨出事务后访问子实体直接抛LazyInitializationExceptionMyBatis-Plus实体关联场景也有类似表现。我的解决方案有两个推荐优先使用第一种方案一在列表查询SQL里通过显式关联查询把需要的字段比如场地名称、用户昵称一次性查出映射到VO对象中避免在实体类里维护对象关联。方案二事务内访问关联字段将数据提前加载到内存避免跨事务调用。这种方法副作用是会增加事务粒度耦合度较高。实际项目里我倾向于写VO类配合联表SQL实体类字段只做单表映射这样数据展示层和持久层是解耦的后续接口加了字段改动也不容易出问题。6.5 接口返回400参数绑定失败很多同学在写RequestBody接收前端JSON时碰到参数类型不匹配比如前端传了字符串给Integer字段、字段名对不上等情况返回400错得很懵。排查这类问题按这个顺序来看前端实际发送的参数体浏览器开发者工具Nevetwork中直接可以查到。对照后端实体类的字段名检查是否因为JSON序列化导致字段名变成下划线风格开启驼峰映射后一般不会。看后端控制台日志SpringMVC会输出详细的参数绑定异常信息具体到哪个字段哪个类型无法转换。另外前后端联调阶段建议统一使用{ code, message, data }结构前端传参统一用JSON而不是FormData可以避免掉很多奇奇怪怪的类型解析问题。7. 项目扩展方向与提升空间主体功能开发完成后我复盘了一遍发现这个项目还有非常大的扩展空间这里一并列出来大家在现有基础上可以继续往下深挖。第一场地状态实时化。当前方案是预约生成后管理员手动审核响应实时性有所欠缺。可以引入WebSocket或者SSE技术当用户提交预约后后端实时推送消息给管理端管理员无需刷新页面就能看到新预约提醒审核效率能明显提升。第二增加消息通知能力。目前用户只能在页面上看到审核结果体验还可以更主动。整合邮箱或短信推送服务如阿里云邮件推送、邮件服务审核通过或驳回时主动发送通知用户感知更友好。第三管理端增加统计报表。基于预约数据可以引入按时段、按场地维度统计预约热度用ECharts做可视化展示帮助场馆运营者掌握核心业务数据让系统从管理工具升级为辅助决策工具。第四引入Redis缓存热点数据。场地列表和可预约时段这类读多写少的数据完全可以放到Redis里缓存减少数据库压力。预约防重校验也可以考虑用Redis的setnx命令做分布式锁为后续多实例部署做铺垫。我个人在实际操作中体会最深的一点是这个项目最大的价值不在于用了多牛的技术而在于把一条完整的业务链路用主流技术栈稳稳地串了起来。从需求拆解、表结构设计到后端服务开发、前端交互实现再到最后的联调上线每个环节其实都能提炼出一堆细节。把这些细节吃透了以后再遇到类似的预约类系统酒店预订、会议室预约、自习室占座你完全可以把这套思路平行迁移过去很快就能上手。最后再分享一个小技巧开发这种前后端分离项目一定要尽早把前后端接口约定固定下来我踩过联调时前端说字段对不上、后端说接口文档没更新的坑来来回回改了好几轮才收敛。现在我的习惯是先让后端把所有接口的{ code, message, data }骨架和VO字段定义清楚前端照着字段名开发联调时基本一遍过。这套流程理顺了整个开发过程会顺畅非常多。