资讯动态

健身房管理系统实战:SpringBoot+Vue3+MyBatis全栈开发要点

发布时间:2026/10/16 5:03:36 来源:尧图企业网站定制
1. 先把需求盘明白健身房管理系统到底要管什么做这个项目之前我拿到的需求其实只有一句话帮我把店里会员、课程、私教预约管理起来。听起来轻飘飘的但真正坐下来梳理业务才知道一家健身房的管理链条比想象中长得多。先说最常见的现状。很多小型健身房还在用 Excel 表格记录会员信息和办卡到期日课程预约靠微信群接龙私教课划卡靠纸质表格。前台换个人就得重新教一遍月底对账的时候更是灾难。我做这套系统的核心目标就是把这套手工流程搬到线上让前台、教练、店长都能在一个系统里完成各自的工作并且每一笔操作都有记录可查。我梳理下来第一版系统至少要覆盖四个角色和五条核心流程会员办卡、续费、预约团课/私教、查看剩余次数和有效期。前台会员开户、办卡收款、修改会员资料、处理预约和取消。教练查看自己的排课表、确认私教课划扣、提交学员上课记录。店长/管理员查看会员总量、续费率、课程出勤率、收入统计。对应的核心业务流就是办卡开户 → 预约课程 → 到店核销 → 划扣次数/天数 → 到期提醒与续费。这个边界非常重要。我没把器材管理、员工工资、进销存健身补剂售卖塞进第一版因为需求一旦铺开项目周期会失控。先跑通会员—卡—预约—上课这条主线后面再扩展。如果你是自己练手做项目我也建议你砍掉那些花哨功能把一个闭环做到能真实运行比堆一堆半成品模块有价值得多。2. 技术选型的理由这套组合不是随便凑的项目标题已经锁定了技术栈SpringBoot Vue3 MyBatis MySQL前后端分离。这不是当前唯一的选择但确实是这类管理系统里性价比非常高的一套组合。我会拆开讲每个环节的取舍逻辑不是照本宣科都是从实际开发里趟出来的体验。2.1 后端为什么是 SpringBoot MyBatis而不是 JPASpringBoot 的优势不用多讲自动配置、内置 Tomcat、生态成熟Java 后端做管理系统基本绕不开它。真正需要对比的是持久层框架MyBatis 和 JPA 之争。我选 MyBatis 的理由有三个SQL 可控性。健身房的查询场景非常杂比如查询未来7天内每天各时段的课程预约数量这种统计 SQL 我直接用 XML 里写原生 SQL 一次搞定JPA 要用 Specification 或者 JPQL 绕半天调试起来也费劲。动态 SQL 能力。会员列表页会有很多筛选条件姓名、手机号、卡类型、到期状态这些条件组合起来是动态的。MyBatis 的if和where标签处理这种场景是天生强项。团队上手成本。团队里只要会写 SQL半天就能上手 MyBatisJPA 的关联关系映射、懒加载、事务陷阱反而更容易让新手踩坑。当然 JPA 也不是没有优点单表 CRUD 确实快但在这个项目里业务复杂度集中在多表关联和统计查询MyBatis 更合适。2.2 前端为什么用 Vue3 Element PlusVue3 的 Composition API 让逻辑复用变得非常清爽。比如会员列表页获取列表、搜索、分页、重置这些逻辑可以抽成一个独立的useMemberList函数多个组件共用的时候不用复制代码。配合script setup语法代码量比选项式 API 少了很多。组件库方面后台管理系统用 Element Plus 是稳妥选择。表单组件、表格组件、弹窗、消息提示、日期选择器全都现成覆盖了管理系统 80% 的界面需求。你要手写这些组件的交互细节至少多花两周时间。我刻意没上 TypeScript。原因很实际项目周期短团队成员对 TS 不熟纯 JS 能让业务开发更聚焦。如果你是自己学习我建议可以上 TS但对快速交付项目来说它在中小型系统里带来的收益没那么明显。2.3 MySQL 单独拎出来说表数量不多但衣服要一层层穿好MySQL 作为数据库没有争议我要说的是连接层的问题。很多小项目直接让后端连数据库但正规一点的做法是加一层数据库连接池配置。我用的是 HikariCPSpringBoot 2.x 以后默认自带连接池的初始大小、最大连接数、空闲超时这几个参数要设好不然并发一上来就会出现连接耗尽的诡异报错错误信息还不明显很容易让人误判成数据库挂了。另外字符集我统一用utf8mb4而不是utf8。因为手机号备注里如果出现 emoji 表情utf8会直接报错utf8mb4才能存进去。这种细节不踩一次坑真的记不住。3. 数据库设计会员、卡种、预约三张核心表的建模细节关于数据库设计我一贯的理念是表结构千万不要照搬业务流程去建。业务流程是动态的表结构要相对稳定。比如会员购买了一张年卡这件事如果只设计一张member表里面加一个card_type字段等会员续费、换卡种的时候历史数据就被覆盖了对账根本没法做。我的做法是拆层设计。3.1 会员主表只存不变的信息member表只保存会员基础信息姓名、手机号、性别、生日、身高体重、注册来源、注册时间、状态。其中手机号加唯一索引这是登录和查询的关键凭证。状态字段我留了active正常、frozen冻结、expired过期三个值方便列表页直接筛选。CREATE TABLE member ( id bigint NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 姓名, phone varchar(20) NOT NULL COMMENT 手机号, gender tinyint DEFAULT 0 COMMENT 0未知 1男 2女, birthday date DEFAULT NULL, height decimal(5,2) DEFAULT NULL COMMENT 身高cm, weight decimal(5,2) DEFAULT NULL COMMENT 体重kg, source varchar(20) DEFAULT offline COMMENT 注册来源, status tinyint DEFAULT 1 COMMENT 1正常 2冻结 3过期, remark varchar(500) DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员主表;3.2 卡种与会员卡把买卡和使用分开卡种card_type是商品定义月卡、季卡、年卡、次卡比如 30 次卡每种卡有价格、时长或次数、适用课程范围。会员卡member_card是购买记录记录哪一个会员买了哪张卡、开卡时间、到期时间/剩余次数。为什么要两张表分开因为同一个会员可能同时持有两种卡年卡管不限次数的团课次卡管私教课。如果把卡信息直接挂在会员表上一次续费就把原卡信息覆盖了后面查续费记录和到期时间就是一笔糊涂账。CREATE TABLE member_card ( id bigint NOT NULL AUTO_INCREMENT, member_id bigint NOT NULL, card_type_id bigint NOT NULL, order_no varchar(32) DEFAULT NULL COMMENT 订单号, start_date datetime DEFAULT NULL COMMENT 开卡时间, end_date datetime DEFAULT NULL COMMENT 到期时间, total_count int DEFAULT NULL COMMENT 总次数(次卡), used_count int DEFAULT 0 COMMENT 已用次数, status tinyint DEFAULT 1 COMMENT 1生效 2已用完 3过期 4退款, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_member (member_id), KEY idx_end_date (end_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段设计上有个关键点开卡时间不直接取购买时间而是留了一个start_date。比如会员 12 月 25 日买年卡但想从 1 月 1 日才开始计算这种延期开卡的需求很常见。如果你图省事直接拿created_at当天开卡后面做续费提醒和过期判断的时候日期全是错的。3.3 课程与预约时间冲突是灵魂问题课程表course保存团课信息课程名称、教练、上课教室、开始时间、结束时间、容纳人数、已预约人数。预约表appointment记录每个会员的预约记录。这里最核心的设计是唯一索引防冲突。同一个会员在同一时间段只能预约一节团课靠数据库层的唯一索引去兜底而不能只靠代码判断。CREATE TABLE appointment ( id bigint NOT NULL AUTO_INCREMENT, member_id bigint NOT NULL, course_id bigint NOT NULL, appoint_date date NOT NULL COMMENT 上课日期, status tinyint DEFAULT 0 COMMENT 0已预约 1已取消 2已完成 3爽约, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_member_course (member_id, course_id, appoint_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;你可能注意到了我把上课日期单独拎出来没有直接存在课程表里。因为排课是周期性的周一到周日每天 19:00 都有瑜伽课但那是七天不同的排课记录。表设计上要用排课实例的概念每次课程实例有独立的日期和时段预约表指向这个实例。不然一个课程要排 30 天你得建 30 条课程记录数据冗余不说改课程名还得批量改。3.4 MyBatis 动态 SQL最实用的一层封装查询会员列表这张页面通常会同时传三个筛选条件姓名模糊搜索、手机号精确匹配、卡类型下拉筛选。直接用三条 SQL 写死条件不全就得改代码。MyBatis 的where标签能自动拼接条件没有传的值自动去掉完美贴合这种场景。select idlistMemberCards resultTypemap SELECT m.name, m.phone, ct.name AS card_name, mc.end_date, mc.status FROM member_card mc JOIN member m ON m.id mc.member_id JOIN card_type ct ON ct.id mc.card_type_id where if testname ! null and name ! AND m.name LIKE CONCAT(%, #{name}, %) /if if testphone ! null and phone ! AND m.phone #{phone} /if if testcardTypeId ! null AND mc.card_type_id #{cardTypeId} /if if teststatus ! null AND mc.status #{status} /if /where ORDER BY mc.created_at DESC /select这种写法很常规但确实可靠。动态 SQL 的核心价值不只是省代码它能避免你在 Service 层拼一堆if/else去决定拼哪个 SQL维护成本一下子降下来。这也是我坚持 MyBatis 而不愿意换掉它的理由之一。4. 后端实现最值得打磨的几个接口4.1 事务边界办卡和写流水必须同生共死健身房系统里对数据一致性要求最高的是办卡操作。会员在前台付款办卡后端要同时完成三件事创建或更新member_card记录写入一条card_order流水记录订单表更新会员状态为 active这三步任何一个失败都不能让另外两个成功。比如会员卡创建成功但订单流水没写月底对账的时候就对不上账了。我的做法是在 Service 层的接口方法上加Transactional事务边界放在一次业务操作的完整流程上。有一个常见错误是把Transactional加到 Controller 层或者私有方法上。加到 Controller 上事务范围太宽了一个请求里多个不相关操作被绑在一起性能损耗和锁冲突都很明显加到私有方法上则完全无效因为 Spring 的事务是通过 AOP 代理实现的私有方法不走代理。这个坑我见太多人踩过了。还有一点Transactional只能管住同一个数据源的事务。如果后面系统加了消息队列或者远程调用那就要引入分布式事务方案但第一版系统完全没有这个必要别过度设计。4.2 私教课扣次并发环境下的超卖问题私教课次卡扣次数这个场景比团课预约更容易出问题。比如一个会员只有最后 1 次私教课但前台和教练手机端同时提交了两节私教课的划扣请求如果代码逻辑是先查剩余次数大于 0 就扣那两次请求都可能查到剩余次数为 1然后双双扣减最后剩余次数变成 -1。这种并发问题的本质是读改写三步操作不是原子的。两种标准解法数据库悲观锁SELECT ... FOR UPDATE锁住这张卡记录同一个时刻只有一个事务能修改它。乐观锁在member_card表加版本号字段version更新时带上WHERE version 旧值更新成功则 version 1失败则说明已被别人改过返回提示操作冲突请重试。我是用乐观锁因为健身房私教课划扣的并发量并不高悲观锁在 MySQL 里容易造成锁等待而乐观锁只要更新 SQL 写对就行。UPDATE member_card SET used_count used_count 1, version version 1 WHERE id #{cardId} AND version #{version} AND used_count lt; total_count注意WHERE里加了used_count total_count这个条件可以在数据库层面直接阻止超扣算是双保险。返回的影响行数为 0 时就说明冲突了或者次数已用完后端据此返回给前端明确提示。4.3 团课预约检查可约名额的时间窗口问题团课预约的核心逻辑是课程有人数上限预约时先查已预约人数小于上限才能预约成功。但先查再写同样面临并发覆盖风险——两个会员同时在最后 1 个名额时预约都可能查到已预约人数为 9然后都插入成功最终超员。最稳妥的方式是用有条件的插入INSERT INTO appointment (member_id, course_id, appoint_date, status) SELECT #{memberId}, #{courseId}, #{date}, 0 FROM course WHERE id #{courseId} AND appoint_date #{date} AND reserved_count lt; max_count这个 SQL 的思路是插入前先检查课程的剩余名额只有条件满足才插入成功。通过affected rows是否为 1 判断是否预约成功。不过这里的前提是course表里要有reserved_count已预约人数和max_count上限字段预约成功后还需要更新reserved_count。我实际开发中用的是另一种方案把预约和人数增加放在一个事务里先UPDATE course SET reserved_count reserved_count 1 WHERE id ? AND reserved_count max_count如果影响行数为 1再插入预约记录。这样既防止了超员又不用额外查询。4.4 登录鉴权JWT 在管理系统里的实战姿势前后端分离项目的鉴权JWT 是标配了。我用 Spring Security 太重这个系统我只用了拦截器加 JWT 的方案。登录成功后签发 token前端每次请求在 Header 里带上Authorization: Bearer token后端写一个拦截器统一校验。token 里我塞了三样信息用户 ID、用户名、角色。角色放在 token 里有什么好处后端做权限判断时不用每次查数据库。比如删除会员这种操作只有管理员能做拦截器里直接取claims.get(role)判断就行少一次数据库查询。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; } }拦截器要做两处配置/api/login放行静态资源放行其他/api/**全部拦截。这里我踩过的坑是放行规则和拦截规则写反了结果所有请求都被拦住登录接口死活通不过。调试了半个小时才发现是路径匹配的优先级问题。Spring 的拦截器配置里excludePathPatterns优先级高于addPathPatterns这个顺序一定要记清楚。另一个值得注意的点token 过期时间的设置。我设的是 24 小时。时间太短用户频繁要重新登录体验很差时间太长又增加安全风险。管理系统内部使用24 小时是一个还算折中的选择。后续如果要接微信小程序端可以改成双 token 机制refresh token access token但第一版没必要。5. 前端 Vue3 落地从搭建到核心页面5.1 脚手架和目录组织方式前端我用 Vite 搭建项目。Vue CLI 虽然也能用但 Vite 的冷启动速度快很多开发体验完全不同。创建项目时直接选择 Vue3 JavaScript 模板。目录组织我是一个页面一个文件夹的规则src/ ├── api/ # 接口请求封装 │ ├── member.js │ ├── course.js │ └── auth.js ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── views/ # 页面视图 │ ├── member/ │ │ ├── MemberList.vue │ │ └── MemberForm.vue │ ├── course/ │ └── dashboard/ └── utils/ # 工具函数request.js 封装这种组织的最大好处是新增一个业务模块时你知道每个文件应该放哪里老项目改起来不会茫茫然找不到头。项目规模再大一点可以按模块拆分 views 的二级目录但不能按组件类型来分把按钮、卡片、弹窗全塞一个文件夹后期必然乱。5.2 Axios 封装的实战技巧请求封装是所有管理系统前端最核心的基础设施没有之一。我的request.js做了统一处理三件事baseURL 配置、请求头带 token、统一错误拦截。import axios from axios import { ElMessage } from element-plus import { useUserStore } from /stores/user const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) request.interceptors.response.use( response { // 直接返回 data省去每个页面再取一层 .data const res response.data if (res.code 200) { return res.data } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { if (error.response error.response.status 401) { ElMessage.error(登录已过期请重新登录) router.push(/login) } else { ElMessage.error(网络错误) } return Promise.reject(error) } )这里有一个细节很多人容易漏后端返回的响应体里如果统一是{ code: 200, data: ..., message: ... }前端拦截器可以直接把data字段剥离返回。这样业务页里调用接口时拿到的直接就是数据本身不用每个方法都写res.data.data看着清爽很多。5.3 会员列表页从script setup到组合式函数会员列表页是管理系统的脸面基本功能包括搜索、分页、新增、编辑、删除、状态筛选。我用了script setup语法逻辑复用通过组合式函数来实现。核心逻辑并不复杂响应式数据定义列表和分页参数getList方法请求后端条件变了就重新拉数据。但有几个细节对这个页面的用户体验影响非常大表格的 loading 状态必须有。接口慢的时候表格空白会让人觉得卡了。删除操作必须二次确认。这个看似简单但没有确认的删除是灾难。分页组件要把当前页码和每页条数双向绑定到查询参数。script setup import { ref, onMounted } from vue import { getMemberList, deleteMember } from /api/member import { ElMessage, ElMessageBox } from element-plus const list ref([]) const total ref(0) const queryParams ref({ page: 1, pageSize: 10, keyword: , cardTypeId: undefined, status: undefined }) async function getList() { const res await getMemberList(queryParams.value) list.value res.records total.value res.total } function handleSearch() { queryParams.value.page 1 getList() } function handleDelete(row) { ElMessageBox.confirm(确定删除会员${row.name}吗, 警告, { type: warning }) .then(async () { await deleteMember(row.id) ElMessage.success(删除成功) getList() }) .catch(() {}) } onMounted(getList) /script要说组合式函数的好处最直接的例子就是我后来又做了一个到期会员列表页面逻辑几乎一样只是搜索条件不同。我把这段增删改查逻辑抽到useCrudList这样一个通用函数里两个页面共用改动一个地方两处生效维护成本直接降低一半。5.4 日期和状态展示的格式化处理表格里展示的日期如果直接是2024-05-01T08:00:00这种格式用户看着很别扭。后端最好直接返回yyyy-MM-dd HH:mm:ss格式的字符串或者前端用 dayjs 做统一格式化。状态字段如果直接用数据库存的 1、2、3 数字用户根本不知道是什么。我用了 Element Plus 的el-tag搭配一个映射对象const statusMap { 1: { label: 正常, type: success }, 2: { label: 冻结, type: warning }, 3: { label: 已过期, type: danger } }模板里根据状态值动态渲染不同颜色的标签一眼就能看出会员的卡状态。这种小优化对前台同事的日常工作来说体验提升是非常明显的。6. 前后端联调中的真实踩坑记录6.1 跨域问题不是加一个注解就完事了联调第一天就被跨域卡住了。前端跑在http://localhost:5173后端跑在http://localhost:8080端口不同必然跨域。网上搜到的方案千篇一律后端加CrossOrigin注解。我用了之后发现一个尴尬的情况单条接口加注解能通但加在 Controller 类上会失效而且每个接口加注解太啰嗦代码很难看。正确的做法是写一个全局的WebMvcConfigurer配置类统一处理Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里还有一个更容易被忽略的点如果后端加了 JWT 拦截器跨域预检请求OPTIONS请求会被拦截器拦下来导致前端报跨域请求失败。必须在拦截器里放行OPTIONS请求否则配置了 CORS 也没用。这个坑我排查了一个下午最后在浏览器 Network 面板里发现预检请求返回的是 401 才定位到问题。典型经验前端报跨域不一定是后端没配 CORS有可能是被拦截器挡了。6.2 时间格式的统一一台机器三个时间健身房的系统里日期无处不在办卡时间、课程时间、预约日期、到期日期。最容易出问题的场景是前端用日期选择器选了个2024-06-01后端拿到的是2024-05-31T16:00:00.000Z因为时区转换差 8 小时。等到判断今天是 6 月 1 日会员是否到期的时候就出现了一天的偏差。我的处理方案是统一约定前端传给后端的日期字符串一律用yyyy-MM-dd不传时分秒。后端接收日期参数的 DTO 字段用JsonFormat(pattern yyyy-MM-dd)。数据库的date类型只存年月日datetime类型才存完整时间。所有时间计算通过 Java 的LocalDate不使用过时的Date和Calendar。另外后端返回给前端的日期格式也用JsonFormat统一成yyyy-MM-dd HH:mm:ss这样前端拿到直接展示就行不用再做转换。6.3 数据库日期字段的“魔鬼细节”这是一个非常具体、但极容易踩的坑在 MyBatis 里查出来再更新的记录如果原值是null更新后这个字段会被自动覆盖成null。比如给会员卡做续费的时候代码逻辑是MemberCard card cardMapper.selectById(cardId); card.setEndDate(newEndDate); cardMapper.updateById(card);如果card对象里有某个字段本来就是null比如次卡的total_count执行updateById时MyBatis-Plus 默认的更新策略会用传入对象的字段值去整体覆盖数据库记录null字段同样会写进数据库。这样年卡续费时那张年卡原本不该有“次数”字段却被置成null问题不大但有些业务字段被误置空就很难发现了。解决办法是给实体类字段加注解配置更新策略TableField(updateStrategy FieldStrategy.NOT_NULL) private Integer totalCount;这样字段为null时MyBatis-Plus 会自动忽略不会覆盖数据库里的原值。做这种先查再改的更新操作时强烈建议多留个心眼查一下你的更新策略。6.4 分页数据的总数显示不对列表页的分页我用了 MyBatis 的分页插件。前几页数据正常翻到后面发现总数有时对不上。后来定位到原因是我写了一个多表联查的 SQL但 resultType 没有指定好分页插件在 count 查询时直接套用了原 SQL 的JOIN导致统计总数时把一对多关系里的重复数据也算进去了。解决办法是在分页 SQL 里显式拆分列表数据查询和总数统计查询分开写。分页插件支持PageHelper的 count SQL 优化但最好的方式还是你自己写的 SQL 本身就要精确查询列表带JOIN没问题但 count 的 SQL 不需要JOIN时就不要带。这个问题的本质和数据库设计时的第三范式是一致的不要为了列表展示方便把所有维度统统塞进一个超大 SQL 里。试几次之后你会对表要怎么拆更有感觉。7. 再往深处走这套系统的优化与扩展方向7.1 经营管理数据不用报表工具也能出统计第一版跑通后店长提的需求变成了我想看看这个月开了多少新卡、续费了多少、哪个课程最受欢迎。这就是统计报表的需求。实现方式不需要引入复杂报表引擎。MyBatis 写几个统计 SQL前端用 ECharts 画折线图和柱状图即可。比如近 6 个月新会员趋势SELECT DATE_FORMAT(created_at, %Y-%m) AS month, COUNT(*) AS total FROM member WHERE created_at DATE_SUB(CURDATE(), INTERVAL 6 MONTH) GROUP BY DATE_FORMAT(created_at, %Y-%m) ORDER BY month这种 SQL 初看简单但有几个细节要处理比如月份没有会员注册时这个月不会出现在结果里前端折线图就会缺数据点。处理方式可以是在 SQL 外层套一个日期表补齐或者在前端循环补齐 6 个月的数据再交给图表。我推荐前端补齐因为它是展示层的事不该由 SQL 处理。7.2 会员到期提醒定时任务和消息推送会员到期前 7 天推送提醒是提频次最高的需求因为直接关系到续费收入。技术实现思路写一个定时任务Spring 的Scheduled每天凌晨扫描member_card表里 7 天内到期、状态正常的记录然后生成提醒记录。推送渠道的选择第一版不需要直接接短信服务商也不接微信模板消息那需要企业微信认证周期长我是在系统里做一个到期提醒列表前台打开仪表盘就能看到今天有没有需要重点跟踪的到期会员。这个做法看似原始但胜在简单可靠。后续要接通知服务只需要把提醒记录批量发送的逻辑替换掉即可提醒表的落库逻辑不用改。提醒表设计时注意加已通知状态防止定时任务执行两次时重复提醒。用唯一索引member_card_idnotify_typenotify_date也能兜底。7.3 后续功能扩展的边界这套系统做完摆在面前的可做方向其实很多课程签到核销通过二维码或会员手机号核销预约替代前台手工打勾。员工排班和提成统计私教课的教练提成比例、团课带课数统计财务需要这些数据。移动端适配教练需要用手机看课表会员想自己在小程序里约课。移动端不必单独开发 App一个适配移动端的 H5 页面或者小程序即可。但每个扩展都对需求和数据模型提出了新的挑战。比如教练提成就要区分一节课的所属教练和实际代课教练会员小程序预约就要和现有的预约防冲突逻辑完全对齐。这些不是不能做而是在启动新需求前务必像最初阶段一样把业务链梳理清楚。8. 一次完整的会员办卡流程走通胜过十天纸上谈兵最后我想强调项目收尾时要做的验证按真实业务的轨迹走一遍完整流程。不要只测试接口通不通要在浏览器里以三种身份亲自操作前台给一个新会员办年卡再给这个会员约一节三天后的团课。前台取消这节课再重新约同一时间另一节瑜伽课确认时间冲突逻辑生效。教练角色登录查看自己的课表然后对私教课进行划扣确认会员剩余次数减一。把私教课次数用光再去预约确认系统提示次数不足。只要这四条流程走通这个系统就已经具备真实交付的条件了。我在实际项目收尾时发现前端页面和接口都测过了但各自的成功并不等同于整体流程的连贯。流程走通时暴露出的问题才是真问题。比如约课成功后课程的人数忘更新这类跨模块的 bug单独测接口根本发现不了。这套系统并不复杂它没有分布式事务、没有高并发、没有复杂的推荐算法。但正因为简单才更需要把每一步的边界、约束、数据一致性考虑清楚。健身房管理系统的难点不在于某个技术点有多深而在于如何把现实的业务规则翻译成严谨的数据逻辑。这个翻译能力才是做这类系统真正值钱的部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑