ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot + Vue + 微信小程序:健身房预约管理系统架构与并发实践

Spring Boot + Vue + 微信小程序:健身房预约管理系统架构与并发实践 做健身房管理系统的人和做电商系统的朋友聊天时经常会被问同一个问题你们这系统不就是个预约工具吗我的回答是预约只是表面真正麻烦的是预约前后那一大堆状态流转和人员权限。我做的康益健身房助手就是这样一个项目微信小程序给会员用Vue 管理后台给健身房运营人员用中间用 Spring Boot 提供统一接口把课程预约、教练排课、会员卡管理、身体数据记录串成一条完整的业务线。这个项目最核心的设计决策是没有把所有页面都塞进小程序里。会员端只需要轻量操作后台管理涉及到课程增删、会员状态变更、营收统计这些操作挪到 Vue 后台更顺手。如果你是准备做类似的 springbootvue 全栈项目或者要给健身房、瑜伽馆这类线下实体门店做一套管理工具这篇文章可以帮你少走弯路。我会把数据库设计、预约并发处理、小程序登录、Vue 权限控制这些关键环节全部拆开讲也会说说我在实际部署联调中踩过的坑。1. 为什么是三端架构而不是一个大一统应用1.1 用户、店长、前台的诉求完全不同先说一个常见的误区很多第一次做这类项目的人喜欢把“查看课程”和“管理课程”两个功能放在同一个页面里加个角色判断就完事了。这种设计在演示时看不出问题一旦真实使用就会变得很别扭。会员打开小程序只想快速看到今天的课程、一键预约店长需要批量处理课程排期查看哪个会员过期了统计这个月卖出了多少张卡。这两种操作的频次、设备、使用时长完全不同。所以我一开始就明确了边界微信小程序面向会员功能做减法突出“看课、约课、到店记录、身体数据”Vue 管理后台面向店长和前台功能做加法覆盖经营管理的每一个环节Spring Boot 接口层统一承载两端的业务逻辑并把权限、事务、并发控制全部放在后端这种拆分带来的直接好处是两端可以独立迭代。小程序发版需要微信审核管理后台可以随时上线互不阻塞。后面我在实际部署时发现这个决定帮了大忙——小程序审核被驳回的时候后台依然能正常更新课程数据不会影响门店运营。1.2 技术选型的过程和理由技术栈方面我的选择非常务实后端Spring Boot 2.7.x MyBatis-Plus MySQL 8.0 Redis后台前端Vue 3 Vite Element Plus Axios ECharts小程序端微信原生小程序不跨端保持最简单选择 MyBatis-Plus 而不是原生 MyBatis是因为这个项目的查询场景里有很多单表 CRUD、分页查询MyBatis-Plus 的 BaseMapper 和 LambdaQueryWrapper 能省掉大量重复代码。选择 Vue 3 Vite 是因为 Element Plus 对 Vue 3 的支持已经很成熟ECharts 做统计报表也顺手。小程序用原生开发是为了避免引入 uni-app 或 Taro 之后还要处理一套自定义编译链。项目里没有横跨多端的需求原生就是最稳的方案。提示如果你对这个项目涉及的几个技术栈还不太熟建议先把 Spring Boot 的启动流程、Vue 的组件通信、小程序的生命周期过一遍再往下看。这篇文章不会讲基础语法只讲怎么把这三样东西串成一套系统。2. Spring Boot 后端表设计、登录与预约并发2.1 数据库表结构是怎么拆出来的后端设计的第一步永远是表结构。我的做法是“按业务对象拆表按操作状态留字段”最终核心表如下表名核心字段用途memberid, openid, nickname, avatar, phone, member_type, expire_time会员基础信息coachid, name, title, avatar, intro教练档案courseid, coach_id, course_name, course_type, start_time, end_time, capacity, booked_count, status课程排期appointmentid, member_id, course_id, appoint_time, cancel_time, status预约记录membership_cardid, card_name, valid_days, price, discount会员卡商品ordersid, order_no, member_id, order_type, pay_amount, status, create_time订单body_recordid, member_id, weight, body_fat, record_date身体数据几个设计要点值得展开说。第一course 表里的 booked_count 是冗余字段。正常来说已预约人数可以通过 appointment 表 count 出来但课程列表页经常要显示“已约 8/12”每次都 count 一遍数据量大了查询负担不小。我选择在 appointment 插入和取消时同步更新 booked_count查询课程时直接用这个字段。做统计报表时如果需要绝对值再去 appointment 表聚合。第二appointment 表里的 status 字段不要只存 0/1。我用了三态1 表示已预约2 表示已取消3 表示已完成课上完这样后面做“爽约率”“出勤率”统计时数据是现成的。这个字段在真实运营里特别有用我后面加上统计报表就是靠它。第三订单和课程预约没有强制绑定。会员购买会员卡是一个订单约课是另一个独立操作。这种设计更符合健身房的实际经营方式——先办卡再约课约课不额外收费。如果你接到的需求是“按次付费约课”那就需要给 appointment 增加 pay_status 字段把订单和预约打通这个在第六节再展开。2.2 微信登录和 JWT 会话管理小程序端的登录流程是wx.login 拿到临时 code传给后端后端拿 code 去微信接口换 openid。拿到 openid 后先去 member 表查有没有这个会员没有就自动注册然后下发一个 JWT 给小程序。之后所有请求都在 Header 里带 Authorization: Bearer 。后端接口放在 AuthController 里核心逻辑大概是这样PostMapping(/auth/login) public Result login(RequestBody WxLoginDTO dto) { String openid wxService.code2Session(dto.getCode()); if (openid null) { return Result.error(微信登录失败); } Member member memberMapper.selectOne( new LambdaQueryWrapperMember() .eq(Member::getOpenid, openid) ); if (member null) { member new Member(); member.setOpenid(openid); member.setNickname(微信用户 genRandomSuffix()); memberMapper.insert(member); } String token jwtUtil.generateToken(member.getId(), member); return Result.ok(new LoginVO(token, member)); }这里有两个实际教训。第一个是 openid 和用户表的绑定必须加唯一索引我差点在第一次上线前漏掉。如果同一个微信用户重复调用登录接口在高并发或接口重试时可能插入两条记录之后所有预约都会关联到错误的会员上。这个唯一索引一定要在建表时加上不要指望应用层判断。第二个是 JWT 的过期时间。小程序端用户可能隔两周打开一次token 设置成 7 天甚至更短会频繁跳回登录页。我把 token 有效期设成 30 天并且在小程序端封装了一个“401 自动静默登录”的拦截器过期之后自动走一遍 wx.login 重新登录用户完全无感。这个后面在小程序章节会详细说。管理端登录就简单多了admin 表里存账号和 BCrypt 加密后的密码登录成功后也签发 JWT在拦截器里区分 member 和 admin 两类角色。2.3 预约操作背后的并发控制约课这个动作看着简单其实有个容易被忽视的技术点并发。同一个热门课程晚上八点开放预约可能同时有几十上百个会员在抢。如果两个请求同时把 booked_count 从 11 改成 12就会出现超售——12 人的课最后约进来 14 个人。我采用的方案是 Redis 分布式锁 数据库乐观锁双保险预约接口的核心流程是这样的接收预约请求校验会员 token 和课程状态调用 redisLock.lock(course:book: courseId, 3秒) 获取锁查课程判断 booked_count capacity插入 appointment 记录更新 booked_count booked_count 1释放锁如果 Redis 不可用兜底逻辑是数据库乐观锁。在 course 表加一个 version 字段更新时用 UPDATE course SET booked_count booked_count 1, version version 1 WHERE id ? AND version ?影响行数为 0 说明并发冲突直接返回“手慢了课程已约满”。这里必须强调一下Redis 锁和乐观锁不是二选一而是互相兜底。只靠 Redis 锁怕 Redis 宕机只靠乐观锁怕冲突频繁导致用户体验差。实际压测下来这个方案在 200 并发下没有出现超售单个预约接口的 P95 响应在 800ms 以内对小程序端完全够用。取消预约的逻辑是相反操作但要注意释放名额的时机。我规定课程开始前 60 分钟可以取消超过这个时间不允许取消避免会员临时放鸽子导致课程空位。这属于业务规则不是技术问题但后端必须把它写成硬校验不能只在界面上做按钮显隐。2.4 MyBatis-Plus 下多表数据组装技巧如果直接把 course 表返回给前端前端拿到的是 coachId还得再发一次请求去查教练姓名。与其在前端做二次查询不如在后端组装好 VO。我习惯的做法是course 表查出来后把所有 coachId 收集成一个集合一次 SELECT * FROM coach WHERE id IN (...) 查出教练信息再在内存里组装成 CourseVO。这样不管课程列表是 20 条还是 100 条数据库查询永远是两次。public ListCourseVO listCourseByDate(String date) { ListCourse courses courseMapper.selectList( new LambdaQueryWrapperCourse() .eq(Course::getCourseDate, date) .orderByAsc(Course::getStartTime) ); if (CollUtil.isEmpty(courses)) { return new ArrayList(); } SetInteger coachIds courses.stream() .map(Course::getCoachId).collect(Collectors.toSet()); MapInteger, Coach coachMap coachMapper.selectBatchIds(coachIds).stream() .collect(Collectors.toMap(Coach::getId, Function.identity())); return courses.stream().map(c - { CourseVO vo new CourseVO(); BeanUtils.copyProperties(c, vo); Coach coach coachMap.get(c.getCoachId()); if (coach ! null) { vo.setCoachName(coach.getName()); vo.setCoachAvatar(coach.getAvatar()); } return vo; }).collect(Collectors.toList()); }这里踩过一个坑如果用的 MyBatis-Plus 版本比较老selectBatchIds 在集合为空时会生成一条 WHERE id IN () 的非法 SQL直接把请求打挂。所以我每次都会先判断 coachIds 是否为空再查询这个习惯从那时候保留到现在。3. 微信小程序端把预约闭环打磨成顺手的产品3.1 小程序页面结构tabBar 和页面跳转怎么安排小程序端的页面我分了三层tabBar 页面、业务页面、弹层页面。tabBar 只有三个首页课程、预约、我的。课程和预约分开是因为“看课程”和“管理我的预约”是两种不同心智前者用于浏览发现后者用于管理个人行程。个人中心承载会员卡、身体数据、消息等入口。业务页面包括课程详情、教练详情、会员卡购买、身体记录编辑等。这类页面用 wx.navigateTo 做栈式跳转返回时能保留上一页状态。弹层页面主要是在课程详情页里用半屏弹层显示“预约成功”“取消确认”等提示不做独立路由减少页面栈深度。这种分层最大的好处是页面职责清晰。首页只需要关注数据加载和列表渲染预约页只需要关注预约状态和取消操作两个页面之间的数据同步通过自定义事件或全局变量触发刷新不搞复杂的全局状态管理。3.2 课程预约与取消预约的完整链路我以“预约一节团课”为例走一遍完整链路。用户点进课程详情页页面拿到 courseId调用 /api/courses/{id} 拿到课程详情。这里要注意详情接口返回的不能只有课程基本信息还要返回当前用户是否已预约、剩余名额、倒计时状态。我在 CourseDetailVO 里加了 bookedByMe 和 remaining 字段由后端一次性算好前端直接渲染。预约按钮点击后小程序弹出确认弹层用户点确定前端调用 /api/appointments POST 接口。接口返回成功后在当前页更新按钮状态为“已预约”同时通过全局事件通知首页刷新课程列表。这里有个细节容易被忽略预约成功之后要调用 wx.showToast 提示但 toast 默认 1.5 秒后自动消失如果用户在 toast 还没消失时马上退出页面体验会有点割裂。我的做法是 toast 显示 2 秒并且只在返回上一个页面时刷新课程列表避免页面销毁后还在更新数据。取消预约我设置了前置交互弹层上明确写着“课程开始前 60 分钟可取消爽约超过 3 次将限制预约”这个提示虽然比单纯一个“确认取消”多了几步但大幅减少后来的客服申诉。3.3 请求封装、登录态刷新和错误提示小程序的 wx.request 相当原始没有拦截器概念我直接在 app.js 里封装了一个 request 方法自动在 Header 里带上 token统一处理 HTTP 状态码和业务状态码遇到 401 时先静默调一次登录接口拿到新 token 后重放原请求网络错误时统一弹 toast不让每个页面重复写错误处理这个封装最关键的是“401 静默登录 重放请求”部分。用 Promise 包一层登录期间其他待重放的请求先排进队列等 token 刷新完成后统一重放。function request(options) { return new Promise((resolve, reject) { const header { Content-Type: application/json }; if (getApp().globalData.token) { header[Authorization] Bearer getApp().globalData.token; } wx.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header, success(res) { if (res.data.code 401) { handleTokenExpired(options, resolve, reject); return; } if (res.data.code ! 0) { wx.showToast({ title: res.data.msg, icon: none }); reject(res.data); return; } resolve(res.data.data); }, fail(err) { wx.showToast({ title: 网络异常请重试, icon: none }); reject(err); } }); }); }这个封装上线后帮我少接无数个客服电话。之前每次 token 过期用户都会看到登录页闪一下体验极差重新封装之后后台管理员都能看到用户平均会话时长明显变长了。3.4 小程序端的几个高频坑第一个坑是顶部导航栏的高度。不同机型状态栏高度不同刘海屏和普通屏差出来几十像素。如果页面用了自定义导航栏必须通过 wx.getMenuButtonBoundingClientRect() 拿胶囊按钮的位置再结合 wx.getSystemInfoSync() 的系统信息动态计算导航栏高度。我一开始写死了一个值结果 iPhone 14 Pro 上按钮直接顶到状态栏里去了。第二个坑是 swiper 的 bindchange 事件触发时机。周视图里我在首页放了 scroll-view 做横向滑动切换日期日期选中态靠 currentIndex 控制。实测发现快速滑动时 scroll-view 的滚动事件会频繁触发容易卡顿。解决方法是加一个防抖并在滚动结束事件 bindscrollend 里统一处理日期切换。第三个坑是原生组件的层级问题。在小程序里map、video 这类原生组件的层级比普通组件高会盖住弹层。课程详情页里我放了教练简介视频预约确认弹层直接显示在视频最底层。后来改成用 cover-view 重新画弹层按钮才解决层级问题。这个坑如果你不在课程页放多媒体内容可能遇不到但要知道有这么回事。4. Vue 管理后台给店长一根管理线4.1 后台功能模块的拆解Vue 管理后台的使用者是店长、前台和教练最常见的操作是仪表盘今日课程数、今日预约数、本周营收、会员增长趋势会员管理搜索会员、查看会籍到期时间、手动续费课程管理增删改查课程排期、修改课程容量、临时取消某节课教练管理维护教练档案和课程绑定关系订单管理查看会员卡和课程订单支持退款身体数据统计查看某位会员的体重、体脂历史趋势图我把这些功能放在侧边栏菜单里每个菜单对应一个 Vue Router 路由。路由文件不是一把梭的平铺结构按模块拆成了 member.js、course.js、order.js 等几个文件再用 import.meta.glob 动态合并。这样每个人维护自己负责的模块不会因为改一个路由导致整个菜单崩掉。4.2 路由守卫和操作权限控制管理后台的权限模型我设计了两个维度菜单权限和操作权限。菜单权限决定侧边栏能看到哪些模块操作权限决定能否点击“新增课程”“退款”这类按钮。实现上用的是非常朴素的做法登录接口返回用户角色以及权限标识数组 permissionsVue Router 的 beforeEach 里根据当前路由的 meta.permission 判断是否放行。router.beforeEach((to, from, next) { const token localStorage.getItem(admin_token); if (!token to.path ! /login) { next(/login); return; } if (token to.path /login) { next(/); return; } const permissions JSON.parse(localStorage.getItem(permissions) || []); if (to.meta.permission !permissions.includes(to.meta.permission)) { next(/403); return; } next(); });按钮级别的权限我用了一个自定义指令 v-permission没有权限时直接移除 DOM。这种方案的缺点是不安全懂点前端的人改 localStorage 就能绕过按钮但它的目标是提升体验真正的安全校验在后端接口里。后端每个接口都会用拦截器校验角色所以前端权限只是“看起来不显示”不是“真的能访问”。4.3 仪表盘用 ECharts 展示预约和营收趋势仪表盘是店长每天打开后台第一个看到的页面我放了四张卡片加两个图表今日预约数、今日营收、本月新增会员、课程满员率下方是近 14 天预约趋势折线图、近 7 天课程热度条形图。图表数据不是前端算的而是后端提供聚合接口。比如 /api/admin/stats/appointment-trend?days14后端一条 SQL 按日期 group by 就出来了SELECT DATE(appoint_time) AS date, COUNT(*) AS cnt FROM appointment WHERE appoint_time DATE_SUB(CURDATE(), INTERVAL 13 DAY) AND status 1 GROUP BY DATE(appoint_time)ECharts 的配置本身不复杂关键点是数据格式。折线图需要的是 { date: 2025-01-05, cnt: 12 } 这种结构后端的聚合接口直接返回这种结构前端就完全不需要二次转换直接塞进 series。这个约定在项目一开始就要和后端对齐否则前端写一堆 map 和 reduce 很容易出 bug。5. 联调和部署阶段我踩过的坑和解决办法5.1 小程序合法域名和真机调试开发阶段微信开发者工具可以勾选“不校验合法域名”局域网里连后端 IP 就能调通。但真机预览时这个开关不生效——真机上小程序对请求域名有强制校验。解决办法是给后端配一个备案过的 HTTPS 域名在微信公众平台后台把域名加到 request 合法域名里。这里有个细节开发环境域名和生产环境域名如果不一样BASE_URL 要写成可配置的我在小程序端用了 ext.json 或者根据 wx.getAccountInfoSync().miniProgram.envVersion 判断环境不同环境走不同域名。5.2 Spring Boot 的 CORS 配置小程序端走的是 wx.request不涉及浏览器同源策略只要域名合法就能访问。但 Vue 管理后台跑在浏览器里前端地址从 http://localhost:5173 换成线上 https://api.xxx.com 后就会出现跨域问题。我一开始在 Spring Boot 里加了一个全局 CORS 配置类允许所有来源。上线后发现问题带上的 Allow-Origin: * 和 Allow-Credentials: true 不能同时存在Cookie 传不过去管理后台登录后刷新页面就掉登录。后来我改成从配置文件读取 cors.allowed-origins在配置类里动态注入没有用通配符这个问题就解决了。Configuration public class CorsConfig { Value(${cors.allowed-origins}) private String allowedOrigins; Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(allowedOrigins.split(,)) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true); } }; } }5.3 支付环节的取舍健身房项目的支付是最容易卡审核的部分。如果你做的是线上课程微信要求使用虚拟支付如果是线下健身房办卡走普通微信支付商户号 Native 支付没有太大问题。我在开发阶段没有真正接入微信支付而是做了一套“模拟支付”前端生成订单调用后端创建订单接口后端生成一个 pay_status0 的订单紧接着提供 /api/pay/mock 接口直接把订单置为已支付。演示的时候确实没问题但如果你要上线这个位置必须换成真实的微信支付下单接口。换真实支付的时候有几件事别漏回调签名验证、订单幂等处理、回调里的金额必须跟库里的订单金额比对。尤其最后一条服务端必须校验支付回调金额等于订单金额否则被刷就麻烦了。5.4 打包部署后端部署我用的是 Maven 打包成 jar放在服务器上 nohup java -jar 跑。服务器是 Linux装了 Redis 和 MySQL 8.0。因为接口是给小程序用的我对时间的处理特别小心——小程序端显示时间依赖本地时区后端统一返回 Asia/Shanghai 时区的数据数据库连接串里也要显式传 serverTimezoneAsia/Shanghai否则日期字段会差 8 个小时预约时间全都对不上。Vue 后台是纯静态站点npm run build 后把 dist 目录扔给 Nginx 托管/api 路径反向代理到 Spring Boot 服务。Nginx 配置文件里我加了一句 client_max_body_size 20m是因为后来要支持教练上传视频素材默认 1m 的上传限制直接拦截了所有大文件请求。6. 这个系统还能往哪个方向扩展如果你的需求比这个项目再多一点可以在不推翻现有架构的前提下做这些扩展第一预约和订单打通。当前设计里约课是免费的会员卡是单独购买的。如果门店要卖“单次体验课”或者“私教课程包”给 appointment 表加一个 pay_status 字段预约时校验是否已支付即可。第二私教课的连贯性管理。私教课通常是一对一、按课时包扣减的需要增加 lesson_package 表和 package_consume 表把私教预约改造成先扣课时、再约时间、结束课程后确认消耗的流程。第三入场核销。小程序首页可以放一个“到店打卡”扫描健身房前台二维码把二维码里的场地 ID 和后端预约记录匹配签到成功后自动标记课程完成。这个功能能直接把出勤率统计做起来对门店经营者来说很有实际价值。第四自动提醒。课程开始前 2 小时通过微信订阅消息提醒用户降低爽约率。微信小程序订阅消息一次订阅只能发一次所以要在用户报名时连续弹出两三次订阅授权把剩余的可发次数攒够。第五推荐算法。如果积累了足够多的预约记录可以根据会员常约的课程类型、教练偏好、到店时间做一个简单的协同过滤推荐。放在课程列表页顶部展示“猜你喜欢”对提升约课率的帮助其实很明显。这些方向里我个人最推荐先做入场核销和订阅消息提醒因为这两个功能对真实门店的运营价值最大开发成本也不高。做完这个项目我最深的感受是技术点再花哨最终都是要落到“用户把课程约上、店长把课排好”这两件事上。微信小程序负责轻Vue 后台负责重Spring Boot 居中调度这套组合在我看来非常适合健身房、瑜伽馆、羽毛球馆这类预约型线下门店。如果你也在做类似的项目建议先花时间把预约并发和后端权限设计想清楚这两块是后面所有功能的地基。实际开发中遇到更具体的问题欢迎随时交流。
返回列表