收口聊天室安全边界并优化特效生命周期

This commit is contained in:
2026-04-25 02:52:30 +08:00
parent 4d3f4f7a4b
commit 855d031b04
26 changed files with 1219 additions and 175 deletions
+136 -18
View File
@@ -22,11 +22,13 @@ use App\Events\MessageSent;
use App\Jobs\SaveMessageJob; use App\Jobs\SaveMessageJob;
use App\Models\Message; use App\Models\Message;
use App\Models\PositionAuthorityLog; use App\Models\PositionAuthorityLog;
use App\Models\Room;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\PositionPermissionService; use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use App\Support\ChatContentSanitizer;
use App\Support\PositionPermissionRegistry; use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -66,8 +68,14 @@ class AdminCommandController extends Controller
$admin = Auth::user(); $admin = Auth::user();
$targetUsername = $request->input('username'); $targetUsername = $request->input('username');
$roomId = $request->input('room_id'); $roomId = (int) $request->input('room_id');
$reason = $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', '请注意言行'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin); $operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
@@ -76,13 +84,18 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); 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 = [ $msg = [
'id' => $this->chatState->nextMessageId($roomId), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => "⚠️ {$operatorDisplay} 警告 <b>{$targetUsername}</b>{$reason}", 'content' => "⚠️ {$operatorDisplay} 警告 <b>{$safeTargetUsername}</b>{$reason}",
'is_secret' => false, 'is_secret' => false,
'font_color' => '#dc2626', 'font_color' => '#dc2626',
'action' => '', 'action' => '',
@@ -124,8 +137,14 @@ class AdminCommandController extends Controller
$admin = Auth::user(); $admin = Auth::user();
$targetUsername = $request->input('username'); $targetUsername = $request->input('username');
$roomId = $request->input('room_id'); $roomId = (int) $request->input('room_id');
$reason = $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', '违反聊天室规则'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin); $operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
@@ -134,6 +153,11 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); 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。 // 在强制踢出前补发目标私聊提示,尽量让对方先看到 toast。
$this->pushTargetToastMessage( $this->pushTargetToastMessage(
roomId: (int) $roomId, roomId: (int) $roomId,
@@ -154,7 +178,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => "🚫 {$operatorDisplay} 已将 <b>{$targetUsername}</b> 踢出聊天室。原因:{$reason}", 'content' => "🚫 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 踢出聊天室。原因:{$reason}",
'is_secret' => false, 'is_secret' => false,
'font_color' => '#dc2626', 'font_color' => '#dc2626',
'action' => '', 'action' => '',
@@ -188,8 +212,14 @@ class AdminCommandController extends Controller
$admin = Auth::user(); $admin = Auth::user();
$targetUsername = $request->input('username'); $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'); $duration = $request->input('duration');
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin); $operatorDisplay = $this->buildOperatorDisplayHtml($admin);
$operatorLabel = $this->buildOperatorIdentityLabel($admin).' '.$admin->username; $operatorLabel = $this->buildOperatorIdentityLabel($admin).' '.$admin->username;
@@ -199,6 +229,11 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); 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 自动过期 // 设置 Redis 禁言标记,TTL 自动过期
$muteKey = "mute:{$roomId}:{$targetUsername}"; $muteKey = "mute:{$roomId}:{$targetUsername}";
Redis::setex($muteKey, $duration * 60, now()->toDateTimeString()); Redis::setex($muteKey, $duration * 60, now()->toDateTimeString());
@@ -209,7 +244,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => "🔇 {$operatorDisplay} 已将 <b>{$targetUsername}</b> 禁言 {$duration} 分钟。", 'content' => "🔇 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 禁言 {$duration} 分钟。",
'is_secret' => false, 'is_secret' => false,
'font_color' => '#dc2626', 'font_color' => '#dc2626',
'action' => '', 'action' => '',
@@ -260,8 +295,14 @@ class AdminCommandController extends Controller
$admin = Auth::user(); $admin = Auth::user();
$targetUsername = $request->input('username'); $targetUsername = $request->input('username');
$roomId = $request->input('room_id'); $roomId = (int) $request->input('room_id');
$reason = $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', '违反聊天室规则'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin); $operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
@@ -270,6 +311,11 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); 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 表示冻结) // 冻结用户账号(将等级设为 -1 表示冻结)
$target = $authorization['target']; $target = $authorization['target'];
$target->user_level = -1; $target->user_level = -1;
@@ -298,7 +344,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => "🧊 {$operatorDisplay} 已冻结 <b>{$targetUsername}</b> 的账号。原因:{$reason}", 'content' => "🧊 {$operatorDisplay} 已冻结 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}",
'is_secret' => false, 'is_secret' => false,
'font_color' => '#dc2626', 'font_color' => '#dc2626',
'action' => '', 'action' => '',
@@ -381,17 +427,23 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403); return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403);
} }
$roomId = $request->input('room_id'); $roomId = (int) $request->input('room_id');
$content = $request->input('content'); $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 = [ $msg = [
'id' => $this->chatState->nextMessageId($roomId), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '系统公告', 'from_user' => '系统公告',
'to_user' => '大家', 'to_user' => '大家',
'content' => "📢 <b>{$publisherLabel}</b> <b>{$admin->username}</b> 发布公告:{$content}", 'content' => "📢 <b>{$publisherLabel}</b> <b>{$publisherUsername}</b> 发布公告:{$content}",
'is_secret' => false, 'is_secret' => false,
'font_color' => '#b91c1c', 'font_color' => '#b91c1c',
'action' => '', 'action' => '',
@@ -447,6 +499,44 @@ class AdminCommandController extends Controller
return "<b>{$identityLabel}</b> <b>{$username}</b>"; return "<b>{$identityLabel}</b> <b>{$username}</b>";
} }
/**
* 校验操作者是否可在指定房间执行聊天室管理命令。
*
* @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(); $admin = Auth::user();
$roomId = $request->input('room_id'); $roomId = (int) $request->input('room_id');
// 改为按职务权限控制聊天室顶部“清屏”按钮。 // 改为按职务权限控制聊天室顶部“清屏”按钮。
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_CLEAR_SCREEN)) { if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_CLEAR_SCREEN)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权执行全员清屏'], 403); 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 中该房间的消息缓存 // 清除 Redis 中该房间的消息缓存
$this->chatState->clearMessages($roomId); $this->chatState->clearMessages($roomId);
@@ -582,7 +677,12 @@ class AdminCommandController extends Controller
} }
$roomId = (int) $request->input('room_id'); $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( broadcast(new BrowserRefreshRequested(
@@ -614,12 +714,17 @@ class AdminCommandController extends Controller
]); ]);
$admin = Auth::user(); $admin = Auth::user();
$roomId = $request->input('room_id'); $roomId = (int) $request->input('room_id');
$type = $request->input('type'); $type = $request->input('type');
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT)) { if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权触发特效'], 403); 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)); broadcast(new EffectBroadcast($roomId, $type, $admin->username));
@@ -658,6 +763,10 @@ class AdminCommandController extends Controller
$roomId = (int) $request->input('room_id'); $roomId = (int) $request->input('room_id');
$amount = (int) $request->input('amount'); $amount = (int) $request->input('amount');
$targetUsername = $request->input('username'); $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) { if ($admin->username === $targetUsername) {
@@ -670,12 +779,21 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404); 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 超级管理员:无需职务,无限额限制 // id=1 超级管理员:无需职务,无限额限制
$isSuperAdmin = $admin->id === 1; $isSuperAdmin = $admin->id === 1;
$userPosition = null; $userPosition = null;
$position = null; $position = null;
if (! $isSuperAdmin) { if (! $isSuperAdmin) {
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_REWARD)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发放奖励'], 403);
}
// ① 必须有在职职务 // ① 必须有在职职务
$userPosition = $admin->activePosition; $userPosition = $admin->activePosition;
if (! $userPosition) { if (! $userPosition) {
+68 -6
View File
@@ -31,6 +31,7 @@ use App\Services\PositionPermissionService;
use App\Services\RoomBroadcastService; use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use App\Services\VipService; use App\Services\VipService;
use App\Support\ChatContentSanitizer;
use App\Support\ChatDailyStatusCatalog; use App\Support\ChatDailyStatusCatalog;
use App\Support\PositionPermissionRegistry; use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -353,6 +354,10 @@ class ChatController extends Controller
$user = Auth::user(); $user = Auth::user();
$imagePayload = null; $imagePayload = null;
if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再发言。')) {
return $response;
}
// 0. 检查用户是否被禁言(Redis TTL 自动过期) // 0. 检查用户是否被禁言(Redis TTL 自动过期)
$muteKey = "mute:{$id}:{$user->username}"; $muteKey = "mute:{$id}:{$user->username}";
if (Redis::exists($muteKey)) { if (Redis::exists($muteKey)) {
@@ -884,13 +889,18 @@ class ChatController extends Controller
return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403); return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403);
} }
if (! $this->chatState->isUserInRoom($id, $user->username)) {
return response()->json(['status' => 'error', 'message' => '请先进入该房间后再修改公告'], 403);
}
$request->validate([ $request->validate([
'announcement' => 'required|string|max:500', 'announcement' => 'required|string|max:500',
]); ]);
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段 $announcementText = trim((string) $request->input('announcement'));
$room->announcement = trim($request->input('announcement'))
.' ——'.$user->username.' '.now()->format('m-d H:i'); // 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段。
$room->announcement = $announcementText.' ——'.$user->username.' '.now()->format('m-d H:i');
$room->save(); $room->save();
// 广播公告更新到所有在线用户 // 广播公告更新到所有在线用户
@@ -899,7 +909,7 @@ class ChatController extends Controller
'room_id' => $id, 'room_id' => $id,
'from_user' => '系统公告', 'from_user' => '系统公告',
'to_user' => '大家', 'to_user' => '大家',
'content' => "📢 {$user->username} 更新了房间公告:{$room->announcement}", 'content' => '📢 '.ChatContentSanitizer::htmlText($user->username).' 更新了房间公告:'.ChatContentSanitizer::htmlText($room->announcement),
'is_secret' => false, 'is_secret' => false,
'font_color' => '#cc0000', 'font_color' => '#cc0000',
'action' => '', 'action' => '',
@@ -941,6 +951,10 @@ class ChatController extends Controller
$giftId = $request->integer('gift_id'); $giftId = $request->integer('gift_id');
$count = $request->integer('count', 1); $count = $request->integer('count', 1);
if ($response = $this->ensureUserCanActInRoom((int) $roomId, $user, '请先进入当前房间后再送礼物。')) {
return $response;
}
// 不能给自己送花 // 不能给自己送花
if ($toUsername === $user->username) { if ($toUsername === $user->username) {
return response()->json(['status' => 'error', 'message' => '不能给自己送花哦~']); return response()->json(['status' => 'error', 'message' => '不能给自己送花哦~']);
@@ -958,6 +972,10 @@ class ChatController extends Controller
return response()->json(['status' => 'error', 'message' => '用户不存在']); return response()->json(['status' => 'error', 'message' => '用户不存在']);
} }
if ($response = $this->ensureTargetOnlineInRoom((int) $roomId, (string) $toUsername)) {
return $response;
}
$totalCost = $gift->cost * $count; $totalCost = $gift->cost * $count;
$totalCharm = $gift->charm * $count; $totalCharm = $gift->charm * $count;
@@ -981,12 +999,15 @@ class ChatController extends Controller
// 广播送花消息(含图片标记,前端识别后渲染图片) // 广播送花消息(含图片标记,前端识别后渲染图片)
$countText = $count > 1 ? " {$count}" : ''; $countText = $count > 1 ? " {$count}" : '';
$safeSender = ChatContentSanitizer::htmlText($user->username);
$safeReceiver = ChatContentSanitizer::htmlText($toUsername);
$safeGiftName = ChatContentSanitizer::htmlText($gift->name);
$sysMsg = [ $sysMsg = [
'id' => $this->chatState->nextMessageId($roomId), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '送花播报', 'from_user' => '送花播报',
'to_user' => $toUsername, 'to_user' => $toUsername,
'content' => "{$gift->emoji}{$user->username}】 向 【{$toUsername}】 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}", 'content' => "{$gift->emoji}{$safeSender}】 向 【{$safeReceiver}】 送出了{$countText}{$safeGiftName}】!魅力 +{$totalCharm}",
'is_secret' => false, 'is_secret' => false,
'font_color' => '#e91e8f', 'font_color' => '#e91e8f',
'action' => '', 'action' => '',
@@ -1255,6 +1276,10 @@ class ChatController extends Controller
$roomId = $request->integer('room_id'); $roomId = $request->integer('room_id');
$amount = $request->integer('amount'); $amount = $request->integer('amount');
if ($response = $this->ensureUserCanActInRoom($roomId, $sender, '请先进入当前房间后再赠送金币。')) {
return $response;
}
// 不能给自己转账 // 不能给自己转账
if ($toName === $sender->username) { if ($toName === $sender->username) {
return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']); return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']);
@@ -1266,6 +1291,10 @@ class ChatController extends Controller
return response()->json(['status' => 'error', 'message' => '用户不存在']); return response()->json(['status' => 'error', 'message' => '用户不存在']);
} }
if ($response = $this->ensureTargetOnlineInRoom($roomId, (string) $toName)) {
return $response;
}
// 余额校验 // 余额校验
if (($sender->jjb ?? 0) < $amount) { if (($sender->jjb ?? 0) < $amount) {
return response()->json([ return response()->json([
@@ -1292,7 +1321,7 @@ class ChatController extends Controller
// 接收方收到消息时,在右下角弹到账提示卡片。 // 接收方收到消息时,在右下角弹到账提示卡片。
'toast_notification' => [ 'toast_notification' => [
'title' => '💰 赠金币到账', 'title' => '💰 赠金币到账',
'message' => "<b>{$sender->username}</b> 向你赠送了 <b>{$amount}</b> 枚金币!", 'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
'icon' => '💰', 'icon' => '💰',
'color' => '#f59e0b', 'color' => '#f59e0b',
'duration' => 8000, '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;
}
} }
+31 -17
View File
@@ -9,9 +9,12 @@ namespace App\Http\Controllers;
use App\Events\EffectBroadcast; use App\Events\EffectBroadcast;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Models\Room;
use App\Models\ShopItem; use App\Models\ShopItem;
use App\Models\UserPurchase; use App\Models\UserPurchase;
use App\Services\ChatStateService;
use App\Services\ShopService; use App\Services\ShopService;
use App\Support\ChatContentSanitizer;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -23,6 +26,7 @@ class ShopController extends Controller
*/ */
public function __construct( public function __construct(
private readonly ShopService $shopService, private readonly ShopService $shopService,
private readonly ChatStateService $chatState,
) {} ) {}
/** /**
@@ -85,16 +89,26 @@ class ShopController extends Controller
{ {
$request->validate([ $request->validate([
'item_id' => 'required|integer|exists:shop_items,id', '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', '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); $item = ShopItem::find($request->item_id);
if (! $item->is_active) { if (! $item->is_active) {
return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400); return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400);
} }
$quantity = (int) $request->input('quantity', 1); $quantity = (int) $request->input('quantity', 1);
$result = $this->shopService->buyItem(Auth::user(), $item, $quantity); $result = $this->shopService->buyItem($user, $item, $quantity);
if (! $result['ok']) { if (! $result['ok']) {
return response()->json(['status' => 'error', 'message' => $result['message']], 400); return response()->json(['status' => 'error', 'message' => $result['message']], 400);
@@ -109,15 +123,14 @@ class ShopController extends Controller
// ── 单次特效卡:广播给指定用户或全员 ──────────────────────── // ── 单次特效卡:广播给指定用户或全员 ────────────────────────
if (isset($result['play_effect'])) { if (isset($result['play_effect'])) {
$user = Auth::user();
$roomId = (int) $request->room_id;
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员 $recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
$message = trim($request->input('message', '')); $message = ChatContentSanitizer::htmlText($request->input('message', ''));
// recipient 为空或 "all" 表示全员 // recipient 为空或 "all" 表示全员
$targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient; $targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient;
$safeTargetUsername = $targetUsername ? ChatContentSanitizer::htmlText($targetUsername) : null;
// 广播特效事件(全员频道) // 广播特效事件时保留原始用户名标识,前端需要用它和当前登录名做精确比较。
broadcast(new EffectBroadcast( broadcast(new EffectBroadcast(
roomId: $roomId, roomId: $roomId,
type: $result['play_effect'], type: $result['play_effect'],
@@ -147,9 +160,11 @@ class ShopController extends Controller
]; ];
// 赠礼消息文案(改成"为XX触发了一场特效" // 赠礼消息文案(改成"为XX触发了一场特效"
$icon = $icons[$result['play_effect']] ?? '✨'; $icon = $icons[$result['play_effect']] ?? '✨';
$toStr = $targetUsername ? "{$targetUsername}" : '全体聊友'; $safeBuyer = ChatContentSanitizer::htmlText($user->username);
$safeItemName = ChatContentSanitizer::htmlText($item->name);
$toStr = $safeTargetUsername ? "{$safeTargetUsername}" : '全体聊友';
$remarkPart = $message ? "{$message}" : ''; $remarkPart = $message ? "{$message}" : '';
$sysContent = "{$icon} {$user->username}{$toStr} 燃放了一场【{$item->name}】特效!{$remarkPart}"; $sysContent = "{$icon} {$safeBuyer}{$toStr} 燃放了一场【{$safeItemName}】特效!{$remarkPart}";
// 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致) // 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致)
$sysMsgEvent = new MessageSent( $sysMsgEvent = new MessageSent(
@@ -170,9 +185,6 @@ class ShopController extends Controller
} }
} else { } else {
// ── 其他类型:广播购买通知到公屏 ──────────────────────────── // ── 其他类型:广播购买通知到公屏 ────────────────────────────
$user = Auth::user();
$roomId = (int) $request->room_id;
if ($roomId > 0) { if ($roomId > 0) {
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式) // auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
$fishDuration = ''; $fishDuration = '';
@@ -185,13 +197,15 @@ class ShopController extends Controller
$broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音'; $broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音';
// 根据商品类型生成不同通知文案 // 根据商品类型生成不同通知文案
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
$safeItemName = ChatContentSanitizer::htmlText($item->name);
$sysContent = match ($item->type) { $sysContent = match ($item->type) {
'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!", 'duration' => "📅 【{$safeBuyer}】购买了全屏特效周卡「{$safeItemName}」,登录时将自动触发!",
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!", 'one_time' => "🎫 【{$safeBuyer}】购买了「{$safeItemName}」道具!",
'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?", 'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!", 'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$user->username}】购买了 {$quantity} 张「{$item->name}」,准备把漏掉的签到补回来!", ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
default => "🛒 【{$user->username}】购买了「{$item->name}」。", default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
}; };
broadcast(new MessageSent( 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); return response()->json($response);
} }
+2 -1
View File
@@ -62,7 +62,7 @@ class SendMessageRequest extends FormRequest
'image' => ['nullable', 'required_without:content', 'file', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:6144'], 'image' => ['nullable', 'required_without:content', 'file', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:6144'],
'to_user' => ['nullable', 'string', 'max:50'], 'to_user' => ['nullable', 'string', 'max:50'],
'is_secret' => ['nullable', 'boolean'], '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 注入 'action' => ['nullable', 'string', 'max:50', Rule::in(self::ALLOWED_ACTIONS)], // 动作字段仅允许预设值,阻断拼接式 XSS 注入
]; ];
} }
@@ -91,6 +91,7 @@ class SendMessageRequest extends FormRequest
'image.image' => '上传的文件必须是图片。', 'image.image' => '上传的文件必须是图片。',
'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。', 'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。',
'image.max' => '图片大小不能超过 6MB。', 'image.max' => '图片大小不能超过 6MB。',
'font_color.regex' => '发言颜色格式不合法,请重新选择颜色。',
'action.in' => '发言动作不合法,请重新选择。', 'action.in' => '发言动作不合法,请重新选择。',
]; ];
} }
+35
View File
@@ -45,6 +45,9 @@ class AppServiceProvider extends ServiceProvider
// 注册登录入口限流器,阻断爆破和批量注册滥用。 // 注册登录入口限流器,阻断爆破和批量注册滥用。
$this->registerAuthRateLimiters(); $this->registerAuthRateLimiters();
// 注册聊天室高频动作限流器,避免消息、购买与特效广播被脚本刷爆。
$this->registerChatActionRateLimiters();
// 注册婚姻系统消息订阅者(结婚/婚礼/离婚通知写入聊天历史) // 注册婚姻系统消息订阅者(结婚/婚礼/离婚通知写入聊天历史)
Event::subscribe(SaveMarriageSystemMessage::class); Event::subscribe(SaveMarriageSystemMessage::class);
@@ -133,4 +136,36 @@ class AppServiceProvider extends ServiceProvider
return implode('|', [$scene, $username, $request->ip()]); 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()]);
}
} }
+23
View File
@@ -0,0 +1,23 @@
<?php
/**
* 文件功能:聊天室动态内容净化工具
*
* 统一处理用户输入在系统消息、公告与商城赠言中的 HTML 转义,避免动态文本注入到前端 innerHTML。
*/
namespace App\Support;
/**
* 类功能:提供聊天室系统文案中用户可控字段的安全转义能力。
*/
class ChatContentSanitizer
{
/**
* 将用户可控文本规整为可安全拼入受控 HTML 模板的字符串。
*/
public static function htmlText(?string $value): string
{
return e(trim((string) $value));
}
}
@@ -43,6 +43,11 @@ class PositionPermissionRegistry
*/ */
public const ROOM_FULLSCREEN_EFFECT = 'room.fullscreen_effect'; public const ROOM_FULLSCREEN_EFFECT = 'room.fullscreen_effect';
/**
* 职务奖励金币权限。
*/
public const ROOM_REWARD = 'room.reward';
/** /**
* 用户警告权限。 * 用户警告权限。
*/ */
@@ -101,6 +106,11 @@ class PositionPermissionRegistry
'label' => '全屏特效', 'label' => '全屏特效',
'description' => '允许触发聊天室内全部全屏动画特效。', 'description' => '允许触发聊天室内全部全屏动画特效。',
], ],
self::ROOM_REWARD => [
'group' => '用户管理',
'label' => '奖励金币',
'description' => '允许在额度限制内向低位阶用户发放职务金币奖励。',
],
self::USER_WARN => [ self::USER_WARN => [
'group' => '用户管理', 'group' => '用户管理',
'label' => '警告用户', 'label' => '警告用户',
+28 -3
View File
@@ -96,6 +96,28 @@ const ConfettiEffect = (() => {
const startTime = performance.now(); const startTime = performance.now();
let lastSpawnAt = startTime; let lastSpawnAt = startTime;
let animId = null; 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) { function animate(now) {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -114,13 +136,16 @@ const ConfettiEffect = (() => {
if (now - startTime < DURATION) { if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
cancelAnimationFrame(animId); finish(false);
ctx.clearRect(0, 0, w, h);
onEnd();
} }
} }
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
} }
return { start }; return { start };
+139 -65
View File
@@ -9,16 +9,33 @@
const EffectManager = (() => { const EffectManager = (() => {
// 当前正在播放的特效名称(防止同时播放两个特效) // 当前正在播放的特效名称(防止同时播放两个特效)
let _current = null; let _current = null;
// 当前特效返回的取消函数,用于手动停止时真正停掉内部 RAF / 定时器
let _currentCancel = null;
// 全屏 Canvas 元素引用 // 全屏 Canvas 元素引用
let _canvas = null; let _canvas = null;
// 待播放特效队列,避免多个进场效果互相打断 // 待播放特效队列,避免多个进场效果互相打断
const _queue = []; const _queue = [];
// 队列最多保留 3 个待播特效,避免高频触发后播放过期动画
const MAX_QUEUE_LENGTH = 3;
// 当前特效播放批次,用于忽略手动停止后的旧回调 // 当前特效播放批次,用于忽略手动停止后的旧回调
let _playToken = 0; let _playToken = 0;
// 是否已经绑定本轮点击停止监听 // 是否已经绑定本轮点击停止监听
let _clickStopBound = false; let _clickStopBound = false;
// 延迟绑定定时器,避免触发播放的同一次点击立即停止特效 // 延迟绑定定时器,避免触发播放的同一次点击立即停止特效
let _clickStopTimer = null; 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 元素 * 获取或创建全屏 Canvas 元素
@@ -41,13 +58,28 @@ const EffectManager = (() => {
"cursor:pointer", "cursor:pointer",
"touch-action:manipulation", "touch-action:manipulation",
].join(";"); ].join(";");
c.width = window.innerWidth; _resizeCanvas(c);
c.height = window.innerHeight;
document.body.appendChild(c); document.body.appendChild(c);
_canvas = c; _canvas = c;
window.addEventListener("resize", _handleResize);
window.addEventListener("orientationchange", _handleResize);
return c; return c;
} }
/**
* 响应窗口尺寸变化,确保手机横竖屏切换后覆盖范围正确。
*/
function _handleResize() {
if (_current) {
stop();
return;
}
if (_canvas) {
_resizeCanvas(_canvas);
}
}
/** /**
* 绑定点击屏幕立即停止当前特效的监听。 * 绑定点击屏幕立即停止当前特效的监听。
* *
@@ -129,7 +161,7 @@ const EffectManager = (() => {
* @param {boolean} options.playNext 是否继续播放队列中的下一个特效 * @param {boolean} options.playNext 是否继续播放队列中的下一个特效
* @param {number|null} options.token 当前特效播放批次 * @param {number|null} options.token 当前特效播放批次
*/ */
function _cleanup({ playNext = true, token = null } = {}) { function _cleanup({ playNext = true, token = null, cancelCurrent = false } = {}) {
if (token !== null && token !== _playToken) { if (token !== null && token !== _playToken) {
return; return;
} }
@@ -137,9 +169,16 @@ const EffectManager = (() => {
_playToken++; _playToken++;
_unbindClickStop(); _unbindClickStop();
if (cancelCurrent && typeof _currentCancel === "function") {
_currentCancel();
}
_currentCancel = null;
if (_canvas && document.body.contains(_canvas)) { if (_canvas && document.body.contains(_canvas)) {
document.body.removeChild(_canvas); document.body.removeChild(_canvas);
} }
window.removeEventListener("resize", _handleResize);
window.removeEventListener("orientationchange", _handleResize);
_canvas = null; _canvas = null;
_current = 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) { if (_current) {
console.log(`[EffectManager] 特效 ${_current} 正在播放,加入队列 ${type}`); console.log(`[EffectManager] 特效 ${_current} 正在播放,加入队列 ${type}`);
_queue.push(type); _enqueue(type);
return; return;
} }
@@ -179,66 +264,53 @@ const EffectManager = (() => {
EffectSounds.play(type); EffectSounds.play(type);
} }
switch (type) { let started = false;
case "fireworks":
if (typeof FireworksEffect !== "undefined") { try {
FireworksEffect.start(canvas, finishCurrent); switch (type) {
} case "fireworks":
break; started = _startEffect(typeof FireworksEffect !== "undefined" ? FireworksEffect : undefined, canvas, finishCurrent);
case "wedding-fireworks": break;
// 婚礼专属:双倍礼花,粉金浪漫配色,持续 12 秒 case "wedding-fireworks":
if (typeof FireworksEffect !== "undefined") { // 婚礼专属:双倍礼花,粉金浪漫配色,持续 12 秒
FireworksEffect.startDouble(canvas, finishCurrent); started = _startEffect(typeof FireworksEffect !== "undefined" ? FireworksEffect : undefined, canvas, finishCurrent, "startDouble");
} break;
break; case "rain":
case "rain": started = _startEffect(typeof RainEffect !== "undefined" ? RainEffect : undefined, canvas, finishCurrent);
if (typeof RainEffect !== "undefined") { break;
RainEffect.start(canvas, finishCurrent); case "lightning":
} started = _startEffect(typeof LightningEffect !== "undefined" ? LightningEffect : undefined, canvas, finishCurrent);
break; break;
case "lightning": case "snow":
if (typeof LightningEffect !== "undefined") { started = _startEffect(typeof SnowEffect !== "undefined" ? SnowEffect : undefined, canvas, finishCurrent);
LightningEffect.start(canvas, finishCurrent); break;
} case "sakura":
break; started = _startEffect(typeof SakuraEffect !== "undefined" ? SakuraEffect : undefined, canvas, finishCurrent);
case "snow": break;
if (typeof SnowEffect !== "undefined") { case "meteors":
SnowEffect.start(canvas, finishCurrent); started = _startEffect(typeof MeteorsEffect !== "undefined" ? MeteorsEffect : undefined, canvas, finishCurrent);
} break;
break; case "gold-rain":
case "sakura": started = _startEffect(typeof GoldRainEffect !== "undefined" ? GoldRainEffect : undefined, canvas, finishCurrent);
if (typeof SakuraEffect !== "undefined") { break;
SakuraEffect.start(canvas, finishCurrent); case "hearts":
} started = _startEffect(typeof HeartsEffect !== "undefined" ? HeartsEffect : undefined, canvas, finishCurrent);
break; break;
case "meteors": case "confetti":
if (typeof MeteorsEffect !== "undefined") { started = _startEffect(typeof ConfettiEffect !== "undefined" ? ConfettiEffect : undefined, canvas, finishCurrent);
MeteorsEffect.start(canvas, finishCurrent); break;
} case "fireflies":
break; started = _startEffect(typeof FirefliesEffect !== "undefined" ? FirefliesEffect : undefined, canvas, finishCurrent);
case "gold-rain": break;
if (typeof GoldRainEffect !== "undefined") { default:
GoldRainEffect.start(canvas, finishCurrent); console.warn(`[EffectManager] 未知特效类型:${type}`);
} }
break; } catch (error) {
case "hearts": console.error(`[EffectManager] 启动特效失败:${type}`, error);
if (typeof HeartsEffect !== "undefined") { }
HeartsEffect.start(canvas, finishCurrent);
} if (!started) {
break; finishCurrent();
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();
} }
} }
@@ -253,8 +325,10 @@ const EffectManager = (() => {
} }
_queue.length = 0; _queue.length = 0;
_cleanup({ playNext: false }); _cleanup({ playNext: false, cancelCurrent: true });
} }
return { play, stop }; return { play, stop };
})(); })();
window.EffectManager = EffectManager;
+27 -3
View File
@@ -126,6 +126,27 @@ const FirefliesEffect = (() => {
const fireflies = Array.from({ length: COUNT }, () => new Firefly(w, h)); const fireflies = Array.from({ length: COUNT }, () => new Firefly(w, h));
const startTime = performance.now(); const startTime = performance.now();
let animId = null; 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) { function animate(now) {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -138,13 +159,16 @@ const FirefliesEffect = (() => {
if (now - startTime < DURATION) { if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
cancelAnimationFrame(animId); finish(false);
ctx.clearRect(0, 0, w, h);
onEnd();
} }
} }
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
} }
return { start }; return { start };
+70 -15
View File
@@ -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 ctx = canvas.getContext("2d");
const w = canvas.width; const w = canvas.width;
const h = canvas.height; 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 duration = config.duration;
const hardStopAt = duration + 2600; 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 rockets = [];
let particles = []; let particles = [];
@@ -398,15 +419,17 @@ const FireworksEffect = (() => {
let scheduledBursts = []; let scheduledBursts = [];
let animId = null; let animId = null;
let launchCnt = 0; let launchCnt = 0;
let finished = false;
const timers = [];
const launchInterval = setInterval(() => { const launchInterval = setInterval(() => {
if (launchCnt >= config.maxLaunches) { if (launchCnt >= maxLaunches) {
clearInterval(launchInterval); clearInterval(launchInterval);
return; return;
} }
const batchSize = config.getBatchSize(launchCnt); 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( _launchRocket(
rockets, rockets,
w, w,
@@ -421,9 +444,9 @@ const FireworksEffect = (() => {
// 开场礼炮先把气氛撑起来,避免一开始太空。 // 开场礼炮先把气氛撑起来,避免一开始太空。
if (typeof config.openingVolley === "function") { if (typeof config.openingVolley === "function") {
setTimeout(() => { timers.push(setTimeout(() => {
config.openingVolley(rockets, w, h); config.openingVolley(rockets, w, h);
}, 120); }, 120));
} }
const startTime = performance.now(); const startTime = performance.now();
@@ -440,9 +463,10 @@ const FireworksEffect = (() => {
for (let i = scheduledBursts.length - 1; i >= 0; i--) { for (let i = scheduledBursts.length - 1; i >= 0; i--) {
if (scheduledBursts[i].triggerAt <= now) { if (scheduledBursts[i].triggerAt <= now) {
const burst = scheduledBursts[i]; const burst = scheduledBursts[i];
_appendParticles( _appendParticlesWithinBudget(
particles, particles,
_burst(burst.x, burst.y, burst.color, burst.type, burst.density), _burst(burst.x, burst.y, burst.color, burst.type, burst.density),
peakParticleBudget,
); );
halos.push(new Halo(burst.x, burst.y, burst.color, burst.haloRadius)); halos.push(new Halo(burst.x, burst.y, burst.color, burst.haloRadius));
scheduledBursts.splice(i, 1); scheduledBursts.splice(i, 1);
@@ -452,15 +476,16 @@ const FireworksEffect = (() => {
for (let i = rockets.length - 1; i >= 0; i--) { for (let i = rockets.length - 1; i >= 0; i--) {
const rocket = rockets[i]; const rocket = rockets[i];
if (rocket.done) { if (rocket.done) {
_appendParticles( _appendParticlesWithinBudget(
particles, particles,
_burst( _burst(
rocket.x, rocket.x,
rocket.y, rocket.y,
rocket.color, rocket.color,
rocket.type, rocket.type,
config.particleDensity, particleDensity,
), ),
peakParticleBudget,
); );
halos.push(new Halo(rocket.x, rocket.y, rocket.color, config.primaryHaloRadius)); 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, y: rocket.y + (Math.random() - 0.5) * 26,
color: _pick(config.colors), color: _pick(config.colors),
type: Math.random() > 0.5 ? "sphere" : "ring", type: Math.random() > 0.5 ? "sphere" : "ring",
density: config.secondaryDensity, density: secondaryDensity,
haloRadius: config.secondaryHaloRadius, haloRadius: config.secondaryHaloRadius,
}); });
} }
@@ -502,14 +527,44 @@ const FireworksEffect = (() => {
if (shouldContinue && elapsed < hardStopAt) { if (shouldContinue && elapsed < hardStopAt) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
clearInterval(launchInterval); finish(false);
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, w, h);
onEnd();
} }
} }
animId = requestAnimationFrame(animate); 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 特效结束回调 * @param {Function} onEnd 特效结束回调
*/ */
function start(canvas, onEnd) { function start(canvas, onEnd) {
_runShow(canvas, onEnd, { return _runShow(canvas, onEnd, {
duration: 10500, duration: 10500,
launchEvery: 340, launchEvery: 340,
maxLaunches: 24, maxLaunches: 24,
@@ -577,7 +632,7 @@ const FireworksEffect = (() => {
"#00ddff", // 其他 "#00ddff", // 其他
]; ];
_runShow(canvas, onEnd, { return _runShow(canvas, onEnd, {
duration: 12400, duration: 12400,
launchEvery: 280, launchEvery: 280,
maxLaunches: 34, maxLaunches: 34,
+27 -3
View File
@@ -106,6 +106,27 @@ const GoldRainEffect = (() => {
const coins = Array.from({ length: COIN_COUNT }, () => new Coin(w, h)); const coins = Array.from({ length: COIN_COUNT }, () => new Coin(w, h));
const startTime = performance.now(); const startTime = performance.now();
let animId = null; 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) { function animate(now) {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -118,13 +139,16 @@ const GoldRainEffect = (() => {
if (now - startTime < DURATION) { if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
cancelAnimationFrame(animId); finish(false);
ctx.clearRect(0, 0, w, h);
onEnd();
} }
} }
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
} }
return { start }; return { start };
+27 -3
View File
@@ -88,6 +88,27 @@ const HeartsEffect = (() => {
const hearts = Array.from({ length: HEART_COUNT }, () => new Heart(w, h)); const hearts = Array.from({ length: HEART_COUNT }, () => new Heart(w, h));
const startTime = performance.now(); const startTime = performance.now();
let animId = null; 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) { function animate(now) {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -100,13 +121,16 @@ const HeartsEffect = (() => {
if (now - startTime < DURATION) { if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
cancelAnimationFrame(animId); finish(false);
ctx.clearRect(0, 0, w, h);
onEnd();
} }
} }
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
} }
return { start }; return { start };
+26 -12
View File
@@ -87,7 +87,7 @@ const LightningEffect = (() => {
* @param {HTMLCanvasElement} canvas * @param {HTMLCanvasElement} canvas
* @param {CanvasRenderingContext2D} ctx * @param {CanvasRenderingContext2D} ctx
*/ */
function _flash(canvas, ctx) { function _flash(canvas, ctx, timers) {
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
@@ -122,16 +122,16 @@ const LightningEffect = (() => {
} }
// 短促残影:闪电消失后保留一层很淡的余辉,避免“闪一下就没了”。 // 短促残影:闪电消失后保留一层很淡的余辉,避免“闪一下就没了”。
setTimeout(() => { timers.push(setTimeout(() => {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
_drawStormGlow(canvas, ctx); _drawStormGlow(canvas, ctx);
ctx.fillStyle = "rgba(185, 205, 255, 0.12)"; ctx.fillStyle = "rgba(185, 205, 255, 0.12)";
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
}, 90); }, 90));
setTimeout(() => { timers.push(setTimeout(() => {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
}, 190); }, 190));
} }
/** /**
@@ -146,6 +146,7 @@ const LightningEffect = (() => {
const DURATION = 7600; const DURATION = 7600;
let count = 0; let count = 0;
let finished = false; let finished = false;
const timers = [];
/** /**
* 统一结束特效,避免多次触发 onEnd。 * 统一结束特效,避免多次触发 onEnd。
@@ -156,6 +157,7 @@ const LightningEffect = (() => {
} }
finished = true; finished = true;
timers.forEach((timer) => clearTimeout(timer));
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
onEnd(); onEnd();
} }
@@ -163,29 +165,41 @@ const LightningEffect = (() => {
// 间隔不规则触发多次闪电(模拟真实雷电节奏) // 间隔不规则触发多次闪电(模拟真实雷电节奏)
function nextFlash() { function nextFlash() {
if (count >= FLASHES) { if (count >= FLASHES) {
setTimeout(() => { timers.push(setTimeout(() => {
finish(); finish();
}, 520); }, 520));
return; return;
} }
_flash(canvas, ctx); _flash(canvas, ctx, timers);
count++; count++;
// 让雷电节奏有“成组爆发”的感觉:有时连续两下,有时间隔更久。 // 让雷电节奏有“成组爆发”的感觉:有时连续两下,有时间隔更久。
const delay = Math.random() > 0.65 const delay = Math.random() > 0.65
? 140 + Math.random() * 140 ? 140 + Math.random() * 140
: 420 + Math.random() * 520; : 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(); 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 }; return { start };
+27 -3
View File
@@ -140,6 +140,27 @@ const MeteorsEffect = (() => {
const meteors = Array.from({ length: 14 }, () => new Meteor(w, h)); const meteors = Array.from({ length: 14 }, () => new Meteor(w, h));
const startTime = performance.now(); const startTime = performance.now();
let animId = null; 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) { function animate(now) {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -163,13 +184,16 @@ const MeteorsEffect = (() => {
if (now - startTime < DURATION) { if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
cancelAnimationFrame(animId); finish(false);
ctx.clearRect(0, 0, w, h);
onEnd();
} }
} }
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
} }
return { start }; return { start };
+27 -3
View File
@@ -75,8 +75,29 @@ const RainEffect = (() => {
}); });
let animId = null; let animId = null;
let finished = false;
const startTime = performance.now(); 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) { function animate(now) {
// 清除画布(透明,不遮挡聊天背景) // 清除画布(透明,不遮挡聊天背景)
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -89,13 +110,16 @@ const RainEffect = (() => {
if (now - startTime < DURATION) { if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
cancelAnimationFrame(animId); finish(false);
ctx.clearRect(0, 0, w, h);
onEnd();
} }
} }
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
} }
return { start }; return { start };
+27 -3
View File
@@ -93,6 +93,27 @@ const SakuraEffect = (() => {
const petals = Array.from({ length: PETAL_COUNT }, () => new Petal(w, h)); const petals = Array.from({ length: PETAL_COUNT }, () => new Petal(w, h));
const startTime = performance.now(); const startTime = performance.now();
let animId = null; 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) { function animate(now) {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -105,13 +126,16 @@ const SakuraEffect = (() => {
if (now - startTime < DURATION) { if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
cancelAnimationFrame(animId); finish(false);
ctx.clearRect(0, 0, w, h);
onEnd();
} }
} }
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
} }
return { start }; return { start };
+27 -3
View File
@@ -181,8 +181,29 @@ const SnowEffect = (() => {
})); }));
let animId = null; let animId = null;
let finished = false;
const startTime = performance.now(); 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) { function animate(now) {
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
@@ -225,13 +246,16 @@ const SnowEffect = (() => {
if (now - startTime < DURATION) { if (now - startTime < DURATION) {
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
} else { } else {
cancelAnimationFrame(animId); finish(false);
ctx.clearRect(0, 0, w, h);
onEnd();
} }
} }
animId = requestAnimationFrame(animate); animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
} }
return { start }; return { start };
@@ -167,8 +167,8 @@
} }
this.restoreVideoDOM(); this.restoreVideoDOM();
if (typeof EffectManager !== 'undefined' && typeof EffectManager.audioManager !== 'undefined') { if (typeof EffectSounds !== 'undefined') {
EffectManager.audioManager.sounds.gold_received?.play().catch(()=>{}); EffectSounds.ding();
} }
}, },
@@ -308,8 +308,8 @@
} }
// 播放到账特定金币音效 // 播放到账特定金币音效
if (typeof EffectManager !== 'undefined' && typeof EffectManager.audioManager !== 'undefined') { if (typeof EffectSounds !== 'undefined') {
EffectManager.audioManager.sounds.gold_received?.play().catch(()=>{}); EffectSounds.ding();
} }
if (data.level_up) { if (data.level_up) {
@@ -51,7 +51,7 @@
</label> </label>
<label>字色: <label>字色:
<input type="color" id="font_color" name="font_color" value="{{ $user->s_color ?? '#000000' }}" <input type="color" id="font_color" name="font_color" value="{{ preg_match('/^#[0-9a-fA-F]{6}$/', (string) $user->s_color) ? $user->s_color : '#000000' }}"
style="width: 22px; height: 18px; padding: 0; border: 1px solid navy; cursor: pointer;"> style="width: 22px; height: 18px; padding: 0; border: 1px solid navy; cursor: pointer;">
</label> </label>
@@ -483,6 +483,7 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
item_id: itemId, item_id: itemId,
room_id: window.chatContext?.roomId ?? 0,
recipient, recipient,
message: message || '', message: message || '',
quantity: quantity || 1 quantity: quantity || 1
@@ -1045,6 +1045,7 @@
$canKickUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_KICK] ?? false) === true); $canKickUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_KICK] ?? false) === true);
$canMuteUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_MUTE] ?? 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); $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; $hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canFreezeUser;
$hasPositionActions = Auth::user()->activePosition || $myLevel >= $superLevel; $hasPositionActions = Auth::user()->activePosition || $myLevel >= $superLevel;
@endphp @endphp
@@ -1095,8 +1096,8 @@
</button> </button>
@endif @endif
{{-- 职务奖励金币(凭空产生),仅有在职职务 max_reward != 0 的人可见 --}} {{-- 职务奖励金币(凭空产生),仅有明确奖励权限 max_reward != 0 的人可见 --}}
@if ($hasPositionActions) @if ($canRewardUser)
<button x-show="window.chatContext?.myMaxReward !== 0" <button x-show="window.chatContext?.myMaxReward !== 0"
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;" style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
x-on:click="openRewardModal(userInfo.username)">💰 奖励金币 x-on:click="openRewardModal(userInfo.username)">💰 奖励金币
+9 -3
View File
@@ -285,7 +285,9 @@ Route::middleware(['chat.auth'])->group(function () {
Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room'); Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room');
// 发送消息 // 发送消息
Route::post('/room/{id}/send', [ChatController::class, 'send'])->name('chat.send'); Route::post('/room/{id}/send', [ChatController::class, 'send'])
->middleware('throttle:chat-send')
->name('chat.send');
// 挂机心跳存点 (限制每分钟最多调用 6 次防止挂机脚本滥用) // 挂机心跳存点 (限制每分钟最多调用 6 次防止挂机脚本滥用)
Route::post('/room/{id}/heartbeat', [ChatController::class, 'heartbeat']) Route::post('/room/{id}/heartbeat', [ChatController::class, 'heartbeat'])
@@ -333,7 +335,9 @@ Route::middleware(['chat.auth'])->group(function () {
Route::post('/command/announce', [AdminCommandController::class, 'announce'])->name('command.announce'); Route::post('/command/announce', [AdminCommandController::class, 'announce'])->name('command.announce');
Route::post('/command/clear-screen', [AdminCommandController::class, 'clearScreen'])->name('command.clear_screen'); Route::post('/command/clear-screen', [AdminCommandController::class, 'clearScreen'])->name('command.clear_screen');
Route::post('/command/refresh-all', [AdminCommandController::class, 'refreshAll'])->name('command.refresh_all'); Route::post('/command/refresh-all', [AdminCommandController::class, 'refreshAll'])->name('command.refresh_all');
Route::post('/command/effect', [AdminCommandController::class, 'effect'])->name('command.effect'); Route::post('/command/effect', [AdminCommandController::class, 'effect'])
->middleware('throttle:chat-effect')
->name('command.effect');
Route::post('/command/baccarat-loss-cover', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'store'])->name('command.baccarat_loss_cover.store'); Route::post('/command/baccarat-loss-cover', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'store'])->name('command.baccarat_loss_cover.store');
Route::post('/command/baccarat-loss-cover/{event}/close', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'close'])->name('command.baccarat_loss_cover.close'); Route::post('/command/baccarat-loss-cover/{event}/close', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'close'])->name('command.baccarat_loss_cover.close');
@@ -345,7 +349,9 @@ Route::middleware(['chat.auth'])->group(function () {
// ---- 商店(购买特效卡/改名卡)---- // ---- 商店(购买特效卡/改名卡)----
Route::get('/shop/items', [\App\Http\Controllers\ShopController::class, 'items'])->name('shop.items'); Route::get('/shop/items', [\App\Http\Controllers\ShopController::class, 'items'])->name('shop.items');
Route::post('/shop/buy', [\App\Http\Controllers\ShopController::class, 'buy'])->name('shop.buy'); Route::post('/shop/buy', [\App\Http\Controllers\ShopController::class, 'buy'])
->middleware('throttle:chat-shop-buy')
->name('shop.buy');
Route::post('/shop/rename', [\App\Http\Controllers\ShopController::class, 'rename'])->name('shop.rename'); Route::post('/shop/rename', [\App\Http\Controllers\ShopController::class, 'rename'])->name('shop.rename');
// ---- 银行资金接口 ---- // ---- 银行资金接口 ----
+164
View File
@@ -10,6 +10,7 @@ namespace Tests\Feature;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Models\Department; use App\Models\Department;
use App\Models\Gift;
use App\Models\Position; use App\Models\Position;
use App\Models\Room; use App\Models\Room;
use App\Models\Sysparam; use App\Models\Sysparam;
@@ -466,6 +467,26 @@ class ChatControllerTest extends TestCase
$this->assertTrue($found, 'Message not found in Redis'); $this->assertTrue($found, 'Message not found in Redis');
} }
/**
* 测试未进入当前房间时不能直接调用发言接口。
*/
public function test_send_message_requires_user_to_be_in_room(): void
{
$room = Room::create(['room_name' => 'sndbd']);
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson(route('chat.send', $room->id), [
'to_user' => '大家',
'content' => '跨房间发言',
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
]);
$response->assertStatus(403);
$this->assertSame([], Redis::lrange("room:{$room->id}:messages", 0, -1));
}
/** /**
* 测试发送接口会拦截不在白名单内的危险动作值。 * 测试发送接口会拦截不在白名单内的危险动作值。
*/ */
@@ -488,6 +509,57 @@ class ChatControllerTest extends TestCase
$response->assertJsonValidationErrors('action'); $response->assertJsonValidationErrors('action');
} }
/**
* 测试发送接口会拒绝非标准十六进制颜色,避免 style 属性注入。
*/
public function test_send_message_rejects_invalid_font_color(): void
{
$room = Room::create(['room_name' => 'badcolor']);
$user = User::factory()->create();
$this->actingAs($user)->get(route('chat.room', $room->id));
$response = $this->actingAs($user)->postJson(route('chat.send', $room->id), [
'to_user' => '大家',
'content' => '颜色注入测试',
'is_secret' => false,
'font_color' => '#fff;evil',
'action' => '',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('font_color');
}
/**
* 测试合法发言颜色仍可正常保存到消息与用户偏好。
*/
public function test_send_message_accepts_valid_hex_font_color(): void
{
$room = Room::create(['room_name' => 'goodcolor']);
$user = User::factory()->create();
$this->actingAs($user)->get(route('chat.room', $room->id));
$response = $this->actingAs($user)->postJson(route('chat.send', $room->id), [
'to_user' => '大家',
'content' => '合法颜色测试',
'is_secret' => false,
'font_color' => '#12AaF0',
'action' => '',
]);
$response->assertOk()->assertJson(['status' => 'success']);
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$message = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['content'] ?? null) === '合法颜色测试');
$this->assertSame('#12AaF0', $message['font_color'] ?? null);
$this->assertSame('#12AaF0', $user->fresh()->s_color);
}
/** /**
* 测试定向消息仅广播到发送方与接收方私有频道。 * 测试定向消息仅广播到发送方与接收方私有频道。
*/ */
@@ -634,6 +706,34 @@ class ChatControllerTest extends TestCase
]); ]);
} }
/**
* 测试房间公告更新广播中的动态内容会被转义。
*/
public function test_set_announcement_escapes_html_in_system_message(): void
{
$room = Room::create(['room_name' => 'annsafe']);
$user = $this->createUserWithPositionPermissions([
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
]);
$this->actingAs($user)->get(route('chat.room', $room->id));
$response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
'announcement' => '<img src=x onerror=alert(1)>',
]);
$response->assertOk();
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$systemMessage = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['from_user'] ?? null) === '系统公告');
$this->assertNotNull($systemMessage);
$this->assertStringNotContainsString('<img src=x onerror=alert(1)>', (string) $systemMessage['content']);
$this->assertStringContainsString('&lt;img src=x onerror=alert(1)&gt;', (string) $systemMessage['content']);
}
/** /**
* 测试超过保留期的聊天图片会被命令清理并改成过期占位消息。 * 测试超过保留期的聊天图片会被命令清理并改成过期占位消息。
*/ */
@@ -745,6 +845,9 @@ class ChatControllerTest extends TestCase
$receiver = User::factory()->create(['jjb' => 100]); $receiver = User::factory()->create(['jjb' => 100]);
$outsider = User::factory()->create(); $outsider = User::factory()->create();
$this->joinRoom($sender, $room);
$this->joinRoom($receiver, $room);
$response = $this->actingAs($sender)->postJson(route('gift.gold'), [ $response = $this->actingAs($sender)->postJson(route('gift.gold'), [
'to_user' => $receiver->username, 'to_user' => $receiver->username,
'room_id' => $room->id, 'room_id' => $room->id,
@@ -805,6 +908,52 @@ class ChatControllerTest extends TestCase
)); ));
} }
/**
* 测试目标用户不在当前房间时不能赠送金币。
*/
public function test_gift_gold_requires_target_online_in_same_room(): void
{
$room = Room::create(['room_name' => 'ggbd']);
$sender = User::factory()->create(['jjb' => 500]);
$receiver = User::factory()->create(['jjb' => 100]);
$this->joinRoom($sender, $room);
$response = $this->actingAs($sender)->postJson(route('gift.gold'), [
'to_user' => $receiver->username,
'room_id' => $room->id,
'amount' => 88,
]);
$response->assertStatus(403);
$this->assertSame(500, $sender->fresh()->jjb);
$this->assertSame(100, $receiver->fresh()->jjb);
}
/**
* 测试目标用户不在当前房间时不能送花。
*/
public function test_send_flower_requires_target_online_in_same_room(): void
{
$room = Room::create(['room_name' => 'flwbd']);
$sender = User::factory()->create(['jjb' => 500]);
$receiver = User::factory()->create(['meili' => 0]);
$gift = Gift::query()->where('is_active', true)->firstOrFail();
$this->joinRoom($sender, $room);
$response = $this->actingAs($sender)->postJson(route('gift.flower'), [
'to_user' => $receiver->username,
'room_id' => $room->id,
'gift_id' => $gift->id,
'count' => 1,
]);
$response->assertStatus(403);
$this->assertSame(500, $sender->fresh()->jjb);
$this->assertSame(0, $receiver->fresh()->meili);
}
/** /**
* 测试会员用户首次进房时会把专属欢迎主题写入历史消息。 * 测试会员用户首次进房时会把专属欢迎主题写入历史消息。
*/ */
@@ -872,6 +1021,8 @@ class ChatControllerTest extends TestCase
]); ]);
$room = Room::create(['room_name' => 'test_ann', 'room_owner' => 'someone']); $room = Room::create(['room_name' => 'test_ann', 'room_owner' => 'someone']);
$this->joinRoom($user, $room);
$response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [ $response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
'announcement' => 'This is a new test announcement', 'announcement' => 'This is a new test announcement',
]); ]);
@@ -892,6 +1043,8 @@ class ChatControllerTest extends TestCase
]); ]);
$room = Room::create(['room_name' => 'test_ann2', 'room_owner' => 'other']); $room = Room::create(['room_name' => 'test_ann2', 'room_owner' => 'other']);
$this->joinRoom($user, $room);
$response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [ $response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
'announcement' => 'This is a new test announcement', 'announcement' => 'This is a new test announcement',
]); ]);
@@ -969,4 +1122,15 @@ class ChatControllerTest extends TestCase
return $user->fresh(); return $user->fresh();
} }
/**
* 将测试用户写入指定房间在线列表,模拟已经进入聊天室的状态。
*/
private function joinRoom(User $user, Room $room): void
{
Redis::hset("room:{$room->id}:users", $user->username, json_encode([
'user_id' => $user->id,
'username' => $user->username,
], JSON_UNESCAPED_UNICODE));
}
} }
@@ -49,6 +49,7 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '特效房', 'room_name' => '特效房',
]); ]);
$this->joinRoom($admin, $room);
$types = ['sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies']; $types = ['sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies'];
foreach ($types as $type) { foreach ($types as $type) {
@@ -75,6 +76,7 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '特效权限房', 'room_name' => '特效权限房',
]); ]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.effect'), [ $response = $this->actingAs($admin)->postJson(route('command.effect'), [
'room_id' => $room->id, 'room_id' => $room->id,
@@ -120,6 +122,7 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '公屏权限房', 'room_name' => '公屏权限房',
]); ]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.announce'), [ $response = $this->actingAs($admin)->postJson(route('command.announce'), [
'room_id' => $room->id, 'room_id' => $room->id,
@@ -158,6 +161,7 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '清屏权限房', 'room_name' => '清屏权限房',
]); ]);
$this->joinRoom($admin, $room);
Redis::rpush("room:{$room->id}:messages", json_encode([ Redis::rpush("room:{$room->id}:messages", json_encode([
'id' => 1, 'id' => 1,
@@ -188,6 +192,7 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '刷新房', 'room_name' => '刷新房',
]); ]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.refresh_all'), [ $response = $this->actingAs($admin)->postJson(route('command.refresh_all'), [
'room_id' => $room->id, 'room_id' => $room->id,
@@ -245,6 +250,8 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '奖励金币房', 'room_name' => '奖励金币房',
]); ]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
$response = $this->actingAs($admin)->postJson(route('command.reward'), [ $response = $this->actingAs($admin)->postJson(route('command.reward'), [
'username' => $target->username, 'username' => $target->username,
@@ -353,6 +360,8 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '职务警告房', 'room_name' => '职务警告房',
]); ]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
$response = $this->actingAs($admin)->postJson(route('command.warn'), [ $response = $this->actingAs($admin)->postJson(route('command.warn'), [
'username' => $target->username, 'username' => $target->username,
@@ -443,6 +452,7 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '无权踢人房', 'room_name' => '无权踢人房',
]); ]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.kick'), [ $response = $this->actingAs($admin)->postJson(route('command.kick'), [
'username' => $target->username, 'username' => $target->username,
@@ -468,6 +478,7 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '高部门位阶房', 'room_name' => '高部门位阶房',
]); ]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.warn'), [ $response = $this->actingAs($admin)->postJson(route('command.warn'), [
'username' => $target->username, 'username' => $target->username,
@@ -501,6 +512,7 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '同部门高位阶房', 'room_name' => '同部门高位阶房',
]); ]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.mute'), [ $response = $this->actingAs($admin)->postJson(route('command.mute'), [
'username' => $target->username, 'username' => $target->username,
@@ -514,6 +526,114 @@ class AdminCommandControllerTest extends TestCase
]); ]);
} }
/**
* 测试有命令权限但未进入目标房间时不能跨房间触发特效。
*/
public function test_position_user_cannot_run_room_command_without_joining_room(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT,
]);
$room = Room::create([
'room_name' => '未进房特效房',
]);
$response = $this->actingAs($admin)->postJson(route('command.effect'), [
'room_id' => $room->id,
'type' => 'fireworks',
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '请先进入该房间后再执行管理命令',
]);
}
/**
* 测试目标用户不在当前房间时不能执行用户管理动作。
*/
public function test_position_user_cannot_moderate_target_outside_current_room(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
]);
$target = User::factory()->create([
'user_level' => 1,
]);
$room = Room::create([
'room_name' => '目标离线房',
]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.warn'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '测试',
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '目标用户不在当前房间,无法执行该操作',
]);
}
/**
* 测试管理员输入的公告与警告理由会被转义后写入系统消息。
*/
public function test_admin_command_content_is_escaped_before_broadcasting(): void
{
Queue::fake();
[$admin, $target, $room] = $this->createAdminCommandActors();
$payload = '<img src=x onerror=alert(1)>';
$this->actingAs($admin)->postJson(route('command.warn'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => $payload,
])->assertOk();
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$combinedContent = collect($messages)
->map(fn (string $item): string => (string) (json_decode($item, true)['content'] ?? ''))
->implode("\n");
$this->assertStringNotContainsString('<img src=x onerror=alert(1)>', $combinedContent);
$this->assertStringContainsString('&lt;img src=x onerror=alert(1)&gt;', $combinedContent);
}
/**
* 测试没有奖励金币权限的职务,即使配置了额度也不能直接 POST 发奖励。
*/
public function test_position_user_without_reward_permission_cannot_reward_even_with_limit(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
]);
$admin->activePosition->position->update([
'max_reward' => 100,
]);
$target = User::factory()->create([
'user_level' => 1,
]);
$room = Room::create([
'room_name' => '无奖励权限房',
]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
$response = $this->actingAs($admin)->postJson(route('command.reward'), [
'username' => $target->username,
'room_id' => $room->id,
'amount' => 20,
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '当前职务无权发放奖励',
]);
}
/** /**
* 创建管理员命令测试共用的操作者、目标用户和房间。 * 创建管理员命令测试共用的操作者、目标用户和房间。
* *
@@ -534,6 +654,8 @@ class AdminCommandControllerTest extends TestCase
$room = Room::create([ $room = Room::create([
'room_name' => '管理操作房', 'room_name' => '管理操作房',
]); ]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
return [$admin, $target, $room]; return [$admin, $target, $room];
} }
@@ -640,4 +762,15 @@ class AdminCommandControllerTest extends TestCase
&& ($item['to_user'] ?? null) === $targetUsername && ($item['to_user'] ?? null) === $targetUsername
&& str_contains((string) ($item['content'] ?? ''), $needle)); && str_contains((string) ($item['content'] ?? ''), $needle));
} }
/**
* 将测试用户写入指定房间在线表,模拟已经进入聊天室的状态。
*/
private function joinRoom(User $user, Room $room): void
{
Redis::hset("room:{$room->id}:users", $user->username, json_encode([
'id' => $user->id,
'username' => $user->username,
], JSON_UNESCAPED_UNICODE));
}
} }
+147 -7
View File
@@ -9,11 +9,13 @@ namespace Tests\Feature;
use App\Events\EffectBroadcast; use App\Events\EffectBroadcast;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Models\Room;
use App\Models\ShopItem; use App\Models\ShopItem;
use App\Models\User; use App\Models\User;
use App\Models\UserPurchase; use App\Models\UserPurchase;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase; use Tests\TestCase;
/** /**
@@ -24,6 +26,15 @@ class ShopControllerTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
/**
* 每个测试前清空 Redis,避免房间在线状态串扰。
*/
protected function setUp(): void
{
parent::setUp();
Redis::flushall();
}
/** /**
* 测试商品列表接口只返回上架商品。 * 测试商品列表接口只返回上架商品。
*/ */
@@ -86,6 +97,8 @@ class ShopControllerTest extends TestCase
public function test_can_buy_one_time_item() public function test_can_buy_one_time_item()
{ {
$user = User::factory()->create(['jjb' => 500]); $user = User::factory()->create(['jjb' => 500]);
$room = Room::create(['room_name' => '购买房']);
$this->joinRoom($user, $room);
$item = ShopItem::firstOrCreate( $item = ShopItem::firstOrCreate(
['slug' => 'rename_card_test'], ['slug' => 'rename_card_test'],
@@ -98,7 +111,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [ $response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id, 'item_id' => $item->id,
'room_id' => 1, 'room_id' => $room->id,
]); ]);
$response->assertStatus(200); $response->assertStatus(200);
@@ -121,6 +134,8 @@ class ShopControllerTest extends TestCase
public function test_cannot_buy_if_insufficient_funds() public function test_cannot_buy_if_insufficient_funds()
{ {
$user = User::factory()->create(['jjb' => 50]); $user = User::factory()->create(['jjb' => 50]);
$room = Room::create(['room_name' => '余额不足房']);
$this->joinRoom($user, $room);
$item = ShopItem::firstOrCreate( $item = ShopItem::firstOrCreate(
['slug' => 'rename_card_test'], ['slug' => 'rename_card_test'],
@@ -133,6 +148,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [ $response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id, 'item_id' => $item->id,
'room_id' => $room->id,
]); ]);
$response->assertStatus(400); $response->assertStatus(400);
@@ -150,6 +166,8 @@ class ShopControllerTest extends TestCase
public function test_cannot_buy_inactive_item() public function test_cannot_buy_inactive_item()
{ {
$user = User::factory()->create(['jjb' => 500]); $user = User::factory()->create(['jjb' => 500]);
$room = Room::create(['room_name' => '下架商品房']);
$this->joinRoom($user, $room);
$item = ShopItem::create([ $item = ShopItem::create([
'name' => 'Old Card', 'name' => 'Old Card',
@@ -161,6 +179,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [ $response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id, 'item_id' => $item->id,
'room_id' => $room->id,
]); ]);
$response->assertStatus(400); $response->assertStatus(400);
@@ -226,6 +245,8 @@ class ShopControllerTest extends TestCase
Event::fake([MessageSent::class]); Event::fake([MessageSent::class]);
$user = User::factory()->create(['jjb' => 500]); $user = User::factory()->create(['jjb' => 500]);
$room = Room::create(['room_name' => '自动钓鱼房']);
$this->joinRoom($user, $room);
$item = ShopItem::create([ $item = ShopItem::create([
'name' => '自动钓鱼卡(2小时)', 'name' => '自动钓鱼卡(2小时)',
@@ -238,14 +259,14 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [ $response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id, 'item_id' => $item->id,
'room_id' => 1, 'room_id' => $room->id,
]); ]);
$response->assertOk(); $response->assertOk();
$response->assertJson(['status' => 'success']); $response->assertJson(['status' => 'success']);
Event::assertDispatched(MessageSent::class, function (MessageSent $event) use ($user, $item): bool { Event::assertDispatched(MessageSent::class, function (MessageSent $event) use ($user, $item, $room): bool {
return $event->roomId === 1 return $event->roomId === $room->id
&& ($event->message['from_user'] ?? null) === '钓鱼播报' && ($event->message['from_user'] ?? null) === '钓鱼播报'
&& str_contains((string) ($event->message['content'] ?? ''), $user->username) && str_contains((string) ($event->message['content'] ?? ''), $user->username)
&& str_contains((string) ($event->message['content'] ?? ''), $item->name); && str_contains((string) ($event->message['content'] ?? ''), $item->name);
@@ -263,6 +284,8 @@ class ShopControllerTest extends TestCase
'username' => 'buyer-user', 'username' => 'buyer-user',
'jjb' => 5000, 'jjb' => 5000,
]); ]);
$room = Room::create(['room_name' => '指定特效房']);
$this->joinRoom($buyer, $room);
$recipient = User::factory()->create([ $recipient = User::factory()->create([
'username' => 'receiver-user', 'username' => 'receiver-user',
]); ]);
@@ -278,7 +301,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($buyer)->postJson(route('shop.buy'), [ $response = $this->actingAs($buyer)->postJson(route('shop.buy'), [
'item_id' => $item->id, 'item_id' => $item->id,
'room_id' => 1, 'room_id' => $room->id,
'recipient' => $recipient->username, 'recipient' => $recipient->username,
'message' => '送你一场烟花', 'message' => '送你一场烟花',
]); ]);
@@ -291,8 +314,8 @@ class ShopControllerTest extends TestCase
'gift_message' => '送你一场烟花', 'gift_message' => '送你一场烟花',
]); ]);
Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event) use ($buyer, $recipient, $item): bool { Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event) use ($buyer, $recipient, $item, $room): bool {
return $event->roomId === 1 return $event->roomId === $room->id
&& $event->type === $item->effectKey() && $event->type === $item->effectKey()
&& $event->operator === $buyer->username && $event->operator === $buyer->username
&& $event->targetUsername === $recipient->username && $event->targetUsername === $recipient->username
@@ -301,12 +324,114 @@ class ShopControllerTest extends TestCase
}); });
} }
/**
* 测试定向特效的目标用户名作为标识字段时保持原始值,避免前端比较失败。
*/
public function test_buy_instant_effect_keeps_raw_target_username_identifier(): void
{
Event::fake([EffectBroadcast::class]);
$buyer = User::factory()->create([
'username' => 'buyer-user-raw',
'jjb' => 5000,
]);
$room = Room::create(['room_name' => '原始目标名房']);
$this->joinRoom($buyer, $room);
$recipient = User::factory()->create([
'username' => 'receiver&user',
]);
$item = ShopItem::create([
'name' => '烟花单次卡',
'slug' => 'once_fireworks_raw_target',
'type' => 'instant',
'price' => 888,
'is_active' => true,
]);
$response = $this->actingAs($buyer)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => $room->id,
'recipient' => $recipient->username,
]);
$response->assertOk()
->assertJsonPath('target_username', $recipient->username);
Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event) use ($recipient): bool {
return $event->targetUsername === $recipient->username;
});
}
/**
* 测试购买特效时缺少房间 ID 会被拒绝,避免广播到 room.0
*/
public function test_buy_instant_effect_requires_room_id(): void
{
$buyer = User::factory()->create([
'jjb' => 5000,
]);
$item = ShopItem::create([
'name' => '烟花单次卡',
'slug' => 'once_fireworks_missing_room',
'type' => 'instant',
'price' => 888,
'is_active' => true,
]);
$response = $this->actingAs($buyer)->postJson(route('shop.buy'), [
'item_id' => $item->id,
]);
$response->assertStatus(422);
}
/**
* 测试购买特效的赠言会转义后再进入广播载荷。
*/
public function test_buy_instant_effect_escapes_gift_message(): void
{
Event::fake([EffectBroadcast::class, MessageSent::class]);
$buyer = User::factory()->create([
'jjb' => 5000,
]);
$room = Room::create(['room_name' => '赠言安全房']);
$this->joinRoom($buyer, $room);
$item = ShopItem::create([
'name' => '烟花单次卡',
'slug' => 'once_fireworks_safe_message',
'type' => 'instant',
'price' => 888,
'is_active' => true,
]);
$response = $this->actingAs($buyer)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'room_id' => $room->id,
'recipient' => 'all',
'message' => '<img src=x onerror=alert(1)>',
]);
$response->assertOk()
->assertJsonPath('gift_message', '&lt;img src=x onerror=alert(1)&gt;');
Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event): bool {
return $event->giftMessage === '&lt;img src=x onerror=alert(1)&gt;';
});
Event::assertDispatched(MessageSent::class, function (MessageSent $event): bool {
return str_contains((string) ($event->message['content'] ?? ''), '&lt;img src=x onerror=alert(1)&gt;')
&& ! str_contains((string) ($event->message['content'] ?? ''), '<img src=x onerror=alert(1)>');
});
}
/** /**
* 测试购买签到补签卡会扣金币并生成可用背包记录。 * 测试购买签到补签卡会扣金币并生成可用背包记录。
*/ */
public function test_buy_sign_repair_card_creates_active_purchase(): void public function test_buy_sign_repair_card_creates_active_purchase(): void
{ {
$user = User::factory()->create(['jjb' => 12000]); $user = User::factory()->create(['jjb' => 12000]);
$room = Room::create(['room_name' => '补签卡房']);
$this->joinRoom($user, $room);
$item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail(); $item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
$item->update([ $item->update([
'price' => 10000, 'price' => 10000,
@@ -315,6 +440,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [ $response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id, 'item_id' => $item->id,
'room_id' => $room->id,
]); ]);
$response->assertOk() $response->assertOk()
@@ -335,6 +461,8 @@ class ShopControllerTest extends TestCase
public function test_buy_sign_repair_card_supports_quantity(): void public function test_buy_sign_repair_card_supports_quantity(): void
{ {
$user = User::factory()->create(['jjb' => 35000]); $user = User::factory()->create(['jjb' => 35000]);
$room = Room::create(['room_name' => '多张补签卡房']);
$this->joinRoom($user, $room);
$item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail(); $item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
$item->update([ $item->update([
'price' => 10000, 'price' => 10000,
@@ -343,6 +471,7 @@ class ShopControllerTest extends TestCase
$response = $this->actingAs($user)->postJson(route('shop.buy'), [ $response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id, 'item_id' => $item->id,
'room_id' => $room->id,
'quantity' => 3, 'quantity' => 3,
]); ]);
@@ -358,4 +487,15 @@ class ShopControllerTest extends TestCase
->where('status', 'active') ->where('status', 'active')
->count()); ->count());
} }
/**
* 将测试用户写入指定房间在线表,模拟已经进入聊天室的状态。
*/
private function joinRoom(User $user, Room $room): void
{
Redis::hset("room:{$room->id}:users", $user->username, json_encode([
'id' => $user->id,
'username' => $user->username,
], JSON_UNESCAPED_UNICODE));
}
} }