
每天来馆里锻炼的会员要刷卡签到私教课要排课、要预约课时包什么时候用完、会员卡什么时候到期还有一堆报表要统计光靠前台小姐姐拿Excel表去记早晚要出乱子。我接手的这个“基于Spring Boot的健身俱乐部管理系统”项目就是要把这些线下琐碎事全部搬到线上让管理员、前台、教练各干各的活数据自动流转账目一目了然。这篇文章从一个可以落地的完整项目角度出发拆解它的业务需求、表结构设计、核心接口逻辑、Spring Boot MyBatis的工程实现以及我实际开发中踩过的坑给正在做类似管理系统或者拿这个题目练手的朋友一个可以直接抄作业的参考。先说清楚这套系统适合谁。如果你是刚学完Spring Boot、MyBatis想找一个综合性不那么高但五脏俱全的练手项目它很合适如果你是给健身房做内部系统需要一个能跑起来的基础骨架它也能直接改改就用。整个系统包含会员管理、课程管理、私教预约、教练排班、储值消费、到期提醒和统计报表等模块覆盖了中小型健身俱乐部日常运营的绝大部分场景。1. 健身俱乐部管理系统到底要管什么1.1 业务场景与需求拆解很多人在网上搜“Spring Boot”相关项目时会看到各种千奇百怪的系统比如大学生就业推荐、电商跨境商城、监控平台等等。健身俱乐部管理系统之所以值得单独拿来拆是因为它的业务边界非常清晰表与表之间的关系也典型。你可以把整个系统要管的业务拆成四条主线会员生命周期办卡录入信息 - 每次到店签到 - 卡即将到期提醒 - 续费或冻结 - 退卡。课程与预约教练创建课程 - 会员查看课表 - 预约 - 上课签到 - 取消预约。教练与排班教练信息维护 - 每月/每周排课 - 课时费统计。财务流水办卡缴费、私教课扣次、商品消费储值扣款、退款每一笔都要落账最终汇成日报月报。这四条线不是孤立的。会员预约课程会同时影响课程表、会员卡剩余次数和教练的课时安排会员储值余额扣减又会写入财务流水。所以数据库设计时如果不提前把这些关联理清楚后续写接口就会陷入各种“查完一个表再逻辑判断另一个表”的泥潭。1.2 角色与权限划分这个系统的用户角色我最终定了四种没有做很复杂的RBAC权限模型而是用一个简单的用户表和角色字段搞定系统管理员管会员、教练、课程、所有订单流水能看全部统计数据。前台/运营人员会员开卡、续费、签到、预约处理。教练看自己的排课表、维护课程信息、标记上课完成。会员微信端或前台登记后查看课表、预约、查看自己的剩余课时和储值余额。在实际开发中权限切分不用一上来就上Spring Security那套“用户-角色-权限-资源”四级模型多数管理系统用“用户表 角色字段 拦截器校验角色”就够用。等业务复杂到菜单权限都需要动态配的时候再改造不要为了技术炫技增加开发量。1.3 核心功能清单与优先级我整理功能清单时按P0/P1/P2排了个优先级避免一上来就铺太大摊子优先级功能模块核心功能点P0会员管理会员信息CRUD、开卡、续费、冻结、签到P0课程管理课程类型维护、排课、课表查询P0预约系统课程预约、取消、冲突校验P1教练管理教练信息、排班、课时统计P1财务管理充值、扣费、消费流水、日报月报P2数据统计到店趋势、课程热度、会员增长图表2. 技术选型为什么是Spring Boot MyBatis2.1 Spring Boot版本怎么选最近网上关于Spring Boot版本讨论很多有搜“spring boot 2.3.x 2.6.x”的也有搜“spring boot 3”的。我的建议很简单不要盲目追新看你的JDK和依赖生态。如果你用的是JDK 8选Spring Boot 2.6.x或2.7.x最顺第三方组件兼容性好网上资料也最多。如果你用JDK 17可以直接上Spring Boot 3.x但要注意Spring Security、MyBatis等都要用配套新版本老文章里的配置很多会报错。这个健身管理系统我实际开发用的是Spring Boot 2.7.18稳并且能和mybatis-spring-boot-starter 2.3.x无缝衔接。标题里有人搜“intellij idea 社区版怎么用spring boot”其实IDEA社区版完全可以开发Spring Boot项目只要装好Lombok和Spring Boot插件用Maven直接创建即可不必非要付费版。2.2 MyBatis和MyBatis-Plus选哪个很多教程里写“Spring Boot MyBatis”但实际写代码时更多人会用MyBatis-Plus。这里有区别原生MyBatis灵活度拉满SQL全写在XML里适合复杂查询和复杂关联但CRUD要自己写一堆重复代码。MyBatis-Plus内置单表CRUD、分页插件、条件构造器同样可以写XML自定义SQL开发效率高很多。我的做法是两者结合单表操作和分页用MyBatis-Plus自带的IService接口多表关联和复杂统计在XML里写SQL。这样既能保住开发速度又不会遇到过于复杂的查询时被条件构造器绕晕。2.3 前端方案与通信方式后台管理端我用的是Vue 3 Element Plus搭建起来快表格、表单、弹窗这些跟管理系统太匹配了。但要注意如果你不想写前端也可以用Spring Boot自带的Thymeleaf模板引擎搭一个简单的多页面。纯后端的同学建议先把接口写好用Swagger或Postman调通后再补前端避免两边同时开导致问题无法定位。前后端通信我统一用JSON格式接口路径遵循/api/...前缀方便后面加网关或者做权限拦截。跨域问题开发阶段用CrossOrigin或者在配置类里配CORS解决不要给Controller到处加注解太丑还容易漏。2.4 项目分层结构整个工程我用了最常见的四层结构com.fitness ├── controller ├── service │ ├── impl ├── mapper ├── entity ├── dto ├── config └── commoncontroller只做参数接收和结果封装。service写业务逻辑比如会员开卡时同时改动会员表、卡表、流水表这一步必须在service里做事务。mapper只负责数据库交互不要在mapper里写业务。dto用于接收前端参数entity严格对应数据库表字段。有人在搜“spring boot 对外提供的接口应该放在哪里”很多项目的后端里既有给管理端用的接口又要对外开放接口给第三方。我的经验是内网后台接口统一放/admin/**外部调用接口如果量不大可以放/open/**并且单独做签名校验千万不要把对外开放的接口和内部接口混在一个Controller里安全策略完全不同后期很容易出漏洞。3. 数据库表设计与核心模块分配3.1 核心表结构数据库方面我用的是MySQL 8.0库名fitness_club字符集统一utf8mb4排序规则utf8mb4_unicode_ci。主要表如下表名说明关键字段member会员表id, name, phone, gender, birthday, status, create_timemember_card会员卡表id, member_id, card_type, start_date, end_date, total_count, remain_count, balancecard_type卡种表id, name, duration_months, price, times, descriptiontrainer教练表id, name, phone, specialty, years, avatarcourse课程表id, name, course_type, trainer_id, start_time, end_time, capacity, booked_count, statusbooking预约记录表id, course_id, member_id, booking_time, statuspayment流水表id, member_id, type, amount, payment_method, create_time, remarksys_user系统账号表id, username, password, real_name, rolecheck_in签到记录表id, member_id, check_time, check_type这里我特别要提醒两个容易踩的坑会员卡剩余次数和预约记录必须联动。预约成功时remain_count - 1取消预约时remain_count 1这两个操作必须放在同一个事务里并且要注意并发情况否则会出现“明明没有了还能约上”的情况。不要为了省事把会员余额、剩余次数直接放在member表一旦卡升级或过期要改的话字段耦合很痛苦。单独member_card表让一个会员可以有多张卡更贴近真实场景。3.2 会员状态与卡状态会员状态我定义为正常、冻结、过期、退卡。会员卡要区分未激活、生效中、已过期、已冻结。这里有个业务经验会员过期不代表这个会员要删掉而是把它的卡状态改成过期保留历史数据。这样才能统计“流失会员数量”这对于健身房的运营很重要。冻结功能也好理解比如会员出差两个月申请暂停就把卡的结束日期往后顺延同时把状态置为冻结冻结期间不能预约签到。实现冻结操作的时候service层要做三步修改member_card的status为冻结。更新end_date顺延时长。在流水表登记一条“冻结延期”记录方便财务核对。三步必须加Transactional别问我怎么知道的第一版没加结果出现好几张卡状态改了日期没变的脏数据。3.3 预约冲突检测预约是这套系统里业务逻辑最需要动脑子的地方。我的设计思路是教练同一时间不能有重叠课程会员同一时间段不能预约两节课。实现时不是等用户提交以后再硬查而是在教练排课阶段就做一次冲突校验。排课时前台传入教练ID、开始时间、结束时间后台SQL查一下SELECT COUNT(*) FROM course WHERE trainer_id #{trainerId} AND status IN (1, 2) AND start_time #{endTime} AND end_time #{startTime}这里用的是区间重叠判断逻辑只要两个时间段存在交集就说明冲突。我见过有人写成start_time ? AND end_time ?的判断这是错的因为课程A的时间是9:00-10:00课程B的时间是9:30-10:30B完全被A包含等于B的查询条件里A既不“开始时间大于等于B开始时间”也不“结束时间小于等于B结束时间”就漏掉了。会员预约时也做同样的校验只不过把trainer_id换成member_id并且需要排除掉已取消的预约记录。3.4 财务统计财务这块我做了充值流水记录和消费流水记录。每次充值和扣款都要往payment表插一条。统计日报的逻辑是SELECT DATE(create_time) AS date, SUM(CASE WHEN type RECHARGE THEN amount ELSE 0 END) AS recharge_amount, SUM(CASE WHEN type PAYMENT THEN amount ELSE 0 END) AS consume_amount FROM payment WHERE create_time #{startDate} AND create_time #{endDate} GROUP BY DATE(create_time) ORDER BY date月报就是把日期粒度改成月份用DATE_FORMAT(create_time, %Y-%m)分组。前端图表用ECharts展示柱状图看充值、折线图看趋势这个对管理员来说很直观。4. 从零搭建项目与关键代码实现4.1 创建项目与依赖配置用IDEA新建Spring Boot项目时我通常直接选择Spring Initializr在依赖里勾选Spring Web、MySQL Driver、Lombok然后再手动加上MyBatis-Plus等依赖。最终pom.xml里关键的依赖长这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency /dependencies这里注意如果用的Spring Boot 3.xmysql-connector-java的groupId变了直接用新版就行还有javax.servlet相关的包也要替换成jakarta.servlet。这也是为什么我建议新手老老实实用2.7.x的原因。4.2 application.yml配置我的配置内容如下比较简单不在这里做逻辑混淆server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fitness_club?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 mybatis-plus: mapper-locations: classpath:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto有人在搜“spring boot修改demo端口号”改默认端口就是上面server.port这个配置。如果启动时8080被占用要么改端口要么用命令行mvn spring-boot:run -Dspring-boot.run.arguments--server.port8081。log-impl配置成StdOutImpl以后控制台会直接打印SQL和参数开发阶段排查问题很舒服。上线前记得把这个去掉否则日志膨胀得吓人。4.3 登录认证与权限拦截登录这块没有引入特别重型的Spring Security而是用JWT做了token验证。为什么这么选因为后台管理系统的接口大多数是需要身份校验的而Spring Security的配置学习和Debug成本对于这个体量的项目偏高JWT配合拦截器完全够用。我在common包里写了一个JwtUtil工具类负责生成token和校验token。登录接口逻辑如下根据用户名查sys_user表。用MD5加盐对比密码。这里有人会说MD5不安全确实但管理系统内部用MD5是历史遗留常规做法如果要更安全可以把密码字段换成BCrypt代码改动也不大。生成token里面塞入userId、username、role过期时间设为24小时。前端把token存在localStorage每次请求在请求头带Authorization: Bearer token。拦截器在config包里注册一个WebMvcConfigurer把/api/login/**、/api/open/**放行其余所有/api/**都走token校验。校验通过后把当前用户信息放到ThreadLocal方便后面业务逻辑里取操作人。登录逻辑的代码大致这样public LoginResult login(String username, String password) { SysUser user userMapper.selectOne( new LambdaQueryWrapperSysUser() .eq(SysUser::getUsername, username)); if (user null) { throw new BusinessException(用户不存在); } String md5Pwd DigestUtils.md5DigestAsHex( (password user.getSalt()).getBytes(StandardCharsets.UTF_8)); if (!md5Pwd.equals(user.getPassword())) { throw new BusinessException(密码错误); } String token JwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole()); return new LoginResult(token, user.getRealName(), user.getRole()); }4.4 会员开卡功能的接口实现开卡是一个比较典型的跨表事务操作。会员到前台办卡可能有两种情况新会员先插入member记录再插入member_card记录再插入payment流水。老会员办新卡插入新的member_card记录同时插入payment流水。对应的service方法我加了Transactional(rollbackFor Exception.class)。这里有一个常见误区只写Transactional不指定rollbackFor这样如果方法内部抛出的是自定义的RuntimeException能回滚但如果你捕获异常后不抛出事务还是不会回滚。所以一定要记住事务回滚的前提是异常能传递到Spring的事务代理外面捕获了就要重新抛出去。开卡核心代码Transactional(rollbackFor Exception.class) public Long createMemberAndCard(MemberCreateDTO dto) { Member member new Member(); member.setName(dto.getName()); member.setPhone(dto.getPhone()); member.setGender(dto.getGender()); member.setStatus(1); memberMapper.insert(member); MemberCard card new MemberCard(); card.setMemberId(member.getId()); card.setCardTypeId(dto.getCardTypeId()); card.setStartDate(LocalDate.now()); card.setEndDate(LocalDate.now().plusMonths(cardType.getDurationMonths())); card.setTotalCount(cardType.getTimes()); card.setRemainCount(cardType.getTimes()); card.setStatus(1); cardMapper.insert(card); Payment payment new Payment(); payment.setMemberId(member.getId()); payment.setType(CARD_OPEN); payment.setAmount(cardType.getPrice()); payment.setPaymentMethod(dto.getPaymentMethod()); paymentMapper.insert(payment); return member.getId(); }这个接口要在事务里跑因为memberMapper、cardMapper、paymentMapper三个表要么全部成功要么全部回滚。直观理解办了卡但流水没记录下来月底账就对不上或者流水记录了但卡没办理成功人家会员拿不到卡都是大事故。4.5 课程预约与取消预约预约课程我设计了两个接口预约POST /api/booking取消POST /api/booking/cancel预约时先做三步检查卡是否在有效期内status是否为正常。剩余次数是否大于0。该会员在该课程时间是否有冲突。这三步同样要在同一个事务方法里。先SELECT ... FOR UPDATE锁定这条会员卡记录再扣次数否则并发下会出现超卖。用MyBatis-Plus时可以在Mapper里自定义方法update iddeductRemainCount UPDATE member_card SET remain_count remain_count - 1 WHERE id #{cardId} AND remain_count 0 /update注意这里的AND remain_count 0很关键它能帮你挡住并发超扣。如果返回影响行数为0说明次数已经扣减失败直接抛异常回滚。取消预约就反过来判断预约状态为已预约改为已取消同时把remain_count加回。这里有个边界情况要处理如果课程已经结束了就不允许取消预约否则会有人事后“补取消”导致次数白赚。4.6 前端页面与接口对接前端我用Vue3 Element Plus Axios。工程结构大概是views ├── Dashboard.vue ├── Member.vue ├── Course.vue ├── Booking.vue ├── Payment.vue └── Login.vue每个页面调用后端的接口封装在src/api目录下面。Axios请求拦截器统一加上tokenservice.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config })响应拦截器里统一处理401和业务错误码service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg) return Promise.reject(new Error(res.msg)) } return res }, error { if (error.response error.response.status 401) { router.push(/login) } ElMessage.error(网络错误) return Promise.reject(error) } )项目开发时Vite默认8080端口会和后端冲突所以我在vite.config.js里设置了代理server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这个代理能解决前端开发时的跨域问题。你要是搜过“vscode spring boot”你会发现在VS Code里也可以直接运行和调试Spring Boot项目配上Spring Boot Extension Pack插件效果和IDEA差不多不过是改端口的话就去application.yml里改就行。5. 实操过程中踩过的坑与排查实录5.1 时间字段的时区问题这个项目在第一次部署到服务器时突然发现课程表的开始时间全部比实际时间晚了8个小时。排查下来是JDBC连接串里没有加serverTimezoneAsia/Shanghai本地MySQL和服务器MySQL的默认时区不一致导致的。解决办法就是在数据库连接URL上显式指定时区serverTimezoneAsia/Shanghai同时在实体类里日期时间字段我全部用LocalDateTime而不是java.util.Date配合Jackson的配置能避免很多序列化问题。如果前端需要展示格式化好的日期应该在DTO里返回字符串不要直接返回LocalDateTime让前端自行处理格式。5.2 MyBatis XML文件里的特殊符号在写预约冲突检测SQL时XML文件里的和直接使用是会报错的因为XML解析器把它们当成了标签符号。解决方式有两种转义写成lt;写成gt;。用![CDATA[ ]]包裹SQL片段。我比较推荐用CDATA因为可读性更强。比如select idcheckConflict resultTypejava.lang.Integer ![CDATA[ SELECT COUNT(*) FROM course WHERE trainer_id #{trainerId} AND status IN (1, 2) AND start_time #{endTime} AND end_time #{startTime} ]] /select这个坑对于第一次用MyBatis的新手来说几乎必踩我见过有人把整个SQL的前后都包上CDATA还发现报错原因是CDATA内部不包含]]就行别嵌套使用即可。5.3 事务不生效的三种情况项目中遇到过三次事务不回滚的问题背景各不相同但都很有代表性。第一种方法内部自己调自己。比如Controller直接调service的一个方法而这个方法内部又调用了同类中的另一个方法。由于Spring事务是通过代理实现的同类内部调用不会走代理事务自然不生效。解决方式是拆成两个Bean或者把事务注解放到Controller层调用的入口方法上。第二种异常被捕获后没有抛出。有个需求是要在预约失败时记录日志于是我在service里加了try-catch把异常吃掉以后返回false结果前面的会员卡扣次数操作也一起被提交了。解决方式是改用事务监听器或者在catch里重新抛出异常。第三种事务方法不是public。Spring的Transactional只对public方法生效改成private后Spring不报错但事务完全没有包住任何敏感操作。这个要仔细检查方法声明。5.4 MyBatis-Plus的字段自动填充问题创建时间和更新时间如果每个表都手动去set很容易漏。我用MyBatis-Plus的MetaObjectHandler实现自动填充Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }实体类字段上要加TableField(fill FieldFill.INSERT)和TableField(fill FieldFill.INSERT_UPDATE)。这个功能能省去很多重复代码但要注意如果数据库字段和实体类字段命名不一致一定要在字段上标注TableField(create_time)。5.5 并发预约导致超卖第一次上线测试时我拿了两个账号同时抢同一节课结果两个都预约成功了但数据库里remain_count只减了1。原因就是前面提到的扣减次数SQL没有带AND remain_count 0条件也没有加行锁。修正方案是两步并行扣减SQL带条件。在service里先用SELECT id FROM member_card WHERE id #{cardId} FOR UPDATE锁行再执行扣减。实际测试下来并发100个线程也不会出现超卖。不过FOR UPDATE锁的粒度是行级前提是查询走了主键索引如果查出来是全表扫描MySQL会在某些情况下升级为表锁性能就会急剧下降。5.6 前端菜单权限暴露问题后台管理界面一开始把“财务统计”菜单只做了前端隐藏结果有个员工改了localStorage里的role字段菜单就显示出来了还能访问统计接口。这个教训说明前端隐藏菜单只是体验优化真正的权限控制必须在后端做。我在拦截器里增加了基于角色的接口放行判断比如财务统计类的接口只允许ADMIN角色访问这样就算前端被篡改后端也会拒绝响应。5.7 报表查询慢的优化数据量到几十万条以后日报表SQL查起来明显变慢。我的优化手段主要有payment表在create_time字段上建索引日报月报查询基本走索引。报表类查询不要实时去跑原始流水可以每天凌晨用一个定时任务把昨天的统计数据汇总到stats_daily表查询时直接读汇总表。Spring Boot里做定时任务很简单在启动类上加上EnableScheduling然后在方法上写Scheduled(cron 0 30 2 * * ?)表示每天凌晨2点30分执行。这里的定时任务如果要做幂等控制可以在执行前先查一下stats_daily表有没有当天的记录有就跳过避免重复调度导致数据翻倍。5.8 启动不了的常见问题速查顺手整理一份高频问题的排查对照表现象大概率原因解决方向启动报数据库连接失败配置的url、用户名、密码不对或端口暴露检查application.yml、查看MySQL是否启动页面一直转圈加载不出前端请求接口失败代理没生效查看浏览器Network检查Vite代理配置接口报401token过期或没传token重新登录确认请求头带了Authorization中文乱码字符集没有配置utf8mb4数据库连接串加characterEncodingutf8接口返回时间格式不对LocalDateTime序列化配置缺失加Jacksonspring.jackson.date-format配置系统做到这个程度我个人最大的体会是Spring Boot本身并不难难的是把业务规则和框架能力揉在一起。预约冲突、并发扣减、事务回滚、权限拦截这些才是区别一个“能跑的demo”和“能用的系统”的分界线。你在网上搜“spring boot 实现监控都有哪些需求和功能”会发现一堆人讨论监控指标、接口监控、系统监控那当然很好但健身俱乐部这个项目再往上扩还可以做消息推送提醒会员上课、对接小程序端让会员自助约课、用Redis缓存热门课表减轻数据库压力扩展路径是现成的。最后分享一个开发时的小习惯每个复杂接口写完以后除了用Postman调通正常流程一定再手测一遍异常情况。比如会员卡已过期去预约已经约满的课再去约取消已经上完的课。把这些边界场景的数据也造出来放进数据库里跑一遍看会不会报错。老话讲“能跑通不算本事各种怼都不出问题才是”这句话用在管理系统开发上一点不假。