1. 项目整体设计与技术选型思路
这几年校园和商业体育馆的场地预约,基本都还停留在“微信群里接龙 + 人工核对 + 现场缴费”的原始阶段。高峰时段场地被乱占、爽约率居高不下、管理员每天要花大量时间对账排期,体验和效率都一言难尽。拿到“体育馆预约平台系统”这个需求时,我第一时间就确定了前后端分离的方案:后端用SpringBoot,前端用Vue,持久层用MyBatis,数据库用MySQL。这套组合在2025年依然是中小型管理系统里非常成熟、性价比极高的搭配,社区资料多、招人容易、部署也简单,很适合快速落地。
为什么不用Spring Cloud微服务?因为场馆预约这类业务,本质上是一个单体的、以CRUD和事务一致性为核心的业务系统,业务量还没到需要分布式拆分的程度。微服务带来的注册中心、配置中心、链路追踪等复杂度,反而会让项目变得臃肿,维护成本更高。SpringBoot 2.7/3.x的单体架构加上合理的模块划分,完全能支撑几百上千的并发请求,后续真要扩展,再按业务边界拆分也不迟。
前端选Vue而不是React,主要考虑到团队的熟悉程度和生态。Vue3 + Vite + Element Plus这套组合在后台管理系统里几乎是标准答案,组件现成、文档全、开发效率高。Vue的响应式模型在处理“选日期 → 查看场次 → 锁定场地 → 提交预约”这种多步联动表单时非常顺手,双向绑定和计算属性能让界面状态管理少写很多样板代码。
1.1 为什么选MyBatis而不是JPA/Hibernate
持久层这块我坚持用MyBatis,核心原因是SQL可控。体育馆预约系统里有大量复杂的查询,比如“某天某个时段内所有空闲场地”“某个用户的预约历史及场地评价”“统计每个场馆一周的使用率”,这些场景用MyBatis写动态SQL非常灵活,可以精确控制每一条语句的执行计划,排查性能问题时也看得明白。
相比之下,JPA虽然开发快,但遇到复杂连表查询和动态条件组合时,要么拼JPQL,要么用Specification,要么干脆用原生SQL,反而绕了一圈。还有N+1问题、懒加载序列化报错等坑,对于水平参差不齐的团队来说,调试成本很高。MyBatis的使用门槛低,一个XML文件搞定所有映射关系,字段名和Java属性名的映射规则清晰,配合通用Mapper或者MyBatis-Plus,单表CRUD也不需要手写太多代码。
1.2 项目结构规划与分层思路
工程结构上,我按常见的Maven多模块方式做了拆分,不过为了简洁,实际项目用了单模块但分层清晰的方式。后端包结构分成controller、service、mapper、entity、dto、vo、config、utils几个层次。controller只负责接收参数和返回结果,service层处理业务逻辑和事务,mapper层只管数据库访问。这样后续加功能、加团队成员时,不会因为代码堆在一起而互相打架。
前端部分,src目录下划分了api、assets、components、router、store、utils、views几个目录。api目录集中管理所有接口请求,views按角色拆分成用户端和管理员端页面。组件层抽取了场地卡片、日期选择器、预约弹窗等通用组件,避免页面之间重复造轮子。
2. 数据库设计与核心表结构解析
数据库设计是整个系统的地基。预约系统最怕的就是数据不一致:同一个场地同一时段被两个人约了、取消和支付状态对不上、统计报表数据不准。所以建表时我花了大量时间梳理业务关系,核心表有用户表、场馆表、场地表、时段表、预约订单表,外加几个辅助表。
2.1 用户角色与权限模型
用户表我做了两种角色:普通用户和管理员。实际开发中很多系统会单独做角色表和权限表,但体育馆预约这个场景角色就两个,权限差异也不复杂,所以直接在用户表里加了一个role字段,0表示普通用户,1表示管理员。这么做的好处是查询时少两次关联,JWT鉴权时也方便从token里直接取出角色,不用每次查数据库。当然,如果以后要接入教练角色、财务角色、超级管理员等,再升级成RBAC模型也不迟。
用户表核心字段包括id、username、password、phone、role、status、create_time、update_time。password存的是BCrypt加密后的密文,绝对不存明文。手机号为全局唯一索引,也是登录时的账号。status字段用于封禁账号,被拉黑的用户无法登录和预约。
2.2 场馆、场地与时段设计
场馆表比较简单,包含id、name、location、description、open_time、close_time、cover_image、status。场地表则关联场馆,重点设计了一个venue_type字段用来区分羽毛球、篮球、乒乓球等不同类型的场地。因为很多场馆里不止一种运动项目,比如同一个体育中心既有羽毛球场又有篮球场,查询时必须先定位场馆和项目,再筛选场地。
时段表是针对每个场地的固定时间段配置。现实中体育馆预约的最小单位一般是一小时或者半小时,我采用时段表单独存储的方式,字段包括id、venue_id、site_id、start_time、end_time、price、weekday。之所以把时段和场地分开,是因为不同场地在不同时段的价格可能不同,比如工作日晚上比白天贵,周末全天贵。如果把时段直接冗余在订单表里,后续调价和查询空闲时段都会变得很痛苦。
2.3 预约订单表与状态流转
预约订单表是业务核心,字段包括id、order_no、user_id、site_id、date、time_slot_id、status、pay_status、amount、book_time、cancel_time、remark。order_no用时间戳+随机数生成的唯一订单号,用户端显示和后台对账都会用到。status字段表示订单状态:待支付、已支付、已使用、已取消、已过期。pay_status单独拆出来是为了处理订单取消后退款的状态同步,避免一个字段既要表达业务流程又要表达支付结果。
状态流转上我制定了明确规则:用户提交预约后生成待支付订单,支付成功后变为已支付,场地管理员核销后变为已使用。用户可以在使用时间开始前取消已支付订单,系统自动退款并释放场地。如果订单超过支付时限(我设置了15分钟)未支付,定时任务会自动将其置为已取消并释放时段,避免占用场地不付款的恶意行为。
3. 后端核心功能实现与难点拆解
后端开发中,我重点攻克了三个难点:登录鉴权、并发防超卖、预约状态机管理。这一块如果只写CRUD,根本撑不起一个稳定的预约系统。
3.1 基于JWT的登录鉴权实践
登录模块我采用了目前主流的JWT方案。用户输入用户名密码后,后端校验通过,生成一个包含userId和role的token返回给前端。前端把token存在本地,之后每次请求都在Authorization请求头里携带。后端用一个拦截器统一解析token,并把用户信息放入ThreadLocal,方便各业务方法随时拿到当前操作人。
JWT本身是无状态的,所以不需要在Redis里存储会话,部署多实例时也不用考虑session共享,这一点比传统Session方案省心不少。当然JWT的缺点是无法主动让token失效,所以我在实现时给token设置了一个较短的过期时间(2小时),同时在前端登录后维护一个刷新机制,有效期快到时自动换取新token。对于体育馆预约这种非高安全敏感业务,完全够用。
密码加密选了BCrypt而不是MD5加盐,因为BCrypt内置随机盐,每次加密结果都不同,破解成本更高。Spring Security的BCryptPasswordEncoder直接能用,不需要自己造轮子。
3.2 并发场景下防止场地超卖
这里是最容易翻车的地方。多个用户同时盯着同一个热门场地,提交预约时如果不做并发控制,很容易出现超卖:同一场地同一时段被两条订单同时拿下来。
我第一版实现是先在代码里查询预约订单表,判断冲突再插入。结果用Jmeter压测时,100个并发请求打过来,硬生生产生了8条重复预约。原因很简单,查询和插入之间存在时间差,两个请求都查到“没有冲突”,然后都插了进去。
后来我改用数据库唯一索引 + 事务控制的方式。在预约订单表给(user_id, date, time_slot_id)这三个字段建立唯一索引,从数据库层面保证同一个用户同一天同一个时段只能有一条订单。但这并不能防止不同用户抢同一场地,于是我进一步在service层加了悲观锁,使用SELECT FOR UPDATE锁定场地对应的时段记录,然后再执行插入。代码大概长这样:
@Transactional public BookingOrder createOrder(BookingRequest request) { // 锁定场次记录,防止并发修改 SiteTimeSlot slot = siteTimeSlotMapper.selectBySiteIdAndTimeSlotForUpdate( request.getSiteId(), request.getTimeSlotId()); if (slot == null || slot.getStatus() == 1) { throw new BizException("该场地已不可预约"); } // 检查订单冲突 int count = orderMapper.countConflictOrder( request.getSiteId(), request.getTimeSlotId(), request.getBookDate(), null); if (count > 0) { throw new BizException("该时段已被预约,请更换时间"); } // 生成订单 BookingOrder order = new BookingOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(LoginUserHolder.getUserId()); order.setSiteId(request.getSiteId()); order.setTimeSlotId(request.getTimeSlotId()); order.setBookDate(request.getBookDate()); order.setStatus(0); order.setPayStatus(0); order.setAmount(slot.getPrice()); order.setCreateTime(LocalDateTime.now()); orderMapper.insert(order); return order; }注意SELECT FOR UPDATE一定要在事务内执行,且锁定的字段要能走到索引,否则容易锁表或者锁范围过大。加了这层锁之后,压测再没出现过超卖。配合前端在确认弹窗里二次提示“该时段即将被系统锁定”,用户体验也能接受。
3.3 动态SQL与复杂查询的MyBatis实现
MyBatis的XML中动态SQL是我最喜欢的地方。比如用户端查询“所有场馆下某一天的空闲场地”,需要按照场馆类型、运动项目、日期、时段条件动态过滤,还要把已经产生订单且状态为有效(待支付、已支付)的记录排除掉。用 、 、 标签组合起来非常清爽。
<select id="listAvailableSites" resultType="com.example.vo.AvailableSiteVO"> SELECT s.id, s.name, s.venue_type, t.id AS time_slot_id, t.start_time, t.end_time, t.price FROM site s INNER JOIN time_slot t ON s.id = t.site_id <where> s.status = 1 <if test="venueType != null"> AND s.venue_type = #{venueType} </if> <if test="date != null"> AND NOT EXISTS ( SELECT 1 FROM booking_order o WHERE o.site_id = s.id AND o.time_slot_id = t.id AND o.book_date = #{date} AND o.status IN (0, 1) ) </if> </where> ORDER BY t.start_time </select>这种SQL把业务判断一股脑交给数据库,比在Java代码里先查出全部场地再过滤内存效率高太多。配合MySQL的索引,查询速度可以轻松控制在几十毫秒。
4. 前端Vue实现与交互细节
前端我选择的是Vue3 + Vite + Element Plus。Vite的冷启动速度比Webpack快了一个数量级,开发体验很好。Element Plus的组件库覆盖了表格、表单、日期选择、弹窗、消息提示等所有后台管理场景,省了大量写样式的时间。
4.1 预约页面的联动交互设计
用户端预约页面是整个系统交互最复杂的部分。我把它拆成三个步骤:选场馆和场馆项目、选日期和时间段、确认订单信息并提交。每一步之间用状态驱动联动。
这里是全程手写的核心逻辑,比如点击一个日期,需要重新请求后端接口获取该日期下每个场地的时间段占用情况。为了避免用户频繁点击造成请求轰炸,我在前端加了防抖,并且对日期选择做了缓存——同一个日期如果已经加载过数据,就直接使用本地缓存,直到有新的预约操作导致数据失效。这些细节平时没人注意,但实测能极大减少后端压力,也能让界面响应更快。
场地卡片的展示上,我用了一个Grid布局,每张卡片显示场地名称、类型、图片以及当天该场地的时段格子。每个时段格子有两种状态:绿色可预约、灰色不可预约。被预约的时段直接禁用,鼠标悬浮时显示“已被约”,不可点击。这个视觉化交互比一个普通下拉选择框直观太多了。
4.2 状态管理与接口封装技巧
前端状态管理我用的是Pinia,用来存储当前登录用户信息、预约步骤中的临时选择数据、全局加载状态。Pinia比Vuex更轻量,TypeScript支持也更友好。实际开发中,我并没有把所有数据都塞进store,只有需要跨组件共享的才放到store里,页面局部的数据直接写在组件中。这样store不会变成一个大杂烩,排查问题也更清晰。
接口封装这块,我在utils/request.js里基于axios统一封装了一个request实例,配置了基础URL、请求超时时间,以及请求拦截器和响应拦截器。请求拦截器自动从localStorage中取出token并添加到header;响应拦截器统一处理业务返回码,比如401跳到登录页、业务异常统一弹出Message提示。这样一来,业务代码里不用每个接口都写一遍错误处理,代码干净不少。
4.3 管理员端的可视化管理页面
管理员端除了基础的场地管理、时段管理、订单管理外,我还加了一个统计报表模块,用来展示各场馆近一周的预约率、收入趋势和项目占比。这里引用了ECharts,后端提供一个统计查询接口,返回按天汇总的数据,前端用折线图和柱状图渲染。写统计时要注意时间范围的处理,我前端传的是开始日期和结束日期,后端用LocalDate接收,避免时区导致的日期偏移问题。实际有一个小坑:如果直接用java.util.Date接收前端传的“2025-03-20”,在反序列化时很容易出现T00:00:00之后转换为次日零点被当成UTC的问题,导致查询结果少了当天数据。后来我全部改用LocalDate,并用Jackson配置了统一格式,彻底解决。
5. 部署上线与性能优化实录
系统开发完成后,部署上线又是一个大活。这里我采用的是前后端分离部署:后端打包成jar包运行在服务器上,前端打包成静态文件交给Nginx托管,同时Nginx配置反向代理把/api开头的请求转发给后端服务。
5.1 环境准备与配置细节
服务器配置我选择的是2核4G的云服务器,这个配置跑SpringBoot + MySQL完全够用。后端打包命令是mvn clean package -DskipTests,产物是一个可执行的jar包。启动脚本里我设置了JVM内存参数,比如-Xms512m -Xmx1024m,避免默认情况下JVM占用过大内存导致服务器卡顿。
MySQL8的配置文件里,我重点调了几个参数:连接池大小(Spring配置了HikariCP,maximumPoolSize设为20)、查询缓存相关参数不太需要动,但innodb_buffer_pool_size我根据服务器内存设置为1G,让InnoDB能缓存更多表和索引数据。这些参数如果不调,默认配置下数据库跑到后面也会越来越慢。
前端打包是npm run build,产物在dist目录。Nginx配置里把dist目录作为网站根目录,并增加了gzip压缩和静态文件缓存头,图片、js、css等资源命中缓存后加载速度会明显提升。
5.2 Nginx反向代理与跨域问题
前后端分离必然要处理跨域。开发环境下,我在Vite里配置了代理,把/api转发到localhost:8080,这样本地调试不会出现跨域问题。生产环境下,Nginx配置如下关键部分:
server { listen 80; server_name your-domain.com; location / { root /var/www/booking/dist; 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; } }注意location / 的try_files配置非常重要,因为Vue是history路由模式,刷新某个页面(比如/admin/orders)时,Nginx要去查找对应的文件,找不到就直接返回index.html,再由前端路由接管。如果没有这一句,刷新页面会404,用户以为系统挂了。这个坑几乎所有前端分离项目都会遇到,强烈建议每次部署前检查。
5.3 数据库备份与定时任务
预约系统的数据很关键,订单、用户信息都不能丢。我写了一个每日凌晨2点的cron脚本,使用mysqldump备份数据库到指定目录,然后保留最近7天的备份文件,自动删除更早的旧备份。命令示例:
0 2 * * * mysqldump -u booking -p'密码' booking_db > /backup/booking_$(date +\%Y\%m\%d).sql脚本执行前最好加上清理逻辑,防止磁盘被备份文件塞满。我还设置了一个15分钟执行一次的定时任务,扫描待支付超过15分钟的订单,自动取消并释放场地。SpringBoot里用@Scheduled注解即可,非常简单,但记得在启动类上加上@EnableScheduling。
6. 常见问题与排查技巧实录
开发过程中踩了不少坑,这里整理一份速查表,希望能帮你少走弯路。
6.1 时区与日期类型导致的数据错乱
这是预约系统里最隐蔽的坑。前端传日期字符串“2025-03-20”到后端,如果实体字段用java.util.Date接收,SpringMVC在反序列化时是参考服务器时区转换的,默认情况下会导致日期变成前一天或后一天。尤其在跨时区的服务器上,用户预约日期和数据库存储日期对不上,第二天看订单时发现全乱套了。
解决办法我统一使用LocalDate来表示日期字段,并且在Jackson配置中设置日期格式和时区:
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=GMT+8同时前端传递日期时,使用固定的YYYY-MM-DD格式字符串,后端用@DateTimeFormat(pattern = "yyyy-MM-dd")接收。这样无论服务器部署在哪里,日期都不会偏。
6.2 MyBatis查询字段映射缺失
很多初学者在写MyBatis时会出现查询结果某些字段为null的问题。最常见的原因是数据库字段是下划线命名(比如venue_type),而Java属性是驼峰命名(venueType),如果没有开启驼峰映射,查出来的对象对应属性就是null。解决办法有两种:一是在数据库连接URL上添加&mapUnderscoreToCamelCase=true,二是在MyBatis全局配置文件中设置:
<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>我建议第二种方式,明确且对所有连接生效。还有一个坑就是resultType和resultMap的混用,如果字段比较多或者有联合查询时,宁可显式写resultMap也别图省事用resultType,否则后续加字段时容易遗漏映射。
6.3 并发压测下连接池被吃满
系统上线前我用Jmeter对预约接口做了压测,发现高并发时接口报错“Connection is not available, request timed out”。原因是HikariCP最大连接数默认10个,压测线程100个,每个线程又需要同时执行查询、插入等多个数据库操作,连接池瞬间被耗尽。
调优方案是把maximumPoolSize设置到50,并适当调小connection-timeout到2000ms。更重要的是,检查代码里是否存在事务嵌套导致连接释放过慢的问题。Spring的@Transactional默认在遇到RuntimeException时才回滚,如果业务里catch了异常而没有抛出,事务边界就会变得不可控,连接会长时间占用。排查后我把service层的事务范围精细化,只在写入操作上加@Transactional,查询接口不使用事务,明显缓解了连接池压力。
6.4 前端路由刷新404与接口跨域
前面提到过Nginx的try_files配置,这里再强调一次,这是部署中最常见的问题。此外,如果接口域名和前端域名不同,一定要在后端配置CORS或者通过Nginx反向代理。开发时Vite的proxy很顺手,但生产环境不能依赖它,Nginx转发是最稳的。还要注意如果开了Spring Security或者自定义拦截器,对OPTIONS预检请求要直接放行,否则前端调跨域接口时会一直被拦。
7. 一些关于项目扩展方向的想法
项目做到这里,核心的预约流程已经跑通了,但如果你要真正落地去运营,还有很多可以扩展的地方。
第一个是小程序端。现在很多人更习惯用微信小程序预约场地,而不是打开网页。小程序端的登录可以直接复用后端的JWT接口,前端多一个客户端适配层就行。主要业务逻辑不需要动,数据库也完全复用,反而能覆盖更多场景。第二个是消息通知。预约成功、约场提醒、取消通知都可以接入短信或者公众号模板消息,提醒用户避免爽约。第三个是信用积分体系,连续爽约扣分,诚信预约加分,积分高的用户可以优先抢热门时段。这些一旦做成,系统的粘性和运营价值会提高不少。
数据库层面,如果预约量继续增长,可以引入Redis缓存热门场馆的可用时段数据,把高频率的查询从数据库剥离出去。不过要注意缓存和数据库的一致性问题,下单成功或者取消订单时必须主动删除对应缓存,而不是依赖过期时间。否则用户看到有位置但提交时被告知已被约,体验会很差。
我在这个项目里最深刻的体会是:预约系统看起来只是一个管理系统,但真正难的地方在于并发控制和业务流程状态的一致性。数据库索引怎么建、事务边界划到哪、锁加在什么级别,这些设计比单纯敲代码要重要十倍。如果一开始就在表结构和底层防并发的方案上多思考,后端的坑会少很多。希望这篇总结能对正在做同类系统的你有点帮助。