diff --git a/.gitignore b/.gitignore index b04f08e..b6c0520 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ /.phpunit.cache /.vscode /.zed +/.junie +/.github +/.gemini /auth.json /node_modules /public/build diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 9b08147..0c437fd 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -175,17 +175,17 @@ class ChatController extends Controller ]; // 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。 - if (! empty($vipPresencePayload)) { - $generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload); - $initialPresenceTheme = $vipPresencePayload; - } + if (! empty($vipPresencePayload)) { + $generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload); + $initialPresenceTheme = $vipPresencePayload; + } - // 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。 - $initialWelcomeMessage = $generalWelcomeMsg; + // 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。 + $initialWelcomeMessage = $generalWelcomeMsg; - $this->chatState->pushMessage($id, $generalWelcomeMsg); - // 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示 - broadcast(new MessageSent($id, $generalWelcomeMsg)); + $this->chatState->pushMessage($id, $generalWelcomeMsg); + // 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示 + broadcast(new MessageSent($id, $generalWelcomeMsg)); // 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。 if (! empty($vipPresencePayload['presence_effect'])) { @@ -547,7 +547,7 @@ class ChatController extends Controller if ($bonusJjb > 0) { $bonusParts[] = "+金币{$bonusJjb}"; } - + $eventContent = $autoEvent->renderText($user->username); if (! empty($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'; if ($isExplicit) { - // 人工显式点击“离开”,不再进行浏览器刷新的防抖,直接同步执行清算和播报。 - // 这对本地没有开启 Queue Worker 的环境尤为重要,能保证大家立刻看到消息。 - // 为了防止 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); + // 人工显式点击“离开”时,立即同步执行清算和播报。 + $this->dispatchImmediateLeave($id, $user, '主动离开了房间'); } else { // 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟 // 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime) @@ -659,6 +672,17 @@ class ChatController extends Controller 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) * 扫描 /public/images/headface/ 目录,返回所有可用头像文件名 diff --git a/app/Jobs/ProcessUserLeave.php b/app/Jobs/ProcessUserLeave.php index e3a3cd0..82bddef 100644 --- a/app/Jobs/ProcessUserLeave.php +++ b/app/Jobs/ProcessUserLeave.php @@ -30,7 +30,8 @@ class ProcessUserLeave implements ShouldQueue public function __construct( public int $roomId, public User $user, - public float $leaveTime + public float $leaveTime, + public string $outInfo = '正常退出了房间', ) {} /** @@ -52,7 +53,7 @@ class ProcessUserLeave implements ShouldQueue // 记录退出时间和退出信息 $this->user->update([ 'out_time' => now(), - 'out_info' => '正常退出了房间', + 'out_info' => $this->outInfo, ]); // 关闭该用户尚未结束的在职登录记录(结算在线时长) @@ -60,41 +61,25 @@ class ProcessUserLeave implements ShouldQueue // 2. 发送离场播报 $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' => "{$leaveText}", + '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) { - // 管理员离场:系统公告 - $leaveMsg = [ - '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' => "{$leaveText}", - '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); - } + // 会员离场时,把横幅与特效信息挂到消息体,前端才能展示专属离场效果。 + if (! empty($vipPresencePayload)) { + $leaveMsg = array_merge($leaveMsg, $vipPresencePayload); } // 将播报存入 Redis 历史及广播 diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index acca144..082cd0c 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -41,6 +41,7 @@ levelBanip: {{ $levelBanip }}, sendUrl: "{{ route('chat.send', $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) }}", fishCastUrl: "{{ route('fishing.cast', $room->id) }}", fishReelUrl: "{{ route('fishing.reel', $room->id) }}", diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 174d6e0..c923d15 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -1527,10 +1527,14 @@ } // ── 退出房间 ───────────────────────────────────── + let leaveRequestInFlight = false; + async function leaveRoom() { - // 标记主动离开,pagehide 里不重复发 beacon - window._manualLeave = true; - clearTimeout(visibilityTimer); + if (leaveRequestInFlight) { + return; + } + + leaveRequestInFlight = true; try { await fetch(window.chatContext.leaveUrl + '?explicit=1', { @@ -1551,42 +1555,30 @@ }, 500); } - - - // ── 关闭/离开页面时自动调用 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) { + async function notifyExpiredLeave() { + if (leaveRequestInFlight) { return; } - const data = new FormData(); - data.append('_token', csrfToken); - navigator.sendBeacon(window.chatContext.leaveUrl, data); + + leaveRequestInFlight = true; + + 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; @@ -1607,6 +1599,7 @@ // 检测登录态失效 if (response.status === 401 || response.status === 419) { + await notifyExpiredLeave(); window.chatDialog.alert('⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。', '连接警告', '#b45309'); window.location.href = '/'; return; diff --git a/routes/web.php b/routes/web.php index c807be2..ae0eb2f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,6 +30,11 @@ Route::post('/login', [AuthController::class, 'login'])->name('login.post'); // 处理退出登录 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 () { // ---- 第六阶段:大厅与房间管理 ---- diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php index 1c571ff..9a09e46 100644 --- a/tests/Feature/ChatControllerTest.php +++ b/tests/Feature/ChatControllerTest.php @@ -6,6 +6,7 @@ use App\Models\Room; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Redis; +use Illuminate\Support\Facades\URL; use Tests\TestCase; class ChatControllerTest extends TestCase @@ -98,6 +99,25 @@ class ChatControllerTest extends TestCase $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)); + } + /** * 测试会员用户首次进房时会把专属欢迎主题写入历史消息。 */