健身预约小程序开发实战:Spring Boot后端与并发预约实现
2026/9/17 2:56:44 网站建设 项目流程

说实话,健身类预约小程序这两年找我咨询的人特别多。有的是健身房老板想做个会员约课工具,有的是刚转行的前端朋友想拿一个完整项目练手,还有人是手上拿到一份源码但不知道怎么跑起来。这个标题里的“健身预约小程序(含小程序源码、后端源码)”我太熟悉了,它几乎把所有热门要素都占了:微信小程序、Spring Boot后端、前后端分离、预约业务、并发控制,哪怕只把一个预约流程吃透,放到简历上都能撑起一大段项目经验。

先说清楚这个东西能解决什么问题。用户端打开小程序,看到教练排期、课程列表,选中时间段一键预约,到店扫码或报手机号核销;管理端维护教练、排课、查看预约记录;后端负责用户登录鉴权、预约冲突处理、订单状态流转。听起来不复杂,但真做起来,坑比想象中多。这篇文章我就用自己的开发视角,从项目拆解、表结构设计、核心流程实现、前后端联调,到上线备案和常见问题排查,完整捋一遍,适合有基础的前端开发者、想转后端的初级工程师,以及准备拿小程序项目练手的同学参考。

1. 项目整体设计:健身预约小程序到底在做什么

1.1 先拆业务,再谈技术选型

拿到一个预约类小程序,第一步不是写代码,而是把业务角色和状态流转盘清楚。这个项目里至少有三种角色:普通用户、教练、管理员。用户关心的是“我能不能约到想上的课”;教练关心的是“我的排期谁约了、有没有冲突”;管理员关心的是“每天有哪些预约、有没有人放鸽子”。

预约业务的核心是“资源”和“时段”。资源是某位教练在某一天、某一个时间段的可预约名额。用户提交预约时,系统要判断这个时段是否还有名额,一旦约上就锁定名额,其他人不能再约。很多新手把预约做成简单的“插入一条记录”,结果就是两个用户同时约同一个时段,数据库里出现两条记录,教练一天被约了两遍。这个问题必须从表设计和代码逻辑两个层面同时解决。

技术选型上,这个小程序的常见组合是原生微信小程序加Spring Boot后端加MySQL数据库。原生小程序的好处是微信API调用直接,没有额外框架的学习成本;Spring Boot是目前后端源码里出现频率最高的框架,生态成熟,社区资料多,遇到问题搜得到;MySQL负责存业务数据。前端同学如果只写过页面,用这个项目练后端是个特别好的切入点,因为Spring Boot的Controller-Service-Mapper分层很直观,配合MyBatis Plus,几乎可以照着RuoYi这类开源框架的思路去理解。

为什么不用纯云开发或者云函数?省事是真省事,但小程序云开发的数据库权限模型、并发处理能力、部署方式都跟传统前后端分离项目差别很大。如果你以后想转企业级开发,Spring Boot这套从登录鉴权到数据库事务的完整链路是绕不开的,云开发反而学不到这些东西。这套带后端源码的项目,价值恰恰在于能让你把“前端怎么调接口、后端怎么吐数据、数据怎么落库”整条链路跑通。

1.2 项目目录结构:一份能直接上手的源码长什么样

拿到源码之后,第一件事是看目录。规范的目录结构用不着看文档,就能猜出个七八成。我按常见结构还原一下:

fit-reservation ├── miniprogram/ # 微信小程序前端 │ ├── pages/ │ │ ├── index/ # 首页:课程推荐、今日排期 │ │ ├── booking/ # 预约页:选教练、选时段 │ │ ├── order/ # 我的预约:待上课、已完成、已取消 │ │ └── mine/ # 个人中心:登录信息、头像昵称 │ ├── utils/ │ │ ├── request.js # 请求封装,统一注入token │ │ └── auth.js # wx.login登录逻辑 │ └── app.js ├── backend/ # 后端源码,Spring Boot工程 │ ├── src/main/java/com/xxx/ │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务层 │ │ ├── mapper/ # 数据访问层 │ │ ├── entity/ # 数据库实体 │ │ └── common/ # 统一返回、异常处理、JWT工具 │ ├── src/main/resources/ │ │ └── application.yml # 数据源、端口等配置 │ └── pom.xml └── sql/ └── init.sql # 建库建表脚本,含初始数据

这个结构最大的好处是:前后端彻底分离,你单独看前端或者单独看后端都能跑通,联调时接口契约对了就行。小程序端没有使用uni-app之类的跨端框架,好处是去掉了一层编译转换,原生组件的生命周期、路由、API调用都直接可见,排查问题更直观。后端也不是那种把代码全堆在一个Controller里的“教学代码”,而是按标准分层写的,对理解工程化项目有实际帮助。

2. 从需求到表结构:后端数据模型的落地

2.1 五张核心表,理清预约业务的数据流转

预约类业务的数据模型是有套路可循的。我用过好几套方案,最后沉淀下来这套核心表结构,简单、没有冗余、不容易出并发问题。

用户表。存储微信用户的基本信息,openid是微信小程序用户唯一标识,一定不能只存昵称和头像,同一微信号换昵称是常有的事。还需要一个status字段,做拉黑或禁用处理时用得上。

教练表。这里有一个小陷阱:教练也应该是用户,或者跟用户表关联,否则后续做“教练登录查看自己的排期”会很痛苦。简化做法是在教练表里直接放一个user_id字段,关联用户表,这样教练登录后也能进小程序管理端。

排期表。这是整个项目的核心语义表,它描述的是“某位教练在某一天有哪几个可预约时段”。我习惯把每天拆成固定时段,比如09:00-10:00、10:00-11:00这种,按半小时或一小时分片。时段不要用datetime拎出来单独存,就用一个日期字段加一个开始时间字段,再加一个结束时间字段,通过查询条件去判断重叠。

预约表。用户点击预约后产生的一条记录。这张表除了user_id、schedule_id、教练信息之外,一定要有一个状态字段,因为预约不是一锤子买卖,它有预约成功、已取消、已完成、已过期这么几个状态,状态流转必须可控。

课程表或服务类型表。不同教练可能带不同课程,比如私教课、搏击课、拉伸课,课程表跟教练表之间可以是多对多,也可以在排期表里直接加一个course_type字段。小项目建议后者,简单直接,不用为了“灵活”把关系搞得过于复杂。

对应的建表SQL大概是这个思路:

CREATE TABLE `t_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `openid` varchar(64) NOT NULL COMMENT '微信openid', `nickname` varchar(64) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `status` tinyint(4) DEFAULT 1 COMMENT '1正常 0禁用', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_trainer` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) DEFAULT NULL COMMENT '关联用户表', `name` varchar(32) NOT NULL, `avatar` varchar(255) DEFAULT NULL, `intro` varchar(500) DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_schedule` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `trainer_id` bigint(20) NOT NULL, `course_name` varchar(64) NOT NULL, `schedule_date` date NOT NULL, `start_time` varchar(10) NOT NULL COMMENT 'HH:mm', `end_time` varchar(10) NOT NULL COMMENT 'HH:mm', `total_slots` int(11) DEFAULT 1 COMMENT '可预约名额', `booked_slots` int(11) DEFAULT 0, `status` tinyint(4) DEFAULT 1 COMMENT '1可约 0已截止', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_appointment` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `schedule_id` bigint(20) NOT NULL, `user_id` bigint(20) NOT NULL, `trainer_id` bigint(20) NOT NULL, `appointment_date` date NOT NULL, `start_time` varchar(10) NOT NULL, `status` tinyint(4) DEFAULT 1 COMMENT '1已预约 2已取消 3已完成 4已过期', `cancel_reason` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_schedule_id` (`schedule_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2.2 为什么排期表里要有“已约人数”字段

很多人设计排期表时只放一个“可约总名额”,预约表里插入一条记录就算完成。这个方案在并发量低的时候没问题,但一旦两个人同时提交,就会出现超卖。解决思路是:在排期表里增加booked_slots字段,预约时通过一条update语句原子性地把已约人数加一,同时判断加一之后有没有超过总名额。

这里补充一个很多后端新手没注意到的经验:booked_slots是冗余字段,但它是有意为之的。它省去了每次预约都去count一次预约表的性能开销,更重要的是它能把“名额剩余判断”变成一个原子操作,这是后面处理并发预约的基石。如果你把这个字段去掉,每次先查再插,在高并发下大概率会出事。

教练信息、课程信息这些字段也冗余进了预约表,这样做虽然违背了教科书上的“第三范式”,但实际操作中能减少很多关联查询,尤其是小程序端列表页要展示教练头像、课程名时,不用一张张去查排期表。记住一点:查询频繁、不经常变化的字段,冗余出来是提效,不是设计错误。

3. 核心功能实操:登录鉴权、预约并发、动态标题一个都不能少

3.1 微信登录与后端JWT鉴权怎么配合

小程序端拿到wx.login产生的code之后,要传给后端,后端调用微信接口换取openid和session_key,拿到openid后再查用户表。存在就返回登录态,不存在就自动注册一条用户记录,然后签发token返回给小程序。这个token我推荐用JWT,因为它无状态,后端不用把session存在内存里,重启服务用户不会掉线。

具体的登录流程,写出来大概是这么几步:

  • 小程序端wx.login()获取临时code。
  • 小程序把code通过request.js封装好的post请求发给后端。
  • 后端用code调微信的code2Session接口,拿到openid。
  • 拿着openid查t_user表,查不到就insert一条新用户。
  • 用openid和userId生成JWT,返回给前端。
  • 小程序把JWT存到storage里,之后所有请求header里带Authorization: Bearer token。
  • 后端加一个拦截器,校验JWT,校验通过才放行。

这里要注意,JWT的密钥一定不要写死在代码里,我习惯放到application.yml里,而且用足够长的随机字符串。token过期时间一般设置成7天,小程序用户没有频繁输入密码的习惯,太短了体验差,太长了不安全。

3.2 预约流程:一条SQL解决并发冲突问题

预约接口的核心逻辑并不复杂,关键就一条:用update语句把“判断名额”和“扣减名额”合成一步。

int updated = scheduleMapper.reduceBookedSlots(scheduleId); if (updated == 0) { // 预约失败:已约满或排期已截止 }

对应的SQL是:

UPDATE t_schedule SET booked_slots = booked_slots + 1 WHERE id = #{scheduleId} AND booked_slots < total_slots AND status = 1

这条update执行后,如果影响行数是1,说明名额扣减成功,后端再插入一条预约记录,整个流程放在同一个事务里。如果影响行数是0,说明排队期已经约满或者排期被设成了截止状态,直接提示用户“该时段已被约满”。

为什么这条update是线程安全的?MySQL的update语句本身会加行锁,两个并发请求同时到达时,后一个会等前一个提交后再执行,这时booked_slots已经被更新过了,where条件里的booked_slots < total_slots自然就不满足了,影响行数就变成0。这个方案我用过很多次,在没有引入Redis的情况下,单机部署完全够用。

补充一个细节:插入预约记录时,一并在t_appointment表里写schedule_id、trainer_id、appointment_date、start_time这些冗余字段,一方面是为了前端列表展示方便,另一方面也保留了后续做“用户预约历史”的查询能力。取消预约的逻辑是对称的,先把预约记录状态改成已取消,再对排期表执行update t_schedule set booked_slots = booked_slots - 1,同样需要事务。

3.3 小程序动态设置页面标题

这个需求在项目里很常见,比如预约成功后,预约成功页的导航栏标题要动态显示“预约成功”,或者进入不同教练的详情页时标题显示教练名字。原生小程序提供了现成的方法,在页面的onLoad或者onShow里调用:

wx.setNavigationBarTitle({ title: '预约成功' });

这个方法只对当前页面生效,不会影响其他页面。如果希望在页面顶部navigationStyle设为custom的时候做自定义导航栏,那就需要自己画一个固定定位的标题栏,用状态栏高度加导航栏高度来计算top值,这套逻辑在真机上调试时会有差异,安卓和iOS的状态栏高度不一样,建议在app.js里通过wx.getSystemInfoSync()读取状态栏高度,写进globalData。

3.4 前端请求封装与token失效处理

小程序端所有请求我都会走一个统一的request.js封装。这个封装主要干三件事:把baseURL统一管理、自动在header里带上token、对接口返回的错误码做统一处理。特别是token过期,不能只在每个页面的回调里写一遍“请重新登录”,而是要在封装层统一判断,遇到401或特定的业务码时,清掉storage里的token并跳转回登录页。

function request(url, method, data) { return new Promise((resolve, reject) => { wx.request({ url: baseUrl + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + wx.getStorageSync('token') }, success(res) { if (res.data.code === 401) { wx.removeStorageSync('token'); wx.navigateTo({ url: '/pages/login/login' }); return; } resolve(res.data); }, fail(err) { reject(err); } }); }); }

注意一点:小程序端不要用后端返回的完整消息去覆盖页面上的UI文案,后端提示语设计成“预约名额不足”之后,前端最好再配合控制wx.showToast的图标类型,给用户一个明确的视觉反馈。

4. 前后端联调与上线避坑实录

4.1 开发环境跨域问题其实可以绕开

前后端分离开发时,跨域是绕不开的话题。不过很多小程序开发者会忽略一个问题:小程序里的wx.request是不受浏览器同源策略限制的,它不存在你在Web开发时遇到的XMLHttpRequest跨域问题。真正需要处理跨域的场景是,你在浏览器里调试管理后台,或者用Swagger测试接口时遇到CORS。

如果你用的是Spring Boot后端,最简单的跨域配置是加一个CorsFilter。我习惯直接写一个配置类:

@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }

这里有个容易踩的坑:如果用了allowCredentials(true),allowedOrigins就不能用*,要么用allowedOriginPattern("*"),要么把前端域名写死。很多人配完还是报跨域,大概率就是死磕了这两个参数的组合。

4.2 小程序上线:备案备注、合法域名一个都不能漏

从2023年之后,微信小程序上线必须完成ICP备案,这是卡了很多人的一步。备案时候有一个“小程序备案备注信息怎么填”的问题,这里的备注要写清楚小程序的实际用途,不能空着,也不能只写“小程序”。健身预约类小程序建议这样写:“用于健身房课程查询、教练预约、会员预约记录管理等健身服务功能。”简短、明确、有业务指向。如果你涉及教练付费预约,还要留意支付类目相关资质,个人主体是做不了这类交易的。

上线前还要在微信公众平台配置服务器域名,request合法域名一定要是HTTPS,而且不能带端口。开发时用http://127.0.0.1:8080能调通,上线后忘改baseURL,或者域名没有备案、没有配置白名单,都会导致所有请求直接失败。这几个问题在真机调试时最有迷惑性,因为开发者工具里“不校验合法域名”这个开关默认是开着的,很多人开发工具里跑得通,一到真机就黑屏,十有八九是域名问题。

4.3 常见问题速查表

问题现象可能原因排查思路
真机请求全部失败request合法域名未配置或域名未备案登录微信公众平台,确认域名已备案且已配置到request合法域名
登录后接口返回401token未保存或已过期检查wx.setStorageSync时机,确认请求拦截器是否加了Authorization头
预约成功但排期没扣减事务没生效或SQL条件不对确认@Transactional生效,检查mapper的update语句是否返回影响行数
同一时段被重复预约没有用原子扣减逻辑改成update t_schedule set booked_slots = booked_slots + 1 where booked_slots < total_slots
头像昵称显示不出来微信头像昵称填写能力调整新版小程序要用open-type="chooseAvatar"和昵称填写组件,不能直接getUserInfo
页面标题不更新调用了wx.setNavigationBarTitle但时机不对放到onReady之后调用,或在onShow里调用并判断当前页面栈

这里再提一个容易忽略的细节:小程序用户头像昵称获取规则改了很多次,现在的推荐做法是引导用户主动填写,而不是在onLoad里直接拿。健身预约场景里可以把头像昵称放在个人中心里让用户自己编辑,不要拦在登录流程里,否则大量用户会卡在第一步。

4.4 后端部署踩坑记录

Spring Boot后端部署相对简单,打jar包扔到服务器上java -jar启动就行,但有几个坑是多数人都会遇到的。第一个是端口和防火墙,8080端口没在安全组或防火墙规则里放行,外部永远访问不到。第二个是数据库连接配置,线上环境一定要用独立的数据库账号,权限最小化,密码不要设成root这种弱口令。第三个是日志,没人喜欢凌晨被人叫起来看报错,logback配置里记得加上按照日期滚动和大小滚动,日志保留7天就够。

还有一个和部署相关的细节:小程序请求的HTTPS证书。很多人图省事用IP或者自签名证书,但微信小程序只认合法CA签发的证书,且必须绑定域名,不能用IP。最省事的方案是用Nginx做反向代理,证书放在Nginx层,后端服务不用处理SSL,只监听本地8080端口就够了,小程序请求打到Nginx的443端口。

5. 源码里隐藏的学习点:从“能跑”到“会改”

5.1 前端开发者学习后端Java的几个切入点

很多前端朋友拿到这套源码,打开Spring Boot工程一脸懵,不知道从哪里看起。我的建议是不要按package的字母顺序去看代码,而是顺着一次请求的路径走一遍。比如用户打开小程序首页,请求排期列表,这个请求先进Controller,Controller调Service,Service调Mapper,Mapper对着实体类操作数据库,最后把结果一层层返回给前端。你把首页这个接口从Controller到SQL完整跟一遍,后端分层的逻辑就通了。

看完接口,再看登录。登录涉及小程序code、HTTP请求、数据库查表、JWT生成,它把网络、数据、缓存、安全串在一起,是整个后端流程最浓缩的一段代码。能独立把登录讲清楚,说明你对后端已经有了基本的掌控感,面试聊项目也更有底气。

5.2 预约业务还能怎么扩展

这个项目做完以后,如果你想继续深挖,有几个很自然的扩展方向。排期模块可以引入循环规则,比如某个教练每周一、周三、周五有课,做一个周规则排期,能省掉管理员大量手工操作。预约成功后增加微信订阅消息通知,上课前一天提醒用户,能有效降低放鸽子率。再进一步,可以给排期表增加教练个人每日限额,比如一天最多上6节课,防止教练被连续排满了导致疲劳。

付费预约也是常见需求,但要把微信支付集成进来就会牵扯到商户号、退款、支付回调,业务复杂度上一个台阶,建议先把免费预约跑通再考虑。我在实际项目里见过很多次“免费预约很稳,一接支付就各种问题”的案例,支付回调的幂等性和对账逻辑,是另一篇长文的容量。

5.3 代码里的两个“隐藏细节”值得反复读

一份好的源码,细节都在不经意的地方。比如排期列表查询接口里,大概率会有对时间的格式化处理,把datetime字段按YYYY-MM-DD HH:mm格式返回给前端,别小看这一步,前端直接拿到数据库原始时间字段做展示,真机上会出现时区、格式化不一致的问题。再比如预约成功后会做一次数据库行锁提交,这个代码写得好不好,直接影响你压测时能不能顶住几百个用户同时约课。

我个人很喜欢看这套项目里对统一返回体的封装。前后端分离项目最忌讳每个接口返回结构都不一样,前端处理起来要写一堆if else。规范的项目会在common包下定义一个Result类,包含code、message、data三个字段,成功失败一个结构,前端解析统一处理。写后端接口时,返回体设计得清楚,联调效率至少提升一半。

最后再分享一个小经验:程序员拿到一份源码,最容易犯的错误是迫不及待打开IDE去跑。我更建议先花半小时看README、看SQL脚本、看目录结构,把项目跑起来之后,再找一个最小功能点去修改,改完观察效果。健身预约小程序这个项目,从预约流程切入最合适——它既有前端交互,又有后端逻辑,还牵扯并发问题,一个功能点能学到的东西,比把整个项目看一遍还多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询