Socket/app/services/chat/AssignService.php

298 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace app\services\chat;
use app\models\chat\ChatSession;
use app\models\chat\ChatAdminStatus;
use think\facade\Db;
use think\facade\Cache;
/**
* 会话分配服务
* Class AssignService
* @package app\services\chat
*/
class AssignService
{
// Redis Key 前缀 (specs.md Section 3)
private const LOCK_PREFIX = 'cs:lock:assign:';
private const ONLINE_PREFIX = 'cs:online:agent:';
private const LOAD_PREFIX = 'cs:agent:load:';
private const QUEUE_KEY = 'cs:queue:pending';
private const SESSION_OWNER_PREFIX = 'cs:session:owner:';
// 约束参数 (specs.md CC-01~CC-04)
private const LOCK_TTL = 3000; // 分配锁超时 3秒
private const MAX_SESSIONS = 10; // 单客服最大会话数
/**
* 分配会话给客服
* @param int $userId 用户ID
* @param int $sessionId 会话ID
* @return int|null 分配的客服IDnull表示无可用客服
*/
public function assignSession($userId, $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();
if (empty($onlineAgents)) {
// 无在线客服,进入留言队列
$this->addToOfflineQueue($userId, $sessionId);
return null;
}
// 4. 选择会话数最少的客服
$selectedAgent = $this->selectLeastLoadAgent($onlineAgents);
if ($selectedAgent === null) {
// 所有客服已满载
$this->addToOfflineQueue($userId, $sessionId);
return null;
}
// 5. 更新会话归属
$this->bindSessionToAgent($sessionId, $selectedAgent);
// 6. 增加客服负载计数
$redis->incr(self::LOAD_PREFIX . $selectedAgent);
return $selectedAgent;
} finally {
// 释放锁 (仅释放自己持有的锁)
$this->releaseLock($lockKey, $lockValue);
}
}
/**
* 获取在线客服列表
*/
public function getOnlineAgents()
{
$redis = $this->getRedis();
$enabledAdmins = ChatAdminStatus::getEnabledAdminIds();
$onlineAgents = [];
foreach ($enabledAdmins as $adminId) {
if ($redis->exists(self::ONLINE_PREFIX . $adminId)) {
$onlineAgents[] = $adminId;
}
}
return $onlineAgents;
}
/**
* 选择会话数最少的客服
*/
public function selectLeastLoadAgent($onlineAgents): ?int
{
$redis = $this->getRedis();
$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);
}
/**
* 添加到离线队列 (按余额降序)
*/
public function addToOfflineQueue($userId, $sessionId)
{
$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($adminId)
{
$redis = $this->getRedis();
$processed = [];
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;
}
// 从队列移除
$redis->zRem(self::QUEUE_KEY, $items[0]);
// 分配会话
$this->bindSessionToAgent($item['sessionId'], $adminId);
$redis->incr(self::LOAD_PREFIX . $adminId);
$processed[] = $item;
}
return $processed;
}
/**
* 释放会话 (会话结束时调用)
*/
public function releaseSession($sessionId, $adminId)
{
$redis = $this->getRedis();
// 删除会话归属
$redis->del(self::SESSION_OWNER_PREFIX . $sessionId);
// 减少客服负载
$load = (int)$redis->get(self::LOAD_PREFIX . $adminId) ?: 0;
if ($load > 0) {
$redis->decr(self::LOAD_PREFIX . $adminId);
}
}
/**
* 设置客服在线状态
*/
public function setAgentOnline($adminId)
{
$redis = $this->getRedis();
$redis->setex(self::ONLINE_PREFIX . $adminId, 60, '1');
ChatAdminStatus::updateLastOnlineTime($adminId);
}
/**
* 刷新客服在线状态 (心跳续期)
*/
public function refreshAgentOnline($adminId)
{
$redis = $this->getRedis();
$redis->expire(self::ONLINE_PREFIX . $adminId, 60);
}
/**
* 设置客服离线
*/
public function setAgentOffline($adminId)
{
$redis = $this->getRedis();
$redis->del(self::ONLINE_PREFIX . $adminId);
$redis->del(self::LOAD_PREFIX . $adminId);
}
/**
* 获取客服最大会话数
*/
private function getAgentMaxSessions($adminId)
{
$status = ChatAdminStatus::getByAdminId($adminId);
return $status['max_sessions'] ?? self::MAX_SESSIONS;
}
/**
* 获取用户已有会话的客服ID
*/
private function getExistingSessionAgent($userId): ?int
{
$session = ChatSession::getActiveByUserId($userId);
return $session['admin_id'] ?? null;
}
/**
* 绑定会话到客服
*/
private function bindSessionToAgent($sessionId, $adminId)
{
$redis = $this->getRedis();
// 更新数据库
ChatSession::where('id', $sessionId)->update([
'admin_id' => $adminId,
'status' => ChatSession::STATUS_ACTIVE,
'update_time' => time(),
]);
// 设置Redis映射
$redis->set(self::SESSION_OWNER_PREFIX . $sessionId, $adminId);
}
/**
* 释放分配锁
*/
private function releaseLock($key, $value)
{
$redis = $this->getRedis();
// 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);
}
/**
* 获取Redis实例
*/
private function getRedis()
{
return Cache::store('redis')->handler();
}
}