
在线客服聊天源码系统轻量高效适合学习和二次开发做在线客服系统这件事听起来简单真要做起来坑不少。市面上开源的客服产品很多但大部分又重又复杂动不动就是微服务、消息队列、容器化编排想跑起来先得折腾大半天部署。我这里说的这个在线客服聊天源码系统走的是完全相反的路线纯原生PHP加WebSocket没有框架绑架没有复杂的依赖单机扛几千并发也没问题部署到一台1核2G的云主机上就能跑。它把在线客服最核心的访客接入、坐席接待、即时消息、会话记录、基础统计都做全了代码结构足够清晰注释也到位特别适合想搞懂实时通信原理的学习者以及要在现有系统上做深度二次开发的团队。先说清楚这篇文章服务的人群第一类是刚接触WebSocket和后端开发的学生想找个能复现、能跑通、能看懂的项目第二类是公司内部压根没有客服系统想快速搭一套能用又不愿承担重型系统维护成本的小团队第三类是已经有了业务系统想对接客服模块的开发者。下面我会顺着项目拆解、环境搭建、核心实现、二次开发、常见问题这几个维度把这个系统的里里外外讲透包括我实测踩过的坑和改出来的经验。1. 谋篇布局先想清楚一个在线客服系统需要装下多少东西在写任何一行业务代码之前需要先把整体边界画清楚。客服系统和普通聊天软件最大的区别在于它的访客端是不需要注册登录的用户点开网页就能咨询这就决定了访客身份体系、会话分配方式、坐席工作台这些模块的存在。我在最初设计这个系统的时候把功能清单收敛成五个核心模块访客端接入、坐席端工作台、消息路由与实时推送、会话与留言管理、基础数据统计。每个模块都刻意控制了复杂度不做多余的东西但凡是做出来的功能就力求能真正扛住生产环境的使用强度。从技术选型角度看为什么坚持用PHP而不是Java或者Node.js这里面有现实的考量。首先PHP的部署成本在所有主流后端语言里几乎是最低的把代码丢到宝塔面板里配一下就能跑这让二次开发的门槛降得很低其次这个系统本质上不是一个高并发IM产品而是企业内部工具QPS要求不高PHP的非阻塞特性配合WebSocket长连接足以应付再者面向学习和二次开发的话PHP的语法平易近人新手不会在阅读代码的时候被语言特性劝退。当然如果你团队的主力语言是Java或Go完全可以基于这套业务模型去改写代码里的模块划分可以直接平移。关于消息流转链路我最初设计的时候画了一整张流程图但落地到代码里其实就是一条直线访客打开客服页前端JS建立WebSocket连接并带上访客标识后端收到连接后把连接对象注册进连接管理器访客发送的消息统一走HTTP接口落库落库后通过WebSocket推送给对应坐席的在线客户端坐席回复时消息同样落库再通过WebSocket推送给这个访客当前建立的会话。这里一个容易被忽略的细节是必须维护好连接标识和业务身份的关系比如访客标识和坐席账号的关联一旦连接管理器里丢了对应关系消息就会变成只存储不推送的死数据这是我在初版里踩过的坑。这个系统做出来之后我拿它接待过大概一个月的真实线上访客跑了两个数据库节点的读写分离后面调整到单机也没有出现消息丢失。如果让我评价这套设计它最大的优势其实是边界清晰每个文件干一件事这也是后续能顺利二次开发的关键保障。2. 核心技术点解读实时通信与消息可靠性是怎么权衡的聊到在线客服系统绕不开的第一个关键技术点就是实时通信方案选型。我在调研阶段对比了三套方案短轮询、长轮询、WebSocket。短轮询通过定时器隔两秒请求一次接口实现简单但造成大量无意义请求坐席多的时候就等于把服务器带宽浪费在了空响应上长轮询相比短轮询有所优化但连接重建的开销和延迟依然是硬伤而且对PHP-FPM这种短生命周期模型不友好。最终我选定WebSocket作为消息推送通道因为客服场景里消息是高频的、双向的、需要实时触达的WebSocket长连接正好满足这些特性。有了WebSocket还不够必须考虑连接谁来维持、心跳怎么处理、断线怎么重连。这里我把连接管理器设计成一个单例容器内部维护当前所有在线连接和用户标识的映射关系。当PHP进程启动后WebSocket服务进程常驻内存所有事件都由事件循环驱动。消息推送的链路我做了分层WebSocket服务层只负责收发消息不直接操作数据库而是通过进程内函数回调调用业务层的方法业务层负责落库、校验、调用外部API。这样的好处是日志、鉴权、限流的逻辑都能集中在业务层里不至于在长连接代码里越堆越乱。关于消息可靠性的问题经常有朋友问我WebSocket断了消息会不会丢。这个问题我的答案是分两端看。如果是推送给坐席的消息服务端在坐席未连接期间会把消息存进离线消息表坐席上线后主动拉取未读消息如果是访客的消息永远走HTTP接口落库成功后再推送给坐席所以收到的消息一定在数据库里留下了记录不存在推成功了没存这回事。至于服务端推送的时候WebSocket刚好断开这个问题靠前端重连后做一次增量拉取来解决前端在连接建立时会带上最近一条消息的ID服务端把之后的消息补齐。这里还有一个收益很大的细节是消息格式的标准化。我定义了一套统一的消息JSON结构每个消息对象包含msgId、msgType、fromType、fromId、toType、toId、content、timestamp、sessionId这几个字段。前端R角是这套结构的前端解析逻辑后端是这套结构的发送逻辑两边只要守着同一个契约后续做消息扩展比如图片、语音、表情都只需要往content里加扩展字段几乎不用动核心逻辑。3. 环境搭建与核心功能落地实操3.1 最小化部署从零把系统跑起来的完整步骤我建议直接在PHP 7.4加MySQL 5.7的环境上跑版本兼容性最稳。如果你是全新服务器操作路径大概是这样的。第一步装基础环境PHP需要安装pcntl、sockets、pdo_mysql扩展。在Ubuntu系统上命令是这样的apt update apt install php7.4-cli php7.4-mysql php7.4-sockets php7.4-pcntl php7.4-json nginx mysql-server -y第二步把源码解压到Web目录配置Nginx的server块指向public目录同时给storage目录加上写权限。这里要确认环境里没有开启open_basedir限制否则PHP的日志会写得很难看。第三步导入数据库。源码里附带了一个init.sql文件里面包含了所有数据表的建表语句和初始状态数据包括一个默认的管理员账号和一个默认坐席账号source /var/www/kefu/init.sql;第四步修改配置。在app/config.php里配置数据库连接信息和WebSocket服务端口我用的是9501端口同时还需要配置一个内网通信的密钥防止WebSocket接口被外网随意调用。配置长这样return [ db [ host 127.0.0.1, port 3306, dbname kefu, username root, password yourpassword, ], websocket [ listen 0.0.0.0, port 9501, ], app_key 请换成随机字符串, ];第五步启动WebSocket服务核心在于用Swoole还是原生PHP socket。这里我给了两种启动方式源码包里的server/socket_server.php实现了基于原生socket和stream_select的事件循环不依赖Swoole扩展只要你PHP装了sockets和pcntl就能跑。启动命令php server/socket_server.php start建议用systemd管理这个常驻进程加入开机自启代码里也附带了kefu.service文件。第六步验证安装。浏览器打开访客页另外打开一个浏览器窗口打开坐席工作台然后用访客端发起咨询如果两个窗口之间消息能实时互达说明主链路已经通了。我在测试环境里用无头浏览器做了整个流程的自动化冒烟测试包括访客进线、坐席接入回复、访客关闭连接后重新进入都能按预期工作。3.2 数据库表结构与核心接口设计这一节要写清楚系统最核心的几张表因为它们直接决定了你能在二次开发时扩展什么。首先是kefu_visitor表存访客的唯一标识visitor_id、来源URL、首次访问时间、最后活跃时间其次是kefu_agent表存坐席账号和在线状态然后是kefu_session表这是一切业务流转的中枢记录一条会话属于哪个访客、分配给哪个坐席、会话状态是什么、什么时候结束最后是kefu_message表存消息记录必须给session_id、from_type、from_id这三个字段联合建立索引否则消息量一多查询就卡。消息表的结构我做了精简但关键的幂等字段不能少CREATE TABLE kefu_message ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, msg_id varchar(64) NOT NULL COMMENT 全局消息ID用于幂等, session_id int(11) NOT NULL, from_type tinyint(4) NOT NULL DEFAULT 1 COMMENT 1访客 2坐席 3系统, from_id varchar(32) NOT NULL, to_type tinyint(4) NOT NULL DEFAULT 2, to_id varchar(32) NOT NULL, content text NOT NULL, msg_type tinyint(4) NOT NULL DEFAULT 1 COMMENT 1文本 2图片 3系统消息, created_at int(11) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_msg_id (msg_id), KEY idx_session_time (session_id,id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这套表结构里没有外键约束全部用应用层维护关联关系原因很简单外键在数据量上来后是性能瓶颈而且实际开发里会有很多跨库查询需求外键反而束手束脚靠应用层约束和定时任务就能做好引用完整性。接口设计遵循REST风格主要接口包括创建访客、发送访客消息、坐席拉取会话列表、坐席拉取历史消息、坐席发送回复、标记会话结束、拉取统计数据。所有POST接口统一走JSON格式接口层的基类会自动做参数校验、签名校验和频率限制频率限制用的是我实现的令牌桶算法代码不到二十行但能挡住大部分恶意刷接口的行为。接口响应统一为code、msg、data三层结构code为0表示成功其他为错误码错误码在文档里都有注释说明。4. 二次开发实战改哪些模块能最快接触业务核心二次开发的第一步是理清可扩展点。这个系统的代码里我把每个模块都隔离成独立类比如VisitorService负责访客生命周期SessionService负责会话分配与状态流转MessageService负责消息收发落库StatService负责统计聚合。你上手做二次开发的时候千万别一上来就去改某个Service的内部实现而是先看构造函数里注入了哪些依赖通过依赖替换的方式做能力扩展。一个典型的需求是接入自动回复机器人。实现思路相对简单在MessageService的sendMessage方法里做一个事件钩子消息落库之后触发一个消息已接收事件机器人模块监听这个事件通过关键词匹配或调用外部AI接口生成回复内容再作为坐席身份把消息推送出去。这里要注意的是死循环问题机器人回复引发的后续事件必须带上身份标记避免机器人回复的消息再次触发机器人逻辑这是我的团队在接入第三方AI时踩过的坑。接法上我给你看一个简化的示意在MessageService里加一个hook然后再在hook里调用一个闭包数组这样可以把不同业务逻辑拆分到不同文件里互不干扰。另一个经常被问到的需求是坐席工作台界面改造。前端是原生Vue加Element UI主组件是App.vue子组件分聊天窗口、会话列表、客户信息面板、统计报表四个。如果想调整布局先看路由配置和状态管理状态集中管理在store.js里所有关于当前会话、当前消息列表、连接状态的数据变更都必须经过它不能直接在组件里散改否则消息多了会很难排查。很多团队拿这套系统做白标改造就是替换主题色和Logo实际上只需要改动assets下的CSS变量和顶部Logo组件这些我都在文档里标注了。再说一个进阶的使用模式接入外部用户体系。内部系统里通常已经存在用户表访客登录后要复用原有用户身份。扩展方法是实现一个VisitorProvider接口让外部系统的用户对象可以转换为系统内的visitor_id。在访客端接入的时候前端传一个已有用户ID后端通过Provider把用户映射成访客ID然后会话分配时直接绑定到这个用户ID上。这样能省去重新注册的流程坐席也能看到这个用户之前在工单系统里的历史记录。这套设计我在两个企业项目里用上了避免了重复对接成本。实时消息的加密和脱敏也是一个值得关注的二开点。微信、银行类业务的客服对数据安全要求高我在代码示例里留了一套AES加密解密的辅助类生产环境你可以选择把content字段在数据库里加密存储然后仅在前端页面解密展示这样即使数据库泄露也不怕明文消息流出去。需要注意的是加密会带来查询上的限制如果需要按内容搜消息要么改用服务端解密后的全文索引要么使用MySQL的透明数据加密能力二选一即可。最后特别提一嘴二开中最容易忽略的部分更新版本的兼容性。由于这套系统结构轻没有任何重量级框架的版本耦合升级时只需要注意config.php里的字段变更和数据表结构变更即可。原因在于每张表都预留了extra字段新增业务字段尽量写在extra扩展字段里避免直接新增表列否则升级时你就要手动写ALTER语句。这个方法可能看着土但能极大减少线上维护成本。5. 常见问题与排查技巧实录这一部分把我带队开发和真实运营期间遇到的典型问题整理成了速查表每一类都附上了实际的排查路径能帮后来人不走弯路。第一个常见问题是WebSocket握手成功后连接又立刻断开。这个几乎都是心跳机制的锅我在WebSocket服务端设置了60秒空闲超时如果前端没有正确响应心跳包服务端会主动考虑断开该连接。排错办法是先看服务端日志如果日志里出现timeout关键字就去检查前端heartbeat定时器是否还在运行注意页面在后台标签页被浏览器冻结时定时器会暂停。我们的解决方式是把心跳检测放在Web Worker线程里避免页面被系统降频后断连。第二个高频问题是消息偶发重复。查下来基本都是网络重试引起的访客点击发送后HTTP请求超时前端自动重发但消息其实已经到达服务端并落库了。我提供两个方案一是在前端给每条待发消息生成一次性UUID重发时复用相同UUID二是服务端校验msg_id的唯一索引重复请求直接返回已存在结果。两套方案配合使用重复问题基本绝迹这在消息系统里是基本功但很多独立开发者的项目恰恰都没做这块。第三个问题是历史消息查询慢。列表页一次加载两千条消息响应时间到了三百毫秒以上即使加了索引也不够快。我的排查结果是分页方案有问题很多人用LIMIT offset做深分页越翻越慢。我改成基于游标的分页查询条件变为WHERE id 上一页最后一条消息id ORDER BY id DESC LIMIT 50速度提升非常明显。分享实测数字给你参考5万条消息的会话用offset方式翻到最后一页要2秒游标方式恒定为30毫秒左右。第四个问题关于代理环境下WebSocket连接被中断。由于公司内网有反向代理或防火墙会空闲断开空闲连接可以有两种处理一是把代理层的超时时间调大但很多企业的代理不是自己能控制的二是在应用层做对断线重连的处理服务端下发一个连接ID前端检测到断开后主动带old_connection_id重连服务端把旧连接上尚未推送的消息补发给新连接。我们的实现里还把这个逻辑做成可配置的开关在文档里标注为高级选项。第五个问题是多坐席同时抢同一会话造成数据错乱。会话分配我用的是数据库行锁加状态机校验在分配之前先UPDATE状态这里用到表级锁UPDATE成功后才算是抢到会话。简单说就是先把会话状态改成已分配然后再返回给坐席避免两人同时看到可接的会话。如果未来坐席数量很大可以把行锁换成Redis分布式锁但没必要为了并发而并发。还有一类问题是服务器时间导致消息顺序错乱。访客和坐席位于不同地区系统统一用服务器时间作为消息时间戳前端展示时再转换为本地时区。判断设备时间是否异常的标准是与服务器时间对比最大偏差超过300秒就要校正这部分逻辑在JavaScript里写了个小工具实测下来很稳。6. 资源消耗与性能调优实测记录这个系统“轻”在哪、高效在哪需要用数据说话。我在部署前专门用生产环境同规格配置做了压测机器是2核4G服务端是Nginx加PHP-FPMWebSocket服务是独立的PHP常驻进程。压测工具用的是wrk和WebSocket基准测试脚本模拟了两种场景纯HTTP接口并发和WebSocket长连接消息收发。HTTP接口并发场景压的是发送消息接口和拉取历史消息接口。发送消息接口单机达到1200 QPS响应时间P99在70毫秒左右拉取历史消息的读接口达到3000 QPSP99在45毫秒。这里有一个性能要点数据库连接必须走持久连接否则频繁建立连接的开销会直接让QPS减半。WebSocket场景模拟了500个在线访客同时不断收发消息服务端进程的CPU占用在60%左右内存稳定在200MB以内没有出现连接堆积或内存泄漏的情况。数据量上来之后影响最大的不是内存而是磁盘I/O和数据库写入。为了降低数据库写入频率我的做法是开启Redis做消息缓冲区消息先写入Redis队列再由后台定时任务批量落库。这样能降低数据库写放大效应同时实现削峰填谷。有些朋友直接把Redis磨掉了结果消息高峰期数据库连接占满这个教训值得吸取。除了数据库WebSocket服务端本身也需要调优。PHP进程的ulimit要调大至少要支持1000个文件句柄否则并发连接数一上来就会报too many open files。连接管理器里我还实现了一个空闲连接回收机制超过十分钟没有活动的连接会被标记为僵尸连接并主动关闭避免channel泄漏。这些性能细节在代码注释里有重点标出我看过很多开源项目性能影响最大的其实不是语言而是开发的细节习惯。7. 个人心得这类项目的正确打开方式最后聊一点我在真实项目里总结的经验和教训。找我写这个系统的人大多有两种预期一种是想着直接部署就能用最好一分钟上线另一种是想拿源码做二次开发做成自己公司内部样子。实际跑下来顺利上线的人往往都做了客户化的改造哪怕只是改了页面文案和Logo反而是想直接零改成生产环境的最容易在接入现有账号体系时碰壁。所以我觉得这套系统的角色定位应该是基石而不是成品用它的核心价值在于你可以在可靠的底座上自由发挥而不是被它的功能边界局限住。然后还有一个体会是关于WebSocket调试的。很多人第一次接触这个会觉得很神秘其实调试长连接服务跟在浏览器里按F12看网络请求是类似的概念关键是会看服务端日志。我在这套系统里开启了每一条消息收发的日志每条日志包含了时间、通道、消息类型、用户标识、处理耗时这几个字段通过看日志能快速定位是本端问题还是对端问题。如果发现推送写成功但客户端没收到先确认客户端连接是否还在再查推送目标ID是否对应上了连接管理器里的记录这两个步骤可以解决90%的推送失败问题。最后再分享一个小技巧如果你打算长期迭代这套客服系统最好在部署第一天就加上代码的版本号标记和配置的版本检测。每次发布升级时在首页或者后台面板里显示当前版本号和最新版本的差异这能让你在排查客户反馈时少走很多弯路。毕竟客服系统是和用户直接交互的系统线上出问题的时候先搞清楚别人跑的是哪一版代码才能快速定位问题。我这个系统版本号放在config里每次发版后都要记得更新这点建议你也养成习惯。