
简介一份基于SpringBoot与Vue框架的健身房会员管理系统完整源码包面向需要完成课程设计、毕业设计或进行前后端分离项目练习的计算机学生与开发者。系统围绕健身房日常运营设计覆盖用户、教练、管理员三种角色业务闭环完整用户可登录注册、购买课程、兑换商品、评价收藏管理员可管理教练、课程、商品、基础数据与公告教练则能回复帖子并查看各类信息。压缩包内共1023个文件约51.67MB以Java、Vue、JavaScript代码为主同时包含SVG图标、CSS样式、图片、XML配置等辅助资源并提供一键执行的批处理脚本便于本地部署启动。项目采用前后端分离架构前端Vue页面与后端SpringBoot接口分层清晰适合对照源码理解RESTful接口设计、Vue组件通信与常用业务逻辑实现。目前已有347人学习下载可作为功能扩展、二次开发以及开题报告或项目文档撰写的参考。1. 为什么一套健身房会员管理系统值得你从零搭一遍刚接手健身房项目时最容易踩的坑不是写不出代码而是把系统做成了“电子表格”。会员续费、私教课核销、储值卡余额、入场闸机联动这些业务看起来简单真正串起来之后订单状态、卡状态、会员过期时间、退款逻辑任何一处对不上运营那边就会拿着手机录屏来找你。基于SpringBoot和Vue的健身房会员管理系统源码设计与实现就是要把这类业务从“能跑”做到“敢上线”。这套系统的价值在于后端用SpringBoot把会员、卡种、订单、入场记录这些核心域拆成独立的模块前端用Vue做管理后台和会员端前后端通过RESTful API通信。适合的人群很明确正在做毕业设计或课设的在校生需要一套能讲清楚业务闭环的完整源码以及小健身房或工作室的owner想低成本拥有一套可定制的管理系统而不是每月付费买SaaS。读完之后你应该能自己跑通一个最小可用的闭环会员办卡 → 前台开卡 → 到场签到 → 续费提醒并且知道每个环节的参数该怎么设、数据表该怎么建、上线前要检查哪些东西。下面我按自己实际做这类项目时的顺序来讲先定数据模型和接口再写SpringBoot后端然后用Vue把页面串起来最后聊部署和那些让我返工过好几次的坑。2. 先把数据模型和接口定清楚后端设计的四个关键决策很多新手拿到这类项目上来就建表、写接口写到一半发现会员卡和订单的关系理不清只能推倒重来。我一般会花半天时间把数据模型和接口边界定好后面编码会快很多。2.1 会员、卡种、订单、入场记录四张核心表的字段与关系健身房会员管理系统的核心域我拆成四块会员member、卡种card_type、订单order、入场记录check_in。会员表存基础身份信息卡种表定义价格、时长、次数订单表记录每一次办卡、续费、退款入场记录表用于闸机或前台核销。CREATE TABLE member ( id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 0, birth_date DATE, emergency_contact VARCHAR(20), status TINYINT DEFAULT 1 COMMENT 1正常 0冻结 2过期, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE card_type ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, duration_days INT NOT NULL COMMENT 有效天数0表示不限, total_times INT DEFAULT 0 COMMENT 总次数0表示不限次数, price DECIMAL(10,2) NOT NULL, type TINYINT NOT NULL COMMENT 1时长卡 2次卡 3储值卡 ); CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, member_id BIGINT NOT NULL, card_type_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_method TINYINT COMMENT 1微信 2支付宝 3现金, status TINYINT DEFAULT 1 COMMENT 1已支付 2已退款 3已作废, expire_date DATE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这里有个关键设计orders 表里冗余了 expire_date 字段。为什么不在卡类型里算因为续费场景下新订单的到期日要基于上一张卡的剩余天数叠加如果每次都实时计算查询逻辑会越来越复杂。冗余这个字段虽然违背了严格意义上的范式但对业务查询非常友好——运营要看的“本月到期会员列表”直接一条 SQL 就能查出来。CREATE TABLE check_in ( id BIGINT AUTO_INCREMENT PRIMARY KEY, member_id BIGINT NOT NULL, check_in_time DATETIME DEFAULT CURRENT_TIMESTAMP, check_in_type TINYINT COMMENT 1前台 2闸机, remark VARCHAR(100) );我一般会在 member 表上建 phone 的唯一索引在 orders 表上建 order_no 的唯一索引这两个字段是天然的业务主键能防止并发下的重复订单。check_in 表按 member_id 建普通索引就够了数量级在十万以内不需要分区。2.2 接口设计用“状态机”而不是“增删改查”来管理订单如果你把订单表设计成任由前端调用 update 接口去改状态上线半个月就会出现“已退款订单还能去健身房签到”的事故。正确的做法是把订单状态变化抽象成状态机后端只暴露“创建订单”“支付成功”“退款”三个动作前端不直接改订单状态。动作前置状态目标状态业务校验创建订单无待支付1会员未冻结该卡种可售支付回调待支付1已支付2校验订单金额一致校验会员手机号一致发起退款已支付2已退款3退款的卡已使用次数为0或按规则折算作废待支付1已作废4仅限超时未支付订单对应的控制器只暴露业务动作而不是暴露通用 CRUDRestController RequestMapping(/api/order) public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService orderService; } PostMapping(/create) public Result create(RequestBody CreateOrderRequest req) { // 参数校验memberId、cardTypeId 不能为空 return Result.ok(orderService.createOrder(req)); } PostMapping(/pay/success) public Result paySuccess(RequestBody PayNotifyRequest req) { return Result.ok(orderService.handlePaySuccess(req.getOrderNo())); } PostMapping(/refund) public Result refund(RequestBody RefundRequest req) { return Result.ok(orderService.refund(req.getOrderNo())); } }这三个接口背后对应的是 OrderService 里的三个领域方法每个方法里先查当前订单状态再决定能不能流转到下一个状态。这样做的直接好处是业务规则集中在一个地方不会出现“前端偷偷调 update 把订单改成已支付”的漏洞。如果你不喜欢状态机这个词可以把它理解成“每个状态变化都走审批流程”只是这里的审批是代码校验。2.3 续费与到期提醒定时任务里的时间边界陷阱到期提醒是健身房系统里最容易被低估的功能。它涉及两个时间点卡的到期日和提醒的触发时间。一个常见错误是用Scheduled(cron 0 0 9 * * ?) public void remindExpiringMembers() { LocalDate today LocalDate.now(); LocalDate remindDate today.plusDays(7); ListMemberCard cards memberCardMapper.findByExpireDate(remindDate); // 发送短信或站内通知 }这段逻辑的问题在于只有恰好“7天后到期”的会员才会收到提醒如果定时任务哪天挂了、或者当天有会员修改了卡的有效期就会漏发。我的做法是每天凌晨跑一次全量扫描找出“剩余有效期在3到7天之间”的会员并且记录上次提醒时间避免重复提醒。Scheduled(cron 0 30 1 * * ?) public void dailyRemindTask() { ListMemberCard cards memberCardMapper.findExpiringBetween(3, 7); for (MemberCard card : cards) { if (!remindLogMapper.existsToday(card.getId())) { sendRemindMessage(card); remindLogMapper.insert(card.getId(), LocalDate.now()); } } }这里的参数“3到7天”不是拍脑袋定的。3天以内应该走人工电话提醒7天以上提醒太早用户会忘。这个窗口期是可以配置的建议放到 application.yml 里运营可以自己调。2.4 报表统计不要为了一个数字写一条SQL再聚合后台管理系统基本都要“今日入场人数”“本月新增会员”“营收总额”这类统计。新手最容易把它们拆成十几个接口每个接口一条 SQL前端再逐个调用拼接。等数据量上来接口响应会越来越慢而且每个统计各查各的口径还不一致。我的方案是建一张 daily_summary 汇总表由一个定时任务在每天凌晨统计前一天的数据写入报表接口直接查这张表。当天实时数据单独走一个轻量接口只查当日不回溯历史。Component public class DailySummaryJob { private final JdbcTemplate jdbcTemplate; public DailySummaryJob(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Scheduled(cron 0 5 0 * * ?) public void run() { jdbcTemplate.update( INSERT INTO daily_summary (summary_date, check_in_count, new_member_count, revenue) SELECT CURDATE() - 1, (SELECT COUNT(*) FROM check_in WHERE DATE(check_in_time) CURDATE() - 1), (SELECT COUNT(*) FROM member WHERE DATE(created_at) CURDATE() - 1), (SELECT IFNULL(SUM(amount),0) FROM orders WHERE DATE(created_at) CURDATE() - 1 AND status 1) ); } }报表页面的数据最多延迟一天对运营决策完全够用。如果你需要看实时数据可以把统计维度缩小到“今日到现在”只查当天避免全表扫描。3. Vue 前端与 SpringBoot 对接从登录到会员管理的完整链路后端接口定好之后前端的关键不在于页面多好看而在于数据流要通、权限要严。Vue 这边我选用 Vue 3 Vite Element Plus 的组合路由用 vue-router状态管理用 Pinia。这套组合在中小型后台项目里很成熟社区资料多遇到问题好搜。3.1 登录鉴权JWT 令牌的存储、刷新与路由守卫健身房管理系统的前端先解决身份问题。后端登录接口返回 JWT前端拿到之后要存起来但存哪里是有讲究的。// src/store/auth.js import { defineStore } from pinia import { login } from /api/auth export const useAuthStore defineStore(auth, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || {}) }), actions: { async login(phone, password) { const res await login({ phone, password }) this.token res.data.token this.userInfo res.data.userInfo localStorage.setItem(token, this.token) localStorage.setItem(userInfo, JSON.stringify(this.userInfo)) }, logout() { this.token this.userInfo {} localStorage.removeItem(token) localStorage.removeItem(userInfo) } } })注释里已经说明这里的核心就是每次请求带上 token。JWT 的过期时间我一般设置成 24 小时后台管理系统不像 C 端 App 那样需要长时间保持登录过期了重新登录是可以接受的。如果你要做“记住我”功能就用 refresh_token 机制但这个在后台系统里不是必需品加多了反而复杂。// src/api/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 15000 }) 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) } ElMessage.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } )路由守卫这块我用 vue-router 的 beforeEach 钩子判断访问的页面是否需要登录。注意要把 /login 路径排除掉否则会无限循环跳转。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path ! /login !token) { next(/login) } else { next() } })这里有个细节后端每个需要鉴权的接口都要校验 token不能只靠前端路由守卫。很多初学者只做了前端守卫后端接口裸奔结果就是别人拿着 Postman 直接调你的接口数据全暴露了。3.2 会员管理页面的核心逻辑搜索、分页、弹窗表单会员管理页是运营每天都要用的页面它的核心不是表格展示而是“搜索条件组合查询 分页 新增/编辑弹窗”这个经典模式。template div el-form :inlinetrue el-form-item label手机号 el-input v-modelquery.phone placeholder输入手机号 clearable/el-input /el-form-item el-form-item label会员状态 el-select v-modelquery.status placeholder全部 el-option label正常 :value1/el-option el-option label冻结 :value0/el-option /el-select /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button /el-form-item /el-form el-table :datamemberList border stripe el-table-column propname label姓名/el-table-column el-table-column propphone label手机号/el-table-column el-table-column label状态 template #defaultscope el-tag :typescope.row.status 1 ? success : danger {{ scope.row.status 1 ? 正常 : 冻结 }} /el-tag /template /el-table-column el-table-column label操作 template #defaultscope el-button sizesmall clickopenEdit(scope.row)编辑/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequery.page v-model:page-sizequery.pageSize :totaltotal current-changefetchList / /div /template对应的 script 部分我习惯把所有查询参数放在一个对象里这样传给后端时序列化很方便const query reactive({ name: , phone: , status: , page: 1, pageSize: 10 }) async function fetchList() { const res await getMemberList({ ...query, status: query.status ? undefined : query.status }) memberList.value res.data.rows total.value res.data.total }search 参数里 status 为空字符串时要把它转为 undefined否则后端收到一个空字符串用 ! null 判断时会把空字符串当成有效值SQL 拼接出WHERE status 的坑查出来永远是空列表。这是我实际踩过的坑现在写代码时会特别注意入参对 null、空字符串、undefined 的区分。3.3 健身房管理系统的前端口径为什么 Vue 打包后要交给 SpringBoot 托管开发阶段前端跑 5173 端口后端跑 8080 端口靠 Vite 的代理转发接口。但生产环境我一般不用 Nginx 单独部署前端而是把 Vue 打包后的 dist 目录交给 SpringBoot 静态资源托管。// vite.config.js export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } }, build: { outDir: dist, assetsDir: static } } })打包之后把 dist 目录下的文件复制到 SpringBoot 项目的 src/main/resources/static 下启动后访问 8080 端口就是完整系统。这样做的好处是部署简单不用维护两套服务坏处是前后端耦合了纯前端改动也要发后端。如果团队后续要拆开再引入 Nginx 不迟开始阶段别过度设计。后端这边要配置一个简单的页面转发让 Vue 的 history 路由在刷新时不会 404Controller public class PageController { RequestMapping(value {/, /login, /dashboard, /member}) public String index() { return forward:/index.html; } }这个 forward 只对带路径的请求生效接口请求 /api 开头的不会被拦截因为你的接口都是 /api 前缀。4. 上线前绕不开的五个坑从数据库到并发每条都让我返工过这个标题覆盖面广爬坑经验是读者最需要的。我挑了五条个人认为最值得说的都是容易犯、难排查的。4.1 数据库时间字段用了 DATETIME导致到期判断错了一天有一个会员 6 月 30 日到期运营说 6 月 29 日还让进了。查问题发现 orders 表里 expire_date 存的是 “2024-06-30 00:00:00”而 check_in 查询判断用的是expire_date NOW()29 日晚上 10 点入场NOW() 是 2024-06-29 22:00显然小于 6 月 30 日零点于是判定未到期。原因到期日存储粒度是日期判断时把时间也放进去了。解决到期判断统一用 DATE 类型字段或者把判断条件改成expire_date CURDATE() - 1。更好的是 expire_date 直接用 DATE 类型MySQL 中 DATE 和 DATETIME 的边界处理完全不同。4.2 并发下单导致同一张卡被重复购买两个前台同时给同一个会员办卡都点了“确认”结果生成了两张订单都扣了钱卡的有效期还被叠加了两次。原因创建订单时没有做幂等控制两个请求同时通过了“会员是否有有效卡”的检查然后各自插入。解决在创建订单前用 Redis 分布式锁或数据库唯一索引兜底。最简单的方式是在 orders 表加一个 (member_id, card_type_id, status) 的唯一索引同一个人同一卡种只能有一条未退款的订单。如果业务允许重复购买那么用订单号规则前端生成唯一请求号后端根据请求号判断是否已处理。4.3 JWT 密钥明文写在 application.yml被实习生提交到了公司仓库SpringBoot 项目的 application.yml 里配了 jwt.secret某次代码审查发现密钥被提交到了 Git 仓库虽然项目是内网的但这是一次明显的安全遗漏。原因没有把配置文件和环境分离。解决密钥放到环境变量或配置中心里本地开发用默认值也只是为了让项目能跑起来但绝不能把生产环境的密钥提交。我现在的做法是 application.yml 里只写占位符${JWT_SECRET}本地 .env 文件里写具体值而且 .env 加入 .gitignore。4.4 Vue 路由模式选了 history刷新页面却 404部署到测试环境后前端首页能打开但进入 /member 后按 F5 刷新就 404。原因SpringBoot 静态资源处理不了 history 模式下的前端路由它把 /member 当成后端路径去找控制器。解决上一章说的 PageController forward 方案或者后端配置一个 fallback把所有非 /api 的路径都转发到 index.html。如果用 Nginx则要加 try_files 规则。4.5 定时任务重复执行会员收到了两条到期提醒服务器在凌晨 1:30 定时跑提醒任务但因为 OOM 重启SpringBoot 的 Scheduled 任务在重启后补跑了一次会员收到两条短信。原因定时任务没有做幂等。解决在任务执行开始时先往提醒记录表里查当天是否有记录有就跳过。更进一步可以用 Redis 的 setnx 加一个分布式锁锁过期时间设为任务执行上限防止多实例同时跑。5. 用自动化测试和部署脚本给自己留一条后悔药这个项目做完之后最值得投入的一件事不是写代码而是写一套“能一键验证”的脚本。我吃过亏交源码那天发现数据库连不上前端调不通现场改了一个多小时极其狼狈。5.1 后端接口测试用 MockMvc 验证状态机流转给订单状态机写三个测试覆盖正常流程、异常流程、非法状态流转全部用 H2 内存数据库跑起来只需要几秒。SpringBootTest AutoConfigureMockMvc class OrderFlowTest { Autowired private MockMvc mockMvc; Test void createOrderAndPaySuccess() throws Exception { mockMvc.perform(post(/api/order/create) .contentType(MediaType.APPLICATION_JSON) .content({\memberId\:1,\cardTypeId\:1})) .andExpect(status().isOk()); } Test void payAlreadyRefundedOrderShouldFail() throws Exception { mockMvc.perform(post(/api/order/pay/success) .contentType(MediaType.APPLICATION_JSON) .content({\orderNo\:\ORDER_REFUNDED\})) .andExpect(status().is5xxServerError()); } }这种测试的价值在于改订单逻辑的时候跑一下全量测试状态机有没有被破坏一眼就看出来。很多项目上线后不敢重构是因为没有自动化测试兜底。5.2 部署脚本一键打包、初始化数据库、备份最后聊一个实战技巧用 shell 脚本把部署过程固化下来包括编译、拷贝静态资源、启动、健康检查。#!/bin/bash # 以我的 Linux 服务器为例 set -e echo Step 1: 打包前端 cd /opt/project/gym-web npm install --registryhttps://registry.npmmirror.com npm run build cp -r dist/* ../gym-server/src/main/resources/static/ echo Step 2: 打包后端 cd ../gym-server mvn clean package -DskipTests echo Step 3: 备份旧数据库 mysqldump -u gym_user -p**** gym_db /data/backup/gym_db_$(date %Y%m%d).sql echo Step 4: 启动服务 systemctl stop gym-server cp target/gym-server.jar /opt/app/gym-server.jar systemctl start gym-server echo Step 5: 健康检查 sleep 15 curl -f http://localhost:8080/api/health echo OK || echo FAIL这套脚本里健康检查是我后来加进去的。以前启动完直接宣布上线结果数据库连接池没起来用户一访问就报错。现在固定等待 15 秒再检查 /api/health 接口接口返回 OK 才认为上线成功。备份那一步也是血泪教训换来的——有一次更新后数据被误删回滚花了两小时从那之后每次部署前必备份。做这类管理系统我的一个习惯是写代码前先想清楚哪些操作是不可逆的针对它们加防护。订单退款、会员删除、卡种修改都属于不可逆或高风险操作。前端弹窗确认只是第一步后端还要再校验一次状态。这就是为什么我前面强调状态机、强调幂等、强调自动化测试——它们本质上都是为了少出生产事故。希望这套从数据模型到部署脚本的完整路径能帮你在做健身房会员管理系统时少走几步弯路把精力留给真正有趣的业务优化上去。本文还有配套的精品资源点击获取