# OPSX Design: 在线客服模块技术设计 ## 变更标识 - **变更ID**: customer-service-module - **版本**: 2.0 - **状态**: DESIGN_COMPLETE - **更新日期**: 2026-01-28 --- ## 1. 架构设计 ### 1.1 系统架构图 ``` ┌─────────────────────────────────────────────────────────────────────┐ │ 客户端层 │ ├─────────────┬─────────────┬─────────────┬─────────────────────────┤ │ PC端 │ Game端 │ Portal端 │ Admin客服工作台 │ │ (Vue 2.x) │ (Vue 3.x) │ (uni-app) │ (Layui + WS) │ │ Element UI │ Vant │ uView │ │ └──────┬──────┴──────┬──────┴──────┬──────┴───────────┬─────────────┘ │ │ │ │ └─────────────┼─────────────┼───────────────────┘ │ WebSocket (chat.* 事件) ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ Socket服务 (TP6 + Swoole) │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ ChatConnect │ │ ChatMessage │ │ ChatSession │ Listeners │ │ │ Listener │ │ Listener │ │ Listener │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ └────────────────┼────────────────┘ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ ChatService │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ MessageSvc │ │ SessionSvc │ │ AssignSvc │ │ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘ │ └──────────────────────────────┬──────────────────────────────────────┘ │ ┌─────────────────────┼─────────────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ MySQL │ │ Redis │ │ 文件存储 │ │ │ │ │ │ │ │ cg_chat_session │ │ cs:online:* │ │ public/uploads/ │ │ cg_chat_message │ │ cs:conn:* │ │ chat/ │ │ cg_chat_quick_ │ │ cs:lock:* │ │ │ │ reply │ │ cs:queue:* │ │ │ │ cg_chat_admin_ │ │ cs:agent:* │ │ │ │ status │ │ │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ ``` ### 1.2 模块职责 | 模块 | 职责 | 技术栈 | |------|------|--------| | **ChatConnect Listener** | 处理WS连接/断开、心跳、鉴权 | Swoole onOpen/onClose | | **ChatMessage Listener** | 处理消息收发、ACK、重试 | Swoole onMessage | | **ChatSession Listener** | 处理会话创建/分配/转接/结束 | Swoole onMessage | | **MessageService** | 消息持久化、查询、状态更新 | ThinkPHP Model | | **SessionService** | 会话生命周期管理 | ThinkPHP Model | | **AssignService** | 会话分配、负载均衡、队列管理 | Redis + Model | --- ## 2. 数据库设计 ### 2.1 表结构 (最终版) ```sql -- 客服会话表 CREATE TABLE `cg_chat_session` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(10) unsigned NOT NULL COMMENT '用户ID', `admin_id` int(10) unsigned DEFAULT NULL COMMENT '客服ID', `source` tinyint(1) NOT NULL DEFAULT '1' COMMENT '来源: 1=PC 2=Game 3=Portal', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态: 0=待分配 1=进行中 2=已结束', `rating` tinyint(1) DEFAULT NULL COMMENT '评分: 1-5', `rating_content` varchar(500) DEFAULT NULL COMMENT '评价内容', `last_msg_id` bigint(20) unsigned DEFAULT NULL COMMENT '最后消息ID(雪花)', `last_msg_time` int(10) unsigned DEFAULT NULL COMMENT '最后消息时间', `create_time` int(10) unsigned NOT NULL, `update_time` int(10) unsigned NOT NULL, `end_time` int(10) unsigned DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_status` (`user_id`, `status`), KEY `idx_admin_status` (`admin_id`, `status`), KEY `idx_status_create` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服会话表'; -- 聊天消息表 CREATE TABLE `cg_chat_message` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `msg_id` bigint(20) unsigned NOT NULL COMMENT '消息ID(雪花算法)', `session_id` int(10) unsigned NOT NULL COMMENT '会话ID', `sender_type` tinyint(1) NOT NULL COMMENT '发送者类型: 1=用户 2=客服', `sender_id` int(10) unsigned NOT NULL COMMENT '发送者ID', `msg_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '消息类型: 1=文字 2=图片', `content` varchar(500) NOT NULL COMMENT '消息内容(文字或图片URL)', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态: 0=pending 1=sent 2=delivered 3=read 4=failed', `retry_count` tinyint(1) NOT NULL DEFAULT '0' COMMENT '重试次数', `create_time` int(10) unsigned NOT NULL, `delivered_time` int(10) unsigned DEFAULT NULL, `read_time` int(10) unsigned DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_msg_id` (`msg_id`), KEY `idx_session_id` (`session_id`, `id`), KEY `idx_session_status` (`session_id`, `status`), KEY `idx_sender` (`sender_type`, `sender_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表'; -- 快捷回复表 CREATE TABLE `cg_chat_quick_reply` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `category` varchar(50) DEFAULT NULL COMMENT '分类', `title` varchar(100) NOT NULL COMMENT '标题', `content` text NOT NULL COMMENT '内容', `sort` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '排序', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态: 0=禁用 1=启用', `create_time` int(10) unsigned NOT NULL, `update_time` int(10) unsigned NOT NULL, PRIMARY KEY (`id`), KEY `idx_category_status` (`category`, `status`, `sort`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快捷回复表'; -- 客服状态表 (辅助表,主状态在Redis) CREATE TABLE `cg_chat_admin_status` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `admin_id` int(10) unsigned NOT NULL COMMENT '客服ID', `max_sessions` int(10) unsigned NOT NULL DEFAULT '10' COMMENT '最大会话数', `is_enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用客服功能', `last_online_time` int(10) unsigned DEFAULT NULL, `create_time` int(10) unsigned NOT NULL, `update_time` int(10) unsigned NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_admin_id` (`admin_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服状态配置表'; ``` ### 2.2 索引设计说明 | 表 | 索引 | 用途 | |----|------|------| | cg_chat_session | `idx_user_status` | 查询用户活跃会话 | | cg_chat_session | `idx_admin_status` | 查询客服进行中会话 | | cg_chat_message | `uk_msg_id` | 消息幂等性校验 | | cg_chat_message | `idx_session_id` | 按会话查询消息(分页) | | cg_chat_message | `idx_session_status` | 查询会话未读消息 | --- ## 3. 核心算法设计 ### 3.1 雪花算法 (消息ID生成) ```php // app/utils/Snowflake.php class Snowflake { private const EPOCH = 1704067200000; // 2024-01-01 00:00:00 UTC private const WORKER_ID_BITS = 5; private const DATACENTER_ID_BITS = 5; private const SEQUENCE_BITS = 12; private $workerId; private $datacenterId; private $sequence = 0; private $lastTimestamp = -1; public function __construct(int $workerId = 1, int $datacenterId = 1) { $this->workerId = $workerId & 0x1F; // 5 bits $this->datacenterId = $datacenterId & 0x1F; // 5 bits } public function nextId(): int { $timestamp = $this->currentTimeMillis(); if ($timestamp === $this->lastTimestamp) { $this->sequence = ($this->sequence + 1) & 0xFFF; // 12 bits if ($this->sequence === 0) { $timestamp = $this->waitNextMillis($this->lastTimestamp); } } else { $this->sequence = 0; } $this->lastTimestamp = $timestamp; return (($timestamp - self::EPOCH) << 22) | ($this->datacenterId << 17) | ($this->workerId << 12) | $this->sequence; } private function currentTimeMillis(): int { return (int)(microtime(true) * 1000); } private function waitNextMillis(int $lastTimestamp): int { $timestamp = $this->currentTimeMillis(); while ($timestamp <= $lastTimestamp) { $timestamp = $this->currentTimeMillis(); } return $timestamp; } } ``` ### 3.2 会话分配算法 (最少会话数 + 分布式锁) ```php // app/services/chat/AssignService.php class AssignService { private const LOCK_TTL = 3000; // 3秒 private const LOCK_PREFIX = 'cs:lock:assign:'; private const ONLINE_PREFIX = 'cs:online:agent:'; private const LOAD_PREFIX = 'cs:agent:load:'; /** * 分配会话给客服 * @param int $userId 用户ID * @param int $sessionId 会话ID * @return int|null 分配的客服ID,null表示无可用客服 */ public function assignSession(int $userId, int $sessionId): ?int { $redis = $this->getRedis(); $lockKey = self::LOCK_PREFIX . $userId; // 1. 获取分配锁 (防止并发双分配) $lockValue = uniqid('', true); $acquired = $redis->set($lockKey, $lockValue, ['NX', 'PX' => self::LOCK_TTL]); if (!$acquired) { // 锁被占用,检查是否已有活跃会话 return $this->getExistingSessionAgent($userId); } try { // 2. 检查用户是否已有活跃会话 $existingAgent = $this->getExistingSessionAgent($userId); if ($existingAgent !== null) { return $existingAgent; } // 3. 获取所有在线客服 $onlineAgents = $this->getOnlineAgents($redis); if (empty($onlineAgents)) { // 无在线客服,进入留言队列 $this->addToOfflineQueue($userId, $sessionId); return null; } // 4. 选择会话数最少的客服 $selectedAgent = $this->selectLeastLoadAgent($redis, $onlineAgents); if ($selectedAgent === null) { // 所有客服已满载 $this->addToOfflineQueue($userId, $sessionId); return null; } // 5. 更新会话归属 $this->bindSessionToAgent($sessionId, $selectedAgent, $redis); // 6. 增加客服负载计数 $redis->incr(self::LOAD_PREFIX . $selectedAgent); return $selectedAgent; } finally { // 释放锁 (仅释放自己持有的锁) $this->releaseLock($redis, $lockKey, $lockValue); } } private function selectLeastLoadAgent(Redis $redis, array $onlineAgents): ?int { $loads = []; foreach ($onlineAgents as $agentId) { $load = (int)$redis->get(self::LOAD_PREFIX . $agentId) ?: 0; $maxSessions = $this->getAgentMaxSessions($agentId); if ($load < $maxSessions) { $loads[$agentId] = $load; } } if (empty($loads)) { return null; } // 返回负载最小的客服 asort($loads); return array_key_first($loads); } private function releaseLock(Redis $redis, string $key, string $value): void { // Lua脚本保证原子性:仅当值匹配时才删除 $script = <<eval($script, [$key, $value], 1); } } ``` ### 3.3 消息重试算法 (指数退避) ```php // app/services/chat/MessageService.php class MessageService { private const MAX_RETRY = 3; private const RETRY_DELAYS = [1000, 2000, 4000]; // ms /** * 推送消息并处理重试 */ public function pushMessage(int $msgId, int $targetFd, array $payload): bool { $redis = $this->getRedis(); $server = $this->getSwooleServer(); for ($retry = 0; $retry <= self::MAX_RETRY; $retry++) { // 检查目标连接是否有效 if (!$server->isEstablished($targetFd)) { // 连接已断开,标记为pending等待重连补发 $this->markMessagePending($msgId); return false; } // 尝试推送 $result = $server->push($targetFd, json_encode($payload)); if ($result) { // 推送成功,更新状态为sent $this->updateMessageStatus($msgId, 'sent'); return true; } // 推送失败,记录重试次数 $this->incrementRetryCount($msgId); if ($retry < self::MAX_RETRY) { // 指数退避等待 usleep(self::RETRY_DELAYS[$retry] * 1000); } } // 超过最大重试次数,标记为failed $this->updateMessageStatus($msgId, 'failed'); return false; } } ``` ### 3.4 离线队列处理 (余额优先) ```php // app/services/chat/AssignService.php (续) class AssignService { private const QUEUE_KEY = 'cs:queue:pending'; /** * 添加到离线队列 (按余额降序) */ public function addToOfflineQueue(int $userId, int $sessionId): void { $redis = $this->getRedis(); // 获取用户余额 $user = Db::name('user')->where('id', $userId)->find(); $balance = $user['money'] ?? 0; // ZSET score 使用负余额实现降序 (余额高的先处理) $score = -$balance; $member = json_encode(['userId' => $userId, 'sessionId' => $sessionId, 'time' => time()]); $redis->zAdd(self::QUEUE_KEY, $score, $member); } /** * 客服上线时处理队列 */ public function processOfflineQueue(int $adminId): void { $redis = $this->getRedis(); while (true) { // 获取队列中优先级最高的会话 (score最小 = 余额最高) $items = $redis->zRange(self::QUEUE_KEY, 0, 0); if (empty($items)) { break; // 队列为空 } $item = json_decode($items[0], true); // 检查客服是否还能接单 $currentLoad = (int)$redis->get(self::LOAD_PREFIX . $adminId) ?: 0; $maxSessions = $this->getAgentMaxSessions($adminId); if ($currentLoad >= $maxSessions) { break; // 客服已满载 } // 尝试分配 (使用分布式锁) $assigned = $this->assignSession($item['userId'], $item['sessionId']); if ($assigned === $adminId) { // 分配成功,从队列移除 $redis->zRem(self::QUEUE_KEY, $items[0]); // 通知用户会话已被接入 $this->notifyUserSessionAssigned($item['userId'], $item['sessionId'], $adminId); } } } } ``` --- ## 4. 接口设计 ### 4.1 Admin 后台 HTTP API | 接口 | 方法 | 路径 | 说明 | |------|------|------|------| | 获取会话列表 | GET | `/admin/chat/sessions` | 支持状态筛选 | | 获取会话详情 | GET | `/admin/chat/session/{id}` | 包含用户信息 | | 获取消息历史 | GET | `/admin/chat/messages` | 支持分页、搜索 | | 结束会话 | POST | `/admin/chat/session/{id}/end` | 手动关闭会话 | | 转接会话 | POST | `/admin/chat/session/{id}/transfer` | 转给其他客服 | | 快捷回复列表 | GET | `/admin/chat/quick-replies` | CRUD | | 添加快捷回复 | POST | `/admin/chat/quick-reply` | - | | 编辑快捷回复 | PUT | `/admin/chat/quick-reply/{id}` | - | | 删除快捷回复 | DELETE | `/admin/chat/quick-reply/{id}` | - | | 客服统计 | GET | `/admin/chat/stats` | 会话数、评分统计 | | 导出记录 | GET | `/admin/chat/export` | CSV导出 | ### 4.2 用户端 HTTP API | 接口 | 方法 | 路径 | 说明 | |------|------|------|------| | 获取会话状态 | GET | `/api/chat/status` | 检查是否有活跃会话 | | 上传图片 | POST | `/api/chat/upload` | 返回图片URL | | 提交评价 | POST | `/api/chat/rate` | 会话评价 | | 获取历史消息 | GET | `/api/chat/history` | 重连后拉取 | --- ## 5. 文件结构 ### 5.1 Socket 模块 ``` Socket/app/ ├── listener/chat/ │ ├── ChatConnectListener.php # 连接/断开/心跳处理 │ ├── ChatMessageListener.php # 消息收发处理 │ └── ChatSessionListener.php # 会话管理处理 ├── services/chat/ │ ├── ChatService.php # 聊天服务入口 │ ├── MessageService.php # 消息服务 │ ├── SessionService.php # 会话服务 │ └── AssignService.php # 分配服务 ├── models/ │ ├── ChatSession.php # 会话模型 │ ├── ChatMessage.php # 消息模型 │ └── ChatQuickReply.php # 快捷回复模型 └── utils/ └── Snowflake.php # 雪花算法 ``` ### 5.2 Pro 模块 (Admin后台) ``` Pro/application/admin/ ├── controller/ │ ├── Chat.php # 客服工作台控制器 │ ├── ChatRecord.php # 聊天记录控制器 │ └── ChatQuickReply.php # 快捷回复控制器 └── view/ ├── chat/ │ ├── index.html # 客服工作台页面 │ └── record.html # 聊天记录页面 └── chat_quick_reply/ ├── index.html # 快捷回复列表 └── add.html # 添加快捷回复 ``` ### 5.3 前端模块 ``` PC/src/components/chat/ ├── ChatWindow.vue # 聊天窗口容器 ├── ChatHeader.vue # 聊天头部(客服信息/关闭) ├── ChatMessages.vue # 消息列表 ├── ChatMessage.vue # 单条消息气泡 ├── ChatInput.vue # 输入框+发送 └── ChatRating.vue # 评价组件 Game/src/components/chat/ ├── ChatWindow.vue # 聊天窗口(Vant风格) ├── ChatMessages.vue # 消息列表 ├── ChatMessage.vue # 消息气泡 └── ChatInput.vue # 输入框 Portal/components/chat/ ├── ChatWindow.vue # 聊天窗口(uView风格) ├── ChatMessages.vue # 消息列表 └── ChatInput.vue # 输入框 ``` --- ## 6. 安全设计 ### 6.1 认证与授权 | 场景 | 方案 | |------|------| | 用户端WS连接 | 复用HTTP登录Token,WS握手时校验 | | 客服端WS连接 | 复用Admin Session,额外校验客服权限 | | 消息发送 | 服务端强制使用鉴权后的身份,禁止伪造senderId | ### 6.2 输入校验 | 字段 | 校验规则 | |------|----------| | 文字消息 | 长度≤500,XSS过滤 | | 图片上传 | MIME白名单(jpg/png/gif),大小≤2MB | | sessionId | 必须属于当前用户或客服 | ### 6.3 限流 | 维度 | 限制 | |------|------| | 消息发送 | 每用户每秒最多5条 | | 图片上传 | 每用户每分钟最多10张 | | WS重连 | 每用户每分钟最多10次 | --- *文档版本: 2.0* *最后更新: 2026-01-28*