收口聊天室安全边界并优化特效生命周期
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' => '发言动作不合法,请重新选择。',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' => '警告用户',
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|
||||||
// ---- 银行资金接口 ----
|
// ---- 银行资金接口 ----
|
||||||
|
|||||||
@@ -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('<img src=x onerror=alert(1)>', (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('<img src=x onerror=alert(1)>', $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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', '<img src=x onerror=alert(1)>');
|
||||||
|
|
||||||
|
Event::assertDispatched(EffectBroadcast::class, function (EffectBroadcast $event): bool {
|
||||||
|
return $event->giftMessage === '<img src=x onerror=alert(1)>';
|
||||||
|
});
|
||||||
|
Event::assertDispatched(MessageSent::class, function (MessageSent $event): bool {
|
||||||
|
return str_contains((string) ($event->message['content'] ?? ''), '<img src=x onerror=alert(1)>')
|
||||||
|
&& ! 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user