收口聊天室安全边界并优化特效生命周期
This commit is contained in:
@@ -22,11 +22,13 @@ use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Message;
|
||||
use App\Models\PositionAuthorityLog;
|
||||
use App\Models\Room;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -66,8 +68,14 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$reason = $request->input('reason', '请注意言行');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '请注意言行'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
@@ -76,13 +84,18 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 广播警告消息
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "⚠️ {$operatorDisplay} 警告 <b>{$targetUsername}</b>:{$reason}",
|
||||
'content' => "⚠️ {$operatorDisplay} 警告 <b>{$safeTargetUsername}</b>:{$reason}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -124,8 +137,14 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$reason = $request->input('reason', '违反聊天室规则');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
@@ -134,6 +153,11 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 在强制踢出前补发目标私聊提示,尽量让对方先看到 toast。
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
@@ -154,7 +178,7 @@ class AdminCommandController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🚫 {$operatorDisplay} 已将 <b>{$targetUsername}</b> 踢出聊天室。原因:{$reason}",
|
||||
'content' => "🚫 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 踢出聊天室。原因:{$reason}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -188,8 +212,14 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$duration = $request->input('duration');
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
$operatorLabel = $this->buildOperatorIdentityLabel($admin).' '.$admin->username;
|
||||
|
||||
@@ -199,6 +229,11 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 设置 Redis 禁言标记,TTL 自动过期
|
||||
$muteKey = "mute:{$roomId}:{$targetUsername}";
|
||||
Redis::setex($muteKey, $duration * 60, now()->toDateTimeString());
|
||||
@@ -209,7 +244,7 @@ class AdminCommandController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🔇 {$operatorDisplay} 已将 <b>{$targetUsername}</b> 禁言 {$duration} 分钟。",
|
||||
'content' => "🔇 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 禁言 {$duration} 分钟。",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -260,8 +295,14 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$reason = $request->input('reason', '违反聊天室规则');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
@@ -270,6 +311,11 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 冻结用户账号(将等级设为 -1 表示冻结)
|
||||
$target = $authorization['target'];
|
||||
$target->user_level = -1;
|
||||
@@ -298,7 +344,7 @@ class AdminCommandController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🧊 {$operatorDisplay} 已冻结 <b>{$targetUsername}</b> 的账号。原因:{$reason}",
|
||||
'content' => "🧊 {$operatorDisplay} 已冻结 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -381,17 +427,23 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403);
|
||||
}
|
||||
|
||||
$roomId = $request->input('room_id');
|
||||
$content = $request->input('content');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$content = ChatContentSanitizer::htmlText($request->input('content'));
|
||||
|
||||
// 按当前在职职务拼装发布者身份,避免继续显示为固定“站长公告”
|
||||
$publisherLabel = $this->buildAnnouncementPublisherLabel($admin);
|
||||
$publisherLabel = ChatContentSanitizer::htmlText($this->buildAnnouncementPublisherLabel($admin));
|
||||
$publisherUsername = ChatContentSanitizer::htmlText($admin->username);
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '大家',
|
||||
'content' => "📢 <b>{$publisherLabel}</b> <b>{$admin->username}</b> 发布公告:{$content}",
|
||||
'content' => "📢 <b>{$publisherLabel}</b> <b>{$publisherUsername}</b> 发布公告:{$content}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#b91c1c',
|
||||
'action' => '',
|
||||
@@ -447,6 +499,44 @@ class AdminCommandController extends Controller
|
||||
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();
|
||||
$roomId = $request->input('room_id');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
// 改为按职务权限控制聊天室顶部“清屏”按钮。
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_CLEAR_SCREEN)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权执行全员清屏'], 403);
|
||||
}
|
||||
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 清除 Redis 中该房间的消息缓存
|
||||
$this->chatState->clearMessages($roomId);
|
||||
|
||||
@@ -582,7 +677,12 @@ class AdminCommandController extends Controller
|
||||
}
|
||||
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$reason = trim((string) $request->input('reason', ''));
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', ''));
|
||||
|
||||
// 立即广播页面刷新指令,确保在线用户尽快拿到最新前端状态。
|
||||
broadcast(new BrowserRefreshRequested(
|
||||
@@ -614,12 +714,17 @@ class AdminCommandController extends Controller
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$type = $request->input('type');
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权触发特效'], 403);
|
||||
}
|
||||
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 广播特效事件给房间内所有在线用户
|
||||
broadcast(new EffectBroadcast($roomId, $type, $admin->username));
|
||||
|
||||
@@ -658,6 +763,10 @@ class AdminCommandController extends Controller
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$amount = (int) $request->input('amount');
|
||||
$targetUsername = $request->input('username');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 不能给自己发放
|
||||
if ($admin->username === $targetUsername) {
|
||||
@@ -670,12 +779,21 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// id=1 超级管理员:无需职务,无限额限制
|
||||
$isSuperAdmin = $admin->id === 1;
|
||||
$userPosition = null;
|
||||
$position = null;
|
||||
|
||||
if (! $isSuperAdmin) {
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_REWARD)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发放奖励'], 403);
|
||||
}
|
||||
|
||||
// ① 必须有在职职务
|
||||
$userPosition = $admin->activePosition;
|
||||
if (! $userPosition) {
|
||||
|
||||
@@ -31,6 +31,7 @@ use App\Services\PositionPermissionService;
|
||||
use App\Services\RoomBroadcastService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use App\Support\ChatDailyStatusCatalog;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -353,6 +354,10 @@ class ChatController extends Controller
|
||||
$user = Auth::user();
|
||||
$imagePayload = null;
|
||||
|
||||
if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再发言。')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 0. 检查用户是否被禁言(Redis TTL 自动过期)
|
||||
$muteKey = "mute:{$id}:{$user->username}";
|
||||
if (Redis::exists($muteKey)) {
|
||||
@@ -884,13 +889,18 @@ class ChatController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403);
|
||||
}
|
||||
|
||||
if (! $this->chatState->isUserInRoom($id, $user->username)) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先进入该房间后再修改公告'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'announcement' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段
|
||||
$room->announcement = trim($request->input('announcement'))
|
||||
.' ——'.$user->username.' '.now()->format('m-d H:i');
|
||||
$announcementText = trim((string) $request->input('announcement'));
|
||||
|
||||
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段。
|
||||
$room->announcement = $announcementText.' ——'.$user->username.' '.now()->format('m-d H:i');
|
||||
$room->save();
|
||||
|
||||
// 广播公告更新到所有在线用户
|
||||
@@ -899,7 +909,7 @@ class ChatController extends Controller
|
||||
'room_id' => $id,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '大家',
|
||||
'content' => "📢 {$user->username} 更新了房间公告:{$room->announcement}",
|
||||
'content' => '📢 '.ChatContentSanitizer::htmlText($user->username).' 更新了房间公告:'.ChatContentSanitizer::htmlText($room->announcement),
|
||||
'is_secret' => false,
|
||||
'font_color' => '#cc0000',
|
||||
'action' => '',
|
||||
@@ -941,6 +951,10 @@ class ChatController extends Controller
|
||||
$giftId = $request->integer('gift_id');
|
||||
$count = $request->integer('count', 1);
|
||||
|
||||
if ($response = $this->ensureUserCanActInRoom((int) $roomId, $user, '请先进入当前房间后再送礼物。')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 不能给自己送花
|
||||
if ($toUsername === $user->username) {
|
||||
return response()->json(['status' => 'error', 'message' => '不能给自己送花哦~']);
|
||||
@@ -958,6 +972,10 @@ class ChatController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在']);
|
||||
}
|
||||
|
||||
if ($response = $this->ensureTargetOnlineInRoom((int) $roomId, (string) $toUsername)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$totalCost = $gift->cost * $count;
|
||||
$totalCharm = $gift->charm * $count;
|
||||
|
||||
@@ -981,12 +999,15 @@ class ChatController extends Controller
|
||||
|
||||
// 广播送花消息(含图片标记,前端识别后渲染图片)
|
||||
$countText = $count > 1 ? " {$count} 份" : '';
|
||||
$safeSender = ChatContentSanitizer::htmlText($user->username);
|
||||
$safeReceiver = ChatContentSanitizer::htmlText($toUsername);
|
||||
$safeGiftName = ChatContentSanitizer::htmlText($gift->name);
|
||||
$sysMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '送花播报',
|
||||
'to_user' => $toUsername,
|
||||
'content' => "{$gift->emoji} 【{$user->username}】 向 【{$toUsername}】 送出了{$countText}【{$gift->name}】!魅力 +{$totalCharm}!",
|
||||
'content' => "{$gift->emoji} 【{$safeSender}】 向 【{$safeReceiver}】 送出了{$countText}【{$safeGiftName}】!魅力 +{$totalCharm}!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#e91e8f',
|
||||
'action' => '',
|
||||
@@ -1255,6 +1276,10 @@ class ChatController extends Controller
|
||||
$roomId = $request->integer('room_id');
|
||||
$amount = $request->integer('amount');
|
||||
|
||||
if ($response = $this->ensureUserCanActInRoom($roomId, $sender, '请先进入当前房间后再赠送金币。')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 不能给自己转账
|
||||
if ($toName === $sender->username) {
|
||||
return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']);
|
||||
@@ -1266,6 +1291,10 @@ class ChatController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在']);
|
||||
}
|
||||
|
||||
if ($response = $this->ensureTargetOnlineInRoom($roomId, (string) $toName)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 余额校验
|
||||
if (($sender->jjb ?? 0) < $amount) {
|
||||
return response()->json([
|
||||
@@ -1292,7 +1321,7 @@ class ChatController extends Controller
|
||||
// 接收方收到消息时,在右下角弹到账提示卡片。
|
||||
'toast_notification' => [
|
||||
'title' => '💰 赠金币到账',
|
||||
'message' => "<b>{$sender->username}</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
|
||||
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
|
||||
'icon' => '💰',
|
||||
'color' => '#f59e0b',
|
||||
'duration' => 8000,
|
||||
@@ -1313,4 +1342,37 @@ class ChatController extends Controller
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验用户是否能在指定房间执行聊天动作。
|
||||
*/
|
||||
private function ensureUserCanActInRoom(int $roomId, ?User $user, string $message): ?JsonResponse
|
||||
{
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||
}
|
||||
|
||||
$room = Room::query()->find($roomId);
|
||||
if (! $room) {
|
||||
return response()->json(['status' => 'error', 'message' => '房间不存在'], 404);
|
||||
}
|
||||
|
||||
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||
return response()->json(['status' => 'error', 'message' => $message], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
|
||||
*/
|
||||
private function ensureTargetOnlineInRoom(int $roomId, string $targetUsername): ?JsonResponse
|
||||
{
|
||||
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
|
||||
return response()->json(['status' => 'error', 'message' => '目标用户不在当前房间,无法执行该操作'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\EffectBroadcast;
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\Room;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\UserPurchase;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ShopService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -23,6 +26,7 @@ class ShopController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ShopService $shopService,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -85,16 +89,26 @@ class ShopController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'item_id' => 'required|integer|exists:shop_items,id',
|
||||
'room_id' => 'required|integer|exists:rooms,id',
|
||||
'recipient' => 'nullable|string|max:50',
|
||||
'message' => 'nullable|string|max:120',
|
||||
'quantity' => 'nullable|integer|min:1|max:99',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$room = Room::query()->findOrFail($roomId);
|
||||
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买聊天室道具。'], 403);
|
||||
}
|
||||
|
||||
$item = ShopItem::find($request->item_id);
|
||||
if (! $item->is_active) {
|
||||
return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400);
|
||||
}
|
||||
|
||||
$quantity = (int) $request->input('quantity', 1);
|
||||
$result = $this->shopService->buyItem(Auth::user(), $item, $quantity);
|
||||
$result = $this->shopService->buyItem($user, $item, $quantity);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
|
||||
@@ -109,15 +123,14 @@ class ShopController extends Controller
|
||||
|
||||
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
||||
if (isset($result['play_effect'])) {
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->room_id;
|
||||
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
|
||||
$message = trim($request->input('message', ''));
|
||||
$message = ChatContentSanitizer::htmlText($request->input('message', ''));
|
||||
|
||||
// recipient 为空或 "all" 表示全员
|
||||
$targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient;
|
||||
$safeTargetUsername = $targetUsername ? ChatContentSanitizer::htmlText($targetUsername) : null;
|
||||
|
||||
// 广播特效事件(全员频道)
|
||||
// 广播特效事件时保留原始用户名标识,前端需要用它和当前登录名做精确比较。
|
||||
broadcast(new EffectBroadcast(
|
||||
roomId: $roomId,
|
||||
type: $result['play_effect'],
|
||||
@@ -147,9 +160,11 @@ class ShopController extends Controller
|
||||
];
|
||||
// 赠礼消息文案(改成"为XX触发了一场特效")
|
||||
$icon = $icons[$result['play_effect']] ?? '✨';
|
||||
$toStr = $targetUsername ? "【{$targetUsername}】" : '全体聊友';
|
||||
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
|
||||
$safeItemName = ChatContentSanitizer::htmlText($item->name);
|
||||
$toStr = $safeTargetUsername ? "【{$safeTargetUsername}】" : '全体聊友';
|
||||
$remarkPart = $message ? " 「{$message}」" : '';
|
||||
$sysContent = "{$icon} {$user->username} 为 {$toStr} 燃放了一场【{$item->name}】特效!{$remarkPart}";
|
||||
$sysContent = "{$icon} {$safeBuyer} 为 {$toStr} 燃放了一场【{$safeItemName}】特效!{$remarkPart}";
|
||||
|
||||
// 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致)
|
||||
$sysMsgEvent = new MessageSent(
|
||||
@@ -170,9 +185,6 @@ class ShopController extends Controller
|
||||
}
|
||||
} else {
|
||||
// ── 其他类型:广播购买通知到公屏 ────────────────────────────
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->room_id;
|
||||
|
||||
if ($roomId > 0) {
|
||||
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
|
||||
$fishDuration = '';
|
||||
@@ -185,13 +197,15 @@ class ShopController extends Controller
|
||||
$broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音';
|
||||
|
||||
// 根据商品类型生成不同通知文案
|
||||
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
|
||||
$safeItemName = ChatContentSanitizer::htmlText($item->name);
|
||||
$sysContent = match ($item->type) {
|
||||
'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!",
|
||||
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!",
|
||||
'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$user->username}】购买了 {$quantity} 张「{$item->name}」,准备把漏掉的签到补回来!",
|
||||
default => "🛒 【{$user->username}】购买了「{$item->name}」。",
|
||||
'duration' => "📅 【{$safeBuyer}】购买了全屏特效周卡「{$safeItemName}」,登录时将自动触发!",
|
||||
'one_time' => "🎫 【{$safeBuyer}】购买了「{$safeItemName}」道具!",
|
||||
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
|
||||
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
|
||||
};
|
||||
|
||||
broadcast(new MessageSent(
|
||||
@@ -212,7 +226,7 @@ class ShopController extends Controller
|
||||
}
|
||||
|
||||
// 返回最新金币余额
|
||||
$response['jjb'] = Auth::user()->fresh()->jjb;
|
||||
$response['jjb'] = $user->fresh()->jjb;
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class SendMessageRequest extends FormRequest
|
||||
'image' => ['nullable', 'required_without:content', 'file', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:6144'],
|
||||
'to_user' => ['nullable', 'string', 'max:50'],
|
||||
'is_secret' => ['nullable', 'boolean'],
|
||||
'font_color' => ['nullable', 'string', 'max:10'], // html color hex
|
||||
'font_color' => ['nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'], // html color hex
|
||||
'action' => ['nullable', 'string', 'max:50', Rule::in(self::ALLOWED_ACTIONS)], // 动作字段仅允许预设值,阻断拼接式 XSS 注入
|
||||
];
|
||||
}
|
||||
@@ -91,6 +91,7 @@ class SendMessageRequest extends FormRequest
|
||||
'image.image' => '上传的文件必须是图片。',
|
||||
'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。',
|
||||
'image.max' => '图片大小不能超过 6MB。',
|
||||
'font_color.regex' => '发言颜色格式不合法,请重新选择颜色。',
|
||||
'action.in' => '发言动作不合法,请重新选择。',
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user