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)