SpringBoot+Vue3+MyBatis体育馆预约系统前后端分离实战与并发控制
做体育馆预约系统这个项目我前后折腾了大概三周时间从最开始的需求梳理到最终部署上线踩了不少坑也积累了一些还算实用的经验。正好最近有不少人问起这套基于 SpringBoot Vue3 MyBatis 的前后端分离方案我就把整个实现过程、核心设计思路和实际遇到的问题整理成这篇总结希望能给正在做类似管理系统的朋友一些参考。这类预约系统本身不算复杂但麻雀虽小五脏俱全涉及到用户认证、场地管理、预约时间冲突检测、后台管理等典型业务模块。而且体育馆预约还有一个比较特殊的点场地资源是有限的同一个时间段不能被重复预约这就需要在业务逻辑层面做好并发控制。除此之外前后端分离架构下的接口设计、跨域处理、权限控制以及 MyBatis 多表关联查询这些细节都是实际开发中绕不开的坎。这篇文章适合谁看如果你正在学习 Java 全栈开发或者毕业设计、个人项目想做一个类似的预约平台又或者公司内部需要一套简单的场地预订管理系统那这篇文章的内容应该会对你有直接帮助。1. 项目全貌与技术选型思路1.1 核心需求拆解开始写代码之前先把需求理清楚。体育馆预约平台最核心的诉求就三个用户能查场地、能订场地、管理员能管场地。围绕这三个核心点再延伸出用户注册登录、场地分类管理、预约订单管理、个人预约记录等功能。我把系统拆成了两个端用户端和管理端。用户端面向普通使用者功能包括注册登录、浏览场地列表、查看场地详情、发起预约、查看自己的预约记录、取消预约。管理端面向场馆运营人员功能包括场地信息维护、预约订单审核或管理、用户管理、数据统计比如场地使用率。这里有一个容易被忽视的需求场地是有类型的比如篮球场、羽毛球场、乒乓球室。不同类型的场地计费方式和时间规则可能不一样所以场地表需要设计类型字段并且预约逻辑要能适配不同场地的规则。我最初设计时把场地类型硬编码在代码里后来发现每加一种场地类型就要改代码、重新部署非常被动。改造后把场地类型独立成一张字典表新增类型只需要在后台维护数据代码完全不用动。1.2 为什么选这套技术栈后端选 SpringBoot 是因为它确实是 Java 领域最适合快速搭建业务系统的框架内嵌 Tomcat、自动配置、起步依赖这些特性让开发效率提升很明显。MyBatis 作为持久层框架SQL 由自己掌控面对预约系统这种关联查询比较多的业务场景很灵活。前端选 Vue3 是因为组合式 API 和响应式系统让组件复用和状态管理比 Vue2 时代顺手太多而且现在前端生态基本都已经切换到 Vue3相关组件库的兼容性也成熟了。前后端分离架构在这个项目里的收益很直接用户端和管理端虽然功能不同但可以共用一套后端接口前端团队和后端团队可以并行开发后期如果要出小程序端或者移动端后端接口可以直接复用只需要开发新的前端展示层就行了。MySQL 作为数据库存储核心业务数据选它主要是看重事务能力和稳定性。预约操作涉及创建订单、锁定场地时间等步骤必须保证原子性MySQL 的 InnoDB 引擎在这方面表现可靠。提示如果你的项目预约量极大比如大型体育场馆每天几千单那后续可能需要引入 Redis 做分布式锁和缓存。但对大多数场景来说MySQL 配合正确的事务和锁机制已经足够过早引入分布式组件反而增加复杂度。2. 数据库设计与核心表结构2.1 设计原则先理清业务关系数据库设计这一步决定后面写代码的顺畅程度我建议动手建表之前先把实体关系图画清楚。这个系统里的核心实体包括用户、场地、场地类型、预约订单。关系很简单一个用户可以有多条预约记录一个场地可以被多条预约记录引用预约记录关联到具体场次和时间段。在设计时我遵循了几个原则公共字段统一创建时间、更新时间、逻辑删除标记金额用 decimal 不用 float状态字段用 tinyint 并写清楚每个值的含义所有表的主键用自增 id 保持简单。2.2 用户表用户表承载认证和基础信息。我保留了用户名、密码、手机号、角色等字段角色字段我用的是role用 0 表示普通用户、1 表示管理员。密码存的是 BCrypt 加密后的密文这一点尤其重要千万不要明文存储密码。CREATE TABLE user ( id int NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, phone varchar(20) DEFAULT NULL COMMENT 手机号, role tinyint NOT NULL DEFAULT 0 COMMENT 角色 0-普通用户 1-管理员, status tinyint NOT NULL DEFAULT 1 COMMENT 状态 1-正常 0-禁用, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime NOT NULL COMMENT 更新时间, deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除 1-已删除, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;用逻辑删除字段deleted是我个人的习惯业务数据不要物理删除后期排查问题、统计数据都方便得多。查询的时候需要留意几乎所有业务查询都要带上deleted 0条件否则会查出脏数据。2.3 场地表与场地类型表场地表我命名为venue。字段除了名称、位置、描述这些基础信息外比较关键的是type_id关联场地类型表、price每小时价格、status场地状态。场地状态我做了两个层面的区分一个是场地本身的启用状态比如维修中应该不能预约另一个是场地是否被占用这个是动态的、要在预约逻辑里去判断不适合放在场地表里。场地类型表venue_type就简单了就是类型名称和描述。为什么单独拆表因为在真实运营中场馆会不断调整可以预约的场地类型拆出来之后管理端增加类型很方便不需要动代码。CREATE TABLE venue ( id int NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 场地名称, type_id int NOT NULL COMMENT 场地类型id, location varchar(200) DEFAULT NULL COMMENT 位置描述, description text COMMENT 场地介绍, price decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 每小时价格, status tinyint NOT NULL DEFAULT 1 COMMENT 状态 1-启用 0-停用, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime NOT NULL COMMENT 更新时间, deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_type_id (type_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场地表;price字段用 decimal(10,2)因为涉及金额计算float 的精度问题在这种场景下会带来麻烦这属于不该省的细节。2.4 预约订单表预约订单表是整个系统的业务核心设计上要重点考虑时间冲突检测和状态流转。CREATE TABLE reservation ( id int NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id int NOT NULL COMMENT 预约用户id, venue_id int NOT NULL COMMENT 场地id, reserve_date date NOT NULL COMMENT 预约日期, start_time time NOT NULL COMMENT 开始时间, end_time time NOT NULL COMMENT 结束时间, total_price decimal(10,2) NOT NULL COMMENT 总金额, status tinyint NOT NULL DEFAULT 0 COMMENT 状态 0-待确认 1-已确认 2-已取消 3-已完成, remark varchar(500) DEFAULT NULL COMMENT 备注, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime NOT NULL COMMENT 更新时间, deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_venue_date_time (venue_id, reserve_date, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约订单表;订单编号order_no我建议自己生成格式可以是日期时间戳随机数不要依赖数据库自增 id 对外暴露。因为订单号会展示给用户用自增 id 很容易让人家猜到你的业务量而且一旦做分库分表自增 id 还会有冲突问题提前用自定义订单号后续扩展省心很多。联合索引idx_venue_date_time是我在实践中最满意的一个设计。因为预约查询最频繁的 WHERE 条件就是查某个场地在某天某个时间段是否空闲这个索引能大幅加快冲突检测的查询速度。刚起步数据量小可能感觉不明显但数据积累到几万条之后效果差异就有了。2.5 索引设计的经验之谈索引不是越多越好这一点我深有体会。前期开发时为了省事所有查询条件的字段都加了索引结果数据量小的时候看不出问题一旦数据量上来每次插入都要维护多个索引写性能明显下降。索引设计的核心原则是用 EXPLAIN 看实际查询的执行计划让索引为实际业务查询服务而不是为所有可能的查询服务。3. 后端接口设计与核心业务实现3.1 工程分层与包结构后端这块我用的是标准的分层架构Controller 层负责接收请求和参数校验、Service 层负责业务逻辑、Mapper 层负责数据库操作。另外抽了一层 DTO 和 VODTO 用于接收前端传来的参数VO 用于返回前端展示的数据。一开始图省事直接用实体类接收参数、返回实体类结果接口给前端的数据不是多了字段就是少了字段最后还是要加 DTO/VO反而折腾了两遍。包结构大致是这样com.example.gym ├── controller │ ├── AuthController.java │ ├── VenueController.java │ └── ReservationController.java ├── service │ ├── UserService.java │ ├── VenueService.java │ └── ReservationService.java ├── mapper │ ├── UserMapper.java │ ├── VenueMapper.java │ └── ReservationMapper.java ├── entity │ ├── User.java │ ├── Venue.java │ └── Reservation.java ├── dto ├── vo ├── common │ ├── Result.java │ ├── ResultCode.java │ └── GlobalExceptionHandler.java └── config ├── WebConfig.java └── SecurityConfig.java统一的返回结果Result类是前后端协作的基石。我定义的返回结构包括code、message和data三个字段code 为 200 表示成功其他为业务错误码。这个结构虽然简单但保证前后端沟通时的协议一致性非常关键。3.2 认证与权限控制的实现方案认证方案我没有引入 Spring Security 全家桶对这类中小型项目来说JWT 拦截器是更轻量、更直观的解法。用户登录成功后后端签发一个 JWT 令牌前端后续请求在请求头里带上Authorization: Bearer token后端通过拦截器解析令牌识别用户身份。JWT 的结构分三部分Header、Payload、Signature。Payload 里我放了用户 id、用户名、角色这些非敏感信息。密钥放在配置文件里实际项目一定要用足够强度的密钥并且不要在代码里硬编码。拦截器校验的逻辑比较直接public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口等白名单路径 String path request.getRequestURI(); if (path.contains(/api/auth/login) || path.contains(/api/auth/register)) { return true; } // 从请求头获取token并校验 String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { // 解析失败返回401 } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } }拦截器里放行路径用白名单方式管理比如登录、注册、场地列表查询这些公开接口。管理端的接口比如新增场地、修改场地还需要校验角色是否为管理员这一步我在 Service 层里通过工具方法获取当前登录用户信息再判断角色。3.3 预约冲突检测的核心逻辑预约系统的灵魂就是冲突检测。判断逻辑其实不复杂查一下同一个场地在同一个日期下有没有时间段和当前预约时间段重叠的记录如果存在且状态不是取消和已过期就判定为冲突。SQL 写法我调了几版。最初我写过很复杂的时间段比较 SQL后来发现最干净的做法是这样select idselectConflictCount resultTypeint SELECT COUNT(*) FROM reservation WHERE venue_id #{venueId} AND reserve_date #{reserveDate} AND status IN (0, 1, 3) AND deleted 0 AND start_time lt; #{endTime} AND end_time gt; #{startTime} /select这个 SQL 的思路是两条预约如果时间段有重叠必然满足新预约的开始时间小于已存在的结束时间且新预约的结束时间大于已存在的开始时间。这个判断比我一开始用的start_time BETWEEN那种写法要严谨因为BETWEEN只判断了单边漏掉了已存在的预约完全包含新预约这种情况。这里用严格小于和严格大于边界情况比如一段预约的结束时间恰好等于另一段的开始时间按业务规则这种不算冲突是正确的。除了业务层面的冲突检测数据库层面我还要说几句。如果你对并发要求比较高比如高峰期很多人同时抢同一个场地光靠业务层先查询再插入是有问题的并发情况下可能两个请求都查到没有冲突然后都插入成功就产生数据脏读。解决办法是用数据库锁在插入前对场地记录加锁或者用唯一约束辅助。对体育馆预约来说最直接可靠的是在插入预约记录时使用SELECT ... FOR UPDATE锁住场地记录保证同一时间只有一个事务在操作同一场地的预约。但FOR UPDATE要谨慎用锁的粒度控制不好容易导致死锁。我的做法是锁的获取顺序必须统一所有事务都先锁定场地表记录再插入预约记录这样就能避免死锁问题的发生。3.4 MyBatis 多表关联查询的实践预约列表页面通常要展示场地名称、场地类型、用户信息等关联数据这就涉及多表查询。MyBatis 里有两种思路一种是JOIN查出所有字段然后手动映射到 VO另一种是分步查询。我项目里用的是 JOIN 方案因为数据量不大一次查出来效率更高代码也更简洁。select idselectReservationDetailList resultTypecom.example.gym.vo.ReservationVO SELECT r.id, r.order_no, r.reserve_date, r.start_time, r.end_time, r.total_price, r.status, v.name AS venue_name, vt.name AS venue_type_name, u.username AS user_name FROM reservation r LEFT JOIN venue v ON r.venue_id v.id LEFT JOIN venue_type vt ON v.type_id vt.id LEFT JOIN user u ON r.user_id u.id WHERE r.deleted 0 ORDER BY r.create_time DESC /select这里用LEFT JOIN而不是INNER JOIN是因为某些关联数据可能被逻辑删除或者不存在但仍然要把预约记录展示出来。resultType直接映射到 VOVO 里的字段加了别名之后与查询列名称对应省掉了写resultMap的繁琐。不过要注意如果查询列多、字段映射关系复杂写resultMap更清晰这个要看实际情况权衡。4. 前端 Vue3 的实现要点4.1 项目结构与关键依赖前端我用 Vite 构建工具创建 Vue3 项目状态管理用的 Pinia路由用的 Vue RouterUI 组件库用的 Element PlusHTTP 请求用的 Axios。这套组合是目前 Vue3 生态比较主流的搭配资料多遇到问题好查。项目结构大致如下src/ ├── api/ // 接口请求封装 │ ├── auth.js │ ├── venue.js │ └── reservation.js ├── assets/ ├── components/ │ └── VenueCalendar.vue // 场地日历组件 ├── router/ │ └── index.js ├── stores/ │ ├── user.js // 用户状态 │ └── venue.js // 场地状态 ├── views/ │ ├── Login.vue │ ├── Register.vue │ ├── venue/ │ │ ├── VenueList.vue │ │ └── VenueDetail.vue │ ├── reservation/ │ │ ├── ReservationList.vue │ │ └── ReservationCreate.vue │ └── admin/ │ ├── AdminVenue.vue │ └── AdminReservation.vue └── utils/ └── request.js // Axios 实例封装request.js里做 Axios 封装是值得用心的地方。我在里面配置了基础 URL、超时时间、请求拦截器加 token、响应拦截器统一处理业务错误码和 HTTP 401 状态。import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )统一错误处理放到拦截器里业务代码就不用到处写try catch和ElMessage.error了代码干净很多。4.2 预约时间选择组件的设计场地预约页面最核心的交互是选日期、选时间段。我封装了一个VenueCalendar组件展示 7 天内的场地空闲时段。这个组件要做的事是接收场地 id根据所选日期从后端获取该场地当天已经被预约的时间段列表然后渲染时间格子已预约的格子置灰不可选。后端返回已预约时间段列表的接口是这样的GetMapping(/reserved) public Result getReservedTimes(RequestParam Long venueId, RequestParam String date) { return success(reservationService.getReservedTimes(venueId, date)); }前端拿到时间段列表后生成一天的可用时间格子。假设场地开放时间是 9:00 到 21:00按每小时一个格子来分就有 12 个格子。遍历书时间段把与预约时间重叠的格子标记为不可用。这里要注意时间比较的操作JavaScript 中字符串格式的时间不能直接比较要先转成Date对象或者统一转成当天的时间戳。用户选择一个开始时间格子后系统默认预约一小时。如果想让用户自己选择时长就需要做连续多选或者下拉选择时长然后计算结束时间同时要把跨整点的场景考虑进去。我做的时候发现这个逻辑不难但边界情况比较多比如选择了 17:30 开始、时长 2 小时结束时间是 19:30而预约记录里的时间段不是整点后面的冲突检测 SQL 是支持任意时间段的这个没问题。4.3 路由守卫与权限控制前端也要做路由级别的权限控制。用户在未登录状态下直接访问预约页面应该被重定向到登录页。我用 Vue Router 的全局前置守卫实现router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) return } if (to.meta.role to.meta.role ! admin) { const user JSON.parse(localStorage.getItem(userInfo) || {}) if (user.role ! 1) { next(/) return } } next() })路由守卫只是一个前端交互层面的限制真正防止越权还要靠后端接口的角色校验这一点要时刻记住。前端守卫影响体验后端校验保证安全两层各司其职。4.4 跨域问题的环境配置前后端分离开发时跨域是最基础但也最容易卡住新手的问题。我提供两种方案二选一即可。第一种是后端全局配置跨域在 SpringBoot 中写一个配置类实现WebMvcConfigurer的addCorsMappings方法允许所有来源和方法。第二种更推荐前端用 Vite 的代理功能在vite.config.js中配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }生产环境再把前端的/api请求通过 Nginx 反向代理到后端服务这样前后端用的是同源地址省去跨域处理也更安全。开发环境用 Vite 代理、生产环境用 Nginx 代理全程不需要在后端单独配置跨域是我比较推荐的做法。5. 数据库设计、并发控制与部署上线5.1 数据库初始化与种子数据数据库表结构我用一个init.sql脚本统一管理里面除了建表语句还会插入基础数据管理员账号密码用 BCrypt 加密、场地类型数据、几块演示场地。这看起来是个小细节实际作用很大。团队成员拉下来代码后第一步执行脚本就能跑起来完整环境不用一个个手工插入数据。脚本一定要用 Git 管理每次表结构变更都同步维护这样能保证所有环境的表结构一致。我在这个项目上吃过亏开发环境改了一个字段测试环境没同步联调时查了半天才发现是表结构不一致。5.2 模拟高并发预约场景的测试我在本地用 JMeter 模拟了 100 个并发请求同时预约同一场地同一个时段目的是验证冲突检测是否可靠。第一版代码没有加数据库锁果然出现了重复预约成功的情况。加了SELECT ... FOR UPDATE之后重新测试就没有这个问题了。这个测试过程很值得做因为直觉上觉得逻辑没问题但真正并发场景下经常暴露问题。测试时需要注意本地机器模拟并发的效果有限。有条件的话在服务器上做压测更准确。我在本地测的时候发现 MySQL 的并发连接数默认是 151100 个并发请求有些会被排队这是正常现象不代表系统有问题。5.3 打包与部署要点后端打包用 Maven 的package命令生成 jar 包后用java -jar启动。我在部署时用了一个application-prod.yml配置文件里面设置生产环境的数据库连接串、JWT 密钥和日志级别。spring: datasource: url: jdbc:mysql://localhost:3306/gym_reservation?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: xxx driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai jwt: secret: your-strong-secret-key-here expire-hours: 24我踩过一个时区的坑。服务器上配置数据库连接时如果serverTimezone没设置或者设置了跟业务时区不一致的时间从数据库查出来的时间可能会差 8 个小时。排查半天发现是时区配置问题所以建议这里明确用Asia/Shanghai不要依赖系统默认时区。前端打包就是npm run build产物是dist目录下的静态文件部署到 Nginx 就完事。Nginx 配置需要做几件事托管静态文件、把/api开头的请求反向代理到后端 8080 端口、配置 history 路由模式的 fallback不然刷新页面会 404。server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }5.4 常见问题与排查思路我把自己做这个系统时遇到的典型问题整理了一下方便你参考。问题现象可能原因排查方式前端请求接口一直 404Vite 代理配置错误或者路径拼接不对先在浏览器直接访问后端接口确认后端通再检查代理路径和请求路径查询出来的时间与数据库实际存储相差 8 小时数据库连接串缺少 serverTimezone 参数统一在 JDBC URL 中指定 Asia/Shanghai并检查应用服务时区登录后请求接口返回 401token 未正确传递或者过期检查请求拦截器是否在 headers 中携带了 Authorization并发预约同一时间段都成功业务层查询后插入没有加锁使用SELECT ... FOR UPDATE锁住场地记录后再插入预约前端刷新页面 404Nginx 未配置 history 模式 fallback增加try_files $uri $uri/ /index.html数据库中文乱码表或连接字符集不是 utf8mb4建库时指定 utf8mb4并在 JDBC URL 中加入 characterEncodingutf8Element Plus 组件样式不生效组件库版本跟 Vue 版本不匹配检查 package.json 中 vue 和 element-plus 的版本兼容性5.5 上线前需要检查的安全清单功能做完只是第一步上线前我按这个清单过了一遍密码是否全部 BCrypt 加密存储JWT 密钥强度是否足够管理端鉴权是否在每个写操作都校验了角色SQL 是否都用了预编译参数没有字符串拼接敏感配置如数据库密码是否写入了外部配置文件而不是代码仓库日志中是否可能输出 token 或密码信息。这些点不复杂但漏掉任何一个都可能在生产环境出安全问题。6. 从做项目的角度聊几句经验这套系统从零到上线完整走了一遍之后我最大的感受是预约类项目业务模型看似简单实际做起来最难的不是某个技术点而是时间逻辑的严谨性和并发场景的可靠性。时间冲突检测需要考虑边界情况数据库锁需要理解事务原理前端时间组件需要处理格式化转换每一环单独拿出来都不难串起来就很容易在细节上翻车。我自己做完这个项目后继续扩展了几个顺手的功能点增加邮件通知预约结果、场地使用率统计报表、日历视图的月模式展示。这些扩展再次证明了一件事当初表结构设计时预留的冗余字段和合理的关联关系让后续迭代轻松了很多。如果你也准备做类似的系统我建议动手前在表设计上多花点时间这个投入的性价比是最高的。