春节前一位开台球室的朋友找我,说要做一个台球室预约小程序。他那边35张球桌,高峰期电话不断,手写登记本翻得稀烂,散客到店经常要等一两个小时。我花了两周把整套台球室预约平台搭起来:小程序端给顾客选球台、选时段、在线付押金下单,后台给他自己管理球台、时段、会员和订单。这条路走下来有收获也有不少坑,我用这套台球室管理系统的完整过程写下来,从业务拆解到上线运营都讲清楚,同行或者准备做预约类小程序的都可以参考。
1. 为什么台球室需要一套预约系统
1.1 球房运营的真实痛点
先拆业务。台球室和其他休闲业态不太一样,它有一个很特殊的地方:球台是一种“按时段独占”的资源。一张球台在某个小时被人占了,其他顾客就没法用。高峰期大家都在门口等着,散客等一小时是常事,而电话预约又经常被高峰期的铃声淹没,忙起来根本记不住。老顾客打电话订台,服务员抄在本子上,抄错、漏记、重复订台都发生过。到了现场发现台被人占了,顾客当场翻脸,口碑一下就没了。
从顾客角度看,痛感也很直接:电话经常打不通,就算打通了,也没法直观看到现在哪张台空着、接下来两个小时能不能订。到了店才发现要排队,半天时间就耗在等待上。这类消费本身就是到店前的“计划型消费”,顾客愿意提前预约,只是没有靠谱渠道。
所以当时我给朋友做的第一件事,不是写代码,而是把预约业务链路上所有人、所有节点理清楚。一套合格的台球室预约系统,至少要考虑三类角色:顾客、前台店员、老板(管理员)。顾客要能快速选台、支付、查看订单;店员要能核销、换台、处理超时;老板要能设置球台价格、不同时段的不同计费策略、查看每天的满台率、非高峰期的空台率。只有这三块都打通,系统才算真正用起来。
1.2 预约业务全链路拆解
我把整个预约链路拆成八步:
- 顾客打开小程序,看到球房今天可用球台的实时状态。
- 选择一个球台,再选择预约日期和时间段。
- 系统检测这个球台在该时间段是否空闲,如果被占则提示换时段。
- 顾客支付定金或全额费用,生成预约订单。
- 到店后店员用管理端确认核销,订单状态变为“使用中”。
- 顾客结束打球,系统按实际时长/局数结算,返还剩余押金或发起补扣。
- 订单转为“已完成”,进入数据统计。
- 老板在后台看每天的营收、球台使用率、高峰时段。
这个链路里最容易出问题的点是第3步和第6步。第3步涉及并发冲突,两个顾客同时看到一张空闲台,同时下单,处理不好就会出现“超卖”。第6步涉及计费规则,预约按小时计费,实际打球超时了怎么办,必须在一开始就想清楚。
后面的章节我会顺着这条链路,把技术实现和踩坑细节一个个展开。先把技术选型和页面结构定下来,再讲核心逻辑,最后讲调试和上线。
2. 小程序端整体设计与技术选型
2.1 技术栈选择:uniapp还是原生
这个项目用的是uniapp + Vue3的组合。当时在原生微信小程序和uniapp之间权衡了很久,最终选uniapp主要有两个原因:一是后续客户可能还要抖音小程序、支付宝小程序,一套代码多端编译能省不少事;二是我自己更熟悉Vue的写法,模板语法、组件化、状态管理都顺手,开发效率明显高于原生小程序的wxml + js。
但uniapp不是没有坑。最明显的就是打包后的代码体积比原生大,因为框架本身要带运行时。后面做分包加载的时候,比原生小程序要更注意静态资源和页面拆分。还有一个细节:uniapp里很多内置组件的样式在不同端表现不一致,比如单选框(radio)在微信和H5上的视觉差异就很大。我们的球台选择页面用了自定义卡片式单选,纯靠CSS控制,避免依赖原生radio组件的差异。
如果你不想引入框架,直接用微信开发者工具写原生小程序也完全可行。这个项目的核心是逻辑和业务设计,不是框架。原生小程序在真机调试、调试器报错信息上反而更直接。选哪个不重要,重要的是先把页面结构定下来。
2.2 核心页面与路由设计
小程序端我规划了四个主页面:
- 首页:球台列表,展示球台编号、位置、类型(中式八球/斯诺克/九球)、当前状态(空闲/使用中/已预约)、接下来的可约时段。
- 预约页:从首页点进某张球台后进入,选择日期、开始时间、时长,系统实时显示“可约”还是“冲突”,确认后生成订单。
- 订单页:我的预约列表,状态分待支付、已预约、使用中、已完成、已取消。列表用分页加载,滚动到底部加载下一页。
- 我的页面:用户信息、会员卡、优惠券、联系客服、设置。
路由上采用微信原生的tabBar模式。首页和订单页放tabBar,预约页作为普通页面压栈进入。订单页用到了微信小程序“页面列表加载更多”的经典玩法:onReachBottom触发下一页加载,配合isLoading锁和hasMore标记,避免重复请求。
有一点要提前说:tabBar页面在微信里最多5个,我们四个正好。如果后面要加分销、积分商城,就得考虑分页跳转或者把tabBar替换掉。
2.3 状态管理与球台实时状态同步
台球室预约是一个强状态系统。球台的“空闲/预约/使用中/维护中”状态,白天的每一分钟都在变化。如果只靠前端本地状态,两个手机打开同一个页面,看到的球台状态会不一致。
我的做法是:球台状态尽量由服务端实时计算,不在前端存副本。前端拉取球台列表时,服务端根据当前时间和所有有效订单实时计算每张球台的状态,返回给前端。比如现在18:30,球台3号有一笔17:00-19:00的预约单,服务端就返回“使用中”;如果下一笔预约从19:30开始,前端会在19:30到来后主动刷新一次。
前端的状态管理(用的Pinia)只存三样东西:用户登录态、当前选中的球台和时间段、全局门店配置。不要把所有球台状态都塞进store里,因为那部分数据本身是动态的,缓存反而容易变成脏数据。
3. 预约的核心逻辑与计费规则
3.1 时间段冲突检测
这是整个预约系统最核心的算法,没有之一。
一张球台在某个日期下,所有有效预约记录组成若干个时间段。新来的预约要判断[newStart, newEnd)是否和已有时间段重叠。判断重叠的逻辑很简单:两个区间[a, b)和[c, d)不重叠,当且仅当b <= c或者d <= a。反过来,只要newStart < existingEnd并且newEnd > existingStart,就是冲突。
我用一个例子说明。球台5号已经有两条预约:10:00-11:00、14:30-16:00。现在要抢的是13:30-15:00。用代码走一遍:
const existing = [ { start: '10:00', end: '11:00' }, { start: '14:30', end: '16:00' } ]; const newStart = '13:30'; const newEnd = '15:00'; const hasConflict = existing.some(slot => { return newStart < slot.end && newEnd > slot.start; }); // true,因为 13:30 < 16:00 且 15:00 > 14:30前端用这个逻辑在用户拖动时间时实时提示冲突。但真正防超卖要靠后端。后端生成订单时,不能只靠查询判断,因为两个请求可能同时查都认为没冲突。稳妥做法是两张表配合:预约订单表里加一个联合唯一索引(table_id, date, start_time, end_time),插入时数据库直接拒绝重复。同时还可以在事务里给该球台当天记录加锁,或者用Redis的分布式锁,锁的key设为table:{id}:{date}。
对于小店(比如一天预约单不超过300条),数据库唯一索引就够了。我实际用的是“先查后插+唯一索引兜底”,简单可靠,没有引入Redis,维护成本也低。
3.2 计时计费与会员储值设计
预约收费我设计成两种模式:预付定金和全额预付。定金模式适合散客,预约时先付10元定金占位,到店后按实际时长再结算。全额预付适合节假日高峰,比如春节期间球台非常抢手,先用全额锁定,放鸽子的成本由顾客承担。
计费单位我统一用“半小时一档”。比如标准台20元/小时,那么0.5小时就是10元。用户选时间时,时间选择器的步长就是30分钟,避免出现“1小时17分钟”这种没法计价的情况。
会员储值这块做了一个简单的余额体系。会员卡本质就是用户表加一个balance字段,充值100送20、充300送80这类规则配置在管理端。用户用余额支付时,前端展示余额和应付金额,下单时后端做一次余额扣减。
这里有个容易忽略的坑:余额扣减和订单创建必须在同一个事务里,否则可能订单创建成功但钱没扣,或者钱扣了但订单没生成。我用的事务顺序是:开启事务 → 锁用户余额 → 判断余额充足 → 扣减 → 创建订单 → 提交事务。如果中途失败,全部回滚。
3.3 预约状态流转与超时释放
订单状态我用数字表示:0待支付、1已预约、2使用中、3已完成、4已取消、5已过期。
超时释放是预约项目的生命线。散客预约了19:00-20:00,但19:15还没到店,这张台一直空着,其他想玩的顾客被耽误了。我的规则是:预付定金的订单,超过预约开始时间15分钟未核销,自动标记为“已过期”,释放球台;如果顾客后来到店,可以用未消费定金顺延预约其他空档。全额预付的订单不自动释放,因为钱已经收了,除非顾客主动取消。
自动释放不能只靠小程序端定时器,小程序在用户退出后就不会运行了。我在服务端写了一个每分钟执行的定时任务,扫描所有“已预约”且“开始时间早于当前时间-15分钟”的订单,批量改成“已过期”。这个任务用Node.js的cron表达式调度,部署到服务器后我实测了几天,凌晨的释放任务也没有误伤过正常订单。
4. 微信登录、手机号获取与后台管理
4.1 微信登录换取openid的完整流程
每个微信小程序用户都对应一个openid,这是用户在小程序内的唯一标识。流程不复杂:
- 前端调用 wx.login 获取临时code。
- 前端把code传给后端接口 /auth/login。
- 后端用code调用微信的 jscode2session 接口,换取 openid 和 session_key。
- 后端用openid查用户表,新用户自动注册,老用户直接返回。
- 后端生成自己的登录态token(我用的JWT,有效期7天),返回给前端。
- 前端把token存到uni.setStorageSync,后续请求都带上。
有个细节必须注意:openid和session_key绝不能让前端拿去做判断。session_key是微信端解密的密钥,一旦泄露,前端就可以自己解密用户敏感数据,风险很大。我后端拿到session_key后直接丢弃,只在需要解密手机号时临时使用。自建token的过期和刷新也建议做,我们的token是7天有效期,过期后前端拦截器检测到401,自动调用refresh接口重新登录,用户无感续期。
登录态是接口安全的根基。很多新手在这步图省事,把openid直接作为登录凭证存本地,这是非常危险的。
4.2 手机号快速验证组件接入
微信小程序的“获取手机号”以前可以用wx.getUserProfile拿到手机号,但后来微信把玩法改了,现在主流做法是用手机号快速验证组件。
接入步骤很简单:页面上放一个button,加上open-type="getPhoneNumber":
<button open-type="getPhoneNumber" @getphonenumber="getPhoneNumber">微信一键登录</button>用户点击授权后,bindgetphonenumber事件回调里会返回一个code,拿着这个code去后端调微信接口换取真实手机号。注意这里的code是动态的,一次性使用,有效期只有5分钟,而且只能用一次。
这里有一个很关键的坑:这个组件不是所有小程序都能用,要求小程序已完成微信认证(主体是个人开发者不行),并且在微信公众平台后台开通“手机号快速验证组件”权限。如果你接的时候发现按钮点了没反应,先检查这两项。开发调试阶段,微信开发者工具里可以用测试号模拟,但拿到真机微信上跑,没有权限就是不行。
我当时卡在这里一个多小时,后来才发现是后台权限没开。这个权限申请审核很快,一般当天就能下来。
4.3 后台管理端的页面规划与接口设计
管理端我做成一个H5后台,管理员直接扫二维码打开网页登录,不用装APP。
页面就五块:工作台、球台管理、订单管理、会员管理、设置。
工作台放今日数据:营收、订单数、满台率、正在使用的球台。球台管理负责增删球台,给每张台设置类型、时价、照片。订单管理支持按日期/状态筛选,是前台客诉和后台对账的核心页面。会员管理查储值余额、充值记录。设置里配置营业时间、预约提前量和最小预约时长。
接口设计遵循一个原则:前端小程序和管理端共用一套后端API,但用不同的权限控制。小程序端用的是用户token,只能操作自己的订单;管理端用的是管理员token,可以操作全局数据。接口鉴权用中间件统一处理,比如创建订单的接口校验用户身份,核销接口校验管理员身份。
预约列表这种接口我用了最简单的分页格式:请求参数带page和pageSize,返回数据带list和hasMore。列表页在小程序端用onReachBottom加载更多,管理端用按钮点击加载更多。两个端共用同一套分页约定,后端代码只写一次。
5. 开发调试实战:抓包、体积与导航栏适配
5.1 用代理工具排查登录接口问题
小程序真机调试时,看不见网络请求是很多人的痛点。电脑上可以用微信开发者工具直接看Network面板,但真机预览时,开发者工具里模拟的请求和真机上跑的不完全一样。卡在登录、支付这类接口问题时,抓包是最管用的排查手段。
我用的是Charles,一套很成熟的HTTP代理抓包工具。基本流程是:电脑上安装并打开Charles,记下电脑的局域网IP和默认的8888代理端口;手机和电脑连同一个WiFi,在手机WiFi的高级设置里找到HTTP代理,填上电脑IP和8888端口。手机第一次连代理时,需要下载并信任Charles的根证书,这样才能解密HTTPS请求。
完成之后,手机上打开小程序,Charles里就能看到这个小程序发出的每个请求,包括请求URL、请求头、请求体和返回体。登录接口返回401、token失效、参数格式不对,看一眼返回体就清楚了。排查完记得把手机的代理关掉,不然手机上其他App的网络请求会走电脑代理,容易误伤别的服务。
这里补充一个微信小程序的限制:正式环境下,小程序请求的域名必须在小程序后台配置为合法域名,且必须是HTTPS。开发调试阶段可以直接在工具里勾选“不校验合法域名”,但真机调试阶段如果域名没配好,抓包看到的第一个问题就是request fail,这个也属于常见坑,放后面问题清单一起说。
5.2 导航栏动态高度适配
自定义导航栏是台球室预约小程序的外观刚需。默认导航栏的标题只能是固定的文案,顶多动态改个标题;但很多球房想让顶部栏和自己的品牌色、店招图片融为一体,那就得自定义导航栏。
自定义导航栏第一个问题就是顶部高度怎么算。不同手机的刘海屏、灵动岛、状态栏高度都不一样,不能写死。我用的是微信官方推荐算法:
const { statusBarHeight } = uni.getSystemInfoSync(); const menuButton = uni.getMenuButtonBoundingClientRect(); const navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height;简单解释一下:statusBarHeight是手机顶部状态栏高度;menuButton是右上角胶囊按钮的位置信息。自定义导航栏的高度等于胶囊按钮所在区域的高度,这个公式计算的是胶囊按钮的垂直居中位置偏移,实测在绝大多数手机上都很准,iPhone和小米、华为我都验证过。
做自定义导航栏时还有个隐藏问题:页面滚动时自定义标题栏要固定在顶部,不能跟着页面滚走。实现上用position: fixed + 占位div,占位div高度等于导航栏高度,否则页面内容会被导航栏遮住。我在这上面吃过亏,第一次做的时候忘了加占位,内容从顶部直接钻到导航栏下面去了。
5.3 分包加载,解决2MB体积限制
微信小程序的代码包有2MB的主包限制。uniapp打包出来的工程很容易超,我的项目第一次打包就报错,提示source size 2612kb exceed max limit 2mb。
解决思路就是分包:小程序允许把页面拆分到分包里,主包只保留启动页、tabBar页面和公共资源,分包里的页面在用户访问时按需加载。
在uniapp里分包很简单,pages.json按官方结构配置:
{ "pages": [ "pages/index/index", "pages/order/order", "pages/mine/mine" ], "subPackages": [ { "root": "pagesSub/book", "pages": [ "book", "pay-success" ] }, { "root": "pagesSub/ticket", "pages": [ "my-coupon" ] } ] }把预约下单、支付成功页、优惠券页这些不常直接打开的页面拆分到分包里,主包体积立刻降到1MB左右。
除了分包,体积瘦身还能做三件事:图片不要放本地,全部传CDN换URL;公共组件尽量复用,不要每个页面复制一份;第三方UI库能不用就不用,uniapp自身的组件够用,UI库全量引入通常是最占体积的元凶。我项目里没用任何第三方UI库,所有列表、卡片、弹窗都是自己写的样式,视觉统一还省体积。
但分包也有坑:tabBar页面不能放分包里;分包之间不能互相跳转,跳转要写全路径;分包里不能有公共的全局自定义组件(除非重复引用)。还有包内的静态资源也会算体积,本地图片是整个分包最大的拖累。
5.4 门店信息动态配置与接口错误排查
球房老板经常会改门店名称,小程序端标题不可能跟着每个版本发。我在小程序首页onLoad时先调用门店配置接口,拿到店名后调用uni.setNavigationBarTitle({ title: name })动态设置导航栏标题,这样后台改一下名称,用户端刷新后就变新名字,不用重新提审。
同样需要动态控制的是页面的分享文案,用onShareAppMessage动态返回title和imageUrl,把“XX台球俱乐部·在线预约”作为默认分享文案,地推和裂变都靠这个。
接口报错排查我总结了固定套路。先看小程序的Network面板或Charles里响应状态码:401是token过期,先检查登录态;500优先看后端日志;4xx一般参数问题多,比对请求参数和后端路由参数名。微信相关的错误码,比如登录换取token时返回的code无效,那就是wx.login返回的code过期了(有效期5分钟),或者被使用了两次。遇到这类问题别急着改代码,先把微信返回的原始报错信息完整贴出来再定位。
6. 上线后的运营迭代与复盘
6.1 数据统计维度设计
系统上线不是终点,运营数据才是检验系统好坏的标尺。后台我加了几个关注维度:每日/每周的营收曲线、球台使用率、高峰期分布、取消率和过期率。
取消率是最值得盯的指标。如果每天取消率超过15%,说明预约门槛太低,顾客把预约当免费占位,应该提高定金或者缩短免费取消时间。过期率则反映了到店履约情况,过期率高说明前台核销不及时,顾客可能已经到店被店员手工安排了但没在小程序里操作,超过15%就要给店员做培训。
这些统计在后端一张日汇总表里,每晚定时任务算好存进去,第二天打开管理端直接看,不用实时聚合。实时聚合数据量大了以后会拖慢数据库,夜间离线计算更稳妥。
6.2 微信开发者工具的分发试用与反馈迭代
开发完第一版后,我没有直接发布到线上,而是先在微信开发者工具里把小程序工程导出成体验版二维码,发给店长、店员和一个老顾客群试用。微信开发者工具支持“上传体验版”功能,上传后生成二维码,最多可以配置几十个体验成员,扫码即可进入真实环境操作,方便收集试用反馈。
这一周收集到的反馈真实有效。店员提得最多的是核销操作太繁琐,要找到订单再点核销,后来我把首页直接放了一个“扫码核销”入口,顾客出示预约二维码,店员扫码就能定位到订单,操作从三步变成一步。顾客反馈最集中的是找不到球台编号,因为台球室灯光偏暗,首页列表的球台位置信息不清楚,后来在球台列表加了“近门口/靠窗/最里面”的位置说明,反馈明显减少。
用体验版的真实反馈迭代两个版本,再提交审核正式发布。这个流程走得越稳,上线后翻车概率越低。
6.3 避坑清单
最后整理一份从开发到上线完整踩过的坑,每一条都是真金白银换的:
- 预约冲突检测一定要“前端提示 + 后端唯一索引”双重保障,只靠前端判断一定会出超卖。
- 手机号组件需要企业认证小程序,个人主体项目在接手机号接口前先确认主体类型,别等打包上线才发现。
- 自定义导航栏高度不写死,用胶囊按钮位置动态计算;fixed定位别忘了配占位元素。
- 预约自动释放用服务端定时任务,不要指望小程序端定时器。
- 主包超2MB直接分包,不要在压缩图片上死磕,图片本身就该走CDN。
- 抓包排查完记得关手机代理,不然其他App请求全走电脑,轻则卡顿,重则误耗流量。
- 体验版测试一定要用真机,开发者工具模拟器和真机在手机号授权、支付回调上的表现差异很大。
我个人在这套台球室预约平台上最大的体会是:预约类系统的核心不在界面多炫,而在状态一致性。球台空闲、订单冲突、余额扣款,每一处都牵扯到并发和数据正确性。把业务链路拆透,把并发兜底做扎实,上线后才能真正稳得住。后面如果要做多人拼场、分段AA计费,或者接入台球计费系统的智能灯控,这套预约骨架也还能继续往上长。