☰
开源在线客服系统源码解析:WebSocket长连接与会话分配实践
2026/11/4 8:09:56 网站建设 项目流程

简介:这份压缩包面向具备一定开发能力的企业技术团队与独立开发者,提供一套可自行部署和二次开发的开源在线客服管理系统源码,用于搭建网页、移动端及社交媒体等渠道的实时沟通入口,并配套客服工作台、常见问题自动回复、服务报表与客户信息管理等能力。压缩包为zip格式,整体约7.78MB;上游文件清单显示文件总数为0,主要文件类型暂无明细,具体目录与文件构成需以解压结果为准。目前已有476人浏览学习,说明该主题在客服系统选型与源码研究中有一定关注度。读者可据此了解在线客服系统从用户界面、工作台到报表分析、CRM/ERP集成、多语言与隐私保护等模块的组织思路,并评估开源方案在成本控制、功能定制、社区支持和后续维护上的取舍,适合用作搭建个性化客服流程或进行二次开发的学习参考。

1. 访客停留的那几十秒:开源在线客服管理系统到底解决什么

访客在商品页停了四十秒,加了购物车又清空,关掉页面就再没回来。这段行为在统计后台里看得清清楚楚,却没有一个人能在他离开前问一句「是不是卡在运费上」。页脚挂张二维码让人加好友,转化窗口早就过去了。这份源码-开源版在线客服管理系统的价值就在这段时间窗里:访客端挂件、坐席工作台、管理后台三段串成一条链路,坐席能看到对方停在哪个页面,直接把对话接过去。它适合自己站点想加在线咨询的独立开发者,适合把客服会话接进已有业务后台的二次开发方,也适合拿它当长连接入门范本的人。整包是标准三层结构,前端一段可嵌入脚本,后端常驻进程扛实时消息,MySQL 存会话,Redis 管在线状态,没有太多玄学的东西。

2. 技术栈与目录结构:三层拆分和数据表怎么对应

2.1 访客挂件、坐席工作台、管理后台的分工

这套源码拆开看是三个前端入口共用一套后端。访客挂件是一段可嵌入任意站点的 JS,正常只渲染一个悬浮气泡,点开才建立连接;坐席工作台是登录后的独立页面,常驻一条长连接等新会话;管理后台走纯 HTTP,管坐席账号、分配规则、快捷话术和会话归档。分工的关键在于「实时」和「非实时」被拆得很干净:登录、拉历史消息、拿站点配置、导出会话记录全部走 HTTP 接口,只有收发消息、上下线通知、新会话提醒走长连接。这么切的直接好处是长连接进程不需要碰登录态,重启一下不影响后台操作。

挂件接入方式决定了它能不能真的装上去。常见做法是在业务页面的页脚加两行脚本,一行塞配置,一行异步加载主文件:

<!-- 站点页脚引入,defer 保证不阻塞业务页面首屏渲染 --> <script> window.chatConfig = { appId: 'site_10086', // 站点唯一标识,后端靠它隔离会话数据 apiBase: 'https://chat.example.com/api', wsUrl: 'wss://chat.example.com/ws', autoOpen: false, // 进站是否自动弹出对话框,营销页才开 visitorTag: { uid: '' } // 业务侧塞进来的身份,登录用户才填 }; </script> <script src="https://chat.example.com/widget.js" defer></script>

appId是多站点共用的关键参数,后端几乎每张表都带这个字段,改它等于换一个业务空间;wsUrl必须是wss,站点本身是 https 时挂 http 的 ws 会被浏览器按混合内容直接拦掉,控制台只给你一行红字,新手最容易被卡在这;visitorTag是二次开发最高频的改动点,后面第 6 章会展开。autoOpen看着不起眼,但自动弹窗在移动端会顶掉业务页面布局,我一般只在活动落地页开。

2.2 目录职责清单与阅读顺序

包解开后目录不算深,但第一次看容易在 socket 入口和业务 service 之间来回跳。合理的读法是先看sql/知道有哪些表,再看config/知道依赖了什么,最后从bin/start.php顺着长连接入口往下走。

openim-server/ ├── app/ │ ├── controller/ # HTTP 接口层:登录、历史消息、站点配置下发 │ ├── service/ # 业务逻辑:会话分配、消息分发、坐席状态机 │ ├── model/ # 数据表映射,一个文件对应一张表 │ └── socket/ # 长连接入口:握手、心跳、帧路由 ├── config/ │ └── config.php # 数据库、Redis、监听端口、跨域白名单 ├── public/ │ ├── widget.js # 编译后的访客挂件,线上就加载这一个文件 │ └── workbench/ # 坐席工作台的静态资源 ├── sql/ │ ├── schema.sql # 建表脚本,带完整索引 │ └── demo.sql # 演示数据,含三个坐席和两条历史会话 ├── runtime/ # 日志、进程 pid 文件,目录权限必须给足 └── bin/ ├── start.php # 启动长连接进程 └── stop.php # 停止,带 -g 参数杀全部 worker

app/socket/是整个项目最容易读错的地方,它不是控制器,而是常驻内存的进程入口,里面的对象在启动时构造一次、之后一直活着。所以任何写在这个目录下的状态变量都是进程级的,多 worker 启动时互不可见,这一点在后面讲广播漏消息时会反复出现。runtime/目录权限问题几乎是新人第一条报错来源,日志写不进去时进程会静默退出,ps一看进程没了,很容易怀疑是代码问题。

2.3 会话、消息、坐席三张主表

表结构是整个系统的骨架,后面所有分发逻辑都围着这三张表转。会话表存一次对话的生命周期,消息表存每一帧内容,坐席表存账号和接客状态。

-- 会话主表:一次访客与坐席的完整对话周期 CREATE TABLE `conversation` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `app_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '站点标识,多租户隔离', `visitor_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '访客指纹,存在访客侧 cookie', `agent_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 表示仍处于排队中', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0排队 1进行中 2已结束 3超时关闭', `source_url` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '发起会话时访客所在页面', `created_at` INT UNSIGNED NOT NULL DEFAULT 0, `closed_at` INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_agent_status` (`agent_id`, `status`), -- 坐席工作台高频查询 KEY `idx_visitor` (`visitor_id`) -- 访客断线重连时找最近一条 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话主表';

source_url这个字段别小看,坐席工作台顶部显示的「对方正在浏览 xxx 页面」就是读它,二次开发里把它换成商品 ID 或订单号,坐席一眼就知道访客卡在哪一步。索引idx_agent_status是为坐席工作台那一句「我的进行中会话」准备的,缺了它坐席一多列表就卡。agent_id = 0表示排队中,这个约定贯穿整个分配模块,改分配逻辑时要先确认这个默认值没被别处复用。

消息表我一般只留必要字段,内容放 TEXT,附件走独立表或对象存储。字符集必须是utf8mb4,用utf8的后果是访客发个 emoji 直接写库失败,而前端不报错,现象就是「消息发出去了但坐席看不见」,这种问题排查起来相当费时间。

3. 长连接消息链路:一条「在吗」从访客发出到坐席弹出

3.1 握手鉴权:访客 ticket 与坐席 token 两种身份

握手阶段是整个链路唯一一次同步查库的机会,之后每一帧都只能靠 Redis 里的映射,所以身份校验必须放在这里做完。访客和坐席走两套凭证:访客用 HTTP 接口签发的一次性 ticket,坐席用登录后下发的 token。

<?php // 长连接服务的握手回调,校验不通过直接关连接,不保留半开状态 public function onOpen($server, $request) { parse_str($request->get['query'] ?? '', $query); $role = $query['role'] ?? ''; if ($role === 'visitor') { // ticket 由 HTTP 接口签发,Redis 里 5 分钟过期,用一次即删 $key = 'visitor_ticket:' . ($query['ticket'] ?? ''); $info = $this->redis->get($key); if (!$info) { $server->close($request->fd); return; } $this->redis->del($key); // 一次性,防止被复制重放 $identity = json_decode($info, true); } elseif ($role === 'agent') { $identity = $this->verifyAgentToken($query['token'] ?? ''); if (!$identity) { $server->close($request->fd); return; } } else { $server->close($request->fd); return; } // fd 与身份的双向映射写 Redis,多 worker 场景下靠它做跨进程寻址 $this->redis->hSet('online_fd', (string)$request->fd, json_encode($identity)); $this->redis->hSet("online_{$role}", (string)$identity['id'], (string)$request->fd); }

为什么不直接让挂件带visitor_id连上来:挂件跑在访客浏览器里,任何写死在前端的 ID 都可以被改,别人拿到你的 visitor_id 就能读你的会话历史。换成短过期的一次性 ticket,重放成本立刻抬上去。ticket 的有效期我一般设 300 秒,够页面加载完发起连接,又不至于被长期持有。

online_fd这个 hash 是整个系统的寻址表,push一条消息前先查它拿到目标 fd。这里有个容量隐患:hash 里的条目靠onClose回调清理,如果进程被kill -9杀掉,回调不会触发,Redis 里就留下一堆指向已关闭 fd 的僵尸记录。后面排查章节里的「坐席收不到新会话」八成是这么来的。

3.2 帧格式与心跳设计

所有消息共用一个 JSON 信封,type决定走哪个分支,业务字段平铺在顶层。这种设计谈不上优雅,但调试时肉眼可读,出问题抓个包就能定位。

// 客户端建连与心跳:30 秒一 ping,服务端 90 秒收不到判定掉线 const ws = new WebSocket(window.chatConfig.wsUrl + '?role=visitor&ticket=' + ticket); let heartbeat = null; ws.onopen = () => { heartbeat = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } }, 30000); }; ws.onmessage = (e) => { const packet = JSON.parse(e.data); if (packet.type === 'pong') return; // 心跳回包不渲染 renderMessage(packet); // 其余都是业务消息 }; ws.onclose = () => { clearInterval(heartbeat); setTimeout(reconnect, backoff()); // 指数退避,别裸重连 };

服务端的入口按type分发,这里必须做幂等。弱网环境下客户端重连后会把没收到回执的消息再发一遍,不做去重就是满屏重复。常见做法是客户端生成client_msg_id,服务端用 Redis 的set nx ex做五秒窗口内的去重:

public function onMessage($server, $frame) { $packet = json_decode($frame->data, true); if (!$packet || empty($packet['type'])) { return; } switch ($packet['type']) { case 'ping': $server->push($frame->fd, json_encode(['type' => 'pong', 'ts' => time()])); return; case 'msg': // 同一 client_msg_id 五秒内只处理一次 $dedup = 'msg_dedup:' . $packet['client_msg_id']; if (!$this->redis->set($dedup, 1, ['nx', 'ex' => 300])) { return; } $this->dispatchMessage($server, $frame->fd, $packet); return; } }

心跳间隔 30 秒、服务端超时 90 秒,这个 1:3 的比例不是随便定的:移动端切后台再回来,浏览器会冻结定时器,间隔太短会误判断线,太长又发现不了真掉线。服务端那侧一般不开 TCP 层 keepalive,交给业务心跳就行,能省掉一层排查困难。ts字段前端会用来算消息时间,服务端一律以落库时间为准,别信客户端传的。

3.3 先落库再广播,顺序不能反

这条链路是整个项目最核心的二十行代码,也是最容易改出问题的地方:

private function dispatchMessage($server, $fd, array $packet) { $from = json_decode($this->redis->hGet('online_fd', (string)$fd), true); // 1. 先落库拿到自增 ID,再广播,保证两端看到的顺序一致 $msgId = $this->messageModel->insert([ 'conversation_id' => (int)$packet['conversation_id'], 'from_role' => $from['role'], 'from_id' => $from['id'], 'content' => $packet['content'], 'content_type' => $packet['content_type'] ?? 'text', 'created_at' => time(), ]); $payload = json_encode(['type' => 'msg', 'id' => $msgId] + $packet); // 2. 找出这条会话的两端,谁在线推给谁 $conv = $this->conversationModel->find($packet['conversation_id']); $targets = [ ['role' => 'visitor', 'id' => $conv['visitor_id']], ['role' => 'agent', 'id' => $conv['agent_id']], ]; foreach ($targets as $t) { if ($t['role'] === $from['role'] && $t['id'] == $from['id']) { continue; // 不回推给发送方自己 } $targetFd = $this->redis->hGet("online_{$t['role']}", (string)$t['id']); if ($targetFd) { $server->push((int)$targetFd, $payload); } } }

先落库再广播,拿到自增 ID 一起下发,前端按id排序就不会出现「坐席看到的两条消息顺序颠倒」。反过来先广播后落库,一旦落库失败,访客以为自己发出去了,刷新页面消息就消失,这类 bug 复现概率低但用户投诉率极高。离线期间的消息不靠 Redis 缓存,一端重连后走 HTTP 接口按conversation_id拉增量,参数用last_msg_id做游标,比按时间戳翻页稳。

push失败要吞掉异常,目标 fd 可能刚好断开,此时 Redis 里的映射还没清理。吞掉之后靠onClose清 map 兜底,同时给会话打一个「对方已离线」的标记,工作台上显示出来。

4. 会话分配与排队:访客进来后到底该落到哪个坐席头上

4.1 排队池与最小负载优先

分配逻辑看起来简单,实际是整个系统里最容易出线上事故的地方。核心是一段从在线坐席里挑人的代码:

// 从在线且未满员的坐席里挑负载最低的一个,没有就进排队池 public function assign(array $conversation) { $onlineAgents = $this->redis->sMembers('agent_online'); if (!$onlineAgents) { // 用有序集合做排队,score 是入队时间,天然按先后出队 $this->redis->zAdd('wait_queue', time(), $conversation['id']); return 0; } $best = 0; $bestLoad = PHP_INT_MAX; foreach ($onlineAgents as $agentId) { $max = (int)$this->redis->hGet('agent_max', (string)$agentId) ?: 5; $load = (int)$this->redis->get("agent_load:{$agentId}"); if ($load >= $max) { continue; // 满员跳过 } if ($load < $bestLoad) { $best = (int)$agentId; $bestLoad = $load; } } if ($best === 0) { $this->redis->zAdd('wait_queue', time(), $conversation['id']); return 0; } $this->redis->incr("agent_load:{$best}"); // 占用一个名额 return $best; }

这里选的是最小负载优先,不是严格轮询。严格轮询实现简单,但老坐席处理快、新坐席处理慢,轮询下去慢的那位队列越积越长,用户等待时间明显变长。最小负载优先会让快的人多接,代价是慢的人可能一直空着,所以后台一般配一个「强制轮转」开关,班组长盯着的时候打开。

agent_max存在 Redis hash 里而不是每次读配置表,是为了让后台改完上限立刻生效,不用等坐席重新登录。agent_load是纯计数器,会话关闭时decr,进程崩溃时计数器不会归零,所以每次系统启动我都要跑一次校准脚本,把agent_load重置成各坐席当前status=1的会话数。

4.2 接客上限、离线转接与断线恢复

坐席的接客上限不是固定值,常见做法按工作时间分档:平时 5 个,大促期间调到 8 到 10。这个值调高之后,坐席端 UI 要跟着改,否则列表超过一屏就得滚动,回复速度反而下降。上限存在坐席表的max_concurrent字段,登录时同步进 Redis,后台改表之后发一条管理端消息触发刷新。

离线转接的逻辑是:坐席心跳超时被判离线后,把他名下status=1的会话全部打回排队池,agent_id置 0,重新走一次分配。这里必须做成幂等的,因为心跳超时检测是个定时任务,网络抖动时同一位坐席可能被连续判离线两次,第二遍再去处理会话就会把已经被别人接走的会话又抢回来。我一般给会话表加一个version字段做乐观锁,update ... where agent_id = 原值才算成功。

访客断线重连是另一条路。访客的visitor_id存在 cookie 里,刷新页面后凭它查最近一条未关闭会话,命中就直接续上,不新建会话。这一步走 HTTP 接口返回会话 ID 和 ticket,再拿 ticket 连长连接。少了这一步,用户刷新一次就是一个新会话,坐席工作台上一堆重复条目,看着就像系统坏了。

4.3 后台配置项怎么影响分配结果

后台几个开关看着不起眼,但互相之间有依赖,改之前得看清楚:

配置项默认值改了会发生什么
max_concurrent5调高排队变少但回复质量下降;调到 0 等于该坐席不接受新会话
assign_strategy最小负载切成轮询后快坐席会被压死,慢坐席队列变长
queue_timeout180 秒超时后会话状态置 3,访客端提示留言;调太短会频繁触发留言
auto_assign开启关掉后必须坐席手动「抢单」,适合售前售后分组明确的场景
agent_offline_grace60 秒心跳超时判定时长,调短了网络一抖就集体下线

queue_timeout和agent_offline_grace是一对容易搞混的参数:前者管访客排队多久放弃,后者管坐席多久没心跳算掉线。有人把 180 和 60 填反,结果是坐席一分钟就掉线、访客三分钟就超时,全程没人接得上。改配置之前先确认这两行的单位是秒不是分钟,血泪经验。

5. 部署与排查:本地跑通到上线掉线的五个真实坑

5.1 环境准备与启动顺序

依赖是三样:PHP 运行时(7.x 以上)、MySQL、Redis。启动顺序有讲究,长连接进程启动时会回读坐席表来初始化 Redis 里的映射,数据库没准备好它起来就是个空壳。

# 1. 先导表结构,再灌演示数据,顺序反了外键和初始坐席都会缺 mysql -u root -p chat_db < sql/schema.sql mysql -u root -p chat_db < sql/demo.sql # 2. 复制配置并修改:数据库账号、Redis 地址、对外 ws 地址 cp config/config.example.php config/config.php vi config/config.php # 3. runtime 目录权限必须给足,写不进日志时进程会静默退出 chmod -R 777 runtime # 4. 先起 HTTP 服务,再起长连接 php -S 0.0.0.0:8080 -t public php bin/start.php start -d # 5. 两个端口都要活:8080 是接口,9502 是长连接监听端口 ss -lntp | grep -E '8080|9502'

本地用php -S内置服务器够跑通,但内置服务器是单进程的,压测时所有接口排队,别把性能问题算到项目头上。上线换成 php-fpm 加反向代理,长连接进程独立跑,别塞进 fpm 里,fpm 的请求生命周期模型压根不适合长驻连接。

反向代理那层要做两件事:把/ws路径升级成 WebSocket 转发到 9502,并在这一层终止 TLS。转发时Upgrade和Connection两个头必须显式透传,少配一行握手就 400,浏览器只给你一句「WebSocket connection failed」,日志里什么都没有。

提示:runtime/目录用 777 只是本地图省事,上线按运行用户和组设 755,别把权限问题带上去。

5.2 五个踩坑记录:从现象到解决

坑一:挂件在页面上完全不出现,控制台没有任何报错。现象是脚本加载了但气泡不渲染。原因通常是站点开了 CSP,script-src白名单里没有挂件域名,浏览器直接拦掉,且不抛 JS 异常。解决办法是先把console面板的 Network 过滤到 JS 看一眼请求有没有发出去,没有就是 CSP 或广告拦截插件干的,白名单里加上挂件域名即可。

坑二:连接建立成功但一发消息就断,重连后依旧。现象是握手成功、心跳正常,发文字就掉线。原因多半是 ticket 被复用:页面刷新后前端没重新调接口换 ticket,拿着旧的一次性凭证又连了一次,服务端校验失败直接close。改法是访客端每次new WebSocket之前必须走一次 HTTP 接口签发新 ticket,把签发和连接绑在同一个流程里。

坑三:访客显示排队中,坐席工作台一条新会话都不弹。现象是 Redis 里wait_queue一直涨,agent_online里有坐席。原因通常是坐席进程被强杀过,online_fd里残留僵尸映射,分配时把会话算给了已关闭的 fd,push静默失败。解决分两步:短期手工清online_fd里指向不存在 fd 的记录,长期在onClose之外加一个定时巡检,比对实时连接列表和 Redis 映射并做差集清理。

坑四:反向代理之后握手一直 400。现象是本地直连 9502 一切正常,套上反向代理就连不上。原因是代理层没有透传升级头,或者把长连接当普通 HTTP 请求做了缓冲。解决是在代理配置里显式加上Upgrade: websocket和Connection: upgrade的透传规则,同时关掉该路径的响应缓冲。这个坑几乎每个项目上线时都要踩一遍。

坑五:消息内容里的 emoji 变成问号,部分消息直接丢失。现象是纯文字正常,带表情的消息坐席收不到。原因是表字符集用了utf8,四字节字符写库报错,而分发逻辑没捕获异常,整条消息被吞。改法是把库、表、连接三处字符集统一成utf8mb4,历史表用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4迁一遍,同时给落库处补上异常捕获和告警。

6. 二次开发:把对话接进自己业务的三处改动

第一处是访客身份打通。默认挂件只带一个前端生成的visitor_id,对业务方来说这就是个匿名串。改法是在业务页面把登录用户的 ID 塞进visitorTag,服务端在签发 ticket 时用共享密钥做一次签名,把uid写进 ticket 载荷,握手时解出来落到会话表的扩展字段上。前端传的uid只能当提示,真身份必须由服务端签,否则谁都能冒充别人查历史会话。

// 签发 ticket 时把业务身份签进来,前端改不动 $payload = json_encode(['uid' => $uid, 'app_id' => $appId, 'exp' => time() + 300]); $sig = hash_hmac('sha256', $payload, $config['ticket_secret']); $this->redis->setex("visitor_ticket:{$ticket}", 300, $payload . '|' . $sig);

第二处是消息侧挂业务回调。会话结束、访客留言、关键词命中这三类事件最适合往外推,推给自己业务的工单或 CRM。做法是在service/里加一个事件分发,别直接在dispatchMessage里写 HTTP 请求,那会把消息主链路拖死。异步队列或者干脆写一张event_outbox表再由定时任务消费,都比同步调用稳。

第三处是挂件形态。默认是 iframe 内嵌,好处是样式完全隔离,坏处是跨域通信麻烦、首屏会闪一下白块;换成 Shadow DOM 能贴着业务页面的设计走,但要自己处理全局样式穿透。我一般售前咨询页用 iframe 图省事,产品内部嵌入用 Shadow DOM,因为内部系统对视觉一致性要求更高。

验证改造有没有改坏,最省事的办法是写两条脚本对打:一条模拟访客建连发消息,一条模拟坐席收消息,跑一遍完整会话生命周期。这个脚本我一般放在bin/下留着自己用,比开两个浏览器点来点去快得多。从那以后我每次动分配逻辑之前,都强制把这两条脚本跑一遍 —— 分配这块看着简单,改错一个判断条件就是全站客服接不到会话,事后追日志的痛苦远超写脚本那十分钟。希望帮到你。

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

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

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

立即咨询