简介这是一套面向中小企业、创业团队及具备基础开发能力者的开源在线客服管理系统源码聚焦网页、移动端等渠道的实时会话接入、客服工作台、常见问题自动回复、服务数据统计与CRM/ERP对接等场景可用于搭建或二次开发自有客服平台。压缩包为zip格式体积约7.78MB文件总数与类型明细暂未提供解压后可按项目结构查阅源码与配置。已有476人学习下载说明其在开源客服系统选型与二次开发方面具备一定参考价值。读者可从中了解在线客服系统的模块划分、前后台交互、工单/会话管理、报表分析、多语言与权限安全等实现思路并基于开放源码进行功能裁剪、界面调整、性能优化或与现有业务系统集成适合作为学习客服系统架构、评估开源方案及低成本落地的实践素材。1. 一套在线客服源码真正的门槛不在那个聊天气泡把在线客服管理系统跑起来多数人第一晚就卡在同一件事上窗口弹出来了消息发出去却没有回音。真正支撑起体验的是会话路由、消息落库和长连接网关这三块聊天气泡本身只是外壳。这套开源版在线客服管理系统的源码把三块拆成了独立目录访客端 SDK、坐席工作台、分配服务、网关进程各管一摊改一处不用推倒重来。它属于典型的「软件/插件」类资源交付形态是能嵌进现有站点的挂件不是另起一个 App。适合两类人公司内部想自建客服台、被 SaaS 按坐席数计费卡住的后端以及接私活需要一套带后台、能二次开发底子的开发者。2. 解压与环境搭建从目录结构到第一条能走通的消息拿到压缩包别急着找 index.php。这类系统跑不起来九成不是代码问题而是长连接进程没起、库表没迁、反代没配 Upgrade 头。按下面顺序走一般半小时内能看到坐席端和访客端互相对话。2.1 先认清四层结构哪一层是你真正要改的解压后先看目录树别急着改代码。开源客服系统普遍是「Web 入口 业务层 常驻网关 前端 SDK」四段式弄清哪段是常驻进程、哪段是请求响应后面排错能省一大半时间。# 只看两层目录避免被 vendor / node_modules 淹掉 unzip kefu-open-source.zip -d kefu cd kefu find . -maxdepth 2 -type d -not -path ./vendor* -not -path ./node_modules* | sort正常会看到这样的骨架./app/Http/Controller # 坐席端、访客端 REST 接口 ./app/Service # 会话分配、消息分发、回执 ./app/Model # 数据模型 ./config # 路由、数据库、网关配置 ./database/migrations # 表结构版本文件 ./gateway # 长连接网关独立常驻进程 ./public # Web 入口与静态资源 ./sdk # 访客端 embed.js ./.env.example目录干什么你后续改动频率app/Service分配策略、消息落库、已读回执高业务定制基本都在这sdk访客端挂件嵌到别人站点里高样式和身份透传要改gatewayWebSocket 常驻进程管连接和推送中多数是调参数database/migrations表结构版本改字段走这里中public入口和静态资源基本不动低判断一段代码是不是「常驻」很简单gateway下的进程用命令行启动、不经过 Nginx 的 PHP-FPM它挂了页面还在、但消息不推。这个区别记住了后面 80% 的「消息不弹窗」都从这里查起。2.2 三件套配置PHP、MySQL、Redis 的最小闭环依赖无非是 PHP 8.1、MySQL 8、Redis 7PHP 侧要开pdo_mysql、redis、pcntl、posix四个扩展——最后两个是网关进程用来做多进程和信号处理的缺了会报「启动即退出」而且错误日志里未必写清楚属于典型的不报错故障。php -v php -m | grep -E pdo_mysql|redis|pcntl|posix # 四个都要有输出 composer install --no-dev --optimize-autoloader cp .env.example .env配置文件按下面这套值改重点是三个时间参数要成梯度别各写各的APP_ENVprod APP_URLhttps://kf.example.com DB_HOST127.0.0.1 DB_PORT3306 DB_DATABASEkefu DB_USERNAMEkefu DB_PASSWORDchange_me REDIS_HOST127.0.0.1 REDIS_PORT6379 REDIS_PREFIXkefu: WS_LISTEN0.0.0.0:8282 WS_HEARTBEAT25 SESSION_TTL1800WS_HEARTBEAT25是网关向客户端发心跳的间隔SESSION_TTL1800表示访客半小时无动作就判定离线并释放坐席占用。我一般把前端心跳设成 30 秒、网关心跳 25 秒、Nginx 的proxy_read_timeout设 120 秒三者从小到大排开任何一层先超时都会造成「连接看着在、消息其实发不出去」的假在线。这个梯度是排查线上问题的第一把尺子。2.3 首启迁表、起网关、配反代一条消息走通库表用迁移文件生成不要手工建表否则后续升级对不上。启动顺序也有讲究先迁表再起网关最后开 Web。# 1. 建库并迁表 mysql -uroot -p -e CREATE DATABASE kefu DEFAULT CHARSET utf8mb4; php bin/console migrate --seed # 入口名以实际为准看 bin/ 下有什么 # 2. 启动长连接网关-d 后台常驻 php gateway/server.php start -d php gateway/server.php status # 确认进程数一般是 CPU 核数 # 3. Web 侧交给 Nginx nginx -t nginx -s reloadWebSocket 必须走反向代理直接暴露 8282 端口在多数机房会被拦。Nginx 里这两段不能少Upgrade和Connection头丢了握手会一直停在 101 之前server { listen 80; server_name kf.example.com; root /data/www/kefu/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } # 长连接单独一段超时必须大于网关心跳间隔 location /ws { proxy_pass http://127.0.0.1:8282; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 120s; } }提示proxy_read_timeout默认 60 秒比大多数前端心跳间隔还短不改的话表现为「聊了大概一分钟突然收不到消息刷新又好了」。这是最常见的一类假故障。起来之后按三步自测浏览器 A 打开坐席工作台并置为在线浏览器 B 打开带 SDK 的测试页发一句话回到 A 看是否秒收并回一条B 端应立刻出现。三步全通说明路由、落库、推送三条链路都是活的可以进入下一步调业务。3. 会话路由与消息通道坐席怎么分、消息怎么不丢跑通之后才是真正要动脑的部分。客服系统好不好用取决于两件事访客进线时被分给谁以及一条消息从发出到对方看见之间发生了什么。前者是策略问题后者是数据一致性问题两块都改错了表现往往不是报错而是「偶尔怪怪的」。3.1 分配策略三选一轮询、最少会话、技能组开源版通常内置不止一种策略。轮询最简单一人一条往下发坐席忙闲不均最少会话按当前负载排序体验最好但每次分配都要读负载字段并发下容易打架技能组是把访客先按来源分池售前、售后、技术池内再按负载挑人实施成本高但最贴近真实业务。我一般让中小团队直接用「技能组 最少会话」组合SQL 一条搞定配合行锁防并发?php // app/Service/AssignService.php namespace App\Service; use PDO; class AssignService { public function __construct(private PDO $db) {} /** * param int[] $groupIds 技能组 id 列表空数组表示全组 */ public function pick(array $groupIds): ?array { $this-db-beginTransaction(); try { $in implode(,, array_fill(0, count($groupIds), ?)); $sql SELECT id, max_concurrent, current_load FROM agent WHERE status 1 AND group_id IN ($in) AND current_load max_concurrent ORDER BY current_load ASC, last_accept_at ASC LIMIT 1 FOR UPDATE; $stmt $this-db-prepare($sql); $stmt-execute($groupIds); $agent $stmt-fetch(PDO::FETCH_ASSOC); if ($agent) { // 先占坑再返回避免同一坐席被两个访客同时选中 $this-db-prepare( UPDATE agent SET current_load current_load 1, last_accept_at NOW() WHERE id ? )-execute([$agent[id]]); } $this-db-commit(); return $agent ?: null; } catch (\Throwable $e) { $this-db-rollBack(); throw $e; } } }FOR UPDATE是这段代码的核心不加行锁两个请求同时读到current_load3各自加 1 写回实际却只增加了 1坐席就被超分了。ORDER BY current_load ASC, last_accept_at ASC的第二排序键保证负载相同时优先给最久没接单的人避免新人永远接不到活。参数建议值说明max_concurrent58超过 8 个人同时聊坐席实际响应会明显变慢current_load运行时维护会话结束时必须减 1漏减就会「永久满载」last_accept_at每次分配更新用于同负载下的公平性排序排队上限按访客量定无空闲坐席时进排队表别直接拒绝会话结束时的释放逻辑要写在同一个事务里并且加一条兜底网关侧检测到访客SESSION_TTL超时后也要触发一次释放。只靠坐席点「结束会话」一旦坐席直接关浏览器这个坑就会一直占着名额属于血泪经验。3.2 两张表撑住会话状态消息落库与已读回执消息不丢的前提是先落库再推送。顺序反了推送成功但写库失败坐席看到了、刷新就没了这类问题最难复盘。表结构不用多复杂两张表够用CREATE TABLE conversation ( id bigint unsigned NOT NULL AUTO_INCREMENT, visitor_id varchar(64) NOT NULL, agent_id int unsigned DEFAULT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0排队 1进行中 2已结束, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_agent_status (agent_id, status), KEY idx_visitor (visitor_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE message ( id bigint unsigned NOT NULL AUTO_INCREMENT, conversation_id bigint unsigned NOT NULL, sender_type tinyint NOT NULL COMMENT 1访客 2坐席 3系统, content text, client_msg_id varchar(64) DEFAULT NULL COMMENT 客户端生成用于去重, is_read tinyint NOT NULL DEFAULT 0, created_at datetime(3) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_client (client_msg_id), KEY idx_conv (conversation_id, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;client_msg_id上的唯一索引是后悔药弱网下客户端重发、网关重推同一句话可能进两次有唯一键就直接INSERT IGNORE多出来的一条被数据库挡掉比在应用层去重靠谱得多。datetime(3)精确到毫秒同一秒内多条消息的排序不会乱。未读数是坐席列表里最常被查的字段别用count(*)全表扫-- 坐席端会话列表一次查出每个会话的未读条数 SELECT conversation_id, COUNT(*) AS unread FROM message WHERE conversation_id IN (101, 102, 103) AND sender_type 1 AND is_read 0 GROUP BY conversation_id;已读回执则是一条批量更新坐席打开某个会话时把该会话里访客的消息全部置读UPDATE message SET is_read 1 WHERE conversation_id ? AND sender_type 1 AND is_read 0;消息表是增长最快的表日活几千访客的系统一个月就能到百万级。常见做法是按created_at做冷热分离三个月前的归档到历史表在线只查热表idx_conv这个联合索引保证按会话翻页始终走索引不会因为数据量涨起来而变慢。3.3 网关保活心跳、指数退避重连与消息补偿前端这一层最容易被写烂。只写new WebSocket(url)加个onmessage网络一抖就永久掉线用户以为客服不理他。合格的重连逻辑要有三样定时心跳、指数退避、重连后按游标补拉。// sdk/socket.js const KF { ws: null, retry: 0, connect(url, onMessage) { this.ws new WebSocket(url); this.ws.onopen () { this.retry 0; // 心跳间隔要比网关的 WS_HEARTBEAT 略大避免无谓重连 this.timer setInterval(() { this.ws.send(JSON.stringify({ type: ping, t: Date.now() })); }, 30000); }; this.ws.onmessage (ev) { const msg JSON.parse(ev.data); if (msg.type pong) return; // 心跳应答不往上抛 onMessage(msg); }; this.ws.onclose () { clearInterval(this.timer); this.retry 1; const wait Math.min(30000, 1000 * 2 ** this.retry); // 2s,4s,8s…封顶 30s setTimeout(() this.connect(url, onMessage), wait); }; } };退避必须有上限。不加Math.min断网十分钟后重连间隔会算到几十分钟用户回来发现客服窗口「死了」。封顶 30 秒是经验和体验的折中。重连成功只是连上了断线期间的消息还在服务端躺着得主动补// 重连成功后补拉用最后一条已知消息 id 做游标避免重复拉全量 async function syncMissed(conversationId, lastId) { const res await fetch( /api/messages?conversation_id${conversationId}after_id${lastId}limit50 ); const list await res.json(); list.forEach(renderMessage); return list.length ? list[list.length - 1].id : lastId; }after_id游标比时间戳可靠时间戳在同毫秒多条消息时会漏。补拉接口要限流别让客户端一次拉一万条limit50加循环即可。4. 访客端挂件与坐席工作台嵌进站点、认出是谁、转得出去系统自己跑通只算一半真正上线是把它塞进别人的站点里还得认得出访客是谁否则坐席每次都要问「您是哪位」。这一章讲的就是对接侧的三件事。4.1 嵌入脚本一行 script 背后的四个必填参数访客端挂件一般就是一个 JS 文件加一段全局配置异步加载不阻塞宿主页面。参数看着多真正不能错的只有四个script window.KF_CONFIG { appId: kf_a1b2c3, // 后台创建的应用标识决定走哪个技能组 apiBase: https://kf.example.com, // 接口和长连接域名必须与站点同协议 theme: #1f6feb, // 主色跟宿主站点对齐 position: right, // 气泡位置 visitor: { uid: , name: } // 匿名访客留空SDK 自动生成 visitor_id }; /script script srchttps://kf.example.com/sdk/embed.js async/scriptappId决定进线分配到哪个技能组填错的表现是「能聊但永远没人接」。apiBase如果宿主页面是 https 而这里写 http浏览器会直接拦掉混合内容控制台报错挂件图标都出不来。async不能省否则客服脚本加载慢会拖累宿主首屏这个锅最后会算到你头上。样式冲突是另一个高频问题。挂件通常用 Shadow DOM 或超高z-index隔离如果宿主站点自己有全局* { box-sizing }或 reset气泡位置可能偏。常见做法是给挂件容器一个独立的z-index: 2147483000并开 Shadow DOM改样式只改 SDK 内部别去动宿主 CSS。4.2 身份打通把业务 uid 映射成 visitor_id匿名访客靠浏览器本地生成的visitor_id维持会话登录用户则必须用业务系统的 uid 覆盖否则用户换设备、清缓存历史记录全断。做法是服务端签一张票据前端只拿字符串密钥不出后端?php // 服务端生成访客身份票据 function kfToken(string $uid, string $name, string $secret): string { $payload [uid $uid, name $name, ts time()]; $body base64_encode(json_encode($payload, JSON_UNESCAPED_UNICODE)); $sign hash_hmac(sha256, $body, $secret); return $body . . . $sign; }服务端校验时三件事必须做全hash_equals比对签名防时序攻击ts与当前时间差超过 300 秒直接拒绝防重放uid只信任签名里的值不接受前端另外传的 uid 参数。少了第三条改个请求体就能读到别人的聊天记录这是最典型的越权洞。场景visitor_id 来源会话是否延续未登录访客本地生成 UUID清缓存即断需接受已登录用户业务 uid 经签名后的值换设备、换浏览器都延续同一用户多标签页复用同一 visitor_id共享一个会话注意前端去重多标签页这个点经常翻车用户开了三个标签每个都建一条会话坐席端看到三个同名访客。解决方式是建会话前先按visitor_id status1查一次有进行中的直接复用。4.3 坐席工作台转接、快捷回复与会话小结坐席端的功能清单不复杂但每个都有坑。转接要防并发A 转给 B 的同时 C 也在转给 BB 那边会收到两条待接入。用带条件的更新靠影响行数判断成败-- 转接只有当前归属人还是原坐席时才更新成功 UPDATE conversation SET agent_id ?, updated_at NOW() WHERE id ? AND agent_id ? AND status 1;影响行数为 0 就说明会话已被别人接手或已结束前端提示「会话状态已变更」即可别硬覆盖。快捷回复按技能组维护别做成全局一份。售后组需要「退款流程」售前组需要「套餐对比」混在一起谁都找不到。会话小结建议设成结束会话的必填项字段就三个问题分类、是否解决、备注。不强制的话坐席 100% 会跳过事后想做质检连数据都没有。工作台的会话列表刷新别用整页轮询。用网关推送增量事件新会话、新消息、状态变更前端只更新对应行轮询间隔压到 3 秒以内会明显增加数据库压力压到 10 秒以上坐席又会觉得「卡」。推送是这套架构存在的意义别退回去轮询。5. 避坑与排查五条上线后才暴露的问题下面五条基本是按出现频率排的本地环境往往复现不了都是并发和真实网络才触发。5.1 消息显示已发送坐席端就是不弹窗现象访客端气泡显示发送成功数据库里也能查到记录坐席端毫无反应刷新页面才看到。原因Web 进程和网关进程连的不是同一个 Redis 库或用了不同REDIS_PREFIX。Web 侧把消息PUBLISH到kefu:channel网关订阅的是kefu:dev:channel各说各话。解决把两边的.env拉出来逐行对比REDIS_DB和REDIS_PREFIX网关启动参数里如果有独立的配置路径也要一起改。改成一致后重启网关用redis-cli SUBSCRIBE手动订阅一次看发布时有没有收到能立刻定位是哪一侧的问题。5.2 访客刷新一次就换了个新会话现象用户发了两句话按 F5 刷新坐席端多出一条新会话历史记录全没了。原因visitor_id只存在内存变量里没落地到localStorage或者站点从 http 切到 httpslocalStorage域不同旧值读不到。解决visitor_id生成后立刻localStorage.setItem持久化读取时先查本地再生成建会话接口做幂等按visitor_id查进行中的会话并复用。协议切换的场景要在上线前统一别让挂件跑在两套协议下。5.3 长连接撑不过一分钟现象聊得好好的大约 60 秒后收不到新消息刷新页面又正常反复如此。原因Nginx 的proxy_read_timeout默认 60 秒而前端心跳 30 秒发一次、网关却没做 pong 回写代理认为连接空闲直接断。解决三处一起调——Nginxproxy_read_timeout调到 120 秒以上网关收到ping必须回pong前端心跳间隔小于超时时间的一半。调完用浏览器开发者工具的网络面板看 WS 帧能稳定看到成对的 ping/pong 就算通过。5.4 两个坐席同时接到同一个访客现象坐席 A 和 B 的工作台同时弹出同一条新会话两边都能发消息访客收到两份回复。原因分配时只做了SELECT没加行锁两个请求读到同一个空闲坐席或者用了ORDER BY current_load但更新负载的语句是异步的存在时间窗。解决整段分配包进事务SELECT ... FOR UPDATE锁住候选行更新current_load后才提交会话创建时给conversation_id加唯一约束visitor_id status1唯一从数据库层兜住重复。压测时专门造 50 个并发进线看有没有重复分配比人工测靠谱。5.5 换个 conversation_id 就能读到别人的记录现象接口GET /api/messages?conversation_id123不做归属校验改个数字就能拉到别人的聊天内容。原因只按会话 id 查没校验当前访客或坐席是否有权访问这条会话。功能开发时前端传什么就查什么最容易漏这层。解决所有会话相关接口统一加一层归属校验——访客侧比对visitor_id坐席侧比对agent_id或所属技能组把校验抽成一个中间件别在每个控制器里手写。上线前用两个账号交叉请求一遍是成本最低的验证方式。6. 上线前的压测与验证200 个模拟访客怎么造功能都通了别急着交付。这类系统最怕的不是报错而是「十个人用没问题一百个人用就开始玄学」。压测不用上重型工具一段 Node 脚本就能把分配和落库两条链路压出原形Node 22 起自带全局fetch不用装任何依赖。6.1 一段脚本模拟并发进线与发消息// bench/visitor-sim.js const BASE process.env.BASE || http://127.0.0.1:8080; const N Number(process.env.N || 200); // 并发访客数 async function one(i) { // 1. 进线拿到会话 id 与访问票据 const r await fetch(${BASE}/api/visitor/enter, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ app_id: kf_a1b2c3, from: bench-${i} }) }); const { conversation_id, token } await r.json(); // 2. 每个访客连发 5 条client_msg_id 固定用于验证去重 for (let k 0; k 5; k) { await fetch(${BASE}/api/message/send, { method: POST, headers: { Content-Type: application/json, X-Token: token }, body: JSON.stringify({ conversation_id, content: msg-${k}, client_msg_id: ${i}-${k} }) }); } } await Promise.all(Array.from({ length: N }, (_, i) one(i))); console.log(done, N);跑的时候盯五个指标比看日志有用得多分配接口的 p95 耗时、消息写入的 QPS、Redis 内存增长、网关的活跃连接数、message表里有没有重复的client_msg_id。第五项最直观——如果唯一索引没生效压完会看到同一句话出现两条。6.2 上线前跑一遍这张表检查项判定标准不通过先看哪会话不串号两账号交叉请求对方conversation_id均返回 403归属校验中间件断网恢复断开网络 30 秒再恢复历史消息能补齐after_id游标与补拉接口坐席满载current_load到上限后新访客进排队而非报错分配 SQL 的LIMIT 1分支消息去重同一client_msg_id发两次库里只有一条uk_client唯一索引时间一致两端消息时间戳与数据库时间差小于 1 秒PHP 与 MySQL 的时区配置长连接稳定挂机 10 分钟不掉线ping/pong 成对出现Nginx 超时与心跳间隔压测数据不用留着跑完把测试会话清掉conversation和message表按created_at删当天的测试记录即可别把脏数据带进正式环境。从那以后我每次交付这类客服系统都强制先跑一遍并发脚本再谈验收哪怕客户说「我们就十来个人用」——因为出问题的从来不是人数而是那两个恰好同时点进来的访客。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?