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 表结构 (最终版)
-- 客服会话表
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生成)
// 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 会话分配算法 (最少会话数 + 分布式锁)
// 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 = <<<LUA
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
LUA;
$redis->eval($script, [$key, $value], 1);
}
}
3.3 消息重试算法 (指数退避)
// 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 离线队列处理 (余额优先)
// 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