前后端分离健身俱乐部网站系统,从脚手架到上线的完整实战记录
这套系统是我今年独立完成的一个前后端分离项目,技术栈就是标题里那套:SpringBoot + Vue + MyBatis + MySQL。做的是一个健身俱乐部的官网和会员管理后台,包含课程展示、教练介绍、预约、会员注册登录、后台课程管理等功能。
说实话,前后端分离在2025年的Java生态里已经算是绝对主流了,但很多刚接触的人卡在的不是技术本身,而是“把前端和后端串起来”的过程。这篇文章我就以这个健身俱乐部系统为例,把从项目设计、表结构、核心代码、跨域、打包、部署整条链路完整写一遍,每一步都尽量说清楚“为什么这么做”。
适合谁看?计划做毕设的朋友、自学前后端分离想找个完整案例练手的同学、以及工作中第一次接触Vue+SpringBoot整合的初级开发。这套结构可以快速改造成校园社团、餐饮门店、健身房、宠物店等任意场景的管理系统。这篇文章不需要你有特别深的经验,但建议你已经用过SpringBoot和Vue做过简单demo,否则可以先跑通官方starter再回来看。
1. 项目全貌与设计思路
1.1 前后端分离架构为什么是标配
前端负责页面渲染和交互,后端只提供JSON接口,两边通过HTTP通信——这种模式现在已经不是什么新鲜概念,但真正动手的时候,很多人还是容易把项目写歪。
最典型的错误:前端把每页的数据请求都直接写在组件里,后端把业务逻辑全堆在Controller。这样确实能跑,但维护起来非常痛苦。健身俱乐部系统虽然不大,我依然按照严格的层级来拆分:Vue端分成视图组件、路由、状态管理、API请求模块四层;SpringBoot端分成Controller、Service、Mapper三层,外加一个统一的返回体封装。
这样做的好处很直接:前端换UI框架,后端接口不用动;后端换数据库,前端代码不受影响。后面如果要加小程序端或者管理端,只需要复用同一套API。
1.2 技术栈选型背后的取舍
选型这件事,网上方案一堆,但我实际用下来,这套组合的性价比确实很高。
SpringBoot不必多说,内置Tomcat,简化了配置,是目前Java后端最稳妥的选择。MyBatis比JPA更容易控制SQL,对于这种有大量多表关联查询和统计报表的业务,写SQL反而比ORM自动生成的更高效。MySQL本身就是中小型项目最普及的数据库,部署和运维成本低。前端Vue 3 + Element Plus,组件库齐全,页面做起来快,生态里遇到问题也容易搜到答案。
有一个很多人问的问题:为什么不用MyBatis-Plus?我的回答是,这个项目里我故意用原生MyBatis把基础CRUD手写了一遍,因为你如果直接上Plus,很多SQL细节会被自动生成掩盖掉。等你能不看任何插件徒手写完一套CRUD,再换成Plus也就几分钟的事。项目里其实还引用了PageHelper做分页,这个后面细说。
1.3 功能模块拆解
健身俱乐部网站,听名字很简单,但实际拆开需求之后并不是一个单页能解决的。我把它分成两个端:前台门户和后台管理。
前台门户是给普通用户看的,包含:
- 首页轮播图、课程推荐
- 课程列表和课程详情
- 教练团队介绍
- 用户注册、登录、个人信息
- 在线预约课程
- 会员中心(查看自己的预约记录)
后台管理是给运营人员用的,包含:
- 管理员登录
- 课程管理(新增、编辑、上下架)
- 教练管理
- 预约订单管理(确认、取消)
- 会员列表
注意,前台用户和管理员不能共用同一张表、同一套登录。这是我踩过的坑:早期我图省事,在user表加了一个role字段,然后用同一个登录接口判断角色。后来发现权限控制特别别扭,前台用户能访问的接口和后台管理员几乎完全重叠,稍微一疏忽就漏出不该访问的数据。所以最终我分成两张表:member和admin,两套Controller、两套拦截器路径,互不干扰。
提示:做这种带后台的系统,权限模型一定要从第一天就分清,不要指望后面再重构。前后端分离的项目重构成本比传统单体高得多。
1.4 数据库设计的整体规划
数据库是整个系统的最底层支撑,表结构设计有问题,后面写SQL全是泪。健身俱乐部系统我设计的基础表就是这几张:
- member:前台会员
- admin:后台管理员
- course:课程表
- coach:教练表
- booking:预约记录表
- banner:首页轮播图
- category:课程分类
设计原则就一句话:宁可多拆不要硬塞。把分类单独建表而不是在课程表里写死字符串,是为了以后改分类名称不用写一堆UPDATE语句。预约表里冗余了课程名称和价格字段,是为减少联表查询次数。这种冗余在互联网企业里叫“空间换时间”,数据量不大时非常实用。
2. 后端核心实现
2.1 SpringBoot项目结构与配置
后端工程我用的Maven,包结构如下:
com.example.gym ├── controller ├── service │ ├── impl ├── mapper ├── entity ├── common │ ├── result │ └── exception ├── config └── interceptor为什么要单独拆一个common包出来?因为前后端分离项目里,Controller返回值、异常、工具类这些被大量全局复用的东西必须统一管理。我定义了一个统一返回体Result<T>,里面包含code、message、data三个字段,这样前端拿到任何接口,解析逻辑都是通用的。
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }配置文件方面,我用application.yml区分了开发环境和生产环境。开发环境本地数据库,生产环境用服务器上的MySQL,两份配置只是数据源地址不同。
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/gym_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.gym.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case: true这个配置一定要开。数据库字段是course_name,实体类属性是courseName,开了之后MyBatis自动映射,不用写一堆resultMap。不开的话,你每个查询都要手写映射,纯属折磨。
2.2 MyBatis映射文件与SQL实战
在Mapper接口之外,我把SQL都写在resources/mapper目录下的XML文件里。有人觉得注解写SQL更简单,但遇到动态SQL、批量更新、多表关联的时候,XML的可读性和可扩展性远好于注解。
举一个典型场景:课程列表筛选。前台页面要根据分类ID、课程名称、上架状态、价格区间筛选课程,还可能排序。用注解写就会很痛苦,用XML动态SQL则非常清晰:
<select id="selectCourseList" resultType="com.example.gym.entity.Course"> SELECT c.*, cat.name AS categoryName FROM course c LEFT JOIN category cat ON c.category_id = cat.id <where> <if test="categoryId != null"> AND c.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND c.name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != null"> AND c.status = #{status} </if> </where> ORDER BY c.create_time DESC </select>这里的<where>标签和<if>标签都是MyBatis动态SQL的核心。注意一个细节:LIKE查询我用了CONCAT('%', #{keyword}, '%'),而不是直接在Java代码里拼好%再传进去,这样可以避免SQL注入风险。
预约流程是整个系统业务逻辑最复杂的部分。一个会员只能预约未开始的课程,同一门课不能重复预约,课程名额已满则不能预约。这些判断虽然可以在前端做,但后端必须做校验,因为接口是可以被直接调用的。我讲一个后端实现预约的伪业务逻辑:
public boolean createBooking(Booking booking) { Course course = courseMapper.selectById(booking.getCourseId()); if (course == null || course.getStatus() != 1) { throw new BusinessException("课程不存在或已下架"); } if (course.getStartTime().before(new Date())) { throw new BusinessException("课程已开始"); } int count = bookingMapper.countByMemberAndCourse(booking.getMemberId(), course.getId()); if (count > 0) { throw new BusinessException("您已预约过该课程"); } int bookedCount = bookingMapper.countByCourse(course.getId()); if (bookedCount >= course.getCapacity()) { throw new BusinessException("课程名额已满"); } bookingMapper.insert(booking); return true; }这段逻辑看似简单,但可以展开说两个值得注意的点。第一,判断课程存在与否时,course == null和course.getStatus() != 1是两种完全不同的错误,一定要分开抛给前端不同的提示,别笼统返回“课程不存在”。第二,并发场景下两个人同时抢最后一个名额,上面的判断会出现超卖。数据量不大的健身俱乐部可以接受,但如果要做严谨版本,需要在booking表加唯一索引(member_id + course_id),或者给course表的booked_count字段加乐观锁版本号。我把唯一索引加了,这是成本最低的兜底方案。
2.3 登录认证与拦截器解析
登录我采用的是JWT方案。流程很简单:用户在Vue端输入账号密码,后端校验通过后生成一个Token返回前端,前端把Token存在localStorage里,之后每个请求在请求头带上Authorization: Bearer xxx,后端拦截器校验Token有效性,并从中取出用户ID放进去。
JWT本身就是一个自带签名的JSON字符串,由Header、Payload、Signature三部分组成。这里的核心要点是:不要把密码放在Token里,也不要让Token的有效期太长,我设置的是24小时。虽然没做RefreshToken那套复杂机制,但24小时对健身俱乐部这种场景够了。
拦截器的路由匹配需要仔细设计。我写了两套拦截器:
- 会员端拦截器,拦截
/api/member/**下面的所有接口 - 管理员拦截器,拦截
/api/admin/**下面的所有接口
登录接口(/api/login、/api/register)需要放行,轮播图和课程列表这类前台公开接口也需要放行。我的实际做法是,在前台页面不强制登录的时候,Controller可以抽出一套不带校验的公开查询接口,放在/api/public/**路径下,全部放行。这样做的好处是前端可以清晰区分哪些接口需要带Token。
2.4 全局异常处理
后端最容易忽视但又极其关键的部分就是全局异常处理。没有它的话,MyBatis抛出的SQL异常会以默认的Spring错误页形式返回给前端,前端拿到的是HTML而不是JSON,解析必然失败。
我用@RestControllerAdvice统一处理三类异常:
- 自定义的
BusinessException,对应业务校验失败,返回code 400 - 参数校验异常
MethodArgumentNotValidException - 兜底
Exception,记录错误日志,返回code 500
这样前端无论遇到什么错误,拿到的都是{code, message, data}结构,统一拦截器里统一弹ElMessage提示,代码会清爽很多。
3. 前端Vue落地要点
3.1 工程初始化与路由拆分
前端我用的Vue 3 + Vite + Element Plus + Pinia + Vue Router。搭建指令直接看官方文档即可,真正需要注意的其实是Vite的代理配置。
前端开发服务器默认跑在5173端口,后端接口跑在8080端口。开发阶段如果直接在axios里写http://localhost:8080这样的绝对地址,就会遇到跨域问题。虽然可以通过后端加CORS头解决,但更优雅的方式是前端配置代理,让浏览器以为请求是同源的。
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端请求/api/course/list,Vite开发服务器就会帮我们把请求转发到http://localhost:8080/api/course/list,浏览器地址栏看到的请求是同源的,就不会有跨域报错。
3.2 页面路由与权限守卫
路由设计上,我把前端页面分成两类:公开页面和管理页面。公开页面包括首页、课程列表、课程详情、教练团队、登录注册;管理页面包括课程管理、教练管理、预约管理、会员管理等。
管理页面不能直接通过URL访问,需要在路由配置里加meta: { requiresAuth: true },然后在全局前置守卫里判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('adminToken') if (to.meta.requiresAuth && !token) { next('/admin/login') } else { next() } })这里还有一个细节:如果用户已经登录了,再访问登录页,应该直接重定向到管理首页,否则会有一种“登录后又看到登录页”的割裂感。
菜单侧边栏我采用的是静态配置方案,管理端只有三个一级菜单:课程管理、教练管理、预约管理、会员管理。没有做多角色动态路由,因为后台管理员角色单一。如果你要扩展多级权限,再考虑动态路由生成方案也不迟,但先说结论:静态路由够用就不要上动态,动态路由的调试成本远高于收益。
3.3 API封装与请求拦截
axios如果不封装,项目里会出现大量重复代码。我统一封装了request.js,导出get、post、put、delete四个方法。核心逻辑在请求拦截器和响应拦截器里:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.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.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message) return Promise.reject(error) } )注意一个设计取舍:我在响应拦截器里把res.data直接返回了,而不是返回整个res。这样业务代码里写const list = await getCourseList()拿到的就是数组本身,不用每次res.data.data地解包。有人会觉得这样不够直观,但我实测下来代码量变少,可读性提升非常明显。
上传功能在管理端经常用到。健身俱乐部项目里课程图片、教练头像都依赖文件上传。如果拿SpringBoot做文件上传处理,需要注意Nginx代理上传大小限制和静态资源路径。我采用的是最简单的方式:SpringBoot接收MultipartFile文件,存到服务器指定目录,然后通过Nginx将/images/路径映射到该目录,前端上传时后端返回图片URL,前端直接使用该URL展示。
3.4 前端页面实现细节
首页设计上,我使用轮播图banner区、课程推荐区、教练介绍区三大块。课程卡片组件用Element Plus的el-card嵌套实现。数据加载统一走onMounted里调用API,加载状态用v-loading指令控制。为了避免页面白屏时用户看到空白,每个区块都有单独的空状态提示。
课程详情页相对复杂:需要展示课程图片、名称、分类、教练、价格、上课时间、剩余名额、课程描述。这里注意一个前端展示细节:剩余名额如果为0,按钮要置灰并显示“已满”;如果当前时间晚于课程开始时间,按钮显示“已结束”。这些状态如果让前端自己判断,需要同步系统时间,容易出现偏差。我后端在返回课程详情时直接计算并返回状态字段(0未开始,1已满,2已结束,3已下架),前端只负责根据数字渲染按钮状态。类似这种“状态应该在哪个端算”的问题,是一个前后端分离新手最容易犯迷糊的地方,我的原则是:任何跟业务规则相关的状态都由后端算好,前端只做展示。
4. 数据库设计与MySQL优化
4.1 核心表结构设计
关于建表,我用SQL脚本直接初始化了两组测试数据,并在部署文档里提供了完整的gym_db.sql文件。这里以最核心的booking预约表为例:
CREATE TABLE `booking` ( `id` INT NOT NULL AUTO_INCREMENT, `member_id` INT NOT NULL COMMENT '会员ID', `course_id` INT NOT NULL COMMENT '课程ID', `course_name` VARCHAR(100) DEFAULT NULL COMMENT '冗余课程名称', `coach_name` VARCHAR(50) DEFAULT NULL COMMENT '冗余教练姓名', `booking_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '预约时间', `status` TINYINT DEFAULT 0 COMMENT '0待确认 1已确认 2已取消', PRIMARY KEY (`id`), UNIQUE KEY `uk_member_course` (`member_id`, `course_id`), KEY `idx_course_id` (`course_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三个索引的用意要说明一下:
- 主键索引是InnoDB聚簇索引,每条记录的物理存储顺序都依赖它
- 唯一索引
uk_member_course防止同一会员重复预约同一门课,这是数据库层面的防重保障 - 普通索引
idx_course_id加速通过课程ID查询预约记录
很多初学者建表时不加索引,数据量小看不出问题,等数据量上万之后慢查询就来了。我给课程表的name字段加了普通索引,给status和category_id建了组合索引,这样列表页的筛选排序不会全表扫描。
4.2 字段类型与字符集
字符集统一使用utf8mb4,这一点很重要。MySQL老版本默认的utf8其实是utf8mb3,只能存基本3字节字符,遇到emoji或者生僻字直接报错。做网站系统,用户可能在任何输入框粘贴任何内容,用utf8mb4是通用稳妥的选择。
时间字段类型有两种常见方案:DATETIME和TIMESTAMP。我统一用的是DATETIME,因为它的范围更广,不会出现2038年问题。时间存储的另一个细节:数据库连接串里设置了serverTimezone=Asia/Shanghai,就是为了避免MyBatis映射时间时因为时区差8小时导致前端显示时间不对。
价格字段我用的是DECIMAL(10,2)而不是FLOAT或DOUBLE。浮点数在计算机里本身是近似值,计算0.1+0.2会出现0.30000000000000004这种结果,涉及金额就必须用高精度定点数。健身俱乐部课程价格虽然只有两位小数,但规范就是规范,涉及钱的字段一律DECIMAL。
4.3 查询优化与慢SQL排查
项目里最容易出现慢查询的是两个场景:首页课程推荐列表带教练信息,以及后台预约管理列表带会员信息。我的做法是,尽量在多表查询时只select需要的字段,而不是select *。虽然select *写起来省事,但会白白增加IO和网络传输开销。
另外我还在本地开启了MySQL慢查询日志,方法是在my.ini里加上:
slow_query_log = 1 slow_query_log_file = /var/log/mysql-slow.log long_query_time = 2把超过2秒的查询全部记录下来,然后逐条分析。实际排查的时候发现,问题往往不是SQL本身写得差,而是索引没建。比如预约记录表里按status查询的时候,如果没有索引,MySQL只能全表扫描。加了一个单列索引之后,查询耗时就降下来了。
5. 部署与上线实操
5.1 本地开发环境准备
本地环境需要安装JDK 8+、Maven 3.6+、MySQL 8.0、Node.js 16+。这里我重点说一下MySQL 8.0的一个坑:8.0默认使用caching_sha2_password认证插件,而一些旧版本JDBC驱动不支持。所以SpringBoot项目一定要用mysql-connector-j8.0.33及以上版本,否则启动项目时会报Public Key Retrieval is not allowed的错误。
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>还有个很容易忽略的坑:项目的serverTimezone不设置,连接数据库非常容易报时区错误。我用的是Asia/Shanghai,注意不要直接填GMT+8,因为Java 8的时区解析对GMT+8这种格式在某些版本上会报错。
5.2 前端构建与静态资源部署
前端开发完成后,在项目根目录执行:
npm run buildVite会生成一个dist目录,里面是纯静态文件:HTML、CSS、JS、图片。这个目录可以直接丢给Nginx托管。
Nginx配置里我设置了两个location块:
server { listen 80; server_name your-server-ip; # 前端静态页面 root /opt/gym-web/dist; index index.html; # 所有 /api 请求转发到后端 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 /images/ { alias /opt/gym-upload/; } }注意proxy_pass的两个写法区别:http://127.0.0.1:8080是带URL路径的,会把/api前缀保留传过去;如果写成http://127.0.0.1:8080/,则会把/api前缀去掉。前端axios的基础路径是/api,后端Controller的路径映射也是/api/course/list这种带/api前缀的,因此用第一种写法,不需要rewrite。
5.3 SpringBoot后端打包发布
后端打包前要先确认application.yml里的数据库地址已经指向服务器MySQL,然后执行:
mvn clean package -DskipTests生成的gym-server.jar在target目录下。本地测试时可以:
java -jar gym-server.jar线上环境我是用systemd管理服务,比直接用nohup更规范,还能实现开机自启和异常自动重启。/etc/systemd/system/gym.service内容如下:
[Unit] Description=Gym Server After=network.target [Service] ExecStart=/usr/bin/java -jar /opt/gym-server/gym-server.jar Restart=on-failure [Install] WantedBy=multi-user.target启动命令:
systemctl daemon-reload systemctl start gym systemctl enable gym日志用journalctl -u gym -f查看,比nohup.out好定位问题。
部署完之后还有一个小事:Nginx要配置gzip on,对前端静态资源开启压缩,Vue打包出来的JS和CSS文件比较大,开启后传输体积能减少60%以上,首屏加载速度提升非常明显。
5.4 部署拓扑图与检查清单
没有用Mermaid画图,但部署链路其实是一条很清晰的链路:浏览器请求80端口,Nginx拿到请求后判断:如果是/api开头的动态接口就转发给8080端口的SpringBoot,如果是普通路径就去/opt/gym-web/dist下找静态文件。SpringBoot再通过3306端口连接MySQL查询数据。上传的图片存到/opt/gym-upload目录,Nginx再通过/images/路径暴露出来。
我每次部署完都会按下面这个清单自测一遍:
- 首页能否打开,刷新不404
- 课程列表能否加载出数据
- 注册、登录成功后能否跳到首页
- 登录后预约课程,管理端能否看到预约记录
- 图片URL是否正常显示
- 服务器重启后前后端服务是否自动拉起
这个清单看着简单,实际上每一行都踩过坑。比如“刷新不404”这件事,如果Nginx没有配置try_files $uri $uri/ /index.html;,Vue路由是history模式时,刷新某个子路由页面会直接变成Nginx的404页面。这个问题几乎每个新手都会遇到,而解决只需要一行配置。
6. 高频问题与排坑实录
6.1 跨域请求被拦截
这个问题的现象是浏览器控制台出现CORS policy或者Access-Control-Allow-Origin报错。
我上面已经讲过Vite代理是最优雅的开发期方案。但如果你不是通过Vite代理,而是直接用axios请求完整地址,那就需要后端开跨域。SpringBoot里可以用一个配置类统一处理:
@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); } }这里要注意:allowCredentials(true)时,allowedOrigins不能设置为*,必须用allowedOriginPatterns("*"),否则会在某些浏览器版本上报错。
6.2 数据库连接失败与SSL报错
连接MySQL 8.0时最容易遇到的三个报错分别是:Access denied for user、Public Key Retrieval is not allowed、SSL connection error。
第一个是账号密码错误或没有远程访问权限,解决办法是检查密码并对该用户执行GRANT ALL PRIVILEGES ON gym_db.* TO 'root'@'%';。第二个是驱动版本问题,升级到8.0.33即可。第三个的解决方式是在连接串加useSSL=false。我这里再补一句:连接串里的characterEncoding=utf8也经常会踩坑,如果在useUnicode=true&characterEncoding=utf8之间少写了useUnicode=true,那MySQL会忽略掉字符集设置。这是个很隐蔽但很容易出现的小毛病。
6.3 MyBatis映射文件找不到
项目启动时报Invalid bound statement (not found),十有八九是XML文件没有被打包或者扫描路径不对。排查顺序:
application.yml里的mapper-locations是否正确指向classpath:mapper/*.xml- XML文件的namespace是否对应Mapper接口全限定名
- XML文件里的语句ID是否和接口方法名一致
- 如果XML放在src/main/java目录下,需要在pom里配置resources插件,否则不会打进classes目录
我建议XML文件统一放在src/main/resources/mapper目录,避开最后那个坑。顺便说一个MyBatis的进阶细节:如果项目中需要自定义枚举类型转换,不要只依赖默认的EnumTypeHandler,比如课程状态如果想存成字符串而不是数字,就要自定义TypeHandler。虽然这个项目里我统一用数字存状态,但是要理解这个用法,后面遇到特殊类型时能少走弯路。
6.4 前端刷新后404
这个问题的原因和解决办法上面已经说过,是Vue Router的history模式需要服务端配置fallback。Nginx下的完整写法:
location / { try_files $uri $uri/ /index.html; }如果你的前端部署在子路径下(比如/gym),路由base也要同步设置,Nginx的try_files路径也要改成/gym/index.html,三者必须一致,少一个就会出现404或者白屏。
6.5 Element Plus 按需引入的问题
使用Element Plus时,很多同学直接在main.js里全量引入,开发方便但打包体积大。我采用的是官方推荐的unplugin-auto-import和unplugin-vue-components按需自动导入方案。这样组件和API都能自动引入,代码里不需要写import { ElMessage } from 'element-plus'。注意一点:按需自动导入之后,ElMessage这类函数式API仍然可以正常编译,不会报ElMessage is not defined。如果真遇到这个报错,大概率是你清空了auto-imports.d.ts文件或者没把插件写进Vite配置,检查这两处即可。
6.6 文件上传失败与图片无法访问
上传失败通常分几种情况:文件太大,Nginx默认client_max_body_size是1m,需要在server块里调大到20m;路径不存在,/opt/gym-upload目录必须提前创建好并给足权限;图片无法访问多半是Nginx alias路径写错了。这里有个注意点:alias路径结尾的/必须带,否则拼接路径会出问题。
上传方面,如果有人想更进一步,可以把MinIO引入到SpringBoot里做对象存储。MinIO的好处是自建对象存储服务,可以将图片、视频统一管控,前端展示时使用MinIO提供的链接。M3U8视频播放这种场景在健身课程回放里也会遇到,Vue端可以用hls.js库实现免安装播放。这个项目里我用的还是最朴素的本地存储加Nginx映射,但如果你未来要做视频课程,建议直接上MinIO加HLS切片方案。
7. 实操心得与扩展方向
这个项目前前后后做了两周多,前端的页面搭建了一天,后端接口和SQL写了一周,剩下时间都在排坑和打磨细节。我最大的体会是:前后端分离项目的难点从来不是某个框架的API记不住,而是如何把前后端之间的契约(接口约定、状态码、异常格式、跨域方式、上传机制)搞清楚。只要把契约定清楚,前后端就可以完全并行开发,这是这类项目的最大红利。
给正在做类似系统的同学几个方向上的建议:
第一,不要一上来就写代码。先用半天时间把表结构设计好,把接口文档(哪怕只是Excel表格)列清楚。我这次因为前期先把接口清单列出来了,前后端联通几乎没返工。第二,一定要做全局异常处理和统一返回体,不要在Controller里散落各种返回格式。第三,自己在本地把打包、部署流程走通一遍,最好能买一台最便宜的云服务器实战一次。我敢说,会部署和不会部署,对一个开发者能力的评价差别非常大。
最后分享一个收尾阶段的小技巧:上线前用Postman把所有接口按模块建立Collection,每个模块设置不同的环境变量,比如{{baseUrl}}、{{token}},这样后续接口回归测试会非常高效。我每次改完一个接口,都会先跑一遍Postman里的相关请求,确认没问题再推送前端,这套流程帮我少改了很多返工代码。
项目本身后续还可以扩展的方向有:课程表加入视频回放功能并接入Vue播放器、教练端增加排课日历、会员端增加消费记录和积分系统、后台增加数据统计图表面板。核心架构不用大改,只需在现有模块上不断添加表和业务逻辑即可。这套代码的最终形态,其实就是把一个健身俱乐部的线下运营搬到了线上,这也是它能作为前后端分离实战案例被反复选用的根本原因。