294 lines
9.1 KiB
PHP
294 lines
9.1 KiB
PHP
<?php
|
||
|
||
/**
|
||
* 文件功能:聊天全局状态中心 (替代旧版 ASP中的 Application 全局对象)
|
||
* 依赖 Redis 内存存取实现在线人员列表、发言记录的高并发读写。
|
||
*
|
||
* @author ChatRoom Laravel
|
||
*
|
||
* @version 1.0.0
|
||
*/
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\SysParam;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Redis;
|
||
|
||
class ChatStateService
|
||
{
|
||
/**
|
||
* 将用户加入到指定房间的在线列表中。
|
||
*
|
||
* @param int $roomId 房间ID
|
||
* @param string $username 用户名
|
||
* @param array $info 用户详细信息 (头像、等级等)
|
||
*/
|
||
public function userJoin(int $roomId, string $username, array $info): void
|
||
{
|
||
$key = "room:{$roomId}:users";
|
||
Redis::hset($key, $username, json_encode($info, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
|
||
/**
|
||
* 将用户从指定房间的在线列表中移除。
|
||
*
|
||
* @param int $roomId 房间ID
|
||
* @param string $username 用户名
|
||
*/
|
||
public function userLeave(int $roomId, string $username): void
|
||
{
|
||
$key = "room:{$roomId}:users";
|
||
Redis::hdel($key, $username);
|
||
}
|
||
|
||
/**
|
||
* 查找用户当前所在的所有房间 ID
|
||
*
|
||
* 扫描 Redis 中所有 room:*:users 的哈希表,检查用户是否在其中。
|
||
*
|
||
* @param string $username 用户名
|
||
* @return array<int> 房间 ID 数组
|
||
*/
|
||
public function getUserRooms(string $username): array
|
||
{
|
||
$rooms = [];
|
||
$prefix = config('database.redis.options.prefix', '');
|
||
$cursor = '0';
|
||
do {
|
||
// scan 带前缀通配,返回的 key 也带前缀
|
||
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
|
||
foreach ($keys ?? [] as $fullKey) {
|
||
// 去掉前缀得到 Laravel Redis Facade 认识的短少 key
|
||
$shortKey = $prefix ? ltrim(substr($fullKey, strlen($prefix)), '') : $fullKey;
|
||
if (Redis::hexists($shortKey, $username)) {
|
||
preg_match('/room:(\d+):users/', $shortKey, $m);
|
||
if (isset($m[1])) {
|
||
$rooms[] = (int) $m[1];
|
||
}
|
||
}
|
||
}
|
||
} while ($cursor !== '0');
|
||
|
||
return $rooms;
|
||
}
|
||
|
||
/**
|
||
* 获取指定房间的所有在线用户列表。
|
||
*
|
||
* @param int $roomId 房间ID
|
||
*/
|
||
public function getRoomUsers(int $roomId): array
|
||
{
|
||
$key = "room:{$roomId}:users";
|
||
$users = Redis::hgetall($key);
|
||
|
||
$result = [];
|
||
foreach ($users as $username => $jsonInfo) {
|
||
$result[$username] = json_decode($jsonInfo, true);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 扫描 Redis,返回当前所有有在线用户的房间 ID 数组(用于全局广播)。
|
||
*
|
||
* @return array<int>
|
||
*/
|
||
public function getAllActiveRoomIds(): array
|
||
{
|
||
$roomIds = [];
|
||
$prefix = config('database.redis.options.prefix', '');
|
||
$cursor = '0';
|
||
do {
|
||
// scan 带前缀通配,返回的 key 也带前缀
|
||
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
|
||
foreach ($keys ?? [] as $fullKey) {
|
||
$shortKey = $prefix ? substr($fullKey, strlen($prefix)) : $fullKey;
|
||
// 只有 hash 非空(有在线用户)才算活跃房间
|
||
if (Redis::hlen($shortKey) > 0) {
|
||
preg_match('/room:(\d+):users/', $shortKey, $m);
|
||
if (isset($m[1])) {
|
||
$roomIds[] = (int) $m[1];
|
||
}
|
||
}
|
||
}
|
||
} while ($cursor !== '0');
|
||
|
||
return array_unique($roomIds);
|
||
}
|
||
|
||
/**
|
||
* 获取全局所有在线用户名(跨所有房间,去重)。
|
||
*
|
||
* 遍历所有活跃房间,合并用户名后去重,
|
||
* 用于好友面板在线状态标记。
|
||
*
|
||
* @return array<string>
|
||
*/
|
||
public function getAllOnlineUsernames(): array
|
||
{
|
||
$usernames = [];
|
||
foreach ($this->getAllActiveRoomIds() as $roomId) {
|
||
$key = "room:{$roomId}:users";
|
||
$users = Redis::hkeys($key); // 只取 key(用户名),不取 value
|
||
foreach ($users as $username) {
|
||
$usernames[] = $username;
|
||
}
|
||
}
|
||
|
||
return array_unique($usernames);
|
||
}
|
||
|
||
/**
|
||
* 将一条新发言推入 Redis 列表,并限制最大保留数量,防止内存泄漏。
|
||
*
|
||
* @param int $roomId 房间ID
|
||
* @param array $message 发言数据包
|
||
* @param int $maxKeep 最大保留条数
|
||
*/
|
||
public function pushMessage(int $roomId, array $message, int $maxKeep = 100): void
|
||
{
|
||
$key = "room:{$roomId}:messages";
|
||
Redis::rpush($key, json_encode($message, JSON_UNESCAPED_UNICODE));
|
||
|
||
// 仅保留最新的 $maxKeep 条,旧的自动截断弹弃 (-$maxKeep 到 -1 的区间保留)
|
||
Redis::ltrim($key, -$maxKeep, -1);
|
||
}
|
||
|
||
/**
|
||
* 清除指定房间的所有消息缓存(管理员全员清屏)。
|
||
*
|
||
* @param int $roomId 房间ID
|
||
*/
|
||
public function clearMessages(int $roomId): void
|
||
{
|
||
$key = "room:{$roomId}:messages";
|
||
Redis::del($key);
|
||
}
|
||
|
||
/**
|
||
* 清除指定房间内,关于某个用户的旧欢迎消息(支持普通人、管理员、新人)。
|
||
* 保证聊天记录里只保留最新的一条,解决频繁进出造成的刷屏问题。
|
||
*
|
||
* @param int $roomId 房间ID
|
||
* @param string $username 用户名
|
||
*/
|
||
public function removeOldWelcomeMessages(int $roomId, string $username): void
|
||
{
|
||
$key = "room:{$roomId}:messages";
|
||
$messages = Redis::lrange($key, 0, -1);
|
||
|
||
if (empty($messages)) {
|
||
return;
|
||
}
|
||
|
||
$filtered = [];
|
||
|
||
foreach ($messages as $msgJson) {
|
||
$msg = json_decode($msgJson, true);
|
||
// 只要消息里带了 welcome_user 且等于当前用户,就抛弃这条旧的
|
||
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username) {
|
||
continue;
|
||
}
|
||
$filtered[] = $msgJson;
|
||
}
|
||
|
||
// 重新写回 Redis(如果发生了过滤)
|
||
if (count($filtered) !== count($messages)) {
|
||
Redis::del($key);
|
||
if (! empty($filtered)) {
|
||
Redis::rpush($key, ...$filtered);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取指定房间的新发言记录。
|
||
* 在高频长轮询或前端断线重连拉取时使用。
|
||
*
|
||
* @param int $roomId 房间ID
|
||
* @param int $lastId 客户端收到的最后一条发言的ID
|
||
*/
|
||
public function getNewMessages(int $roomId, int $lastId): array
|
||
{
|
||
$key = "room:{$roomId}:messages";
|
||
$messages = Redis::lrange($key, 0, -1); // 获取当前缓存的全部
|
||
|
||
$newMessages = [];
|
||
foreach ($messages as $msgJson) {
|
||
$msg = json_decode($msgJson, true);
|
||
if (isset($msg['id']) && $msg['id'] > $lastId) {
|
||
$newMessages[] = $msg;
|
||
}
|
||
}
|
||
|
||
return $newMessages;
|
||
}
|
||
|
||
/**
|
||
* 分布式发号器:为房间内的新消息生成绝对递增的 ID。
|
||
* 解决了 MySQL 自增在极致并发下依赖读锁的问题。
|
||
*
|
||
* @param int $roomId 房间ID
|
||
*/
|
||
public function nextMessageId(int $roomId): int
|
||
{
|
||
$key = "room:{$roomId}:message_seq";
|
||
|
||
return Redis::incr($key);
|
||
}
|
||
|
||
/**
|
||
* 读取系统参数并设置合理的缓存。
|
||
* 替代每次都去 MySQL query 的性能损耗。
|
||
*
|
||
* @param string $alias 别名
|
||
*/
|
||
public function getSysParam(string $alias): ?string
|
||
{
|
||
return Cache::remember("sys_param:{$alias}", 60, function () use ($alias) {
|
||
return SysParam::where('alias', $alias)->value('body');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 更新系统参数并刷新 Cache 缓存。
|
||
*
|
||
* @param string $alias 别名
|
||
* @param mixed $value 参数值
|
||
*/
|
||
public function setSysParam(string $alias, mixed $value): void
|
||
{
|
||
Cache::put("sys_param:{$alias}", $value);
|
||
}
|
||
|
||
/**
|
||
* 提供一个分布式的 Redis 互斥锁包围。
|
||
* 防止并发抢占资源(如同时创建重名房间,同时修改某一敏感属性)。
|
||
*
|
||
* @param string $key 锁名称
|
||
* @param callable $callback 获得锁后执行的闭包
|
||
* @param int $timeout 锁超时时间(秒)
|
||
* @return mixed
|
||
*/
|
||
public function withLock(string $key, callable $callback, int $timeout = 5)
|
||
{
|
||
$lockKey = "lock:{$key}";
|
||
// 尝试获取锁,set nx ex
|
||
$isLocked = Redis::set($lockKey, 1, 'EX', $timeout, 'NX');
|
||
|
||
if (! $isLocked) {
|
||
// 获取锁失败,业务可自行决定抛异常或重试
|
||
throw new \Exception("The lock {$key} is currently held by another process.");
|
||
}
|
||
|
||
try {
|
||
return $callback();
|
||
} finally {
|
||
Redis::del($lockKey);
|
||
}
|
||
}
|
||
}
|