前阵子花了两周时间把一个健身房预约小程序从零到上线完整跑通了一遍,技术栈选了最稳的SpringBoot + Vue + 微信小程序这套组合。做这个事的起因很接地气——小区楼下健身房还在用Excel表排号,会员想约课只能微信群接龙,高峰期完全乱套。与其抱怨不如自己动手,于是就把整套系统整理成了「源码 + 数据库 + 文档」的完整交付物,现在写篇文章把里面的设计思路和坑都盘一盘。
这篇文章适合三种人看:准备拿这类项目做毕业设计的在校生、想给线下健身房做预约系统的外包开发者、以及想用一套通用模板快速切入小程序SaaS场景的技术创业者。我尽量不写废话,把能直接抄作业的表结构、接口流程、部署步骤都摊开讲,也会结合自己踩过的坑说明每个环节为什么要这么设计。
1. 整体设计与技术选型思路
1.1 这个系统到底要解决什么问题
健身房预约看起来很简单,做深了才会发现有几个核心矛盾:高峰时段课程约满却有人放鸽子、教练排班全靠口头沟通、会员不知道当前时段还剩多少名额、管理员想统计出勤率却只有一堆纸质签字单。
我设计这个系统时,第一件事不是写代码,而是把业务角色和核心场景列清楚。系统里主要有三类角色:普通会员、前台/管理员、教练。会员关心的是“今天有没有课、还有没有位置、怎么快速约上”;教练关心的是“我的课被谁约了、课时怎么算”;管理员关心的是“每个时间段的使用率怎么样、要不要增加排课”。
基于这些场景,系统的功能边界就清晰了:会员端负责注册登录、浏览课程与教练、按日期时段提交预约、查看/取消自己的预约;管理端负责维护教练资料、排课、管理场地时段、统计预约数据。所有功能都围绕“预约”这条主链路展开,不做过度的营销功能,这是很多同类项目容易栽的坑——动不动就加积分、加拼团,结果核心预约反而做得稀烂。
1.2 为什么是 SpringBoot + Vue + 小程序这套组合
选型理由不复杂,就三条。
后端用SpringBoot是因为生态太成熟了。做项目最怕的不是写代码,而是被各种莫名其妙的配置卡住。SpringBoot的自动配置机制让你几乎不用关心Bean装配,内嵌Tomcat也让部署变成一条java -jar命令。加上MyBatis-Plus这种ORM框架自带分页插件、代码生成器,CRUD接口半天就能铺完。而且国内Java简历的普适性也高,如果是毕业设计场景,导师看到SpringBoot基本不会纠结选型问题。
前端拆成了两个部分:会员用微信小程序,管理员用Vue后台。小程序侧我选了uni-app框架,一套代码能同时编译到微信小程序和H5,开发效率比原生小程序高很多;管理端用Vue3 + Element Plus,表格、表单、弹窗这类后台组件开箱即用。如果你更熟悉Vue2,用Vue2 + Element UI也完全没问题,核心逻辑不变。
有人会问,为什么不用现在更流行的前后端不分离模板,或者干脆用低代码平台?我的回答是:健身房预约这种业务,虽然CRUD占比高,但预约冲突校验、时段状态流转、并发控制这些逻辑必须掌握在代码手里,低代码平台很难灵活表达。再加上这类项目经常要拿去答辩或者做二次开发,清晰的分层代码本身就是最大的交付价值。
1.3 功能模块拆分
整个系统分两个端,每个端再切子模块:
- 会员小程序端:微信登录、首页公告轮播、场馆介绍、教练列表、课程列表、预约下单、预约记录、取消预约、个人信息管理。
- 管理后台端:登录、仪表盘数据统计、会员管理、教练管理、课程/场地管理、预约订单管理、时段规则配置、排课管理。
模块边界必须提前定死。我见过很多人做这种系统做到一半开始纠结“教练能不能自己登录小程序看课表”,这一纠结至少多花三天。我的建议是:第一版只做管理员统一管理,教练信息由管理员录入维护,教练端权限放到二期。MVP思维在这里特别重要,核心预约链路通了,其他都是加分项。
2. 数据库设计与核心模型
2.1 核心表结构与字段设计
这个系统的表结构不算复杂,核心就八张表:用户表、教练表、课程表、场地表、预约表、时段表、公告表、管理员表。我贴一下关键表的设计思路,字段清单比代码更重要。
用户表是最容易被忽略细节的地方。除了常规的id、昵称、头像、手机号,必须预留openid字段作为微信登录的唯一标识。性别用tinyint存,0未知1男2女,不要直接存字符串。创建时间和更新时间用datetime,而且要加逻辑删除标记deleted,小程序端请求都走逻辑删除,防止误删数据后无法追溯。
课程表要注意的是课程类型和容量上限。类型字段我建议用type_code字符串而不是自增id,比如“group_class”代表团课、“private_class”代表私教课,因为字符串的可读性更强,前端做条件筛选时也不用join查类型表。capacity这个字段是预约校验的基础,下单时必须比较“已约人数 < capacity”。
预约表是全系统的重头戏。字段上一定要区分store_id这种店铺维度,哪怕当前只是单店版本,也要先预留——这不是过度设计,而是很多健身房本来就是连锁模式,后面拓展多店时改表结构会很痛苦。预约状态用status字段存:0待上课、1已完成、2已取消、3爽约。真正跑过业务的人会明白,“爽约”一定要单独用状态标出来,它和“取消”性质完全不同,直接影响后续统计。
2.2 预约时段模型:系统最关键的几张表
我把时段表单独设计了一张table,而不是让预约表里直接写死begin_time和end_time。原因是同一个日期、同一个课程,一天内会被拆成多个可预约时段,比如上午场07:00-09:00、中午场12:00-14:00。如果每个预约记录里冗余时段字符串,后续要调整时段价格、关闭某个时段,就得批量update历史数据。
时段表的字段大概是:id、course_id、site_id(场地)、date、start_time、end_time、max_count、current_count、status。注意status这里有两种含义:一种是管理员手动暂停该时段(字段pause_flag),另一种是时段被约满后的自动状态(由current_count >= max_count推导)。手动暂停和自动约满要分开控制,因为约满可能因为有人取消而释放名额,手动暂停则不会被释放逻辑干扰。
预约表则通过reserve_date + time_slot_id来关联时段,而不是冗余时间段字符串。这样一个时段下所有预约记录都可以通过time_slot_id聚合统计。表里需要有唯一索引(user_id, time_slot_id, status),防止同一个用户对同一个时段重复提交预约,这是并发控制的第一道防线。
2.3 状态流转与数据一致性的设计考量
预约状态流转是这个系统最需要想清楚的地方。我画了一版状态机:初始状态是已提交(记为PENDING),支付流程走完变为已确认(CONFIRMED),上课时间未到且用户主动取消变为已取消(CANCELLED),管理员标记完成变为已完成(FINISHED),到上课时间用户未签到且未取消则变为爽约(NO_SHOW)。
为什么要单独设计已提交和已确认两个状态?因为我见过很多简化方案直接用待支付/已支付。但健身房会员很多时候是月度卡、年卡用户,根本不涉及单次支付。我的做法是:状态字段统一叫status,再单独加一个pay_status用于兼容支付场景。这样即使完全不接支付,也能正常走预约流程;接了微信支付后,pay_status自然变成已支付,不影响主状态。
数据一致性方面还要考虑一个实际场景:用户发起取消时,应该同时把时段表的current_count减一。这个操作必须放在同一个事务里,用@Transactional包住,否则就会出现“预约记录已取消,但时段已约人数不减”的数据错乱。我在项目里遇到过这个bug,排查方式是通过一个定时任务对账,后来才发现是事务边界没控制好。
另一个并发问题在3.3节详细讲,但数据库层面先埋一个伏笔:预约成功SQL不能用三段式查询再update,要用update events set current_count = current_count + 1 where id = ? and current_count < max_count这样的原子操作判断返回值,affected rows为1才代表抢到了名额。
3. 后端 SpringBoot 核心实现
3.1 代码分层与工程结构
后端工程结构我建议这么分包:controller、service、mapper、entity、common、config。common里放统一返回结果类Result、全局异常处理器GlobalExceptionHandler、工具类JwtUtil。controller层的类只负责收参数、调service、返回Result,业务逻辑全部下沉到service实现类,这样单元测试也好写,后面接别的端也不至于撕扯接口逻辑。
接口路径规约尽量从第一天就立好。我习惯用/api/v1/模块名/动作的格式,比如/api/v1/reservation/create、/api/v1/reservation/cancel。既然是面向小程序的API,统一返回结构尤其重要。Result类的结构大概是code、message、data,code为0表示成功,非0表示各种业务错误码。不要用HTTP状态码表达业务错误,比如预约满了前端拿到的还是200,但code是10086,这样前端拦截器可以根据code做统一提示,而不是解析一堆乱七八糟的HTTP状态。
工程级别还有一个容易被忽略的点:跨域配置。小程序生产环境域名必须备案并配置到白名单,但本地开发调试时请求直接打到http://localhost:8080,SpringBoot必须开启CORS。写一个WebMvcConfigurer实现类,addCorsMappings里允许所有来源、所有方法,注意allowedHeaders不要漏掉Authorization这个header,否则前端带token请求会报跨域。
3.2 登录鉴权链路
小程序登录和传统网页登录完全不同,核心是wx.login拿到的code换openid。我的实现流程串一遍:
- 小程序端调用wx.login获取临时code;
- 把这个code POST到后端/oauth/login接口;
- 后端调用微信的code2Session接口,用appid、secret、code换取openid和session_key;
- 根据openid查用户表,没查到就自动注册一个新用户;
- 查到就更新最后登录时间,然后用jwt工具生成自定义token返回给前端;
- 小程序后续所有请求都在请求头Authorization里带上这个token。
这里有个安全细节:后端和微信服务端通讯时必须用restTemplate或者HttpClient,绝对不能把appsecret下发给前端,这是很多新手会犯的致命错误。另一个细节是token有效期,小程序场景建议设置7天有效期,过期后前端要静默调用刷新接口重新换token,不要让用户频繁重新登录。
权限控制通过拦截器实现。我写了一个AuthInterceptor,preHandle里从请求头parse token,解析出userId放到ThreadLocal,然后直接放行到Controller。管理员接口加一个AdminAuthInterceptor,除了校验token还要校验用户角色。这两个拦截器注册到WebMvcConfigurer的addInterceptors里,并且用excludePathPatterns排除登录、注册、公告查询这些公开接口。
3.3 预约接口的业务实现与并发控制
这是整个系统最核心的接口,值得拆开讲。预约接口/booking的实现步骤如下:
- 参数校验:userId、timeSlotId、reserveDate不能为空;
- 判断时间合法性:reserveDate不能早于今天,也不能超过系统配置的“提前X天预约”上限;
- 查时段表,校验状态:当前时段必须处于可预约状态(pause_flag=0),且当前时间在预约截止时间之前;
- 原子扣减:update time_slot set current_count = current_count + 1 where id = ? and current_count < max_count,返回值是1才继续,否则抛“该时段已约满”;
- 插入预约记录,状态设为已确认,同时记录预约时间;
- 提交事务。
第四步为什么要用原子update而不是先select再判断?因为高并发场景下,两个用户同时select到的current_count可能都是9,max_count是10,两个请求都判断“未满”然后都执行insert,最后会出现超卖。原子update则把判断和扣减合并成一条SQL,数据库的行锁和条件判断天然保证了同一时刻只有一个事务能成功扣减。
如果是秒杀级并发,100%都只访问同一行时段记录,行锁会排队造成性能瓶颈,可以再用Redis做一层预检,或者用Redisson的分布式锁把整个预约服务串行化。但对健身房这种一天最多几百单的体量,数据库原子更新完全够用,不要为了炫技引入多余组件。
我贴一下核心service代码片段,方便参考:
@Transactional(rollbackFor = Exception.class) public Long createReservation(ReservationCreateDTO dto) { // 1. 参数与业务校验 TimeSlot slot = timeSlotMapper.selectById(dto.getTimeSlotId()); if (slot == null || slot.getPauseFlag() == 1) { throw new BusinessException("该时段不可预约"); } if (dto.getReserveDate().isBefore(LocalDate.now())) { throw new BusinessException("预约日期不能早于今天"); } // 2. 原子扣减时段名额 int affected = timeSlotMapper.decreaseStock( dto.getTimeSlotId(), slot.getMaxCount()); if (affected != 1) { throw new BusinessException("该时段名额已满"); } // 3. 插入预约记录 Reservation reservation = new Reservation(); reservation.setUserId(dto.getUserId()); reservation.setTimeSlotId(dto.getTimeSlotId()); reservation.setStatus(ReservationStatus.CONFIRMED.getCode()); reservationMapper.insert(reservation); return reservation.getId(); }3.4 定时任务与过期订单处理
预约系统必须处理三个时间维度的逻辑:超过截止时间未上课自动标记爽约、前一天未确认的待支付订单自动关闭、用户取消后名额释放。
我用了Spring自带的@Scheduled注解实现,没有引入xxl-job这类分布式调度框架,原因是单机部署场景用不上那么重的方案。配置一个taskScheduler线程池,三个定时任务分别跑:每5分钟扫描一次已经过了上课时间且状态仍为已确认未完成的预约,批量改成爽约;每10分钟扫描一次待支付超过30分钟的订单改成已关闭;每小时做一次对账,统计时段current_count与预约记录的差异。
这里有个经验值得分享:定时任务别在方法内部加太多业务判断,最好先查出满足条件的主键列表,再循环过一遍service层的统一处理方法。比如释放名额这个动作,必须同时更新预约状态和时段current_count,直接在定时任务里写一遍容易漏掉事务,调用service方法才能复用原有逻辑。
4. Vue 小程序端开发实践
4.1 小程序框架选择与工程搭建
我个人用的是uni-app,理由前面提过:一套代码多端编译。uni-app基于Vue语法,会Vue的人基本能无缝上手。搭建流程很简单:HBuilderX新建uni-app项目,选默认模板,然后通过manifest.json配置微信小程序appid。
框架层面我要说一个让人又爱又恨的点:uni-app的生态组件质量参差不齐。像日历组件、滚动选择器这些,网上能找到很多第三方插件,但很多在真机上的表现和模拟器完全不同。我自己踩过的坑是日期选择组件在iOS上冒出了左右滑动冲突,后来干脆舍掉组件库,直接用原生picker做日期选择。在做这类中小型项目时,原生组件往往比花哨的第三方库更可靠。
管理后台用Vue3搭建时要注意npm版本问题。我碰到过一个很典型的环境坑:本地node版本是18,Vue3.4的vite项目编译时提示rollup版本不兼容,切到node16.20才稳定。如果你照着教程搭环境时发现create-vite报错,先检查node版本,别急着换模板。
4.2 页面结构与核心交互
小程序端我划分了四个tab页:首页、课程、预约、我的。
首页主要渲染公告轮播和运营信息,数据来自公告接口,轮播图用uni自带swiper组件。课程页是核心流量入口,列表展示课程名称、教练头像、时段剩余名额/总名额。名额信息我建议后端直接返回remaining字段,不要前端用total减already这样算,因为这两个数字是查询时点的快照,前端计算可能出现不一致。
预约页是整个端最复杂的页面。用户先选日期(用picker),再选时段,然后选课程或教练,最后点击确认预约。这个页面的状态特别多,我的经验是提交按钮的disabled状态要覆盖全面:没选日期时禁用、没选时段时禁用、时段已满时不仅禁用而且要灰掉显示“已约满”、已经预约过该时段时禁用并提示“您已预约”。
管理后台的页面相对规整,Dashboard放简单统计卡片(今日预约数、本月营业额、教练排课数),表格页用Element Plus的el-table组件,联动的教练筛选用el-select。Excel导出功能是管理员的刚需,我用的是前端导出CSV方案,纯前端实现,不依赖后端poi,轻量可靠。
4.3 请求封装与登录态维护
小程序端请求必须做统一封装,不然接口多了之后拦截器逻辑散落一地、维护成本极高。我在utils/request.js里封装了一个request函数,基于uni.request,统一做了三件事:自动携带token、统一处理code非0的错误提示、401时自动清理登录态并跳转登录页。
核心代码逻辑大概是:
const request = (options) => { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Authorization': uni.getStorageSync('token') || '' }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data); } else if (res.data.code === 401) { uni.removeStorageSync('token'); uni.navigateTo({ url: '/pages/login/login' }); reject(res.data); } else { uni.showToast({ title: res.data.message, icon: 'none' }); reject(res.data); } }, fail: (err) => reject(err) }); }); };登录页的交互流程是:进入页面先调uni.login拿code,然后请求后端/oauth/login,拿到token后存储到uni.setStorageSync。如果后端判断是首次登录,会自动注册并返回新用户信息,前端不需要额外做注册表单。
4.4 预约时段选择的组件实现与适配
时段选择我用的是自定义标签列表而不是picker。原因很简单:时段列表通常只有几个选项,用标签的方式展示更直观,用户一目了然看到可选和已满状态。实现上就是一个scroll-view包裹多个view标签,每个标签的class动态绑定当前选中/不可用状态。
时段的展示还有一个细节:时段名称要友好。不要直接显示07:00-08:00这种干巴巴的时间,而是拼上标签,比如“早课场 07:00-08:00 已约12/15”。文案一旦带上已约人数和容量,用户决策成本大幅降低,预约成功率也会更高。
真机适配方面,我建议在iPhone SE这类小屏机型上做一次全流程测试,重点看时段标签是否换行错位、日期选择组件是否被键盘遮挡。这类UI问题在开发者工具里很难暴露,只有真机调试才能看出来。
5. 部署上线与常见问题排查
5.1 从本地到服务器:一次完整部署流程
整个部署链路分四段:后端jar包、前端静态资源、数据库初始化、小程序配置。
后端部署最简单:把application-prod.yml里的数据库地址、Redis地址、微信配置改成生产环境值,然后mvn clean package打jar包,服务器上用systemd或者宝塔面板做进程守护。我实际用的命令是nohup java -jar gym-api.jar --spring.profiles.active=prod > app.log 2>&1 &,上线初期日志实时输出到app.log,排查问题直接tail -f。
管理后台部署就是一堆静态文件。执行npm run build,把dist目录的文件扔到Nginx的html目录下,Nginx配置里注意两个点:监听80端口并将/api/路径反向代理到后端8080端口,这样前端请求不需要关心后端端口;gzip开启以减小首屏加载体积。
数据库初始化我会提供一个sql脚本,包含建表语句和测试数据。写文档时一定要把这个脚本单独说明,因为很多人拿到项目后第一件事就是导入数据库,脚本写得不清楚极易劝退。
小程序上线需要到微信公众平台操作:使用测试号开发没问题,正式上线前需要注册小程序账号、完成微信认证,然后在开发管理-服务器域名里配置request合法域名,域名必须是HTTPS,且ICP备案是前提。开发工具里要勾选“不校验合法域名”才能在本地调试,上线前必须取消勾选,否则会出现真机请求全部失败但开发者工具正常的情况。
5.2 常见问题排查实录
我把自己实际遇到过并且花时间最多的问题整理成一张速查表:
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 小程序真机请求全部报错 | 未配置合法域名或未备案 | 后台配置request合法域名,确保域名已备案且SSL证书有效 |
| 后端接口跨域报错 | 后端未开CORS或Nginx未处理OPTIONS预检 | 后端加CORS配置,或用Nginx统一拦截OPTIONS并返回204 |
| 预约人数超卖 | 使用select再update的非原子操作 | 改为原子update current_count并校验返回值 |
| 用户取消后名额不变 | 事务边界未包住“更新状态+释放名额”两个动作 | 确认取消方法上有@Transactional注解 |
| 登录后获取不到用户信息 | token解析失败或ThreadLocal未清除 | 检查拦截器是否放行登录接口,token过期后重新获取 |
| 管理端打包后页面空白 | vue-router使用了history模式但Nginx未配置try_files | 配置fallback到index.html |
| 数据库导入报错字符集问题 | sql文件编码不是UTF-8 | 导入前设置SET NAMES utf8mb4,用utf8mb4字符集建库 |
这里挑两个说细一点。
Nginx部署单页应用空白的问题很隐蔽。vue-router默认hash模式不会触发服务端路由问题,但如果用了history模式,用户直接访问/booking路径时Nginx会尝试找booking文件,找不到就404,此时前端路由无法接管。解决方法是location /块里加try_files $uri $uri/ /index.html,把请求全部引导到index.html,由前端路由决定渲染什么页面。
另一个容易踩的是ThreadLocal内存泄漏。我在拦截器里把userId存到ThreadLocal后,如果afterCompletion方法忘记remove,高并发下线程池复用导致userId穿串——一个用户可能查到别人的预约记录。这是极难排查的隐性bug,排查思路是在登录接口打日志,对比token和查询接口id是否一致。
5.3 一些实用小技巧与扩展方向
整个系统跑通后,如果还有余力,我建议往三个方向扩展,难度递增:
- 推送通知:通过订阅消息实现预约成功提醒、上课前30分钟提醒,大幅提升产品完成度;
- 支付闭环:接入微信支付,把课程价格和预约状态打通,变成真正可以商业化的小程序;
- 数据看板:把预约数据按小时、按教练、按课程聚合,用ECharts做成可视化图表页面,管理者一眼看出哪些时段是热门时段、哪些教练出勤率低。
数据看板这个方向我特别推荐毕设场景选择,因为技术难度不大,但视觉呈现效果好,答辩时能快速让导师看到系统的数据价值。实现时就直接在管理后台新增一个Dashboard页面,后端聚合接口返回按日期的预约趋势数据,前端用ECharts渲染折线图或柱状图就行了。
我个人在实际操作中的体会是,这类预约系统的核心难点不在某个单独环节,而是各个模块之间的数据一致性。从时段容量、预约记录、用户状态到统计报表,一条数据的变动会像涟漪一样扩散到很多张表。所以写代码前先花时间把状态机和表关系设计清楚,后面再写代码就会顺畅很多。最后再分享一个小技巧:开发阶段可以把测试环境的数据库和本地数据库分离,每天结束时自动执行一次数据备份,这样即使改表结构出错也不至于把辛苦造的数据全丢了。