简介:这是一套全开源的PHP在线客服系统IM即时通讯源码,面向Web开发者、中小企业技术负责人及SaaS服务集成方,解决多端客户咨询统一接入与高效响应问题。系统支持网站、微信公众号、小程序、H5及APP全渠道接入,提供不限数量客服应用与席位、分组管理、公众号模板消息实时提醒、离线消息承接及跨平台用户信息(如微信昵称/头像)同步能力,适配中高级PHP工程师二次开发与私有化部署。资源为24.67MB的ZIP压缩包,含核心PHP业务逻辑、前端交互模块、数据库结构脚本及多端适配配置文件,文件总数未提供,但目录结构体现清晰的模块划分(如客服后台、访客端、API接口层、微信/小程序对接组件)。已有3209人学习下载,购买即获完整可运行源码、持续免费升级权限及配套售后支持,无需订阅席位或缴纳年费,可快速集成至现有商城、官网或独立部署为专属客服中台。
1. 为什么一个全开源 PHP IM 系统要同时支持微信公众号、小程序、H5 和 APP 网页端?
你正在维护一个面向中小企业的在线客服系统,客户提出需求:“用户在微信公众号里点一下就聊,小程序里能发图片和语音,H5 页面嵌入官网不跳转,安卓/iOS APP 里消息要实时同步——但预算只够买一台 4 核 8G 的云服务器。”这不是理想化场景,而是真实交付现场。这类需求背后,本质是单套 PHP 后端服务需承载多端协议适配、会话状态统一、消息路由收敛与长连接资源复用四大刚性约束。市面上多数“PHP 客服源码”仅提供网页版轮询或简单 WebSocket,一旦接入微信公众号(需处理 OAuth2 授权+模板消息回执)、小程序(要求wx.connectSocket兼容 + 小程序专属 session 解析)、H5(跨域 + 自动重连 + 离线缓存)和 APP(TCP 长连保活 + 心跳压缩),立刻暴露架构短板:消息不同步、已读状态错乱、文件上传路径不一致、用户身份在各端无法映射为同一 UID。本文聚焦的这套全开源 PHP IM 系统,其核心价值不在“能跑”,而在通过一套 PHP 业务逻辑层 + 分层通信网关 + 统一会话上下文模型,让五类终端共用同一套消息队列、同一套用户关系链、同一套消息存储结构。适合 PHP 工程师主导的中小型项目团队,无需引入 Java/Go 微服务,也不依赖 SaaS 平台订阅费,所有代码可审计、可定制、可离线部署。
2. 构建统一消息通道:PHP 后端如何同时支撑 WebSocket、HTTP API 与微信事件推送
2.1 为什么必须放弃传统轮询,选择混合长连接架构
传统 PHP 客服系统采用 AJAX 轮询(如每 3 秒 GET/api/messages?last_id=xxx),在高并发下迅速耗尽 Apache 进程或 PHP-FPM worker。当微信公众号用户点击菜单触发客服入口时,需在 5 秒内建立会话;小程序首次加载需同步历史消息;APP 启动后需维持心跳。这些场景要求毫秒级响应 + 持久连接 + 服务端主动推送。纯 WebSocket 方案在微信公众号中不可用(微信浏览器禁用原生 WebSocket),而纯 HTTP 流式响应(SSE)在 iOS Safari 中兼容性差。因此,本系统采用三通道混合架构:
- WebSocket 通道:供 H5 网页端、APP WebView、PC 端使用,基于 Workerman 或 Swoole 实现;
- HTTP 长轮询通道:供微信公众号内置浏览器(iOS/Android 微信 WebView)使用,超时设为 30 秒,配合
X-Accel-Buffering: no防止 Nginx 缓存; - 微信事件通道:监听微信服务器 POST 到
/wechat/callback的 XML 消息,解析MsgType=text/event后转换为内部消息格式并写入 Redis Stream。
提示:不要试图用单一协议覆盖所有终端。微信公众号强制走微信自有 JS-SDK 通信链路,必须接受其限制;小程序虽支持 WebSocket,但需配置
socket://域名白名单且不支持自签名证书;H5 网页端则优先选用标准 WebSocket 以降低延迟。
2.2 使用 Swoole 构建可扩展的 PHP 长连接网关
Swoole 4.8+ 提供协程 WebSocket Server,比 Workerman 更轻量且原生支持协程 MySQL/Redis。以下是最小可行网关启动脚本:
<?php // gateway.php use Swoole\WebSocket\Server; use Swoole\Http\Request; use Swoole\WebSocket\Frame; $server = new Server('0.0.0.0', 9501); // 连接建立时绑定用户身份(从 URL 参数或 Cookie 提取) $server->on('open', function (Server $server, Request $request) { $uid = $request->get['uid'] ?? ''; $platform = $request->get['platform'] ?? 'web'; // web/app/mp/wechat if (!$uid) { $server->close($request->fd); return; } // 将 fd 与用户会话绑定到 Redis Hash,key: "session:{$uid}" $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->hSet("session:{$uid}", $platform . ':fd', $request->fd); $redis->expire("session:{$uid}", 86400); // 24小时过期 }); // 收到消息后广播给同会话用户(非群聊场景) $server->on('message', function (Server $server, Frame $frame) { $data = json_decode($frame->data, true); if (!$data || !isset($data['to_uid'])) { return; } $toUid = $data['to_uid']; $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // 获取目标用户所有在线终端 fd $fds = $redis->hVals("session:{$toUid}"); foreach ($fds as $fd) { if ($server->exist($fd)) { $server->push($fd, $frame->data); } } }); $server->start();关键参数说明:
9501端口需在宝塔面板或安全组中放行,并配置 Nginx 反向代理(避免直接暴露);$request->get['uid']是前端在建立 WebSocket 连接时传入的用户唯一标识,必须由业务层生成并校验合法性,禁止前端随意填写;platform字段用于区分终端类型,后续消息路由、通知策略(如小程序需调用微信模板消息)均依赖此值;Redis Hash存储结构保证同一用户多端登录时消息可跨设备投递,hVals()获取全部 fd 是关键操作。
2.3 微信公众号事件解析与消息桥接
微信服务器向你的接口推送 XML 数据,需解析后转为统一消息结构:
// wechat/callback.php $xml = file_get_contents('php://input'); libxml_disable_entity_loader(true); $simpleXml = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA); if (!$simpleXml || !isset($simpleXml->ToUserName)) { exit('invalid xml'); } $msg = [ 'from_uid' => (string)$simpleXml->FromUserName, 'to_uid' => (string)$simpleXml->ToUserName, 'msg_type' => (string)$simpleXml->MsgType, 'content' => '', 'timestamp'=> (int)$simpleXml->CreateTime, ]; switch ($msg['msg_type']) { case 'text': $msg['content'] = (string)$simpleXml->Content; break; case 'event': if ((string)$simpleXml->Event === 'subscribe') { $msg['content'] = '欢迎关注!点击下方【开始咨询】进入客服'; } break; case 'image': $mediaId = (string)$simpleXml->MediaId; // 调用微信 API 下载图片到本地 /uploads/wechat/{$mediaId}.jpg $msg['content'] = '[图片]'; break; } // 写入 Redis Stream,供后台消费 $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->xAdd('im:stream', '*', $msg);注意:
libxml_disable_entity_loader(true)是防止 XXE 攻击的强制措施;file_get_contents('php://input')是接收原始 POST 数据的唯一可靠方式,$_POST在 XML 场景下为空;- 所有微信事件必须在 5 秒内响应空字符串,否则微信会重复推送,因此解析后立即写入 Redis Stream,业务逻辑异步处理;
xAdd写入 Stream 后,由独立的消费者进程(如php consumer.php)读取并分发至对应用户会话。
3. 多端身份统一与会话状态管理:从微信 OpenID 到小程序 UnionID 的映射实践
3.1 微信生态内用户 ID 的三级体系及映射策略
微信公众号、小程序、APP 三端用户看似独立,实则可通过微信开放平台实现身份打通。关键在于理解以下 ID 层级:
| ID 类型 | 获取方式 | 作用范围 | 是否可互通 |
|---|---|---|---|
OpenID | 公众号授权获取 | 单公众号内唯一 | ❌ 不同公众号间不通用 |
UnionID | 用户在开放平台绑定公众号+小程序后生成 | 同一主体下所有应用通用 | ✅ 前提是公众号与小程序同属一个微信开放平台账号 |
MP-OpenID | 小程序单独授权获取 | 单小程序内唯一 | ❌ 与公众号 OpenID 无直接关系 |
本系统采用“UnionID 为主键 + OpenID/MP-OpenID 为索引”的双层设计:
CREATE TABLE `im_users` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `unionid` varchar(64) DEFAULT NULL COMMENT '微信开放平台 UnionID', `openid` varchar(64) DEFAULT NULL COMMENT '公众号 OpenID', `mp_openid` varchar(64) DEFAULT NULL COMMENT '小程序 OpenID', `nickname` varchar(50) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `created_at` int unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uk_unionid` (`unionid`), KEY `idx_openid` (`openid`), KEY `idx_mp_openid` (`mp_openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;映射流程:
- 用户首次通过公众号进入客服,授权后获取
openid和unionid(若已绑定开放平台); - 用户切换至小程序,再次授权,获取
mp_openid和unionid; - 后端检测到新
mp_openid对应已有unionid,则更新im_users.mp_openid字段; - 若
unionid为空(未绑定开放平台),则根据手机号或昵称做模糊合并(需人工审核)。
注意:微信开放平台绑定需企业资质认证,个人订阅号无法获取 UnionID。若客户无认证资质,需降级方案——在数据库中维护
openid ↔ mp_openid映射表,并通过用户主动输入手机号完成关联。
3.2 H5 网页端与 APP 端的用户身份注入机制
H5 页面嵌入官网时,用户可能未登录。此时需通过以下方式注入身份:
方案 A(推荐):JWT Token 注入
后端生成含uid、exp、platform=h5的 JWT,前端将其存入localStorage,每次 WebSocket 连接时作为 query 参数传递:const token = localStorage.getItem('im_token'); const ws = new WebSocket(`wss://im.example.com?token=${token}`);网关端验证 JWT 并提取
uid,避免暴露原始数据库 ID。方案 B:Cookie + Session 同步
若 H5 与官网同域,可复用官网登录态 Cookie。Nginx 配置proxy_cookie_path / "/; Path=/; HttpOnly; Secure;"确保 Cookie 透传。
APP 端则通过 SDK 初始化时传入uid和device_id:
// Android 示例 ImSdk.init(this, "https://im.example.com", uid, deviceId);后端将device_id记录到im_user_devices表,用于精准推送(如某设备静音、某设备登出)。
3.3 消息已读状态的跨端同步实现
已读状态不同步是多端客服最常见体验断层。本系统采用“消息 ID + 终端类型 + 已读时间戳” 三元组记录法:
CREATE TABLE `im_message_read` ( `msg_id` bigint unsigned NOT NULL COMMENT '消息主键 ID', `uid` bigint unsigned NOT NULL COMMENT '用户 ID', `platform` enum('web','app','mp','wechat') NOT NULL COMMENT '终端类型', `read_at` int unsigned NOT NULL DEFAULT '0' COMMENT '已读时间戳', PRIMARY KEY (`msg_id`,`uid`,`platform`), KEY `idx_uid_platform` (`uid`,`platform`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;当用户在小程序中点击某条消息,前端发送:
POST /api/messages/12345/read Content-Type: application/json { "platform": "mp" }后端执行:
$redis->hSet("msg:read:{$msgId}", "{$uid}:mp", time()); // 同时通知其他终端该消息已读(如 H5 页面顶部显示“对方已读”) $server->push($otherFd, json_encode(['type'=>'read_ack','msg_id'=>$msgId]));Redis Hash 存储保证高性能写入,hSet命令天然幂等,避免重复点击导致脏数据。
4. 文件与富媒体消息的统一存储与分发策略
4.1 图片、语音、文件上传的标准化处理流程
微信公众号、小程序、H5 上传能力差异极大:
- 公众号:仅支持
media_id上传(调用微信 API); - 小程序:支持
wx.uploadFile上传到自有服务器; - H5:支持
<input type="file">直传。
本系统强制所有文件先经 PHP 后端中转,统一生成file_id并存入数据库:
// upload.php if ($_SERVER['REQUEST_METHOD'] === 'POST') { $file = $_FILES['file'] ?? null; if (!$file || $file['error'] !== UPLOAD_ERR_OK) { die(json_encode(['code'=>400,'msg'=>'上传失败'])); } $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $allowed = ['jpg','jpeg','png','gif','mp3','amr','pdf','doc','docx','xls','xlsx']; if (!in_array($ext, $allowed)) { die(json_encode(['code'=>400,'msg'=>'不支持的文件类型'])); } $fileId = uniqid('f_') . '_' . time(); $path = '/var/www/im/uploads/' . date('Ym') . '/' . $fileId . '.' . $ext; mkdir(dirname($path), 0755, true); move_uploaded_file($file['tmp_name'], $path); // 写入数据库 $pdo = new PDO('mysql:host=localhost;dbname=im', 'user', 'pass'); $stmt = $pdo->prepare("INSERT INTO im_files (file_id, original_name, ext, size, path, uploaded_at) VALUES (?, ?, ?, ?, ?, ?)"); $stmt->execute([$fileId, $file['name'], $ext, $file['size'], $path, time()]); echo json_encode([ 'code' => 0, 'data' => [ 'file_id' => $fileId, 'url' => 'https://im.example.com/uploads/' . str_replace('/var/www/im', '', $path) ] ]); }关键设计点:
file_id全局唯一,避免文件名冲突(如两个用户都传1.jpg);path按月分目录(/202406/),防止单目录文件过多影响 inode 性能;url返回 CDN 域名(如https://cdn.example.com/...),生产环境需配置 Nginx 静态文件服务或对接 OSS。
4.2 语音消息的 AMR 转 MP3 与前端自动播放
微信小程序上传语音为 AMR 格式,但 H5 网页端无法直接播放。需在服务端转码:
# 安装 ffmpeg(Ubuntu) sudo apt update && sudo apt install ffmpeg # PHP 中调用转码 $amrPath = '/var/www/im/uploads/202406/f_abc123_1717023456.amr'; $mp3Path = str_replace('.amr', '.mp3', $amrPath); exec("ffmpeg -i {$amrPath} -ar 44100 -ac 2 -b:a 128k {$mp3Path} 2>/dev/null");前端播放逻辑:
// 检测消息类型并加载对应资源 if (msg.type === 'voice') { const audio = new Audio(msg.mp3_url); // 服务端返回转码后 URL audio.play().catch(e => console.log('自动播放被阻止,请用户手动点击')); }提示:AMR 转 MP3 会增加 CPU 开销,建议使用队列异步处理(如 Redis List + Consumer 进程),避免阻塞主请求。对实时性要求不高的语音,可设置为“收到后 5 秒内转码完成”。
4.3 消息撤回与编辑的原子性保障
消息撤回不是简单删除数据库记录,需确保:
- 所有终端收到撤回指令;
- 撤回状态不可逆(防止反复撤回);
- 撤回后原消息内容不可恢复。
实现方式为“软删除 + 指令广播”:
ALTER TABLE `im_messages` ADD COLUMN `is_revoked` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已撤回', ADD COLUMN `revoke_at` int unsigned DEFAULT NULL COMMENT '撤回时间戳';撤回请求:
// revoke.php $msgId = (int)$_POST['msg_id']; $uid = getCurrentUid(); // 从 JWT 或 session 获取当前用户 // 检查是否为消息发送者且未超时(默认 2 分钟) $stmt = $pdo->prepare("SELECT from_uid, created_at FROM im_messages WHERE id = ? AND is_revoked = 0"); $stmt->execute([$msgId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row || $row['from_uid'] !== $uid || time() - $row['created_at'] > 120) { die(json_encode(['code'=>403,'msg'=>'撤回失败:非本人发送或超时'])); } // 更新数据库 $pdo->prepare("UPDATE im_messages SET is_revoked = 1, revoke_at = ? WHERE id = ?")->execute([time(), $msgId]); // 广播撤回指令 $redis->publish('im:channel:revoke', json_encode(['msg_id'=>$msgId, 'revoked_at'=>time()]));各终端 WebSocket 监听revoke事件,前端执行:
case 'revoke': const msgEl = document.querySelector(`[data-msg-id="${data.msg_id}"]`); if (msgEl) { msgEl.innerHTML = '<i class="icon-revoked">[消息已撤回]</i>'; msgEl.classList.add('revoked'); } break;5. 生产环境部署与性能调优:从宝塔面板到 Redis Stream 消费者守护
5.1 宝塔面板下的 PHP + Swoole + Nginx 一体化配置
在宝塔 Linux 面板中,需调整以下关键配置:
| 组件 | 配置项 | 推荐值 | 说明 |
|---|---|---|---|
| PHP | max_execution_time | 0(不限制) | Swoole 进程常驻,需禁用超时 |
| PHP | memory_limit | 512M | 避免大文件上传时内存溢出 |
| Nginx | proxy_read_timeout | 60 | 长轮询连接保持时间 |
| Nginx | proxy_buffering | off | 防止长轮询响应被缓存 |
| Nginx | WebSocket 代理 | proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; | 必须添加,否则 WebSocket 握手失败 |
Nginx 反向代理配置示例(/www/server/nginx/conf/vhost/im.conf):
upstream im_gateway { server 127.0.0.1:9501; } server { listen 443 ssl http2; server_name im.example.com; ssl_certificate /www/server/panel/vhost/cert/im/fullchain.pem; ssl_certificate_key /www/server/panel/vhost/cert/im/privkey.pem; location /ws/ { proxy_pass http://im_gateway; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 60; } location /wechat/callback { fastcgi_pass unix:/tmp/php-cgi-74.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location /uploads/ { alias /var/www/im/uploads/; expires 1h; } }验证步骤:
- 重启 Nginx:
bt reload nginx; - 启动 Swoole 网关:
php /www/wwwroot/im/gateway.php &; - 检查端口监听:
netstat -tuln | grep :9501; - 浏览器访问
https://im.example.com/ws/?uid=123&platform=web,观察 WebSocket 连接状态。
5.2 Redis Stream 消费者进程的守护与监控
微信事件、消息分发、文件转码等异步任务均依赖 Redis Stream。需确保消费者进程永不退出:
# consumer.sh #!/bin/bash while true; do php /www/wwwroot/im/consumer.php sleep 1 done使用 Supervisor 管理(/etc/supervisor/conf.d/im-consumer.conf):
[program:im-consumer] command=/bin/bash /www/wwwroot/im/consumer.sh directory=/www/wwwroot/im user=www autostart=true autorestart=true redirect_stderr=true stdout_logfile=/www/wwwroot/im/logs/consumer.log消费者核心逻辑(consumer.php):
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // 从 stream 读取消息,GROUP 名为 'im_group',CONSUMER 名为 'worker1' while (true) { $messages = $redis->xRead(['im:stream' => '$'], 1, 0, 'im_group', 'worker1'); if (!$messages) { usleep(100000); // 100ms 间隔重试 continue; } foreach ($messages as $stream => $msgs) { foreach ($msgs as $id => $data) { try { // 处理消息:写入数据库、通知 WebSocket、触发模板消息 processMessage($data); // 确认消费,防止重复处理 $redis->xAck($stream, 'im_group', $id); $redis->xDel($stream, $id); // 删除已确认消息 } catch (Exception $e) { error_log("Consumer failed: " . $e->getMessage()); // 失败消息暂不 ack,下次继续处理 } } } }关键监控指标:
redis-cli xinfo groups im:stream查看 pending 消息数,持续增长说明消费者卡住;tail -f /www/wwwroot/im/logs/consumer.log观察错误日志;ps aux | grep consumer.sh确认进程存活。
5.3 MySQL 连接池与慢查询优化实战
IM 系统高频写入消息表,易触发max_connections限制。在my.cnf中调整:
[mysqld] max_connections = 500 wait_timeout = 28800 interactive_timeout = 28800 innodb_buffer_pool_size = 2G # 物理内存的 70%针对im_messages表的慢查询,添加复合索引:
-- 查询某用户所有消息(按时间倒序) ALTER TABLE `im_messages` ADD INDEX `idx_from_to_created` (`from_uid`, `to_uid`, `created_at`); -- 查询未读消息数 ALTER TABLE `im_messages` ADD INDEX `idx_to_uid_is_revoked` (`to_uid`, `is_revoked`);使用EXPLAIN验证查询计划:
EXPLAIN SELECT * FROM im_messages WHERE to_uid = 123 AND is_revoked = 0 ORDER BY created_at DESC LIMIT 20;理想结果中type应为ref,key显示使用了idx_to_uid_is_revoked。
注意:
im_messages表数据量超过 100 万行后,需考虑分表。按to_uid % 16分 16 张子表(im_messages_0~im_messages_15),路由逻辑在 PHP 中实现,避免 MySQL 分库中间件复杂度。
本文还有配套的精品资源,点击获取