简介:这是一套面向台球场馆数字化运营的全功能后端源码,专为中小型台球馆管理者、PHP开发者及小程序对接工程师设计,解决客户管理混乱、收银效率低、会员服务割裂、线上预约缺失等典型业务痛点。资源共388个文件,主体为354个PHP后端逻辑文件(含CRM用户模型、收银事务处理、小程序API接口及星球模块服务层),辅以12个.gitignore等版本控制配置、3个JS前端交互脚本、以及.crt/.pem证书文件支撑安全通信,整体压缩包仅911KB,轻量易部署。已有327人学习下载,适合希望快速落地台球行业SaaS化管理的开发者。源码结构清晰,内置Laravel风格artisan命令行工具、.env.example环境配置模板、composer.json依赖清单及readme.txt部署指南,涵盖台位智能预约、电子会员卡、积分营销、赛事活动管理等核心业务模块,并提供完整RESTful接口支持小程序端调用,开箱即用。
接手台球厅系统改版,我用PHP+JavaScript把CRM、收银、小程序和会员星球串成了一套后端
去年下半年接了一个台球厅的系统改版项目,标题写得很长——"基于PHP和JavaScript的CRM+收银+小程序+星球新改版台球后端设计源码"。当时第一反应是:这不就是个收银加会员管理的小项目吗?但真正把需求理完之后,我发现这个场景比想象中复杂得多。台球厅看起来经营模式简单,实际上同时牵扯到计时计费、桌台状态流转、会员等级折扣、商品销售、线上预约、社群运营,还有一堆日常营销活动。
这套系统的设计核心,是用PHP做后端业务逻辑,JavaScript承担小程序端和管理后台的交互逻辑,再通过一套标准的接口协议把CRM(客户管理)、收银(订单计费)、小程序(C端入口)、星球模块(会员私域内容)全部打通。本文把我实际改造过程中的技术选型、架构设计、关键模块的落地方式,以及踩过的坑一次说清楚。适合正在做门店数字化系统、或者准备从单一收银系统向"线上+线下+私域"一体化转型的开发者参考。
1. 项目整体设计与思路拆解
1.1 台球厅经营管理到底缺什么
台球厅这个生意,表面看就是出租球桌,按小时收费。但真正经营起来,老板要面对的问题很具体:怎么防止老客户流失?怎么让客户在非高峰时段也愿意来?怎么让客户充值的钱变成长期锁客的工具?还有就是会员的数据散落在收银机、Excel甚至老板的脑子里,根本没法做精准营销。
传统单机版收银系统能解决"算钱"的问题,但完全解决不了"管人"和"做运营"的问题。客户今天来打了两小时球,消费信息记在收银系统里,然后呢?没有然后了。你不知道他喜欢打美式还是斯诺克,不知道他习惯下午来还是晚上来,更没法在他两周没来的时候推一张优惠券把他拉回来。这就是CRM系统在台球场景下的价值:把每一次消费、每一次充值、每一次预约,变成客户档案的一部分,让经营者能看到完整的客户生命周期。
另一个痛点是私域流量的承载。"星球"这个模块,说白了就是会员的私域社群,门店可以把赛事公告、新手教学、优惠活动发在里边,会员可以在里边约球、晒战绩、攒积分。没有这个模块之前,台球厅的社群可能就是一个微信群,消息半天就被刷没了,活动报名靠接龙,积分靠手工记录,运营成本极高。
1.2 四位一体的系统架构
这次改版的核心目标,就是把四个原本割裂的模块装进同一套后端系统里。我把整体架构拆成四个层次来看:
**CRM层(客户关系管理)**负责会员档案、等级体系、标签画像、储值余额、积分流水。它解决的是"客户是谁"的问题。
**收银层(交易闭环)**负责桌台计时、商品售卖、订单结算、优惠核销。它解决的是"钱怎么收"的问题。
**小程序层(C端入口)**负责扫码开台、在线预约、会员中心、历史账单查询。它解决的是"客户怎么自助"的问题。
**星球层(私域运营)**负责内容发布、活动报名、积分任务、公告推送。它解决的是"怎么让客户持续来"的问题。
这四个模块不是四条独立的线,而是共享同一套会员数据、同一套订单数据、同一套积分资产。CRM里产生的标签会指导营销推送;小程序里的预约会自动生成待结算订单;收银台的结算结果会反写进会员消费记录;星球里的活动报名又能带动新一轮的预约和消费。这样一套闭环设计,才是"新改版"和"老系统"之间最本质的区别。
1.3 新改版要解决的老问题
改版之前,这套系统的问题非常典型。第一是各模块数据不通,收银数据在本地数据库,小程序端是另一个外包团队做的,会员数据两边各存各的,经常出现小程序上看到余额和门店端不一致的情况。第二是代码结构乱,PHP文件里直接写HTML,SQL语句拼在业务逻辑里,改一个功能要动三个文件。第三是计费逻辑比较死板,只能按整点计费,到了非整点就不知道怎么处理,更别提会员折扣、时段优惠、节假日调价这些复杂规则。
改版的时候我列了一个优先级清单:先统一数据模型,把"会员、桌台、订单、商品"这四个核心域的数据结构重新设计;再重写收银计时引擎,把计费规则做成可配置化;然后才是小程序端的重构和星球模块的新增。整个改版过程花了大概两个月,其中数据库设计和计费引擎的改造占掉了将近一半时间。事实证明,这两个部分也是最容易出问题的地方。
2. 技术选型与核心架构解析
2.1 为什么用PHP打底
这套系统的后端主语言是PHP,老代码用的是ThinkPHP 3.2.3框架。改版的时候团队内部其实讨论过要不要换语言,比如用Java或者Go重写。但评估下来有几个现实因素让PHP依然是更合理的选择:
第一,台球厅系统的业务逻辑并不复杂,核心是CRUD加计费计算,PHP的开发和部署效率在这种体量的项目上优势明显。第二,老系统积累了完整的数据库结构和业务数据,直接迁移比推倒重写风险低得多。第三,市面上能招到的PHP开发者数量多,后期维护成本可控。第四,PHP的生态里,ThinkPHP、Laravel这类框架足够支撑门店级系统的并发需求——一家台球厅同时在线用户撑死几百人,远没到需要考虑高并发架构的级别。
我保留了ThinkPHP框架做为主体,但做了一次渐进式重构。把所有业务逻辑从控制器里拆出来放到Service层,SQL全部换成查询构造器或者模型关联,接口输出统一走一个响应函数。这套重构完成后,后续加功能、改计费规则都轻松很多。
2.2 JavaScript在小程序和管理后台中的分工
JavaScript在这套系统里承担两个角色:一个是微信小程序端的核心开发语言,一个是管理后台前端(也就是门店收银和运营人员用的页面)的交互逻辑。
小程序端我用了原生小程序语法,没有引入uni-app或者Taro这类跨端框架。原因很简单:这个项目只需要微信小程序,不需要额外兼容支付宝或者抖音小程序。原生语法在包体积、组件调用、性能调优上都有优势,还省去了编译链路的排错成本。再说微信小程序的API本身就够完善,扫码(wx.scanCode)、定位(wx.getLocation)、支付(wx.requestPayment)、订阅消息(wx.requestSubscribeMessage)都是现成的能力。
管理后台的前端用的是Vue 2加Element UI。选Vue的原因主要是它和原生小程序的开发思路有相似之处(数据绑定、组件化),团队转型成本低。管理后台的核心页面包括收银台、桌台状态面板、会员列表、商品管理、活动配置和星球内容管理。
2.3 接口设计与数据返回规范
既然确定了前后端分离的架构,接口设计就成了最重要的约束。我定了一套统一的数据返回格式:
// 公共响应函数 function apiReturn($code = 0, $message = 'success', $data = []) { header('Content-Type: application/json'); echo json_encode([ 'code' => $code, 'message' => $message, 'data' => $data ]); exit; } // 成功返回 apiReturn(0, 'success', ['order_id' => 10086]); // 失败返回 apiReturn(1001, '桌台已占用', null);这套格式的好处是前端拿到任何接口的返回都能统一处理。code为0表示成功,非0表示业务异常,message给用户看提示,data装业务数据。HTTP状态码我故意没有过度依赖,因为有些微信小程序的基础库对非2xx的响应处理不太友好,统一用200返回再在业务层里判断code,反而更省心。
关于PHP数组对象的问题,这里特别提醒一下:接口返回时,如果数据是纯数组,json_encode出来就是JSON数组;如果是关联数组或者对象,json_encode出来的就是JSON对象。前端JS拿到后,如果判断不出来,可以用Array.isArray()来区分。
2.4 数据库核心表设计思路
数据库是这套系统的地基,表结构设计不好后面全是坑。我整理了五张核心表,它们是整个系统的骨架:
| 表名 | 核心字段 | 说明 |
|---|---|---|
| member | id, openid, name, phone, level_id, balance, points, source | 会员主表,存基础信息和资产 |
| table_info | id, name, type, hourly_rate, status, location | 桌台信息表,status区分空闲/占用/清洁 |
| order_info | id, order_no, member_id, table_id, open_time, close_time, total_fee, discount_fee, status | 订单主表,记录每一次开台到结账 |
| order_item | id, order_id, goods_id, goods_name, price, count | 订单明细表,记录酒水商品消费 |
| member_level | id, level_name, discount, min_points | 会员等级表,和member做关联 |
除此之外还有积分流水表、充值记录表、优惠券表、活动表、星球内容表等辅助表。整体设计的核心原则是:订单永远关联会员ID,没有会员也至少用openid或者手机号关联,这样每一笔消费都能回溯到客户,CRM才有数据可用。
3. 核心模块实操:从CRM到收银到小程序
3.1 CRM客户管理模块
CRM模块的落地,我重点做了三件事:会员等级自动升降级、消费行为标签、储值余额管理。
会员等级设计得很简单,四级体系:普通会员、银卡、金卡、黑卡。等级表里配置了对应的折扣率:普通会员不打折,银卡9.5折,金卡9折,黑卡8.5折。等级不靠手动改,而是根据"累计充值金额"或者"累计消费积分"自动触发。这块逻辑我放在了Service层:
class MemberLevelService { public function refreshLevel($memberId) { $member = MemberModel::find($memberId); $totalRecharge = RechargeModel::where('member_id', $memberId) ->where('status', 1) ->sum('amount'); // 按累计充值金额自动匹配等级 $level = MemberLevelModel::where('min_recharge', '<=', $totalRecharge) ->orderBy('min_recharge', 'desc') ->find(); if ($level && $member['level_id'] != $level['id']) { $member['level_id'] = $level['id']; $member->save(); } return $level; } }消费行为标签是CRM里比较出效果的功能。我根据订单表的数据,给会员自动打上标签,比如"经常晚上来"(订单open_time在18点之后的比例超过70%)、"喜欢打斯诺克"(关联的桌台类型为斯诺克的比例最高)、"两周未到店"(最近一次订单时间距今天数大于14天)。这些标签在运营侧非常有用,运营人员可以直接筛选出"标签=两周未到店"的会员群发优惠券,拉活效率比盲目群发高很多。
3.2 计时收费与桌台状态管理
收银模块是台球厅系统的核心,也是最容易出Bug的地方。计费逻辑再强调一遍:台球按小时收费,但客户很少恰好打满整数小时,所以计费规则必须支持"向上取整"或者"按分钟计费"两种模式,而且这个规则必须可配置。
我设计了一套计费引擎,核心逻辑是:开台时记录open_time,结账时根据当前时间和桌台类型计算应收费。支持三种计费模式:按小时向上取整(不足1小时按1小时)、按分钟计费(精确到分钟)、包时段计费(比如夜间通宵场)。
public function calculateFee($tableId, $orderId) { $table = TableModel::find($tableId); $order = OrderModel::find($orderId); $openTime = strtotime($order['open_time']); $closeTime = time(); $minutes = ceil(($closeTime - $openTime) / 60); $fee = 0; if ($table['billing_mode'] == 1) { // 按小时向上取整 $hours = ceil($minutes / 60); $fee = $hours * $table['hourly_rate']; } elseif ($table['billing_mode'] == 2) { // 按分钟计费 $fee = $minutes * ($table['hourly_rate'] / 60); } // 如果有关联会员,计算折扣 $memberId = $order['member_id']; if ($memberId) { $member = MemberModel::find($memberId); $level = MemberLevelModel::find($member['level_id']); $discount = $level ? $level['discount'] : 10; $fee = round($fee * $discount / 10, 2); } // 叠加优惠券 $couponId = $order['coupon_id']; if ($couponId) { $coupon = CouponModel::find($couponId); $fee = max(0, $fee - $coupon['face_value']); } return $fee; }这段代码把订单表里的状态、会员折扣、优惠券叠加都处理了。实际上线后还补了一个"最小计费时长"的配置,比如开台不满半小时按半小时收费,这是门店老板提的需求,说有些客人来了打十分钟就走,按小时收又太贵,按分钟收门店亏,取了个中间值。
桌台状态管理我用了一个简单的状态机:空闲(0)→ 使用中(1)→ 清洁中(2)→ 空闲。开台时把状态改成使用中,结账后改成清洁中,保洁阿姨在后台点"清洁完成"再改回空闲。这个流程很简单,但非常实用,收银员能在后台一眼看出哪些桌子可以用,哪些正在打扫。
3.3 小程序端扫码开台与预约
小程序端的核心场景有三个:扫码开台、在线预约、会员中心。
扫码开台的流程是这样的:每张球桌上贴一个带参数的小程序码,客户用微信扫一扫进入小程序,带上桌台的ID参数(scene),小程序把table_id传给后端开台接口,后端校验桌台状态是空闲后,创建一笔订单并把桌台状态改为使用中。
public function openTable($memberId, $tableId) { $table = TableModel::find($tableId); if ($table['status'] != 0) { apiReturn(1002, '这个桌台当前不可用', null); } $order = OrderModel::create([ 'order_no' => 'TB' . date('YmdHis') . rand(1000, 9999), 'member_id' => $memberId, 'table_id' => $tableId, 'open_time' => date('Y-m-d H:i:s'), 'status' => 1 // 1进行中 2已结账 3已取消 ]); $table->status = 1; $table->save(); apiReturn(0, '开台成功', ['order_id' => $order['id']]); }这里有个细节:小程序码扫出来之后,用户如果还没登录,会先跳转到授权登录页,拿到openid之后先查这个openid有没有绑定手机号,没绑定的弹窗让用户留手机号。这一步非常关键,因为手机号才是CRM系统里识别客户的主要ID,openid只是微信侧的标识,换了手机或者换微信就找不回来了。
在线预约稍微复杂一点,涉及到时间段的冲突判断。我用的方案是后端在预约接口里做一次重叠区间查询:查该桌台在预约时间段内有没有status为1的订单,或者预约表中有没有pending状态的记录,有就提示用户换时间段。
会员中心就比较常规了,展示余额、积分、优惠券、消费记录。还有一个比较受门店欢迎的功能是"历史账单",客户能看到每次打球的时长、费用、消费明细,透明度高了之后,客户对储值的信任度会明显提升。
3.4 星球模块的内容与积分设计
"星球"模块是整个系统里最有运营想象空间的部分。它的定位是门店的私域内容社区,我参考了知识星球的形式,做了一个轻量版的"台球俱乐部星球"。
功能上包括:门店公告(教练排班、比赛通知)、技术教学(短视频和图文)、约球广场(会员发帖找球友)、积分任务。积分设计是星球模块的核心,我设定了几种积分获取方式:
- 首次完善资料:+50积分
- 每日签到:+5积分(连续签到第7天额外+20)
- 消费1元:+1积分
- 发帖被精华:+30积分
- 邀请好友注册:+100积分
积分可以兑换台时抵扣券、购买商品时抵现或者兑换球房周边。这个设计打通了星球内容和消费行为——客户为了攒积分会更频繁地打开小程序,打开就会看到门店的活动,从而带动消费。
技术实现上,星球内容表核心是content_id、member_id、content_type(公告/教学/约球/动态)、title、content、images、like_count、comment_count。点赞和评论各建一张表,用content_id关联。由于并发量不大,我直接用MySQL自增主键,没有引入Redis来缓存点赞数。不过在查询列表的时候,我用了PHP的分页查询加上在content_id上建索引,实测小程序端加载列表的速度在200毫秒以内,够用了。
4. 常见问题与排查技巧实录
4.1 PHP接口返回数组对象,前端拿到却是字符串
这个坑基本上每个做前后端分离的人都会遇到。接口返回的数据,有些字段在PHP里是整数,但前端拿到发现是字符串。比如member_id明明数据库存的是int,json_encode出来却变成字符串"10086"。
原因很简单:PDO的默认配置里,MySQL的int字段通过查询会返回字符串。解决方式是连接MySQL时设置PDO的ATTR_EMULATE_PREPARES为false,并且把ATTR_STRINGIFY_FETCHES设置为false,让MySQL驱动返回原生int类型:
new PDO($dsn, $user, $pass, [ PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_STRINGIFY_FETCHES => false ]);ThinkPHP里可以在数据库配置文件中加上:
'PDO' => [ PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_STRINGIFY_FETCHES => false ]这个配置加上之后,前端再判断金额、积分、余额这些字段时就不用担心类型不一致了。
4.2 JavaScript闭包与隐式转换的经典坑
在管理后台的前端代码里,我踩过最多的是闭包问题。最经典的就是循环里绑事件:
// 错误写法 for (var i = 0; i < tableList.length; i++) { $('#table_' + tableList[i].id).click(function() { openTable(tableList[i].id); // 所有点击拿到的都是最后一个值 }); } // 正确写法:用let替代var for (let i = 0; i < tableList.length; i++) { $('#table_' + tableList[i].id).click(function() { openTable(tableList[i].id); }); }var是函数级作用域,循环结束后i已经变成了最大值;let是块级作用域,每次循环都会生成一个新的绑定。这个特性在JavaScript的for循环闭包问题里属于必考点。
另一个比较隐蔽的是隐式转换。做会员筛选的时候,我发现了一个匪夷所思的问题:输入框里填了"100"积分,筛选出来一批积分为0的会员。排查下来发现是隐式转换的锅:把字符串和数值用==比较时,JavaScript会把两边都转成数值再比较。但如果是把空字符串''和0比较,结果是true,于是积分为0(或者什么都没填)的记录也被筛选出来了。后来统一改成严格等于(===),并强制把输入框的值用parseInt转换,问题才彻底解决。
4.3 小程序分包异步化与其他分包中的组件引用
小程序端踩的坑是分包异步化的问题。项目做到后期,小程序主包的体积已经接近2MB限制,我把预约页面和星球模块挪到了分包里。但挪过去之后发现,星球模块里引用的一个自定义组件(富文本编辑器)还在主包里,而分包页面不能直接引用主包的组件(反过来可以)。
微信官方提供的解决方案是"分包异步化",主要两种方式:
方式一:require.async动态加载
// 在分包页面里异步加载主包的模块 const editorModule = require.async('/components/editor/editor.js');方式二:useSp:组件跨分包引用
{ "component": true, "usingComponents": { "editor": "/components/editor/index" } }在实际操作中,我推荐用分包异步化配置中的"independent": true+"async"选项组合,把富文本编辑器做成独立分包,然后再在星球模块的分包里异步引用。这样既减少了主包体积,又不会因为组件缺失导致页面白屏。
4.4 并发刷卡计费的脏数据问题
最后一个要提醒的是并发问题。台球厅高峰期,收银员快速操作收银台,或者客户在小程序端同时扫码开台,后端容易出现"同一张桌子被开出去两次"的脏数据。
我的解决方案是加了一个简单的乐观锁:开台接口执行前,先做一次"状态CAS(Compare And Set)"操作,只有当桌台状态还是0(空闲)时才允许改成1(使用中)。
$affectedRows = Db::name('table_info') ->where('id', $tableId) ->where('status', 0) ->update(['status' => 1]); if ($affectedRows == 0) { apiReturn(1002, '桌台已被占用,请刷新重试', null); }这个方法比"先查再改"可靠得多,因为update的where条件里加了status=0,只有其他请求还没改状态时,当前请求才能更新成功。如果两个请求同时进来,数据库层面只会有一个update影响行数为1,另一个影响行数为0,自然被挡掉了。类似的并发控制,我也用在了会员充值和优惠券核销上。
这套系统从改版到上线,再到稳定运行,中间经历了太多折腾。我个人最大的体会是:门店级系统的复杂度不在于技术,而在于业务细节。一个计费规则就牵扯到最小计费时长、折扣叠加顺序、优惠券使用门槛、整数/小数取舍一堆问题。把业务规则梳理清楚,技术方案反而是次要的。
另外想提醒的就是"星球"这类私域模块,别把它做成一个花架子。积分、内容、活动必须和消费行为深度绑定,才能形成运营闭环。如果只是让客户多一个看公告的入口,那这个模块基本没有运营价值。
最后分享一个小技巧:不管系统多小,接口层的错误码一定要从一开始就规范化。我前期为了省事,很多地方直接返回字符串错误信息,后期前端对接时改起来非常痛苦。现在统一维护一份错误码文档,前端根据code提示对应的文案,联调效率高了很多。这套系统的完整源码可以在合法渠道获取,有需要的读者不妨结合自己的场景做二次开发。
本文还有配套的精品资源,点击获取