From ba6406ed68e3bf40d943c7b5864efc24ad458332 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 19 Apr 2026 14:42:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E6=88=BF=E9=97=B4=E5=87=86?= =?UTF-8?q?=E5=85=A5=E4=B8=8E=E6=B6=88=E6=81=AF=E5=B9=BF=E6=92=AD=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Events/MessageSent.php | 58 ++++++++- app/Http/Controllers/ChatController.php | 14 +++ app/Models/Room.php | 64 +++++++++- resources/js/chat.js | 13 ++ routes/channels.php | 12 +- tests/Feature/ChatControllerTest.php | 157 +++++++++++++++++++++++- 6 files changed, 304 insertions(+), 14 deletions(-) diff --git a/app/Events/MessageSent.php b/app/Events/MessageSent.php index 5cbe5be..fc2cead 100644 --- a/app/Events/MessageSent.php +++ b/app/Events/MessageSent.php @@ -10,12 +10,17 @@ namespace App\Events; +use App\Models\User; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PresenceChannel; +use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +/** + * 类功能:根据消息可见范围选择广播频道。 + */ class MessageSent implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; @@ -32,14 +37,25 @@ class MessageSent implements ShouldBroadcastNow ) {} /** - * Get the channels the event should broadcast on. + * 获取消息应广播到的频道。 * - * 聊天消息广播至包含在线状态管理的 PresenceChannel。 + * 公共消息走房间 Presence 频道; + * 定向消息 / 悄悄话只发给发送方与接收方的私有用户频道。 * * @return array */ public function broadcastOn(): array { + if ($this->shouldBroadcastPrivately()) { + $privateChannels = []; + + foreach ($this->resolveVisibleUserIds() as $userId) { + $privateChannels[] = new PrivateChannel('user.'.$userId); + } + + return $privateChannels; + } + return [ new PresenceChannel('room.'.$this->roomId), ]; @@ -56,4 +72,42 @@ class MessageSent implements ShouldBroadcastNow 'message' => $this->message, ]; } + + /** + * 判断当前消息是否应仅广播给特定用户。 + */ + private function shouldBroadcastPrivately(): bool + { + $toUser = trim((string) ($this->message['to_user'] ?? '')); + + return $toUser !== '' && $toUser !== '大家'; + } + + /** + * 解析本条消息真正可见的用户 ID 列表。 + * + * @return array + */ + private function resolveVisibleUserIds(): array + { + $userIds = []; + + $fromUser = trim((string) ($this->message['from_user'] ?? '')); + if ($fromUser !== '') { + $senderId = User::query()->where('username', $fromUser)->value('id'); + if ($senderId !== null) { + $userIds[] = (int) $senderId; + } + } + + $toUser = trim((string) ($this->message['to_user'] ?? '')); + if ($toUser !== '' && $toUser !== '大家') { + $receiverId = User::query()->where('username', $toUser)->value('id'); + if ($receiverId !== null) { + $userIds[] = (int) $receiverId; + } + } + + return array_values(array_unique($userIds)); + } } diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index f19bf6b..e1eedbb 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -71,6 +71,8 @@ class ChatController extends Controller $room = Room::findOrFail($id); $user = Auth::user(); + $this->ensureUserCanEnterRoom($room, $user); + // 房间人气 +1(每次访问递增,复刻原版人气计数) $room->increment('visit_num'); @@ -290,6 +292,18 @@ class ChatController extends Controller ]); } + /** + * 校验当前用户是否允许进入指定房间。 + */ + private function ensureUserCanEnterRoom(Room $room, User $user): void + { + if ($room->canUserEnter($user)) { + return; + } + + abort(403, $room->entryDeniedMessage($user)); + } + /** * 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。 * diff --git a/app/Models/Room.php b/app/Models/Room.php index df750ff..16ac173 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -13,7 +13,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * 类功能:承载聊天室房间资料与准入规则。 + */ class Room extends Model { /** @@ -56,31 +60,83 @@ class Room extends Model ]; } - // ---- 兼容新版逻辑和 Blade 视图的访问器 ---- - + /** + * 兼容新版视图访问器:返回房间名称。 + */ public function getNameAttribute(): string { return $this->room_name ?? ''; } + /** + * 兼容新版视图访问器:返回房间介绍。 + */ public function getDescriptionAttribute(): string { return $this->room_des ?? ''; } + /** + * 兼容新版视图访问器:返回房主用户名。 + */ public function getMasterAttribute(): string { return $this->room_owner ?? ''; } + /** + * 兼容新版视图访问器:判断是否系统房间。 + */ public function getIsSystemAttribute(): bool { return (bool) $this->room_keep; } - // 同样可为主讲人关联提供便捷方法 - public function masterUser() + /** + * 关联房间房主用户。 + */ + public function masterUser(): BelongsTo { return $this->belongsTo(User::class, 'room_owner', 'username'); } + + /** + * 判断指定用户是否允许进入当前房间。 + */ + public function canUserEnter(User $user): bool + { + if ($this->userCanBypassEntryRestrictions($user)) { + return true; + } + + if (! $this->door_open) { + return false; + } + + return $user->user_level >= (int) ($this->permit_level ?? 0); + } + + /** + * 返回用户被拒绝进入房间时的中文提示。 + */ + public function entryDeniedMessage(User $user): string + { + if (! $this->door_open && ! $this->userCanBypassEntryRestrictions($user)) { + return '该房间当前已关闭,暂不允许进入。'; + } + + return '您的等级不足,暂时无法进入该房间。'; + } + + /** + * 判断用户是否可绕过房间开放状态与等级限制。 + */ + private function userCanBypassEntryRestrictions(User $user): bool + { + $superLevel = (int) Sysparam::getValue('superlevel', '100'); + + return $user->id === 1 + || $user->username === $this->master + || $user->user_level >= $superLevel; + } } diff --git a/resources/js/chat.js b/resources/js/chat.js index 312eeec..df12e3c 100644 --- a/resources/js/chat.js +++ b/resources/js/chat.js @@ -9,6 +9,8 @@ export function initChat(roomId) { return; } + const userId = window.chatContext?.userId; + // 监听全局系统事件(如 AI 机器人开关) window.Echo.channel('chat.system') .listen('ChatBotToggled', (e) => { @@ -147,6 +149,17 @@ export function initChat(roomId) { new CustomEvent("chat:horse.settled", { detail: e }), ); }); + + // 监听当前用户私有消息,确保悄悄话与定向系统通知不会泄漏给整个房间。 + if (userId) { + window.Echo.private(`user.${userId}`) + .listen("MessageSent", (e) => { + console.log("收到私有聊天消息:", e.message); + window.dispatchEvent( + new CustomEvent("chat:message", { detail: e.message }), + ); + }); + } } /** diff --git a/routes/channels.php b/routes/channels.php index 61dbe3b..5d811ee 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -8,9 +8,15 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) { // 聊天室房间 Presence Channel 鉴权与成员信息抓取 Broadcast::channel('room.{roomId}', function ($user, $roomId) { - // 这里未来可以增加判断:比如该房间是否被锁定,或者该用户是否在此房间的黑名单中 - // 凡是通过了这个判断的人(返回一个数组),他就会成功建立 WebSocket, - // 且他的这个数组信息会被 Reverb 推送给这个房间内的所有其他人 (joining / here 事件)。 + $room = \App\Models\Room::find($roomId); + if (! $room || ! $room->canUserEnter($user)) { + return false; + } + + $chatState = app(\App\Services\ChatStateService::class); + if (! $chatState->isUserInRoom((int) $roomId, $user->username)) { + return false; + } $superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100'); diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php index 2792946..01e53eb 100644 --- a/tests/Feature/ChatControllerTest.php +++ b/tests/Feature/ChatControllerTest.php @@ -1,12 +1,22 @@ 'testroom']); $user = User::factory()->create(); @@ -47,6 +56,70 @@ class ChatControllerTest extends TestCase $this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username)); } + /** + * 测试关闭房间会拒绝普通用户直接进入。 + */ + public function test_cannot_view_closed_room_without_bypass_permission(): void + { + $room = Room::create([ + 'room_name' => 'closed', + 'door_open' => false, + ]); + $user = User::factory()->create([ + 'user_level' => 1, + ]); + + $response = $this->actingAs($user)->get(route('chat.room', $room->id)); + + $response->assertForbidden(); + $this->assertSame(0, Redis::hexists("room:{$room->id}:users", $user->username)); + } + + /** + * 测试等级不足的用户不能进入受限房间。 + */ + public function test_cannot_view_room_when_level_is_below_permit_level(): void + { + $room = Room::create([ + 'room_name' => 'viprm', + 'permit_level' => 10, + 'door_open' => true, + ]); + $user = User::factory()->create([ + 'user_level' => 5, + ]); + + $response = $this->actingAs($user)->get(route('chat.room', $room->id)); + + $response->assertForbidden(); + $this->assertSame(0, Redis::hexists("room:{$room->id}:users", $user->username)); + } + + /** + * 测试只有已成功进入房间的用户才可通过 Presence 频道鉴权。 + */ + public function test_room_presence_channel_requires_room_access_and_join_state(): void + { + $room = Room::create([ + 'room_name' => 'guard', + 'door_open' => true, + ]); + $user = User::factory()->create(); + $channelCallback = Broadcast::driver()->getChannels()->get('room.{roomId}'); + + $this->assertIsCallable($channelCallback); + $this->assertFalse($channelCallback($user, (string) $room->id)); + + $this->actingAs($user)->get(route('chat.room', $room->id)); + + $authorizedPayload = $channelCallback($user, (string) $room->id); + $this->assertIsArray($authorizedPayload); + $this->assertSame($user->id, $authorizedPayload['id'] ?? null); + + Redis::del("room:{$room->id}:users"); + $this->assertFalse($channelCallback($user, (string) $room->id)); + } + /** * 测试主干默认聊天室页面不会渲染虚拟形象挂载点和配置。 */ @@ -87,7 +160,7 @@ class ChatControllerTest extends TestCase /** * 测试用户可以发送普通文本消息。 */ - public function test_can_send_message() + public function test_can_send_message(): void { $room = Room::create(['room_name' => 'test_send']); $user = User::factory()->create(); @@ -100,7 +173,7 @@ class ChatControllerTest extends TestCase 'content' => '测试消息', 'is_secret' => false, 'font_color' => '#000000', - 'action' => 'say', + 'action' => '微笑', ]); $response->assertStatus(200); @@ -121,6 +194,80 @@ class ChatControllerTest extends TestCase $this->assertTrue($found, 'Message not found in Redis'); } + /** + * 测试发送接口会拦截不在白名单内的危险动作值。 + */ + public function test_send_message_rejects_invalid_action_value(): void + { + $room = Room::create(['room_name' => 'badact']); + $user = User::factory()->create(); + + $this->actingAs($user)->get(route('chat.room', $room->id)); + + $response = $this->actingAs($user)->postJson(route('chat.send', $room->id), [ + 'to_user' => '大家', + 'content' => '危险动作测试', + 'is_secret' => false, + 'font_color' => '#000000', + 'action' => '\">', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('action'); + } + + /** + * 测试定向消息仅广播到发送方与接收方私有频道。 + */ + public function test_targeted_message_event_uses_private_user_channels(): void + { + $sender = User::factory()->create(['username' => 'sender-user']); + $receiver = User::factory()->create(['username' => 'receiver-user']); + + $event = new MessageSent(1, [ + 'room_id' => 1, + 'from_user' => $sender->username, + 'to_user' => $receiver->username, + 'content' => '只给你看', + 'is_secret' => true, + 'font_color' => '#000000', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]); + + $channels = $event->broadcastOn(); + + $this->assertCount(2, $channels); + $this->assertContainsOnlyInstancesOf(PrivateChannel::class, $channels); + $this->assertSame([ + 'private-user.'.$sender->id, + 'private-user.'.$receiver->id, + ], array_map(fn (PrivateChannel $channel) => $channel->name, $channels)); + } + + /** + * 测试公共消息仍广播到房间 Presence 频道。 + */ + public function test_public_message_event_still_uses_room_presence_channel(): void + { + $event = new MessageSent(3, [ + 'room_id' => 3, + 'from_user' => 'tester', + 'to_user' => '大家', + 'content' => '公开消息', + 'is_secret' => false, + 'font_color' => '#000000', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]); + + $channels = $event->broadcastOn(); + + $this->assertCount(1, $channels); + $this->assertInstanceOf(PresenceChannel::class, $channels[0]); + $this->assertSame('presence-room.3', $channels[0]->name); + } + /** * 测试文本内容为字符串 0 时仍可正常发送。 */