Pro/openspec/archive/customer-service-module/design.md

22 KiB
Raw Blame History

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 分配的客服IDnull表示无可用客服
     */
    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登录TokenWS握手时校验
客服端WS连接 复用Admin Session额外校验客服权限
消息发送 服务端强制使用鉴权后的身份禁止伪造senderId

6.2 输入校验

字段 校验规则
文字消息 长度≤500XSS过滤
图片上传 MIME白名单(jpg/png/gif)大小≤2MB
sessionId 必须属于当前用户或客服

6.3 限流

维度 限制
消息发送 每用户每秒最多5条
图片上传 每用户每分钟最多10张
WS重连 每用户每分钟最多10次

文档版本: 2.0 最后更新: 2026-01-28