Files
chatroom/app/Services/ChatStateService.php
lkddi 5f30220609 feat: 任命/撤销通知系统 + 用户名片UI优化
- 任命/撤销事件增加 type 字段区分类型
- 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息
- 撤销:灰色弹窗 + 灰色系统消息,无礼花
- 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏
- 系统消息加随机鼓励语(各5条轮换)
- ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds)
- 用户名片折叠优化:管理员视野、职务履历均可折叠
- 管理操作 + 职务操作合并为「🔧 管理操作」折叠区
- 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开
2026-02-28 23:44:38 +08:00

272 lines
8.4 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
/**
* 文件功能:聊天全局状态中心 (替代旧版 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);
}
/**
* 将一条新发言推入 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);
}
}
}