简介:开源版在线客服管理系统源码包,面向中小企业、开发者与技术运维人员,提供一套可自由定制的在线客服平台。系统聚焦客户沟通与工单处理流程,融合网页端对话界面、客服工作台、AI机器人应答、服务报表分析及CRM/ERP集成能力,并考虑多语言支持与安全隐私保护,开源形态允许企业根据业务场景二次开发、修复缺陷或对接自有系统。压缩包整体约7.78MB,上游暂未提供文件总数与文件类型明细,具体目录结构可在解压后查看。资源发布以来已有468人学习下载,关注者多为希望低成本构建客服体系的技术团队与个人开发者。下载后可获得完整项目源码,借助代码梳理用户端、客服端与后台管理的实现思路,也可在此基础上扩展多渠道接入、多语言支持或安全审计功能,快速搭建一套符合自身业务节奏的客户服务系统,同时为后续维护与二次开发保留技术底子。
1. 为什么自己搭一套开源客服系统:三个SaaS说不清的边界
很多团队一上来就买云客服,按坐席数包年,访客端挂一个JS脚本,一周就能上线。但它有几个很现实的问题:坐席超过十几个之后费用涨得很快,聊天数据全在别人库里,想要把历史会话导出到自建BI还得走审批接口,想接内部工单系统时发现对方只给了一个Webhook还得看心情维护。这个开源版在线客服管理系统源码,就是为了解决这三个边界问题准备的:数据留在自己的服务器,坐席数量不再受授权限制,访客端插件和工作台都能改代码。适合有小团队开发能力、需要私有化部署或者做渠道整合的技术负责人。本文不评价商业产品好坏,只讲拿到这套软件包之后怎么部署、怎么改、有哪些坑要绕开。
2. 会话分配与服务状态机:拿到源码先读这三段逻辑
2.1 一条访客消息从发出到坐席看到,中间经历了什么
在线客服系统最核心的不是聊天界面,而是消息流转链路。访客在浏览器里点开对话窗口,输入一句话,这条消息要经历:前端采集 → 接口接收 → 会话匹配 → 坐席分配 → 消息落库 → 推送坐席工作台 → 坐席回复 → 推送访客端。任何一个环节断了,用户感知就是“消息发不出去”或者“客服半天没回”。
从源码结构上看,这部分的实现通常分成三层:
- 接入层:处理访客端HTTP请求,包括会话创建、消息发送、历史记录拉取
- 调度层:负责坐席状态管理、会话分配、超时回收
- 存储层:会话表、消息表、坐席表、分配日志表
接入层负责“收”,调度层负责“分”,存储层负责“记”。拿到源码后先别急着改样式,把这三层的目录结构找出来,从消息表结构入手读代码,比从头读接口文档快得多。
一个容易被忽略的点是消息推送方式。不少开源系统用的是长轮询而不是WebSocket,因为长轮询实现简单、兼容老浏览器、不需要额外维护长连接服务。缺点是实时性稍微差点,延迟一般在1到3秒内。如果你的业务对实时性要求高,比如需要“正在输入”状态,那就要考虑改造WebSocket,但改造前先确认你的反向代理支持WebSocket升级,这一步见第5章踩坑记录。
2.2 坐席分配算法:轮询、空闲优先和并发上限
分配策略直接决定客服的接待体验。排队排死、有人闲死有人忙死,基本都是分配策略没调好。这个源码里最常见的分配逻辑有几种,我按推荐程度排个序:
- 空闲优先:按当前活跃会话数最少的坐席分配
- 轮流分配:按坐席列表顺序轮询,每人一个
- 组内优先:先按分组过滤,再在组内执行空闲优先或轮询
“空闲优先”在大多数场景下是体验最好的,但它有一个前提:系统能准确知道每个坐席当前手上到底有几个未结束的会话。看源码时留意会话状态的变更时机,是在坐席点击“结束会话”时改状态,还是在聊天窗口关闭时就改。很多系统的坑在于会话已经结束了,状态没更新,导致分配算法一直跳过某个坐席。
这是分配坐席时最核心的一条SQL,伪代码如下:
-- 找当前在线且未达到最大并发数的坐席,按活跃会话数排序 SELECT s.id, s.name, (SELECT COUNT(*) FROM conversations c WHERE c.agent_id = s.id AND c.status = 'active') AS load_cnt FROM agents s WHERE s.status = 'online' AND s.is_deleted = 0 HAVING load_cnt < s.max_concurrency ORDER BY load_cnt ASC, s.last_work_at ASC LIMIT 1;参数说明:max_concurrency是坐席最大并发会话数,在管理后台按人配置;last_work_at是坐席最后一次处理消息的时间,用这个字段做二次排序,保证长时间没接到活的坐席优先被选中;HAVING是在分组统计结果上做过滤,不能写到WHERE里,因为load_cnt是聚合计算出来的列。
常见做法是把这个查询放到事务里执行,分配成功后立刻更新会话的agent_id和status,防止两个访客同时抢到同一个坐席。并发量大的时候,建议把分配操作改成短事务加行锁,避免整表锁死。
2.3 会话状态机:分配、转接、结束和超时的状态流转
会话不是只有“进行中”和“已结束”两种状态。一套够用的状态机至少要有这六个状态:
| 状态值 | 含义 | 触发条件 |
|---|---|---|
| pending | 等待分配 | 访客创建会话,暂无坐席 |
| active | 接待中 | 坐席接入会话 |
| transfer | 转接中 | 坐席发起转接,等待新坐席 |
| closed | 已结束 | 任一方主动结束 |
| timeout | 超时未回复 | 超过N分钟无人回复 |
| system_close | 系统强制关闭 | 访客离线超时或会话超时上限 |
状态机设计里最常出问题的是转接。坐席A把会话转给坐席B,代码里如果直接更新agent_id,B端工作台有时能收到有时收不到,原因多半是转接时没有把状态改成transfer,导致前端逻辑不知道这是一个新会话。正确流程应该是:
发起转接 → 状态改为 transfer → 释放原坐席占用 → 通知目标坐席 → 目标坐席接受 → 状态改为 active → 更新 agent_id另外,超时回收逻辑一般用定时任务扫。常见做法是每两分钟扫描一次会话表,把最后一条消息时间超过阈值且状态为active的会话标记为timeout。扫描SQL要注意加上索引,不然会话表到几万条之后,这个定时任务会拖慢数据库。
-- 每两分钟执行,找出超过20分钟没有新消息的活跃会话 UPDATE conversations SET status = 'timeout' WHERE status = 'active' AND updated_at < NOW() - INTERVAL 20 MINUTE AND EXISTS ( SELECT 1 FROM messages m WHERE m.session_id = conversations.id ORDER BY m.created_at DESC LIMIT 1 );这个语句的EXISTS子查询用于确认会话有消息记录,避免把建了会话但没说话的空会话回收掉。INTERVAL 20 MINUTE是一个可配置项,建议在配置中心暴露出来,不要写死在代码里。
3. 部署与初始化:从空服务器到首条访客消息的完整动作
3.1 环境选型:LNMP还是Docker Compose
这套系统是PHP技术栈,依赖MySQL和Redis。部署方式主要有两种,我分别说下适用场景。
| 对比项 | LNMP手动部署 | Docker Compose |
|---|---|---|
| 部署速度 | 2小时左右 | 15分钟 |
| 服务器要求 | 无特殊要求 | 需要Docker环境 |
| 维护复杂度 | 中,要手动管依赖 | 低,容器化隔离 |
| 升级回滚 | 手动备份文件和数据库 | docker-compose down即可回滚 |
| 适合场景 | 已有LNMP环境的服务器 | 从零部署或频繁迁移 |
我一般建议团队用Docker Compose起一套标准环境,原因很简单:这套系统依赖PHP扩展、Redis扩展、Nginx配置好几个东西,手动装容易漏,漏一个扩展就是各种莫名其妙的报错。容器化之后环境一致,换台服务器不用重新折腾。
以下是docker-compose.yml的关键片段:
version: '3' services: nginx: image: nginx:1.24-alpine ports: - "80:80" volumes: - ./public:/var/www/html/public - ./nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - php php: build: ./docker/php volumes: - .:/var/www/html environment: DB_HOST: mysql REDIS_HOST: redis mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: kefu_root_pwd MYSQL_DATABASE: kefu volumes: - ./data/mysql:/var/lib/mysql redis: image: redis:7-alpine command: redis-server --appendonly yes环境变量说明:DB_HOST指向MySQL容器的服务名,PHP代码里解析数据库主机名时不要写127.0.0.1;MYSQL_DATABASE指定初始创建的库名,建议就叫kefu,避免后续改配置;Redis开启AOF持久化是防止会话数据和服务状态丢在内存里,消息已经落MySQL了,但排队中的临时状态还在Redis,持久化至少能降低重启后的数据丢失概率。
3.2 初始化数据库与管理员账号
容器起来之后,第一件事不是打开浏览器,而是确认数据库迁移和初始数据是否完成。
# 进入PHP容器执行初始化脚本 docker exec -it kefu-php-1 php artisan migrate --seed docker exec -it kefu-php-1 php artisan key:generate docker exec -it kefu-php-1 php artisan admin:create --name=admin --email=admin@example.com --password=初始密码三条命令的作用:第一条执行数据库迁移并写入种子数据,包含默认菜单、默认角色、默认分组;第二条生成应用密钥,这个密钥用于加密会话ID和登录态,不生成的话所有加密相关功能都会报错;第三条创建管理员账号,--name和--email会作为坐席显示名称和登录名。
登录之后先去“系统设置”里检查三个配置项:
- 站点名称:会显示在访客端对话窗口标题栏
- 客服欢迎语:访客发起会话时自动发送的首条消息
- 会话超时时间:默认建议20分钟,低于10分钟会导致用户还没打完字会话就被回收
3.3 访客端JS与工作台入口的连通性验证
部署完成后不要直接上线,先手动验证一条链路。打开访客端页面,在浏览器控制台执行:
# 用curl模拟一次访客发消息的请求 curl -X POST https://your-domain.com/api/visitor/session \ -H "Content-Type: application/json" \ -d '{"site_id": 1, "visitor_name": "测试用户"}'返回结果里应包含session_id和connect_token,把session_id记下来,然后再发一条消息:
curl -X POST https://your-domain.com/api/visitor/message \ -H "Content-Type: application/json" \ -d '{"session_id": "上面拿到的ID", "content": "这是测试消息"}'如果返回{"code":0},说明接入层和存储层已经通了。这时候用管理员账号登录工作台,应该能看到这个会话出现在“待接入”列表里。如果工作台里看不到,优先查Redis里是否存了会话ID映射,常见做法是访客端消息先写Redis再异步同步MySQL,Redis没起来就会导致工作台读不到。
4. 二次开发关键点:访客身份、坐席路由与Webhook转发
4.1 访客身份识别:session、token还是userId参数
系统默认用浏览器Cookie生成一个匿名的访客标识,但你的业务系统里用户是登录的。如果不在嵌入端把业务用户ID传进来,用户换个浏览器历史聊天记录就全丢了,这会导致客服重复问一遍“您之前的问题解决了没”。
标准做法是在JS嵌入代码里显式传一个customer_token,让后端把这个token与会话绑定。
<script src="https://your-domain.com/assets/js/visitor.js"></script> <script> KEFU.init({ site_id: '1', customer_token: '用户业务系统里的唯一ID', extra_fields: { order_no: 'SO20240601', level: 'vip' }, assign_group: 'technical' }); </script>参数说明:customer_token是你自己业务库里用户的加密ID,不是明文userId,防止被人遍历;extra_fields会写入会话的自定义字段表,后续分配策略里可以按这些字段做路由;assign_group是初始分组,比如“售前”“售后”“技术”,访客进来直接进对应组的队列。
后端在收到customer_token时,要做一次会话合并:如果该token下已有未结束的会话,直接复用原会话ID,而不是新创建。这个逻辑在源码里通常叫resumeOrCreate,拿不到这个方法的源码实现的话,自己写也不难:先查customer_token + status != closed的记录,有就更新,没有就插入。
4.2 自定义字段与会话分派:把业务属性带进分配逻辑
默认分配逻辑只认分组和坐席负载,但这不够。常见需求是“VIP用户优先分配给资深客服”或者“有订单号的会话必须分给售后的某个坐席”。这就需要在分配前读会话的自定义字段,再决定进哪个队列。
extra_fields里的数据在服务端会转成一张session_fields关联表,结构大致是:
CREATE TABLE session_fields ( id INT AUTO_INCREMENT PRIMARY KEY, session_id VARCHAR(32) NOT NULL, field_key VARCHAR(64) NOT NULL, field_value VARCHAR(255), INDEX idx_session (session_id) );分配逻辑里加一步字段匹配:
// 伪代码:分配前检查是否VIP用户 $fields = getSessionFields($sessionId); if (isset($fields['level']) && $fields['level'] === 'vip') { $agent = assignBestAgent($groupId, ['tag' => 'senior']); } else { $agent = assignBestAgent($groupId, []); }assignBestAgent第二参数是一个过滤条件数组,在max_concurrency和online状态之外增加坐席标签匹配。坐席表里可以加一个tags字段,存JSON数组如["senior","after_sale"],分配时用JSON_CONTAINS做过滤。MySQL版本低于5.7的话不支持JSON_CONTAINS,那就改成关联表agent_tags,查询时JOIN。
这里注意:不要让自定义字段的解析逻辑散落在各个控制器里。我习惯在进入分配器之前统一解析extra_fields并写入session_fields表,分配器只读表数据,不关心字段来源。这样以后加字段不用改分配器,改前端传参就行。
4.3 消息Webhook转发:把客服系统接到你的工单或CRM里
很多团队用这套系统的最终目的是打通自己的工单系统。最省事的做法是监听消息事件,把访客消息同步推送到外部接口。
import requests, hmac, hashlib, json payload = { "event": "message.new", "session_id": "S20240601-001", "from": "visitor", "content": "订单 SO20240601 显示未发货", "ts": "2024-06-01 10:00:00" } # 用密钥签名,防止接口被伪造调用 sign = hmac.new( b"your-webhook-secret", json.dumps(payload, ensure_ascii=False).encode('utf-8'), hashlib.sha256 ).hexdigest() r = requests.post( "https://your-crm.example.com/webhook/kefu", json=payload, headers={"X-Sign": sign}, timeout=3 )参数说明:your-webhook-secret放在环境变量里,不要硬编码到代码中,也不要写在前端JS里,这个签名只用于服务端对服务端;timeout=3加超时限制是因为客服系统的消息写入不能被外部接口拖慢,外部接口挂了不能反过来阻塞客服聊天。常见做法是Webhook推送放到消息队列里异步执行,系统里如果自带队列组件就用自带的,没有的话自己起一个简单的Redis队列。
5. 部署运行避坑:五个高频问题与排查路径
5.1 访客消息发出去了,坐席端一直在转圈
现象:访客在对话窗口里发消息,界面显示发送成功,但工作台那边过了十几秒才收到,或者一直收不到,刷新页面后又出现了。
原因:绝大多数情况是长轮询接口被反向代理切断了。Nginx默认的proxy_read_timeout是60秒,长轮询请求挂起超过60秒就被强制断开,前端只能重新发起连接,中间这段消息就丢了或者延迟很久。
解决:在Nginx的站点配置里调整超时时间。
location /api/poll { proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_connect_timeout 5s; }注意proxy_read_timeout指的是两次读取操作之间的间隔,不是总时长。如果你用的是Docker部署,还要检查容器网络有没有额外限制。从那以后我每次部署完第一件事就是curl测一下长轮询接口,等70秒看是否断开。
5.2 消息入库了但列表不刷新
现象:数据库messages表里能看到新记录,但访客端和工作台界面都没有新消息提示。
原因:列表数据是从接口读取的,接口在HTTP层被缓存了。尤其是Nginx配置了proxy_cache或者浏览器端开了缓存,轮询接口返回了304,前端拿到旧的响应就认为没有新消息。
解决:给轮询接口加请求头禁用缓存,同时确保接口返回正确的Cache-Control:
location /api/ { add_header Cache-Control "no-store, no-cache, must-revalidate"; }还有一种情况是Redis里存了消息序列号,代码里读到Redis和MySQL数据不一致导致的。解决方式是清掉Redis里对应的key,在这些系统里Redis通常是“黑匣子”,出了问题先redis-cli flushdb验证是不是缓存问题,再定位是代码还是配置。
5.3 用户重新打开页面后历史会话丢了
现象:访客关掉浏览器再打开,对话窗口变成全新会话,之前的聊天记录不见了,客服也看不到历史。
原因:前端没有传持久化的用户标识,服务端只能用Cookie里的临时标识识别访客,Cookie一过期就识别不出来了。另一个常见原因是customer_token在会话创建后就不再更新,用户换了设备或者清了Cookie,系统只能把它当新用户。
解决:把用户身份标识放到本地存储里,同时服务端在每次访客创建会话时做一次按token查询旧会话的操作。
// 访客端JS里用localStorage持久化用户标识 const token = localStorage.getItem('kefu_customer_token') || generateUUID(); localStorage.setItem('kefu_customer_token', token); KEFU.init({ site_id: '1', customer_token: token });注意localStorage有跨域限制。如果客服系统部署在chat.example.com,业务站点在www.example.com,直接共享同一个localStorage是做不到的,常见做法是访客端插件里由业务系统页面把token写入到客服系统域名下的Cookie,或者通过URL参数传递一次。
5.4 坐席全部离线时,消息堆积在“待接入”里没人管
现象:非工作时间访客发了很多消息,第二天坐席登录后,这些会话还在待接入列表里,但分配系统没有自动分配给任何坐席。
原因:默认的分配触发机制是“访客发送消息时触发”,坐席不在线时不触发分配逻辑。第二天坐席上线,系统没有重新扫描未分配会话,所以一直堆着。
解决:写一个定时任务,每隔5分钟扫描一次pending状态的会话,尝试重新分配。
# crontab 每5分钟执行一次 */5 * * * * cd /var/www/kefu && php artisan schedule:scanPendingSessions这个定时任务的核心SQL就是:找出status = 'pending'且created_at超过等待阈值的会话,再执行之前提到的空闲优先分配查询。要注意扫描频率不要太低,否则高峰期访客等待时间会翻倍;也不要太高,每1分钟扫一次对数据库压力偏大但也可以接受,看你的会话量。
5.5 上传附件图片一直报错或者直接就超时
现象:访客发图片,提示“上传失败”,或者转圈半天最后报500。
原因:上传链路有三道限制,漏任何一个都会导致问题。第一道是Nginx的client_max_body_size,默认1M,一张手机照片就超了;第二道是PHP的post_max_size和upload_max_filesize,默认分别是8M和2M;第三道是PHP脚本执行时间max_execution_time,默认30秒,大文件上传时间不够。
解决:三处一起改,单独改Nginx没有用。
client_max_body_size 20M;; php.ini post_max_size = 20M upload_max_filesize = 20M max_execution_time = 120改完重启Nginx和PHP-FPM。图片上传还涉及一个隐藏问题:存储目录权限不够,PHP写入失败。检查storage/目录的写权限,特别是用了Docker挂载卷的场景,宿主机目录权限没放开的话,容器里的PHP也写不进去。
6. 上线前三个验证技巧:压测、消息落库与外部系统联动
新系统部署完,别急着把线上流量切过去。我每次做这类部署都会强制走一遍三个验证动作,各花十分钟,但能挡掉八成上线后的问题。
第一个是压测会话分配。用脚本模拟多个访客同时发起会话,观察分配是否均匀。
import requests, threading def visit(i): # 创建会话 s = requests.post("https://your-domain.com/api/visitor/session", json={"site_id": 1, "visitor_name": f"测试{i}"}) sid = s.json()["data"]["session_id"] # 发一条消息 requests.post("https://your-domain.com/api/visitor/message", json={"session_id": sid, "content": f"测试消息{i}"}) threads = [] for i in range(20): t = threading.Thread(target=visit, args=(i,)) threads.append(t) t.start() for t in threads: t.join()脚本跑完后,去工作台查看“分配记录”或者直接查数据库,确认20个会话均匀分布在不同坐席名下。如果全部分配给了同一个人,分配算法的排序和并发判断就要重新查。
第二个是核对消息落库。页面端看到的消息数和数据库里实际的消息数必须一致。最直接的方法是查库:
SELECT date(created_at) AS dt, session_id, COUNT(*) AS msg_cnt FROM messages WHERE created_at >= NOW() - INTERVAL 1 HOUR GROUP BY date(created_at), session_id;拿到结果后和运营后台的统计数对一下。不一致就说明有消息只写到了Redis,或者某个异步任务失败了。
第三个是验证Webhook稳定性。用不同事件各推一次测试消息,确认外部业务系统收到且签名校验通过。发送前把目标地址指向一个本地调试接口,观察推送延迟和异常重试。这里有一个常见隐患:外部接口响应慢,导致客服端消息发送被阻塞。正确姿势是Webhook队列独立于消息主链路,即使Webhook地址挂了,也不影响聊天本身。
从那以后我每次部署完客服系统,都会强制走一遍这三步压测,再检查一遍反向代理超时和Redis连接状态。很多看起来像“玄学”的线上故障,其实都出在这几个不起眼的配置项上。这套开源版在线客服管理系统源码整体结构清晰,数据表设计也比较规整,适合拿来改造成团队自己的客户服务中台。希望帮到你。
本文还有配套的精品资源,点击获取