SpringBoot+Vue体育馆预约系统实战:从数据库建模到部署上线
2026/9/23 2:25:58 网站建设 项目流程

1. 先去场馆坐半天:预约系统的本质是"人、场地、时间"的绑定

去年帮朋友所在的社区体育馆做内部管理系统,我特地去前台坐了半天观察他们的工作流。前台小姑娘的日常就是接电话、翻一个共享Excel表、拿笔登记,遇到重复预订、临时取消、场地维护,全凭人肉记忆,周末高峰期常常自己都分不清哪个场子到底空没空。所以这类"体育馆预约平台"表面上是个技术项目,本质上是在解决一个非常具体的业务问题:把"谁",在"哪个场地",占用"哪个时间段"这三件事稳定地记录下来,并且保证互不冲突。

技术栈已经明确是前后端分离的SpringBoot+Vue+MyBatis+MySQL,意味着这个系统天然分成两块:Vue负责页面展示和交互,SpringBoot负责业务逻辑和接口,MyBatis负责数据库操作,MySQL负责最终存储。这个组合在目前中小型项目中非常常见,做毕设、做课设、或者给单位内部搭一套预约系统,都很合适。下面我会从业务建模一直讲到部署上线,把完整链路中值得注意的地方全部过一遍。

1.1 业务痛点与系统目标

先想清楚一个事情:预约系统要解决的核心矛盾是"场地有限、时段有限、需求随机"。体育馆里的羽毛球馆可能只有6片场地,每个场地每天有固定的开放时段,用户想在周五晚上7点约一块场地,系统要做的就是在这个时段被占掉之前,允许用户锁定它。

基于这个目标,系统至少要具备以下能力:

  • 用户注册、登录,区分普通用户和管理员两种角色。
  • 场馆信息展示,包括场地名称、类型、位置、时段价格和当前可用状态。
  • 用户按日期和时段在线预约,能查看自己的预约记录,也能取消未开始的预约。
  • 管理员维护场馆基础信息,查看所有订单,必要时强制取消某条预约。
  • 所有预约操作必须避免"同一场地同一时段被重复预约"的问题。

很多新手拿到这类需求会直接开写代码,结果写到订单表的时候才想起来要处理冲突;写到管理端的时候发现前期表结构没有状态字段;写到部署的时候才发现数据库连接串配错了时区。所以我的建议是:第一步不是建项目,而是先把业务规则一条条列出来,哪怕写在纸上都行。

1.2 角色权限和预约规则怎么定

角色权限这里不需要设计得过于复杂。普通用户就是注册、登录、选场地、预约、取消、看自己的记录;管理员负责场馆维护和订单管理。前后端分离的结构下,权限控制通常在两个层面做:前端通过路由守卫控制页面入口,后端通过拦截器校验接口权限。光靠前端隐藏按钮是不够的,后端接口必须自己校验角色,否则别人模拟一个请求就能把管理接口扫一遍。

预约规则建议在项目初期就定死,否则后期改起来牵一发动全身。以我做的这个场馆为例,开放时间是每天9点到22点,最小预约单位是半小时,用户每次最多预约2小时,可以提前7天预约,开场前2小时不允许取消。你可以根据自己场馆的情况调整,但这些参数最好集中放在后端配置里,不要散落在代码的各个角落,不然上线的第一个周末就会有人打电话投诉。

1.3 核心业务名词先统一

开发前把名词统一,后面沟通会省很多事。这套系统里最常见的几个概念是:

  • 场馆(Venue):物理场地,比如"羽毛球1号场"。
  • 时段(TimeSlot):预约的时间片,比如"2024-05-20 19:00-20:00"。
  • 预约单(Reservation):用户和场馆时段的一次绑定关系。
  • 状态(Status):预约单的状态,常见有待支付、已确认、已完成、已取消。

这一段看起来不像技术,但很多项目做砸了就是因为"场馆""场地""预约""订单"这些词大家理解不一致,前后端联调的时候接口字段名叫法五花八门,平白增加沟通成本。把这几个词在需求文档里固定好,后面的表设计和接口路径都会清晰很多。

2. 技术选型不跟风:这四件套为什么够用且好维护

很多初学者喜欢追新,一上来就要上微服务、上Docker、上Redis集群。我理解追求学习新技术的心情,但这类预约系统本质上属于CRUD密集型业务系统,核心难点在于并发控制和状态流转,不在于架构炫技。SpringBoot+Vue+MyBatis+MySQL这套组合是当前中小型项目里非常主流、资料最全、排错最容易的搭配,对个人学习、毕设展示和小型真实项目都足够。

2.1 SpringBoot负责后端服务,省去大量配置

SpringBoot最直观的价值是"自动配置"。打个比方,以前用SSM(Spring+SpringMVC+MyBatis)搭项目,要写web.xml、spring配置、mybatis配置、数据库连接池配置,一堆XML看得人头皮发麻。SpringBoot把这些约定成俗的东西都自动配好了,你只需要引入对应的starter依赖,它自己在启动时帮你装配好。

放到预约系统这个场景里,SpringBoot提供的核心能力有这些:

  • 内置Tomcat,打成一个Jar包直接运行,部署时不用单独装Tomcat。
  • spring-boot-starter-web提供RESTful接口支持,前后端分离靠的就是这一层Json传输。
  • spring-boot-starter-validation做参数校验,比如手机号格式、预约时段是否为空。
  • 配置项集中在application.yml,数据库、端口、日志、文件上传大小全在这里改。
  • 生态成熟,遇到问题搜一下基本都有解决方案。

如果你看到"若依框架"这类快速开发脚手架,也别觉得项目落后,很多前后端分离的预约系统都基于类似RuoYi二次开发,核心还是SpringBoot这一套,只是提前集成了权限、代码生成、定时任务等通用功能。新手自己从零搭一遍流程,收获会比直接套脚手架更大。

2.2 Vue负责前端交互,Vue 2还是Vue 3要想清楚

Vue的核心是组件化开发。页面拆成一个个组件,每个组件管理自己的状态和模板,数据变化时页面自动更新,开发体验比操作DOM的老写法舒服太多。

现在的问题是该选Vue 2还是Vue 3。如果你是自己从零开始新写项目,我建议直接Vue 3 + Vite,Vue 3的性能更好,Composition API写起来逻辑复用更清晰,而且Vite冷启动快得让人感动,npm install之后启动基本上秒开。但如果你下载的是网上现成的源码,很多是Vue 2 + Vue CLI + Element UI的组合,运行之前先看清楚package.json里的版本再装依赖,不然一个npm install装出一堆高版本依赖,前端直接起不来。

在这套预约系统里,Vue主要负责四件事:路由跳转、接口请求、状态管理、组件渲染。实际开发中vue-router、axios、vuex/pinia是跑不掉的三个库,我后面会逐个讲它们是怎么配合的。

2.3 MyBatis加MySQL,SQL自由是这个组合的核心优势

MyBatis和JPA/Hibernate的最大区别在于:MyBatis把SQL牢牢握在开发者手里,SQL怎么写由你决定。预约系统里会有大量复杂的查询,比如"查询某个场馆在某天已经预约的时段列表""统计每个场馆的周预约量",这类语句用MyBatis的XML文件写起来非常直观,也方便DBA后期帮你调优。

MySQL则是这套组合里最稳定的一环。部署简单,一个MySQL 8.0的安装包解压配好就能跑,官方文档和中文资料都极其丰富。对于预约系统这种数据量在百万以内的项目,正确建索引之后,MySQL性能完全不是瓶颈。

值得提醒的是,MyBatis分页不要自己用limit去算页码,直接用PageHelper插件,ThreadLocal方式传递分页参数,一行代码搞定分页。我见过最惨的写法是手动拼limit、count两套SQL,改个页码还要维护两个查询,后期痛点非常明显。

2.4 没上Redis、MQ这类中间件的取舍逻辑

我理解很多人会问:预约系统不需要Redis缓存吗?不需要消息队列削峰吗?

答案是:看规模。如果场馆只有十几块场地,一天几千次访问,MySQL加正确索引完全扛得住,强行引入Redis反而增加了缓存一致性、过期策略、缓存穿透这些新问题。消息队列更是没有必要,预约这个动作本身是同步操作,用户点了提交就需要立刻知道成功还是失败,异步化之后反而让用户困惑。

但这不代表完全不用中间件。如果项目明确要求处理高并发秒杀式预约,一个务实的升级路径是:先在预约表上加唯一约束挡住并发,再用Redis做分布式锁兜底,最后把短时高峰流量缓冲到MQ里排队落库。这是后话,初学者先把单体版本跑通,理解每一条SQL的真实执行计划,比盲目堆中间件收获大得多。

3. 数据库先建模:预约冲突从源头就要堵死

数据库设计是预约系统的地基。地基没打好,后面写再多业务代码都是拿泥巴糊墙。我见过很多人的订单表连唯一索引都不知道加,最后并发测试一压全是重复预约记录,只能手动删数据。这一节我从三张核心表讲起,再重点讲如何用数据库设计从源头拦截冲突。

3.1 三张核心表与字段设计

一个最简可用的预约系统,至少需要三张表:用户表、场馆表、预约表。再扩展的话可以加场馆类型表、支付订单表、操作日志表,但核心业务就围绕这三张表转。

用户表(sys_user)的常见字段:

CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `role` TINYINT NOT NULL DEFAULT 1 COMMENT '1-普通用户 2-管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

场馆表(venue)主要存物理场地信息,比如名称、类型、每小时价格、所在位置、状态。注意这里不要想太多,不需要把每一天、每一个时段都预先拆成一条记录存在表里,那是过度设计。场馆表就是静态的基础资料,动态的可约状态由预约表反向推导出来。

预约表(reservation)是整个系统的核心,字段设计决定系统上限:

CREATE TABLE `reservation` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '预约人', `venue_id` BIGINT NOT NULL COMMENT '场馆id', `reserve_date` DATE NOT NULL COMMENT '预约日期', `start_time` VARCHAR(10) NOT NULL COMMENT '开始时间 HH:mm', `end_time` VARCHAR(10) NOT NULL COMMENT '结束时间 HH:mm', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-已确认 1-已完成 2-已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_venue_slot` (`venue_id`, `reserve_date`, `start_time`), KEY `idx_user_date` (`user_id`, `reserve_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约表';

注意到没有,预约表里有一个唯一索引uk_venue_slot,这就是承担"防止同一场地同一开始时间被重复预约"的数据库层防线。后面我会详细说它是怎么在并发下起作用的。

3.2 时段粒度为什么用半小时,而不是精确到分钟

时段的粒度选择很关键,它直接影响整个系统的复杂度和用户体验。如果粒度精确到分钟,用户看场馆日历会看到无数个可选时间点,数据库里的冲突判断也要变成"区间重叠判断",逻辑复杂很多。如果粒度太粗,比如固定2小时一场,用户想订1小时就很尴尬。

折中方案是使用固定时隙(Time Slot),比如30分钟为一个槽位。用户预约19:00-20:00,实际上就是占用19:00、19:30两个槽位。实现时用VARCHAR存start_time和end_time,格式是HH:mm,比较时按字符串排序即可,不需要转成DateTime去算。这个方案的好处是:

  • 前端展示时段列表直接按槽位渲染,清晰明了。
  • 后端冲突判断从"区间重叠判断"退化为"槽位精确匹配",非常高效。
  • 数据库唯一索引可以直接覆盖,不需要写复杂条件锁。

代价是灵活性下降,用户无法预约19:10到19:50这种任意时段。对体育馆来说,这个代价完全可以接受,现实中场馆预约本来就是按整点或半小时排期。

3.3 防止重复预约的双保险:唯一索引加事务重查

解决了时段粒度问题,重复预约还有最后一个漏洞:两个用户同时提交同一个场地的同一个时段。这时候只靠应用层"先查再插"是不够的,因为两个请求可能同时查到"当前没有冲突",然后同时插入,最终重复。

数据库层的唯一索引就是在这个场景中起作用的核武器。当服务端同时收到针对同一venue_id、reserve_date、start_time的两条插入请求时,数据库唯一索引会让第二条插入直接报DuplicateKeyException,不会产生两行脏数据。

应用层还要再加一道保险:在事务内先使用SELECT ... FOR UPDATE锁定场馆当天相关记录,再判断是否冲突,最后插入。这种"悲观锁+唯一索引"的组合,开销不高,正确性却非常有保障。完整代码我在第四章给出。

3.4 RESTful接口清单提前规划

建表之后,接口文档也建议一开始就列个清单。这套系统最核心的接口大概如下:

模块方法路径说明
用户POST/api/user/register注册
用户POST/api/user/login登录,返回Token
用户GET/api/user/info获取当前用户信息
场馆GET/api/venue/list分页查询场馆列表
场馆GET/api/venue/detail查询场馆详情
预约GET/api/reservation/available查询某场馆某天的已约时段
预约POST/api/reservation/create创建预约
预约GET/api/reservation/my我的预约列表
预约POST/api/reservation/cancel取消预约
管理POST/api/admin/venue/save新增或修改场馆
管理GET/api/admin/reservation/list管理员查看全部预约

路径命名统一为/api开头,方便Nginx做反向代理转发。开发阶段前后端端口不同,也需要通过这个前缀做跨域和代理设置,这一块到部署章节再展开。

4. 后端SpringBoot实现的关键链路:JWT鉴权、事务与MyBatis动态SQL

进入代码阶段。这一节我只挑三个最有代表性的链路来讲:登录鉴权、预约下单、以及MyBatis分页和动态SQL。这三块覆盖了后端70%的工作量,剩下的就是普通的CRUD,照着写就行。

4.1 项目骨架与源码结构

如果你拿到的是完整源码,第一步先看目录结构。一个合理的SpringBoot前后端分离项目,后端目录应该长这样:

src/main/java/com/example/gym/ ├── config/ │ ├── CorsConfig.java # 跨域配置 │ ├── WebMvcConfig.java # 拦截器注册 │ └── MybatisPlusConfig.java # 分页插件配置 ├── controller/ │ ├── UserController.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 ├── common/ │ ├── Result.java # 统一返回体 │ └── JwtUtil.java # JWT工具 ├── exception/ │ └── GlobalExceptionHandler.java └── interceptor/ └── AuthInterceptor.java # 登录拦截器

关键点:统一返回体Result一定要有,它保证所有接口无论成功失败都返回code、message、data三件套,前端axios拦截器只需要按这个结构解包即可,不用每个接口单独判断。

4.2 JWT登录鉴权的实现思路

JWT(JSON Web Token)是目前前后端分离项目最常用的登录态方案。用户登录成功后,后端生成一个Token字符串返回给前端,前端存在localStorage或状态管理里,之后每次请求在请求头带上Authorization字段,后端拦截器校验Token的合法性和有效期。Token本身携带了用户基本信息,比如userId和role,后端解码就能拿到当前身份,不需要每次查数据库。

生成Token的简化代码:

public String generateToken(Long userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }

拦截器里面对OPTIONS请求直接放行,否则前后端分离跨域预检请求会被拦截导致前端报一堆CORS错误。这个坑我至少见过十个人踩过。

管理员接口的权限校验,可以在拦截器里拿到role字段后判断,role不等于2就返回403。不要只在前端路由守卫里判断角色,后端必须把权限边界守住。

4.3 预约下单Service:事务、锁与唯一索引的组合

预约创建是整个系统技术含量最高的地方。核心逻辑分四步:

  1. 对预约表和场馆记录加锁(悲观锁)
  2. 查询该场馆当天指定时段是否已经被预约
  3. 没冲突则插入预约记录,库表唯一索引兜底
  4. 提交事务,响应结果

参考代码如下:

@Transactional(rollbackFor = Exception.class) public Result createReservation(ReservationCreateDTO dto, Long userId) { // 1. 锁住场馆记录,防止同一个场馆并发处理 Venue venue = venueMapper.selectByIdForUpdate(dto.getVenueId()); if (venue == null) { return Result.error("场馆不存在"); } // 2. 校验时段是否合法 if (!validateTimeSlot(dto.getStartTime(), dto.getEndTime())) { return Result.error("预约时段不合法"); } // 3. 检查该时段是否已被约走 int count = reservationMapper.countConflict( dto.getVenueId(), dto.getReserveDate(), dto.getStartTime(), dto.getEndTime()); if (count > 0) { return Result.error("该时段已被预约,请选择其他时间"); } // 4. 插入,唯一索引兜底 try { Reservation reservation = new Reservation(); reservation.setUserId(userId); reservation.setVenueId(dto.getVenueId()); reservation.setReserveDate(dto.getReserveDate()); reservation.setStartTime(dto.getStartTime()); reservation.setEndTime(dto.getEndTime()); reservation.setStatus(0); reservationMapper.insert(reservation); } catch (DuplicateKeyException e) { throw new BusinessException("手慢了,该时段刚刚被抢走了"); } return Result.success("预约成功"); }

这段代码最关键的是两个点:selectByIdForUpdate一定要存在,它把当前场馆的行锁住了,后面另一个并发请求只能排队等待;countConflict这个SQL里查询的条件依赖前面的行锁,这样在并发场景下不会出现两条请求同时查到count=0的脏数据。再加上表里唯一的索引兜底,才算完整闭环。

4.4 MyBatis动态SQL和分页插件

预约系统里会大量用到动态查询。比如场馆列表支持按名称模糊搜索、按类型过滤;预约列表支持按时间范围、状态过滤。这些用MyBatis的<if>标签组合起来非常方便。

<select id="selectReservationPage" resultType="com.example.gym.entity.Reservation"> SELECT * FROM reservation <where> <if test="userId != null"> AND user_id = #{userId} </if> <if test="venueId != null"> AND venue_id = #{venueId} </if> <if test="status != null"> AND status = #{status} </if> <if test="startDate != null"> AND reserve_date &gt;= #{startDate} </if> <if test="endDate != null"> AND reserve_date &lt;= #{endDate} </if> </where> ORDER BY reserve_date DESC, start_time ASC </select>

这里有个经典小坑:写日期比较时,><是XML的保留字符,必须换成&gt;&lt;,否则XML解析直接报错。很多新手第一跑就是挂在这里。

分页直接用PageHelper,三步走:引入依赖、配置插件、业务代码里调用PageHelper.startPage,紧接着的下一条select查询就会自动拼接limit。注意startPage只管它后面第一条查询,如果你在中间插入了其他Mapper查询,分页条件就会被吞掉。

public PageResult<Reservation> pageMyReservations(int page, int size, Long userId) { PageHelper.startPage(page, size); List<Reservation> list = reservationMapper.selectReservationPage( new ReservationQuery(userId, null, null, null, null)); PageInfo<Reservation> pageInfo = new PageInfo<>(list); return PageResult.of(pageInfo.getTotal(), pageInfo.getList()); }

5. Vue前端落地:路由守卫、axios封装与场馆卡片

后端把接口都提供齐了,前端就是一个"取数据、渲染数据、传数据"的过程。但要想做得像样,以下几个基础工程必须搭好。

5.1 前端工程结构与依赖

一个合理的前端项目目录:

src/ ├── api/ │ ├── request.js # axios统一封装 │ ├── user.js # 用户相关接口 │ ├── venue.js # 场馆相关接口 │ └── reservation.js # 预约相关接口 ├── assets/ ├── components/ │ ├── VenueCard.vue # 场馆卡片组件 │ └── ReserveDialog.vue # 预约弹窗组件 ├── router/ │ └── index.js # 路由配置 + 路由守卫 ├── store/ │ └── user.js # 用户状态管理 ├── views/ │ ├── Login.vue │ ├── Register.vue │ ├── Home.vue │ ├── VenueDetail.vue │ ├── MyReservation.vue │ └── admin/ │ ├── AdminLayout.vue │ ├── VenueManage.vue │ └── ReservationManage.vue └── App.vue

前端目录的划分核心原则是:api目录统一放接口请求,views目录按页面模块组织,components目录放公共组件。这样即使项目变大,也还能保持结构清晰。

5.2 axios封装:请求拦截器与响应拦截器

axios如果不封装,每个页面都要写一套请求逻辑,代码会非常碎。统一封装的核心有两个拦截器:

  • 请求拦截器:把localStorage里的Token取出来,放到请求头Authorization里。没登录就跳登录页。
  • 响应拦截器:后端返回的Result统一包成了code、message、data,这里统一处理code不为200的情况,比如Token过期就清除本地用户信息并跳回登录页。

封装代码大致长这样:

// api/request.js import axios from 'axios' import router from '@/router' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request

这里用baseURL/api而不是写死后端IP,是因为本地开发时可以通过Vite代理解决跨域,部署时通过Nginx转发。把这个习惯养成,就不会出现"本地改一个IP、线上又改一个IP"的窘境。

5.3 路由守卫:页面访问的看门人

前端路由守卫主要做两件事:判断是否登录、判断角色有没有权限。Vue Router 4提供的beforeEach钩子里,拿到目标路由meta里配置的requiresAuth和role信息,再和本地存储的登录状态比对。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next('/login') return } if (to.meta.role && to.meta.role !== Number(role)) { next('/403') return } next() })

记住前面说过的一句话:路由守卫只是用户体验层面的拦截,不是安全边界。服务器的接口权限校验才是真正的防线,两边都要做,不要二选一。

5.4 场馆卡片与预约弹窗的交互细节

前端技术含量最高的地方,在我看来是"动态获取某天某场馆的可约时段"。用户选了一个日期,前端就要请求后端,拿到该场馆当天已经被预约的时段列表,然后渲染卡片时把已约时段置灰,把可约时段亮出来。

这个交互的效果决定了这个系统看起来是"能用的工具"还是"花架子"。实现时:

  • 用户切换日期时,调用 /api/reservation/available 接口。
  • 返回的List是已约时段的字符串数组,比如 ["19:00-20:00"]。
  • 前端生成从9:00到22:00的连续时段列表,与已约集合比对,动态生成可点击状态。

用户点击某个时段后,弹窗展示场地名称、日期、时段、单价,确认后调reservation/create接口。这个流程简单直接,前端不需要自己维护太复杂的逻辑。

还有一个小细节:用户取消预约后,场馆卡片的可用时段应该立刻刷新。处理方式是在取消接口返回成功之后,再调一次available接口刷新数据,不要让前端凭感觉改状态,避免数据不一致。

6. 从本地跑到服务器:完整部署教程

源码在手、本地能跑只是第一步,很多人挂在部署上线这一步。前后端分离项目部署其实流程固定,掌握一次就能举一反三。

6.1 本地开发环境版本对照

不同的SpringBoot、Node、MySQL版本组合,表现差异很大。最稳的一套版本组合建议如下:

组件推荐版本说明
JDK1.8目前大部分SpringBoot 2.x源码运行最稳的版本
Maven3.6+依赖管理和打包工具
SpringBoot2.7.x成熟稳定,资料最多
Node.js16或18Vite 4/Vue 3项目可用
MySQL8.0使用utf8mb4字符集
Nginx1.24+部署前端和反向代理

如果你的项目是SpringBoot 3.x版本,JDK必须换成17及以上,MyBatis依赖坐标也有变化,很多老旧教程不适用。源码是什么版本就用对应的环境,不要拿SpringBoot 3.x项目配JDK 8,启动时直接报UnsupportedClassVersionError。

6.2 后端打包与配置外置

本地开发时数据库连接直接写在application.yml里,但部署到服务器时,配置最好外置,这样改配置不需要重新打Jar包。做法是在Jar包同目录放一个application-prod.yml,启动命令指定使用它:

mvn clean package -DskipTests java -jar gym-server.jar --spring.profiles.active=prod

application-prod.yml里需要注意的几个点:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/gym?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的数据库密码 servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true

serverTimezone=Asia/Shanghai必须加,否则数据库连接会报时区错误。map-underscore-to-camel-case必须开,否则数据库下划线字段和Java驼峰属性对不上,查询出来全是null。

6.3 MySQL初始化和数据导入

部署时数据库初始化的标准做法是:在服务器MySQL中创建数据库,导入项目提供的sql脚本,创建一个专门的业务账号,而不是直接用root远程连数据库。

mysql -u root -p CREATE DATABASE gym DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; GRANT ALL PRIVILEGES ON gym.* TO 'gym_user'@'localhost' IDENTIFIED BY '强密码'; FLUSH PRIVILEGES; exit mysql -u gym_user -p gym < gym.sql

数据库账号权限最小化这个习惯值得养成,万一应用被攻击,攻击者拿到的也只是单库权限,影响范围可控。

6.4 Nginx反向代理与前端发布

前端构建产出一堆静态文件,扔给Nginx托管就行。但有个核心配置:前端页面的接口请求都走/api前缀,Nginx要把/api转发到后端8080端口,这样前端浏览器只和Nginx一个端口打交道,不存在跨域问题。

参考配置:

server { listen 80; server_name 你的域名或服务器IP; # 前端静态文件 root /opt/gym-web/dist; index index.html; # 解决Vue history路由刷新404 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; } }

注意location /api/里的proxy_pass末尾是否带路径,带不带结果完全不同。代理到http://127.0.0.1:8080时,客户端请求/api/venue/list会被完整转发给后端;如果写成http://127.0.0.1:8080/,则/api前缀会被替换成/,同样能通,但容易混淆。我的习惯是原来有什么路径就原样转发,后端Controller统一用/api开头。

6.5 上线后的基础验证清单

部署完成后不要直接宣布大功告成,按以下清单快速过一遍:

  • 打开前端首页,确认场馆卡片正常加载,图片不裂。
  • 注册一个新账号,确认能收到验证码或能正常注册。
  • 登录后预约一个时段,然后换一个账号登录,确认同样时段变灰不可约。
  • 退出登录,手动访问需要登录的页面,确认被路由守卫拦截。
  • 直接用Postman调管理端接口,不带Token确认返回401,带普通用户Token确认返回403。
  • 模拟提交一个体积较大的文件上传,确认在配置的10MB限制下返回友好提示。

这套清单十分钟就能跑完,能提前发现至少80%的部署问题。

7. 实测中踩过的坑,你最好提前知道

项目做完只是一半,真正让人掉头发的是测试和上线阶段。这一节我把实测中踩过、以及帮别人排查过的高频坑都列出来,每个坑都附上排查思路,希望你能绕开。

7.1 并发预约产生的重复记录:唯一索引不是装饰

有次压测时,我用JMeter同时发50个请求预约同一个场馆同一个时段,结果数据库里出现了多条记录。查了半天发现,那套代码的表结构里压根没有唯一索引,只有应用层"先查再插"。光靠应用层判断在高并发下根本不严谨,两个请求可能同时通过检查再插入,于是重复记录诞生了。

修复方案就是我前面说的双保险:数据库加唯一索引uk_venue_slot(venue_id, reserve_date, start_time),事务里对场馆行加锁。加了索引之后,你再怎么并发压,数据库只会保留一条记录,后到的直接报DuplicateKeyException。排查这类问题的时候,先看表结构,再看事务隔离级别,别上来就怀疑是代码逻辑写错了。

7.2 跨域问题排查完整链路

前后端分离开发时跨域问题几乎人人会碰到。现象是前端请求后端接口,浏览器控制台报"CORS policy"错误。不是所有跨域都要配CORS,我的排查链路是:

  1. 先确认前端用的代理配置。开发阶段用Vite代理,Vite配置proxy把/api转发到后端,这样可以绕过跨域;生产阶段用Nginx反向代理,同样可以避免跨域。
  2. 如果确实需要跨域请求,后端配置CorsConfig,允许前端来源、允许Authorization请求头、允许GET/POST/PUT/DELETE方法。
  3. 如果CORS配置了还是不生效,检查拦截器是不是把OPTIONS预检请求拦截了。预检请求不做业务处理,拦截器里第一行就需要放行OPTIONS。

跨域报错有80%是第三步导致的,先看后端日志有没有打印OPTIONS请求相关异常,一抓一个准。

7.3 日期时间格式与MySQL时区问题

MySQL 8.0连接串不配serverTimezone=Asia/Shanghai,启动直接报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。这串乱码看着吓人,本质就是数据库和驱动之间的时区对不上,手动指定即可。

还有更隐蔽的问题:Java实体里用LocalDateTime,前端传过来的日期字符串格式稍微不对,反序列化就报错。建议统一在application.yml里配置全局时间格式:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

或者更简单一点,统一使用String类型接收日期时间参数,入库前再转换。对预约系统来说,reserve_date用LocalDate类型就够了,因为用户选的永远是某一天,不需要时分秒。

7.4 Vue history路由打包后404

前端路由用了history模式(即URL里没有#号),直接部署到服务器后,刷新某个子页面会404。原因是Nginx找不到对应的真实文件路径,比如用户访问/venue/3,服务器去查找/venue/3这个文件,当然找不到。

前面Nginx配置里那句try_files $uri $uri/ /index.html就是专门解决这个问题的。它告诉Nginx:这个路径没找到就回退到index.html,然后交给Vue Router去匹配路由。凡是使用history模式的项目,这句配置不能省。

7.5 容易被忽略的小细节

最后列几个容易让系统显得不专业的小问题:

  • 密码不要明文存库,用BCrypt加密,Spring Security里的BCryptPasswordEncoder可以单独用,不用引入全套Security。
  • 后端返回错误信息不要直接把异常堆栈抛给前端,用全局异常处理器统一封装成Result。
  • 取消预约要校验时间,开场前2小时禁止取消,这个状态在前后端都要判断。
  • 数据库字段默认值要设置好,比如status默认0,create_time默认当前时间,能少写很多重复代码。
  • 管理员删除场馆前,要检查该场馆是否还有未完成的预约,不能直接物理删除。

按我个人的经验,把这些细节打磨好,整个项目的完成度会明显上一个档次。编码只是把功能实现,把边界情况和异常流程处理好,才是真正能上线的系统。这套体育馆预约系统虽然不算复杂,但它覆盖了前后端分离、登录鉴权、事务并发、部署上线这一整条主线,认真走一遍,之后再接触更大规模的业务系统,很多思路都能复用得上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询