☰
TP6+Swoole4客服系统:长连接、WebSocket与多端消息路由实战
2026/10/28 17:55:46 网站建设 项目流程

简介:这套开源的客服系统源码包基于ThinkPHP6与Swoole4构建,面向需要自建多端客服系统的企业开发者及技术团队。系统支持微信网页、H5端、PC端客户接入,商家端提供PC管理后台、H5及App接待入口,并具备用户标签与分组功能,可用于电商、在线服务等场景的咨询统一管理与客户精细化运营。资源包共包含2000个文件,总体积约90.77MB,代码以PHP、JavaScript、Vue、Java为主,辅以CSS、HTML、SQL及配置文件,覆盖后端接口、前端页面、移动端工程与数据库初始化脚本,目录结构完整,便于按模块开展二次开发。目前已有263人浏览学习。借助这套源码可深入掌握TP6框架配合Swoole4处理高并发连接、异步任务和WebSocket通信的实现思路;同时前端与多端代码全部开放,既可作为企业客服系统快速落地,也能作为PHP高并发项目的实战参考。

1. TP6+Swoole4开源客服系统:先想清楚它解决的是长连接问题

做客服系统和做普通Web项目是完全不同的两种活。访客点开H5页面、微信网页还是PC浏览器,第一件事就是建立一条消息通道,消息要在几百毫秒内推送到客服端。TP6+Swoole4这套开源客服系统的核心价值,就是把ThinkPHP 6(就是常说的TP6)的开发效率和Swoole4的常驻内存、协程、WebSocket服务能力拼在一起,让微信网页、H5端、PC端三个访客入口共用同一套会话状态,商家端则同时覆盖PC管理、H5接待和App接待。如果你正在找一套可以私有化部署、能按自己业务改坐席逻辑、还想省掉每年几万块SaaS坐席费的客服系统,这类项目值得先跑通,再决定上不上。别急着改代码,先理解它的长连接模型,这是后面所有部署和踩坑的前提。

2. 为什么客服系统要选TP6+Swoole4:长连接、常驻内存与多端消息路由

2.1 传统PHP-FPM模式为什么扛不住客服长连接

普通PHP应用跑在PHP-FPM下,每个请求进来都会拉起一个PHP进程或者复用进程池里的进程,处理完就释放所有变量和连接。对一个客服项目来说,最大问题在于WebSocket长连接的状态没法保存——访客连接谁来维持、在线状态放哪、消息如何主动推给客服,这些问题在一次请求结束之后就全断了。你可以用轮询模拟,但H5端每3秒拉一次未读消息,用户多了以后MySQL的压力和网络流量都会很难看。

Swoole4的思路是整个服务常驻内存,通过事件驱动处理连接。TP6在Swoole里不再“每次请求重启”,而是像一个长驻进程一样运行,路由、控制器、模型这些还是TP6那套,底层HTTP和WebSocket服务交给Swoole4。这个组合对客服场景的意义在于,访客连接一直挂在进程里,聊天消息可以通过内存事件通道实时流转,不用每次都走一遍PHP生命周期。

我之前用纯PHP做过一次客服,连接状态全部压在数据库里,客服端靠轮询取消息,50个坐席同时在线时,每秒钟大概要产生300多次轮询请求,数据库连接直接被打满。后来换成Swoole常驻内存方案,轮询接口直接下线,消息推送改成WebSocket,同样的坐席数量只产生聊天本身的数据查询。结论比较直白:客服系统不是一个CRUD系统,它优先要解决的是“谁在线、谁在哪个会话、消息怎么推过去”,这些都需要长驻进程来管理。

# 先确认PHP环境里Swoole扩展可用,以及版本在4.x php -v php -m | grep swoole

上面第一行看PHP版本,第二行确认扩展已经加载。Swoole4和Swoole5在API上有差异,标题写的swoole4,那就建议锁定4.x版本使用,很多网上开源的客服包也都跑在4.x下,直接换到5可能遇到函数签名不兼容的问题。

2.2 在TP6中接入Swoole4:官方think-swoole扩展的使用方式

TP6官方生态里有一个think-swoole扩展,把TP6应用作为Swoole的HTTP服务器跑起来。常见做法是用Composer安装这个扩展,然后在config目录下生成swoole配置文件,里面可以定义server监听的host和port、Worker进程数、是否开启WebSocket等。

composer require topthink/think-swoole

安装之后,一般会在config目录下看到swoole.php。关键配置如下:

// config/swoole.php 中与客服系统相关的关键项 return [ 'server' => [ 'host' => '0.0.0.0', 'port' => 9501, 'mode' => SWOOLE_PROCESS, ], 'swoole' => [ 'worker_num' => 4, 'task_worker_num' => 2, 'max_conn' => 10000, 'heartbeat_idle_time' => 120, 'heartbeat_check_interval' => 60, ], 'websocket' => [ 'enable' => true, 'handler' => \app\websocket\ChatHandler::class, ], ];

这里的关键参数说明:host和port是Swoole服务监听地址,0.0.0.0表示允许外部访问;worker_num是处理HTTP和WebSocket事件的进程数,建议按CPU核数设置;task_worker_num是异步任务进程数,客服消息的离线存储可以放到Task里;max_conn是最大连接数,客服系统一般设置到5000到10000就够。heartbeat相关参数后面再展开,现在先有个概念就行。

WebSocket开启后,连接、消息、关闭这些事件会进到handler类里。我在项目里的handler大概长这样:

namespace app\websocket; use Swoole\WebSocket\Server; use think\App; class ChatHandler { // 记录 fd 与用户/访客的绑定关系 protected array $connections = []; public function onOpen(Server $server, $request): void { // $request->fd 是Swoole分配给当前连接的ID // 根据URL上的token或者参数绑定到具体用户 $this->connections[$request->fd] = [ 'type' => $request->get['type'] ?? 'guest', // guest 或 staff 'user_id' => $request->get['user_id'] ?? 0, ]; } public function onMessage(Server $server, $frame): void { // $frame->data 是客户端传来的消息原文 // 这里一般是JSON字符串,需要解码后路由给对应客服或访客 $data = json_decode($frame->data, true); // 根据 data 里的会话ID找到接收方fd,然后 push } }

这个handler做的事情很朴素:onOpen时把Swoole分配的fd(连接文件描述符)和访客或客服身份绑定,onMessage时把消息路由到对应会话的对方连接。重点在于不要把聊天逻辑全堆在handler里,消息内容解析、敏感词过滤、消息落库这些应该交给TP6的Service层,handler只负责收发和推送。

2.3 多端消息路由:微信网页、H5、PC怎么共用同一份会话状态

多个访客端接入的一个关键设计是,微信网页、H5、PC浏览器本质上都是“访客端”,区别只是承载方式,后端不需要为每个端单独维护一套WebSocket服务。比较合理的做法是:所有端统一连接同一个WebSocket地址,进入时通过token或临时游客ID标识身份,后端维护一份会话表,会话ID就是多端共用的唯一线索。

比如用户在PC端发起咨询,会话ID是10086,他离开PC后又在H5端打开同一个客服页,这时候用户ID如果一样,后端就能把新连接绑定到已有会话10086上,历史和消息记录继续沿用。反过来,客服在PC后台接待到一半,临时要出去,打开手机上的H5接待端或App端,只要登录同一个坐席账号,也能继续接收这个会话的新消息。

// 简化后的会话绑定逻辑 public function bindSession($fd, $userId, $sessionId) { // 在Redis里把fd与用户、会话关联,方便多端查找对方fd $key = 'chat:bind:' . $userId; redis()->hSet($key, 'fd', $fd); redis()->hSet($key, 'session_id', $sessionId); }

这段代码解释一下:在Redis里用用户ID做key,存上当前连接的fd和会话ID。因为一个用户可能同时打开多个端,真实场景里hSet会存多个fd,这里只做示意。后面消息路由的时候,先根据消息里的会话ID查到对方用户ID,再到这个key里找到对方最新的fd,直接push过去。

微信网页和H5端的区别主要在前面这点:微信网页要把用户身份换成微信OAuth后的openid,再映射到系统内的用户ID;H5端一般用注册手机号或游客标识。这个“先免登换身份,再建立连接”的思路,和很多内部系统嵌入第三方平台时的免登录对接是同一个套路。比如飞书嵌入H5的免登录,本质上也是先跳转授权或静默登录,拿code换成系统内用户态,再带着这个用户态去连接WebSocket。想明白这一层,多端接入就不慌了。

3. 把客服系统在本地跑起来:环境要求、部署流程与最小验证

3.1 环境清单:PHP版本、Swoole扩展、Redis与MySQL的取舍

在动手装之前,先把环境确定下来。TP6框架最低要求PHP 7.2.5,但Swoole4常驻内存场景建议用PHP 7.4或8.0。Swoole扩展必须是4.x,最好4.6以上,因为连接池、协程HTTP客户端这些更稳定。另外还需要Composer来拉依赖。既然是开源项目,代码就在手边,部署流程大多也是这套,差别主要在扩展版本和配置项上。

Redis在这个客服系统里通常是必需的,至少要做到三件事:缓存会话绑定关系、做消息推送的临时队列、给多进程之间共享在线状态。MySQL用来落会话和消息记录,H5端和PC端的历史消息都是从MySQL读的。如果项目里没有Redis,可以先用file缓存顶着跑demo,但并发一上来肯定扛不住。

有一点要特别注意:Windows环境下Swoole扩展官方不支持,直接用Docker或WSL2里的Linux容器跑。很多人在Windows上装了一天Swoole没装上,就是因为这条路本身就比较难走。在Linux服务器上反而简单,pecl一条命令的事。

# 一键检查PHP版本、Swoole版本与Redis扩展 php -v php -m | grep -E 'swoole|redis'

输出里swoole 4.x和redis扩展都在,说明环境基本OK。如果grep结果为空,先解决扩展安装问题再继续。Swoole扩展可以pecl安装,也可以编译安装,但不管哪种方式,最后php --ri swoole能看到版本号才算成功。装完扩展记得看一眼pkill -f php把旧的php-fpm进程清一下,别让旧进程占着扩展。

注意:Windows下Swoole扩展官方不支持,优先用Docker或WSL2环境跑,不要在这上面浪费时间。

3.2 初始化数据表:客服账号、访客、会话与消息

客服系统最少要有四张表:users(坐席/客服账号和访客通通可以放在这)、sessions(会话表)、messages(消息表)、session_members(会话成员,可选的)。标题里明确说“支持用户添加”,说明商家端要能主动创建坐席账号,而不是只能用安装时的初始账号。

用TP6的方式建表,可以先手动执行SQL,或者用迁移工具。下面以users表的一部分字段为例:

CREATE TABLE `users` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL DEFAULT '' COMMENT '登录名', `password` varchar(255) NOT NULL DEFAULT '' COMMENT '密码哈希', `nickname` varchar(50) NOT NULL DEFAULT '' COMMENT '昵称', `type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0访客 1坐席 2管理员', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户/坐席表';

字段其实不用设计得太复杂,客服场景里最常查询的就是:根据登录名找用户、根据type筛选坐席、根据status判断是否允许接待。type字段把访客和坐席放在同一张表,一是后续加功能方便,二是用户添加坐席时不需要额外同步账号体系。

用户添加坐席的接口,在TP6里的实现大致如下:

namespace app\admin\controller; use app\common\model\User; use think\facade\Validate; class Staff extends Base { public function add() { $data = request()->post(); $validate = Validate::rule([ 'username' => 'require|unique:user', 'password' => 'require|min:6', 'nickname' => 'require', ]); if (!$validate->check($data)) { return json(['code' => 1, 'msg' => $validate->getError()]); } User::create([ 'username' => $data['username'], 'password' => password_hash($data['password'], PASSWORD_DEFAULT), 'nickname' => $data['nickname'], 'type' => 1, ]); return json(['code' => 0, 'msg' => '添加成功']); } }

这段代码的逻辑说明:先用TP6的验证器检查用户名是否为空且唯一,密码至少6位;然后调用User模型create写入数据,密码用password_hash哈希,不存明文;type=1表示坐席。这里我用的是TP6模型语法,如果你习惯Db类,效果一样。密码处理是踩过坑的点,后面避坑章再展开。

3.3 跑通第一个会话:从H5访客端到PC商家端接待的完整链路

假设代码已经装到web目录,依赖也装好了,接下来启动服务。用think-swoole的常用命令是把TP6应用作为HTTP服务跑起来:

php think swoole:start

启动后Swoole监听在9501端口。它同时处理HTTP请求和WebSocket升级,所以H5页面可以直接通过http://127.0.0.1:9501/h5访问,WebSocket地址就是ws://127.0.0.1:9501。启动日志里会显示worker进程数和监听地址,确认都正常再往下走。

为了验证链路,我一般会开两个浏览器窗口,一个模拟访客,一个模拟坐席后台。访客端建立连接的JS大致如下:

// H5访客端连接WebSocket const ws = new WebSocket('ws://127.0.0.1:9501/?type=guest&user_id=1001'); ws.onopen = function () { // 连接建立后,发送一条上线消息 ws.send(JSON.stringify({ event: 'online', session_id: 0, // 0表示创建新会话 })); }; ws.onmessage = function (evt) { // 收到消息后追加到聊天界面 const msg = JSON.parse(evt.data); appendMessage(msg); }; ws.onclose = function () { // 这里要提示用户网络断开,后续做重连 };

关键说明放在参数上:URL里的type=guest告诉后端这是访客连接,user_id=1001是业务侧用户ID。Swoole的onOpen事件会解析这些参数,和上一条handler里代码对应。session_id=0只是示意,实际项目里应该由后端生成或者从历史会话列表里带回。

PC商家端登录后台后,打开接待页面,也建立WebSocket连接,type=staff。当访客发送消息时,后端的handler解析出session_id,查到这个会话分配的客服是哪个,再向客服的fd推送消息。客服回复时走相反方向。这一条链路跑通,就说明服务端长连接、身份绑定、消息路由三块都正常了。验证完成后,用php think swoole:stop停掉服务,别直接kill -9,容易把Redis里的绑定关系留下脏数据。

4. 微信网页、H5端、PC端统一会话接入:身份识别、消息体设计与必调参数

4.1 访客身份识别与跨端会话保持

多端接入的第一个问题是:同一个访客从微信网页跳到H5,再跳到PC浏览器,后端怎么知道这是同一个人。常见做法是首次访问时生成一个唯一标识,浏览器里放localStorage或Cookie,后面这些端再打开时带着这个标识来连接WebSocket。这个思路跟“H5随意玩”的场景类似,页面形态可以随意变,但用户身份背后要是同一个,才不会出现换个端就开新会话的尴尬。

微信网页接入时,微信内置浏览器会给前端一个code,前端拿去换openid,后端再把这个openid和系统用户表关联。这个流程其实是标准的OAuth免登流程,跟企业微信或飞书H5嵌入时的免登录逻辑一模一样。区别只在授权地址和参数名不同,代码骨架可以复用。

App端接待并不需要另起一套原生长连接技术。常见做法是App里用WebView加载一个H5接待页,或者在uni-app工程里直接复用这套H5代码,这样商家在三端看到的是同一个会话界面。需要原生通信的地方只有推送通知,比如把离线消息推到手机通知栏。

会话保持的核心数据结构很简单:用户ID对应一条或多条连接fd,会话ID对应一组参与成员。我用Redis存两类key:

chat:bind:{userId} -> hash, 存 fd 和 session_id chat:online:{staffId} -> set, 存当前接待的访客会话ID

用hash存fd是因为同一用户可能同时开多个端,但活跃的往往只有一个;用set存坐席当前会话,是为了在分配客服时快速筛选空闲坐席。这两类key的设计对后面的消息路由影响很大。

4.2 WebSocket消息体设计:event、session_id、msg_type、from、to

WebSocket消息统一JSON格式,设计好字段能让路由逻辑简单很多。我自己通常用这套:

字段类型说明
eventstring事件名:online/chat/typing/read/close
session_idint会话ID,新会话为0
msg_typestringtext/image/system
fromint发送者用户ID
toint接收者用户ID,0表示待分配
contentstring消息文本或图片URL
tsint客户端时间戳

举个例子:访客发消息时,WebSocket handler收到后不是直接push给某人,而是走“解析event→找路由目标→落库→推送”四步。以chat事件为例:

public function onChat($data) { $sessionId = $data['session_id']; $toUid = $this->getStaffIdBySession($sessionId); if ($toUid <= 0) { // 会话还没有客服接待,进入排队队列 $this->pushToQueue($sessionId, $data); return; } // 找到客服当前的fd并推送 $fd = redis()->hGet('chat:bind:' . $toUid, 'fd'); $server->push($fd, json_encode($data)); // 异步落库到MySQL $this->saveMessage($data); }

这里需要说明的是:$toUid为0表示访客消息进来但客服还没接入,此时不应该丢弃消息,而是进入排队或分配逻辑;saveMessage是异步任务,不阻塞当前推送;如果message里的msg_type是system,说明是系统公告或会话分配通知,这种消息不落库也可以,看业务需要。事件如果要加,常见的是status事件,比如客服上线、下线、忙闲状态切换。这个在H5接待端和App端都会用到。

4.3 三个必调参数:worker_num、max_conn、心跳超时

think-swoole的配置里,有几个参数对客服系统影响最直接,新手容易直接用默认值跑出各种奇怪问题。

第一个是worker_num。它决定Swoole启动多少个Worker进程处理事件。客服系统有大量长连接,每个Worker进程都在事件循环里处理WebSocket帧,设得太少,单Worker连接数过多会导致延迟升高;设得太多,进程切换开销也大。一般按CPU核数的1到2倍设置,线上8核机器可以设16,本地4核就设4到8。

第二个是max_conn。这个参数是Swoole服务器的最大连接数。默认值可能到10万,但客服系统不需要这么大,关键是防止服务器内存被无效连接耗尽。结合worker_num来算,特别是外部有Nginx代理时,Nginx也得同步调大worker_connections,否则Swoole这边没满,Nginx先报错。

第三个是心跳相关:heartbeat_idle_time和heartbeat_check_interval。这两个参数配合检测死连接。比如空闲超过120秒没任何数据交互,Swoole会关闭这个连接。客服场景里,访客可能一直挂着页面但半天不说话,这时前端需要有心跳包(ping/pong)来维持连接不被切断。

'heartbeat_idle_time' => 120, 'heartbeat_check_interval' => 60,

参数含义很容易记:每60秒检查一次,超过120秒没活动就断开。如果你预计访客经常停留在聊天页且不发消息,前端每50秒发一个ping就能躲过这个断开。不做心跳的后果就是客服端看着对方在线,实际发消息过去对方已经收不到,然后用户跑来骂客服。

5. 部署与避坑:TP6在Swoole常驻内存环境下的4条典型踩坑

5.1 坑一:静态属性和单例缓存导致用户信息串台

现象:客服A在PC端接待访客A,突然看到客服B的会话列表;H5端访客登录后,偶尔展示的是另一个用户的昵称。

原因:Swoole常驻内存下,PHP进程不会在请求结束后销毁。TP6框架里如果你把某些数据存到静态属性或全局单例里,第二次请求读到的还是第一次的数据。普通FPM模式下这种bug几乎不存在,因为进程每次都会重新创建。最常见的就是把当前登录用户放到一个静态属性,或者在一个长生命周期对象里缓存用户信息。

解决:不要用静态属性保存请求级数据,尤其是用户ID、会话列表、当前登录态。需要用TP6的请求对象或容器来承载。自查方式很简单:在代码里搜static关键字,看有没有用于存“当前用户”“当前店铺”“当前角色”这类请求级状态。有就全部改成注入或每次从容器取。

5.2 坑二:数据库连接断线与TCP粘包半包

现象:服务运行一两天后,客服端发消息偶尔没有响应,日志里出现MySQL server has gone away;或者接收到的WebSocket消息偶尔是两条拼在一起。

原因:数据库连接在Swoole常驻进程里是长连接,MySQL默认wait_timeout到了会断开旧连接。TP6的数据库连接池如果没配置自动重连,第一次查询就会报错。TCP粘包半包是Swoole底层协议设置问题,WebSocket协议本身有帧,但如果自定义TCP协议又没设置协议解析,就容易出现。

解决:数据库层面启用连接池并设置重建连接,检查config/database.php里是否有connect_time和auto_reconnect相关支持;自定义TCP服务时,在Swoole配置里加open_eof_check或open_length_check,或者统一用WebSocket协议避免处理这个。

5.3 坑三:改代码不重启服务不生效

现象:本地改了控制器后刷新页面,发现功能还是旧的,甚至语法错误也没暴露,直到某次重启才报错。

原因:Swoole是常驻内存,PHP代码只在Worker启动时加载一次。改完代码,旧进程里跑的仍然是旧代码。TP6的调试模式在Swoole下也不能像FPM那样即时生效。

解决:维护阶段统一用php think swoole:restart,代码发布流程里加上“重启服务”这步。更精细的做法是配置reloadable进程并在部署后用kill -USR1给Swoole主进程发信号,但并不是所有代码都能热更新,opcache、协程内的状态都不行。最稳妥的就是每次发版都重启。

5.4 坑四:微信用内置浏览器H5页面长连接频繁断开

现象:H5页面在普通浏览器里连接稳定,但在微信内置浏览器里,切后台超过30秒回来,连接已经断开,客服发的消息收不到;甚至在聊天页停留几分钟也会进入这个状态。

原因:微信浏览器对WebSocket支持整体OK,但页面切换到后台时WebSocket连接会被系统或浏览器挂起;Android上特别明显,长时间无操作还会被当成后台标签页回收。

解决:前端把心跳调整为30秒一次,并且在visibilitychange事件里检测到页面重新可见时主动重建连接;服务端对同一会话的断开重连要幂等,不要因为重连就创建重复会话。这个问题根因在客户端,后端能做的就是把重连接口做好。另外注意,手机省电模式也会放大这个问题,别指望后端参数能完全解决。

6. 进阶:压测验证与一个决策建议

6.1 用最小脚本验证连接数和消息吞吐

部署到线上前,至少做一次连接数验证。用一个简单的PHP脚本创建N个WebSocket连接,然后向服务器发送消息统计延迟。这里给出一个连接数验证的脚本:

<?php // 压测脚本:创建1000个连接并保持 $count = 0; for ($i = 0; $i < 1000; $i++) { $client = new Swoole\Coroutine\WebSocket\Client('127.0.0.1', 9501); if ($client->upgrade('/?type=guest&user_id=' . (10000 + $i))) { $count++; } } echo "成功连接: $count\n";

这个脚本利用Swoole协程客户端创建1000个WebSocket连接。注意:这不能代表真实业务压力,但可以验证max_conn和worker_num配置是否让服务端能够稳定接受大批量连接。如果$count远低于预期,优先检查系统文件描述符上限ulimit -n。

线上压测建议用wrk或websocket-bench这类工具,但真实场景里,客服并发比普通Web低很多,通常1000个并发连接已经超过绝大多数中小商家的需求。在压测时重点看两点:内存是否稳定、消息延迟是否随连接数增加明显恶化。内存持续上涨说明有变量泄漏或连接没释放,需要查代码。

6.2 我的一个教训:第一版不要塞智能机器人

第一次做客服系统时,我花了两周给项目加了一个关键词回复机器人,结果上线后大部分精力都在维护机器人规则库,真正的主流程反而没打磨好。整个项目最该先做扎实的是:消息收发不丢、会话分配正确、多端状态同步一致。把这三条跑稳,再考虑机器人、工单、满意度评价这些增强功能。机器人看起来是加一个自动回复,实际会引入关键词匹配、转人工策略、会话标签三套逻辑,复杂度一下子爆掉。

现在再让我做这个项目,我会先把TP6+Swoole4这套基座跑通,然后只做“访客-坐席-会话-消息”四张表和H5、PC、微信网页三个入口。用户添加功能已经在里面了,后续扩展坐席权限就很容易。这个方向真正值得投入的点不在功能数量,而在稳定性和可维护性,跑一年不翻车比什么都重要。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询