298 lines
8.1 KiB
PHP
298 lines
8.1 KiB
PHP
<?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 分配的客服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();
|
||
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(): array
|
||
{
|
||
$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(array $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(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): array
|
||
{
|
||
$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(int $sessionId, int $adminId): void
|
||
{
|
||
$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(int $adminId): void
|
||
{
|
||
$redis = $this->getRedis();
|
||
$redis->setex(self::ONLINE_PREFIX . $adminId, 60, '1');
|
||
ChatAdminStatus::updateLastOnlineTime($adminId);
|
||
}
|
||
|
||
/**
|
||
* 刷新客服在线状态 (心跳续期)
|
||
*/
|
||
public function refreshAgentOnline(int $adminId): void
|
||
{
|
||
$redis = $this->getRedis();
|
||
$redis->expire(self::ONLINE_PREFIX . $adminId, 60);
|
||
}
|
||
|
||
/**
|
||
* 设置客服离线
|
||
*/
|
||
public function setAgentOffline(int $adminId): void
|
||
{
|
||
$redis = $this->getRedis();
|
||
$redis->del(self::ONLINE_PREFIX . $adminId);
|
||
$redis->del(self::LOAD_PREFIX . $adminId);
|
||
}
|
||
|
||
/**
|
||
* 获取客服最大会话数
|
||
*/
|
||
private function getAgentMaxSessions(int $adminId): int
|
||
{
|
||
$status = ChatAdminStatus::getByAdminId($adminId);
|
||
return $status['max_sessions'] ?? self::MAX_SESSIONS;
|
||
}
|
||
|
||
/**
|
||
* 获取用户已有会话的客服ID
|
||
*/
|
||
private function getExistingSessionAgent(int $userId): ?int
|
||
{
|
||
$session = ChatSession::getActiveByUserId($userId);
|
||
return $session['admin_id'] ?? null;
|
||
}
|
||
|
||
/**
|
||
* 绑定会话到客服
|
||
*/
|
||
private function bindSessionToAgent(int $sessionId, int $adminId): void
|
||
{
|
||
$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(string $key, string $value): void
|
||
{
|
||
$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(): \Redis
|
||
{
|
||
return Cache::store('redis')->handler();
|
||
}
|
||
}
|