简介:一份基于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) | 仅限超时未支付订单 |
对应的控制器只暴露业务动作,而不是暴露通用 CRUD:
@RestController @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); List<MemberCard> cards = memberCardMapper.findByExpireDate(remindDate); // 发送短信或站内通知 }这段逻辑的问题在于:只有恰好“7天后到期”的会员才会收到提醒,如果定时任务哪天挂了、或者当天有会员修改了卡的有效期,就会漏发。我的做法是每天凌晨跑一次全量扫描,找出“剩余有效期在3到7天之间”的会员,并且记录上次提醒时间,避免重复提醒。
@Scheduled(cron = "0 30 1 * * ?") public void dailyRemindTask() { List<MemberCard> 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 :inline="true"> <el-form-item label="手机号"> <el-input v-model="query.phone" placeholder="输入手机号" clearable></el-input> </el-form-item> <el-form-item label="会员状态"> <el-select v-model="query.status" placeholder="全部"> <el-option label="正常" :value="1"></el-option> <el-option label="冻结" :value="0"></el-option> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleSearch">查询</el-button> </el-form-item> </el-form> <el-table :data="memberList" border stripe> <el-table-column prop="name" label="姓名"></el-table-column> <el-table-column prop="phone" label="手机号"></el-table-column> <el-table-column label="状态"> <template #default="scope"> <el-tag :type="scope.row.status === 1 ? 'success' : 'danger'"> {{ scope.row.status === 1 ? '正常' : '冻结' }} </el-tag> </template> </el-table-column> <el-table-column label="操作"> <template #default="scope"> <el-button size="small" @click="openEdit(scope.row)">编辑</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @current-change="fetchList" /> </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 路由在刷新时不会 404:
@Controller 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 --registry=https://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 才认为上线成功。备份那一步也是血泪教训换来的——有一次更新后数据被误删,回滚花了两小时,从那之后每次部署前必备份。
做这类管理系统,我的一个习惯是:写代码前先想清楚哪些操作是不可逆的,针对它们加防护。订单退款、会员删除、卡种修改,都属于不可逆或高风险操作。前端弹窗确认只是第一步,后端还要再校验一次状态。这就是为什么我前面强调状态机、强调幂等、强调自动化测试——它们本质上都是为了少出生产事故。
希望这套从数据模型到部署脚本的完整路径,能帮你在做健身房会员管理系统时少走几步弯路,把精力留给真正有趣的业务优化上去。
本文还有配套的精品资源,点击获取