
前阵子花了两周时间把一个健身房预约小程序从零到上线完整跑通了一遍技术栈选了最稳的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、datacode为0表示成功非0表示各种业务错误码。不要用HTTP状态码表达业务错误比如预约满了前端拿到的还是200但code是10086这样前端拦截器可以根据code做统一提示而不是解析一堆乱七八糟的HTTP状态。工程级别还有一个容易被忽略的点跨域配置。小程序生产环境域名必须备案并配置到白名单但本地开发调试时请求直接打到http://localhost:8080SpringBoot必须开启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不要让用户频繁重新登录。权限控制通过拦截器实现。我写了一个AuthInterceptorpreHandle里从请求头parse token解析出userId放到ThreadLocal然后直接放行到Controller。管理员接口加一个AdminAuthInterceptor除了校验token还要校验用户角色。这两个拦截器注册到WebMvcConfigurer的addInterceptors里并且用excludePathPatterns排除登录、注册、公告查询这些公开接口。3.3 预约接口的业务实现与并发控制这是整个系统最核心的接口值得拆开讲。预约接口/booking的实现步骤如下参数校验userId、timeSlotId、reserveDate不能为空判断时间合法性reserveDate不能早于今天也不能超过系统配置的“提前X天预约”上限查时段表校验状态当前时段必须处于可预约状态pause_flag0且当前时间在预约截止时间之前原子扣减update time_slot set current_count current_count 1 where id ? and current_count max_count返回值是1才继续否则抛“该时段已约满”插入预约记录状态设为已确认同时记录预约时间提交事务。第四步为什么要用原子update而不是先select再判断因为高并发场景下两个用户同时select到的current_count可能都是9max_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版本是18Vue3.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.activeprod app.log 21 上线初期日志实时输出到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渲染折线图或柱状图就行了。我个人在实际操作中的体会是这类预约系统的核心难点不在某个单独环节而是各个模块之间的数据一致性。从时段容量、预约记录、用户状态到统计报表一条数据的变动会像涟漪一样扩散到很多张表。所以写代码前先花时间把状态机和表关系设计清楚后面再写代码就会顺畅很多。最后再分享一个小技巧开发阶段可以把测试环境的数据库和本地数据库分离每天结束时自动执行一次数据备份这样即使改表结构出错也不至于把辛苦造的数据全丢了。