ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot健身房管理系统开发实战:从数据库设计到部署

SpringBoot健身房管理系统开发实战:从数据库设计到部署 1. 需求拆解先想清楚健身房的业务闭环再动手健身房管理系统光看标题很容易做歪要么做成纯会员信息的增删改查要么把权限、报表、短信提醒全堆上去最后课没约成、卡没办利索项目还又大又乱。我一开始接手这个题目时第一件事不是建SpringBoot工程而是把健身房的日常运营捋成几条主线让数据库设计和接口设计都有据可依。1.1 健身房日常运营的四条主线健身房和普通的商品进销存系统有很大区别商品系统管的是货品数量健身房管的更多是“时间资源”和“会员权益”。我梳理下来核心有四条线会员生命周期线办卡 - 进场核销 - 卡到期 - 续费/过期。这条线决定了会员表和会员卡表必须能够回溯历史状态不能只留一张“当前卡”的字段。预约与排期线团课教室的时段、私教教练的时段天然存在冲突。预约模块的难点不是CRUD而是防冲突、防重复、防超卖。资金流水线办卡、续费、购买私教课、退款每一笔变动都要关联订单后续对账和统计才不会乱。场地与设施维护线器械坏了要有登记、维修记录这类表结构简单但容易漏却是健身房日常管理的刚需。1.2 系统角色与权限边界在画表之前我先把角色定清楚角色定完菜单路由、接口权限和前端页面基本就出来了一大半。这套系统至少要考虑四类角色管理员全局配置比如卡类型定义、教练分配、课程上架下架、数据统计。前台/收银办理办卡、续费、退款处理进场核销。这类角色不关心教练管理。教练查看自己的排课表标记会员到课情况。会员在线约课、取消预约、查看卡剩余次数和有效期。权限模型直接用RBAC用户表、角色表、菜单表三件套就够了。很多人在这个阶段容易犯的错是给会员也设计成“进入管理后台操作所有功能”这会让后续的前后端联调和权限拦截都非常痛苦。我的建议是会员端做成独立的模块或者独立的前端入口和后台管理分开权限边界在需求阶段就划清楚。1.3 功能清单的优先级划分这个题目如果出现在毕设或者简历项目里功能并不是越多越好。我和一个健身房老板聊过他真正每天要用的就那么几件事会员办卡、卡到期提醒、约课、私教消课、记录维修器械。所以我当时把功能分成了两个阶段第一阶段必须做会员管理、卡类型管理、办卡/续费/退款、课程管理、团课预约、私教练课消课、订单流水、器材维护记录、基础统计会员数、办卡收入、课程预约量。第二阶段可选加分优惠券营销、人脸识别门禁对接、微信小程序会员端、消息推送。这部分我做成了预留接口不急着实现。这个优先级划分帮我避免了一个很常见的问题一开始就把几乎所有分析功能全塞进去结果表结构越来越复杂连自己都解释不清某张表存在的意义。先把业务闭环跑通再谈扩展。2. 技术栈与项目骨架为什么我选了SpringBoot 2.7 MyBatis-Plus技术选型这步很多人只看“最新”不看“稳定”。我在踩过版本坑之后对选型有了完全不一样的理解。这个项目的技术栈我定为SpringBoot 2.7.x MyBatis-Plus 3.5.x MySQL 8.0 Redis预约并发用 Vue3 Element Plus。2.1 SpringBoot版本选择的逻辑首先解决一个经常被问到的点“为什么不直接用SpringBoot 3.x”答案是看生态和成本。我列一个对比大家就明白了对比项SpringBoot 2.7.xSpringBoot 3.xJDK要求JDK 8成熟稳定服务器上安装普遍最低JDK 17包名javax.*jakarta.*大量旧教程代码迁移有成本第三方兼容性MyBatis-Plus、各类生成器、ActiveMQ等老版本兼容好部分组件需要升级新版本学习资料全网存量资料最多新资料逐年增多但不少还在过渡期做健身房管理系统核心功能集中在业务建模和代码组织并不依赖SpringBoot 3的新特性。选2.7.x可以让团队成员把精力放在业务逻辑上而不是今天处理一个ClassNotFoundException: javax.servlet明天修一个第三方库版本冲突。网上有大量关于“springboot版本太高”的求助帖很多就是升级到3.x之后被各种兼容问题折磨。当然如果是新公司从零建设且团队熟悉JDK17选3.x完全没问题但对大多数做项目的个人开发者来说2.7.x仍是性价比最高的选择。2.2 MyBatis-Plus在项目里的分工持久层框架我选了MyBatis-Plus没有用纯MyBatis也没有用Spring Data JPA。原因很实在单表CRUD完全不用手写SQL。会员表、卡类型表、预约表这种常规操作继承BaseMapperT就完了开发效率高一个档次。逻辑删除、分页插件都是内置的配置量很小。复杂统计查询比如“某个课程一个月的预约率”“不同类型会员卡的续费比例”直接用XML手写SQL仍然保留了MyBatis的灵活性。有些人觉得用MyBatis-Plus会让SQL不可控但在这个体量的业务系统里单表操作占七八成手写SQL完全属于浪费。真正复杂的SQL用XML管理可读性反而比JPA的自动拼接更好。2.3 SpringBoot自动配置在项目里是如何帮我省事的这个项目遇到过最典型的问题之一就是配置文件越来越长、每个模块都在写重复的Bean定义。后来我认真把SpringBoot自动配置的机制捋了一遍发现原理其实不复杂工程启动时SpringBootApplication里的EnableAutoConfiguration会加载META-INF/spring.factories2.7或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports3.0中注册的自动配置类。每个自动配置类上有大量条件注解比如ConditionalOnClass、ConditionalOnProperty、ConditionalOnMissingBean。以DataSourceAutoConfiguration为例只有类路径中存在数据源相关类并且你没有自定义过DataSourceBean时它才会帮你配置默认数据源。理解了这套机制后我把项目的通用能力做成了一个自定义starter主要包括统一返回结果ResultT封装全局异常处理器业务异常、参数校验异常、兜底异常通用工具类组件日期处理、手机号脱敏、Excel导出基础方法幂等校验注解。这个自定义starter自动注册的原理和SpringBoot内置自动配置一模一样靠AutoConfiguration.imports加上条件注解。项目里引入它之后新模块不需要再配置任何东西返回格式和异常处理天然统一。这一步做完我对SpringBoot的理解才真正从“会用”变成了“明白它为什么这么设计”。2.4 项目分层与目录结构这套系统的目录结构是我反复调整过几轮的核心思路是“按业务模块分包模块内按技术层次分层”尽量避免出现几十个包平铺、找接口全靠翻的情况com.gym.system ├── common # 通用模块统一返回、异常、常量、工具类 ├── config # 配置类MyBatis-Plus、Redis、Cors、WebMvc ├── security # 登录认证与权限控制 ├── module │ ├── member # 会员管理 │ ├── card # 卡类型与会员卡 │ ├── course # 团课与私教课 │ ├── booking # 预约管理 │ ├── order # 订单与退款 │ └── equipment # 器材维护 └── framework # 自定义starter相关逻辑每个业务模块下再放controller、service、mapper、entity、dto、vo。controller只做参数接收和结果返回业务判断全在service层SQL在mapper层这个边界一定要守住。我见过太多人把一堆业务代码堆在controller里前期爽后期想扩展一个接口发现根本拆不出来。3. 数据库设计健身房的表结构比想象中要讲究数据库设计是这套系统的灵魂。网上很多开源项目把健身房表设计成“一个会员表 一个订单表”就完事了真正业务跑起来根本扛不住。接下来我直接给出这套项目用的核心表结构并解释关键设计思路。3.1 核心业务表一览表名作用关键字段member会员基础信息id, name, phone(唯一索引), gender, member_no, statuscard_type卡类型定义id, type_name, duration_days, total_times, price, statusmember_card具体到人的会员卡id, member_id, card_type_id, card_no, start_time, end_time, remain_times, statuscourse团课课程id, course_name, coach_id, capacity, start_time, end_time, statuscourse_booking团课预约记录id, course_id, member_id, booking_time, status(预约/取消/已核销/爽约)pt_package私教套餐id, member_id, total_times, used_times, valid_start_time, valid_end_timept_booking私教预约记录id, coach_id, member_id, pt_package_id, start_time, end_time, statusorder_info所有资金流水id, order_no, member_id, order_type, amount, pay_time, statusequipment器械台账id, equipment_name, location, purchase_time, statusequipment_maintenance维修保养记录id, equipment_id, maintenance_time, description, cost, handler_id这里最值得展开说明的是member_card和order_info的关系以及预约表如何防冲突。3.2 会员卡、套餐、订单为什么要拆分一开始我以为给会员表加几个字段card_type、expire_time就够用了但很快就发现两个问题第一会员可能办了两张卡一张年卡、一张次卡一张表根本放不下第二会员没续费之前的卡和续费之后的卡混在一起历史状态无法还原。所以我把“当下的卡状态”抽成了member_card表把“每笔资金操作”放进了order_info表。举个例子前台给会员A办了一张年卡金额3600元数据库会有两件事同时发生在member_card插入一张新卡start_time今天end_time一年后status为有效在order_info插入一笔办卡订单order_type为办卡amount为3600status为已支付。这两件事必须在一个事务里完成否则会出现“钱收了卡没开”或“卡开了订单没记录”的脏数据。后续续费、退款同理每一笔操作都在订单流水里有据可查月底对账直接按order_info汇总。3.3 预约冲突数据库层的两道保险健身房系统的预约是典型的资源竞争场景。团课教室一个时段最多容纳20人私教教练一个时段只能服务1个会员。如果把冲突判断全部交给代码层并发高一点就会出问题。我的做法是两道保险第一道保险数据库唯一索引。对于course_booking表建立(course_id, member_id)唯一索引从双维度保证同一个会员对同一节团课最多只能有一条有效预约记录。即使前端连点了十次提交数据库这关就会直接拒绝重复插入。第二道保险重叠时段校验。针对私教预约这种非固定周期资源的场景插入前要先查一次重叠时段SQL核心逻辑大概是这样select count(*) from pt_booking where coach_id #{coachId} and status in (已预约, 进行中) and start_time #{newEndTime} and end_time #{newStartTime}这个重叠判断条件很多人会写反。我最早写成start_time newStartTime and end_time newEndTime结果只拦截了那种完全被包含的预约跨时段的预约全部漏掉了。正确的思路是“两个区间有交集”只要满足newStart existingEnd and newEnd existingStart这也是各种会议室、教室预约系统的通用判断方式。4. 核心功能实现的几个硬骨头数据库设计完接下来就是真正写代码的阶段。这个项目里有几个实现细节必须单独拿出来讲因为它们决定了系统能不能扛住真实使用场景。4.1 会员卡过期处理SpringBoot定时任务怎么做健身房每天都有卡到期系统需要自动完成两件事把过期卡状态改成“已过期”、给会员生成一条提醒记录。如果全靠人工在后台点按钮前台能累死。实现方式是在启动类上加EnableScheduling然后写一个定时任务Component Slf4j public class CardExpireTask { Resource private MemberCardMapper memberCardMapper; Resource private MemberRemindMapper memberRemindMapper; /** * 每天凌晨1点扫描一次过期会员卡 * cron: 秒 分 时 日 月 周 */ Scheduled(cron 0 0 1 * * ?) public void expireScan() { // 查询所有状态为有效、end_time 小于当前时间的会员卡 ListMemberCard expiredCards memberCardMapper.selectExpiredList(); if (CollectionUtils.isEmpty(expiredCards)) { return; } // 分批处理避免一次性加载过多数据 for (MemberCard card : expiredCards) { // 1. 更新会员卡状态为过期 memberCardMapper.updateStatus(card.getId(), CardStatusEnum.EXPIRED.getCode()); // 2. 插入提醒记录 memberRemindMapper.insertRemind(card.getMemberId(), 您的会员卡已到期请及时续费); } } }这里有几个容易踩的坑第一个坑是“扫描条件”。selectExpiredList要判断的是“曾经有效但现在已过期的卡”所以条件是status 有效 AND end_time now如果end_time恰好是当天凌晨零点之间跨天扫描会漏。我当时统一约定把所有到期时间精确到日和时分并让SQL判断落在“严格小于当前时间”上避免边界值没扫进来的问题。第二个坑是“重复提醒”。因为任务每天执行一次如果某张卡第一次扫描后更新状态成功但插入提醒记录失败事务回滚后不会重复提醒但如果提醒成功而状态更新失败第二天又会提醒一次。保险起见我在提醒表加了一个业务唯一键member_id card_id remind_type保证同一张卡同类型提醒最多一条。第三个坑是“容器多实例重复执行”。如果服务部署了多个实例定时任务会在每个实例上各跑一遍数据量大的时候就重复处理。这个问题我放在后面踩坑复盘部分细说。4.2 预约并发防重别只靠前端按钮禁用预约功能最容易出现的问题是用户快速点击“提交预约”两次前端按钮虽然做了禁用但依然有大量请求在禁用生效前到达后端。这种场景下后端必须自己兜底。我的私教预约接口逻辑分三步核心代码如下Transactional(rollbackFor Exception.class) public boolean bookPt(BookingRequest request) { // 第一步查询该教练当前时段是否已被预约 Long conflictCount ptBookingMapper.countConflict( request.getCoachId(), request.getStartTime(), request.getEndTime()); if (conflictCount ! null conflictCount 0) { throw new BizException(该时段已被预约请选择其他时间); } // 第二步判断会员该课程的剩余次数是否足够 PtPackage ptPackage ptPackageMapper.selectById(request.getPtPackageId()); if (ptPackage null || ptPackage.getRemainTimes() 0) { throw new BizException(私教剩余次数不足); } // 第三步插入预约记录同时扣减次数 PtBooking booking buildBooking(request); ptBookingMapper.insert(booking); int update ptPackageMapper.updateRemainTimes(ptPackage.getId(), -1); if (update ! 1) { throw new BizException(预约失败请重试); } return true; }有人会问既然第一步查了冲突并发下两个请求同时查到无冲突怎么办答案就是数据库层的兜底。除了代码判断我在pt_booking表针对教练和时段建了唯一索引coach_id, start_time, end_time极端情况下即使两个请求都通过了业务校验数据库的第二道锁也会让其中一个插入失败。这里用的还是乐观更新update ... where remain_times 0来扣减次数保证次数不会被扣成负数。这一整套下来预约并发才有保障。4.3 私教课消课与退款事务边界要划清楚私教课和团课不同会员通常按“次”购买。每次预约成功只是占用了未来的一个时段真正扣次数是在“核销到课”的时候。问题最集中出现在退款场景会员买了10节私教课上了3节现在要退剩下的7节整个过程涉及三步操作计算剩余金额并生成退款订单把私教套餐状态置为已退款释放该会员后续所有未上私教预约。这三步必须在一个事务里完成任何一步失败都要整体回滚否则就会出现钱退了但套餐还显示可用的情况。我在这里专门强调一点Transactional注解只对由Spring代理对象调用的方法生效同类内部调用是无效的。比如下面这种写法public void refundPtPackage(Long packageId) { // 这里先做校验 this.doRefund(packageId); // 同类直接调用doRefund上的Transactional不生效 } Transactional public void doRefund(Long packageId) { // 真正的退款逻辑 }我踩过这个坑之后形成的习惯是事务操作要么写在独立的Service类中要么在controller层直接调用带有事务注解的公开方法坚决不在同一个类里用this.method()调自己。如果一定想在同类型调用可以通过Autowired注入自身的代理对象或者干脆把事务操作拆到另一个Service里。4.4 Vue打包放进SpringBoot别再依赖跨域了前后端分离模式下开发环境前端跑在8081、后端跑在8080用axios代理很好用。但真正要交付部署的时候最省心的方式是把前端构建产物直接放进SpringBoot的静态资源目录让后端同一个端口同时承载API和页面从根上消除跨域问题。我的做法有两种按场景选择第一种手动集成。前端执行npm run build后dist目录下生成index.html、static等文件把它们复制到后端src/main/resources/static目录下随SpringBoot一起打包。注意放的时候要让index.html在static根路径下否则访问http://ip:8080/时找不到首页。第二种Maven插件自动集成。在pom.xml里通过frontend-maven-plugin在构建阶段自动执行前端打包并把产物复制到static目录。这种方式适合发布流程规范化的团队优点是本地一次mvn clean package就完成前后端一体化构建缺点是你没装Node环境的话会构建失败需要在CI环境或本机配置好Node。第一次用Vue Router的history模式时我还遇到过一个经典问题首页能打开但直接访问http://ip:8080/booking这样的二级路径会404因为服务端没有对应的路由。解决办法是在SpringBoot里做一个简单的转发对所有非API路径的请求都转到index.html让前端路由接管。这个方案对hash模式不是必须的但对history模式是必须的。5. 实测复盘开发过程中踩过的坑与完整排查链路无论设计阶段想得多周全写代码和联调阶段还是会踩坑。这里把我有印象的几次排查过程记录下来没有按顺序讲而是每条都写了“现象 - 根因 - 修复”希望各位少走一点弯路。5.1 前端拿到的LocalDateTime变成了一串数组现象会员卡详情接口返回的startTime在浏览器里显示成[2024, 11, 5, 10, 0, 0]而不是2024-11-05 10:00:00。前端同事一度以为后端传错了字段。根因SpringBoot默认使用Jackson作为序列化工具而Jackson对LocalDateTime的默认处理方式序列化成数组结构。这个和数据库里的值没任何关系纯粹是序列化机制的问题。修复我做了两个层面的配置。第一在application.yml里显式配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8同时给ObjectMapper注册JavaTimeModule并关闭WRITE_DATES_AS_TIMESTAMPS。第二在实体类的日期字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)作为兜底。这两步做完前后端日期格式彻底统一了。这里提醒一下spring.jackson.date-format对java.util.Date生效但对LocalDateTime不完全生效所以JavaTimeModule的配置不能省。我现在所有项目的公共starter里都会默认带一套JacksonConfig这样新模块接入后日期格式天然一致。5.2 MyBatis-Plus逻辑删除和联表查询的冲突现象管理员在后台把某个教练设置成了“已删除”状态这个教练的排课记录确实不再出现在教练列表里了。但会员端还能约到这位教练——前端在预约页面查可约教练时居然把这个已删教练带了出来。根因我在coach表上加了MyBatis-Plus的TableLogic逻辑删除这导致直接操作该表的查询会自动追加and deleted 0。但预约详情页的业务SQL是联表查询SQL里有join coach c on ...逻辑删除字段的自动过滤只作用于MyBatis-Plus生成的主表查询联表SQL是不会自动帮你拼c.deleted 0的。排查链路先看MyBatis-Plus控制台打印的SQL发现主表确实带了过滤条件但联表SQL没带。再去看XML发现问题出在查询可约教练的SQL上我漏写了教练表的删除标记判断。修复把所有涉及coach表的业务SQL全部检查一遍在查询条件里显式加上and c.deleted 0。这也是一个通用教训逻辑删除字段在联表查询里没有任何魔法该写条件就得写不要指望框架全自动。5.3 定时任务在Docker多实例下的重复执行现象系统部署了两套实例做负载均衡之后每天早上1点的过期卡扫描任务居然执行了两遍会员接连收到两条一模一样“您的会员卡已到期”的提醒后台日志里也能看到两套实例同时跑到了同样的任务关键点。根因Scheduled定时任务只在单个应用实例内生效它不感知其他实例的存在。多个实例部署时每个实例都会按cron表达式执行这就是任务重复。排查链路先从日志确认两个实例确实都在扫同一批数据再排查是不是负载均衡导致请求重放排除这个可能后锁定了定时任务本身。修复这个问题有几种主流解法。最简单的是用Redis的SETNX做一个分布式锁拿到锁的任务才能执行更标准的做法是引入ShedLock定时任务锁在任务执行期间让其他实例跳过。我当时的方案是在公共模块里封装了一个基于Redis的分布式锁工具Scheduled(cron 0 0 1 * * ?) public void expireScan() { String lockKey lock:card-expire-scan; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofMinutes(10)); if (!locked) { log.info(任务已在其他实例执行本实例跳过); return; } // 执行真正的过期扫描逻辑 }这个方案不依赖额外组件改造成本低在绝大多数并发业务量下完全够用。如果是金融类对一致性要求极高的场景建议还是上ShedLock或者用数据库锁表。6. 从IDEA到服务器部署配置、Docker镜像与验收清单项目开发完毕只是第一步能把系统稳定跑在服务器上、并且让团队其他人也能一键部署才算有交付价值。这一节我讲一下这个项目的部署链路。6.1 多环境配置dev、prod分离开发环境和生产环境的数据库、Redis地址肯定不一样不要每次部署时手动改application.yml太容易出错。我的做法是拆分配置文件application.yml放公共配置只指定当前生效的profileapplication-dev.yml本地开发库连接、调试日志、关闭某些安全校验application-prod.yml生产环境数据库地址、密码、日志级别。启动时这样控制环境java -jar gym-system.jar --spring.profiles.activeprod这里有两个细节很容易被忽略。第一生产环境的数据库密码不要写在配置文件里提交到git仓库要走环境变量注入比如${DB_PASSWORD}。第二时区要显式设置数据库连接串上加serverTimezoneAsia/ShanghaiJVM启动参数加-Duser.timezoneAsia/Shanghai否则定时任务和日期展示可能差一天。6.2 宝塔面板Docker部署的实际操作服务器用的是宝塔面板部署我采用Docker方式。先写一个简单的DockerfileFROM openjdk:8-jre-alpine ENV TZAsia/Shanghai COPY target/gym-system.jar /app/gym-system.jar WORKDIR /app ENTRYPOINT [java, -jar, gym-system.jar, --spring.profiles.activeprod] EXPOSE 8080在宝塔Docker管理器中构建镜像时我把application-prod.yml单独用挂载卷的方式挂进容器里镜像本身不携带生产库密码这样即使镜像被误导出敏感信息也不会泄露。启动命令大致这样docker run -d \ --name gym-system \ -p 8080:8080 \ -v /opt/gym/config:/app/config \ -e DB_PASSWORDxxx \ gym-system:1.0.0前端打包进Jar包之后只需要部署这一个容器入口统一为http://服务器IP:8080不需要额外配置Nginx转发。如果以后把前后端拆开再让Nginx指向两个服务也不迟。6.3 上线前的验收清单和并发冒烟测试部署完成后不要急着宣布“上线成功”。我按下面的清单逐项验证功能验收用两个不同角色的账号分别登录验证权限菜单是否抓得准办一张卡立刻查看订单流水和会员卡状态约同一时段的两节私教课确认第二个请求被拦截退款后确认套餐次数回滚且预约记录释放。并发冒烟测试用JMeter模拟100个线程同时预约同一节团课查看最终预约成功人数是否等于课程容量上限有没有超卖。这一步能直观验证数据库唯一索引和事务是否生效。数据一致性抽查挑一张刚退款的套餐核对pt_package的remain_times、order_info里的退款金额、相关预约记录三者是否完全对应。定时任务验证把服务器时间临时调到过期扫描任务触发时间之后确认过期卡状态更新、提醒记录生成、多实例下不重复执行。这套清单跑完系统才算真正具备交付条件。这套系统做下来我个人最大的体会是健身房管理系统的难点从来不是某个框架怎么用而是业务模型能不能经得起真实场景的推敲。SpringBoot的自动配置、定时任务、事务管理这些特性只有在具体的业务难题里才会显示出它们的价值。如果你也想拿这个题目练手建议先花足够时间把数据库表关系和状态流转画清楚写代码反而是水到渠成的事。后面还可以把人脸识别门禁、微信小程序端、营销优惠券这些模块逐步加进来整个系统的想象空间比想象中要大。
返回列表