From 855d031b049b4768f0be14c3d3bb74fd92bc4944 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sat, 25 Apr 2026 02:52:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E8=81=8A=E5=A4=A9=E5=AE=A4?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E8=BE=B9=E7=95=8C=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=89=B9=E6=95=88=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AdminCommandController.php | 154 +++++++++++-- app/Http/Controllers/ChatController.php | 74 ++++++- app/Http/Controllers/ShopController.php | 48 +++-- app/Http/Requests/SendMessageRequest.php | 3 +- app/Providers/AppServiceProvider.php | 35 +++ app/Support/ChatContentSanitizer.php | 23 ++ app/Support/PositionPermissionRegistry.php | 10 + public/js/effects/confetti.js | 31 ++- public/js/effects/effect-manager.js | 204 ++++++++++++------ public/js/effects/fireflies.js | 30 ++- public/js/effects/fireworks.js | 85 ++++++-- public/js/effects/gold-rain.js | 30 ++- public/js/effects/hearts.js | 30 ++- public/js/effects/lightning.js | 38 ++-- public/js/effects/meteors.js | 30 ++- public/js/effects/rain.js | 30 ++- public/js/effects/sakura.js | 30 ++- public/js/effects/snow.js | 30 ++- .../chat/partials/games/earn-panel.blade.php | 8 +- .../chat/partials/layout/input-bar.blade.php | 2 +- .../views/chat/partials/shop-panel.blade.php | 1 + .../chat/partials/user-actions.blade.php | 5 +- routes/web.php | 12 +- tests/Feature/ChatControllerTest.php | 164 ++++++++++++++ .../Feature/AdminCommandControllerTest.php | 133 ++++++++++++ tests/Feature/ShopControllerTest.php | 154 ++++++++++++- 26 files changed, 1219 insertions(+), 175 deletions(-) create mode 100644 app/Support/ChatContentSanitizer.php diff --git a/app/Http/Controllers/AdminCommandController.php b/app/Http/Controllers/AdminCommandController.php index 406e891..5272f00 100644 --- a/app/Http/Controllers/AdminCommandController.php +++ b/app/Http/Controllers/AdminCommandController.php @@ -22,11 +22,13 @@ use App\Events\MessageSent; use App\Jobs\SaveMessageJob; use App\Models\Message; use App\Models\PositionAuthorityLog; +use App\Models\Room; use App\Models\Sysparam; use App\Models\User; use App\Services\ChatStateService; use App\Services\PositionPermissionService; use App\Services\UserCurrencyService; +use App\Support\ChatContentSanitizer; use App\Support\PositionPermissionRegistry; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -66,8 +68,14 @@ class AdminCommandController extends Controller $admin = Auth::user(); $targetUsername = $request->input('username'); - $roomId = $request->input('room_id'); - $reason = $request->input('reason', '请注意言行'); + $roomId = (int) $request->input('room_id'); + $roomAuthorization = $this->authorizeManagedRoom($roomId, $admin); + if (! $roomAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); + } + + $reason = ChatContentSanitizer::htmlText($request->input('reason', '请注意言行')); + $safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername); $operatorDisplay = $this->buildOperatorDisplayHtml($admin); // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 @@ -76,13 +84,18 @@ class AdminCommandController extends Controller return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); } + $targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername); + if (! $targetAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403); + } + // 广播警告消息 $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统传音', 'to_user' => '大家', - 'content' => "⚠️ {$operatorDisplay} 警告 {$targetUsername}:{$reason}", + 'content' => "⚠️ {$operatorDisplay} 警告 {$safeTargetUsername}:{$reason}", 'is_secret' => false, 'font_color' => '#dc2626', 'action' => '', @@ -124,8 +137,14 @@ class AdminCommandController extends Controller $admin = Auth::user(); $targetUsername = $request->input('username'); - $roomId = $request->input('room_id'); - $reason = $request->input('reason', '违反聊天室规则'); + $roomId = (int) $request->input('room_id'); + $roomAuthorization = $this->authorizeManagedRoom($roomId, $admin); + if (! $roomAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); + } + + $reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则')); + $safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername); $operatorDisplay = $this->buildOperatorDisplayHtml($admin); // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 @@ -134,6 +153,11 @@ class AdminCommandController extends Controller return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); } + $targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername); + if (! $targetAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403); + } + // 在强制踢出前补发目标私聊提示,尽量让对方先看到 toast。 $this->pushTargetToastMessage( roomId: (int) $roomId, @@ -154,7 +178,7 @@ class AdminCommandController extends Controller 'room_id' => $roomId, 'from_user' => '系统传音', 'to_user' => '大家', - 'content' => "🚫 {$operatorDisplay} 已将 {$targetUsername} 踢出聊天室。原因:{$reason}", + 'content' => "🚫 {$operatorDisplay} 已将 {$safeTargetUsername} 踢出聊天室。原因:{$reason}", 'is_secret' => false, 'font_color' => '#dc2626', 'action' => '', @@ -188,8 +212,14 @@ class AdminCommandController extends Controller $admin = Auth::user(); $targetUsername = $request->input('username'); - $roomId = $request->input('room_id'); + $roomId = (int) $request->input('room_id'); + $roomAuthorization = $this->authorizeManagedRoom($roomId, $admin); + if (! $roomAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); + } + $duration = $request->input('duration'); + $safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername); $operatorDisplay = $this->buildOperatorDisplayHtml($admin); $operatorLabel = $this->buildOperatorIdentityLabel($admin).' '.$admin->username; @@ -199,6 +229,11 @@ class AdminCommandController extends Controller return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); } + $targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername); + if (! $targetAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403); + } + // 设置 Redis 禁言标记,TTL 自动过期 $muteKey = "mute:{$roomId}:{$targetUsername}"; Redis::setex($muteKey, $duration * 60, now()->toDateTimeString()); @@ -209,7 +244,7 @@ class AdminCommandController extends Controller 'room_id' => $roomId, 'from_user' => '系统传音', 'to_user' => '大家', - 'content' => "🔇 {$operatorDisplay} 已将 {$targetUsername} 禁言 {$duration} 分钟。", + 'content' => "🔇 {$operatorDisplay} 已将 {$safeTargetUsername} 禁言 {$duration} 分钟。", 'is_secret' => false, 'font_color' => '#dc2626', 'action' => '', @@ -260,8 +295,14 @@ class AdminCommandController extends Controller $admin = Auth::user(); $targetUsername = $request->input('username'); - $roomId = $request->input('room_id'); - $reason = $request->input('reason', '违反聊天室规则'); + $roomId = (int) $request->input('room_id'); + $roomAuthorization = $this->authorizeManagedRoom($roomId, $admin); + if (! $roomAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); + } + + $reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则')); + $safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername); $operatorDisplay = $this->buildOperatorDisplayHtml($admin); // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 @@ -270,6 +311,11 @@ class AdminCommandController extends Controller return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); } + $targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername); + if (! $targetAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403); + } + // 冻结用户账号(将等级设为 -1 表示冻结) $target = $authorization['target']; $target->user_level = -1; @@ -298,7 +344,7 @@ class AdminCommandController extends Controller 'room_id' => $roomId, 'from_user' => '系统传音', 'to_user' => '大家', - 'content' => "🧊 {$operatorDisplay} 已冻结 {$targetUsername} 的账号。原因:{$reason}", + 'content' => "🧊 {$operatorDisplay} 已冻结 {$safeTargetUsername} 的账号。原因:{$reason}", 'is_secret' => false, 'font_color' => '#dc2626', 'action' => '', @@ -381,17 +427,23 @@ class AdminCommandController extends Controller return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403); } - $roomId = $request->input('room_id'); - $content = $request->input('content'); + $roomId = (int) $request->input('room_id'); + $roomAuthorization = $this->authorizeManagedRoom($roomId, $admin); + if (! $roomAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); + } + + $content = ChatContentSanitizer::htmlText($request->input('content')); // 按当前在职职务拼装发布者身份,避免继续显示为固定“站长公告” - $publisherLabel = $this->buildAnnouncementPublisherLabel($admin); + $publisherLabel = ChatContentSanitizer::htmlText($this->buildAnnouncementPublisherLabel($admin)); + $publisherUsername = ChatContentSanitizer::htmlText($admin->username); $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统公告', 'to_user' => '大家', - 'content' => "📢 {$publisherLabel} {$admin->username} 发布公告:{$content}", + 'content' => "📢 {$publisherLabel} {$publisherUsername} 发布公告:{$content}", 'is_secret' => false, 'font_color' => '#b91c1c', 'action' => '', @@ -447,6 +499,44 @@ class AdminCommandController extends Controller return "{$identityLabel} {$username}"; } + /** + * 校验操作者是否可在指定房间执行聊天室管理命令。 + * + * @return array{ok: bool, message: string, room?: Room} + */ + private function authorizeManagedRoom(int $roomId, User $operator): array + { + $room = Room::query()->find($roomId); + if (! $room) { + return ['ok' => false, 'message' => '房间不存在']; + } + + if (! $room->canUserEnter($operator)) { + return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令']; + } + + // 管理命令只能作用于操作者当前所在房间,防止手工 POST 跨房间操作。 + if (! $this->chatState->isUserInRoom($roomId, $operator->username)) { + return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令']; + } + + return ['ok' => true, 'message' => '校验通过', 'room' => $room]; + } + + /** + * 校验目标用户是否仍在线于当前房间。 + * + * @return array{ok: bool, message: string} + */ + private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array + { + if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) { + return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作']; + } + + return ['ok' => true, 'message' => '校验通过']; + } + /** * 校验聊天室用户管理动作是否可执行。 * @@ -548,12 +638,17 @@ class AdminCommandController extends Controller ]); $admin = Auth::user(); - $roomId = $request->input('room_id'); + $roomId = (int) $request->input('room_id'); // 改为按职务权限控制聊天室顶部“清屏”按钮。 if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_CLEAR_SCREEN)) { return response()->json(['status' => 'error', 'message' => '当前职务无权执行全员清屏'], 403); } + $roomAuthorization = $this->authorizeManagedRoom($roomId, $admin); + if (! $roomAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); + } + // 清除 Redis 中该房间的消息缓存 $this->chatState->clearMessages($roomId); @@ -582,7 +677,12 @@ class AdminCommandController extends Controller } $roomId = (int) $request->input('room_id'); - $reason = trim((string) $request->input('reason', '')); + $roomAuthorization = $this->authorizeManagedRoom($roomId, $admin); + if (! $roomAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); + } + + $reason = ChatContentSanitizer::htmlText($request->input('reason', '')); // 立即广播页面刷新指令,确保在线用户尽快拿到最新前端状态。 broadcast(new BrowserRefreshRequested( @@ -614,12 +714,17 @@ class AdminCommandController extends Controller ]); $admin = Auth::user(); - $roomId = $request->input('room_id'); + $roomId = (int) $request->input('room_id'); $type = $request->input('type'); if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT)) { return response()->json(['status' => 'error', 'message' => '当前职务无权触发特效'], 403); } + $roomAuthorization = $this->authorizeManagedRoom($roomId, $admin); + if (! $roomAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); + } + // 广播特效事件给房间内所有在线用户 broadcast(new EffectBroadcast($roomId, $type, $admin->username)); @@ -658,6 +763,10 @@ class AdminCommandController extends Controller $roomId = (int) $request->input('room_id'); $amount = (int) $request->input('amount'); $targetUsername = $request->input('username'); + $roomAuthorization = $this->authorizeManagedRoom($roomId, $admin); + if (! $roomAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); + } // 不能给自己发放 if ($admin->username === $targetUsername) { @@ -670,12 +779,21 @@ class AdminCommandController extends Controller return response()->json(['status' => 'error', 'message' => '用户不存在'], 404); } + $targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername); + if (! $targetAuthorization['ok']) { + return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403); + } + // id=1 超级管理员:无需职务,无限额限制 $isSuperAdmin = $admin->id === 1; $userPosition = null; $position = null; if (! $isSuperAdmin) { + if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_REWARD)) { + return response()->json(['status' => 'error', 'message' => '当前职务无权发放奖励'], 403); + } + // ① 必须有在职职务 $userPosition = $admin->activePosition; if (! $userPosition) { diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index ebc560d..5f9f7df 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -31,6 +31,7 @@ use App\Services\PositionPermissionService; use App\Services\RoomBroadcastService; use App\Services\UserCurrencyService; use App\Services\VipService; +use App\Support\ChatContentSanitizer; use App\Support\ChatDailyStatusCatalog; use App\Support\PositionPermissionRegistry; use Illuminate\Http\JsonResponse; @@ -353,6 +354,10 @@ class ChatController extends Controller $user = Auth::user(); $imagePayload = null; + if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再发言。')) { + return $response; + } + // 0. 检查用户是否被禁言(Redis TTL 自动过期) $muteKey = "mute:{$id}:{$user->username}"; if (Redis::exists($muteKey)) { @@ -884,13 +889,18 @@ class ChatController extends Controller return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403); } + if (! $this->chatState->isUserInRoom($id, $user->username)) { + return response()->json(['status' => 'error', 'message' => '请先进入该房间后再修改公告'], 403); + } + $request->validate([ 'announcement' => 'required|string|max:500', ]); - // 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段 - $room->announcement = trim($request->input('announcement')) - .' ——'.$user->username.' '.now()->format('m-d H:i'); + $announcementText = trim((string) $request->input('announcement')); + + // 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段。 + $room->announcement = $announcementText.' ——'.$user->username.' '.now()->format('m-d H:i'); $room->save(); // 广播公告更新到所有在线用户 @@ -899,7 +909,7 @@ class ChatController extends Controller 'room_id' => $id, 'from_user' => '系统公告', 'to_user' => '大家', - 'content' => "📢 {$user->username} 更新了房间公告:{$room->announcement}", + 'content' => '📢 '.ChatContentSanitizer::htmlText($user->username).' 更新了房间公告:'.ChatContentSanitizer::htmlText($room->announcement), 'is_secret' => false, 'font_color' => '#cc0000', 'action' => '', @@ -941,6 +951,10 @@ class ChatController extends Controller $giftId = $request->integer('gift_id'); $count = $request->integer('count', 1); + if ($response = $this->ensureUserCanActInRoom((int) $roomId, $user, '请先进入当前房间后再送礼物。')) { + return $response; + } + // 不能给自己送花 if ($toUsername === $user->username) { return response()->json(['status' => 'error', 'message' => '不能给自己送花哦~']); @@ -958,6 +972,10 @@ class ChatController extends Controller return response()->json(['status' => 'error', 'message' => '用户不存在']); } + if ($response = $this->ensureTargetOnlineInRoom((int) $roomId, (string) $toUsername)) { + return $response; + } + $totalCost = $gift->cost * $count; $totalCharm = $gift->charm * $count; @@ -981,12 +999,15 @@ class ChatController extends Controller // 广播送花消息(含图片标记,前端识别后渲染图片) $countText = $count > 1 ? " {$count} 份" : ''; + $safeSender = ChatContentSanitizer::htmlText($user->username); + $safeReceiver = ChatContentSanitizer::htmlText($toUsername); + $safeGiftName = ChatContentSanitizer::htmlText($gift->name); $sysMsg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '送花播报', 'to_user' => $toUsername, - 'content' => "{$gift->emoji} 【{$user->username}】 向 【{$toUsername}】 送出了{$countText}【{$gift->name}】!魅力 +{$totalCharm}!", + 'content' => "{$gift->emoji} 【{$safeSender}】 向 【{$safeReceiver}】 送出了{$countText}【{$safeGiftName}】!魅力 +{$totalCharm}!", 'is_secret' => false, 'font_color' => '#e91e8f', 'action' => '', @@ -1255,6 +1276,10 @@ class ChatController extends Controller $roomId = $request->integer('room_id'); $amount = $request->integer('amount'); + if ($response = $this->ensureUserCanActInRoom($roomId, $sender, '请先进入当前房间后再赠送金币。')) { + return $response; + } + // 不能给自己转账 if ($toName === $sender->username) { return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']); @@ -1266,6 +1291,10 @@ class ChatController extends Controller return response()->json(['status' => 'error', 'message' => '用户不存在']); } + if ($response = $this->ensureTargetOnlineInRoom($roomId, (string) $toName)) { + return $response; + } + // 余额校验 if (($sender->jjb ?? 0) < $amount) { return response()->json([ @@ -1292,7 +1321,7 @@ class ChatController extends Controller // 接收方收到消息时,在右下角弹到账提示卡片。 'toast_notification' => [ 'title' => '💰 赠金币到账', - 'message' => "{$sender->username} 向你赠送了 {$amount} 枚金币!", + 'message' => ''.ChatContentSanitizer::htmlText($sender->username)." 向你赠送了 {$amount} 枚金币!", 'icon' => '💰', 'color' => '#f59e0b', 'duration' => 8000, @@ -1313,4 +1342,37 @@ class ChatController extends Controller ], ]); } + + /** + * 校验用户是否能在指定房间执行聊天动作。 + */ + private function ensureUserCanActInRoom(int $roomId, ?User $user, string $message): ?JsonResponse + { + if (! $user) { + return response()->json(['status' => 'error', 'message' => '请先登录'], 401); + } + + $room = Room::query()->find($roomId); + if (! $room) { + return response()->json(['status' => 'error', 'message' => '房间不存在'], 404); + } + + if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) { + return response()->json(['status' => 'error', 'message' => $message], 403); + } + + return null; + } + + /** + * 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。 + */ + private function ensureTargetOnlineInRoom(int $roomId, string $targetUsername): ?JsonResponse + { + if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) { + return response()->json(['status' => 'error', 'message' => '目标用户不在当前房间,无法执行该操作'], 403); + } + + return null; + } } diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index b68eb33..105c8b1 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -9,9 +9,12 @@ namespace App\Http\Controllers; use App\Events\EffectBroadcast; use App\Events\MessageSent; +use App\Models\Room; use App\Models\ShopItem; use App\Models\UserPurchase; +use App\Services\ChatStateService; use App\Services\ShopService; +use App\Support\ChatContentSanitizer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -23,6 +26,7 @@ class ShopController extends Controller */ public function __construct( private readonly ShopService $shopService, + private readonly ChatStateService $chatState, ) {} /** @@ -85,16 +89,26 @@ class ShopController extends Controller { $request->validate([ 'item_id' => 'required|integer|exists:shop_items,id', + 'room_id' => 'required|integer|exists:rooms,id', + 'recipient' => 'nullable|string|max:50', + 'message' => 'nullable|string|max:120', 'quantity' => 'nullable|integer|min:1|max:99', ]); + $user = Auth::user(); + $roomId = (int) $request->input('room_id'); + $room = Room::query()->findOrFail($roomId); + if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) { + return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买聊天室道具。'], 403); + } + $item = ShopItem::find($request->item_id); if (! $item->is_active) { return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400); } $quantity = (int) $request->input('quantity', 1); - $result = $this->shopService->buyItem(Auth::user(), $item, $quantity); + $result = $this->shopService->buyItem($user, $item, $quantity); if (! $result['ok']) { return response()->json(['status' => 'error', 'message' => $result['message']], 400); @@ -109,15 +123,14 @@ class ShopController extends Controller // ── 单次特效卡:广播给指定用户或全员 ──────────────────────── if (isset($result['play_effect'])) { - $user = Auth::user(); - $roomId = (int) $request->room_id; $recipient = trim($request->input('recipient', '')); // 空字符串 = 全员 - $message = trim($request->input('message', '')); + $message = ChatContentSanitizer::htmlText($request->input('message', '')); // recipient 为空或 "all" 表示全员 $targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient; + $safeTargetUsername = $targetUsername ? ChatContentSanitizer::htmlText($targetUsername) : null; - // 广播特效事件(全员频道) + // 广播特效事件时保留原始用户名标识,前端需要用它和当前登录名做精确比较。 broadcast(new EffectBroadcast( roomId: $roomId, type: $result['play_effect'], @@ -147,9 +160,11 @@ class ShopController extends Controller ]; // 赠礼消息文案(改成"为XX触发了一场特效") $icon = $icons[$result['play_effect']] ?? '✨'; - $toStr = $targetUsername ? "【{$targetUsername}】" : '全体聊友'; + $safeBuyer = ChatContentSanitizer::htmlText($user->username); + $safeItemName = ChatContentSanitizer::htmlText($item->name); + $toStr = $safeTargetUsername ? "【{$safeTargetUsername}】" : '全体聊友'; $remarkPart = $message ? " 「{$message}」" : ''; - $sysContent = "{$icon} {$user->username} 为 {$toStr} 燃放了一场【{$item->name}】特效!{$remarkPart}"; + $sysContent = "{$icon} {$safeBuyer} 为 {$toStr} 燃放了一场【{$safeItemName}】特效!{$remarkPart}"; // 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致) $sysMsgEvent = new MessageSent( @@ -170,9 +185,6 @@ class ShopController extends Controller } } else { // ── 其他类型:广播购买通知到公屏 ──────────────────────────── - $user = Auth::user(); - $roomId = (int) $request->room_id; - if ($roomId > 0) { // auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式) $fishDuration = ''; @@ -185,13 +197,15 @@ class ShopController extends Controller $broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音'; // 根据商品类型生成不同通知文案 + $safeBuyer = ChatContentSanitizer::htmlText($user->username); + $safeItemName = ChatContentSanitizer::htmlText($item->name); $sysContent = match ($item->type) { - 'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!", - 'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!", - 'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?", - 'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!", - ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$user->username}】购买了 {$quantity} 张「{$item->name}」,准备把漏掉的签到补回来!", - default => "🛒 【{$user->username}】购买了「{$item->name}」。", + 'duration' => "📅 【{$safeBuyer}】购买了全屏特效周卡「{$safeItemName}」,登录时将自动触发!", + 'one_time' => "🎫 【{$safeBuyer}】购买了「{$safeItemName}」道具!", + 'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?", + 'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!", + ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!", + default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。", }; broadcast(new MessageSent( @@ -212,7 +226,7 @@ class ShopController extends Controller } // 返回最新金币余额 - $response['jjb'] = Auth::user()->fresh()->jjb; + $response['jjb'] = $user->fresh()->jjb; return response()->json($response); } diff --git a/app/Http/Requests/SendMessageRequest.php b/app/Http/Requests/SendMessageRequest.php index 00a0f56..52ae5a6 100644 --- a/app/Http/Requests/SendMessageRequest.php +++ b/app/Http/Requests/SendMessageRequest.php @@ -62,7 +62,7 @@ class SendMessageRequest extends FormRequest 'image' => ['nullable', 'required_without:content', 'file', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:6144'], 'to_user' => ['nullable', 'string', 'max:50'], 'is_secret' => ['nullable', 'boolean'], - 'font_color' => ['nullable', 'string', 'max:10'], // html color hex + 'font_color' => ['nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'], // html color hex 'action' => ['nullable', 'string', 'max:50', Rule::in(self::ALLOWED_ACTIONS)], // 动作字段仅允许预设值,阻断拼接式 XSS 注入 ]; } @@ -91,6 +91,7 @@ class SendMessageRequest extends FormRequest 'image.image' => '上传的文件必须是图片。', 'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。', 'image.max' => '图片大小不能超过 6MB。', + 'font_color.regex' => '发言颜色格式不合法,请重新选择颜色。', 'action.in' => '发言动作不合法,请重新选择。', ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 16adf7f..beddaaa 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -45,6 +45,9 @@ class AppServiceProvider extends ServiceProvider // 注册登录入口限流器,阻断爆破和批量注册滥用。 $this->registerAuthRateLimiters(); + // 注册聊天室高频动作限流器,避免消息、购买与特效广播被脚本刷爆。 + $this->registerChatActionRateLimiters(); + // 注册婚姻系统消息订阅者(结婚/婚礼/离婚通知写入聊天历史) Event::subscribe(SaveMarriageSystemMessage::class); @@ -133,4 +136,36 @@ class AppServiceProvider extends ServiceProvider return implode('|', [$scene, $username, $request->ip()]); } + + /** + * 注册聊天室内高频动作限流器。 + */ + private function registerChatActionRateLimiters(): void + { + RateLimiter::for('chat-send', function (Request $request): Limit { + return Limit::perMinute(40) + ->by($this->buildChatActionRateLimitKey($request, 'chat-send')); + }); + + RateLimiter::for('chat-shop-buy', function (Request $request): Limit { + return Limit::perMinute(20) + ->by($this->buildChatActionRateLimitKey($request, 'chat-shop-buy')); + }); + + RateLimiter::for('chat-effect', function (Request $request): Limit { + return Limit::perMinute(6) + ->by($this->buildChatActionRateLimitKey($request, 'chat-effect')); + }); + } + + /** + * 构造聊天室动作限流键,按场景、用户与房间隔离计数。 + */ + private function buildChatActionRateLimitKey(Request $request, string $scene): string + { + $userId = (string) ($request->user()?->id ?? 'guest'); + $roomId = (string) ($request->route('id') ?? $request->input('room_id', 'global')); + + return implode('|', [$scene, $userId, $roomId, $request->ip()]); + } } diff --git a/app/Support/ChatContentSanitizer.php b/app/Support/ChatContentSanitizer.php new file mode 100644 index 0000000..5b92f79 --- /dev/null +++ b/app/Support/ChatContentSanitizer.php @@ -0,0 +1,23 @@ + '全屏特效', 'description' => '允许触发聊天室内全部全屏动画特效。', ], + self::ROOM_REWARD => [ + 'group' => '用户管理', + 'label' => '奖励金币', + 'description' => '允许在额度限制内向低位阶用户发放职务金币奖励。', + ], self::USER_WARN => [ 'group' => '用户管理', 'label' => '警告用户', diff --git a/public/js/effects/confetti.js b/public/js/effects/confetti.js index ef2dc1a..7377f2e 100644 --- a/public/js/effects/confetti.js +++ b/public/js/effects/confetti.js @@ -96,6 +96,28 @@ const ConfettiEffect = (() => { const startTime = performance.now(); let lastSpawnAt = startTime; let animId = null; + let finished = false; + + /** + * 统一结束彩纸动画,手动取消时只清理不回调。 + * + * @param {boolean} canceled 是否为手动取消 + */ + function finish(canceled) { + if (finished) { + return; + } + + finished = true; + if (animId) { + cancelAnimationFrame(animId); + } + pieces = []; + ctx.clearRect(0, 0, w, h); + if (!canceled) { + onEnd(); + } + } function animate(now) { ctx.clearRect(0, 0, w, h); @@ -114,13 +136,16 @@ const ConfettiEffect = (() => { if (now - startTime < DURATION) { animId = requestAnimationFrame(animate); } else { - cancelAnimationFrame(animId); - ctx.clearRect(0, 0, w, h); - onEnd(); + finish(false); } } animId = requestAnimationFrame(animate); + return { + cancel() { + finish(true); + }, + }; } return { start }; diff --git a/public/js/effects/effect-manager.js b/public/js/effects/effect-manager.js index d136575..cdacf5b 100644 --- a/public/js/effects/effect-manager.js +++ b/public/js/effects/effect-manager.js @@ -9,16 +9,33 @@ const EffectManager = (() => { // 当前正在播放的特效名称(防止同时播放两个特效) let _current = null; + // 当前特效返回的取消函数,用于手动停止时真正停掉内部 RAF / 定时器 + let _currentCancel = null; // 全屏 Canvas 元素引用 let _canvas = null; // 待播放特效队列,避免多个进场效果互相打断 const _queue = []; + // 队列最多保留 3 个待播特效,避免高频触发后播放过期动画 + const MAX_QUEUE_LENGTH = 3; // 当前特效播放批次,用于忽略手动停止后的旧回调 let _playToken = 0; // 是否已经绑定本轮点击停止监听 let _clickStopBound = false; // 延迟绑定定时器,避免触发播放的同一次点击立即停止特效 let _clickStopTimer = null; + // 当前画布像素倍率固定为 1,避免高清手机放大绘制面积拖慢特效 + const MAX_DPR = 1; + + /** + * 按窗口尺寸重置画布像素尺寸。 + * + * @param {HTMLCanvasElement} canvas 全屏特效画布 + */ + function _resizeCanvas(canvas) { + const ratio = Math.min(window.devicePixelRatio || 1, MAX_DPR); + canvas.width = Math.floor(window.innerWidth * ratio); + canvas.height = Math.floor(window.innerHeight * ratio); + } /** * 获取或创建全屏 Canvas 元素 @@ -41,13 +58,28 @@ const EffectManager = (() => { "cursor:pointer", "touch-action:manipulation", ].join(";"); - c.width = window.innerWidth; - c.height = window.innerHeight; + _resizeCanvas(c); document.body.appendChild(c); _canvas = c; + window.addEventListener("resize", _handleResize); + window.addEventListener("orientationchange", _handleResize); return c; } + /** + * 响应窗口尺寸变化,确保手机横竖屏切换后覆盖范围正确。 + */ + function _handleResize() { + if (_current) { + stop(); + return; + } + + if (_canvas) { + _resizeCanvas(_canvas); + } + } + /** * 绑定点击屏幕立即停止当前特效的监听。 * @@ -129,7 +161,7 @@ const EffectManager = (() => { * @param {boolean} options.playNext 是否继续播放队列中的下一个特效 * @param {number|null} options.token 当前特效播放批次 */ - function _cleanup({ playNext = true, token = null } = {}) { + function _cleanup({ playNext = true, token = null, cancelCurrent = false } = {}) { if (token !== null && token !== _playToken) { return; } @@ -137,9 +169,16 @@ const EffectManager = (() => { _playToken++; _unbindClickStop(); + if (cancelCurrent && typeof _currentCancel === "function") { + _currentCancel(); + } + _currentCancel = null; + if (_canvas && document.body.contains(_canvas)) { document.body.removeChild(_canvas); } + window.removeEventListener("resize", _handleResize); + window.removeEventListener("orientationchange", _handleResize); _canvas = null; _current = null; // 通知音效引擎停止(兜底:正常情况下音效会自行计时结束) @@ -155,6 +194,52 @@ const EffectManager = (() => { } } + /** + * 将特效加入有限队列,同类型短时间重复触发时只保留一份。 + * + * @param {string} type 待播放特效类型 + */ + function _enqueue(type) { + const existingIndex = _queue.indexOf(type); + if (existingIndex !== -1) { + _queue.splice(existingIndex, 1); + } + + _queue.push(type); + while (_queue.length > MAX_QUEUE_LENGTH) { + _queue.shift(); + } + } + + /** + * 记录具体特效返回的取消句柄。 + * + * @param {Object|undefined} controller 特效启动返回值 + */ + function _bindEffectController(controller) { + _currentCancel = typeof controller?.cancel === "function" + ? controller.cancel + : null; + } + + /** + * 启动具体特效并保存取消句柄。 + * + * @param {Object|undefined} effectObject 特效全局对象 + * @param {HTMLCanvasElement} canvas 全屏特效画布 + * @param {Function} finishCurrent 当前特效结束回调 + * @param {string} startMethod 启动方法名称 + * @returns {boolean} 是否成功找到并启动特效 + */ + function _startEffect(effectObject, canvas, finishCurrent, startMethod = "start") { + if (!effectObject || typeof effectObject[startMethod] !== "function") { + return false; + } + + _bindEffectController(effectObject[startMethod](canvas, finishCurrent)); + return true; + } + /** * 播放指定特效 * @@ -164,7 +249,7 @@ const EffectManager = (() => { // 防重入:同时只允许一个特效 if (_current) { console.log(`[EffectManager] 特效 ${_current} 正在播放,加入队列 ${type}`); - _queue.push(type); + _enqueue(type); return; } @@ -179,66 +264,53 @@ const EffectManager = (() => { EffectSounds.play(type); } - switch (type) { - case "fireworks": - if (typeof FireworksEffect !== "undefined") { - FireworksEffect.start(canvas, finishCurrent); - } - break; - case "wedding-fireworks": - // 婚礼专属:双倍礼花,粉金浪漫配色,持续 12 秒 - if (typeof FireworksEffect !== "undefined") { - FireworksEffect.startDouble(canvas, finishCurrent); - } - break; - case "rain": - if (typeof RainEffect !== "undefined") { - RainEffect.start(canvas, finishCurrent); - } - break; - case "lightning": - if (typeof LightningEffect !== "undefined") { - LightningEffect.start(canvas, finishCurrent); - } - break; - case "snow": - if (typeof SnowEffect !== "undefined") { - SnowEffect.start(canvas, finishCurrent); - } - break; - case "sakura": - if (typeof SakuraEffect !== "undefined") { - SakuraEffect.start(canvas, finishCurrent); - } - break; - case "meteors": - if (typeof MeteorsEffect !== "undefined") { - MeteorsEffect.start(canvas, finishCurrent); - } - break; - case "gold-rain": - if (typeof GoldRainEffect !== "undefined") { - GoldRainEffect.start(canvas, finishCurrent); - } - break; - case "hearts": - if (typeof HeartsEffect !== "undefined") { - HeartsEffect.start(canvas, finishCurrent); - } - break; - case "confetti": - if (typeof ConfettiEffect !== "undefined") { - ConfettiEffect.start(canvas, finishCurrent); - } - break; - case "fireflies": - if (typeof FirefliesEffect !== "undefined") { - FirefliesEffect.start(canvas, finishCurrent); - } - break; - default: - console.warn(`[EffectManager] 未知特效类型:${type}`); - finishCurrent(); + let started = false; + + try { + switch (type) { + case "fireworks": + started = _startEffect(typeof FireworksEffect !== "undefined" ? FireworksEffect : undefined, canvas, finishCurrent); + break; + case "wedding-fireworks": + // 婚礼专属:双倍礼花,粉金浪漫配色,持续 12 秒 + started = _startEffect(typeof FireworksEffect !== "undefined" ? FireworksEffect : undefined, canvas, finishCurrent, "startDouble"); + break; + case "rain": + started = _startEffect(typeof RainEffect !== "undefined" ? RainEffect : undefined, canvas, finishCurrent); + break; + case "lightning": + started = _startEffect(typeof LightningEffect !== "undefined" ? LightningEffect : undefined, canvas, finishCurrent); + break; + case "snow": + started = _startEffect(typeof SnowEffect !== "undefined" ? SnowEffect : undefined, canvas, finishCurrent); + break; + case "sakura": + started = _startEffect(typeof SakuraEffect !== "undefined" ? SakuraEffect : undefined, canvas, finishCurrent); + break; + case "meteors": + started = _startEffect(typeof MeteorsEffect !== "undefined" ? MeteorsEffect : undefined, canvas, finishCurrent); + break; + case "gold-rain": + started = _startEffect(typeof GoldRainEffect !== "undefined" ? GoldRainEffect : undefined, canvas, finishCurrent); + break; + case "hearts": + started = _startEffect(typeof HeartsEffect !== "undefined" ? HeartsEffect : undefined, canvas, finishCurrent); + break; + case "confetti": + started = _startEffect(typeof ConfettiEffect !== "undefined" ? ConfettiEffect : undefined, canvas, finishCurrent); + break; + case "fireflies": + started = _startEffect(typeof FirefliesEffect !== "undefined" ? FirefliesEffect : undefined, canvas, finishCurrent); + break; + default: + console.warn(`[EffectManager] 未知特效类型:${type}`); + } + } catch (error) { + console.error(`[EffectManager] 启动特效失败:${type}`, error); + } + + if (!started) { + finishCurrent(); } } @@ -253,8 +325,10 @@ const EffectManager = (() => { } _queue.length = 0; - _cleanup({ playNext: false }); + _cleanup({ playNext: false, cancelCurrent: true }); } return { play, stop }; })(); + +window.EffectManager = EffectManager; diff --git a/public/js/effects/fireflies.js b/public/js/effects/fireflies.js index d7ac6b6..f5ead53 100644 --- a/public/js/effects/fireflies.js +++ b/public/js/effects/fireflies.js @@ -126,6 +126,27 @@ const FirefliesEffect = (() => { const fireflies = Array.from({ length: COUNT }, () => new Firefly(w, h)); const startTime = performance.now(); let animId = null; + let finished = false; + + /** + * 统一结束萤火虫动画,手动取消时只清理不回调。 + * + * @param {boolean} canceled 是否为手动取消 + */ + function finish(canceled) { + if (finished) { + return; + } + + finished = true; + if (animId) { + cancelAnimationFrame(animId); + } + ctx.clearRect(0, 0, w, h); + if (!canceled) { + onEnd(); + } + } function animate(now) { ctx.clearRect(0, 0, w, h); @@ -138,13 +159,16 @@ const FirefliesEffect = (() => { if (now - startTime < DURATION) { animId = requestAnimationFrame(animate); } else { - cancelAnimationFrame(animId); - ctx.clearRect(0, 0, w, h); - onEnd(); + finish(false); } } animId = requestAnimationFrame(animate); + return { + cancel() { + finish(true); + }, + }; } return { start }; diff --git a/public/js/effects/fireworks.js b/public/js/effects/fireworks.js index c84f8fa..9914716 100644 --- a/public/js/effects/fireworks.js +++ b/public/js/effects/fireworks.js @@ -358,6 +358,22 @@ const FireworksEffect = (() => { } } + /** + * 在粒子预算内追加粒子,避免主爆炸阶段瞬间超量。 + * + * @param {Particle[]} target + * @param {Particle[]} incoming + * @param {number} budget + */ + function _appendParticlesWithinBudget(target, incoming, budget) { + const remaining = Math.max(0, budget - target.length); + if (remaining <= 0) { + return; + } + + _appendParticles(target, incoming.slice(0, remaining)); + } + /** * 统一发射一枚火箭。 * @@ -388,9 +404,14 @@ const FireworksEffect = (() => { const ctx = canvas.getContext("2d"); const w = canvas.width; const h = canvas.height; + const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640; + const mobileScale = isMobile ? 0.72 : 1; const duration = config.duration; const hardStopAt = duration + 2600; - const peakParticleBudget = config.peakParticleBudget ?? 1650; + const peakParticleBudget = Math.round((config.peakParticleBudget ?? 1650) * mobileScale); + const maxLaunches = Math.max(8, Math.round(config.maxLaunches * mobileScale)); + const particleDensity = config.particleDensity * mobileScale; + const secondaryDensity = config.secondaryDensity * mobileScale; let rockets = []; let particles = []; @@ -398,15 +419,17 @@ const FireworksEffect = (() => { let scheduledBursts = []; let animId = null; let launchCnt = 0; + let finished = false; + const timers = []; const launchInterval = setInterval(() => { - if (launchCnt >= config.maxLaunches) { + if (launchCnt >= maxLaunches) { clearInterval(launchInterval); return; } const batchSize = config.getBatchSize(launchCnt); - for (let i = 0; i < batchSize && launchCnt < config.maxLaunches; i++) { + for (let i = 0; i < batchSize && launchCnt < maxLaunches; i++) { _launchRocket( rockets, w, @@ -421,9 +444,9 @@ const FireworksEffect = (() => { // 开场礼炮先把气氛撑起来,避免一开始太空。 if (typeof config.openingVolley === "function") { - setTimeout(() => { + timers.push(setTimeout(() => { config.openingVolley(rockets, w, h); - }, 120); + }, 120)); } const startTime = performance.now(); @@ -440,9 +463,10 @@ const FireworksEffect = (() => { for (let i = scheduledBursts.length - 1; i >= 0; i--) { if (scheduledBursts[i].triggerAt <= now) { const burst = scheduledBursts[i]; - _appendParticles( + _appendParticlesWithinBudget( particles, _burst(burst.x, burst.y, burst.color, burst.type, burst.density), + peakParticleBudget, ); halos.push(new Halo(burst.x, burst.y, burst.color, burst.haloRadius)); scheduledBursts.splice(i, 1); @@ -452,15 +476,16 @@ const FireworksEffect = (() => { for (let i = rockets.length - 1; i >= 0; i--) { const rocket = rockets[i]; if (rocket.done) { - _appendParticles( + _appendParticlesWithinBudget( particles, _burst( rocket.x, rocket.y, rocket.color, rocket.type, - config.particleDensity, + particleDensity, ), + peakParticleBudget, ); halos.push(new Halo(rocket.x, rocket.y, rocket.color, config.primaryHaloRadius)); @@ -475,7 +500,7 @@ const FireworksEffect = (() => { y: rocket.y + (Math.random() - 0.5) * 26, color: _pick(config.colors), type: Math.random() > 0.5 ? "sphere" : "ring", - density: config.secondaryDensity, + density: secondaryDensity, haloRadius: config.secondaryHaloRadius, }); } @@ -502,14 +527,44 @@ const FireworksEffect = (() => { if (shouldContinue && elapsed < hardStopAt) { animId = requestAnimationFrame(animate); } else { - clearInterval(launchInterval); - cancelAnimationFrame(animId); - ctx.clearRect(0, 0, w, h); - onEnd(); + finish(false); } } animId = requestAnimationFrame(animate); + + /** + * 统一结束烟花演出,取消时不再回调管理器。 + * + * @param {boolean} canceled 是否为手动取消 + */ + function finish(canceled) { + if (finished) { + return; + } + + finished = true; + clearInterval(launchInterval); + timers.forEach((timer) => clearTimeout(timer)); + if (animId) { + cancelAnimationFrame(animId); + } + rockets = []; + particles = []; + halos = []; + scheduledBursts = []; + ctx.clearRect(0, 0, w, h); + + if (!canceled) { + onEnd(); + } + } + + return { + cancel() { + finish(true); + }, + }; } /** @@ -519,7 +574,7 @@ const FireworksEffect = (() => { * @param {Function} onEnd 特效结束回调 */ function start(canvas, onEnd) { - _runShow(canvas, onEnd, { + return _runShow(canvas, onEnd, { duration: 10500, launchEvery: 340, maxLaunches: 24, @@ -577,7 +632,7 @@ const FireworksEffect = (() => { "#00ddff", // 其他 ]; - _runShow(canvas, onEnd, { + return _runShow(canvas, onEnd, { duration: 12400, launchEvery: 280, maxLaunches: 34, diff --git a/public/js/effects/gold-rain.js b/public/js/effects/gold-rain.js index ad9e5e8..d3ff3bd 100644 --- a/public/js/effects/gold-rain.js +++ b/public/js/effects/gold-rain.js @@ -106,6 +106,27 @@ const GoldRainEffect = (() => { const coins = Array.from({ length: COIN_COUNT }, () => new Coin(w, h)); const startTime = performance.now(); let animId = null; + let finished = false; + + /** + * 统一结束金币雨动画,手动取消时只清理不回调。 + * + * @param {boolean} canceled 是否为手动取消 + */ + function finish(canceled) { + if (finished) { + return; + } + + finished = true; + if (animId) { + cancelAnimationFrame(animId); + } + ctx.clearRect(0, 0, w, h); + if (!canceled) { + onEnd(); + } + } function animate(now) { ctx.clearRect(0, 0, w, h); @@ -118,13 +139,16 @@ const GoldRainEffect = (() => { if (now - startTime < DURATION) { animId = requestAnimationFrame(animate); } else { - cancelAnimationFrame(animId); - ctx.clearRect(0, 0, w, h); - onEnd(); + finish(false); } } animId = requestAnimationFrame(animate); + return { + cancel() { + finish(true); + }, + }; } return { start }; diff --git a/public/js/effects/hearts.js b/public/js/effects/hearts.js index 6ae362d..622e108 100644 --- a/public/js/effects/hearts.js +++ b/public/js/effects/hearts.js @@ -88,6 +88,27 @@ const HeartsEffect = (() => { const hearts = Array.from({ length: HEART_COUNT }, () => new Heart(w, h)); const startTime = performance.now(); let animId = null; + let finished = false; + + /** + * 统一结束爱心动画,手动取消时只清理不回调。 + * + * @param {boolean} canceled 是否为手动取消 + */ + function finish(canceled) { + if (finished) { + return; + } + + finished = true; + if (animId) { + cancelAnimationFrame(animId); + } + ctx.clearRect(0, 0, w, h); + if (!canceled) { + onEnd(); + } + } function animate(now) { ctx.clearRect(0, 0, w, h); @@ -100,13 +121,16 @@ const HeartsEffect = (() => { if (now - startTime < DURATION) { animId = requestAnimationFrame(animate); } else { - cancelAnimationFrame(animId); - ctx.clearRect(0, 0, w, h); - onEnd(); + finish(false); } } animId = requestAnimationFrame(animate); + return { + cancel() { + finish(true); + }, + }; } return { start }; diff --git a/public/js/effects/lightning.js b/public/js/effects/lightning.js index 3642e8b..91de213 100644 --- a/public/js/effects/lightning.js +++ b/public/js/effects/lightning.js @@ -87,7 +87,7 @@ const LightningEffect = (() => { * @param {HTMLCanvasElement} canvas * @param {CanvasRenderingContext2D} ctx */ - function _flash(canvas, ctx) { + function _flash(canvas, ctx, timers) { const w = canvas.width; const h = canvas.height; @@ -122,16 +122,16 @@ const LightningEffect = (() => { } // 短促残影:闪电消失后保留一层很淡的余辉,避免“闪一下就没了”。 - setTimeout(() => { + timers.push(setTimeout(() => { ctx.clearRect(0, 0, w, h); _drawStormGlow(canvas, ctx); ctx.fillStyle = "rgba(185, 205, 255, 0.12)"; ctx.fillRect(0, 0, w, h); - }, 90); + }, 90)); - setTimeout(() => { + timers.push(setTimeout(() => { ctx.clearRect(0, 0, w, h); - }, 190); + }, 190)); } /** @@ -146,6 +146,7 @@ const LightningEffect = (() => { const DURATION = 7600; let count = 0; let finished = false; + const timers = []; /** * 统一结束特效,避免多次触发 onEnd。 @@ -156,6 +157,7 @@ const LightningEffect = (() => { } finished = true; + timers.forEach((timer) => clearTimeout(timer)); ctx.clearRect(0, 0, canvas.width, canvas.height); onEnd(); } @@ -163,29 +165,41 @@ const LightningEffect = (() => { // 间隔不规则触发多次闪电(模拟真实雷电节奏) function nextFlash() { if (count >= FLASHES) { - setTimeout(() => { + timers.push(setTimeout(() => { finish(); - }, 520); + }, 520)); return; } - _flash(canvas, ctx); + _flash(canvas, ctx, timers); count++; // 让雷电节奏有“成组爆发”的感觉:有时连续两下,有时间隔更久。 const delay = Math.random() > 0.65 ? 140 + Math.random() * 140 : 420 + Math.random() * 520; - setTimeout(nextFlash, delay); + timers.push(setTimeout(nextFlash, delay)); } // 短暂延迟后开始第一次闪电 - setTimeout(nextFlash, 300); + timers.push(setTimeout(nextFlash, 300)); // 安全兜底:超时强制结束 - setTimeout(() => { + timers.push(setTimeout(() => { finish(); - }, DURATION + 500); + }, DURATION + 500)); + + return { + cancel() { + if (finished) { + return; + } + + finished = true; + timers.forEach((timer) => clearTimeout(timer)); + ctx.clearRect(0, 0, canvas.width, canvas.height); + }, + }; } return { start }; diff --git a/public/js/effects/meteors.js b/public/js/effects/meteors.js index 0de56ba..4d8bb77 100644 --- a/public/js/effects/meteors.js +++ b/public/js/effects/meteors.js @@ -140,6 +140,27 @@ const MeteorsEffect = (() => { const meteors = Array.from({ length: 14 }, () => new Meteor(w, h)); const startTime = performance.now(); let animId = null; + let finished = false; + + /** + * 统一结束流星动画,手动取消时只清理不回调。 + * + * @param {boolean} canceled 是否为手动取消 + */ + function finish(canceled) { + if (finished) { + return; + } + + finished = true; + if (animId) { + cancelAnimationFrame(animId); + } + ctx.clearRect(0, 0, w, h); + if (!canceled) { + onEnd(); + } + } function animate(now) { ctx.clearRect(0, 0, w, h); @@ -163,13 +184,16 @@ const MeteorsEffect = (() => { if (now - startTime < DURATION) { animId = requestAnimationFrame(animate); } else { - cancelAnimationFrame(animId); - ctx.clearRect(0, 0, w, h); - onEnd(); + finish(false); } } animId = requestAnimationFrame(animate); + return { + cancel() { + finish(true); + }, + }; } return { start }; diff --git a/public/js/effects/rain.js b/public/js/effects/rain.js index 52b39f0..49e9455 100644 --- a/public/js/effects/rain.js +++ b/public/js/effects/rain.js @@ -75,8 +75,29 @@ const RainEffect = (() => { }); let animId = null; + let finished = false; const startTime = performance.now(); + /** + * 统一结束雨滴动画,手动取消时不触发队列续播。 + * + * @param {boolean} canceled 是否为手动取消 + */ + function finish(canceled) { + if (finished) { + return; + } + + finished = true; + if (animId) { + cancelAnimationFrame(animId); + } + ctx.clearRect(0, 0, w, h); + if (!canceled) { + onEnd(); + } + } + function animate(now) { // 清除画布(透明,不遮挡聊天背景) ctx.clearRect(0, 0, w, h); @@ -89,13 +110,16 @@ const RainEffect = (() => { if (now - startTime < DURATION) { animId = requestAnimationFrame(animate); } else { - cancelAnimationFrame(animId); - ctx.clearRect(0, 0, w, h); - onEnd(); + finish(false); } } animId = requestAnimationFrame(animate); + return { + cancel() { + finish(true); + }, + }; } return { start }; diff --git a/public/js/effects/sakura.js b/public/js/effects/sakura.js index 8608153..d8f4052 100644 --- a/public/js/effects/sakura.js +++ b/public/js/effects/sakura.js @@ -93,6 +93,27 @@ const SakuraEffect = (() => { const petals = Array.from({ length: PETAL_COUNT }, () => new Petal(w, h)); const startTime = performance.now(); let animId = null; + let finished = false; + + /** + * 统一结束樱花动画,手动取消时只清理不回调。 + * + * @param {boolean} canceled 是否为手动取消 + */ + function finish(canceled) { + if (finished) { + return; + } + + finished = true; + if (animId) { + cancelAnimationFrame(animId); + } + ctx.clearRect(0, 0, w, h); + if (!canceled) { + onEnd(); + } + } function animate(now) { ctx.clearRect(0, 0, w, h); @@ -105,13 +126,16 @@ const SakuraEffect = (() => { if (now - startTime < DURATION) { animId = requestAnimationFrame(animate); } else { - cancelAnimationFrame(animId); - ctx.clearRect(0, 0, w, h); - onEnd(); + finish(false); } } animId = requestAnimationFrame(animate); + return { + cancel() { + finish(true); + }, + }; } return { start }; diff --git a/public/js/effects/snow.js b/public/js/effects/snow.js index 9b34d03..2aee95d 100644 --- a/public/js/effects/snow.js +++ b/public/js/effects/snow.js @@ -181,8 +181,29 @@ const SnowEffect = (() => { })); let animId = null; + let finished = false; const startTime = performance.now(); + /** + * 统一结束雪花动画,手动取消时只清理不回调。 + * + * @param {boolean} canceled 是否为手动取消 + */ + function finish(canceled) { + if (finished) { + return; + } + + finished = true; + if (animId) { + cancelAnimationFrame(animId); + } + ctx.clearRect(0, 0, w, h); + if (!canceled) { + onEnd(); + } + } + function animate(now) { ctx.clearRect(0, 0, w, h); @@ -225,13 +246,16 @@ const SnowEffect = (() => { if (now - startTime < DURATION) { animId = requestAnimationFrame(animate); } else { - cancelAnimationFrame(animId); - ctx.clearRect(0, 0, w, h); - onEnd(); + finish(false); } } animId = requestAnimationFrame(animate); + return { + cancel() { + finish(true); + }, + }; } return { start }; diff --git a/resources/views/chat/partials/games/earn-panel.blade.php b/resources/views/chat/partials/games/earn-panel.blade.php index 7c3a898..e5e4590 100644 --- a/resources/views/chat/partials/games/earn-panel.blade.php +++ b/resources/views/chat/partials/games/earn-panel.blade.php @@ -167,8 +167,8 @@ } this.restoreVideoDOM(); - if (typeof EffectManager !== 'undefined' && typeof EffectManager.audioManager !== 'undefined') { - EffectManager.audioManager.sounds.gold_received?.play().catch(()=>{}); + if (typeof EffectSounds !== 'undefined') { + EffectSounds.ding(); } }, @@ -308,8 +308,8 @@ } // 播放到账特定金币音效 - if (typeof EffectManager !== 'undefined' && typeof EffectManager.audioManager !== 'undefined') { - EffectManager.audioManager.sounds.gold_received?.play().catch(()=>{}); + if (typeof EffectSounds !== 'undefined') { + EffectSounds.ding(); } if (data.level_up) { diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index 2ae7866..c876462 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -51,7 +51,7 @@ diff --git a/resources/views/chat/partials/shop-panel.blade.php b/resources/views/chat/partials/shop-panel.blade.php index be277cb..8be8f32 100644 --- a/resources/views/chat/partials/shop-panel.blade.php +++ b/resources/views/chat/partials/shop-panel.blade.php @@ -483,6 +483,7 @@ }, body: JSON.stringify({ item_id: itemId, + room_id: window.chatContext?.roomId ?? 0, recipient, message: message || '', quantity: quantity || 1 diff --git a/resources/views/chat/partials/user-actions.blade.php b/resources/views/chat/partials/user-actions.blade.php index 8caf3c6..c527837 100644 --- a/resources/views/chat/partials/user-actions.blade.php +++ b/resources/views/chat/partials/user-actions.blade.php @@ -1045,6 +1045,7 @@ $canKickUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_KICK] ?? false) === true); $canMuteUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_MUTE] ?? false) === true); $canFreezeUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_FREEZE] ?? false) === true); + $canRewardUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_REWARD] ?? false) === true); $hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canFreezeUser; $hasPositionActions = Auth::user()->activePosition || $myLevel >= $superLevel; @endphp @@ -1095,8 +1096,8 @@ @endif - {{-- 职务奖励金币(凭空产生),仅有在职职务且 max_reward != 0 的人可见 --}} - @if ($hasPositionActions) + {{-- 职务奖励金币(凭空产生),仅有明确奖励权限且 max_reward != 0 的人可见 --}} + @if ($canRewardUser)