diff --git a/app/Http/Controllers/AdminCommandController.php b/app/Http/Controllers/AdminCommandController.php index 2f5d2cc..972c55a 100644 --- a/app/Http/Controllers/AdminCommandController.php +++ b/app/Http/Controllers/AdminCommandController.php @@ -83,6 +83,17 @@ class AdminCommandController extends Controller broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); + // 给被警告用户补一条私聊提示,并复用右下角 toast 通知。 + $this->pushTargetToastMessage( + roomId: (int) $roomId, + targetUsername: $targetUsername, + content: "⚠️ 管理员 {$admin->username} 警告了你:{$reason}", + title: '⚠️ 收到警告', + toastMessage: "{$admin->username} 警告了你:{$reason}", + color: '#f59e0b', + icon: '⚠️', + ); + return response()->json(['status' => 'success', 'message' => "已警告 {$targetUsername}"]); } @@ -112,6 +123,17 @@ class AdminCommandController extends Controller return response()->json(['status' => 'error', 'message' => '权限不足'], 403); } + // 在强制踢出前补发目标私聊提示,尽量让对方先看到 toast。 + $this->pushTargetToastMessage( + roomId: (int) $roomId, + targetUsername: $targetUsername, + content: "🚫 管理员 {$admin->username} 已将你踢出聊天室。原因:{$reason}", + title: '🚫 已被踢出', + toastMessage: "{$admin->username} 已将你踢出聊天室。
原因:{$reason}", + color: '#ef4444', + icon: '🚫', + ); + // 从 Redis 在线列表移除 $this->chatState->userLeave($roomId, $targetUsername); @@ -183,6 +205,17 @@ class AdminCommandController extends Controller broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); + // 给被禁言用户补一条私聊提示,并复用右下角 toast 通知。 + $this->pushTargetToastMessage( + roomId: (int) $roomId, + targetUsername: $targetUsername, + content: "🔇 管理员 {$admin->username} 已将你禁言 {$duration} 分钟。", + title: '🔇 已被禁言', + toastMessage: "{$admin->username} 已将你禁言 {$duration} 分钟。", + color: '#6366f1', + icon: '🔇', + ); + // 广播禁言事件(前端禁用输入框) broadcast(new \App\Events\UserMuted( roomId: $roomId, @@ -228,6 +261,17 @@ class AdminCommandController extends Controller $target->user_level = -1; $target->save(); + // 先给被冻结用户补发私聊提示,再将其移出各房间并强制下线。 + $this->pushTargetToastMessage( + roomId: (int) $roomId, + targetUsername: $targetUsername, + content: "🧊 管理员 {$admin->username} 已冻结你的账号。原因:{$reason}", + title: '🧊 账号已冻结', + toastMessage: "{$admin->username} 已冻结你的账号。
原因:{$reason}", + color: '#3b82f6', + icon: '🧊', + ); + // 从所有房间移除 $rooms = $this->chatState->getUserRooms($targetUsername); foreach ($rooms as $rid) { @@ -709,4 +753,41 @@ class AdminCommandController extends Controller return true; } + + /** + * 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。 + */ + private function pushTargetToastMessage( + int $roomId, + string $targetUsername, + string $content, + string $title, + string $toastMessage, + string $color, + string $icon, + ): void { + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统', + 'to_user' => $targetUsername, + 'content' => $content, + 'is_secret' => true, + 'font_color' => $color, + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + // 复用现有聊天 toast 机制,在右下角弹出操作结果提示。 + 'toast_notification' => [ + 'title' => $title, + 'message' => $toastMessage, + 'icon' => $icon, + 'color' => $color, + 'duration' => 10000, + ], + ]; + + $this->chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + } } diff --git a/app/Http/Controllers/ChatAppointmentController.php b/app/Http/Controllers/ChatAppointmentController.php index 5ef2481..7339b8a 100644 --- a/app/Http/Controllers/ChatAppointmentController.php +++ b/app/Http/Controllers/ChatAppointmentController.php @@ -13,6 +13,8 @@ namespace App\Http\Controllers; use App\Events\AppointmentAnnounced; +use App\Events\MessageSent; +use App\Jobs\SaveMessageJob; use App\Models\Position; use App\Models\User; use App\Services\AppointmentService; @@ -92,6 +94,17 @@ class ChatAppointmentController extends Controller departmentName: $position->department?->name ?? '', operatorName: $operator->username, )); + + // 给被任命用户补一条私聊提示,并复用右下角 toast 通知。 + $this->pushTargetToastMessage( + roomId: (int) $roomId, + targetUsername: $target->username, + content: "✨ {$operator->username} 已任命你为 {$position->icon} {$position->name}。", + title: '✨ 职务任命通知', + toastMessage: "{$operator->username} 已任命你为 {$position->icon} {$position->name}。", + color: '#a855f7', + icon: '✨', + ); } } @@ -136,6 +149,17 @@ class ChatAppointmentController extends Controller operatorName: $operator->username, type: 'revoke', )); + + // 给被撤职用户补一条私聊提示,并复用右下角 toast 通知。 + $this->pushTargetToastMessage( + roomId: (int) $roomId, + targetUsername: $target->username, + content: "📋 {$operator->username} 已撤销你的 {$posIcon} {$posName} 职务。", + title: '📋 职务变动通知', + toastMessage: "{$operator->username} 已撤销你的 {$posIcon} {$posName} 职务。", + color: '#6b7280', + icon: '📋', + ); } } @@ -144,4 +168,41 @@ class ChatAppointmentController extends Controller 'message' => $result['message'], ], $result['ok'] ? 200 : 422); } + + /** + * 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。 + */ + private function pushTargetToastMessage( + int $roomId, + string $targetUsername, + string $content, + string $title, + string $toastMessage, + string $color, + string $icon, + ): void { + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统', + 'to_user' => $targetUsername, + 'content' => $content, + 'is_secret' => true, + 'font_color' => $color, + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + // 复用现有聊天 toast 机制,在右下角弹出职务变动提示。 + 'toast_notification' => [ + 'title' => $title, + 'message' => $toastMessage, + 'icon' => $icon, + 'color' => $color, + 'duration' => 10000, + ], + ]; + + $this->chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + } } diff --git a/tests/Feature/ChatAppointmentControllerTest.php b/tests/Feature/ChatAppointmentControllerTest.php new file mode 100644 index 0000000..2a4213e --- /dev/null +++ b/tests/Feature/ChatAppointmentControllerTest.php @@ -0,0 +1,155 @@ +createAppointmentActors(); + + $response = $this->actingAs($admin)->postJson(route('chat.appoint.appoint'), [ + 'username' => $target->username, + 'position_id' => $position->id, + 'remark' => '聊天室快速任命', + 'room_id' => $room->id, + ]); + + $response->assertOk()->assertJson([ + 'status' => 'success', + 'message' => "已成功将【{$target->username}】任命为【{$position->name}】。", + ]); + + $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已任命你为'); + + $this->assertNotNull($privateMessage); + $this->assertSame('✨ 职务任命通知', $privateMessage['toast_notification']['title'] ?? null); + $this->assertSame('✨', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#a855f7', $privateMessage['toast_notification']['color'] ?? null); + + Queue::assertPushed(SaveMessageJob::class, 1); + } + + /** + * 测试撤销职务后会给目标用户写入带 toast 的私聊提示。 + */ + public function test_revoke_pushes_private_toast_notification_to_target(): void + { + Queue::fake(); + + [$admin, $target, $room, $position] = $this->createAppointmentActors(); + + UserPosition::create([ + 'user_id' => $target->id, + 'position_id' => $position->id, + 'appointed_by_user_id' => $admin->id, + 'appointed_at' => now()->subDay(), + 'remark' => '已有职务', + 'is_active' => true, + ]); + $target->update(['user_level' => $position->level]); + + $response = $this->actingAs($admin)->postJson(route('chat.appoint.revoke'), [ + 'username' => $target->username, + 'remark' => '聊天室快速撤销', + 'room_id' => $room->id, + ]); + + $response->assertOk()->assertJson([ + 'status' => 'success', + ]); + + $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已撤销你的'); + + $this->assertNotNull($privateMessage); + $this->assertSame('📋 职务变动通知', $privateMessage['toast_notification']['title'] ?? null); + $this->assertSame('📋', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#6b7280', $privateMessage['toast_notification']['color'] ?? null); + + Queue::assertPushed(SaveMessageJob::class, 1); + } + + /** + * 创建任命测试共用的操作者、目标用户、房间与职务。 + * + * @return array{0: User, 1: User, 2: Room, 3: Position} + */ + private function createAppointmentActors(): array + { + $admin = User::factory()->create([ + 'id' => 1, + 'user_level' => 100, + ]); + $target = User::factory()->create([ + 'user_level' => 1, + ]); + $room = Room::create([ + 'room_name' => '职务操作房', + ]); + $department = Department::create([ + 'name' => '办公厅', + 'rank' => 90, + ]); + $position = Position::create([ + 'department_id' => $department->id, + 'name' => '巡查员', + 'icon' => '✨', + 'rank' => 50, + 'level' => 20, + ]); + + return [$admin, $target, $room, $position]; + } + + /** + * 从房间消息缓存中定位目标用户收到的系统私聊提示。 + * + * @return array|null + */ + private function findPrivateSystemMessage(int $roomId, string $targetUsername, string $needle): ?array + { + $messages = Redis::lrange("room:{$roomId}:messages", 0, -1); + + return collect($messages) + ->map(fn (string $item) => json_decode($item, true)) + ->first(fn (array $item) => ($item['from_user'] ?? null) === '系统' + && ($item['to_user'] ?? null) === $targetUsername + && str_contains((string) ($item['content'] ?? ''), $needle)); + } +} diff --git a/tests/Feature/Feature/AdminCommandControllerTest.php b/tests/Feature/Feature/AdminCommandControllerTest.php index 048c879..7be495a 100644 --- a/tests/Feature/Feature/AdminCommandControllerTest.php +++ b/tests/Feature/Feature/AdminCommandControllerTest.php @@ -23,6 +23,15 @@ class AdminCommandControllerTest extends TestCase { use RefreshDatabase; + /** + * 每个测试前清空 Redis,避免跨用例的聊天室消息污染断言。 + */ + protected function setUp(): void + { + parent::setUp(); + Redis::flushall(); + } + /** * 测试站长可以触发全部新增全屏特效。 */ @@ -98,4 +107,158 @@ class AdminCommandControllerTest extends TestCase // 奖励公告和接收者私信都应进入异步落库队列。 Queue::assertPushed(SaveMessageJob::class, 2); } + + /** + * 测试警告操作会给目标用户写入带 toast 的私聊提示。 + */ + public function test_warn_message_contains_toast_notification_for_receiver(): void + { + Queue::fake(); + [$admin, $target, $room] = $this->createAdminCommandActors(); + + $response = $this->actingAs($admin)->postJson(route('command.warn'), [ + 'username' => $target->username, + 'room_id' => $room->id, + 'reason' => '请注意发言尺度', + ]); + + $response->assertOk()->assertJson([ + 'status' => 'success', + 'message' => "已警告 {$target->username}", + ]); + + $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '管理员 '.$admin->username.' 警告了你'); + + $this->assertNotNull($privateMessage); + $this->assertSame('⚠️ 收到警告', $privateMessage['toast_notification']['title'] ?? null); + $this->assertSame('⚠️', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#f59e0b', $privateMessage['toast_notification']['color'] ?? null); + Queue::assertPushed(SaveMessageJob::class, 2); + } + + /** + * 测试禁言操作会给目标用户写入带 toast 的私聊提示。 + */ + public function test_mute_message_contains_toast_notification_for_receiver(): void + { + Queue::fake(); + [$admin, $target, $room] = $this->createAdminCommandActors(); + + $response = $this->actingAs($admin)->postJson(route('command.mute'), [ + 'username' => $target->username, + 'room_id' => $room->id, + 'duration' => 15, + ]); + + $response->assertOk()->assertJson([ + 'status' => 'success', + 'message' => "已禁言 {$target->username} 15 分钟", + ]); + + $this->assertTrue((bool) Redis::exists("mute:{$room->id}:{$target->username}")); + + $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已将你禁言 15 分钟'); + + $this->assertNotNull($privateMessage); + $this->assertSame('🔇 已被禁言', $privateMessage['toast_notification']['title'] ?? null); + $this->assertSame('🔇', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#6366f1', $privateMessage['toast_notification']['color'] ?? null); + Queue::assertPushed(SaveMessageJob::class, 2); + } + + /** + * 测试踢出操作会给目标用户写入带 toast 的私聊提示。 + */ + public function test_kick_message_contains_toast_notification_for_receiver(): void + { + Queue::fake(); + [$admin, $target, $room] = $this->createAdminCommandActors(); + Redis::hset("room:{$room->id}:users", $target->username, json_encode(['user_id' => $target->id], JSON_UNESCAPED_UNICODE)); + + $response = $this->actingAs($admin)->postJson(route('command.kick'), [ + 'username' => $target->username, + 'room_id' => $room->id, + 'reason' => '刷屏', + ]); + + $response->assertOk()->assertJson([ + 'status' => 'success', + 'message' => "已踢出 {$target->username}", + ]); + + $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已将你踢出聊天室'); + + $this->assertNotNull($privateMessage); + $this->assertSame('🚫 已被踢出', $privateMessage['toast_notification']['title'] ?? null); + $this->assertSame('🚫', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#ef4444', $privateMessage['toast_notification']['color'] ?? null); + Queue::assertPushed(SaveMessageJob::class, 2); + } + + /** + * 测试冻结操作会给目标用户写入带 toast 的私聊提示。 + */ + public function test_freeze_message_contains_toast_notification_for_receiver(): void + { + Queue::fake(); + [$admin, $target, $room] = $this->createAdminCommandActors(); + + $response = $this->actingAs($admin)->postJson(route('command.freeze'), [ + 'username' => $target->username, + 'room_id' => $room->id, + 'reason' => '严重违规', + ]); + + $response->assertOk()->assertJson([ + 'status' => 'success', + 'message' => "已冻结 {$target->username} 的账号", + ]); + + $this->assertSame(-1, (int) $target->fresh()->user_level); + + $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已冻结你的账号'); + + $this->assertNotNull($privateMessage); + $this->assertSame('🧊 账号已冻结', $privateMessage['toast_notification']['title'] ?? null); + $this->assertSame('🧊', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#3b82f6', $privateMessage['toast_notification']['color'] ?? null); + Queue::assertPushed(SaveMessageJob::class, 2); + } + + /** + * 创建管理员命令测试共用的操作者、目标用户和房间。 + * + * @return array{0: User, 1: User, 2: Room} + */ + private function createAdminCommandActors(): array + { + $admin = User::factory()->create([ + 'id' => 1, + 'user_level' => 100, + ]); + $target = User::factory()->create([ + 'user_level' => 1, + ]); + $room = Room::create([ + 'room_name' => '管理操作房', + ]); + + return [$admin, $target, $room]; + } + + /** + * 从房间消息缓存中定位目标用户收到的系统私聊提示。 + * + * @return array|null + */ + private function findPrivateSystemMessage(int $roomId, string $targetUsername, string $needle): ?array + { + $messages = Redis::lrange("room:{$roomId}:messages", 0, -1); + + return collect($messages) + ->map(fn (string $item) => json_decode($item, true)) + ->first(fn (array $item) => ($item['from_user'] ?? null) === '系统' + && ($item['to_user'] ?? null) === $targetUsername + && str_contains((string) ($item['content'] ?? ''), $needle)); + } }