加固房间准入与消息广播边界
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
|
||||
@@ -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 时仍可正常发送。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user