这年头去健身房办卡容易管理起来是真的烦。我去年帮一位做私教工作室的朋友搞了套管理系统他之前一直用Excel记会员到期日群里接龙约课月底对账对到怀疑人生。后来我给他拆了现有业务用SpringBootVueMySQLMyBatis把这套健身房管理系统从零撸了出来。本文就是这套系统的完整设计与实现复盘里面包含了数据库设计、后端接口、前端页面、部署配置以及我实际开发过程中踩过的一堆坑。如果你正好在写类似的毕设、课设或者想给自己工作室做一套后台管理系统可以直接照着这个思路做。这套系统解决的核心问题其实很明确会员办卡、私教预约、课程排期、营收统计全部线上化。传统的纸质登记和Excel管理第一个问题是卡种到期时间算不准第二个问题是私教课约重了约串了第三个问题是老板想看今天的营收还得翻聊天记录。系统上线之后会员自己能看到剩余次数教练能在后台挂课老板打开首页就能看到一周的流水趋势。这就是打工人和老板双赢的东西。本文给的源码思路偏实用派不搞花架子。我会把项目整体架构、表结构设计、后端核心逻辑、前端页面实现、部署遇到的常见坑一条条拆开讲。适合有Java和Vue基础的人直接上手零基础也能通过这篇文章弄明白一个完整的前后端分离项目是如何串起来的。1. 项目到底在解决什么问题技术栈为什么这么选1.1 健身房管理场景里的真实痛点我接触过几个小型健身工作室他们的日常管理基本长这样会员办卡登记在一本纸质本子上卡种有月卡、季卡、年卡、次卡每次到期了靠前台翻本子查查完还得手动提醒续费。私教课约课靠微信群教练在群里发课表会员回复“约”先到先得结果经常出现两个人同时约同一节课前台最后还要人工裁决。更麻烦的是月底统计营收要把微信转账、支付宝、刷卡记录全部导出来挨个比对。这些问题抽象出来就是三类需求会员生命周期管理、课程预约资源管理、经营数据汇总。会员生命周期管理包括办卡、续卡、到期提醒、会员状态变更课程预约管理包括排课、约课、取消、次数扣减经营数据汇总包括订单流水、卡种销售、课程消耗统计。搞清楚这三块系统的边界就画出来了后面的表和代码都是围着它们转。另外我特意问了朋友的定价和会员体量他那边大概三百多个会员6个教练每天十几节课。这个量级决定了系统不需要太重的架构不需要分布式不需要消息队列。一台普通服务器跑SpringBoot前端用Nginx托管Vue打包后的静态文件MySQL存数据完完全全够用了。如果一开始就引入微服务那才是给自己找麻烦。1.2 为什么是SpringBoot Vue MySQL MyBatis这套组合在中小型管理系统里几乎是标准答案。SpringBoot负责后端接口它最大的优势是约定大于配置起步快内嵌Tomcat打个jar包就能跑。Vue负责前端页面组件化开发让表格、弹窗、表单这些反复出现的东西可以复用数据驱动视图跟后端接口对接非常顺畅。MySQL是主流的关系型数据库存储会员、订单这类强结构化数据再合适不过。MyBatis则把SQL和Java代码解耦复杂查询可以直接手写SQL控制比JPA在某些场景下更灵活。有人可能会问既然都选Java了orm为什么不用JPA而用MyBatis。我的理由是健身房管理系统的报表查询很杂比如统计某个时间段内每种卡种的售卖数量、统计某个教练的课程被预约次数这些直白又带点复杂的SQL用MyBatis写起来最顺手。MyBatis的xml文件里可以放任意SQL控制力很强。JPA虽然也能写Query但实际项目里遇到动态排序、复合条件筛选时MyBatis的体验要好很多。还有一点这套技术栈的学习资料极其多。SpringBoot的版本踩坑、Vue的环境配置、MyBatis的缓存机制、MySQL的安装部署每一个环节遇到问题都能搜到对应的解法。对做毕设或者独立开发者来说这意味着效率。我见过有人用Python Flask或者Node写类似系统不是说不行但在这个领域内Java老一套的稳定性、可维护性和网上可参考的资料数量还是更让人放心。2. 系统整体设计与数据库建模2.1 前后端分离的项目结构是怎么组织的项目在物理上是两个独立工程一个后端springboot-gym-api一个前端vue-gym-admin。后端严格按Controller、Service、Mapper三层来分包。Controller层只做参数接收和结果封装不写业务Service层处理真正的业务逻辑比如办卡时计算到期日、预约时检查冲突Mapper层就是MyBatis的接口跟xml里的SQL一一对应。实体类用entity包DTO和VO分离DTO是接收前端参数的VO是返回前端数据的防止把数据库表结构直接暴露出去。前端的目录结构也很常规。src/api目录下按模块拆接口请求文件src/router配置页面路由src/store用的Vuex管理登录状态和全局用户信息src/views放页面组件src/components放公共组件。页面组件与业务组件分离是重点比如会员管理页里搜索栏、数据表格、新增弹窗都拆成子组件后面新增字段或调整布局时不用在几百行的单文件里大海捞针。前后端通过HTTP JSON接口通信接口前缀统一是/api。前端的开发环境通过Vue CLI的proxy把/api代理到本地的8080端口生产环境则是把后端接口路径跟后端服务部署地址做反向代理匹配。这样做的好处是开发时前端只用npm run dev后端只用启动SpringBoot两边互不干扰上线时前端打包成静态资源扔给Nginx后端单独跑jar包资源互相隔离出问题好排查。2.2 数据库表怎么设计才不返工这是整个项目的地基表结构设计好了后面写代码能省一半力。我按业务模块拆成了四组表用户权限组、会员卡组、课程预约组、订单流水组。用户权限组字段少但很关键。admin表存后台登录用户字段有id、username、passwordBCrypt加密后的密文、real_name、role、status。role字段我用了最简单的字符串设计admin和staff两种没有引RBAC那套权限模型因为小型工作室不需要那么细粒度的权限。菜单权限靠前端路由守卫判断角色来控制。会员卡组是核心中的核心。member表存会员基本信息id、name、phone、gender、birthday、remark、create_time注意这里不直接存卡种名称而是存card_type_id通过关联去查。card_type表存卡种定义id、type_name、duration_days、total_times、price、status。这样设计的好处是会员到期后续卡只需要插入一条member_card_order记录更新member的card_type_id和到期时间历史订单被完整保留月底统计营收直接查订单表就行。课程预约组要处理教练和会员之间的关系。coach表存教练信息course表存课程模板比如“动感单车”“普拉提基础”字段含course_name、course_type、duration_minutes、price、coach_id这里的price是私教课的单价course_schedule表存具体的某一天某一节课schedule_id、course_id、coach_id、class_date、start_time、end_time、max_students、booked_count。预约记录存放在appointment表字段有id、member_id、schedule_id、status、create_timestatus区分已预约、已取消、已完成。订单流水表orders的设计花了我最多心思。字段包括id、order_no、member_id、order_type办卡、续卡、私教课、card_type_id、course_id、amount_paid、pay_type、pay_status、create_time。之所以单独建表而不是在member表里加个数字字段是因为老板对“这个月卖了多少钱”比对“当前多少个会员”更敏感。所有金额变动都走订单表财务数据就有据可查。2.3 接口清单和权限控制思路后端接口按模块划分典型的RESTful风格。会员模块包括GET /api/member/page分页查询、POST /api/member新增会员、POST /api/member/renew续卡、GET /api/member/{id}查询详情。课程模块包括GET /api/course/list、POST /api/course/schedule/add排课、POST /api/appointment/book预约、POST /api/appointment/cancel取消。数据模块包括GET /api/dashboard/sales获取近期营收。接口鉴权我用的是JWT方案。用户登录成功后后端生成一个token返回给前端前端存在localStorage里每次请求由Axios拦截器自动加在Authorization头里。后端写一个HandlerInterceptor拦截除了登录接口之外的所有/api请求校验token合法性再将用户的id和角色放进ThreadLocal供Service层使用。这里有个经验教训拦截器里不要只判断token存不存在一定要解析出过期时间和角色否则随便造个假token就能绕过。另一个经验是密码字段绝对不能返回给前端所以用户详情VO里只放username和role。3. 后端SpringBoot MyBatis核心实现细节3.1 工程初始化的版本与依赖坑SpringBoot的版本选择很重要不同版本的启动器坐标、内嵌Tomcat版本和依赖管理行为都不一样。这个项目我用的SpringBoot 2.7.x搭配JDK8因为SpringBoot 3.x强制要求JDK17很多老机器和云服务器的JDK版本不满足没必要折磨自己。如果你想用新特性再上3.x但要做好MyBatis starter和Druid连接池版本同步升级的心理准备。pom.xml里有几个坐标容易出错我直接说结论。mybatis-spring-boot-starter用2.2.2版本跟SpringBoot 2.7.x兼容良好。MySQL驱动在2.7.x里默认是mysql-connector-java但如果你连的是MySQL 8.0以上建议显式指定8.0.33版本避免时区和SSL的默认配置问题。Druid连接池我用的druid-spring-boot-starter 1.2.20同时引入spring-boot-starter-validation便于做参数校验。还有一个容易忽视的坑是lombok。如果你的IDE是较高版本容易跟旧lombok冲突报出奇怪的编译错误。解决方案是直接指定lombok版本比如1.18.30如果还不行就检查JDK版本和IDE的注解处理器配置。很多人项目没跑起来就卡在这一步浪费大量时间。3.2 application.yml中的连接配置与MyBatis设置配置文件我是这样写的重点在于几个细节参数server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/gym_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.gym.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 自定义配置 jwt: secret: your-secret-key expire-hours: 24这里的细节很多人不知道为什么要这样写。serverTimezoneAsia/Shanghai必须加否则MySQL 8和Java的java.time类型对不上查询日期时间时经常报错。useSSLfalse是因为本地开发一般不配SSL证书开着反而会报SSL连接错误。allowPublicKeyRetrievaltrue是MySQL 8.0.33及以上版本用缓存SHA2密码插件时需要的不加会报Public Key Retrieval is not allowed。mapper-locations指定了xml文件的位置如果你把xml放错目录运行时就会出现Invalid bound statement (not found)的报错。map-underscore-to-camel-casetrue这个配置极其有用它能让数据库的create_time自动映射到实体类的createTime字段不用手写resultMap。log-impl配成StdOutImpl开发阶段每个SQL都会打印到控制台排查问题非常方便上线前再关掉。3.3 会员办理与续卡的业务逻辑实现办卡是系统最核心的流程我举这个例子讲一下Service层的写法。新增会员的时候前端传来的参数是会员基本信息和所选卡种id。Service层先根据card_type_id查出卡种然后计算到期时间。月卡就加30天季卡加90天年卡加365天但次卡不算到期只记录总次数。计算到期时间的时候有个细节不能直接在代码里写“new Date() 30天”要用LocalDate或LocalDateTime的plusDays方法。用java.util.Date做日期计算在跨月和跨年的时候容易出错。正确的做法是public LocalDate calcExpireDate(Integer cardTypeId, LocalDate startDate) { CardType cardType cardTypeMapper.selectById(cardTypeId); return startDate.plusDays(cardType.getDurationDays()); }同时要生成一条订单记录。订单号我用的规则是时间戳加随机数yyyyMMddHHmmss 4位随机数字。这个生成逻辑要放在服务端不能前端传否则容易被伪造。插入订单时pay_status默认置为已支付因为门店办卡一般当场收费。这里我用Transactional注解保证插入会员和插入订单是一个事务任何一步失败都回滚。预约课程的逻辑也值得一提。会员选了一节课要做的操作有三步第一步检查会员卡有没有过期次卡的话剩余次数是否大于0第二步检查这节课的booked_count是否小于max_students第三步插入预约记录并把course_schedule表的booked_count字段加1。这三步必须放在同一个事务里否则并发情况下可能出现两个预约都成功但超员的情况。MyBatis的update是在原值上加一用set booked_count booked_count 1这种SQL写法能天然避免部分并发问题。3.4 MyBatis映射文件与缓存使用的心得MyBatis映射文件我按照实体类分模块建MemberMapper.xml、CourseMapper.xml、OrderMapper.xml这样。xml里的SQL写起来比注解灵活的地方在于动态SQL可以用if标签拼条件。比如会员分页查询经常要根据姓名、手机号、卡状态筛选用注解写会拼出一长串字符串在xml里写就清楚多了。一个典型的分页查询是这样的select idselectMemberPage resultTypecom.gym.entity.Member select * from member where if testname ! null and name ! and name like concat(%, #{name}, %) /if if testphone ! null and phone ! and phone like concat(%, #{phone}, %) /if if teststatus ! null and status #{status} /if /where order by create_time desc limit #{offset}, #{pageSize} /select这里有个SQL注入的安全点要强调if标签里的判断用的是test属性但真正传给SQL的参数用的是#{}不是${}。${}会直接拼接字符串等于把SQL注入的大门敞开。比如排序字段这种实在要用${}的地方一定要做白名单校验只允许传入预设的几个字段名。关于MyBatis缓存我要泼一盆冷水。一级缓存是SqlSession级别的每次方法执行完就失效作用不大二级缓存是Mapper级别的虽然能跨SqlSession共享但如果你用了Spring的Transactional事务提交和缓存刷新的时机容易造成脏数据。我这个项目直接把二级缓存关掉所有查询都走数据库业务量不大根本不需要靠缓存扛。真要提速等到了几千上万的并发再上Redis现在引入反而增加复杂度。4. 前端Vue项目搭建与页面实现4.1 Vue环境配置和项目初始化的那些事开发前端前先把本地Node环境搞定。我建议装Node 16 LTS或18 LTSnpm源换成国内的镜像源否则npm install能卡到你怀疑人生。用Vue CLI还是Vite我这个项目用的是Vue CLI也就是webpack那套因为要配合Element UI生态和proxy配置网上资料也更多。Vite虽然启动快但遇到老项目的依赖兼容问题会多很多。创建一个Vue 2项目命令大概是vue create gym-admin选择vue2也就是默认的Options API风格。可能有人纠结Vue 2还用不用我的看法是管理系统这种老派的项目Vue 2 Element UI就是最舒服的组合坑都被人踩平了。Vue 3 Element Plus也好但有些组件API变了网上的教程很多还停留在Vue 2写法新手容易对不上号。前端目录里要做的第一件事是安装依赖。基础依赖有axios、vue-router、element-ui、echarts加上vuex。element-ui按需引入还是全量引入我建议全量引入虽然打包体积大一点但省心。管理系统是后台应用又不是给手机用户加载的首屏体积多几百KB完全无所谓。4.2 登录鉴权、路由守卫和Axios封装三件套登录流程是前端所有安全逻辑的起点。用户输入账号密码调用POST /api/login接口后端返回token和用户角色。前端把token存到localStorage把用户信息存到vuex里。之后每次路由跳转之前router.beforeEach守卫都要做判断如果没有token就强制跳到登录页。Axios封装这步特别重要因为你会发现在几十个页面里每个请求都需要带token、处理错误提示、处理登录态失效。我在src/utils/request.js里封装了一个实例const service axios.create({ baseURL: process.env.VUE_APP_BASE_API || /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 401) { localStorage.removeItem(token) router.push(/login) Message.error(登录状态过期请重新登录) return Promise.reject(new Error(unauthorized)) } return res }, error { Message.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } )统一处理的好处是后端返回的统一响应格式是{code, message, data}前端只需要在/src/api/member.js里把接口路径和参数定义好返回的数据直接就能用。后端如果约定401表示未登录前端拦截器里一收所有页面都会自动跳转登录页不用在业务代码里每次手写判断。4.3 会员管理、课程预约和经营看板的实现思路会员管理页面是典型的表格加弹窗结构。我用Element UI的el-table展示会员列表、el-pagination做分页、el-dialog包表单。搜索栏做了姓名和手机号两个输入框点查询按钮时把表单数据作为参数传给接口、重新拉取第一页数据。这里有个细节是el-table列的宽度要合理设置特别是操作列放编辑、续卡、删除三个按钮宽度不够就会出现按钮换行页面很难看。课程预约页是整套系统里最有交互感的部分。排课列表我用日历视图和表格视图两个组件展示日历用第三方库vue-calendar表格就是el-table。会员端看到的课程已满员的按钮变成disabled状态并显示“已约满”。点击预约时前端先弹确认框调用后端接口成功后局部刷新当前排期的剩余名额。预约提醒这里我没接短信和邮件只是接口返回成功后前端Message.success提示省事而且够用。经营看板页是老板最爱看的一页。我用了ECharts画折线图和柱状图。折线图展示近30天每日营收柱状图展示各卡种的售卖数量。数据来源是dashboard/sales接口返回一个对象包含日期数组和金额数组。这里有个经验图表组件的容器div必须预先设定好高度比如400px否则ECharts初始化后会显示成0高度的白屏。很多人图表不显示八成坑在这里。4.4 前端代理配置与生产环境部署要点开发模式下的跨域问题解决方案是Vue CLI的devServer.proxy。我在vue.config.js里配置module.exports { devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样前端页面上发请求到/api/member/page开发服务器会把请求转发到后端8080端口因为changeOrigin设为true后端拿到的请求头Host是localhost:8080就不会被误判为跨域。如果这个配置不生效先去检查是不是有多个vue.config.js或者npm run serve是不是跑在别的端口。生产环境的部署就更直接了。执行npm run build生成dist目录里面是纯静态文件。把dist目录里的东西扔到Nginx的html目录下Nginx再配置一个location /api/反向代理到SpringBoot的8080地址。这里要特别注意路由模式如果Vue Router用了history模式刷新页面时如果Nginx没配置try_files会出现404。必须加这样一段location / { try_files $uri $uri/ /index.html; }try_files的意思是对所有路径先找有没有对应的静态文件找不到就回退到index.html由前端路由接管。5. 部署上线遇到的坑一条条帮你趟平5.1 MySQL连接报错类问题怎么快速定位我把这个项目部署和开发过程中最常见的问题按出现频率排了一遍第一名绝对是数据库连接相关的报错。如果你用MySQL 8.0启动SpringBoot时报SSLConnectionError基本就是URL里没加useSSLfalse。如果报Public Key Retrieval is not allowed就要加上allowPublicKeyRetrievaltrue。如果报时区错误检查serverTimezone是不是Asia/Shanghai。这三个参数我都写在配置里了照抄即可。另一个高频问题是mysql驱动版本不匹配。SpringBoot 2.7自带的是mysql-connector-java 8.0.x你如果手动改成了5.x驱动连接8.x版本的MySQL数据库会直接报Communications link failure。判断方法很简单看驱动类名com.mysql.cj.jdbc.Driver是8.x的写法com.mysql.jdbc.Driver是老版本写法。新版驱动下这两个类都能用但旧版驱动连不上8.x数据库。还有一类是权限问题比如报access denied for user。这个一般就是数据库账号密码或者ip授权的问题。排查测试的时候你可以在MySQL命令行直接执行select version()和show grants先确认MySQL本身能连再排除程序配置问题。不要把大量时间花在重启程序上很多数据库问题用命令行一测就知道答案了。5.2 MyBatis运行期的常见错误速查MyBatis报错最常见的是Invalid bound statement (not found)。简而言之就是Mapper接口的方法没找到对应的SQL语句。原因就那么几种xml文件没放在mapper-locations配置的路径下、xml文件里的namespace写错了、接口方法名和xml里的id不一致。检查顺序也是这个顺序三分之一的概率是namespace写成了com.gym.mapper但实际的包在com.gym.mapper包下面然后漏了一层。另外xml文件必须放到resources目录或通过build插件打进jar包否则部署后同样报这个错。另一种是查询结果映射错误。比如select语句查出来create_time字段但实体类里是createTime并且mybatis的map-underscore-to-camel-case没开启那这个字段就传不回来。如果你不想全局开启可以用resultMap手动映射。但我的建议是全局开启规范统一。还有一个隐蔽的问题在xml里写小于号。因为xml把符号当作标签开头所以在条件判断里写status 3会直接报XML解析错误。解决办法是用代替或者把判断条件改写成status 3。这个坑我在写分页和过期会员查询时都踩过必须记住。5.3 Vue前端常见的端口和路由问题前端开发阶段最常见的问题是8080被占用。后端跑在8080前端又想跑8080结果冲突。解决方法是上面配置里写了前端devServer的port改成3000。但别忘了所有API请求的前缀还是/apiproxy也会把3000上的/api转发到后端8080所以前端项目里不要写死某个后端的IP地址。还有一个路由模式的坑。用history模式时开发环境一切正常npm run build发布到Nginx后用户刷新某个子页面时404。解决方法已经在部署要点里给了就是配try_files。如果你不想改Nginx可以直接把Vue Router切回hash模式就是地址栏会多个#号但不影响功能。我实际项目里一般为了省事直接用history加Nginx配置因为地址好看也方便分享。关于跨域方面很多人以为前后端都部署在同一台Nginx下就不会跨域了但如果你前端请求地址写的是http://localhost:8080/api而不是/api浏览器还是会因为端口不同而判定跨域。所以所有资源请求统一用相对路径/api开头由Nginx转发就不会有这种问题。5.4 业务逻辑层的并发和事务细节最后一个坑是业务逻辑的并发。预约课程那一步如果两个会员同时点击约课后端判断名额和插入预约记录这俩操作之间没有加锁就会发生超卖。我前面提到的set booked_count booked_count 1只是在更新阶段原子化了但预约记录的插入和名额更新不是同一时刻。严格来说还需要在insert之前用select ... for update给排课记录加行锁或者在member预约记录上加唯一约束保证同一会员同一节课不能重复预约。我实际用的方案是双保险menber_id和schedule_id建联合唯一索引同时用UPDATE course_schedule SET booked_count booked_count 1 WHERE schedule_id ? AND booked_count max_students这种条件更新。如果更新影响行数为0说明名额没了直接返回“已约满”。这样能避开大部分并发问题。更复杂的分布式锁在这个体量下完全没必要。事务注解也要注意。Transactional默认只处理RuntimeException如果你在Service层抛的是checked Exception事务不会回滚。所以我在封装的Service方法里统一抛自定义业务异常类继承RuntimeException同时建议配置rollbackFor Exception.class彻底杜绝这种问题。6. 源码拿到手之后第一步该做什么6.1 初始化数据库和修改配置的正确顺序如果你下载了这套系统的源码想把它跑起来我建议严格按照这个顺序操作能少碰很多坑。第一步用Navicat或者命令行执行项目里sql目录下的gym_db.sql脚本创建数据库和全部表顺便插入默认的admin账号。第二步打开后端项目的application.yml修改数据库的URL、用户名、密码。第三步启动SpringBoot主类看到Tomcat started on port 8080就算成功。第四步进入前端项目目录执行npm install再npm run serve。打开localhost:3000用初始化脚本里的账号密码登录。这里有个特别容易被忽略的地方MySQL版本。如果数据库里用了utf8mb4字符集并且你的MySQL是5.6这种老版本建表时可能会遇到索引过长之类的错误。建议直接装MySQL 5.7以上或者8.0作为开发环境来说两者都可以按你自己电脑上方便安装的来。但如果是部署到云服务器优先选8.0因为生活压力小一点也是现在的主流。另外所有的数据库脚本我都给出了基础的测试数据比如卡种会有月卡、季卡、次卡三个默认记录教练表会有默认的教练信息。不要一上来就把测试数据删光很多页面的下拉框选项依赖这些字典数据先跑通再清理。6.2 从毕业设计到真实项目还能扩展哪些方向这套系统如果只是交作业做到现在这个程度已经够了。但如果真想拿到真实场景里用我建议按这几个顺序扩展。第一把Excel导入导出加上会员信息批量导入、经营报表导出成Excel这是老板最喜欢的功能。第二把教练移动端加上做一个教练的H5页面或者小程序教练在上面维护自己的课程表而不是用后台电脑端排课。第三增加微信支付和支付宝支付的接入订单和真实支付打通而不是人工标记已收款。这说明系统已经从一个内部管理工具往商业产品方向走了。技术层面的扩展方向其实更宽。数据库读写分离一定要到量级再上现在每天几百个请求读写分离是浪费。Redis用来缓存卡种列表和课程预约信息可以适当减轻MySQL的压力但引入之后要考虑缓存失效策略典型的坑是缓存里有旧数据会员状态改了半天前端还看到旧的。定时任务可以用Spring的Scheduled做会员到期提醒每天凌晨扫描即将到期的会员给前台生成待办通知。这些扩展都还在单体架构的范围内一件件加上去不会让项目烂掉。我不会劝你直接上微服务、上Spring Cloud、上分布式事务。健身房管理系统这种业务体量单体应用就是最优解。过度设计是另一个角度的资源浪费。先把核心业务做到顺畅遇到真实的量级瓶颈再去拆这才是做项目的正常成长路径。最后说点实在的。这个项目从开发到上线我最深的体会是管理系统这东西难点压根不在技术炫技而在于把业务流程问清楚。会员到期怎么算、约课要不要限制提前几小时取消、老板想看到哪些维度的报表这些问题想清楚了表一建模、接口一出系统自然就成型。技术选型上SpringBootVueMySQLMyBatis就是最稳的一种组合它不惊艳但皮实能在生产环境稳稳当当地跑好多年。希望这篇复盘能帮你少走点弯路做项目的时候心里更有数。