接到健身房私教预约系统这个需求的时候,我一开始是拒绝的——这类预约系统看起来简单,教练列表、时间选择、提交预约,几个页面就完事了。但真做起来才发现,里面藏着的坑一点不比电商系统少:并发预约怎么锁时间、教练端怎么处理临时取消、微信支付回调怎么对账、还有uniapp跑在不同端的兼容性问题。这篇就把整个系统的设计思路和落地过程完整梳理一遍,从技术选型到数据模型,从会员端预约链路到教练端工作台,再到上线打包避坑,希望给正准备做同类系统的朋友一些可复用的经验。
1. 选型背景:为什么私教预约系统用uniapp+vue这套组合
1.1 健身房预约场景的真实痛点
健身房私教预约的核心业务其实很简单:会员在手机上看到教练的档期,选一个适合自己的时间段,付费或者使用次卡锁定这个时段;教练端收到预约提醒,确认之后这个时段就被真正占用。它不像电商有复杂的SKU和物流,也不像社交产品有那么强的实时性要求,但它有一个很突出的难点——教练的时间资源是稀缺且不可复制的。
一个教练一天可用的私教时段就那么多,上午三小时、下午四小时、晚上三小时,每个时段一旦被确认,就必须从可预约列表中移除。如果两个会员在同一个时间点提交预约,系统必须保证只有一个人能成功。这个"唯一性"如果处理不好,就会出现一节课被卖两次的尴尬,后续的退款、投诉、教练满意度全都会崩。
再说运营方的需求。健身房老板要的不是一个"能预约"的小程序,他要的是会员约课、教练管理、业绩统计、课程排期一整套服务闭环。会员在小程序上预约,教练在手机上确认,店长在后端看报表。三条业务线对应着完全不同的操作终端和使用场景,这就对技术选型提出了跨端要求。
1.2 uniapp+vue 的现实优势
我最开始想过用微信小程序原生开发,毕竟私教预约的主战场就是微信生态。但仔细评估之后发现,原生小程序只能覆盖会员端——教练端总不能让教练也天天开着微信开发者工具,管理员更不可能在手机上查看排班报表。当时团队还要兼顾一个管理工作台,如果每种终端都单独开发一套代码,工作量直接翻倍。
uniapp的定位恰好解决了这个问题:一套vue代码,编译到微信小程序、App、H5甚至支付宝小程序。对健身房这种体量的项目来讲,最大的价值不是"一套代码多端运行"这个口号,而是业务逻辑层可以完全复用。
比如我们封装了一个request.js,统一处理登录态、错误码和token刷新。会员端小程序用,教练端App用,管理后台H5页面也用。一套接口对接逻辑,三个端同时受益,后期接口改动只需要动一处。
另外vue的响应式开发体验对这类表单密集型应用非常友好。会员选择教练、选时间槽、填备注,本质上就是将一个JSON对象逐步构造、最终提交的过程。在vue3的setup语法糖下,整个页面状态管理清晰,数据流一眼就能看透,不像原生小程序需要在data、methods、setData之间来回跳。
1.3 原生小程序方案为何被我放弃
不是原生不行,而是投入产出比不划算。原生小程序写一个页面,要和uniapp写一个页面做对比:
- 原生需要自己处理页面的数据监听、事件绑定,代码量和维护成本都更高;
- 原生无法直接运行在App端,后续如果要上架安卓市场或者做iOS端,需要单独开发;
- uniapp底层对微信小程序的API做了大量兼容封装,比如uni.login、uni.requestPayment,写一次就能在多个平台跑。
当然uniapp也有代价——它引入了一层框架抽象,遇到框架bug时需要自己绕路解决。比如某些小程序基础库升级后,uniapp编译出来的代码可能触发兼容性警告,这时候你得对vue到小程序之间的编译产物有基本认知,不能完全当黑盒用。
2. 系统核心模块拆解与数据模型设计
2.1 角色与权限体系设计
整个系统划分成三个角色:会员、教练、管理员。
会员端在小程序里,围绕"找教练、看档期、约课、付款、我的课程"这五个动作来设计页面。教练端通过另一个小程序页面入口或者App端进入,功能围绕"今日课表、预约确认、扫码核销、休课设置"展开。管理员端不需要做成移动端,一个管理后台网页就够用。
权限控制我建议不要在角色字段上玩太多花活,而是定义三个明确的角色值user_type字段,范围是1(会员)、2(教练)、3(管理员),前端路由守卫拦截页面访问,后端每个请求都校验登录态和角色。像"教练是否可以查看所有会员的信息"这类问题,在接口层用一个简单的归属判断就够了。
2.2 核心表结构设计
预约系统的实体不算多,但表与表之间的关联比较关键。我拆一下核心表。
-- 会员表 CREATE TABLE member ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL COMMENT '微信openid', nickname VARCHAR(32), avatar_url VARCHAR(255), phone VARCHAR(20), balance DECIMAL(10,2) DEFAULT 0 COMMENT '储值余额', card_times INT DEFAULT 0 COMMENT '剩余私教次数', created_at DATETIME, UNIQUE KEY uk_openid (openid) ); -- 教练表 CREATE TABLE coach ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32), avatar_url VARCHAR(255), title VARCHAR(64) COMMENT '头衔,如高级私人教练', introduce TEXT, rating DECIMAL(3,2) DEFAULT 5.00, status TINYINT DEFAULT 1 COMMENT '1在教 0停课', created_at DATETIME ); -- 课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, coach_id INT NOT NULL, course_name VARCHAR(64), course_type TINYINT COMMENT '1体验课 2常规课 3康复课', duration INT DEFAULT 60 COMMENT '时长,单位分钟', price DECIMAL(10,2), status TINYINT DEFAULT 1 ); -- 排课时间段表 CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, coach_id INT NOT NULL, course_id INT NOT NULL, start_time VARCHAR(20) COMMENT '如 09:00', end_time VARCHAR(20) COMMENT '如 10:00', weekday TINYINT COMMENT '1-7对应周一到周日', status TINYINT DEFAULT 1 COMMENT '1可用 0停用', UNIQUE KEY uk_coach_time (coach_id, weekday, start_time) ); -- 预约表 CREATE TABLE appointment ( id INT PRIMARY KEY AUTO_INCREMENT, appointment_no VARCHAR(32) COMMENT '预约单号', member_id INT NOT NULL, coach_id INT NOT NULL, course_id INT NOT NULL, schedule_id INT NOT NULL, booking_date DATE NOT NULL COMMENT '预约的具体日期', start_time VARCHAR(20), end_time VARCHAR(20), status TINYINT DEFAULT 0 COMMENT '0待确认 1已确认 2已完成 3已取消 4已过期', pay_status TINYINT DEFAULT 0 COMMENT '0未支付 1已支付', remark VARCHAR(255), created_at DATETIME, updated_at DATETIME, UNIQUE KEY uk_booking (schedule_id, booking_date), KEY idx_member (member_id), KEY idx_coach (coach_id, booking_date) ); -- 订单表 CREATE TABLE order_info ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32), appointment_id INT, member_id INT, amount DECIMAL(10,2), pay_type TINYINT COMMENT '1微信支付 2余额 3次卡', transaction_id VARCHAR(64) COMMENT '微信支付流水号', status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已退款', created_at DATETIME, paid_at DATETIME );这里有一个很关键的设计,我把排课时间段表schedule和预约表appointment分开,而不是直接在appointment里存时间段。这样做的原因有两个:第一,schedule表是教练可约时间的"模板",属于配置性质的数据;而appointment是预约发生后的"快照",属于流水性质的数据。两者混在一起会导致逻辑混乱——教练修改下周排课时,到底哪些历史预约要跟着变?如果分开,历史预约存的仍然是自己那份start_time和end_time,不会受模板变动影响。
2.3 并发预约的唯一性保证
私教预约最常见的并发问题就是"超卖":两个会员同时看到教练10点的档期,同时点预约,如果代码只用普通的查重判断,极容易出现两个预约单都创建成功的情况。我在测试环境用脚本并发调用接口,真的复现过这种事故,上下文里两个请求都查到了"该时段空闲",然后双双插入成功。
解决办法分两层。第一层是数据库层面的唯一约束:
UNIQUE KEY uk_booking (schedule_id, booking_date)这个唯一键的含义是:同一个排课ID在同一天只能被成功插入一条预约记录。当两个事务同时插入相同schedule_id和booking_date的记录时,数据库会拒绝第二个插入。这是防止超卖的底牌。
第二层是接口层面的"先查再插"用事务包住:
START TRANSACTION; -- 锁定该时间段,防止其他事务同时修改 SELECT * FROM appointment WHERE schedule_id = #{scheduleId} AND booking_date = #{date} AND status IN (0,1) FOR UPDATE; -- 如果查询结果为空,执行插入 INSERT INTO appointment (...); -- 更新订单信息 COMMIT;注意这里用了SELECT ... FOR UPDATE,在InnoDB引擎下会给命中的记录加行级锁。如果查到的预约记录status是已取消,则不锁冲突记录,允许重新预约。我在生产环境实际压测过,这个方案在几十个并发同时预约同一个时间段时可以稳稳保证只有一条成功。如果未来预约量上到几千几万级别,再把预约队列改为基于Redis的原子操作也不迟,但起步阶段数据库约束已经完全够用。
3. 会员端预约核心链路开发实录
3.1 教练列表与详情
会员打开小程序,第一个核心页面就是教练列表。这个页面在uniapp里用vue3的setup语法写,页面结构很直观。
<template> <view class="coach-list"> <view class="coach-card" v-for="coach in coachList" :key="coach.id" @click="goDetail(coach.id)" > <image :src="coach.avatarUrl" mode="aspectFill" class="avatar" /> <view class="info"> <text class="name">{{ coach.name }}</text> <text class="title">{{ coach.title }}</text> <text class="rating">评分 {{ coach.rating }}</text> </view> </view> </view> </template>这里有个容易忽略的点:微信小程序的image组件必须设置宽高,如果用mode="aspectFill"但不给固定尺寸,图片渲染会在不同机型上错乱。列表页我一般用rpx单位,2rpx等于1px,适配不同屏幕宽度。
v-for循环渲染的列表如果很长,记得在页面onLoad阶段一次性拉取首页数据,滚动到底部再触发分页拉取。uniapp用onReachBottom生命周期实现这个很顺手。我用的page是uniapp的pages.json里配置的,这个生命周期在微信小程序端和App端表现一致,不需要额外兼容。
3.2 时段选择:时间槽是怎么生成的
用户选择教练之后进入详情页,核心交互是选择一个时段。这里我不建议前端硬编码时间槽,而是由后端根据教练的排课表动态返回。
后端生成可用时段的逻辑是这样的:先取出该教练在指定日期的排课计划,再排除已经被预约的时间段,然后把剩余时间段按时间排序返回给前端。如果只传一个星期几、没有指定日期,前端需要先展示未来7天的可约日历。
生成未来7天可约时间的伪代码如下:
def generate_slots(coach_id, start_date, days=7): slots = [] for i in range(days): date = start_date + timedelta(days=i) weekday = date.weekday() + 1 # 周一到周日对应1-7 schedules = get_schedules(coach_id, weekday) for s in schedules: booked = is_booked(coach_id, date, s.start_time) if not booked: slots.append({ 'date': date.strftime('%Y-%m-%d'), 'start_time': s.start_time, 'end_time': s.end_time, 'schedule_id': s.id, }) return slots实际前端展示时,用户点选一个日期的槽位,后端再返回该日期下所有可约时段。这里要注意时区问题——小程序端获取用户的当前日期,不要简单地用new Date()去拼字符串,因为H5端和App端的时间格式化结果有差异。最好的做法是统一由后端接收前端的时间戳,后端按服务器时区解析。
3.3 提交预约与支付对接(含code换取openid过程)
用户选好时段、点击"立即预约"后,前端把schedule_id、booking_date、course_id、remark这些参数通过uni.request POST到后端,后端创建一条status=0(待确认)的预约单。如果这个课程是收费的,需要立刻拉起支付。
微信支付在小程序端的完整链路是这样的:
先调用uni.login拿到临时登录凭证code:
uni.login({ provider: 'weixin', success: async (loginRes) => { const code = loginRes.code; // 把code传给后端,后端用code向微信服务器兑换openid和session_key } });后端拿到code后,向微信接口发送请求:
GET https://api.weixin.qq.com/sns/jscode2session ?appid=APPID &secret=SECRET &js_code=CODE &grant_type=authorization_code返回的数据里最关键的是openid,这是用户在某个微信小程序下的唯一身份标识。同一个用户在你们小程序和别的小程序里openid是不同的,所以openid必须作为会员表的唯一键。
提示:网上经常看到"code换token"的说法,本质就是这个jscode2session的过程。token是你们后端自己生成的登录态标识,openid是微信侧返回的身份标识。两者的关系要理清楚:每次会话可以换一个token,但同一个用户的openid是永久不变的。
拿到openid后,后端创建预约单、生成订单,再调用微信支付的统一下单接口,返回前端的pay参数包含timeStamp、nonceStr、package、signType、paySign。前端拿到这五个参数后调起支付:
uni.requestPayment({ provider: 'wxpay', timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: 'RSA', paySign: payParams.paySign, success: (res) => { // 支付成功,跳转我的预约列表 }, fail: (err) => { // 用户取消支付或者支付失败 } });这里我踩过的坑是:paySign对应的签名算法在小程序端必须和统一下单时一致,常用的有MD5和RSA两种。微信支付接口已经在逐步淘汰MD5签名,新商户默认RSA,前端填错签名类型会直接报错。
支付成功不代表预约流转到"已确认"状态。我建议把支付回调和预约确认分开处理:支付成功只是"已完成支付",而是否被教练接受还需要一个独立的确认动作。除非是固定排课的课,不建议支付成功就自动确认。
3.4 预约状态的联动更新
会员端看到的预约状态,和教练端的操作、系统的自动任务都要联动。这里定义一个清晰的预约状态机很重要:
| 状态码 | 状态名 | 触发条件 | 后续动作 |
|---|---|---|---|
| 0 | 待确认 | 会员提交预约 | 教练端弹提醒 |
| 1 | 已确认 | 教练确认 | 时段锁定 |
| 2 | 已完成 | 课程结束且扫码核销 | 教练计课时 |
| 3 | 已取消 | 会员/教练取消 | 释放时段 |
| 4 | 已过期 | 未确认且超过预约日期 | 自动取消 |
这个状态机的核心价值是保证业务动作有明确的流转路径。例如会员取消一个status=1的预约,需要先释放时段,再触发退款流程;如果直接改状态而不释放时段,会出现用户取消后依然显示被占用的bug。我的做法是把这些状态流转都封装在后端service层,前端页面只调用"cancelAppointment"这样的语义化接口,不在前端拼装复杂的业务判断。
4. 教练端工作台与预约状态机
4.1 今日排课视图的实现
教练端最看重的功能就是今天上什么课、几点上、在哪里上、会员是谁。这个页面我用了一个简单的分组列表:按开始时间排序,把当天的预约记录逐条展示。
教练端的登录流程和会员端略有不同——同一个教练可能也是健身房老板用管理员账号创建的,他第一次打开小程序用微信登录后,后端要根据手机号或者邀请码把微信账号和教练身份绑定。这里不建议让教练直接改角色字段,而是单独建一张coach_member_binding表,避免角色越权。
今日排课列表我直接请求后端接口:
export const getTodayCourses = (coachId) => { return request({ url: '/coach/today-courses', method: 'GET', data: { coachId } }); };接口返回的是当天状态为待确认和已确认的预约记录,按start_time升序排列。教练端需要在每天早上9点推送一条今日排课的消息,我是用微信小程序订阅消息实现的。订阅消息的模板需要提前在公众平台申请,而且必须经过用户授权才能推送,所以在教练第一次登录时就会弹出订阅授权请求。
4.2 预约确认/拒绝的权限与状态维护
教练对预约单的操作主要是确认和拒绝。确认很简单,调一个接口把appointment的status从0改成1。拒绝则需要填写原因,同时系统要把这个预约单改为取消状态、给会员发通知。
这里有一个业务细节需要自己想清楚:教练拒绝预约后,该时段是否能被其他人立刻约到?我当时的处理是:教练拒绝后,该时段回到可预约池,其他会员可以继续约。但如果这个预约已经支付且金额较大,触发全额退款后会员体验可能不好。
所以我在拒绝操作上加了确认弹窗,提醒教练"该预约已支付,拒绝后将原路退款",让教练明确知道操作后果。这个提醒看似简单,但减少了大量客服纠纷。
4.3 扫码核销:预约状态从确认走向完成
预约确认只是开始,真正让课时闭环的是到场核销。我遇到的一个典型场景是:会员到场后,教练要手动在手机上找这个预约单,很麻烦;又或者会员预约了没出现,课时还在那里挂着,月底统计业绩时对不上账。
我的解决方案是在"我的预约"页面生成一个包含预约单号的二维码,教练端用uniapp的扫码能力直接核销。
uni.scanCode({ onlyFromCamera: false, success: (res) => { const result = res.result; // 解析结果,拿到预约单号 verifyAppointment(result); } });这里有一个真实踩过的坑:微信扫一扫在部分安卓机型上返回的res.result格式不统一,有的直接是字符串"APPOINTMENT20240615001",有的外面包了一层JSON。如果后端直接把这个值当预约单号去数据库查,大概率查不到。我最后在后端加了一个兼容函数,先尝试普通字符串匹配,再尝试JSON解析,两端都覆盖。
核销通过后,预约状态从1(已确认)变为2(已完成),同时记录核销人和核销时间。这个动作也是教练课时统计的数据来源,比让教练手工填写课时记录可靠得多。
4.4 课表日历的简单实现方案
教练端的课表日历是否要做得花哨?我的经验是不要过度设计。第一版我就用了一个横向滚动的月份视图,点击某一天,下方列表显示那天的课程。如果要看整月课时数,用一个统计接口返回每天的预约数量即可。
日历组件不建议引第三方库,uniapp跨端的日历组件往往存在某些端不兼容的问题。我直接用CSS Grid实现了一个最简单的日历:头部是星期标题,下面按行渲染日期格子。
<view class="calendar-grid"> <view class="weekday" v-for="w in ['一','二','三','四','五','六','日']">{{ w }}</view> <view v-for="day in dayList" :key="day.date" class="day-cell" :class="{ active: day.date === selectedDate }" @click="selectDate(day.date)" > <text>{{ day.day }}</text> <text v-if="day.hasCourse" class="dot"></text> </view> </view>hasCourse字段由后端在返回日历数据时算好,前端只负责展示,避免前端自己比对日期字符串的兼容性问题。日历点击某一天后,再调今日课程接口刷新下面的课程列表。整个页面实现下来不到两百行代码,稳定性和维护成本都控制得不错。
5. 小程序打包发行与上线避坑清单
5.1 HBuilderX发行小程序完整步骤
项目开发完成后,最关键的关卡是从HBuilderX发行到微信开发者工具。用uniapp开发的项目,HBuilderX可以说是一站式工具:新建项目、写代码、打包、上传一气呵成。
具体步骤如下:
- 用HBuilderX打开项目,确认manifest.json里的微信小程序配置已经填了AppID;
- 点击菜单栏"发行" -> "小程序-微信";
- 若提示安装微信开发者工具,按引导安装;
- 发行完成后,HBuilderX会自动拉起微信开发者工具,并打开编译产物目录;
- 在微信开发者工具中确认项目路径无误,点击"编译",预览效果;
- 确认体验版本没问题后,点击"上传"按钮,填写版本号;
- 登录微信公众平台,在"版本管理"里把上传的版本提交审核;
- 审核通过后点击"发布"。
每一步都有可能踩坑。比如第1步AppID填错会导致调用微信登录时直接报错;第4步如果发行时HBuilderX没能自动拉起开发者工具,说明开发者工具的服务端口没有开启,需要在微信开发者工具的"设置"->"安全设置"里打开服务端口。
5.2 manifest.json配置细节
manifest.json是uniapp项目的核心配置文件,它决定了你的应用在微信小程序里的各种行为。需要注意这几个字段:
- mp-weixin.appid:微信小程序的AppID,不能留空,否则无法在开发者工具中正常编译;
- mp-weixin.usingComponents:是否启用自定义组件,一般保持默认;
- mp-weixin.lazyCodeLoading:官方推荐配置为"requiredComponents",启用按需注入,可以减小小程序首包体积;
- mp-weixin.requiredPrivateInfos:如果你用到获取地理位置等功能,需要在微信公众平台申请对应接口权限并在配置里声明;
- mp-weixin.optimization:是否开启分包优化,预约系统在后期加了大量图片资源后,需要合理使用分包让主包体积降到2MB以下。
小程序对主包大小的限制是2MB,整体包大小限制是20MB(部分类目)。如果你的私教预约系统包含大量教练实拍封面图、课程视频,务必把它们放到远程服务器或者云存储上,不要在包里存大图。实际上图片资源建议一律走CDN,这比纠结分包更彻底。
5.3 隐私协议与合法域名那些绕不开的坑
微信小程序对隐私协议的要求越来越严格。只要你的小程序需要获取用户手机号、微信头像昵称、位置等隐私信息,就必须在微信公众平台配置《用户隐私保护指引》,并在代码中主动触发隐私授权弹窗。
我在这上面栽过一次跟头:开发环境一切正常,一上线审核就被打回,理由是"收集用户手机号前未征得用户明确同意"。解决方法是:在用户进入手机号授权页面时,先调用uni.getPrivacySetting检查用户隐私授权状态,未授权时引导用户去授权,再请求手机号。
另一个绕不开的坑是合法域名。微信小程序在真机上只能请求已在公众平台配置的域名,且域名必须是HTTPS。开发过程中可以在开发者工具里勾选"不校验合法域名",但提交体验版后这个选项就会失效。
所以最稳妥的做法是:项目初始化第一天就准备一个HTTPS接口域名,并且在manifest.json里把request、uploadFile、downloadFile等域名配好。临时用内网IP调试,等域名配置好了再切回来。
5.4 真机调试与开发工具不一致问题
uniapp开发中最隐蔽的问题是:开发工具运行正常,但真机一跑就出问题。常见的有这么几个:
- 开发工具里接口能通,真机上报"request:fail",多半是合法域名未配置或者证书过期;
- iOS真机无法播放音频,可能是音频格式问题,微信小程序在iOS上不支持部分音频格式;
- 安卓和iOS的顶部导航栏高度不一致,需要动态获取系统的statusBarHeight去适配;
- 本地调试接口用http时正常,但上线后HTTPS的证书链不完整导致请求失败,需要检查证书是否包含完整的CA链。
我在适配顶部导航栏的时候用的方式是在App.vue里统一读取系统信息:
const systemInfo = uni.getSystemInfoSync(); this.statusBarHeight = systemInfo.statusBarHeight;拿到statusBarHeight后,再根据是否为胶囊按钮位置计算导航栏高度,iOS和安卓基本能统一。
6. 运营过程中遇到的几个实际问题
6.1 超卖预约的修复过程
这个前面提过,但我还是想单独展开说一下完整的排查链路。系统上线第二周,有个会员反馈说预约成功了但到店发现教练那段时间已经有别的会员在上课。查日志发现两条预约单几乎在同一毫秒创建,都指向同一个schedule_id和booking_date。
我的排查步骤是:
- 先查数据库,确认两条记录确实存在;
- 再看应用日志,发现两个请求都是先执行了SELECT查询,都返回"该时段空闲",再执行INSERT;
- 再用JMeter模拟50个并发请求复现,确认是竞态条件;
- 最后加上了第2章说的唯一约束和FOR UPDATE事务锁,回滚旧代码的问题。
这个案例给我的启发是:任何涉及资源占用的系统,都必须假设并发是真实存在的,不能在代码层面只做"看起来正确"的逻辑,数据库约束才是最后的兜底。
6.2 时间格式兼容性:iOS与安卓的显示差异
在私教预约业务里,日期字符串的解析踩了一个典型坑。前端从后端拿到"2024-06-15 10:00"这样的时间字符串,在安卓上可以直接new Date()解析,但在iOS上会报Invalid Date,导致页面崩溃或者时间显示为NaN。
原因在于iOS的JavaScriptCore对日期格式的支持比安卓严格,不识别"2024-06-15 10:00"这种带横线且中间有空格的写法,必须改成"2024/06/15 10:00"或者纯时间戳。
解决方案是在封装的公共方法里做一次格式化转换:
export const formatDate = (dateStr) => { if (!dateStr) return ''; return dateStr.replace(/-/g, '/'); };所有从前端new Date()的地方,都先经过这个处理。别小看这个看似弱智的替换,真机上iOS的日期解析Bug比任何业务逻辑Bug都难排查——因为它在开发者工具里根本不报错。
6.3 数据统计:教练课时与业绩报表
系统上线后,运营方会长开始频繁问一个问题:"这个月哪个教练课时最多?谁完成业绩了?"这类统计需求完全不值得在一开始就做得很重,我用了最直接的定时脚本:每天凌晨统计前一天的预约完成数据,写入一张统计汇总表。
统计口径的关键在于"完成"的定义。一个预约要计入教练课时,必须满足:status=2(已完成)、pay_status=1(已支付)、且课程实际发生在该教练的排课时段内。如果不加这些条件,会出现"会员约了没来、教练白等但课时照算"的尴尬。
下面这个SQL是每月课时统计的核心:
SELECT coach_id, COUNT(*) AS lesson_count FROM appointment WHERE status = 2 AND pay_status = 1 AND booking_date BETWEEN #{startDate} AND #{endDate} GROUP BY coach_id ORDER BY lesson_count DESC;前端管理后台再把结果做成柱状图或者表格。对健身房而言,这个数据不仅能给教练发绩效,也能用来优化课程排期的合理性——比如发现某个教练的私教课总是约不满,就可以减少他的排课时段,把资源让给其他教练。
系统上线跑了大半年,我自己最大的感受是:预约类系统的核心从来不在页面多炫、动画多流畅,而在于数据模型的严谨性和状态流转的闭环。如果让我再做一个同类的预约系统,我会在第一天就把并发唯一约束和状态机设计好,而不是等到超卖事故出现了再去补。会员端体验可以慢慢迭代,但地基一定要一次打牢。