
简介面向Java学习者与健身房管理项目开发者这份会员制健身中心管理系统以B/S三层结构和MySQL数据库为基础完整实现修改登录密码、工作人员管理、会员卡类型管理、会员资料管理、健身器材管理、教练执教管理、安全退出七大模块操作界面友好适合课程设计、毕业设计参考或二次开发。资源共7个文件整体压缩包约19.44MB包含源代码zip、论文zip、MySQL数据库脚本(sql)、必读说明txt以及3张jpg运行截图覆盖从环境搭建到功能演示的完整素材。已有195人学习读者可依据sql文件快速初始化数据结合论文与源码理解三层架构下的数据交互流程并根据截图核验界面效果。整体内容结构清晰辅助资料齐全能帮助初学者快速跑通项目并掌握健身俱乐部信息管理系统的核心设计思路。1. Java 健身俱乐部管理系统要解决的不只是记一笔账当Java健身俱乐部管理系统这个标题出现在需求清单里它背后通常不是一套复杂的SaaS产品而是一家有几百名会员、十几名教练、每天排几十节课的线下健身房的真实运营问题会员卡怎么分类型、上课怎么约、教练课时费怎么算、月底怎么和会籍顾问对业绩。用 Java 技术栈来做最常见也最稳妥的路线是 Spring Boot MyBatis-Plus MySQL前端配合 Vue3 Element Plus 搭一个后台管理端登录、权限、预约、计费、报表这五类功能跑通系统就能从能做增删改查升级到门店能用的程度。这套方案适合两类人把毕设或项目课做成完整产品的同学以及单休应用经验丰富但没碰过实体业务系统的 Java 开发。下面从数据模型开始把它一层层拆开。2. 先建好会员、卡种、课程三张表健身俱乐部管理系统的数据底座2.1 一张会员卡要能表达次卡和期间卡两种计费方式健身俱乐部里会员卡是最核心的资产而且它不是用户表加个有效期就能覆盖的。按常见运营模式卡种至少有次卡比如30次私教卡、期间卡月卡/季卡/年卡、储值卡三种。次卡关心剩余次数期间卡关心到期时间储值卡关心余额三种计费方式对应的字段完全不同。如果把所有卡压成一张表建议把核心字段拆开来设计例如 membership_card 表中保留card_type枚举 FIXED_TIME期间卡、FIXED_COUNT次卡、STORED_VALUE储值卡valid_from、valid_to期间卡用次卡可空total_count、remaining_count次卡专用balance储值卡专用数据库允许冗余但不要用一个字段塞所有场景的做法后端代码一旦复杂起来这种表结构会让人改到怀疑人生。2.1.1 会员与卡的建表 SQL下面是一份 MySQL 建表脚本省略了部分索引实际项目中还要补充会员表上的来源渠道、紧急联系人等字段但核心结构已经够用CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, gender TINYINT DEFAULT 0 COMMENT 0未知 1男 2女, birthday DATE, source_channel VARCHAR(30) COMMENT 渠道来源, status TINYINT DEFAULT 1 COMMENT 1正常 2冻结 3注销, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE membership_card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, card_template_id BIGINT NOT NULL COMMENT 关联卡种表, card_type VARCHAR(20) NOT NULL COMMENT FIXED_TIME/FIXED_COUNT/STORED_VALUE, total_count INT DEFAULT 0, remaining_count INT DEFAULT 0, balance DECIMAL(10,2) DEFAULT 0.00, valid_from DATETIME, valid_to DATETIME, status TINYINT DEFAULT 1 COMMENT 1有效 2冻结 3过期, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_member (member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 SQL 的重点是 card_type 和四个可空字段并列存在。你可能觉得次卡数量和储值余额放在同一行很奇怪但业务上会员很可能同时持有一张储值卡和一张期间卡这两种卡不能用同一条 membership_card 记录否则续费、冻结和报表都分不清。一位会员可以拥有多条 membership_card 记录每张卡对应一种计费模型。2.2 课程表要处理预约占位和签到核销两个动作课程和私教约课是俱乐部里第二类核心数据。常见做法是建一张 course_schedule 表记录某天某个时间段在哪个场地由哪个教练上哪门课再分别记录 total_slots 和 booked_slots 两个字段。不要把预约做成简单的插入一条预约记录因为课程报满后要拒绝约课还要维护预约人数上限。比较可靠的做法是一次预约操作同时更新 course_schedule 的 booked_slots 字段并插入预约记录且两个操作放在同一事务里对并发场景用带条件的 UPDATE 语句来防超卖这点在第 3 章会展开。2.2.1 课程预约表与状态字典课程计划表和预约表的 SQL 设计如下CREATE TABLE course_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT 关联课程信息表, coach_id BIGINT NOT NULL, venue_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, total_slots INT NOT NULL DEFAULT 10, booked_slots INT NOT NULL DEFAULT 0, status TINYINT DEFAULT 1 COMMENT 1可约 2已满 3停课, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, member_id BIGINT NOT NULL, card_id BIGINT NOT NULL COMMENT 扣减哪张卡, appointment_type TINYINT DEFAULT 1 COMMENT 1团课 2私教, status TINYINT DEFAULT 1 COMMENT 1已预约 2已核销 3爽约 4取消, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, checked_at DATETIME, UNIQUE KEY uk_schedule_member (schedule_id, member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的唯一约束 uk_schedule_member 很重要。不加它同一会员可以在一个课程下约出好几条记录月底统计到场率时数据会乱掉。预约状态建议在应用层维护一个整数枚举不要直接在数据库里写已预约、爽约这种字符串后面做统计筛选和迁移都不方便。2.3 用逻辑删除而不是物理删除会员删除会员时不能直接 delete from member因为扣款流水、约课记录、订单和签到记录还指向这条数据。把一个仍在开卡期的会员物理删除后续报表和追溯会全部断链前端列表也会因为关联缺失报错。业界标准做法是逻辑删除也就是加一个 deleted 标记位ALTER TABLE member ADD COLUMN deleted TINYINT DEFAULT 0 COMMENT 逻辑删除标记, ADD KEY idx_deleted (deleted);查询时统一带 deleted 0 条件。如果用 MyBatis-Plus在实体字段上标 TableLogic 可以自动附加这个过滤条件省去手动拼 SQL 的麻烦。实际运营里销卡处理更需要关注的是未用完次数或余额的退款方案那应该走一张退卡单而不是一条 DELETE。这一章把数据表骨架搭出来了但真正碰到并发扣减或报表统计时光靠表结构还不够得用后端代码把业务规则关起来。接下来先实现登录、权限和余额扣减这组基础接口。3. 用 Spring Boot 实现会员登录、JWT 权限和余额扣减3.1 项目基础配置与数据访问选型项目初始化时建议选 Spring Boot 2.7 或 3.x。如果团队习惯手写 SQL用 MyBatis如果想减少重复 CRUD直接引入 MyBatis-Plus。下面这组依赖是健身管理系统最常见的组合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 groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency配置文件中需要关心两个点驼峰映射和逻辑删除。数据库字段 created_at 要能映射到 Java 里的 createdAt必须开启 map-underscore-to-camel-case。如果使用 application.yml相关配置类似mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这两个配置直接影响后面所有接口能否省心。不要在实体类里把字段写成 created_at 这种下划线命名驼峰映射配好后Java 实体保持正常写法即可。3.2 登录、Token 和基于角色的权限拦截登录过程是根手机号加密码查询 member 表或 staff 表校验通过后签发 JWT。区分会员端和员工端时建议在 token 里存一个 role 字段而不是另起一套鉴权逻辑。JWT 本身是明文可解敏感操作仍需到数据库做二次校验这个边界要清楚。一个面向管理端的登录 Controller 可以这样写RestController RequestMapping(/api/auth) public class AuthController { private final MemberService memberService; private final JwtTokenProvider jwtTokenProvider; public AuthController(MemberService memberService, JwtTokenProvider jwtTokenProvider) { this.memberService memberService; this.jwtTokenProvider jwtTokenProvider; } PostMapping(/login) public ResultString login(RequestBody LoginRequest req) { Member member memberService.findByPhone(req.getPhone()); if (member null || !member.getPassword().equals(DigestUtils.md5DigestAsHex(req.getPassword().getBytes()))) { return Result.fail(手机号或密码错误); } if (member.getStatus() ! 1) { return Result.fail(账号已冻结); } String token jwtTokenProvider.createToken(member.getId(), MEMBER); return Result.ok(token); } }这里没有引入 Spring Security 全家桶是希望业务代码更直观。JWT 生成的核心逻辑大致是SecretKey key Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); String token Jwts.builder() .setSubject(String.valueOf(memberId)) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 7200000L)) .signWith(key, SignatureAlgorithm.HS256) .compact();登录成功后前端在 axios 拦截器里统一加 Authorization 请求头。后端用一个 HandlerInterceptor 拦截 /api/** 路径解析并校验 JWT再把 memberId 和 role 放进 ThreadLocal 或 request attribute 里。需要注意如果拦截器没有配置排除路径登录接口本身也会被拦下来然后出现每个接口都报未认证这种误导性错误。3.3 扣减次卡余额的并发写法与事务回滚健身系统里最常见的并发场景有两类同一个会员在两台设备同时约课以及多个会员同时抢同一个课程的最后一个名额。很多早期项目习惯先查再改代码如下MembershipCard card cardMapper.selectById(cardId); if (card.getRemainingCount() 1) { throw new BusinessException(次数不足); } card.setRemainingCount(card.getRemainingCount() - 1); cardMapper.updateById(card);这段先查后改的代码在并发时必有问题。两个请求同时读到剩余次数为 100各自减 1 再写回最后剩 99一次超扣。原因在于查询和更新不是原子操作。针对余次扣减更稳妥的做法是使用带条件的 UPDATEUpdate(UPDATE membership_card SET remaining_count remaining_count - 1, updated_at NOW() WHERE id #{cardId} AND remaining_count 0) int deductCount(Param(cardId) Long cardId);这段 SQL 的关键在于语句影响行数返回 1 表示扣减成功返回 0 表示余额不足或卡状态异常。业务方法可以这样组装Transactional public void reserveCourse(AppointmentRequest req) { int affected cardMapper.deductCount(req.getCardId()); if (affected 0) { throw new BusinessException(次数不足或卡状态异常); } int inserted appointmentMapper.insert(req.toEntity()); if (inserted 0) { throw new BusinessException(重复预约); } }注意一个隐含点如果次数扣减成功但预约记录插入失败事务会整体回滚所以第 1 步的扣减也会被撤销。这是 Transactional 提供的原子性保证。使用该方案时数据库引擎必须是 InnoDB如果是 MyISAM事务回滚不生效。单体应用阶段条件更新加唯一索引足够到了需要拆库拆表的规模再考虑 Redis 分布式锁也不迟。这一章把登录鉴权和余额扣减打通了下一步就要把接口组织成前端能用的后台管理端。4. 用 Vue3 配合 Element Plus 搭建健身管理系统后台4.1 后台管理端的工程结构与 token 处理用 Vue3 Vite Element Plus 是当前 Java 后台管理系统里最常见的前端组合。一个清爽的工程结构大致是src/ api/ # axios 接口封装 router/ # vue-router 路由 stores/ # pinia 状态管理 views/ member/ # 会员管理 course/ # 排课与约课 report/ # 报表统计 dashboard/ # 工作台管理端的登录流程是用户在登录页提交手机号密码拿到后端 token 后存到 localStorage并在 axios 拦截器里统一处理。下面这段封装是后台系统的常见做法import axios from axios 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( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } ) export default request把 token 放进 Authorization 请求头后后端从请求头里解析会员或员工身份。如果 header 名称不一致比如前端用的是 Access-Token后端读的是 Authorization接口就会一直提示未认证这类问题在联调时很常见。4.2 路由守卫按角色控制菜单健身系统里的角色不止管理员和会员通常还要区分会籍顾问、前台、教练、店长。前端控制菜单的常见方式是路由 meta 配 roles然后在路由守卫里判断。示例配置{ path: /report, name: Report, component: () import(/views/report/index.vue), meta: { roles: [ADMIN, STORE_MANAGER] } }vue-router 的全局前置守卫如下router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() return } if (!token) { next(/login) return } const role localStorage.getItem(role) if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) return } next() })前端菜单开关能做到界面上的隐藏但真正的数据保护一定要由后端接口完成。前端守卫和后端权限注解是两件事如果把安全寄托在菜单不显示上别人直接调接口就能绕过。4.3 会员列表页的完整骨架与分页参数坑会员列表是健身系统里最常用的页面用 el-table 展示会员信息上方放搜索框底部放分页。下面给出可直接运行的最小页面骨架template el-card el-form inline el-form-item label手机号 el-input v-modelquery.phone placeholder输入手机号 clearable / /el-form-item el-form-item el-button typeprimary clickloadData查询/el-button /el-form-item /el-form el-table :datalist v-loadingloading el-table-column propname label姓名 width100 / el-table-column propphone label手机号 width140 / el-table-column propcardType label卡类型 width100 template #default{ row } {{ row.cardType FIXED_TIME ? 期间卡 : row.cardType FIXED_COUNT ? 次卡 : 储值卡 }} /template /el-table-column el-table-column propremainingCount label剩余次数 width90 / el-table-column propbalance label储值余额 width100 / el-table-column propcreatedAt label注册时间 / /el-table el-pagination v-model:current-pagequery.page v-model:page-sizequery.size :totaltotal layouttotal, prev, pager, next current-changeloadData / /el-card /template script import request from /api/request import { ref, onMounted } from vue export default { setup() { const list ref([]) const total ref(0) const loading ref(false) const query ref({ page: 1, size: 10, phone: }) const loadData async () { loading.value true const res await request.get(/member/page, { params: query.value }) if (res.code 0) { list.value res.data.records total.value res.data.total } loading.value false } onMounted(loadData) return { list, total, loading, query, loadData } } } /script这个页面有个高频踩坑点分页参数不一致。后端用 PageHelper 时习惯收 pageNum 和 pageSizeMyBatis-Plus 的 Page 默认是 current 和 size。前端传 page 和 size后端不识别列表就永远只有第一页。团队里要定死一组参数名前后端一旦确定就不要随意改。我这里的约定是 query.page 和 query.size后端 Controller 对应接收 RequestParam(page)、RequestParam(size)返回结构统一包含 records 和 total 两个字段。卡类型在数据库里是字符串枚举不要直接输出到页面上用模板语法换成中文或者打 tag 变彩色业务人员看到 FIXED_TIME 这种值只能皱眉。页面能跑不是终点还要解决一个关键体验问题预约课程和签到。签到页要能根据手机号或卡号快速定位会员提交后立刻反馈签到成功和剩余次数这对前后端交互一致性要求更高。5. 预约冲突、报表统计和缓存把健身俱乐部管理系统推到生产可用5.1 预约冲突校验与课程余位更新要在同一事务里预约接口最关键的是把几个动作放进同一个事务。下面是可复用的预约流程设计根据 scheduleId 查询课程计划状态不是可约就抛出异常。校验会员的卡类型是否符合这门课的使用条件比如团课不能用私教次卡。检查会员是否已经约过同一时段的其他课程。对 course_schedule 执行条件更新 update course_schedule set booked_slots booked_slots 1 where id ? and booked_slots total_slots影响行数为 1 后插入 appointment 记录。第 4 步是防止超卖的关键。多个会员同时抢最后 1 个名额时条件更新能保证只有一个请求的成功回执。用悲观锁 select ... for update 也能解决但在期间一般并发量下条件更新对数据库连接更友好代码也更简单。5.2 用 SQL 统计续费率、销课率与门店到场率报表是健身系统区别于普通小工具的标志。最基础的指标是月度新增会员和累计会员数这个直接 count 就行但续费率不能只查一张表得先建好 payment 表保存每笔订单对应的卡种、金额和购买时间这样统计时才能算清楚有多少人买了不止一次卡。一份常用的月度营收 SQL 如下SELECT DATE_FORMAT(paid_at, %Y-%m) AS month, COUNT(DISTINCT member_id) AS paid_members, COUNT(*) AS order_count, SUM(amount) AS revenue FROM payment WHERE paid_at DATE_SUB(CURDATE(), INTERVAL 12 MONTH) GROUP BY DATE_FORMAT(paid_at, %Y-%m) ORDER BY month DESC;统计今日到场人数时我习惯从签到表关联课程计划来算比在会员表上找 update_time 字段更准确。销课率则是已核销的 appointment 数量除以已产生的预约数量。这三个指标拆开都不难难的是口径统一比如续费是按支付时间算还是按开卡时间算上系统前要和店长对齐。5.3 给会员卡加 Redis 缓存会碰到的一致性问题几千张卡同时在线时会员列表和详情页对数据库的压力会明显上来。常见手段是把低频读取的数据放到 Redis比如课程介绍、门店信息、卡种列表这些数据缓存过期几十分钟也无所谓。但余额、次数这类强一致数据不建议直接缓存。卡被冻结或退款后缓存里还是旧数据扣减就会出错。如果非要缓存只有一条原则更新时直接删缓存而不是更新缓存。删缓存的方式能避免多个服务同时写缓存导致的值不可控。Spring Boot 里的写法是CacheEvict(value memberCard, key #cardId) public void freezeCard(Long cardId) { // 冻结卡的处理逻辑 }这个缓存还应该设置合理的 TTL防止极端情况下缓存永不失效。Redis 同时常用来做分布式锁但第 3 章已经提过单体应用用数据库条件更新就够了没必要引入锁过期时间、锁重入这些额外复杂度。5.4 定时任务处理到期提醒与课程开门提醒Spring Boot 的 Scheduled 可以承担健身系统里两类典型任务每天早上 9 点查询当天 24 小时内到期但未续费的卡生成一条站内信或短信提醒。开课前 30 分钟查询已预约会员并发送上课提醒。把这些任务放进独立组件加 EnableScheduling并按 cron 表达式执行Component public class MemberCardJob { Scheduled(cron 0 0 9 * * ?) public void remindExpiringCard() { // 查询最近一天内到期的有效卡 // 生成提醒记录 } }Spring 的 cron 表达式是 6 位默认不支持秒这点和 Quartz 不一样。第一次写的时候多留一位会一直不执行排查成本还挺高。核心功能都已就位最后就是把整套系统在本地跑通并做验收避免代码能编译但部署失败。6. 本地用 Docker 跑通全栈验收健身系统需要盯住的三个细节把前后端代码准备好后最省心的验证方法是用 docker compose 启一个最小依赖环境再让 Spring Boot 应用连接 MySQL 和 Redis。下面是我常用的 docker-compose.yml 核心片段services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: fitness ports: - 3306:3306 command: --default-authentication-pluginmysql_native_password redis: image: redis:7 ports: - 6379:6379 app: build: . depends_on: - mysql - redis ports: - 8080:8080验收时我通常从三个细节入手这些都是健身系统特有的验证项。第一数据库初始化脚本必须有执行顺序。先跑建表脚本再插入卡种种子数据。如果 Spring Boot 用 JPA 的 ddl-autoupdate 自动建表脚本和实体之间一旦出现字段差异报表统计就会缺列。生产环境建议关掉 ddl-auto用 Flyway 管理版本化迁移。第二确认同一会员两次并发预约只有一个成功。用 JMeter 或命令行并发工具推两个相同请求观察返回结果。成功数应该正好是 1卡剩余次数应该正确减 1。如果两个请求都成功去查事务注解是否生效、更新 SQL 是否带了条件。这是验收第 3 章并发逻辑的最直接方法。第三检查时区。MySQL 连接串要加 serverTimezoneAsia/ShanghaiJVM 启动参数加 -Duser.timezoneAsia/Shanghai否则预约提醒和课程时间会整体偏移 8 小时门店端看到的排课表和实际时间完全对不上这种问题在线下演示时很容易被当场发现。本文还有配套的精品资源点击获取