validate([ 'username' => 'required|string', 'room_id' => 'required|integer', 'reason' => 'nullable|string|max:200', ]); $admin = Auth::user(); $targetUsername = $request->input('username'); $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); // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 $authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_WARN, '警告'); if (! $authorization['ok']) { 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} 警告 {$safeTargetUsername}:{$reason}", 'is_secret' => false, 'font_color' => '#dc2626', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($roomId, $msg); broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); // 给被警告用户补一条私聊提示,并复用右下角 toast 通知。 $this->pushTargetToastMessage( roomId: (int) $roomId, targetUsername: $targetUsername, content: "⚠️ {$operatorDisplay} 警告了你:{$reason}", title: '⚠️ 收到警告', toastMessage: "{$operatorDisplay} 警告了你:{$reason}", color: '#f59e0b', icon: '⚠️', ); return response()->json(['status' => 'success', 'message' => "已警告 {$targetUsername}"]); } /** * 踢出用户(=T 理由) * * 将目标用户从聊天室踢出,清除其 Redis 在线状态。 * * @param Request $request 请求对象,需包含 username, room_id, reason * @return JsonResponse 操作结果 */ public function kick(Request $request): JsonResponse { $request->validate([ 'username' => 'required|string', 'room_id' => 'required|integer', 'reason' => 'nullable|string|max:200', ]); $admin = Auth::user(); $targetUsername = $request->input('username'); $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); // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 $authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_KICK, '踢出'); if (! $authorization['ok']) { 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, targetUsername: $targetUsername, content: "🚫 {$operatorDisplay} 已将你踢出聊天室。原因:{$reason}", title: '🚫 已被踢出', toastMessage: "{$operatorDisplay} 已将你踢出聊天室。
原因:{$reason}", color: '#ef4444', icon: '🚫', ); // 从 Redis 在线列表移除 $this->chatState->userLeave($roomId, $targetUsername); // 广播踢出消息(通知前端强制该用户跳转) $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统传音', 'to_user' => '大家', 'content' => "🚫 {$operatorDisplay} 已将 {$safeTargetUsername} 踢出聊天室。原因:{$reason}", 'is_secret' => false, 'font_color' => '#dc2626', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($roomId, $msg); broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); // 广播踢出事件(前端监听后强制跳转) broadcast(new \App\Events\UserKicked($roomId, $targetUsername, $reason)); return response()->json(['status' => 'success', 'message' => "已踢出 {$targetUsername}"]); } /** * 禁言用户(=B 分钟数) * * 使用 Redis TTL 自动过期机制,禁止用户发言指定分钟数。 * * @param Request $request 请求对象,需包含 username, room_id, duration * @return JsonResponse 操作结果 */ public function mute(Request $request): JsonResponse { $request->validate([ 'username' => 'required|string', 'room_id' => 'required|integer', 'duration' => 'required|integer|min:1|max:1440', ]); $admin = Auth::user(); $targetUsername = $request->input('username'); $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; // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 $authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_MUTE, '禁言'); if (! $authorization['ok']) { 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()); // 广播禁言消息 $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统传音', 'to_user' => '大家', 'content' => "🔇 {$operatorDisplay} 已将 {$safeTargetUsername} 禁言 {$duration} 分钟。", 'is_secret' => false, 'font_color' => '#dc2626', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($roomId, $msg); broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); // 给被禁言用户补一条私聊提示,并复用右下角 toast 通知。 $this->pushTargetToastMessage( roomId: (int) $roomId, targetUsername: $targetUsername, content: "🔇 {$operatorDisplay} 已将你禁言 {$duration} 分钟。", title: '🔇 已被禁言', toastMessage: "{$operatorDisplay} 已将你禁言 {$duration} 分钟。", color: '#6366f1', icon: '🔇', ); // 广播禁言事件(前端禁用输入框) broadcast(new \App\Events\UserMuted( roomId: $roomId, username: $targetUsername, muteTime: $duration, message: "{$operatorLabel} 已将 [{$targetUsername}] 禁言 {$duration} 分钟。", operator: $operatorLabel, )); return response()->json(['status' => 'success', 'message' => "已禁言 {$targetUsername} {$duration} 分钟"]); } /** * 封禁用户账号。 * * 将目标账号设为封禁状态,并将其从当前在线房间中移出。 * * @param Request $request 请求对象,需包含 username, room_id, reason * @return JsonResponse 操作结果 */ public function ban(Request $request): JsonResponse { $request->validate([ 'username' => 'required|string', 'room_id' => 'required|integer', 'reason' => 'nullable|string|max:200', ]); $admin = Auth::user(); $targetUsername = $request->input('username'); $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); // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 $authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BAN, '封禁'); if (! $authorization['ok']) { 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); } $target = $authorization['target']; $target->user_level = -1; $target->save(); $this->pushTargetToastMessage( roomId: $roomId, targetUsername: $targetUsername, content: "⛔ {$operatorDisplay} 已封禁你的账号。原因:{$reason}", title: '⛔ 账号已封禁', toastMessage: "{$operatorDisplay} 已封禁你的账号。
原因:{$reason}", color: '#991b1b', icon: '⛔', ); $this->removeUserFromAllRooms($targetUsername); $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统传音', 'to_user' => '大家', 'content' => "⛔ {$operatorDisplay} 已封禁 {$safeTargetUsername} 的账号。原因:{$reason}", 'is_secret' => false, 'font_color' => '#991b1b', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($roomId, $msg); broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被封禁:{$reason}")); return response()->json(['status' => 'success', 'message' => "已封禁 {$targetUsername} 的账号"]); } /** * 封禁用户 IP。 * * 将目标账号设为封禁状态并把最近登录 IP 写入黑名单。 * * @param Request $request 请求对象,需包含 username, room_id, reason * @return JsonResponse 操作结果 */ public function banIp(Request $request): JsonResponse { $request->validate([ 'username' => 'required|string', 'room_id' => 'required|integer', 'reason' => 'nullable|string|max:200', ]); $admin = Auth::user(); $targetUsername = $request->input('username'); $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); // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 $authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BANIP, '封禁 IP '); if (! $authorization['ok']) { 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); } $target = $authorization['target']; $targetIp = (string) ($target->last_ip ?? ''); if ($targetIp !== '') { Redis::sadd('banned_ips', $targetIp); } $target->user_level = -1; $target->save(); $this->pushTargetToastMessage( roomId: $roomId, targetUsername: $targetUsername, content: "🌐 {$operatorDisplay} 已封禁你的 IP 并冻结账号。原因:{$reason}", title: '🌐 IP 已封禁', toastMessage: "{$operatorDisplay} 已封禁你的 IP 并冻结账号。
原因:{$reason}", color: '#7c2d12', icon: '🌐', ); $this->removeUserFromAllRooms($targetUsername); $ipSuffix = $targetIp !== '' ? "(IP:{$targetIp})" : '(未记录有效 IP)'; $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统传音', 'to_user' => '大家', 'content' => "🌐 {$operatorDisplay} 已封禁 {$safeTargetUsername} 的 IP 并冻结账号。原因:{$reason} {$ipSuffix}", 'is_secret' => false, 'font_color' => '#9a3412', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($roomId, $msg); broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "IP 已被封禁:{$reason}")); return response()->json([ 'status' => 'success', 'message' => $targetIp !== '' ? "已封禁 {$targetUsername} 的 IP 与账号" : "已封禁 {$targetUsername} 的账号,但未记录可封禁 IP", ]); } /** * 查看用户私信(=S) * * 管理员查看指定用户最近的悄悄话记录。 * AJAX 请求返回 JSON,浏览器请求返回美观的 HTML 页面。 * * @param string $username 目标用户名 * @return JsonResponse|\Illuminate\View\View 私信记录 */ public function viewWhispers(Request $request, string $username): JsonResponse|\Illuminate\View\View { $admin = Auth::user(); $superLevel = (int) Sysparam::getValue('superlevel', '100'); if ($admin->user_level < $superLevel) { if ($request->expectsJson()) { return response()->json(['status' => 'error', 'message' => '仅站长可查看私信'], 403); } abort(403, '仅站长可查看私信'); } // 查询最近 50 条悄悄话(发送或接收) $messages = Message::where('is_secret', true) ->where(function ($q) use ($username) { $q->where('from_user', $username) ->orWhere('to_user', $username); }) ->orderByDesc('id') ->limit(50) ->get(['id', 'from_user', 'to_user', 'content', 'sent_at']); // AJAX 请求返回 JSON(给名片弹窗用),浏览器请求返回 HTML 页面 if ($request->expectsJson()) { return response()->json([ 'status' => 'success', 'username' => $username, 'messages' => $messages, ]); } return view('admin.whispers', [ 'username' => $username, 'messages' => $messages, ]); } /** * 聊天室公屏讲话 * * 拥有 room.public_broadcast 权限的职务可以发送全聊天室公告, * id=1 站长仍然拥有完整兜底权限。 * * @param Request $request 请求对象,需包含 content, room_id * @return JsonResponse 操作结果 */ public function announce(Request $request): JsonResponse { $request->validate([ 'content' => 'required|string|max:500', 'room_id' => 'required|integer', ]); $admin = Auth::user(); if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST)) { return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403); } $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 = ChatContentSanitizer::htmlText($this->buildAnnouncementPublisherLabel($admin)); $publisherUsername = ChatContentSanitizer::htmlText($admin->username); $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统公告', 'to_user' => '大家', 'content' => "📢 {$publisherLabel} {$publisherUsername} 发布公告:{$content}", 'is_secret' => false, 'font_color' => '#b91c1c', 'action' => '', 'sent_at' => now()->toDateTimeString(), 'toast_notification' => [ 'title' => '📢 公屏公告', 'message' => strip_tags($content), 'icon' => '📢', 'color' => '#b91c1c', 'duration' => 10000, ], ]; $this->chatState->pushMessage($roomId, $msg); broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); return response()->json(['status' => 'success', 'message' => '公告已发送']); } /** * 生成公屏公告发布者身份标签。 * * 普通在职用户按“部门+职务”显示;站长无在职职务时保持“站长”标识兜底。 */ private function buildAnnouncementPublisherLabel(User $user): string { return $this->buildOperatorIdentityLabel($user); } /** * 生成操作者的身份标签。 * * 有在职职务时统一显示为“部门·职务”,无在职职务的 id=1 兜底显示“站长”。 */ private function buildOperatorIdentityLabel(User $user): string { $position = $user->activePosition?->position; if ($position) { $departmentName = (string) ($position->department?->name ?? ''); return $departmentName ? "{$departmentName}·{$position->name}" : $position->name; } if ($user->id === 1) { return '站长'; } return '管理员'; } /** * 生成操作者在聊天室文案中的完整展示文本。 */ private function buildOperatorDisplayHtml(User $user): string { $identityLabel = e($this->buildOperatorIdentityLabel($user)); $username = e($user->username); return "{$identityLabel} {$username}"; } /** * 校验操作者是否可在指定房间执行聊天室管理命令。 * * @return array{ok: bool, message: string, room?: Room} */ private function authorizeManagedRoom(int $roomId, User $operator): array { $room = Room::query()->find($roomId); if (! $room) { return ['ok' => false, 'message' => '房间不存在']; } if (! $room->canUserEnter($operator)) { return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令']; } // 管理命令只能作用于操作者当前所在房间,防止手工 POST 跨房间操作。 if (! $this->chatState->isUserInRoom($roomId, $operator->username)) { return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令']; } return ['ok' => true, 'message' => '校验通过', 'room' => $room]; } /** * 校验目标用户是否仍在线于当前房间。 * * @return array{ok: bool, message: string} */ private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array { if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) { return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作']; } return ['ok' => true, 'message' => '校验通过']; } /** * 校验聊天室用户管理动作是否可执行。 * * 规则: * - id=1 站长始终放行 * - 其他人必须拥有对应职务权限 * - 不能操作自己 * - 不能处理 user_level 高于自己的用户 * - 不能处理部门位阶或职务位阶高于自己的用户 * * @return array{ok: bool, message: string, target?: User} */ private function authorizeModerationAction( User $admin, string $targetUsername, string $permissionCode, string $actionLabel, ): array { if ($admin->id !== 1 && ! $this->positionPermissionService->hasPermission($admin, $permissionCode)) { return ['ok' => false, 'message' => "当前职务无权{$actionLabel}用户"]; } if ($admin->username === $targetUsername) { return ['ok' => false, 'message' => '不能对自己执行该操作']; } $target = User::query() ->where('username', $targetUsername) ->with('activePosition.position.department') ->first(); if (! $target) { return ['ok' => false, 'message' => '用户不存在']; } if (! $this->canModerateTargetByDutyRank($admin, $target)) { return ['ok' => false, 'message' => '不能处理职务高于自己的用户']; } if ($admin->id !== 1 && $target->user_level > $admin->user_level) { return ['ok' => false, 'message' => '不能处理等级高于自己的用户']; } return ['ok' => true, 'message' => '校验通过', 'target' => $target]; } /** * 将目标用户从当前在线的全部房间中移除。 */ private function removeUserFromAllRooms(string $targetUsername): void { $rooms = $this->chatState->getUserRooms($targetUsername); foreach ($rooms as $rid) { $this->chatState->userLeave((int) $rid, $targetUsername); } } /** * 判断操作者是否可以按职务位阶处理目标用户。 * * 规则: * - 先比较部门 rank * - 部门相同再比较职务 rank * - 对方没有在职职务时视为可处理 * - 同职务平级允许操作,仅禁止处理更高职务 */ private function canModerateTargetByDutyRank(User $admin, User $target): bool { if ($admin->id === 1) { return true; } $adminPosition = $admin->activePosition?->load('position.department')->position; if (! $adminPosition) { return false; } $targetPosition = $target->activePosition?->position; if (! $targetPosition) { return true; } $adminDepartmentRank = (int) ($adminPosition->department?->rank ?? 0); $targetDepartmentRank = (int) ($targetPosition->department?->rank ?? 0); if ($adminDepartmentRank > $targetDepartmentRank) { return true; } if ($adminDepartmentRank < $targetDepartmentRank) { return false; } return (int) $adminPosition->rank >= (int) $targetPosition->rank; } /** * 管理员全员清屏 * * 清除 Redis 中该房间的聊天记录缓存,并广播清屏事件通知所有用户前端清除消息。 * 前端只清除普通消息,保留悄悄话。 * * @param Request $request 请求对象,需包含 room_id * @return JsonResponse 操作结果 */ public function clearScreen(Request $request): JsonResponse { $request->validate([ 'room_id' => 'required|integer', ]); $admin = Auth::user(); $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); // 广播清屏事件 broadcast(new \App\Events\ScreenCleared($roomId, $admin->username)); return response()->json(['status' => 'success', 'message' => '已执行全员清屏']); } /** * 站长触发当前房间全员刷新页面。 * * 仅允许 id=1 的站长使用,向当前聊天室在线用户广播刷新事件, * 适用于功能更新后强制让前端重新拉取最新页面状态。 */ public function refreshAll(Request $request): JsonResponse { $request->validate([ 'room_id' => 'required|integer', 'reason' => 'nullable|string|max:100', ]); $admin = Auth::user(); if ((int) $admin->id !== 1) { return response()->json(['status' => 'error', 'message' => '仅站长可执行全员刷新'], 403); } $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', '')); // 立即广播页面刷新指令,确保在线用户尽快拿到最新前端状态。 broadcast(new BrowserRefreshRequested( roomId: $roomId, operator: $admin->username, reason: $reason, )); return response()->json([ 'status' => 'success', 'message' => '已通知当前房间所有在线用户刷新页面', ]); } /** * 管理员触发全屏特效。 * * 向房间内所有用户广播 EffectBroadcast 事件,前端收到后播放对应 Canvas 动画。 * 仅拥有 room.fullscreen_effect 权限的职务可触发。 * * @param Request $request 请求对象,需包含 room_id, type * @return JsonResponse 操作结果 */ public function effect(Request $request): JsonResponse { $request->validate([ 'room_id' => 'required|integer', 'type' => ['required', 'string', Rule::in(EffectBroadcast::TYPES)], ]); $admin = Auth::user(); $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)); return response()->json(['status' => 'success', 'message' => "已触发特效:{$type}"]); } /** * 职务奖励金币(凭空发放,无需扣操作者余额) * * 三层限额校验: * 1. amount ≤ position.max_reward (单次上限) * 2. 今日累计发放 + amount ≤ position.daily_reward_limit (操作人单日累计上限) * 3. 今日对同一接收者发放次数 < position.recipient_daily_limit(同一接收者每日次数限) * * 成功后: * - 通过 UserCurrencyService 给接收者增加金币 * - 写入 PositionAuthorityLog(action_type=reward,记录到履职记录) * - 向房间发送悄悄话通知接收者 * * @param Request $request 需包含 username, room_id, amount */ public function reward(Request $request): JsonResponse { $request->validate([ 'username' => 'required|string', 'room_id' => 'required|integer', 'amount' => 'required|integer|min:1|max:999999999', ], [ 'amount.max' => '单次发放金币不能超过 999999999', 'amount.min' => '发放金币至少为 1', 'amount.integer' => '金币数量必须是整数', 'amount.required' => '请输入要发放的金币数量', ]); $admin = Auth::user(); $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) { return response()->json(['status' => 'error', 'message' => '不能给自己发放奖励'], 422); } // 目标用户必须存在 $target = User::where('username', $targetUsername)->first(); if (! $target) { 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) { return response()->json(['status' => 'error', 'message' => '你当前没有在职职务,无权发放奖励'], 403); } $position = $userPosition->position; // 职务 max_reward = 0 表示禁止,null 表示不限,正整数表示有上限 if ($position?->max_reward === 0) { return response()->json(['status' => 'error', 'message' => '你的职务未配置奖励权限'], 403); } // ② 单次上限校验(max_reward > 0 时才限制,null = 不限) if ($position->max_reward && $amount > $position->max_reward) { return response()->json([ 'status' => 'error', 'message' => "单次奖励上限为 {$position->max_reward} 金币,请调整金额", ], 422); } // ③ 操作人单日累计上限校验 if ($position->daily_reward_limit) { $todayTotal = PositionAuthorityLog::where('user_id', $admin->id) ->where('action_type', 'reward') ->whereDate('created_at', today()) ->sum('amount'); if ($todayTotal + $amount > $position->daily_reward_limit) { $remaining = max(0, $position->daily_reward_limit - $todayTotal); return response()->json([ 'status' => 'error', 'message' => "今日剩余可发放额度为 {$remaining} 金币,超出单日上限({$position->daily_reward_limit})", ], 422); } } // ④ 职务级别:接收者每日次数上限 if ($position->recipient_daily_limit) { $recipientCount = PositionAuthorityLog::where('target_user_id', $target->id) ->where('action_type', 'reward') ->whereDate('created_at', today()) ->count(); if ($recipientCount >= $position->recipient_daily_limit) { return response()->json([ 'status' => 'error', 'message' => "{$targetUsername} 今日已由全体职务人员累计发放 {$recipientCount} 次奖励,已达每日上限({$position->recipient_daily_limit})", ], 422); } } } // ⑤ 全局系统级别:每位用户单日最多接收奖励次数(所有操作人通用,含超管) $globalMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0'); if ($globalMax > 0) { $globalCount = PositionAuthorityLog::where('target_user_id', $target->id) ->where('action_type', 'reward') ->whereDate('created_at', today()) ->count(); if ($globalCount >= $globalMax) { return response()->json([ 'status' => 'error', 'message' => "{$targetUsername} 今日已累计接收 {$globalCount} 次奖励,已达全局每日上限({$globalMax})", ], 422); } } // 发放金币(通过 UserCurrencyService 原子性更新 + 写流水) // 组合「部门 · 职务」显示名,超管特殊处理 if ($isSuperAdmin) { $positionName = '超级管理员'; } elseif ($position) { $deptName = $position->department?->name; $positionName = $deptName ? "{$deptName} · {$position->name}" : $position->name; } else { $positionName = '职务'; } $this->currencyService->change( $target, 'gold', $amount, CurrencySource::POSITION_REWARD, "{$admin->username}({$positionName})职务奖励", $roomId, ); // 写履职记录(PositionAuthorityLog;超管无职务时 user_position_id 留 null) PositionAuthorityLog::create([ 'user_id' => $admin->id, 'user_position_id' => $userPosition?->id, 'action_type' => 'reward', 'target_user_id' => $target->id, 'amount' => $amount, 'remark' => "发放奖励金币 {$amount} 枚给 {$targetUsername}", ]); // ① 聊天室公开公告(所有在场用户可见) $publicMsg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统公告', 'to_user' => '', 'content' => "💰 {$admin->username}({$positionName})向 {$targetUsername} 发放了 {$amount} 枚奖励金币!", 'is_secret' => false, 'font_color' => '#d97706', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($roomId, $publicMsg); broadcast(new MessageSent($roomId, $publicMsg)); SaveMessageJob::dispatch($publicMsg); // ② 接收者私信(含 toast_notification 触发右下角小卡片) $freshJjb = $target->fresh()->jjb; $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统', 'to_user' => $targetUsername, 'content' => "🎁 {$admin->username}({$positionName})向你发放了 {$amount} 枚金币奖励!当前金币:{$freshJjb} 枚。", 'is_secret' => true, 'font_color' => '#d97706', 'action' => '', 'sent_at' => now()->toDateTimeString(), // 前端 toast-notification 组件识别此字段,弹出右下角通知卡片 'toast_notification' => [ 'title' => '💰 奖励金币到账', 'message' => "{$admin->username}({$positionName})向你发放了 {$amount} 枚金币!", 'icon' => '💰', 'color' => '#f59e0b', 'duration' => 3000, ], ]; $this->chatState->pushMessage($roomId, $msg); broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); return response()->json([ 'status' => 'success', 'message' => "已向 {$targetUsername} 发放 {$amount} 金币奖励 🎉", ]); } /** * 查询当前操作人的奖励额度信息(供发放弹窗展示) * * 返回字段: * - max_once: 单次上限(null = 不限) * - daily_limit: 单日发放总额上限(null = 不限) * - today_sent: 今日已发放总额 * - daily_remaining: 今日剩余可发放额度(null = 不限) */ public function rewardQuota(): \Illuminate\Http\JsonResponse { $admin = Auth::user(); $isSuperAdmin = $admin->id === 1; // 最近 10 条本人发放记录(含目标用户名) $recent = PositionAuthorityLog::with('targetUser:id,username') ->where('user_id', $admin->id) ->where('action_type', 'reward') ->latest() ->limit(10) ->get() ->map(fn ($log) => [ 'target' => $log->targetUser?->username ?? '未知', 'amount' => $log->amount, 'created_at' => $log->created_at?->format('m-d H:i'), ]); if ($isSuperAdmin) { return response()->json([ 'max_once' => null, 'daily_limit' => null, 'today_sent' => (int) PositionAuthorityLog::where('user_id', $admin->id) ->where('action_type', 'reward') ->whereDate('created_at', today()) ->sum('amount'), 'daily_remaining' => null, 'recent_rewards' => $recent, ]); } $position = $admin->activePosition?->position; if (! $position) { return response()->json([ 'max_once' => 0, 'daily_limit' => null, 'today_sent' => 0, 'daily_remaining' => null, 'recent_rewards' => $recent, ]); } // 今日已发放总额 $todaySent = (int) PositionAuthorityLog::where('user_id', $admin->id) ->where('action_type', 'reward') ->whereDate('created_at', today()) ->sum('amount'); $dailyLimit = $position->daily_reward_limit; $remaining = $dailyLimit !== null ? max(0, $dailyLimit - $todaySent) : null; return response()->json([ 'max_once' => $position->max_reward, 'daily_limit' => $dailyLimit, 'today_sent' => $todaySent, 'daily_remaining' => $remaining, 'recent_rewards' => $recent, ]); } /** * 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。 */ private function pushTargetToastMessage( int $roomId, string $targetUsername, string $content, string $title, string $toastMessage, string $color, string $icon, ): void { $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统', 'to_user' => $targetUsername, 'content' => $content, 'is_secret' => true, 'font_color' => $color, 'action' => '', 'sent_at' => now()->toDateTimeString(), // 复用现有聊天 toast 机制,在右下角弹出操作结果提示。 'toast_notification' => [ 'title' => $title, 'message' => $toastMessage, 'icon' => $icon, 'color' => $color, 'duration' => 10000, ], ]; $this->chatState->pushMessage($roomId, $msg); broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); } }