
1. 项目背景瑜伽馆的约课难题为什么值得自研一套系统我接手这个项目的时候客户的瑜伽馆已经开了五年会员将近两千人但约课方式还停留在最原始的阶段——微信群接龙加前台手写登记。每天上午十点准时开始接龙会员在群里刷屏教练统计人数再人工核对哪些课满了、哪些课人不够要取消。热门课比如晚间流瑜伽、周末空中瑜伽经常一秒满员抢不到的会员不满意前台统计漏了、统计错了一团浆糊。更麻烦的是课表变动。教练临时请假要改课微信群通知发出去总有人没看到到了馆里发现课取消了白跑一趟。会员的剩余课时、体验券使用记录、请假记录全靠Excel换一个人管就断档。这种情况在全国的瑜伽馆里其实非常普遍。约课系统听起来不复杂但真正跑起来要覆盖的东西远比想象中多会员端查看所有课程的排期、选课、取消预约、查看我的课表、消耗课时记录教练端查看自己的排课表、确认学员名单、记录学员签到管理端课程管理分类、名称、难度、封面图、排期管理什么时间、哪位老师、哪个教室、容量多少人、学员管理、订单与课时记录当初也不是没想过直接买现成的SaaS约课工具。调研了一圈市面上确实有成熟产品按年收费但问题是会员数据、排课逻辑全部在别人平台上无法深度定制比如这类瑜伽馆特有的课程难度分级、老师的擅长风格标签、学员请假次数限制规则年费对于一家中型瑜伽馆来说不算小数目而且随着会员量增长续费水涨船高还有我客户特别在意的品牌形象问题——约课页面上总是带着别人的Logo体验确实一般。所以最后拍板自研。技术路线定了两件事后端采用PHP系框架具体在ThinkPHP与Laravel之间选型前端使用uniapp开发微信小程序之后再考虑要不要扩展App端和H5端。整个系统从需求梳理到上线大约花了两个月目前稳定运行大半年支撑了约课高峰期上千人同时访问的流量。这篇博文就完整复盘一遍这个项目——架构怎么定、数据库怎么设计、预约核心逻辑怎么保证不超卖、小程序端有哪些隐藏得很深的坑。2. 技术选型的取舍为什么最终定下ThinkPHP为主、Laravel为辅的双框架思路2.1 为什么会同时出现ThinkPHP和Laravel两个框架标题里同时出现ThinkPHP和Laravel可能会让不少人疑惑一个项目用两个PHP框架其实这里要讲清楚我的真实做法。团队里两个主力后端一个习惯ThinkPHP 6一个主力用Laravel 10。刚开始约定统一用其中一个但沟通下来发现让队友放弃自己最熟悉的东西并不明智——项目排期紧硬切换框架的试错成本反而更高。所以我的处理方式是核心预约与选课模块拆成独立的PHP服务交付一个RESTful API服务内部可以用ThinkPHP 6实现管理后台仍然采用Laravel 10两个模块基于同一套MySQL数据库通过一个共享的API网关层统一鉴权。这个所谓双框架方案听着有点怪实际上在中小型团队里并不少见。彼此的边界非常清楚ThinkPHP负责高并发的预约接口设计简单直接Laravel负责后台复杂的业务管理像课程管理、报表聚合这类功能正好利用Eloquent ORM的便捷。如果你是一个人做整个项目我的建议是别学这种搞法老老实实选一个框架到底。另外补充说一个个人观点如果从零开始做类似系统我更推荐Laravel。它的中间件体系、队列、缓存抽象、迁移功能都更完善开发体验明显比ThinkPHP顺滑。但ThinkPHP在国内的资料多、上手门槛低、虚拟主机兼容性好这也是它一直没有退出竞争的原因。2.2 关键选型对比对比维度ThinkPHP 6Laravel 10路由定义文件内类数组风格相对自由闭包与控制器方法绑定规范统一ORM简单实用关联查询够用Eloquent强大关联与聚合好用中间件有但生态偏少丰富内置多种认证与限流队列/任务调度基础版成熟适合超时释放等任务学习曲线平缓文档中文友好稍陡峭但社区资料全球丰富2.3 前端为什么锁定uniapp前端技术选型几乎没怎么犹豫。一开始就知道最优先要落地的是微信小程序微信的生态就是瑜伽馆获客的核心渠道。但客户也明确提过将来想在美团、抖音或者自有App里复用这套系统。这时候uniapp的优势就出来了——一套Vue语法的代码编译到微信小程序、支付宝小程序、H5甚至App。技术上uniapp用Vue组件化开发对小程序的API做了很好封装遇到平台差异时还能使用条件编译加原生代码。我在实际开发中体会最深的一点如果只做微信小程序原生小程序其实也很好但只要有跨端预期uniapp几乎是唯一理性的选择。成本差异在后端不用改一行代码——小程序端只是同一套API的消费者。3. 从需求到数据结构课程、排期、预约三张核心表的设计逻辑系统要跑得稳数据库设计是地基。我花了最多时间做的不是代码而是把「课程」「排期」「预约」这三个核心概念的边界理清楚。3.1 课程与排期分离课程是静态的元数据相当于一个模板叫「哈他瑜伽」、难度是初级、时长60分钟、适合人群描述、封面图、课程介绍视频链接。排期才是实在的一次可约课2025年3月20日周四19:00-20:00、教室A、教练王老师、容量12人。同一个课程可以排十个不同时间的排期用户实际约的是排期。为什么这样分一旦把课程和排期混在一张表里改课程名称就得批量更新所有历史排期极易造成数据不一致。分离之后课程信息修改只影响未来排期的展示历史数据通过外键关联到课程ID内容不会串。核心表字段大概如下course课程表id、name课程名、category_id分类、level难度级别、cover_url封面图、video_url课程视频、description简介status上下架状态course_schedule排期表id、course_id、coach_id教练ID、classroom_id教室ID、start_time、end_time、max_members最大容量、min_members最低开班人数、status未开始/进行中/已结束/已取消、signup_count当前约课人数booking预约表id、member_id会员用户ID、schedule_id排期ID、booking_no唯一预约单号、status已预约/已取消/已核销/已过期、payment_status免费/待支付/已支付、created_at关于教室表如果馆里只有一两间教室可以不做独立表直接存字符串教室名但考虑到将来加门店我建了独立classroom表。教练表建议不在users表里加is_coach字段而是单独建coach表关联user_id——因为教练会有自己的课时费单价、擅长课程标签、教学年限这类属性混在用户表里会很难维护。3.2 预约状态机不要让约课状态是一个简单的字符串预约不只是「约了/取消了」两个状态它实际上是一个带时序的状态流转。我设计的状态机如下pended待支付付费团课先锁定名额15分钟内未支付自动释放booked已预约支付完成或者免费课直接锁定成功cancelled已取消用户主动取消或者超时未到被系统取消checked_in已核销会员到馆教练或前台扫码核销expired已过期课程结束时间已过且未核销有一个小细节取消预约未必都能退课时。我一开始允许无限制取消后来发现很多人约了不到课导致少数想上课的人约不进来。后来在业务规则上加了约束——开课前2小时允许免费取消2小时以内取消会扣一次课时。这个规则不需要太复杂的代码在服务层做判断即可。3.3 防超卖唯一索引与事务缺一不可预约系统最容易出的线上事故就是超卖排期显示剩1个名额两个会员同时提交预约结果两个人都约上了等到上课才发现教室有三个垫子不够坐。解决思路其实并不神秘关键在两点排期表的signup_count不是计算出来的而是预约成功后自增的。预约创建时用一条update语句配合条件判断UPDATE course_schedule SET signup_count signup_count 1 WHERE id ? AND signup_count max_members。如果影响行数是0说明名额已满预约失败。这比先SELECT再UPDATE要安全得多后者在并发下必然出现竞态条件。在booking表加唯一索引(member_id, schedule_id)防止同一用户对同一排期重复提交预约。这个索引是兜底方案就算应用层逻辑有漏洞数据库也能拦住。事务层面创建预约、扣减课时、更新名额这三步操作必须包在一个数据库事务里。PHP里用框架的事务闭包包裹即可一旦任一步失败整体回滚名额和数据都不会错乱。4. 预约选课的业务闭环预约、取消、超时释放、签到核销4.1 为什么用户看到的是「剩余名额」后端却在锁库存用户的视角非常简单点「预约」约到了就成功约不到就提示满了。后端的真实流程要复杂一些。我用一个场景来完整串联会员小红打开小程序课程列表页显示「流瑜伽周四19:00剩5/12人」。这个「剩余名额」是后端实时查询排期表返回的。她点进入详情页点击预约按钮提交请求。后端处理预约的步骤如下校验用户是否登录、课程是否在可预约时段开课30分钟前校验该排期状态是否为「未开始」且当前时间在预约截止时间之前执行前面提到的条件更新语句尝试锁定名额如果排期是付费课程且需要先支付则把预约状态置为pended返回一个订单号让小程序发起微信支付支付成功回调后再把状态改为booked如果是免费课或者用户课时充足比如次卡用户状态直接booked同步扣减会员的可用次数全部操作包在事务里提交这里的边界条件非常值得注意。条件更新的SQL在MySQL InnoDB引擎下会对碰到同一行数据的并发事务做行锁排队因此不会出现两个事务同时把signup_count从11加到12的情况。实测压测下200并发提交预约最后signup_count与实际预约成功数完全一致。4.2 取消预约与超时释放的两种实现思路取消预约的接口相对简单将booking状态改为cancelled把排期表的signup_count减1如果该用户本次消耗了课时次卡则把课时回补到剩余次数上。同样需要事务。这里有一个不明显的坑如果用户把最后的课时花在取消的这节课上回补后还需要校验课时有效期会不会已过。过期课次回补等于没补得把状态置成expired并明确提示用户。超时释放是针对pended状态的订单。用户发起预约但一直没支付名额会一直被占着别人约不进来体验很不好。最常见的实现方案是定时任务/队列任务扫描pended订单超过15分钟未支付自动取消并释放名额。Laravel里用任务调度很合适cron每分钟跑一次ThinkPHP也可以实现或者干脆用一个常驻脚本轮询MySQL。也有人在Redis里以schedule_id为key存pended标志加过期时间但考虑到我们这个项目MySQL压力并不大定期扫描这类表量级完全可以接受。4.3 签到核销用二维码与扫码实现到馆验证签到功能是运营方强烈要求的。最初的需求是「前台在电脑上看到预约列表打个勾」后来优化成教练在小程序端扫会员的二维码核销。实现上系统为每条预约生成一个带签名参数的二维码内容包含booking_no和一个一次性随机token。教练端小程序调用扫码API解析之后将token传给后端校验——同一个token只能核销一次第二次扫码直接提示已核销。这里要补充一个安全细节二维码里的token过期时间不宜过长常见做法是5分钟内有效防止别人截图冒用。5. 后端接口设计ThinkPHP与Laravel如何协同以及和小程序的对接细节5.1 统一API响应格式与错误码小程序端和后端对接最忌讳每个接口返回格式都不一样。我在接项目第一天就先定义一个统一的响应结构{ code: 0, message: ok, data: {} }code为0表示成功非0为业务错误码例如1001表示参数错误、1002表示未登录、2001表示课程名额已满。HTTP状态码我只用在请求层级的错误上——比如404、500——业务层面的失败统一靠code区分。原因很简单小程序端通过微信的request调用接口时只要请求能到后端包括500都会走success回调如果把业务错误塞到HTTP 4xx开发者工具里看着一堆红实际上业务逻辑还得靠statusCode区分不如直接统一。5.2 路由与控制器设计ThinkPHP 6这边我更习惯用路由注解或直接在route目录下定义// ThinkPHP 6 路由示例 Route::get(api/courses, course/list); Route::post(api/booking, booking/create); Route::post(api/booking/cancel, booking/cancel);Laravel 10这边用Route facade定义// Laravel 10 路由示例 Route::middleware(auth:api)-group(function () { Route::get(/courses, [CourseController::class, index]); Route::post(/booking, [BookingController::class, create]); Route::post(/booking/cancel, [BookingController::class, cancel]); });两边的控制器保持瘦控制器模式只做参数接收、调用Service服务层、返回响应。预约业务逻辑全部放在Service类中例如BookingService::create()。这样即便两个框架风格不同核心业务的可读性和复用性都在。5.3 微信登录与JWT鉴权链路小程序端登录不需要账号密码。微信官方提供了静默登录的能力wx.login拿到code后端拿code去微信接口换openid。我的处理方式是小程序调用wx.login()获取临时code把code发给后端/api/auth/wx-login接口ThinkPHP后端调用微信接口https://api.weixin.qq.com/sns/jscode2session换取openid和session_key用openid在users表里查找或创建用户签发一个JWT token返回给前端前端把token存到uni.setStorageSync之后每个请求带上请求头Authorization: Bearer token为什么不用传统的PHP session因为小程序端并非浏览器如果我们后续接H5或者Appsession维持登录态需要额外维护cookie同步而JWT无状态、跨端友好、前端控制生命周期非常灵活。讲到sessionLaravel的session机制在管理后台里很常用但小程序API层我个人建议统一JWT避免混淆。这里也解释一下为什么不少人在网上搜「Laravel session」——管理后台的登录态保持确实适合用框架自带session但它是后端模板场景下的方案小程序API不是这么玩的。JWT有个注意点token一旦签发服务端默认无法提前让它失效这在用户封禁场景下很头疼。我的做法是在token里带上一个jti字段服务端存一个「blacklist_jti」缓存需要让token失效时就写入这个缓存。每次请求中间件里检查一下即可代价极低。5.4 管理后台用Laravel两个服务共用数据库的风险控制Laravel管理后台访问同一个MySQL又维护同一批业务数据最大的风险是并发与数据一致性。为此我做了两条约定数据库迁移和字段变更只在Laravel侧做ThinkPHP只消费既有表结构两个服务不直接操作对方的缓存键Redis每个模块有独立前缀这样分工的好处是日常管理端的报表等功能通过Laravel的Eloquent来做而面向C端高并发的预约接口在ThinkPHP侧保持精简互不干扰。目前运行下来唯一一次数据不一致发生在一次手工改库后两边模型缓存未刷新加个主动清缓存操作就解决了。6. uniapp端的小程序开发请求封装、生命周期、导航栏与常见坑6.1 项目初始化与目录规划我建立uniapp项目并选择Vue 3版本构建目标编译到微信小程序。项目目录上规整为src/ api/ # 接口请求模块按业务域拆分 components/ # 公共组件 pages/ index/ # 首页课程列表 schedule/ # 排期详情 booking/ # 我的预约 profile/ # 个人中心 utils/ request.js # 请求封装 auth.js # 登录状态与token管理 static/页面规划很重要。首页做课程分类Tab每个分类下是纵向卡片列表点击卡片进入排期列表页排期页展示教练、时间、地点、剩余名额以及「立即预约」按钮底部TabBar安排「首页」「我的预约」「我的」三个入口。整体交互路径控制在三次点击以内对不熟悉手机操作的瑜伽馆会员非常友好。6.2 请求封装拦截器、Token刷新与错误统一提示微信小程序的网络请求与浏览器里fetch的差异不大但uniapp中我统一在utils/request.js里封装了一个Promise化的请求方法。核心逻辑包括请求前读取本地token附加到请求头Authorization响应后先检查HTTP状态码再解析业务codecode为0时resolve出data非0时统一弹出uni.showToast提示用户遇到401时清理本地token并跳转回登录/授权页一段简化后的核心封装逻辑参考如下// utils/request.js const request (options) { return new Promise((resolve, reject) { const token uni.getStorageSync(token) uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: token ? Bearer ${token} : }, success: (res) { if (res.statusCode 200) { if (res.data.code 0) { resolve(res.data.data) } else if (res.data.code 1002) { uni.removeStorageSync(token) uni.navigateTo({ url: /pages/auth/login }) reject(res.data) } else { uni.showToast({ title: res.data.message, icon: none }) reject(res.data) } } else { uni.showToast({ title: 网络请求失败, icon: none }) reject(res.data) } }, fail: (err) reject(err) }) }) }这里有一个实际开发中常见的坑如果某个请求需要登录但promise被reject之后页面里没有处理会导致toast反复弹出。所以我在具体页面的业务层统一加catch阻止错误继续冒泡到全局。6.3 生命周期管理为什么我的预约列表要放在onShow里刷新uniapp页面生命周期中最容易被忽略的是onShow和onLoad的区别。onLoad只在页面首次加载时执行一次onShow每次页面显示都会触发。我的「我的预约」页面用户在取消一个预约后返回列表页此时页面可能并未被销毁尤其在小程序这种多页面栈场景下。如果只在onLoad里加载数据返回时看到的还是旧数据用户会以为取消没成功。因此凡是依赖用户操作后回显的数据刷新我都放在onShow里。这也是为什么团队里很多人搜「uniapp生命周期」——踩过这个坑才会明白每个钩子的意义。另一个生命周期细节是在小程序里从详情页返回列表页时onShow同样触发。我在列表页里加了数据过期判断如果上次加载时间离现在超过30秒就静默刷新否则直接复用现有列表减少无谓请求。6.4 顶部导航栏自定义导航与iPhone刘海屏的适配微信小程序顶部导航栏高度在不同机型上差异非常大。默认导航栏在小程序里体验尚可但为了界面更精致我们选择了自定义导航栏——在pages.json里设置navigationStyle: custom然后自己画一个胶囊返回按钮和标题栏。工作量大增的直接原因就是状态栏高度和胶囊按钮位置每个机型都不一样。一个被验证有效的适配方案// 获取状态栏高度与胶囊信息 const systemInfo uni.getSystemInfoSync() const statusBarHeight systemInfo.statusBarHeight const menuButton uni.getMenuButtonBoundingClientRect() // 自定义导航栏总高度 胶囊顶部与状态栏的间距 胶囊高度 胶囊底部与状态栏的间距 const navBarHeight (menuButton.top - statusBarHeight) * 2 menuButton.height这个navBarHeight statusBarHeight就是自定义导航栏的整体高度。头部背景和内容区都要按这个动态计算出来的高度做占位。完全没有经验的时候很容易把导航栏高度写死成44px结果在iPhone 14 Pro上一挤就撞到胶囊按钮这种体验一上市就会被用户吐槽。6.5 manifest配置、分享、扫码与缓存那些事这些是跟微信小程序能力强相关的点每一个单独拿出来都是容易踩坑的地方。manifest配置。小程序AppID一定检查清楚开发工具里配置了测试号可是要真机预览就报错。实际开发中还容易漏掉的是权限声明——比如扫码API、位置接口都需要在小程序后台申请开通并在manifest里同步声明。另外分享好友这个功能在小程序里有两种做法右上角菜单的默认转发和自定义按钮转发。默认转发需要在页面里定义onShareAppMessage返回标题、图片和路径。路径里的query参数建议带上课程ID好友打开直接落到对应详情页。这里有一个坑onShareAppMessage的path不能是相对路径随意乱写必须是以pages/开头的完整页面路径否则转发出去打不开。扫码功能。我们在前台和教练端都用到了扫码。会员展示二维码、教练扫码核销。如果只是打开小程序内已有页面调用uni.scanCode获取到结果后解析参数即可。因为我们的核销码不是正常URL而是一个包含签名参数的字符串几乎不会被微信拦截。但要注意iOS与安卓在扫码参数编码格式上有细微差异稳妥做法是把参数做一次URL编码解码时统一处理。缓存时间设置。小程序本地缓存uni.setStorageSync没有原生过期机制。课程分类列表数据不经常变化我设置了一个带时间戳的缓存封装读取时校验时间超过10分钟就重新请求。这个方案解决了普通缓存没有TTL的问题。小程序缓存大小限制是10MB存图片容易超限所以图片一律不落本地存储只存URL。注意视频资源也绝不能直接base64编码进缓存老老实实用URL地址加载。6.6 课程展示视频与H5多域名问题课程介绍视频的播放我用了video组件src直接指向CDN上的mp4文件。涉及两个域名的问题如果视频和接口分别部署在不同域名需要在微信公众平台后台配置downloadFile合法域名和request合法域名。还有一类常见需求也常被搜索封装H5时要指向两个不同域名接口。我的处理方法是请求层里读取一个环境配置变量区分API域名与资源域名若H5页面与后端跨域则后端开启CORS并允许指定域名。有一类比较隐蔽的问题是在小程序中播放视频时Android上容易出现自动全屏而iOS不会这其实和视频编码格式有关。建议视频统一转成H.264编码的mp4不要用其他奇葩编码兼容问题会少很多。7. 排错实战抓包调试、反向排查与性能优化7.1 小程序接口调试从开发者工具到真实设备抓包微信小程序开发过程中开发者工具里可以方便地看到网络请求但真机预览时很多问题无法直接看到网络层信息。我在联调阶段最常用的工具组合是微信开发者工具的Network面板加Charles或Fiddler抓包代理。真机设置代理后HTTPS请求还需要安装证书并做SSL代理。这里的坑在于小程序内部的request请求域名必须已经在小程序后台登记并在代码里配置了合法域名否则真机上直接报fail连请求都发不出去。调试时我习惯先用开发者工具把接口调通再切真机验证。很多人卡在真机上请求失败却看不到原因时我建议先用抓包工具看看是不是证书问题还是域名白名单问题。这两个占了真机请求失败的八成原因。7.2 预约接口的并发压测与慢查询优化还没有真正上线前我就用并发工具对预约接口做过一轮压测。200并发同时提交预约同一节课程发现两个问题数据库连接池被打满导致大量请求排队超时部分请求返回了系统异常而不是明确的「名额已满」提示第一个问题通过把数据库连接池上限调大并加Redis缓存做前置判断解决——先检查Redis里的实时名额若已满直接返回业务错误不压数据库。第二个问题是因为MySQL默认事务隔离级别是REPEATABLE READ高并发下条件更新虽然不会超卖但在极少数场景下会出现锁等待超时默认锁等待50秒。我把限流和锁等待超时时间调整到5秒加上代码里把事务尽量缩短只保留必要SQL整体压测通过率明显改善。性能优化上还有一条经验课程列表页默认一次返回20条数据每条附带教练名和当前剩余名额。为了减少几十个教练名的重复查询我一次性把教练数据放入Redis缓存失效时间5分钟。首页接口响应时间从原来的600ms降到了150ms左右。前端再配合骨架屏和懒加载用户体感基本是秒开。7.3 安全加固防刷、参数校验、越权与幂等做预约系统安全这块也必须提几句。接口防刷。预约操作属于高频敏感操作我按会员ID做了简单的限流——同一用户1秒内最多提交2次预约请求。微信登录接口和短信验证码接口同样做了IP维度的限流。不用引入太重的组件Redis计数加过期时间即可。参数校验。后端必须对前端传来的每个字段都做合法性校验比如schedule_id必须是正整数status必须是白名单内的枚举值。尤其注意openid和member_id不能由前端传入必须从token中解析——否则用户可以传别人的member_id直接越权操作他人账号的预约这是最典型的水平越权漏洞。幂等控制。用户连续点击预约按钮前端防重复点击可以拦一部分但后端也必须做幂等。我为每个预约请求生成一个request_id由前端产生UUID后端在Redis里以request_id为key做SETNX若已存在说明是重复提交直接返回上一次处理结果。这个设计上线后至少消除了九五成以上的重复预约订单。8. 部署上线与持续迭代的心得项目上线前的部署步骤简单梳理一下。服务器用的是一台4核8G的云主机Nginx PHP-FPM MySQL 8.0 Redis 6.0的组合。两个PHP服务各跑一个PHP-FPM pool通过不同域名区分入口。小程序API域名配了HTTPS证书Nginx全部301跳转HTTPS。MySQL每天凌晨自动备份到OSS保留最近30天。上线前还做了一份检查清单向客户交代清楚小程序后台配置合法域名request域名和downloadFile域名分开支付商户号与小程序绑定支付回调地址必须是HTTPS且公网可达订阅消息模板申请通过后才能用于开课提醒测试号与正式号彻底切换不能混用教练端、管理后台的账号权限灰度分配上线之后系统运行平稳但也少不了持续迭代。目前客户又提了几个扩展方向积分系统约课签到给积分、积分换礼品、次卡与时间卡的组合计费逻辑、私教一对一预约按教练时段划分、多门店管理和课程直播回放。这些需求新老系统都能很好支持因为当初的架构边界很清楚预约核心无论怎么变无非是调整排期的资源模型而付费规则、营销玩法这些都只是在外围加模块。最后分享一个实际运营中的小经验这个系统上线后会员取消再约的现象比想象中频繁得多。开课前两小时是取消高峰而前一天的晚上十点则是预约高峰。针对这个规律我在预约高峰期前预热了课程列表缓存在取消高峰后增加一次数据库统计任务确保第二天早上的剩余名额展示准确。这种排期运营的细节不是在写代码时能预判到的而是真正跑起来之后才慢慢领悟到的。作为一个从微信群接龙一路做到现在稳定支撑上千会员在线预约的系统这个项目让我最强烈的体会是技术选型、框架争论、代码风格最后都不是项目成败的关键。真正决定系统好不好的是预约逻辑是否严谨、数据是否是实时的、会员和管理员用得是否顺手。小程序端一个按钮位置的调整可能比后端一百行优化更让客户满意。如果这篇复盘能帮到正在做同类预约选课系统的朋友那也算值得了。