前后端分离的Java项目这几年在技术社区里一直热度不减,尤其是一套能跑通完整业务流的SpringBoot2+Vue3系统,对做毕业设计、转行练手或者小团队自用来说都非常合适。最近我经手了一套健身房管理系统的完整源码,技术栈是SpringBoot2+MyBatis-Plus+MySQL8.0做后端,Vue3+Element Plus做前端,还附带了数据库脚本和说明文档。我花了一整天把整个项目从环境搭建到最终部署完整跑了一遍,过程中踩了不少坑,也顺手梳理了这套系统的设计思路和核心代码逻辑,今天一次性把这些东西都写出来。
这篇文章不是简单贴源码,而是想清楚讲明白这套健身房管理系统“为什么这样设计”、“关键代码怎么落地”、“哪些地方容易翻车”。如果你正准备用类似技术栈做一个带会员管理、课程预约、器材报修这类业务场景的Web系统,或者想找一个结构清晰的前后端分离项目来学习,这篇文章应该能帮你少走很多弯路。
1. 项目全貌:健身房管理系统到底在管理什么
1.1 业务角色与核心流程拆解
健身房管理系统乍一听范围挺大,实际上核心业务是非常具体的。我拿到源码后先做了两件事:看数据库表结构,再看前端路由。前后端两侧一对齐,整个业务边界就清晰了。
这套系统的业务角色主要分成四类:前台接待、会员、私教教练、系统管理员。前端路由和后台菜单都是围绕这四个角色展开的。
前台接待的日常工作最繁琐,核心场景是会员到店后的开卡、续费、充值、扣次,还有处理课程预约和器材报修登记。系统里对应的是“会员管理”和“收银中心”两个模块。
会员端相对简单,核心是查看自己的卡信息、剩余次数、预约记录,以及在线预约团操课或私教课。
教练端最核心的功能是排课和查看自己的课表,教练可以在一段时间内添加可授课时段,会员在教练开放的时段内进行预约。
管理员端则是全局视角,管理员工账号、课程分类、器械档案、财务报表,以及查看各类统计图表。
整个系统的核心闭环是这样的:前台上新会员开卡 -> 会员到店刷手环/报手机号消费 -> 会员约课 -> 教练上课 -> 器材出现问题走报修流程 -> 财务每天查看营业额。所有的功能模块都是围绕这条业务链展开的,这也是为什么这套系统比简单做几个CRUD页面有价值得多。
1.2 功能模块地图:一套系统怎么覆盖健身房日常
我按照源码里的控制器和前端页面,把功能模块画了一张脑图。这里帮你用文字梳理出来,你在看源码的时候可以对照着看:
- 登录认证模块:验证码登录、JWT签发、路由权限控制
- 会员管理模块:会员档案CRUD、会员卡开卡、续费、充值、挂失/补卡、到期提醒
- 课程管理模块:团操课分类、私教课分类、课程排期管理、教练排课
- 预约管理模块:会员端预约课程、预约记录查询、教练端查看预约名单
- 器材管理模块:器材档案登记、器材报修、维修状态流转、保养计划记录
- 收银与财务模块:充值计费、消费扣费、收支流水、营业统计报表
- 报表统计模块:会员增长趋势、到店率统计、课程预约热度、卡项销售占比
- 系统管理模块:员工账号管理、角色权限分配、菜单管理、操作日志
我实际看代码的时候发现,源码里还额外做了一个“操作日志”功能,用AOP切面记录管理员的所有写操作,包括操作人、操作时间、请求路径、参数、执行结果。这个功能虽然不起眼,但在答辩或面试时是一个不错的加分项,因为它在业务代码之外展示了切面编程的实战能力。
2. 技术栈选型思路:为什么偏偏是这套组合
2.1 SpringBoot2 + MyBatis-Plus:把开发效率放在第一位
当前做Java Web项目,技术选型上有几种主流路线:Spring Boot 2搭配Spring Data JPA,Spring Boot 2搭配MyBatis-Plus,以及更新一些的Spring Boot 3搭配MyBatis-Plus。这套源码选的是SpringBoot2.x加MyBatis-Plus的组合。
为什么这么选?这里有一个很实在的权衡逻辑。Spring Boot 3要求JDK17起步,虽然性能更好,但对很多还在用JDK8的开发环境不太友好。SpringBoot2.x版本对JDK8的支持非常成熟,生产环境稳定时间足够长,学习资料也最多。更重要的是,SpringBoot2.x搭配MyBatis-Plus的生态非常成熟,网上无论遇到什么问题几乎都能搜到解决方案。
再说MyBatis-Plus。它对比原生MyBatis最大的优势是“单表CRUD免写SQL”。用户表、卡种表、预约记录表这类基础表的增删改查,全部用BaseMapper提供的方法搞定,连SQL都不用写一行。分页查询也内置了物理分页插件,只需要配置一个PaginationInnerInterceptor就能自动拼接LIMIT语句。
我用这套源码里的“会员列表”查询举个例子。后端接收page、limit、keyword三个参数,用LambdaQueryWrapper构造查询条件,一行代码都不用写XML就能完成分页搜索:
public PageResult<Member> pageMember(long page, long limit, String keyword) { LambdaQueryWrapper<Member> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Member::getName, keyword) .orderByDesc(Member::getCreateTime); Page<Member> p = new Page<>(page, limit); memberMapper.selectPage(p, wrapper); return new PageResult<>(p.getTotal(), p.getRecords()); }这个代码块体积不大,但解决了查询列表80%的场景。真正复杂的报表统计SQL才会手写到XML里,这也是MyBatis-Plus在实际项目中非常受欢迎的核心理由:简单场景零SQL,复杂场景能兜底。
2.2 Vue3 + Element Plus:SPA交互比传统模板引擎舒服在哪
健身房管理系统如果放在十年前,大概会用JSP加Bootstrap前后端混在一起写。现在这一套选择了前后端彻底分离:后端只提供JSON接口,前端用Vue3做单页应用。
前端这边用了Vite构建工具、Vue3的组合式API、Element Plus组件库、Pinia状态管理,路由用的Vue Router。这个技术组合在目前的前端开源项目里非常常见。
我看了前端代码后发现一个值得学习的点:封装了一个统一的request模块。所有HTTP请求都走同一条通道,统一挂载token、统一处理401跳转、统一弹错误提示。
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' 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( res => { const data = res.data if (data.code !== 200) { ElMessage.error(data.msg || '请求失败') return Promise.reject(new Error(data.msg)) } return data }, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(err.message || '网络异常') return Promise.reject(err) } )这是一个非常标准的前端封装思路。所有接口都统一返回{code, msg, data}格式,code为200表示成功,登录过期统一跳转页面。看起来简单,但在前后端分离项目中,这套封装能省下大量重复代码。
2.3 MySQL8.0:特定版本的坑与配置
数据库用了MySQL8.0,这在当前已经是主流版本。不过MySQL8.0和MySQL5.7之间存在几个实际坑,很多第一次使用的人会栽在这里。
第一个坑是驱动类名变了。MySQL8.0对应的JDBC驱动类不再是com.mysql.jdbc.Driver,而是com.mysql.cj.jdbc.Driver,同时URL需要额外加上时区和公钥配置:
spring: datasource: url: jdbc:mysql://localhost:3306/gym_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver这里有几个细节值得说明。serverTimezone=Asia/Shanghai如果不配,默认和本地时间相差8小时,查出来的时间全部是错的。allowPublicKeyRetrieval=true如果不配,使用一些客户端连接时会直接报Public Key Retrieval is not allowed的错误。
第二个坑是字符集。MySQL8.0默认字符集是utf8mb4,实际上这比5.7时代的utf8要合理得多,但前提是建库时显式指定。如果建表时没有指定字符集,遇到表情符号存储会直接报错。这套源码的初始化SQL里在建表语句尾部显式加了ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,这个细节值得学习。
第三个坑是密码加密规则。MySQL安装时如果选择了默认加密方式,正好配合MySQL8.0自带的caching_sha2_password,但某些老版本的图形化客户端可能连不上。这种情况要么换新客户端,要么在MySQL里把用户加密方式改成mysql_native_password,我在部署文档里看到源码作者把这个情况的解决方案也写进去了。
3. 数据库设计:这套系统最值钱的地基
3.1 会员业务相关的表设计
会员模块是整套系统的核心。我在数据库里翻了半天,发现围绕“会员”这个概念拆了很多张表,互相咬合得非常清晰。
主表member存的是会员基础档案,包括姓名、手机号、性别、生日、身份证号、紧急联系人、开卡门店等。注意手机号字段在数据库里设了唯一索引,因为手机号是会员登录和身份识别的主要凭证。
卡种表card_type定义健身房在售的各类会员卡,字段大致是:卡名称(月卡、季卡、年卡、次卡)、卡类型(时长卡/次数卡)、有效时长天数、总次数、价格、状态。这张表本身不复杂,但它是一个典型的数据字典表,跟会员卡是分离开的。
真正跟会员绑定的卡是member_card表,记录的是某个会员在某段时间内持有的具体卡实例。字段包括会员id、卡种id、开卡时间、到期时间、剩余次数、状态。这里有一个关键设计:冗余了卡种的快照信息。如果卡种价格改了,已经售出的卡不受到影响,这样处理后续账目核对时非常方便。
还有一张recharge_record表,记录会员的每次充值,字段包括会员id、充值金额、赠送金额、支付方式、操作员id、充值时间。这种流水表的共同设计原则是:只增不改,如果要退款也是新插一条负向记录,而不是去修改原来的记录。这种模式叫流水账,财务做账的时候离不开它。
3.2 课程预约与教练排期:防止冲突的关键设计
健身房系统的复杂度很大程度上集中在课程预约上。这套源码用三张表来处理这块业务。
教练表coach跟员工表employee是分开的。教练的个人基本信息在employee里,教练特有的职业资质、主教课程类型、课时费等信息放在coach表里,两个表用employee_id关联。这种“主数据 + 扩展信息”的模式在很多业务系统里都会用到。
课程排期表course_schedule存的是“未来某天某个时段教练会上某节课”这一事实。字段包括课程id、教练id、上课日期、开始时间、结束时间、预约人数上限。
预约记录表booking_record存的是每个会员的预约行为:排期id、会员id、预约时间、状态(已预约/已取消/已上课/已爽约)。
这套预约系统在数据库层面最值得关注的是“防冲突”策略。一个教练同时间段不能排两节课,一个会员同时间段不能预约两节课。源码里做的是一个双重保障:应用层代码在插入前先查一遍重叠区间,数据库层给coach_id和start_time、end_time建了唯一索引。
代码层面的查询逻辑是这样的:
SELECT COUNT(*) FROM course_schedule WHERE coach_id = #{coachId} AND #{endTime} > start_time AND #{startTime} < end_time这个判断两个时间段是否重叠的写法很经典,用“新结束时间大于旧开始时间且新开始时间小于旧结束时间”来判定有交集。比直接比较一个相等条件要严谨得多。
3.3 器材报修与员工管理:状态机的妙处
器械管理模块看着简单,实际处理好需要状态流转意识。器材表equipment的字段包含器械名称、编号、所在区域、购买日期、当前状态,而状态值是这套表设计里最有意思的地方。
器材状态在系统里被定义成一个枚举:正常、维修中、已报废。一次完整的报修流程是这样的:前台发现器材故障,在equipment_repair表里新增一条报修单,状态设为待维修;维修师傅接单后状态改为维修中;维修完成状态改为已修复;管理员复核后把器材主表状态切回正常,如果发现彻底坏了则改为已报废。
这个状态流转模型放到任何工业类管理系统里都通用。它的核心收益是:维修历史和器材主数据分离,每一次维修都有完整的留痕记录。即使后面要统计某台器械一年修了几次,查维修表就能快速得出答案,不需要去解析主表里的备注字段。
3.4 RBAC权限表:四张经典表打天下
系统管理部分用了最经典的RBAC模型,四张核心表:user表(员工登录账号)、role表(角色)、menu表(菜单)、user_role和role_menu两张关联表。
这套方案的逻辑很清晰:用户不直接和菜单权限挂钩,而是先挂一个角色,角色再挂一批菜单。前台的登录用户被分到“前台”角色,教练被分到“教练”角色,管理员被分到“管理员”角色。
我看到源码里后端登录成功后会查询用户对应的菜单列表,返回给前端。前端拿到菜单数据后动态生成侧边导航栏,没有权限的菜单直接不渲染。这里的权限控制粒度精确到按钮级别,比如管理员可以删除会员,普通前台只能编辑。
说到RBAC模型,实际项目里有两种做权限的方式:一种是后端每次请求都做接口级别的权限校验,另一种是前端只做菜单显隐控制。这套系统两种都做了,后端用拦截器加自定义注解来校验接口权限,前端用动态路由来控制页面展示。完整的做法应该就是这样,双管齐下才能真正达到权限安全。
4. 核心业务实现:手把手拆解关键逻辑
4.1 登录鉴权链路:JWT配合拦截器的完整闭环
登录部分,这套源码没有用极易引入复杂配置的Spring Security,而是选择JWT配自定义拦截器。这个选择对毕业设计级别和中小型项目来说很合理,代码更直观,也更容易在答辩时讲清楚。
登录流程是这样的:
用户输入账号密码和验证码 -> 后端校验验证码 -> 校验账号密码 -> 登录成功后生成一个JWT令牌返回给前端。
public String login(LoginDTO loginDTO) { Captcha captcha = captchaService.get(loginDTO.getUuid()); if (captcha == null || !captcha.getCode().equalsIgnoreCase(loginDTO.getCaptcha())) { throw new BusinessException("验证码错误"); } User user = userMapper.selectOne(new LambdaQueryWrapper<User>() .eq(User::getUsername, loginDTO.getUsername())); if (user == null || !PasswordUtil.matches(loginDTO.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } if (user.getStatus() == 0) { throw new BusinessException("账号已被禁用"); } String token = JwtUtil.createToken(user.getId(), user.getUsername()); user.setLastLoginTime(LocalDateTime.now()); userMapper.updateById(user); return token; }密码存储这块要特别说一句,这套系统用的不是明文密码,也不是简单的MD5,而是加盐的哈希算法。MD5虽然网上还有人在教程里用,但在真实项目里是明确不推荐的,因为彩虹表攻击可以轻松逆向出弱密码。加盐值之后安全性会高很多。
登录成功后每次请求携带token,后端注册了一个拦截器做统一校验:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("username", claims.get("username")); return true; } catch (Exception e) { throw new BusinessException("登录已过期,请重新登录"); } } throw new BusinessException("未登录"); }这种写法的优势是只要注册拦截器的时候排除掉登录接口和静态资源路径,其他所有需要登录的接口自动受到保护,不需要每个控制器里写重复的鉴权代码。
不过这里有一个容易被忽略的工具类设计,JwtUtil里必须同时包含两个方法:createToken用来生成token,parseToken用来解析token。密钥放在配置文件里而不是硬编码在代码中。我看到源码里把JWT的过期时间也做成可配置项了,默认是2小时,前端通过401响应统一跳登录页。整个链路非常顺畅。
4.2 会员开卡事务处理:多表写入怎么保证数据一致
开卡这个操作表面上看只是往会员卡表里插一条记录,但放到真实业务里,如果操作中断就会出现“钱已收入但会员卡没开出来”的尴尬局面。这套源码在开卡逻辑上用了Spring的声明式事务。
来看一下开卡的核心代码逻辑:
@Transactional(rollbackFor = Exception.class) public void openCard(OpenCardDTO openCardDTO) { // 1. 创建会员档案(如果会员不存在) Member member = createOrGetMember(openCardDTO.getMemberInfo()); // 2. 计算开卡时间与到期时间 CardType cardType = cardTypeMapper.selectById(openCardDTO.getCardTypeId()); LocalDateTime startTime = LocalDateTime.now(); LocalDateTime endTime = startTime.plusDays(cardType.getValidityDays()); // 3. 创建会员卡实例 MemberCard memberCard = new MemberCard(); memberCard.setMemberId(member.getId()); memberCard.setCardTypeId(cardType.getId()); memberCard.setStartTime(startTime); memberCard.setEndTime(endTime); memberCard.setRemainingTimes(cardType.getTotalTimes()); memberCard.setStatus(1); memberCardMapper.insert(memberCard); // 4. 记录收款流水 RechargeRecord record = new RechargeRecord(); record.setMemberId(member.getId()); record.setAmount(cardType.getPrice()); record.setType(1); record.setOperatorId(SecurityUtils.getCurrentUserId()); rechargeRecordMapper.insert(record); }类上标注@Transactional(rollbackFor = Exception.class)表示只要方法内部抛出任何异常,之前执行的插入操作全部回滚,数据库自动撤销。注意rollbackFor = Exception.class这个参数不能少,虽然Spring默认对RuntimeException回滚,但对检查型异常默认是提交事务的。
我在跑代码的时候特意测试了这个异常场景:在步骤3和步骤4之间故意抛出一个运行时异常模拟系统崩溃。实测结果是member表、member_card表、recharge_record表全部没有新增数据,事务生效了,不会出现脏数据。
这里需要提一个Spring事务的经典大坑:事务自调用失效。如果在一个类内部的另外方法里调用这个openCard方法,注解会失效,因为事务是通过代理对象生效的,直接调用本类方法是绕过代理的。我看到源码里的调用方式是从Controller层调Service层,跨Bean调用,规避了这个问题。很多人开发时在这个问题上翻车,可以参考这个写法。
4.3 课程预约冲突检测:这段逻辑是精髓
课程预约的并发冲突是这套系统里最值得深入研究的一段逻辑。表面上需求很简单:会员选一个课,点预约按钮,系统判断是否冲突,不冲突则写入预约记录。
先看判断冲突的核心代码:
public void bookCourse(BookingDTO bookingDTO) { CourseSchedule schedule = scheduleMapper.selectById(bookingDTO.getScheduleId()); if (schedule == null) { throw new BusinessException("课程排期不存在"); } if (schedule.getBookedCount() >= schedule.getMaxCount()) { throw new BusinessException("该课程已约满"); } // 检查会员是否在同一时间段已有预约 LambdaQueryWrapper<BookingRecord> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(BookingRecord::getMemberId, bookingDTO.getMemberId()) .eq(BookingRecord::getStatus, 1) .apply("({0} < end_time AND {1} > start_time)", schedule.getStartTime(), schedule.getEndTime()); Long count = bookingRecordMapper.selectCount(wrapper); if (count > 0) { throw new BusinessException("您在该时间段已有预约,请先取消原预约"); } // 扣减名额并插入预约记录 scheduleMapper.updateBookedCount(schedule.getId()); BookingRecord booking = new BookingRecord(); booking.setMemberId(bookingDTO.getMemberId()); booking.setScheduleId(schedule.getId()); booking.setStatus(1); bookingRecordMapper.insert(booking); }注意这里查询冲突用了一个动态拼接的条件,.apply("({0} < end_time AND {1} > start_time)", ...) 把两个时间参数拼进SQL。这个写法的门道在于:它在数据库层面判断“待预约课程的开始时间小于已预约记录的结束时间,并且待预约课程的结束时间大于已预约记录的开始时间”,这就是区间重叠判断。
如果两个用户同时点击同一个课最后一个名额,会出现超卖问题吗?源码里给schedule表的booked_count字段做了updateBookedCount操作,SQL是这样的:
UPDATE course_schedule SET booked_count = booked_count + 1 WHERE id = #{id} AND booked_count < max_count这行SQL利用数据库的行级锁和条件更新,保证了多人同时约课时不会出现超卖,只有一条更新语句能成功,另一条的更新影响行数为0,然后抛出约满异常。这种方案也是市面上处理库存扣减比较轻量可靠的做法。
4.4 数据报表统计:复杂SQL的正确打开方式
健身房的经营者最关心的问题是:今天卖了多少钱、来了多少人、哪个课最受欢迎。这套系统的报表统计部分用了几个比较复杂的SQL查询。
以“近7天营业额趋势”为例,核心思路是把营收数据按天分组聚合:
public List<RevenueStatistic> recentRevenue(int days) { LocalDate endDate = LocalDate.now(); LocalDate startDate = endDate.minusDays(days - 1); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); return rechargeRecordMapper.selectRevenueByDateRange( startDate.format(formatter), endDate.format(formatter)); }对应的XML SQL:
<select id="selectRevenueByDateRange" resultType="RevenueStatistic"> SELECT DATE(create_time) AS date, IFNULL(SUM(amount), 0) AS revenue FROM recharge_record WHERE DATE(create_time) BETWEEN #{startDate} AND #{endDate} GROUP BY DATE(create_time) ORDER BY DATE(create_time) </select>这里有一个隐藏的技巧:如果某一天没有任何收入,在结果集中这个日期是缺失的。前端如果直接拿结果画折线图,图表会缺少中间某天导致线条断开。处理办法是前端补全日期或者后端循环填充数据。系统源码里后端专门做了一个补全逻辑,遍历所有日期,缺失的日期补0金额,这样前端拿到的永远是连续7天的数据。
这么一看,报表开发确实不光是写SQL,更重要的是考虑数据缺失、前端图表展示体验这些细节。这套源码里的处理方式是比较规范的做法。
5. 环境搭建与部署避坑实录
5.1 本地开发环境准备:版本矩阵不能乱搭
把项目跑起来的第一步,是准备一套能匹配源码的本地环境。这里我把每个组件的版本要求列出来,这是我实际验证过的组合,缺一个版本都可能出问题:
- JDK 8或11,推荐用JDK8,SpringBoot2对8的兼容性最稳
- Maven 3.6+,用来构建后端模块
- MySQL 8.0.x,注意5.7在部分SQL语法上不兼容
- Node.js 16+,低于16跑不动Vite3构建工具
- npm或pnpm,安装前端依赖
我最初在这个环节犯过一次错:本机是JDK17,直接eveluate后端项目发现SpringBoot2里的某些自动配置类在JDK17下反射会报错。后来切回JDK8立刻正常。所以如果你用JDK17遇到奇怪的错误,第一反应应该是换个JDK版本试试。
前端依赖安装也可能踩坑。npm install在大陆网络环境下经常失败,解决方案是配置镜像源:
npm config set registry https://registry.npmmirror.com系统源码里有package.json,直接进入前端目录执行npm install再npm run dev,本地Vite默认端口是5173。后端端口在application.yml里配置,我看到的版本用的是8080端口。
5.2 配置文件与数据库初始化
数据库初始化是整个搭建过程中最容易出问题的一环。源码中提供了init.sql文件,用MySQL客户端执行一下即可:
mysql -uroot -p < init.sql初始化脚本里创建了数据库gym_system,并包含所有表的建表语句和初始测试数据。里面自带管理员账号,用户名是admin,密码是加盐哈希后的值,登录时没办法直接看到明文,只能正常走登录接口。
我建议你在执行初始化脚本前,先检查一下脚本里是否存在DROP TABLE IF EXISTS语句。如果不存在且本地已经建过同名表,执行脚本会报错。稳妥的步骤是先手动drop掉旧库再导入新脚本:
DROP DATABASE IF EXISTS gym_system; CREATE DATABASE gym_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;后端配置文件里的数据库连接信息务必按本机实际环境修改。尤其是密码,源码里的都是作者本地的dev密码,你自己部署时必须改。
5.3 前后端联调与生产构建
本地开发时前端开发服务器调用后端接口会遇到跨域问题。源码里后端写了一个全局CORS配置类,允许跨域请求,开发阶段直接访问Vite的5173端口就能连上8080的后端。
到了生产部署阶段,前后端分离项目通常是后端打成一个jar包,前端构建成静态文件丢进Nginx。前端构建命令:
npm run build构建产物在dist目录下,把整个dist目录上传到服务器,Nginx配置如下:
server { listen 80; server_name your-domain.com; root /var/www/gym/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }这个配置里最关键的是第二段location /,用try_files把路由回退到index.html。如果不加这段,Vue Router使用history模式时页面一旦刷新,Nginx会直接返回404,因为服务端没有对应的真实文件路径。同理,图片等静态资源也通过这种方式进行访问。
后端jar包启动命令:
mvn clean package -DskipTests java -jar gym-admin.jar --server.port=8080如果Nginx和后端不在同一台机器,proxy_pass里的127.0.0.1要改成后端服务器的真实IP。
6. 常见问题与排查经验速查表
我完整跑通这套项目过程中,整理了遇到过的几个高频问题,每一个都对应真实项目中很典型的坑。这里整理成速查表,方便你直接对照排查。
| 问题现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 前端npm install报错,网络超时或证书错误 | 默认npm源访问慢或网络受限 | 切换npm镜像源,删除node_modules和package-lock.json后重装 |
| 启动后端时数据库连接失败 | MySQL服务未启动或用户名密码错误 | 先确认本机MySQL能正常连接,再核对application.yml中的账号密码 |
| 查询数据库时间比本地少8小时 | JDBC连接串缺少时区参数 | 在数据库连接URL上追加serverTimezone=Asia/Shanghai |
| 分页查询返回全量数据,LIMIT不生效 | MyBatis-Plus分页插件未注册 | 检查是否配置PaginationInnerInterceptor到MyBatis拦截器链里 |
| 点击部署到Nginx的页面刷新变成404 | Vue Router history模式与Nginx不兼容 | 给Nginx加try_files $uri $uri/ /index.html规则 |
| 上传的图片在Linux服务器上访问不到 | 文件存储在本地磁盘路径,Nginx映射没配 | 把文件路径地址用配置文件管理,并让Nginx或SpringBoot代理此路径 |
| 数据库连接每天固定时间断开报错 | 与MySQL wait_timeout有关,连接空闲超时被断开 | 数据库连接池配置timeout参数,执行空闲连接回收 |
| 大批量课程导入时报错事务回滚 | SQL语法或唯一键冲突 | 查看完整异常堆栈,确认是否有唯一索引重复数据 |
| 前端请求总是401 | token过期或请求头未携带token | 检查请求拦截器是否统一挂载Authorization,检查登录态过期时间 |
| 会员卡明明显示剩余次数用完却仍能预约 | booked_count和剩余次数扣减没有放在同一个事务里 | 核对预约方法是否有@Transactional事务控制 |
这里的排查思路不一定只适用于这套系统。很多Java Web前后端分离项目在部署阶段都会遇到一样的坑,养成看日志、看堆栈的习惯特别重要。
7. 一些私房话:这套系统应该怎么去学习和扩展
这套健身房管理系统源码最让我欣赏的地方,是它的业务完整度:会员、卡种、课程、预约、器材、收银、报表、权限全部打通。很多练手项目的通病是只有简单的单表CRUD,你看完只能学到怎么调用框架,完全理解不了业务闭环。这套系统至少像是一个真实可运营的健身房后台,每个模块之间都是有关联的。
如果你要拿它做毕业设计或项目面试讲解,我建议按这个顺序去研究源码:先看数据库设计,把每张表的职责搞清楚,再看后端Controller层怎么暴露接口,然后看Service层怎么组织业务逻辑,最后才是看前端页面怎么对接。
千万不要直接打开前端页面开始点点点,那样你什么都学不到。我带着读者操作的习惯是:把后端代码拉进开发工具,从前到后走一遍请求路径,打开调试模式,顺着一次请求把每一层代码都看一遍。
基于这套系统,还能做不少有价值的扩展。比如增加一个私教课程购买功能,会员可以单独购买私教课时包;增加一个健身房门禁的对接方案,把会员卡数据推送给硬件设备;或者增加一个基于腾讯云或阿里云的短信提醒,给会员发送到期续费通知。这些功能扩展如果写在论文里或面试介绍里,都会是加分项。
我自己在复盘这次完整跑项目的过程中,最大的一个体会是:技术栈本身不是难点,SpringBoot和Vue3的用法都是固定的,真正的难点永远是理清业务关系和数据关系。这套系统把健身房的真实业务压缩成了一个可运行的软件模型,光这一点就够你咀嚼一阵子了。