优化 刷新页面不在重复播报 离开和登录提示
This commit is contained in:
@@ -11,6 +11,9 @@
|
|||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
|
/.junie
|
||||||
|
/.github
|
||||||
|
/.gemini
|
||||||
/auth.json
|
/auth.json
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
|
|||||||
@@ -175,17 +175,17 @@ class ChatController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
|
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
|
||||||
if (! empty($vipPresencePayload)) {
|
if (! empty($vipPresencePayload)) {
|
||||||
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
|
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
|
||||||
$initialPresenceTheme = $vipPresencePayload;
|
$initialPresenceTheme = $vipPresencePayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
||||||
$initialWelcomeMessage = $generalWelcomeMsg;
|
$initialWelcomeMessage = $generalWelcomeMsg;
|
||||||
|
|
||||||
$this->chatState->pushMessage($id, $generalWelcomeMsg);
|
$this->chatState->pushMessage($id, $generalWelcomeMsg);
|
||||||
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
||||||
broadcast(new MessageSent($id, $generalWelcomeMsg));
|
broadcast(new MessageSent($id, $generalWelcomeMsg));
|
||||||
|
|
||||||
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
|
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
|
||||||
if (! empty($vipPresencePayload['presence_effect'])) {
|
if (! empty($vipPresencePayload['presence_effect'])) {
|
||||||
@@ -547,7 +547,7 @@ class ChatController extends Controller
|
|||||||
if ($bonusJjb > 0) {
|
if ($bonusJjb > 0) {
|
||||||
$bonusParts[] = "+金币{$bonusJjb}";
|
$bonusParts[] = "+金币{$bonusJjb}";
|
||||||
}
|
}
|
||||||
|
|
||||||
$eventContent = $autoEvent->renderText($user->username);
|
$eventContent = $autoEvent->renderText($user->username);
|
||||||
if (! empty($bonusParts)) {
|
if (! empty($bonusParts)) {
|
||||||
$eventContent .= '('.$user->vipName().'追加:'.implode(',', $bonusParts).')';
|
$eventContent .= '('.$user->vipName().'追加:'.implode(',', $bonusParts).')';
|
||||||
@@ -600,6 +600,24 @@ class ChatController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理登录失效后的离场清理。
|
||||||
|
*
|
||||||
|
* 该接口通过临时签名 URL 调用,即使会话已过期也能安全完成离场结算。
|
||||||
|
*/
|
||||||
|
public function expiredLeave(int $id, int $user): JsonResponse
|
||||||
|
{
|
||||||
|
$expiredUser = User::find($user);
|
||||||
|
|
||||||
|
if (! $expiredUser) {
|
||||||
|
return response()->json(['status' => 'error'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dispatchImmediateLeave($id, $expiredUser, '登录失效离开了房间');
|
||||||
|
|
||||||
|
return response()->json(['status' => 'success']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回所有房间的在线人数,供右侧房间面板轮询使用。
|
* 返回所有房间的在线人数,供右侧房间面板轮询使用。
|
||||||
*
|
*
|
||||||
@@ -642,13 +660,8 @@ class ChatController extends Controller
|
|||||||
$isExplicit = strval($request->query('explicit')) === '1';
|
$isExplicit = strval($request->query('explicit')) === '1';
|
||||||
|
|
||||||
if ($isExplicit) {
|
if ($isExplicit) {
|
||||||
// 人工显式点击“离开”,不再进行浏览器刷新的防抖,直接同步执行清算和播报。
|
// 人工显式点击“离开”时,立即同步执行清算和播报。
|
||||||
// 这对本地没有开启 Queue Worker 的环境尤为重要,能保证大家立刻看到消息。
|
$this->dispatchImmediateLeave($id, $user, '主动离开了房间');
|
||||||
// 为了防止 ProcessUserLeave 中的时间对比失败,我们直接删掉 join_time 表示彻底离线。
|
|
||||||
\Illuminate\Support\Facades\Redis::del("room:{$id}:join_time:{$user->username}");
|
|
||||||
|
|
||||||
$job = new \App\Jobs\ProcessUserLeave($id, clone $user, $leaveTime);
|
|
||||||
dispatch_sync($job);
|
|
||||||
} else {
|
} else {
|
||||||
// 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟
|
// 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟
|
||||||
// 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime)
|
// 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime)
|
||||||
@@ -659,6 +672,17 @@ class ChatController extends Controller
|
|||||||
return response()->json(['status' => 'success']);
|
return response()->json(['status' => 'success']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立即执行离场清理,并跳过刷新防抖逻辑。
|
||||||
|
*/
|
||||||
|
private function dispatchImmediateLeave(int $id, User $user, string $outInfo): void
|
||||||
|
{
|
||||||
|
Redis::del("room:{$id}:join_time:{$user->username}");
|
||||||
|
|
||||||
|
$job = new \App\Jobs\ProcessUserLeave($id, clone $user, microtime(true), $outInfo);
|
||||||
|
dispatch_sync($job);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取可用头像列表(返回 JSON)
|
* 获取可用头像列表(返回 JSON)
|
||||||
* 扫描 /public/images/headface/ 目录,返回所有可用头像文件名
|
* 扫描 /public/images/headface/ 目录,返回所有可用头像文件名
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class ProcessUserLeave implements ShouldQueue
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public int $roomId,
|
public int $roomId,
|
||||||
public User $user,
|
public User $user,
|
||||||
public float $leaveTime
|
public float $leaveTime,
|
||||||
|
public string $outInfo = '正常退出了房间',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +53,7 @@ class ProcessUserLeave implements ShouldQueue
|
|||||||
// 记录退出时间和退出信息
|
// 记录退出时间和退出信息
|
||||||
$this->user->update([
|
$this->user->update([
|
||||||
'out_time' => now(),
|
'out_time' => now(),
|
||||||
'out_info' => '正常退出了房间',
|
'out_info' => $this->outInfo,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 关闭该用户尚未结束的在职登录记录(结算在线时长)
|
// 关闭该用户尚未结束的在职登录记录(结算在线时长)
|
||||||
@@ -60,41 +61,25 @@ class ProcessUserLeave implements ShouldQueue
|
|||||||
|
|
||||||
// 2. 发送离场播报
|
// 2. 发送离场播报
|
||||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||||
|
|
||||||
|
[$leaveText, $color] = $broadcast->buildLeaveBroadcast($this->user);
|
||||||
|
$vipPresencePayload = $broadcast->buildVipPresencePayload($this->user, 'leave');
|
||||||
|
$leaveMsg = [
|
||||||
|
'id' => $chatState->nextMessageId($this->roomId),
|
||||||
|
'room_id' => $this->roomId,
|
||||||
|
'from_user' => '进出播报',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => $color,
|
||||||
|
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
||||||
|
'welcome_user' => $this->user->username,
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
|
||||||
if ($this->user->user_level >= $superLevel) {
|
// 会员离场时,把横幅与特效信息挂到消息体,前端才能展示专属离场效果。
|
||||||
// 管理员离场:系统公告
|
if (! empty($vipPresencePayload)) {
|
||||||
$leaveMsg = [
|
$leaveMsg = array_merge($leaveMsg, $vipPresencePayload);
|
||||||
'id' => $chatState->nextMessageId($this->roomId),
|
|
||||||
'room_id' => $this->roomId,
|
|
||||||
'from_user' => '系统公告',
|
|
||||||
'to_user' => '大家',
|
|
||||||
'content' => "👋 管理员 【{$this->user->username}】 已离开聊天室。",
|
|
||||||
'is_secret' => false,
|
|
||||||
'font_color' => '#b91c1c',
|
|
||||||
'action' => 'admin_welcome',
|
|
||||||
'welcome_user' => $this->user->username,
|
|
||||||
'sent_at' => now()->toDateTimeString(),
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
[$leaveText, $color] = $broadcast->buildLeaveBroadcast($this->user);
|
|
||||||
$vipPresencePayload = $broadcast->buildVipPresencePayload($this->user, 'leave');
|
|
||||||
$leaveMsg = [
|
|
||||||
'id' => $chatState->nextMessageId($this->roomId),
|
|
||||||
'room_id' => $this->roomId,
|
|
||||||
'from_user' => '进出播报',
|
|
||||||
'to_user' => '大家',
|
|
||||||
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
|
|
||||||
'is_secret' => false,
|
|
||||||
'font_color' => $color,
|
|
||||||
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
|
||||||
'welcome_user' => $this->user->username,
|
|
||||||
'sent_at' => now()->toDateTimeString(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// 会员离场时,把横幅与特效信息挂到消息体,前端才能展示专属离场效果。
|
|
||||||
if (! empty($vipPresencePayload)) {
|
|
||||||
$leaveMsg = array_merge($leaveMsg, $vipPresencePayload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将播报存入 Redis 历史及广播
|
// 将播报存入 Redis 历史及广播
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
levelBanip: {{ $levelBanip }},
|
levelBanip: {{ $levelBanip }},
|
||||||
sendUrl: "{{ route('chat.send', $room->id) }}",
|
sendUrl: "{{ route('chat.send', $room->id) }}",
|
||||||
leaveUrl: "{{ route('chat.leave', $room->id) }}",
|
leaveUrl: "{{ route('chat.leave', $room->id) }}",
|
||||||
|
expiredLeaveUrl: "{{ \Illuminate\Support\Facades\URL::temporarySignedRoute('chat.leave.expired', now()->addHours(12), ['id' => $room->id, 'user' => $user->id]) }}",
|
||||||
heartbeatUrl: "{{ route('chat.heartbeat', $room->id) }}",
|
heartbeatUrl: "{{ route('chat.heartbeat', $room->id) }}",
|
||||||
fishCastUrl: "{{ route('fishing.cast', $room->id) }}",
|
fishCastUrl: "{{ route('fishing.cast', $room->id) }}",
|
||||||
fishReelUrl: "{{ route('fishing.reel', $room->id) }}",
|
fishReelUrl: "{{ route('fishing.reel', $room->id) }}",
|
||||||
|
|||||||
@@ -1527,10 +1527,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 退出房间 ─────────────────────────────────────
|
// ── 退出房间 ─────────────────────────────────────
|
||||||
|
let leaveRequestInFlight = false;
|
||||||
|
|
||||||
async function leaveRoom() {
|
async function leaveRoom() {
|
||||||
// 标记主动离开,pagehide 里不重复发 beacon
|
if (leaveRequestInFlight) {
|
||||||
window._manualLeave = true;
|
return;
|
||||||
clearTimeout(visibilityTimer);
|
}
|
||||||
|
|
||||||
|
leaveRequestInFlight = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(window.chatContext.leaveUrl + '?explicit=1', {
|
await fetch(window.chatContext.leaveUrl + '?explicit=1', {
|
||||||
@@ -1551,42 +1555,30 @@
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function notifyExpiredLeave() {
|
||||||
|
if (leaveRequestInFlight) {
|
||||||
// ── 关闭/离开页面时自动调用 leave,结算勤务时长 ──────────────────────
|
|
||||||
// 使用 sendBeacon 确保浏览器关闭时请求也能发出(比 fetch 更可靠)
|
|
||||||
// 注意:用 pagehide 而非 beforeunload,避免 Chrome 触发原生「离开网站」确认框
|
|
||||||
function sendLeaveBeacon() {
|
|
||||||
if (window._manualLeave) {
|
|
||||||
return;
|
|
||||||
} // 主动调用 leaveRoom() 时不重复发
|
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
||||||
if (!csrfToken || !window.chatContext?.leaveUrl) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = new FormData();
|
|
||||||
data.append('_token', csrfToken);
|
leaveRequestInFlight = true;
|
||||||
navigator.sendBeacon(window.chatContext.leaveUrl, data);
|
|
||||||
|
try {
|
||||||
|
if (!window.chatContext?.expiredLeaveUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(window.chatContext.expiredLeaveUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pagehide:页面关闭/浏览器关闭/刷新均触发,且不会弹原生确认框
|
|
||||||
window.addEventListener('pagehide', sendLeaveBeacon);
|
|
||||||
|
|
||||||
// visibilitychange:切换到后台标签超过30秒也结算(防止长期挂机不算时长)
|
|
||||||
let visibilityTimer = null;
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
// 切到后台,30秒后结算
|
|
||||||
visibilityTimer = setTimeout(sendLeaveBeacon, 30 * 1000);
|
|
||||||
} else {
|
|
||||||
// 切回来,取消结算
|
|
||||||
clearTimeout(visibilityTimer);
|
|
||||||
visibilityTimer = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── 掉线检测计数器 ──
|
// ── 掉线检测计数器 ──
|
||||||
let heartbeatFailCount = 0;
|
let heartbeatFailCount = 0;
|
||||||
@@ -1607,6 +1599,7 @@
|
|||||||
|
|
||||||
// 检测登录态失效
|
// 检测登录态失效
|
||||||
if (response.status === 401 || response.status === 419) {
|
if (response.status === 401 || response.status === 419) {
|
||||||
|
await notifyExpiredLeave();
|
||||||
window.chatDialog.alert('⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。', '连接警告', '#b45309');
|
window.chatDialog.alert('⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。', '连接警告', '#b45309');
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ Route::post('/login', [AuthController::class, 'login'])->name('login.post');
|
|||||||
// 处理退出登录
|
// 处理退出登录
|
||||||
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
|
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
|
||||||
|
|
||||||
|
// 登录失效后用于收口离场清理的签名地址,不依赖当前会话。
|
||||||
|
Route::get('/room/{id}/leave-expired/{user}', [ChatController::class, 'expiredLeave'])
|
||||||
|
->middleware('signed')
|
||||||
|
->name('chat.leave.expired');
|
||||||
|
|
||||||
// 聊天室系统内部路由 (需要鉴权)
|
// 聊天室系统内部路由 (需要鉴权)
|
||||||
Route::middleware(['chat.auth'])->group(function () {
|
Route::middleware(['chat.auth'])->group(function () {
|
||||||
// ---- 第六阶段:大厅与房间管理 ----
|
// ---- 第六阶段:大厅与房间管理 ----
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\Room;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ChatControllerTest extends TestCase
|
class ChatControllerTest extends TestCase
|
||||||
@@ -98,6 +99,25 @@ class ChatControllerTest extends TestCase
|
|||||||
$this->assertEquals(0, Redis::hexists("room:{$room->id}:users", $user->username));
|
$this->assertEquals(0, Redis::hexists("room:{$room->id}:users", $user->username));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_can_leave_room_through_signed_expired_route(): void
|
||||||
|
{
|
||||||
|
$room = Room::create(['room_name' => 'expired_leave_room']);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->get(route('chat.room', $room->id));
|
||||||
|
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
|
||||||
|
|
||||||
|
$url = URL::temporarySignedRoute('chat.leave.expired', now()->addMinutes(5), [
|
||||||
|
'id' => $room->id,
|
||||||
|
'user' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson($url);
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertEquals(0, Redis::hexists("room:{$room->id}:users", $user->username));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试会员用户首次进房时会把专属欢迎主题写入历史消息。
|
* 测试会员用户首次进房时会把专属欢迎主题写入历史消息。
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user