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

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;
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<int, \Illuminate\Broadcasting\Channel>
*/
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<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);
$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));
}
/**
* 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。
*
+60 -4
View File
@@ -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;
}
}
+13
View File
@@ -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 }),
);
});
}
}
/**
+9 -3
View File
@@ -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');
+152 -5
View File
@@ -1,12 +1,22 @@
<?php
/**
* 文件功能:聊天室控制器功能测试
*
* 覆盖进房、发言、图片消息、权限限制与广播范围等关键聊天流程。
*/
namespace Tests\Feature;
use App\Events\MessageSent;
use App\Models\Room;
use App\Models\User;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
@@ -14,8 +24,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\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']);
$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' => '\"></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 时仍可正常发送。
*/