加固房间准入与消息广播边界

This commit is contained in:
2026-04-19 14:42:52 +08:00
parent 5ce83a769d
commit ba6406ed68
6 changed files with 304 additions and 14 deletions
+56 -2
View File
@@ -10,12 +10,17 @@
namespace App\Events; namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel; use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
/**
* 类功能:根据消息可见范围选择广播频道。
*/
class MessageSent implements ShouldBroadcastNow class MessageSent implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -32,14 +37,25 @@ class MessageSent implements ShouldBroadcastNow
) {} ) {}
/** /**
* Get the channels the event should broadcast on. * 获取消息应广播到的频道。
* *
* 聊天消息广播至包含在线状态管理的 PresenceChannel。 * 公共消息走房间 Presence 频道;
* 定向消息 / 悄悄话只发给发送方与接收方的私有用户频道。
* *
* @return array<int, \Illuminate\Broadcasting\Channel> * @return array<int, \Illuminate\Broadcasting\Channel>
*/ */
public function broadcastOn(): array public function broadcastOn(): array
{ {
if ($this->shouldBroadcastPrivately()) {
$privateChannels = [];
foreach ($this->resolveVisibleUserIds() as $userId) {
$privateChannels[] = new PrivateChannel('user.'.$userId);
}
return $privateChannels;
}
return [ return [
new PresenceChannel('room.'.$this->roomId), new PresenceChannel('room.'.$this->roomId),
]; ];
@@ -56,4 +72,42 @@ class MessageSent implements ShouldBroadcastNow
'message' => $this->message, 'message' => $this->message,
]; ];
} }
/**
* 判断当前消息是否应仅广播给特定用户。
*/
private function shouldBroadcastPrivately(): bool
{
$toUser = trim((string) ($this->message['to_user'] ?? ''));
return $toUser !== '' && $toUser !== '大家';
}
/**
* 解析本条消息真正可见的用户 ID 列表。
*
* @return array<int, int>
*/
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));
}
} }
+14
View File
@@ -71,6 +71,8 @@ class ChatController extends Controller
$room = Room::findOrFail($id); $room = Room::findOrFail($id);
$user = Auth::user(); $user = Auth::user();
$this->ensureUserCanEnterRoom($room, $user);
// 房间人气 +1(每次访问递增,复刻原版人气计数) // 房间人气 +1(每次访问递增,复刻原版人气计数)
$room->increment('visit_num'); $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));
}
/** /**
* 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。 * 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。
* *
+60 -4
View File
@@ -13,7 +13,11 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:承载聊天室房间资料与准入规则。
*/
class Room extends Model class Room extends Model
{ {
/** /**
@@ -56,31 +60,83 @@ class Room extends Model
]; ];
} }
// ---- 兼容新版逻辑和 Blade 视图的访问器 ---- /**
* 兼容新版视图访问器:返回房间名称。
*/
public function getNameAttribute(): string public function getNameAttribute(): string
{ {
return $this->room_name ?? ''; return $this->room_name ?? '';
} }
/**
* 兼容新版视图访问器:返回房间介绍。
*/
public function getDescriptionAttribute(): string public function getDescriptionAttribute(): string
{ {
return $this->room_des ?? ''; return $this->room_des ?? '';
} }
/**
* 兼容新版视图访问器:返回房主用户名。
*/
public function getMasterAttribute(): string public function getMasterAttribute(): string
{ {
return $this->room_owner ?? ''; return $this->room_owner ?? '';
} }
/**
* 兼容新版视图访问器:判断是否系统房间。
*/
public function getIsSystemAttribute(): bool public function getIsSystemAttribute(): bool
{ {
return (bool) $this->room_keep; return (bool) $this->room_keep;
} }
// 同样可为主讲人关联提供便捷方法 /**
public function masterUser() * 关联房间房主用户。
*/
public function masterUser(): BelongsTo
{ {
return $this->belongsTo(User::class, 'room_owner', 'username'); 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;
}
} }
+13
View File
@@ -9,6 +9,8 @@ export function initChat(roomId) {
return; return;
} }
const userId = window.chatContext?.userId;
// 监听全局系统事件(如 AI 机器人开关) // 监听全局系统事件(如 AI 机器人开关)
window.Echo.channel('chat.system') window.Echo.channel('chat.system')
.listen('ChatBotToggled', (e) => { .listen('ChatBotToggled', (e) => {
@@ -147,6 +149,17 @@ export function initChat(roomId) {
new CustomEvent("chat:horse.settled", { detail: e }), 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 }),
);
});
}
} }
/** /**
+9 -3
View File
@@ -8,9 +8,15 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
// 聊天室房间 Presence Channel 鉴权与成员信息抓取 // 聊天室房间 Presence Channel 鉴权与成员信息抓取
Broadcast::channel('room.{roomId}', function ($user, $roomId) { Broadcast::channel('room.{roomId}', function ($user, $roomId) {
// 这里未来可以增加判断:比如该房间是否被锁定,或者该用户是否在此房间的黑名单中 $room = \App\Models\Room::find($roomId);
// 凡是通过了这个判断的人(返回一个数组),他就会成功建立 WebSocket, if (! $room || ! $room->canUserEnter($user)) {
// 且他的这个数组信息会被 Reverb 推送给这个房间内的所有其他人 (joining / here 事件)。 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'); $superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
+152 -5
View File
@@ -1,12 +1,22 @@
<?php <?php
/**
* 文件功能:聊天室控制器功能测试
*
* 覆盖进房、发言、图片消息、权限限制与广播范围等关键聊天流程。
*/
namespace Tests\Feature; namespace Tests\Feature;
use App\Events\MessageSent;
use App\Models\Room; use App\Models\Room;
use App\Models\User; use App\Models\User;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
@@ -14,8 +24,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase; use Tests\TestCase;
/** /**
* 聊天室控制器功能测试 * 类功能:验证聊天室核心控制器的关键行为。
* 覆盖进房、发言、图片消息与退房等关键聊天流程。
*/ */
class ChatControllerTest extends TestCase class ChatControllerTest extends TestCase
{ {
@@ -33,7 +42,7 @@ class ChatControllerTest extends TestCase
/** /**
* 测试用户可以正常进入聊天室页面。 * 测试用户可以正常进入聊天室页面。
*/ */
public function test_can_view_room() public function test_can_view_room(): void
{ {
$room = Room::create(['room_name' => 'testroom']); $room = Room::create(['room_name' => 'testroom']);
$user = User::factory()->create(); $user = User::factory()->create();
@@ -47,6 +56,70 @@ class ChatControllerTest extends TestCase
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username)); $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']); $room = Room::create(['room_name' => 'test_send']);
$user = User::factory()->create(); $user = User::factory()->create();
@@ -100,7 +173,7 @@ class ChatControllerTest extends TestCase
'content' => '测试消息', 'content' => '测试消息',
'is_secret' => false, 'is_secret' => false,
'font_color' => '#000000', 'font_color' => '#000000',
'action' => 'say', 'action' => '微笑',
]); ]);
$response->assertStatus(200); $response->assertStatus(200);
@@ -121,6 +194,80 @@ class ChatControllerTest extends TestCase
$this->assertTrue($found, 'Message not found in Redis'); $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' => '\"></span><img src=x onerror=alert(1)>',
]);
$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 时仍可正常发送。 * 测试文本内容为字符串 0 时仍可正常发送。
*/ */