健身房私教预约微信小程序开发实战:从排课混乱到系统稳定
去年秋天一个开健身工作室的朋友找到我开口第一句就是哥我这儿私教排课要炸了。当时他店里8个教练每天排课靠一张A4纸表格加微信群接龙会员约课全靠私聊教练教练上课时根本来不及回消息经常出现两个会员同一时段约到同一个教练的情况。这个项目后来被我起了个内部代号叫 weixin112也就是这次要聊的健身房私教预约微信小程序。前后折腾了两个月上线踩了不少坑。这篇文章把这些经验整理出来从业务梳理、数据模型、并发控制到微信生态对接给准备做同类项目的人一个完整参考。1. 一个真实需求私教排课乱成什么样才需要系统来管先说清楚我朋友那家店之前是怎么运作的这样才能理解为什么非做系统不可。1.1 传统排课方式的四个死穴第一排班信息不透明。教练每周排班写在纸质表格上会员根本看不到只能私聊教练问这周四下午有空吗。教练上课、带操课、吃饭经常忘记回复一问就是等会儿我看看。第二口头预约没有约束力。会员在微信里说约好明天下午3点教练也答应了但第二天教练加课或者会员临时来不了两边全靠记忆。迟到、爽约完全靠人情教练也没法统计谁经常放鸽子。第三教练离职带走会员关系。纸质排课表和教练微信里的聊天记录是店里唯一的预约数据。教练一走这些客户资源一起带走老板连哪位会员约了哪些课都查不到。第四收入确认滞后。私教课通常按课时包售卖但会员上没上课、上了几节月底财务对账全靠手工翻聊天记录漏算错算是常事。这四个问题单拎出来哪个都让人头疼凑在一起就是灾难。做系统的核心目标不是炫技而是把这四个环节的数据全部数字化、自动化。1.2 为什么选微信小程序而不是App或H5我朋友一开始问能不能直接做个App我跟他算了一笔账App要下载、要注册、要更新健身房里绝大多数会员不会为了约课专门装一个App。而微信小程序天然在微信生态里会员扫一下桌台上的小程序码就能用不占手机内存用完即走下次在微信里一搜就找回来。对于低频使用的场景小程序是唯一合理的选择。和公众号H5相比小程序在交互体验和原生能力上更接近App订阅消息、微信支付、手机号授权这些能力都是H5没法直接用的。更重要的是微信搜索私教预约相关词时小程序有更大的曝光机会对健身房来说等于多了一个获客入口。当然小程序也有它的限制后面审核那节我会专门讲。1.3 预约系统的三种角色与业务流程动手写代码之前先把角色和业务流程捋清楚这一步偷懒后面全要还。系统里一共有三种角色会员端浏览教练信息、查看可约时段、提交预约、取消预约、购买课时包、查看我的课程。教练端维护自己的可约排期哪些时间段接课、哪些休息、查看当天和本周的课程安排、给上课会员做签到。管理端教练资料管理、课程与课时包配置、会员管理、预约数据统计。业务流程上核心链路是教练先维护排期 - 会员在可约时段里选课 - 提交预约并冻结课时 - 系统给双方发送订阅消息通知 - 上课时教练签到 - 课后生成上课记录。这个闭环里最容易出问题的是选时段这一步因为涉及到并发和冲突后面专门用一节来讲。2. 数据模型先行预约系统的地基怎么打很多人做预约系统喜欢先写界面我习惯反过来先把数据表设计好。预约系统是强数据模型驱动的应用表结构一旦设计错了后期改起来比改界面痛苦十倍。2.1 教练、课程、会员三张基础表的字段设计先看教练表trainer这是整个系统的核心资源表CREATE TABLE trainer ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, avatar_url VARCHAR(255), title VARCHAR(50) COMMENT 职称明星私教/康复师等, tags VARCHAR(255) COMMENT 标签减脂、增肌、产后修复, intro TEXT COMMENT 个人简介, status TINYINT DEFAULT 1 COMMENT 1在职 0离职, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这里我要强调一下 status 字段。很多初始版本会忽略这个字段等教练离职了才发现没法从列表里移除直接删数据又会导致历史预约记录对不上。所以从一开始就保留教练档案用状态位控制展示是最稳妥的做法。课程表course主要保存课程类型、时长、价格和适用人群CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, type TINYINT COMMENT 1私教课 2康复课 3体验课, duration INT DEFAULT 60 COMMENT 时长单位分钟, price DECIMAL(10,2), is_package TINYINT DEFAULT 0 COMMENT 是否按课时包售卖, status TINYINT DEFAULT 1 );会员表member除了基础信息外最重要的是和微信身份关联的字段。一般用 openid 做唯一标识如果以后想打通公众号和小程序的数据再关联一个 unionid 字段。手机号单独存一列但要注意手机号属于敏感信息建议加密存储。提示教练表的职称和标签字段建议做成独立的字典表而不是直接写死字符串。因为健身房的营销玩法变化很快今天是明星私教明天可能就换成冠军导师做成字典方便运营随时调整。2.2 排期表与预约单如何避免数据冗余排期表和预约单是全系统的命脉。先看这两张表的常见设计CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, trainer_id INT NOT NULL, course_id INT NOT NULL, slot_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, status TINYINT DEFAULT 0 COMMENT 0可约 1已锁定 2停用, version INT DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_trainer_slot (trainer_id, slot_date, start_time) ); CREATE TABLE booking ( id INT PRIMARY KEY AUTO_INCREMENT, booking_no VARCHAR(32) NOT NULL UNIQUE COMMENT 预约单号, member_id INT NOT NULL, trainer_id INT NOT NULL, course_id INT NOT NULL, schedule_id INT NOT NULL, slot_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待上课 1已完成 2已取消 3已爽约, source TINYINT DEFAULT 1 COMMENT 预约渠道, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这里有一个设计取舍booking 表里冗余了 slot_date、start_time、end_time 三个字段。很多刚入门的人会问这些信息 schedule 表里已经有了为什么还要重复存一份答案是历史可追溯。如果某条排期因为教练请假被删除或修改预约单里如果只存 schedule_id那会员的预约记录就会跟着消失或者显示的时间变成改期后的时间。冗余存一份快照等于给每次预约留下了不可变的历史记录。这在涉及钱和课时的业务里非常重要。2.3 课时包扣减逻辑在哪一层做才安全私教业务里会员通常先买一个12节私教课包然后每上一次课扣一节。这里就涉及到课时包表CREATE TABLE package ( id INT PRIMARY KEY AUTO_INCREMENT, member_id INT NOT NULL, course_id INT NOT NULL, total_count INT NOT NULL, remaining_count INT NOT NULL, expire_date DATE, status TINYINT DEFAULT 1 COMMENT 1有效 0已用完/过期 ); CREATE TABLE package_log ( id INT PRIMARY KEY AUTO_INCREMENT, package_id INT NOT NULL, booking_id INT, change_count INT, operation_type TINYINT COMMENT 1预约冻结 2冻结返还 3上课扣减, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );课时扣减我采用的是预约时冻结、上课时扣减的策略。也就是说会员提交预约时并不直接把 remaining_count 减掉而是先冻结一个名额在 package_log 里记录 operation_type1等教练确认签到上课后再真正扣减课时。这样做的原因是会员预约后可能会取消。如果预约时立刻扣减课时取消时又要加回来中间一旦程序崩溃或者并发操作课时余额很容易对不上。冻结机制的好处是取消预约时只需把冻结记录反转不影响总余额真正上课时再做最终扣减保证财务报表和会员看到的数据一致。3. 时段锁定与并发控制防止一节课被两个人约走这是整个预约系统最核心的技术难点。你想想8个教练每个教练一天有6到8个可约时段一个热门时段可能同时有3、4个会员在手机上点预约如果处理不好就会出现超卖——同一个教练同一个时间被约给了两个人。3.1 时间粒度选60分钟还是30分钟直接影响系统复杂度先解决一个看起来简单的问题一个时间片多久我朋友店里私教课标准时长是60分钟最初版本我就按60分钟一个时段来做。上线后发现有些会员只想约30分钟的拉伸放松课有些教练会在两个60分钟时段之间安排一个30分钟的康复指导。如果排期表只有60分钟粒度这些需求全部没法覆盖。后来把粒度降到了30分钟schedule 表里的时段可以配置为30分钟、60分钟、90分钟三种。这个改动看起来不大但随之而来的问题是要处理时段重叠判断一个90分钟的课程不能被拆成两个30分钟去约一个60分钟的排期和相邻的30分钟排期也不能被同一个人分别预约。我的做法是给每门课程配置推荐时长生成排期时按课程时长生成标准时段。时段之间的最小间隔设为30分钟这样相邻时段天然互斥会员不可能把连续的两个时段分别约给不同课程。3.2 并发预约的三种处理方案与实际选择接下来是硬骨头同一时刻多个请求抢同一个时段。这里梳理一下我用过的三种可行方案方案原理优点缺点适用场景数据库唯一索引 条件更新用唯一约束兜底UPDATE 受影响行数判断是否抢到实现简单无额外依赖事务安全高峰期可能出现少量锁等待单店或中小规模首选SELECT ... FOR UPDATE 行锁事务内锁住排期记录其他请求阻塞等待逻辑直观可控性强忘记释放锁会引发死锁并发高时性能下降订票类高并发且必须严格串行的场景Redis 分布式锁用 SETNX 抢锁抢锁成功才操作性能最好支持多实例需要维护 Redis锁过期时间要仔细设计多实例部署或大规模并发预约方案一的核心代码很简洁UPDATE schedule SET status 1, version version 1 WHERE id ? AND status 0;如果这条 UPDATE 影响的函数是0说明时段已经被别人抢走后端直接返回该时段已被预约。方案二需要把 SELECT 和后续的 INSERT、UPDATE 放在同一个事务里但实际操作中经常出现事务里某个 SQL 异常导致锁迟迟不释放的问题排查起来很费劲。方案三性能最优但单店场景引入 Redis 属于杀鸡用牛刀。三种方案对比下来对于单店、几十个教练的规模方案一就已经足够省事且可靠。只有部署了多个后端实例、预约高峰远超单个 MySQL 承受量时才有必要升级到方案三。3.3 取消、改期与爽约规则比代码先定预约系统的规则设计比代码实现更考验产品能力。我们最终实施的规则是开课前2小时以上可以免费取消取消后立即释放时段并返还冻结课时。开课前2小时以内取消需联系前台人工处理系统默认不允许在线取消。未取消且未签到算爽约爽约的课时照扣并且计入会员的爽约次数。会员累计爽约3次后系统限制其只能预约未来48小时内的时段。改期的实现我踩过一次坑。最初我把改期做成先取消旧预约、再创建新预约两步操作结果有一次用户点改期时新的时段被别人抢走旧时段也已经被释放会员两头落空。后来改成先锁定新时段、再释放旧时段——也就是改期接口先做新增预约的冻结成功后在同一事务里取消旧预约。流程上看起来多了两步但用户的体验完全不一样。4. 小程序端开发把预约流程做成傻瓜式操作后端逻辑再严谨用户感知不到。小程序端要做的是把复杂的预约流程收敛成一个简单的三步操作看教练、选时间、确认预约。4.1 登录与手机号授权别让用户没看教练就先弹窗微信小程序的登录流程是前端调用 wx.login() 获取一个临时 code后端拿这个 code 调微信接口换 openid 和 session_key。现在微信把大部分接口权限都收紧了但基本的静默登录还是可以用的。一个常见的错误是小程序一启动就弹授权登录框用户还没看到任何有价值的内容就被劝退了。我们的做法是进入小程序直接允许浏览教练列表和课程介绍等用户真正点击预约按钮时才触发登录和手机号授权。手机号授权的接口是 wx.getPhoneNumber按钮上加 open-typegetPhoneNumber后端拿到 code 后调用接口换取明文手机号。注意这个能力需要小程序完成微信认证且属于获取手机号接口审核时需要有明确的使用场景说明。注意不要在 App.onLaunch 里同时调用 wx.login 和 getPhoneNumber。wx.login 是静默的没问题但 getPhoneNumber 必须由用户点击行为触发否则会被微信拦截这在真机上表现尤其明显。4.2 教练列表与可约时段的展示逻辑教练列表页顶部用 swiper 做了几个推荐位下面是教练卡片流。每个卡片展示头像、职称、标签和综合评分。点击进详情页详情页最核心的模块是可约时段组件。时段组件的交互设计花了点心思。按周展示底部一排日期选择器顶部是当天教练的时段列表。每个时段按钮三种状态灰色不可约已满或休息、绿色可约、红色是当前正在为你锁定的时段。一个重要的实现细节是时段列表的数据不能只看本地缓存每次进入页面必须重新请求。因为预约状态是实时变化的一个按钮在页面里显示了一小时可约实际上可能早就被别人约走了。我们给时段列表加了30秒的轮询刷新后来发现30秒刷新对服务器压力不小改成进入页面刷新下拉手动刷新之后服务器压力小了很多用户也感知不到差异。4.3 预约提交、确认页与订阅消息用户点击某个可约时段后进入预约确认页。确认页展示课程名、教练名、时间、上课地点、剩余课时数底部一个大大的确认预约按钮。用户点击后前端先检查剩余课时数是否足够然后调用后端创建预约接口。接口返回成功后前端弹窗提示预约成功并调用 wx.requestSubscribeMessage 申请订阅消息权限。这里要提一个大坑订阅消息的授权弹窗必须由用户主动点击触发不能在接口回调里静默调用否则会被微信拦截。所以我们的做法是把授权请求放在预约成功的确认弹窗按钮里用户点了好的系统同时申请订阅消息权限这样合规又不打扰。5. 微信支付与订阅消息预约闭环里的两个关键环节如果只是约课、不涉及付费预约系统是不完整的。私教课的核心付费模式是课时包也就是会员一次性购买12节或24节私教课后续每上一次扣一节。这部分的支付链路必须走微信支付。5.1 微信支付的接入与回调处理微信支付在小程序端的流程是后端统一下单调用微信支付API拿到支付参数小程序端 wx.requestPayment 拉起支付用户输入密码完成支付微信服务器异步回调后端接口通知支付结果。这里最容易出问题的是回调处理。微信支付回调同一个支付结果可能通知多次且通知顺序不保证。如果后端没有做幂等处理就可能出现用户支付成功了但课时包被创建了两次或者同样一笔订单发了两条到账通知。我的处理方式是回调接口先查本地订单表如果订单状态已经是已支付直接返回成功不再重复处理。创建课时包的逻辑放在同一个事务里先更新订单状态再插入课时包记录任何一个步骤失败都整体回滚。不管处理成功还是失败都返回给微信一个明确的结果码但只在确认全链路成功后才返回成功码。退款流程同样重要。会员买完课时包想退需要走微信支付的退款接口。退款时要注意课时包只要上过课退款金额就要按已消耗课时数折算这个逻辑一定要在后端算清楚不能简单退回全额。5.2 订阅消息一次性订阅与长期订阅的取舍订阅消息是小程序替代短信通知的关键能力也是私教预约服务体验的重要组成部分。微信把订阅消息分成两类一次性订阅和长期订阅。一次性订阅的意思是用户每点一次授权你只能给他发一条消息。如果会员这次约了课要发预约成功和开课提醒两条消息就需要用户在一个弹窗里点两次允许。很多用户嫌麻烦不愿意点。实际操作中我们做了几个优化在预约成功页把订阅请求合并成允许预约成功通知和允许开课提醒两个勾选项用明显的说明告诉用户这两条消息各有什么用。对已经订阅过的用户如果剩余次数大于0就不重复弹窗。开课提醒统一在开课前1小时发送避免发太早用户忘记、发太晚来不及。订阅消息的模板需要在小程序后台申请审核比较严格尤其是开课提醒这种需要发给用户的模板必须写清楚使用场景。这里建议提前一周申请不然会卡住上线进度。5.3 课程包支付与iOS虚拟支付的边界还有一个注意点如果后续想在课程包里卖线上视频课电子食谱这类虚拟商品就会碰到 iOS 虚拟支付规范的问题。微信规定实物商品和线下服务不受限制但虚拟商品比如线上的课程视频在 iOS 端不能使用微信支付必须使用苹果的 IAP 内购否则小程序审核会被拒。健身房预约系统的主营业务是线下私教服务属于线下服务用微信支付没问题。但如果产品经理后续加线上训练营电子营养方案这类功能就要提前规划好 iOS 端的支付方案这是很多项目做大了之后才会意识到的坑。6. 上线前后踩过的坑从数据异常到审核被拒最后这节分享几个上线前后的真实踩坑记录这些都是文档里不会写、但实际开发一定会遇到的事情。6.1 日期凭空少了一天时区问题第一个版本上线后有会员反馈约课日期不对。比如他在手机上看到的是8月20日周二但支付成功后收到的通知里变成了8月19日周一。排查下来发现是时区问题。服务器的数据库连接默认用了 UTC 时区前端传过来的 2024-08-20 被后端解析时带上了时区偏移存储到数据库后再查询出来就变成前一天的日期了。处理方案有两个一是数据库连接串里明确指定 serverTimezoneAsia/Shanghai二是日期字段全部用 DATE 类型只存字符串格式不要用 DATETIME 配合时区转换。两个方案我最终都做了双保险之后再没出现过日期错乱。6.2 同一时段被约了两次超卖问题的完整排查这是整个项目中最惊险的一次故障。上线第一个月的某个晚上我朋友打电话说有两个会员同时约到了同一个教练同一个时段。排查过程是这样的先查数据库 booking 表发现确实有两条记录指向同一个 schedule_id。然后查代码发现预约接口的更新逻辑是先SELECT查时段状态再INSERT预约单再UPDATE时段状态中间没有加锁也没有唯一索引。后来的修订方案就是前面提到的在 schedule 表上加 (trainer_id, slot_date, start_time) 唯一索引预约时直接 UPDATE schedule SET status1 WHERE id? AND status0用受影响行数是否大于0判断是否抢到。同时把创建预约单和更新排期状态放进同一个数据库事务。从那次之后再没出现过超卖。6.3 审核被拒的两次经历小程序提审时被拒过两次都是因为服务类目和用户隐私问题。第一次是因为小程序名称里有健身两个字审核要求提供营业执照且经营范围包含健身服务否则不能使用这个名称。这个在提交前就要查清楚小程序的名称和服务类目必须和营业执照的经营范围匹配。第二次是因为用户隐私保护指引里填的内容项和实际收集的数据不一致。小程序需要声明收集了哪些用户信息手机号、位置、相册等如果声明得比实际收集的多或者少都会被拒。我们的做法是只声明真正用到的数据项并且在隐私弹窗里写清楚每个数据项的用途。6.4 上线后的数据复盘和扩展思考系统上线三个月最直观的变化是排课效率从原来每天花2小时人工协调缩短到全部线上自助完成。会员爽约率从原来的25%左右降到了8%以下因为爽约会被记录会员明显更认真对待自己的预约了。如果这个项目继续做下去有几个方向值得考虑一是从单店扩展到连锁需要在 trainer 表上增加 store_id 字段排期和预约逻辑要按门店隔离二是增加教练评价体系会员上课后可以打分评分高的教练曝光更多形成正向循环三是把教练的课时绩效自动计算出来月底直接生成工资报表把财务对账的时间从两三天缩短到半天。预约系统的核心不在于功能多花哨而在于把每天重复发生的约课这件事做稳、做准、做得让用户无感。用户感觉不到系统的存在恰恰说明系统真正在高效运转。这也是我在这个项目里最深的一点体会预约系统的技术难点不在单个功能而在环环相扣的数据一致性和流程完整性每一步都值得认真对待。