简介:一款基于ThinkPHP内核开发的无限坐席在线客服系统源码,面向需要部署私有化客服系统的站长、企业运维人员,以及希望学习客服系统架构与PHP二次开发的开发者。系统支持多坐席同时接入,压缩包约36.73MB,共2000个文件,其中包含3469个PHP文件、1621个PNG图片、573个JS脚本、287个HTML页面及268个GIF动态图等,PHP文件承载业务逻辑与接口,JS与CSS用于前端交互和界面样式,图片资源覆盖图标与场景素材,整体目录结构清晰,便于定位核心模块。已有963人学习下载,可作为研究客服会话分配、消息推送、坐席管理、统计报表等功能的参考项目。源码包内附带完整的安装引导文件、配置示例及常用辅助脚本,适合具备一定PHP基础的学习者直接部署体验,并在此基础上进行功能扩展与界面定制。
1. 基于ThinkPHP的无限坐席在线客服系统,架构先于功能
在线客服系统这个需求,第一反应往往是“能做聊天就行”。但真正投入生产后瓶颈几乎都出现在同一个地方:坐席数量上来之后,连接、状态同步、消息分发全挤在一起,数据库连接被占满,PHP-FPM直接卡死。所谓“无限坐席”,不是指一台服务器能挂无数连接,而是架构上要支持坐席水平扩展、消息不丢、状态实时一致。ThinkPHP在这个场景里不是用来写聊天页面的,而是承担业务内核:坐席管理、会话路由、消息持久化、数据统计。把内核层与实时通信层拆开,是这套系统能否扛住坐席增长的关键。本文面向需要自研客服系统的技术负责人和PHP工程师,从选型、表结构、分配策略到压测验证,给出可落地的完整路径。
2. ThinkPHP选型与坐席体系的数据模型设计
2.1 用哪个版本:ThinkPHP 5.1还是6.0,PHP 8兼容性怎么处理
选ThinkPHP做内核,主要看中它的ORM、验证器、中间件和命令行的成熟度。需要明确一点:自研客服系统里,ThinkPHP只处理业务API和管理后台,WebSocket长连接由独立网关承载,不走TP的请求生命周期。这样的话,TP的性能瓶颈就被隔离在业务API一侧,不至于影响实时消息吞吐。
版本选型上,新项目建议直接用ThinkPHP 6.0,配合PHP 8.0以上。如果老系统是TP 3.2,需要兼容PHP 8的话,问题集中在mysql_*函数移除、each()语法废弃、以及魔术方法的兼容层。常见做法是写一个兼容扩展包,把TP 3.2的DB类重写为基于PDO的实现,但这属于老项目迁移的范畴,新项目不必走这条路。
提示:如果你维护的是TP 3.2老项目,PHP 8下最常见的报错是
Call to undefined function mysql_connect(),解决思路不是打补丁强撑,而是把数据库操作层替换为think\db的PDO实现,同时处理掉each、list在foreach中的语法变化。
2.2 坐席模块的三张核心表:关于“无限坐席”的表结构设计
无限坐席在数据库层面的核心不是“不做限制”,而是让坐席数据可以水平切分。以下是直接可用的三张表设计,涵盖了坐席账号、状态记录和技能组关联。
-- 坐席账号表 CREATE TABLE `agent` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `agent_no` varchar(32) NOT NULL COMMENT '坐席工号,业务内唯一', `real_name` varchar(64) NOT NULL DEFAULT '' COMMENT '姓名', `type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1在线客服 2机器人', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0离线 1空闲 2忙碌 3小休', `max_sessions` int(11) NOT NULL DEFAULT '5' COMMENT '最大同时接待数', `created_at` int(11) NOT NULL DEFAULT '0', `updated_at` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uk_agent_no` (`agent_no`), KEY `idx_status_type` (`status`, `type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='坐席基础表'; -- 坐席实时状态表(按天分表或使用Redis替代) CREATE TABLE `agent_status_log` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `agent_no` varchar(32) NOT NULL, `status` tinyint(4) NOT NULL COMMENT '状态值', `session_count` int(11) NOT NULL DEFAULT '0' COMMENT '当前会话数', `last_active_at` int(11) NOT NULL DEFAULT '0' COMMENT '最后活跃时间', PRIMARY KEY (`id`), KEY `idx_agent_no` (`agent_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='坐席状态流水,可按月归档'; -- 坐席-技能组关联表 CREATE TABLE `agent_skill_group` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `agent_no` varchar(32) NOT NULL, `group_id` int(11) NOT NULL COMMENT '技能组ID', `priority` tinyint(4) NOT NULL DEFAULT '0' COMMENT '优先级,数值小优先', PRIMARY KEY (`id`), UNIQUE KEY `uk_agent_group` (`agent_no`, `group_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='坐席技能组关联';agent表用来持久化坐席的静态信息,status字段允许冗余,真正的实时状态应该放到Redis,因为数据库的读写粒度和频率跟不上坐席状态切换的速度。max_sessions是实现“无限坐席”的软性控制点:不限制坐席总数,但限制单个坐席的并发接待量,避免一个坐席被会话打爆。
这里的agent_status_log按天分表后,坐席总数可以做到十万级以上,查询历史状态时按月表路由,不会因为单表数据膨胀拖慢写入。实际上,当一个客服系统的坐席超过一万时,实时状态基本全部走Redis,这张流水表只用于管理员查看历史状态和排班报表。
2.3 Redis在内核层承担的角色:在线状态与坐席维度的数据隔离
ThinkPHP内核算的是业务规则,而实时状态的数据载体落在Redis。每个坐席上线时,向Redis写入一个Hash结构:
use think\facade\Cache; // 坐席上线时写入状态 $key = 'agent:online:' . $agentNo; Cache::store('redis')->hSet($key, 'status', 1); // 1空闲 2忙碌 3小休 Cache::store('redis')->hSet($key, 'session_count', 0); Cache::store('redis')->hSet($key, 'last_active_at', time()); Cache::store('redis')->expire($key, 86400); // 24小时过期,防止僵尸数据 // 坐席心跳续期,TP命令行任务中执行 Cache::store('redis')->expire($key, 86400);用Hash而不是String的优势在于:可以只更新last_active_at这一个字段,不需要先读出整个JSON再写回,减少并发写冲突。另外,坐席的在线列表存为一个Set,用于分配时快速过滤:
// 将空闲坐席加入可分配池 Cache::store('redis')->sAdd('agent:pool:group:' . $groupId, $agentNo);agent:pool:group:*这个Set就是分配算法的输入源,坐席数增加时不需要扫描全表,直接从Set里按策略取值。当坐席状态变化时,移除或加入对应的技能组池子。这种设计下,单台Redis实例支撑数千坐席的状态读写没有压力,瓶颈只会出现在消息推送层。
3. 无限坐席的分配策略与会话路由
3.1 坐席状态机与状态变更的事件驱动
无限坐席系统的核心是状态机:离线、空闲、忙碌、小休四种状态之间如何流转,以及流转时同步哪些数据。状态流转不只是改一个字段,还要触发分配池更新、会话转移、离线通知等动作。
每次状态变更建议统一走同一个入口,而不是在各处直接改库:
namespace app\service; use think\facade\Cache; class AgentStateService { public static function change(int $agentNo, int $newStatus): bool { $oldStatus = Cache::store('redis')->hGet('agent:online:' . $agentNo, 'status'); if ($oldStatus === $newStatus) { return true; } // 更新Redis Cache::store('redis')->hSet('agent:online:' . $agentNo, 'status', $newStatus); // 同步分配池 $poolKey = 'agent:pool:default'; if ($newStatus == 1) { Cache::store('redis')->sAdd($poolKey, $agentNo); } else { Cache::store('redis')->sRem($poolKey, $agentNo); } // 写入状态流水(异步队列) \think\facade\Queue::push(AgentStatusJob::class, [ 'agent_no' => $agentNo, 'status' => $newStatus, 'ts' => time() ]); return true; } }这个服务的两个细节值得注意。第一,分配池只用default组,实际项目中应根据技能组拆成多个key,比如agent:pool:group:10,这样访客进线时只从对应技能组的池子取人。第二,状态流水通过TP的Queue异步写入,避免坐席频繁点“小休”时阻塞主流程。队列消费失败时要有重试机制,否则管理人员会看到状态日志缺失。
3.2 分配算法:空闲优先、轮询与技能组匹配的组合策略
分配是“无限坐席”的灵魂。经典的算法是空闲优先,但这还不够,因为客服场景里技能组和优先级往往比“谁最闲”更重要。一个处理售后的坐席不应该被分配到售前咨询。实际工程中,我一般用两级筛选:
namespace app\service; use think\facade\Cache; class RouterService { /** * 根据技能组和访客ID分配坐席 * @param int $groupId 技能组ID * @param string $visitorId 访客会话标识 * @return string|null 返回agent_no或null(排队) */ public function dispatch(int $groupId, string $visitorId): ?string { $poolKey = 'agent:pool:group:' . $groupId; $agents = Cache::store('redis')->sMembers($poolKey); if (empty($agents)) { return null; } // 第一轮:过滤掉达到最大接待量的坐席 $candidates = []; foreach ($agents as $agentNo) { $onlineKey = 'agent:online:' . $agentNo; $sessions = (int) Cache::store('redis')->hGet($onlineKey, 'session_count'); $maxSessions = (int) Cache::store('redis')->hGet($onlineKey, 'max_sessions'); if ($sessions < $maxSessions) { // 找到坐席的当前接待数并作为排序依据 $candidates[$agentNo] = $sessions; } } if (empty($candidates)) { return null; } // 第二轮:接待数最少的优先,同接待数时取最早空闲的 asort($candidates); $selected = array_key_first($candidates); // 命中后立即递增session_count,防止并发重复分配 Cache::store('redis')->hIncrBy('agent:online:' . $selected, 'session_count', 1); return $selected; } }array_key_first在PHP 7.3+可用,这里取接待数最少的坐席。需要注意一个关键问题:分配和递增不是原子的,两个访客同时进线时可能选到同一个坐席。解决办法是在Redis里执行Lua脚本将“取候选+递增计数”合并为一个原子操作,或者在业务层加锁。生产环境必须用Lua,否则高并发下必然出现超卖式的分配冲突。轮询算法相对简单,但空闲优先在客服场景中通常体验更好,因为客服喜欢一个一个处理完,而不是同时挂着十个会话各聊一句。
3.3 排队与溢出策略:分配不到坐席时内核怎么兜底
当候选池为空时,访客进入排队队列。排队不是简单的FIFO,而是带优先级的FIFO:VIP访客可以插队,重复来访的老客户比新访客优先级高。
// Redis有序集合实现排队,score为优先级*100000+时间戳 $score = $priority * 100000 + time(); Cache::store('redis')->zAdd('queue:group:' . $groupId, $score, $visitorId);坐席空闲时,由TP的命令行任务或事件回调触发抽人操作:从queue:group:*中取score最小的访客,再走dispatch流程。溢出策略上,队列超过阈值(比如20人)或排队超过3分钟时,引导访客留言或转机器人。这里的阈值配置建议放到TP的配置文件中,方便运营随时调整。
4. 实时消息推送与会话保持,TP内核之外的通信网关
4.1 WebSocket网关与ThinkPHP的职责边界划分
在线客服的实时消息不能靠HTTP轮询,那是把数据库当消息队列用,坐席一多就会拖垮业务库。工程上最稳妥的方案是:独立网关负责WebSocket长连接,ThinkPHP提供HTTP API和业务数据处理,两者通过Redis消息队列通信。
网关层存在多种选择,常见的包括Workerman、Swoole等。如果团队PHP经验为主,Workerman上手成本最低;如果已有Java或Go团队,也可以另外写网关。这里的关键不在于选哪个,而在于把协议对齐。消息格式建议统一为JSON:
{ "event": "chat.message", "data": { "msg_id": "20240101120000123456", "from": "visitor_10001", "to": "agent_20001", "content": "你好,我想咨询一下退款流程", "ts": 1704110400 } }网关收到消息后,先写入Redis的待处理队列,ThinkPHP的消费脚本从队列取出,做敏感词过滤、落库、然后调用网关的HTTP接口把消息推送给对方。这个链路的好处是:推送和落库解耦,网关不需要知道MySQL的表结构,ThinkPHP不需要维持长连接,各自可以独立横向扩容。
4.2 心跳与断线重连:坐席掉线后如何不丢会话
WebSocket连接的稳定性是客服系统的生命线。坐席网络抖动、浏览器切后台、公司断网,都会导致连接断开。必须实现心跳机制与断线重连。网关层的心跳参数一般设25到30秒一次ping,3次无响应判定断线。
// Workerman GatewayWorker中设置心跳 $worker->pingInterval = 25; // 秒 $worker->pingNotResponseLimit = 3; // 连续3次未回应则断开ThinkPHP侧要做的不是维持心跳,而是监听网关的断线回调。当网关通知某个坐席连接断开时,TP调用AgentStateService::change()将该坐席状态置为离线,并把它负责的会话转移到技能组池子重新分配。转移时要注意会话中原坐席的session_count要减掉,否则状态计数会漂移。
提示:断线不等于会话结束。访客等待期内可以提示“坐席暂时离开”,而不是立即显示“会话已结束”。重连后原坐席优先回收该会话,超过5分钟才允许被其他坐席接管,这个逻辑要在分配时加一个
last_agent_no字段判断。
4.3 消息去重与离线消息补偿
网络重传会导致同一条消息被收到两次,所以消息头必须带msg_id。TP在消费消息时,用msg_id做幂等判断:
namespace app\service; use app\model\ChatMessage; use think\facade\Cache; class MessageService { public function handle(array $message): bool { $msgId = $message['data']['msg_id']; // 用Redis SETNX做幂等,防止并发重复写入 $lock = Cache::store('redis')->set('msg:dedup:' . $msgId, 1, ['nx' => true, 'ex' => 3600]); if (!$lock) { return true; // 已处理过,直接丢弃 } ChatMessage::create([ 'msg_id' => $msgId, 'from' => $message['data']['from'], 'to' => $message['data']['to'], 'content' => $message['data']['content'], 'create_time' => $message['data']['ts'] ]); return true; } }离线消息补偿的逻辑是:消息落库后,如果推送目标不在线,不抛异常,而是把msg_id写入目标的离线消息列表。等目标上线时,从离线列表拉取最近50条未读消息。实现上可以用Redis List存储,上线时一次性弹出并推送。
5. 压测方法、参数调优与常见瓶颈排查
5.1 用脚本模拟并发进线,验证分配接口的TPS
在交付前建议做一个基础压测,压测的目标不是证明系统能抗10万并发,而是验证单实例的分配接口在500并发下不报错、响应在200ms内。可以用一个简单PHP脚本模拟:
// bench.php 并发模拟脚本 $url = 'http://your-domain/api/router/dispatch'; $requests = []; for ($i = 0; $i < 500; $i++) { $requests[] = $url . '?group_id=10&visitor_id=visitor_' . $i; } $mh = curl_multi_init(); foreach ($requests as $i => $req) { $ch = curl_init($req); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_multi_add_handle($mh, $ch); } $running = null; $successCount = 0; $totalTime = microtime(true); do { curl_multi_exec($mh, $running); $result = curl_multi_select($mh); } while ($running > 0); // 统计结果 foreach ($requests as $i => $req) { $output = curl_multi_getcontent($ch); if ($output !== false && json_decode($output)->code == 200) { $successCount++; } }压测最直接的观察指标有两个:成功率和平均响应时间。如果成功率低于98%,先检查Redis连接数是否打满,再看PHP-FPM的pm.max_children是否够用。多数情况下瓶颈在FPM进程数而不是代码本身。
5.2 PHP-FPM、Redis与Nginx的参数联动调整
坐席系统的请求特点是小而频,每个请求的耗时通常低于100ms,但每秒请求量大。针对这个特征,FPM配置建议调整为动态模式:
pm = dynamic pm.max_children = 120 pm.start_servers = 30 pm.min_spare_servers = 20 pm.max_spare_servers = 50Redis这边,主要是确认最大连接数定义合理。默认的maxclients是10000,但PHP-FPM每个进程会占用一个连接,Redis的timeout要设置到300秒以上,避免空闲连接被服务端切断。Nginx的keepalive_timeout建议设成65秒,保持与WebSocket网关的错峰,不强制复用HTTP长连接,因为客服页面的消息走WebSocket,HTTP接口只做业务操作。
5.3 ThinkPHP框架层的SQL查询隐患
客服系统最容易被忽视的性能杀手是N+1查询。比如拉取会话列表时,在foreach中查坐席姓名或访客信息,坐席一多就慢。任何列表页都要用TP的with预加载关联模型:
$list = SessionModel::with(['agent', 'visitor']) ->where('create_time', '>', $startTime) ->order('last_message_time', 'desc') ->limit(50) ->select();排查SQL慢查询时,开启TP的SQL日志,把时间超过1秒的查询单独落文件。一般涉及三个字段的索引就够,不要每个字段都加,索引过多会拖慢写入。
最后一格实战建议:给session表的last_message_time和status建联合索引,这是客服会话页加载最频繁的查询条件,加上后即便会话表过百万行,列表页也能稳定在200ms内返回。这套基于ThinkPHP搭建的内核结构,配合网关转发和Redis状态层,坐席从几十扩展到几千时只需要横向加网关节点,业务代码可以保持不变。
本文还有配套的精品资源,点击获取