diff --git a/app/Http/Controllers/Admin/SystemController.php b/app/Http/Controllers/Admin/SystemController.php index b891ffd..95673c4 100644 --- a/app/Http/Controllers/Admin/SystemController.php +++ b/app/Http/Controllers/Admin/SystemController.php @@ -118,6 +118,15 @@ class SystemController extends Controller */ private function isDedicatedAlias(string $alias): bool { - return in_array($alias, ['levelexp'], true); + return in_array($alias, [ + 'levelexp', + 'level_warn', + 'level_mute', + 'level_kick', + 'level_announcement', + 'level_ban', + 'level_banip', + 'level_freeze', + ], true); } } diff --git a/app/Http/Controllers/Admin/UserManagerController.php b/app/Http/Controllers/Admin/UserManagerController.php index 6580a01..f914360 100644 --- a/app/Http/Controllers/Admin/UserManagerController.php +++ b/app/Http/Controllers/Admin/UserManagerController.php @@ -241,10 +241,10 @@ class UserManagerController extends Controller abort(403, '权限不足:无法删除同级或高级账号!'); } - // 管理员保护:达到踢人等级(level_kick)的用户视为管理员,不可被强杀 - $levelKick = (int) \App\Models\Sysparam::getValue('level_kick', '10'); - if ($targetUser->user_level >= $levelKick) { - abort(403, '该用户为管理员,不允许强杀!请先在用户编辑中降低其等级。'); + // 任命体系保护:仍持有在职职务的账号不可直接强杀,必须先走撤职流程。 + $targetUser->loadMissing('activePosition.position'); + if ($targetUser->id === 1 || $targetUser->activePosition?->position) { + abort(403, '该用户当前拥有在职职务,不允许强杀!请先撤销职务。'); } $targetUser->delete(); diff --git a/app/Http/Controllers/AdminCommandController.php b/app/Http/Controllers/AdminCommandController.php index 5272f00..62a6639 100644 --- a/app/Http/Controllers/AdminCommandController.php +++ b/app/Http/Controllers/AdminCommandController.php @@ -4,7 +4,7 @@ * 文件功能:管理员聊天室实时命令控制器 * * 提供管理员在聊天室内对用户执行的管理操作: - * 警告(=J)、踢出(=T)、禁言(=B)、冻结(=Y)、查看私信(=S)、职务公屏讲话。 + * 警告(=J)、踢出(=T)、禁言(=B)、封号、封IP、查看私信(=S)、职务公屏讲话。 * * 对应原 ASP 文件:DOUSER.ASP / KILLUSER.ASP / LOCKIP.ASP / NEWSAY.ASP * @@ -278,14 +278,14 @@ class AdminCommandController extends Controller } /** - * 冻结用户账号(=Y 理由) + * 封禁用户账号。 * - * 将用户账号状态设为冻结,禁止登录。 + * 将目标账号设为封禁状态,并将其从当前在线房间中移出。 * - * @param Request $request 请求对象,需包含 username, reason + * @param Request $request 请求对象,需包含 username, room_id, reason * @return JsonResponse 操作结果 */ - public function freeze(Request $request): JsonResponse + public function ban(Request $request): JsonResponse { $request->validate([ 'username' => 'required|string', @@ -301,12 +301,12 @@ class AdminCommandController extends Controller return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); } - $reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则')); + $reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规')); $safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername); $operatorDisplay = $this->buildOperatorDisplayHtml($admin); // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 - $authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_FREEZE, '冻结'); + $authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BAN, '封禁'); if (! $authorization['ok']) { return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); } @@ -316,37 +316,30 @@ class AdminCommandController extends Controller return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403); } - // 冻结用户账号(将等级设为 -1 表示冻结) $target = $authorization['target']; $target->user_level = -1; $target->save(); - // 先给被冻结用户补发私聊提示,再将其移出各房间并强制下线。 $this->pushTargetToastMessage( - roomId: (int) $roomId, + roomId: $roomId, targetUsername: $targetUsername, - content: "🧊 {$operatorDisplay} 已冻结你的账号。原因:{$reason}", - title: '🧊 账号已冻结', - toastMessage: "{$operatorDisplay} 已冻结你的账号。
原因:{$reason}", - color: '#3b82f6', - icon: '🧊', + content: "⛔ {$operatorDisplay} 已封禁你的账号。原因:{$reason}", + title: '⛔ 账号已封禁', + toastMessage: "{$operatorDisplay} 已封禁你的账号。
原因:{$reason}", + color: '#991b1b', + icon: '⛔', ); - // 从所有房间移除 - $rooms = $this->chatState->getUserRooms($targetUsername); - foreach ($rooms as $rid) { - $this->chatState->userLeave($rid, $targetUsername); - } + $this->removeUserFromAllRooms($targetUsername); - // 广播冻结消息 $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统传音', 'to_user' => '大家', - 'content' => "🧊 {$operatorDisplay} 已冻结 {$safeTargetUsername} 的账号。原因:{$reason}", + 'content' => "⛔ {$operatorDisplay} 已封禁 {$safeTargetUsername} 的账号。原因:{$reason}", 'is_secret' => false, - 'font_color' => '#dc2626', + 'font_color' => '#991b1b', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; @@ -354,10 +347,93 @@ class AdminCommandController extends Controller broadcast(new MessageSent($roomId, $msg)); SaveMessageJob::dispatch($msg); - // 广播踢出事件 - broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被冻结:{$reason}")); + broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被封禁:{$reason}")); - return response()->json(['status' => 'success', 'message' => "已冻结 {$targetUsername} 的账号"]); + 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", + ]); } /** @@ -555,7 +631,7 @@ class AdminCommandController extends Controller string $permissionCode, string $actionLabel, ): array { - if (! $this->positionPermissionService->hasPermission($admin, $permissionCode)) { + if ($admin->id !== 1 && ! $this->positionPermissionService->hasPermission($admin, $permissionCode)) { return ['ok' => false, 'message' => "当前职务无权{$actionLabel}用户"]; } @@ -583,6 +659,18 @@ class AdminCommandController extends Controller 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); + } + } + /** * 判断操作者是否可以按职务位阶处理目标用户。 * diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 3190caa..d3ea516 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -2,13 +2,8 @@ /** * 文件功能:用户中心与管理控制器 - * 接管原版 USERinfo.ASP, USERSET.ASP, chpasswd.asp, KILLUSER.ASP, LOCKIP.ASP - * - * 权限等级通过 sysparam 表动态配置: - * level_kick - 踢人所需等级 - * level_mute - 禁言所需等级 - * level_ban - 封号所需等级 - * level_banip - 封IP所需等级 + * 接管原版 USERinfo.ASP, USERSET.ASP 与 chpasswd.asp。 + * 聊天室管理动作已统一迁移到 AdminCommandController 的职务权限链路。 * * @author ChatRoom Laravel * @@ -18,25 +13,23 @@ namespace App\Http\Controllers; use App\Enums\CurrencySource; -use App\Events\UserKicked; -use App\Events\UserMuted; use App\Events\UserStatusUpdated; use App\Http\Requests\ChangePasswordRequest; use App\Http\Requests\RevealProfileInfoRequest; use App\Http\Requests\UpdateChatPreferencesRequest; use App\Http\Requests\UpdateDailyStatusRequest; use App\Http\Requests\UpdateProfileRequest; -use App\Models\Room; use App\Models\Sysparam; use App\Models\User; use App\Services\ChatStateService; use App\Services\ChatUserPresenceService; +use App\Services\PositionPermissionService; use App\Services\UserCurrencyService; +use App\Support\PositionPermissionRegistry; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Redis; /** * 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。 @@ -65,6 +58,7 @@ class UserController extends Controller private readonly ChatStateService $chatState, private readonly ChatUserPresenceService $chatUserPresenceService, private readonly UserCurrencyService $currencyService, + private readonly PositionPermissionService $positionPermissionService, ) {} /** @@ -166,9 +160,10 @@ class UserController extends Controller ] : null, ]; - // 拥有封禁IP(level_banip)或踢人以上权限的管理,可以查看IP和归属地 - $levelBanIp = (int) Sysparam::getValue('level_banip', '15'); - if ($operator && $operator->user_level >= $levelBanIp) { + // 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。 + $canViewNetworkInfo = $operator + && ($operator->id === 1 || $this->positionPermissionService->hasPermission($operator, PositionPermissionRegistry::USER_BANIP)); + if ($canViewNetworkInfo) { $data['first_ip'] = $targetUser->first_ip; // last_ip 目前定义为『上次登录IP』(取数据库 previous_ip) $data['last_ip'] = $targetUser->previous_ip; @@ -415,7 +410,7 @@ class UserController extends Controller /** * 生成微信绑定代码 */ - public function generateWechatCode(\Illuminate\Http\Request $request): JsonResponse + public function generateWechatCode(Request $request): JsonResponse { $user = \Illuminate\Support\Facades\Auth::user(); if (! $user) { @@ -435,7 +430,7 @@ class UserController extends Controller /** * 取消绑定微信 */ - public function unbindWechat(\Illuminate\Http\Request $request): JsonResponse + public function unbindWechat(Request $request): JsonResponse { $user = \Illuminate\Support\Facades\Auth::user(); if (! $user) { @@ -451,162 +446,6 @@ class UserController extends Controller ]); } - /** - * 通用权限校验:检查操作者是否有权操作目标用户 - * - * @param object $operator 操作者 - * @param string $targetUsername 目标用户名 - * @param int $roomId 房间ID - * @param string $levelKey sysparam中的等级键名(如 level_kick) - * @param string $actionName 操作名称(用于错误提示) - * @return array{room: Room, target: User}|JsonResponse - */ - private function checkPermission(object $operator, string $targetUsername, int $roomId, string $levelKey, string $actionName): array|JsonResponse - { - $room = Room::findOrFail($roomId); - $requiredLevel = (int) Sysparam::getValue($levelKey, '15'); - - // 鉴权:操作者要是房间房主或达到所需等级 - if ($room->master !== $operator->username && $operator->user_level < $requiredLevel) { - return response()->json(['status' => 'error', 'message' => "权限不足(需要{$requiredLevel}级),无法执行{$actionName}操作。"], 403); - } - - $targetUser = User::where('username', $targetUsername)->first(); - if (! $targetUser) { - return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404); - } - - // 防误伤:不能操作等级 >= 自己的人 - if ($targetUser->user_level >= $operator->user_level) { - return response()->json(['status' => 'error', 'message' => "权限不足,无法对同级或高级用户执行{$actionName}。"], 403); - } - - return ['room' => $room, 'target' => $targetUser]; - } - - /** - * 踢出房间 (对应 KILLUSER.ASP) - * 所需等级由 sysparam level_kick 配置 - */ - public function kick(Request $request, string $username): JsonResponse - { - $operator = Auth::user(); - $roomId = $request->input('room_id'); - - if (! $roomId) { - return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422); - } - - $result = $this->checkPermission($operator, $username, $roomId, 'level_kick', '踢出'); - if ($result instanceof JsonResponse) { - return $result; - } - - // 广播踢出事件 - broadcast(new UserKicked($roomId, $result['target']->username, "管理员 [{$operator->username}] 将 [{$result['target']->username}] 踢出了聊天室。")); - - return response()->json(['status' => 'success', 'message' => "已成功将 {$result['target']->username} 踢出房间。"]); - } - - /** - * 禁言 (对应原版限制功能) - * 所需等级由 sysparam level_mute 配置 - * 禁言信息存入 Redis,TTL 到期自动解除 - */ - public function mute(Request $request, string $username): JsonResponse - { - $operator = Auth::user(); - $roomId = $request->input('room_id'); - $duration = (int) $request->input('duration', 5); - - if (! $roomId) { - return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422); - } - - $result = $this->checkPermission($operator, $username, $roomId, 'level_mute', '禁言'); - if ($result instanceof JsonResponse) { - return $result; - } - - // 写入 Redis 禁言标记,TTL = 禁言分钟数 * 60 - Redis::setex("mute:{$roomId}:{$username}", $duration * 60, json_encode([ - 'operator' => $operator->username, - 'reason' => '管理员禁言', - 'until' => now()->addMinutes($duration)->toDateTimeString(), - ])); - - // 广播禁言事件 - broadcast(new UserMuted($roomId, $username, $duration)); - - return response()->json(['status' => 'success', 'message' => "已对 {$username} 实施禁言 {$duration} 分钟。"]); - } - - /** - * 封号(禁止登录) - * 所需等级由 sysparam level_ban 配置 - * 将用户等级设为 -1 表示封禁 - */ - public function ban(Request $request, string $username): JsonResponse - { - $operator = Auth::user(); - $roomId = $request->input('room_id'); - - if (! $roomId) { - return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422); - } - - $result = $this->checkPermission($operator, $username, $roomId, 'level_ban', '封号'); - if ($result instanceof JsonResponse) { - return $result; - } - - // 封号:设置等级为 -1 - $result['target']->user_level = -1; - $result['target']->save(); - - // 踢出聊天室 - broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的账号。")); - - return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号。"]); - } - - /** - * 封IP(记录IP到黑名单并踢出) - * 所需等级由 sysparam level_banip 配置 - */ - public function banIp(Request $request, string $username): JsonResponse - { - $operator = Auth::user(); - $roomId = $request->input('room_id'); - - if (! $roomId) { - return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422); - } - - $result = $this->checkPermission($operator, $username, $roomId, 'level_banip', '封IP'); - if ($result instanceof JsonResponse) { - return $result; - } - - $targetIp = $result['target']->last_ip; - - if ($targetIp) { - // 将IP加入 Redis 黑名单(永久) - Redis::sadd('banned_ips', $targetIp); - } - - // 同时封号 - $result['target']->user_level = -1; - $result['target']->save(); - - // 踢出聊天室 - broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的IP地址。")); - - $ipInfo = $targetIp ? "(IP: {$targetIp})" : '(未记录IP)'; - - return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号并封IP{$ipInfo}。"]); - } - /** * 判断操作者是否可以免费查看目标用户经验、金币与魅力。 */ diff --git a/app/Models/PositionAuthorityLog.php b/app/Models/PositionAuthorityLog.php index b6cb7de..963e9a5 100644 --- a/app/Models/PositionAuthorityLog.php +++ b/app/Models/PositionAuthorityLog.php @@ -58,6 +58,7 @@ class PositionAuthorityLog extends Model 'warn' => '警告', 'kick' => '踢出', 'mute' => '禁言', + 'ban' => '封号', 'banip' => '封锁IP', 'other' => '其他', ]; diff --git a/app/Support/PositionPermissionRegistry.php b/app/Support/PositionPermissionRegistry.php index 1ec7c78..3ffc56a 100644 --- a/app/Support/PositionPermissionRegistry.php +++ b/app/Support/PositionPermissionRegistry.php @@ -64,9 +64,14 @@ class PositionPermissionRegistry public const USER_MUTE = 'room.user_mute'; /** - * 用户冻结权限。 + * 用户封号权限。 */ - public const USER_FREEZE = 'room.user_freeze'; + public const USER_BAN = 'room.user_ban'; + + /** + * 用户封IP权限。 + */ + public const USER_BANIP = 'room.user_banip'; /** * 返回全部权限定义。 @@ -126,10 +131,15 @@ class PositionPermissionRegistry 'label' => '禁言用户', 'description' => '允许在用户名片内对低于自身职务的用户执行禁言。', ], - self::USER_FREEZE => [ + self::USER_BAN => [ 'group' => '用户管理', - 'label' => '冻结用户', - 'description' => '允许在用户名片内冻结低于自身职务的用户账号。', + 'label' => '封号用户', + 'description' => '允许在用户名片内封禁低于自身职务的用户账号并强制下线。', + ], + self::USER_BANIP => [ + 'group' => '用户管理', + 'label' => '封IP', + 'description' => '允许在用户名片内封禁低于自身职务的用户 IP,并查看管理员网络信息。', ], ]; } diff --git a/database/migrations/2026_02_26_092923_create_sysparam_table.php b/database/migrations/2026_02_26_092923_create_sysparam_table.php index b85fc62..d559993 100644 --- a/database/migrations/2026_02_26_092923_create_sysparam_table.php +++ b/database/migrations/2026_02_26_092923_create_sysparam_table.php @@ -45,13 +45,6 @@ return new class extends Migration ['alias' => 'jjb_per_heartbeat', 'body' => '1-3', 'guidetxt' => '💰 每次心跳金币奖励(支持固定值如"1"或范围如"1-5";0关闭)', 'created_at' => $now, 'updated_at' => $now], // ── 管理操作权限等级 ────────────────────────────────────── - ['alias' => 'level_warn', 'body' => '5', 'guidetxt' => '警告所需等级', 'created_at' => $now, 'updated_at' => $now], - ['alias' => 'level_mute', 'body' => '50', 'guidetxt' => '禁言所需等级', 'created_at' => $now, 'updated_at' => $now], - ['alias' => 'level_kick', 'body' => '60', 'guidetxt' => '踢人所需等级', 'created_at' => $now, 'updated_at' => $now], - ['alias' => 'level_announcement', 'body' => '60', 'guidetxt' => '设置公告所需等级', 'created_at' => $now, 'updated_at' => $now], - ['alias' => 'level_ban', 'body' => '80', 'guidetxt' => '封号所需等级', 'created_at' => $now, 'updated_at' => $now], - ['alias' => 'level_banip', 'body' => '90', 'guidetxt' => '封IP所需等级', 'created_at' => $now, 'updated_at' => $now], - ['alias' => 'level_freeze', 'body' => '14', 'guidetxt' => '冻结账号所需等级', 'created_at' => $now, 'updated_at' => $now], // ── 随机事件 ────────────────────────────────────────────── ['alias' => 'auto_event_chance', 'body' => '10', 'guidetxt' => '随机事件触发概率(百分比,1-100)', 'created_at' => $now, 'updated_at' => $now], diff --git a/database/migrations/2026_04_26_203922_remove_legacy_level_permission_sysparams.php b/database/migrations/2026_04_26_203922_remove_legacy_level_permission_sysparams.php new file mode 100644 index 0000000..dd2ecf4 --- /dev/null +++ b/database/migrations/2026_04_26_203922_remove_legacy_level_permission_sysparams.php @@ -0,0 +1,58 @@ +whereIn('alias', [ + 'level_warn', + 'level_mute', + 'level_kick', + 'level_announcement', + 'level_ban', + 'level_banip', + 'level_freeze', + ]) + ->delete(); + } + + /** + * 方法功能:回滚时恢复旧等级权限参数默认值。 + */ + public function down(): void + { + $now = now(); + + foreach ([ + 'level_warn' => ['5', '警告所需等级'], + 'level_mute' => ['50', '禁言所需等级'], + 'level_kick' => ['60', '踢人所需等级'], + 'level_announcement' => ['60', '设置公告所需等级'], + 'level_ban' => ['80', '封号所需等级'], + 'level_banip' => ['90', '封IP所需等级'], + 'level_freeze' => ['14', '冻结账号所需等级'], + ] as $alias => [$body, $guidetxt]) { + DB::table('sysparam')->updateOrInsert( + ['alias' => $alias], + [ + 'body' => $body, + 'guidetxt' => $guidetxt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + } +}; diff --git a/database/migrations/2026_04_26_205012_merge_freeze_permission_into_ban_permission.php b/database/migrations/2026_04_26_205012_merge_freeze_permission_into_ban_permission.php new file mode 100644 index 0000000..b7f8fe9 --- /dev/null +++ b/database/migrations/2026_04_26_205012_merge_freeze_permission_into_ban_permission.php @@ -0,0 +1,58 @@ +each(function (Position $position): void { + $permissions = array_values($position->permissions ?? []); + + if (! in_array('room.user_freeze', $permissions, true)) { + return; + } + + $normalizedPermissions = array_values(array_unique(array_map( + fn (string $permission): string => $permission === 'room.user_freeze' ? 'room.user_ban' : $permission, + $permissions, + ))); + + $position->forceFill([ + 'permissions' => $normalizedPermissions, + ])->save(); + }); + } + + /** + * 方法功能:回滚时把合并后的封号权限恢复为冻结权限。 + */ + public function down(): void + { + Position::query()->each(function (Position $position): void { + $permissions = array_values($position->permissions ?? []); + + if (! in_array('room.user_ban', $permissions, true)) { + return; + } + + $normalizedPermissions = array_values(array_unique(array_map( + fn (string $permission): string => $permission === 'room.user_ban' ? 'room.user_freeze' : $permission, + $permissions, + ))); + + $position->forceFill([ + 'permissions' => $normalizedPermissions, + ])->save(); + }); + } +}; diff --git a/resources/js/chat-room/user-card.js b/resources/js/chat-room/user-card.js index 17469cf..1b94e77 100644 --- a/resources/js/chat-room/user-card.js +++ b/resources/js/chat-room/user-card.js @@ -603,18 +603,49 @@ export function userCardComponent() { } }, - /** 冻结用户 */ - async freezeUser() { + /** 封禁账号 */ + async banUser() { const confirmed = await this.$confirm( - '确定要冻结 ' + this.userInfo.username + ' 的账号吗?冻结后将无法登录!', - '冻结账号', - '#cc4444' + '确定要封禁 ' + this.userInfo.username + ' 的账号吗?封禁后将无法登录。', + '封禁账号', + '#991b1b' ); if (!confirmed) return; - const reason = await this.$prompt('冻结原因:', '严重违规', '填写原因', '#cc4444'); + const reason = await this.$prompt('封禁原因:', '严重违规', '填写原因', '#991b1b'); if (reason === null) return; try { - const res = await fetch('/command/freeze', { + const res = await fetch('/command/ban', { + method: 'POST', + headers: this._headers(), + body: JSON.stringify({ + username: this.userInfo.username, + room_id: window.chatContext.roomId, + reason: reason || '严重违规' + }) + }); + const data = await res.json(); + if (data.status === 'success') { + this.showUserModal = false; + } else { + this.$alert('操作失败:' + data.message, '操作失败', '#cc4444'); + } + } catch (e) { + this.$alert('网络异常', '错误', '#cc4444'); + } + }, + + /** 封禁 IP */ + async banIpUser() { + const confirmed = await this.$confirm( + '确定要封禁 ' + this.userInfo.username + ' 的 IP 吗?该操作会同时冻结账号。', + '封禁 IP', + '#9a3412' + ); + if (!confirmed) return; + const reason = await this.$prompt('封禁原因:', '严重违规', '填写原因', '#9a3412'); + if (reason === null) return; + try { + const res = await fetch('/command/banip', { method: 'POST', headers: this._headers(), body: JSON.stringify({ diff --git a/resources/views/admin/positions/index.blade.php b/resources/views/admin/positions/index.blade.php index a8b7da0..643cfde 100644 --- a/resources/views/admin/positions/index.blade.php +++ b/resources/views/admin/positions/index.blade.php @@ -442,6 +442,7 @@ 权限管理 (控制聊天室输入框上方「管理」菜单中可见的功能按钮) +

聊天室管理动作已统一按职务权限控制,不再使用等级阈值参数。

@foreach ($positionPermissions as $groupName => $permissions)
diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index ce7a874..d478349 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -17,13 +17,6 @@ @php - // 从 sysparam 读取权限等级配置 - $levelWarn = (int) \App\Models\Sysparam::getValue('level_warn', '5'); - $levelKick = (int) \App\Models\Sysparam::getValue('level_kick', '10'); - $levelMute = (int) \App\Models\Sysparam::getValue('level_mute', '8'); - $levelBan = (int) \App\Models\Sysparam::getValue('level_ban', '12'); - $levelBanip = (int) \App\Models\Sysparam::getValue('level_banip', '14'); - $levelFreeze = (int) \App\Models\Sysparam::getValue('level_freeze', '14'); $superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100'); $myLevel = Auth::user()->user_level; $positionPermissions = array_keys(array_filter($roomPermissionMap ?? [])); @@ -64,10 +57,6 @@ 'userSex' => match ((int) $user->sex) {1 => '男', 2 => '女', default => ''}, 'userLevel' => $user->user_level, 'superLevel' => $superLevel, - 'levelKick' => $levelKick, - 'levelMute' => $levelMute, - 'levelBan' => $levelBan, - 'levelBanip' => $levelBanip, 'sendUrl' => route('chat.send', $room->id), 'leaveUrl' => route('chat.leave', $room->id), 'expiredLeaveUrl' => \Illuminate\Support\Facades\URL::temporarySignedRoute('chat.leave.expired', now()->addHours(12), ['id' => $room->id, 'user' => $user->id]), diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index 80dafb8..068edb1 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -4,8 +4,7 @@ 第二行:输入框 + 发送按钮 从 frame.blade.php 拆分,便于独立维护 - 依赖变量:$user, $room, $levelKick, $levelMute, $levelBan, $levelBanip, - $roomPermissionMap, $hasRoomManagementPermission + 依赖变量:$user, $room, $roomPermissionMap, $hasRoomManagementPermission --}} @php diff --git a/resources/views/chat/partials/user-actions.blade.php b/resources/views/chat/partials/user-actions.blade.php index 0a6926d..20078c3 100644 --- a/resources/views/chat/partials/user-actions.blade.php +++ b/resources/views/chat/partials/user-actions.blade.php @@ -418,9 +418,10 @@ $canWarnUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_WARN] ?? 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); - $canFreezeUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_FREEZE] ?? false) === true); + $canBanUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BAN] ?? false) === true); + $canBanIpUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BANIP] ?? false) === true); $canRewardUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_REWARD] ?? false) === true); - $hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canFreezeUser; + $hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canBanUser || $canBanIpUser; $hasPositionActions = Auth::user()->activePosition || $myLevel >= $superLevel; @endphp @if ($hasUserModerationPermission || $hasPositionActions) @@ -463,10 +464,16 @@ x-on:click="isMuting = !isMuting">🔇 禁言 @endif - @if ($canFreezeUser) + @if ($canBanUser) + @endif + @if ($canBanIpUser) + @endif diff --git a/resources/views/rooms/guide.blade.php b/resources/views/rooms/guide.blade.php index 71d35c0..6faa2db 100644 --- a/resources/views/rooms/guide.blade.php +++ b/resources/views/rooms/guide.blade.php @@ -62,12 +62,6 @@ $charmSame = Sysparam::getValue('charm_same_sex', '1'); $charmLimit = Sysparam::getValue('charm_hourly_limit', '20'); - // 管理权限等级 - $levelWarn = (int) Sysparam::getValue('level_warn', '5'); - $levelMute = (int) Sysparam::getValue('level_mute', '8'); - $levelKick = (int) Sysparam::getValue('level_kick', '10'); - $levelFreeze = (int) Sysparam::getValue('level_freeze', '14'); - // 排行榜配置 $lbLimit = (int) Sysparam::getValue('leaderboard_limit', '50'); @@ -480,35 +474,42 @@

🛡️ 管理权限

-

等级越高,可使用的管理功能越多。双击用户名片中可执行以下操作:

+

聊天室管理动作已统一按职务权限控制。被任命到具备对应权限的职务后,双击用户名片即可执行以下操作:

⚠️
警告用户 - 需要 LV.{{ $levelWarn }} 以上 + 需具备“警告用户”职务权限
🔇
禁言用户 - 需要 LV.{{ $levelMute }} 以上 + 需具备“禁言用户”职务权限
🚫
踢出用户 - 需要 LV.{{ $levelKick }} 以上 + 需具备“踢出用户”职务权限
-
- 🧊 +
+
- 冻结账号 - 需要 LV.{{ $levelFreeze }} 以上 + 封号用户 + 需具备“封号用户”职务权限 +
+
+
+ 🌐 +
+ 封 IP / 查看管理员网络信息 + 需具备“封IP”职务权限
diff --git a/routes/web.php b/routes/web.php index f2dc801..f56748f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -109,11 +109,6 @@ Route::middleware(['chat.auth'])->group(function () { Route::post('/user/unbind-wechat', [UserController::class, 'unbindWechat'])->name('user.unbind_wechat'); Route::post('/user/send-email-code', [\App\Http\Controllers\Api\VerificationController::class, 'sendEmailCode'])->name('user.send_email_code'); Route::put('/user/password', [UserController::class, 'changePassword'])->name('user.update_password'); - Route::post('/user/{username}/kick', [UserController::class, 'kick'])->name('user.kick'); - Route::post('/user/{username}/mute', [UserController::class, 'mute'])->name('user.mute'); - Route::post('/user/{username}/ban', [UserController::class, 'ban'])->name('user.ban'); - Route::post('/user/{username}/banip', [UserController::class, 'banIp'])->name('user.banip'); - // ---- 好友系统 ---- Route::get('/friends', [\App\Http\Controllers\FriendController::class, 'index'])->name('friend.index'); Route::get('/friend/{username}/status', [\App\Http\Controllers\FriendController::class, 'status'])->name('friend.status'); @@ -329,7 +324,8 @@ Route::middleware(['chat.auth'])->group(function () { Route::post('/command/warn', [AdminCommandController::class, 'warn'])->name('command.warn'); Route::post('/command/kick', [AdminCommandController::class, 'kick'])->name('command.kick'); Route::post('/command/mute', [AdminCommandController::class, 'mute'])->name('command.mute'); - Route::post('/command/freeze', [AdminCommandController::class, 'freeze'])->name('command.freeze'); + Route::post('/command/ban', [AdminCommandController::class, 'ban'])->name('command.ban'); + Route::post('/command/banip', [AdminCommandController::class, 'banIp'])->name('command.banip'); Route::post('/command/reward', [AdminCommandController::class, 'reward'])->name('command.reward'); Route::get('/command/reward-quota', [AdminCommandController::class, 'rewardQuota'])->name('command.reward_quota'); Route::get('/command/whispers/{username}', [AdminCommandController::class, 'viewWhispers'])->name('command.whispers'); diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php index 0f51442..79a6683 100644 --- a/tests/Feature/ChatControllerTest.php +++ b/tests/Feature/ChatControllerTest.php @@ -370,6 +370,7 @@ class ChatControllerTest extends TestCase $user = $this->createUserWithPositionPermissions([ PositionPermissionRegistry::USER_WARN, PositionPermissionRegistry::USER_MUTE, + PositionPermissionRegistry::USER_BAN, ]); $response = $this->actingAs($user)->get(route('chat.room', $room->id)); @@ -377,7 +378,9 @@ class ChatControllerTest extends TestCase $response->assertOk(); $response->assertSee('⚠️ 警告', false); $response->assertSee('🔇 禁言', false); + $response->assertSee('⛔ 封号', false); $response->assertDontSee('🚫 踢出', false); + $response->assertDontSee('🌐 封IP', false); $response->assertDontSee('🧊 冻结', false); } @@ -395,6 +398,8 @@ class ChatControllerTest extends TestCase $response->assertDontSee('⚠️ 警告', false); $response->assertDontSee('🚫 踢出', false); $response->assertDontSee('🔇 禁言', false); + $response->assertDontSee('⛔ 封号', false); + $response->assertDontSee('🌐 封IP', false); $response->assertDontSee('🧊 冻结', false); } diff --git a/tests/Feature/Feature/AdminCommandControllerTest.php b/tests/Feature/Feature/AdminCommandControllerTest.php index 28dc6be..fd13df0 100644 --- a/tests/Feature/Feature/AdminCommandControllerTest.php +++ b/tests/Feature/Feature/AdminCommandControllerTest.php @@ -409,14 +409,14 @@ class AdminCommandControllerTest extends TestCase } /** - * 测试冻结操作会给目标用户写入带 toast 的私聊提示。 + * 测试封号操作会给目标用户写入带 toast 的私聊提示。 */ - public function test_freeze_message_contains_toast_notification_for_receiver(): void + public function test_ban_message_contains_toast_notification_for_receiver(): void { Queue::fake(); [$admin, $target, $room] = $this->createAdminCommandActors(); - $response = $this->actingAs($admin)->postJson(route('command.freeze'), [ + $response = $this->actingAs($admin)->postJson(route('command.ban'), [ 'username' => $target->username, 'room_id' => $room->id, 'reason' => '严重违规', @@ -424,17 +424,49 @@ class AdminCommandControllerTest extends TestCase $response->assertOk()->assertJson([ 'status' => 'success', - 'message' => "已冻结 {$target->username} 的账号", + 'message' => "已封禁 {$target->username} 的账号", ]); $this->assertSame(-1, (int) $target->fresh()->user_level); - $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已冻结你的账号'); + $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已封禁你的账号'); $this->assertNotNull($privateMessage); - $this->assertSame('🧊 账号已冻结', $privateMessage['toast_notification']['title'] ?? null); - $this->assertSame('🧊', $privateMessage['toast_notification']['icon'] ?? null); - $this->assertSame('#3b82f6', $privateMessage['toast_notification']['color'] ?? null); + $this->assertSame('⛔ 账号已封禁', $privateMessage['toast_notification']['title'] ?? null); + $this->assertSame('⛔', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#991b1b', $privateMessage['toast_notification']['color'] ?? null); + Queue::assertPushed(SaveMessageJob::class, 2); + } + + /** + * 测试封IP操作会给目标用户写入带 toast 的私聊提示。 + */ + public function test_banip_message_contains_toast_notification_for_receiver(): void + { + Queue::fake(); + [$admin, $target, $room] = $this->createAdminCommandActors(); + $target->forceFill(['last_ip' => '192.168.1.100'])->save(); + + $response = $this->actingAs($admin)->postJson(route('command.banip'), [ + 'username' => $target->username, + 'room_id' => $room->id, + 'reason' => '恶意刷屏', + ]); + + $response->assertOk()->assertJson([ + 'status' => 'success', + 'message' => "已封禁 {$target->username} 的 IP 与账号", + ]); + + $this->assertSame(-1, (int) $target->fresh()->user_level); + $this->assertSame(1, Redis::sismember('banned_ips', '192.168.1.100')); + + $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已封禁你的 IP 并冻结账号'); + + $this->assertNotNull($privateMessage); + $this->assertSame('🌐 IP 已封禁', $privateMessage['toast_notification']['title'] ?? null); + $this->assertSame('🌐', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#7c2d12', $privateMessage['toast_notification']['color'] ?? null); Queue::assertPushed(SaveMessageJob::class, 2); } @@ -466,6 +498,64 @@ class AdminCommandControllerTest extends TestCase ]); } + /** + * 测试缺少封号权限的职务用户会被拒绝。 + */ + public function test_position_user_without_ban_permission_cannot_ban_target(): void + { + $admin = $this->createPositionedManager([ + PositionPermissionRegistry::USER_WARN, + ]); + $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.ban'), [ + 'username' => $target->username, + 'room_id' => $room->id, + 'reason' => '测试', + ]); + + $response->assertStatus(403)->assertJson([ + 'status' => 'error', + 'message' => '当前职务无权封禁用户', + ]); + } + + /** + * 测试缺少封IP权限的职务用户会被拒绝。 + */ + public function test_position_user_without_banip_permission_cannot_ban_target_ip(): void + { + $admin = $this->createPositionedManager([ + PositionPermissionRegistry::USER_WARN, + ]); + $target = User::factory()->create([ + 'user_level' => 1, + ]); + $room = Room::create([ + 'room_name' => '无权封IP房', + ]); + $this->joinRoom($admin, $room); + $this->joinRoom($target, $room); + + $response = $this->actingAs($admin)->postJson(route('command.banip'), [ + 'username' => $target->username, + 'room_id' => $room->id, + 'reason' => '测试', + ]); + + $response->assertStatus(403)->assertJson([ + 'status' => 'error', + 'message' => '当前职务无权封禁 IP 用户', + ]); + } + /** * 测试不能处理部门位阶更高的目标用户。 */ @@ -645,7 +735,8 @@ class AdminCommandControllerTest extends TestCase PositionPermissionRegistry::USER_WARN, PositionPermissionRegistry::USER_MUTE, PositionPermissionRegistry::USER_KICK, - PositionPermissionRegistry::USER_FREEZE, + PositionPermissionRegistry::USER_BAN, + PositionPermissionRegistry::USER_BANIP, ]); $admin->load('activePosition.position.department'); $target = User::factory()->create([ diff --git a/tests/Feature/Feature/AdminPositionPermissionTest.php b/tests/Feature/Feature/AdminPositionPermissionTest.php index c52ce0c..dc10412 100644 --- a/tests/Feature/Feature/AdminPositionPermissionTest.php +++ b/tests/Feature/Feature/AdminPositionPermissionTest.php @@ -47,6 +47,8 @@ class AdminPositionPermissionTest extends TestCase 'permissions' => [ PositionPermissionRegistry::ROOM_ANNOUNCEMENT, PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST, + PositionPermissionRegistry::USER_BAN, + PositionPermissionRegistry::USER_BANIP, ], ]); @@ -59,6 +61,8 @@ class AdminPositionPermissionTest extends TestCase $this->assertSame([ PositionPermissionRegistry::ROOM_ANNOUNCEMENT, PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST, + PositionPermissionRegistry::USER_BAN, + PositionPermissionRegistry::USER_BANIP, ], $position->permissions); } @@ -195,7 +199,9 @@ class AdminPositionPermissionTest extends TestCase $response->assertSee('默认礼包总量'); $response->assertSee('默认礼包份数'); $response->assertSee('警告用户'); - $response->assertSee('冻结用户'); + $response->assertSee('封号用户'); + $response->assertSee('封IP'); + $response->assertSee('聊天室管理动作已统一按职务权限控制'); } /** diff --git a/tests/Feature/Feature/AdminSystemControllerTest.php b/tests/Feature/Feature/AdminSystemControllerTest.php index 0e2dd4b..718e223 100644 --- a/tests/Feature/Feature/AdminSystemControllerTest.php +++ b/tests/Feature/Feature/AdminSystemControllerTest.php @@ -39,6 +39,13 @@ class AdminSystemControllerTest extends TestCase $response->assertDontSee('wechat_bot_config'); $response->assertDontSee('chatbot_max_gold'); $response->assertDontSee('levelexp'); + $response->assertDontSee('level_warn'); + $response->assertDontSee('level_mute'); + $response->assertDontSee('level_kick'); + $response->assertDontSee('level_announcement'); + $response->assertDontSee('level_ban'); + $response->assertDontSee('level_banip'); + $response->assertDontSee('level_freeze'); $response->assertSee('maxlevel'); $response->assertSee('superlevel'); } @@ -55,6 +62,13 @@ class AdminSystemControllerTest extends TestCase 'sys_name' => '新版聊天室', 'sys_notice' => '新的公共公告', 'levelexp' => '20,80,180', + 'level_warn' => '40', + 'level_mute' => '50', + 'level_kick' => '60', + 'level_announcement' => '65', + 'level_ban' => '80', + 'level_banip' => '90', + 'level_freeze' => '95', 'maxlevel' => '88', 'superlevel' => '666', 'smtp_host' => 'attacker.smtp.example', @@ -79,6 +93,34 @@ class AdminSystemControllerTest extends TestCase 'alias' => 'levelexp', 'body' => '10,50,150', ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'level_warn', + 'body' => '5', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'level_mute', + 'body' => '50', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'level_kick', + 'body' => '60', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'level_announcement', + 'body' => '60', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'level_ban', + 'body' => '80', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'level_banip', + 'body' => '90', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'level_freeze', + 'body' => '14', + ]); $this->assertDatabaseHas('sysparam', [ 'alias' => 'maxlevel', 'body' => '88', @@ -147,6 +189,13 @@ class AdminSystemControllerTest extends TestCase 'sys_name' => '原始聊天室', 'sys_notice' => '原始公告', 'levelexp' => '10,50,150', + 'level_warn' => '5', + 'level_mute' => '50', + 'level_kick' => '60', + 'level_announcement' => '60', + 'level_ban' => '80', + 'level_banip' => '90', + 'level_freeze' => '14', 'maxlevel' => '99', 'superlevel' => '100', 'smtp_host' => 'owner.smtp.example', diff --git a/tests/Feature/UserControllerTest.php b/tests/Feature/UserControllerTest.php index 6ad8291..89b3903 100644 --- a/tests/Feature/UserControllerTest.php +++ b/tests/Feature/UserControllerTest.php @@ -8,11 +8,15 @@ namespace Tests\Feature; use App\Enums\CurrencySource; +use App\Models\Department; +use App\Models\Position; use App\Models\Room; use App\Models\Sysparam; use App\Models\User; use App\Models\UserCurrencyLog; +use App\Models\UserPosition; use App\Services\ChatUserPresenceService; +use App\Support\PositionPermissionRegistry; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; @@ -39,10 +43,6 @@ class UserControllerTest extends TestCase Redis::flushall(); Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']); - Sysparam::updateOrCreate(['alias' => 'level_kick'], ['body' => '15']); - Sysparam::updateOrCreate(['alias' => 'level_mute'], ['body' => '15']); - Sysparam::updateOrCreate(['alias' => 'level_ban'], ['body' => '15']); - Sysparam::updateOrCreate(['alias' => 'level_banip'], ['body' => '15']); Sysparam::updateOrCreate(['alias' => 'smtp_enabled'], ['body' => '1']); // Allow email changing in tests } @@ -294,6 +294,51 @@ class UserControllerTest extends TestCase ->assertJsonPath('data.bank_jjb_can_reveal', false); } + /** + * 测试拥有封IP职务权限的用户可以查看名片中的管理员网络信息。 + */ + public function test_position_user_with_banip_permission_can_view_admin_network_info(): void + { + $viewer = $this->createUserWithPositionPermissions([ + PositionPermissionRegistry::USER_BANIP, + ]); + $target = User::factory()->create([ + 'username' => 'nettarget', + 'first_ip' => '10.0.0.1', + 'previous_ip' => '10.0.0.2', + 'last_ip' => '10.0.0.3', + ]); + + $response = $this->actingAs($viewer)->getJson("/user/{$target->username}"); + + $response->assertOk() + ->assertJsonPath('data.first_ip', '10.0.0.1') + ->assertJsonPath('data.last_ip', '10.0.0.2') + ->assertJsonPath('data.login_ip', '10.0.0.3'); + } + + /** + * 测试普通用户查看别人名片时不会拿到管理员网络信息字段。 + */ + public function test_user_without_banip_permission_cannot_view_admin_network_info(): void + { + $viewer = User::factory()->create(); + $target = User::factory()->create([ + 'username' => 'hidden-net', + 'first_ip' => '10.0.1.1', + 'previous_ip' => '10.0.1.2', + 'last_ip' => '10.0.1.3', + ]); + + $response = $this->actingAs($viewer)->getJson("/user/{$target->username}"); + + $response->assertOk() + ->assertJsonMissingPath('data.first_ip') + ->assertJsonMissingPath('data.last_ip') + ->assertJsonMissingPath('data.login_ip') + ->assertJsonMissingPath('data.location'); + } + /** * 测试不改邮箱时可以正常更新个人资料。 */ @@ -510,126 +555,6 @@ class UserControllerTest extends TestCase $this->assertTrue(Hash::check('newpassword123', $user->password)); } - public function test_admin_can_kick_user() - { - $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]); - $target = User::factory()->create(['username' => 'target', 'user_level' => 1]); - $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']); - - $this->actingAs($admin); - - $response = $this->postJson("/user/{$target->username}/kick", [ - 'room_id' => $room->id, - ]); - - $response->assertStatus(200) - ->assertJsonPath('status', 'success'); - } - - public function test_low_level_user_cannot_kick() - { - $user = User::factory()->create(['username' => 'user', 'user_level' => 1]); - $target = User::factory()->create(['username' => 'target', 'user_level' => 1]); - $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']); - - $this->actingAs($user); - - $response = $this->postJson("/user/{$target->username}/kick", [ - 'room_id' => $room->id, - ]); - - $response->assertStatus(403); - } - - public function test_room_master_can_kick() - { - $user = User::factory()->create(['username' => 'user', 'user_level' => 2]); - $target = User::factory()->create(['username' => 'target', 'user_level' => 1]); - $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'user']); // Master is 'user' - - $this->actingAs($user); - - $response = $this->postJson("/user/{$target->username}/kick", [ - 'room_id' => $room->id, - ]); - - if ($response->status() !== 200) { - dump($response->json()); - } - $response->assertStatus(200); - } - - public function test_cannot_kick_higher_level() - { - $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]); - $superadmin = User::factory()->create(['username' => 'superadmin', 'user_level' => 100]); - $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']); - - $this->actingAs($admin); - - $response = $this->postJson("/user/{$superadmin->username}/kick", [ - 'room_id' => $room->id, - ]); - - $response->assertStatus(403); - } - - public function test_admin_can_mute_user() - { - $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]); - $target = User::factory()->create(['username' => 'target', 'user_level' => 1]); - $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']); - - Redis::shouldReceive('setex')->once(); - - $this->actingAs($admin); - - $response = $this->postJson("/user/{$target->username}/mute", [ - 'room_id' => $room->id, - 'duration' => 10, - ]); - - $response->assertStatus(200); - } - - public function test_admin_can_ban_user() - { - $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]); - $target = User::factory()->create(['username' => 'target', 'user_level' => 1]); - $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']); - - $this->actingAs($admin); - - $response = $this->postJson("/user/{$target->username}/ban", [ - 'room_id' => $room->id, - ]); - - $response->assertStatus(200); - - $target->refresh(); - $this->assertEquals(-1, $target->user_level); - } - - public function test_admin_can_ban_ip() - { - $admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]); - $target = User::factory()->create(['username' => 'target', 'user_level' => 1, 'last_ip' => '192.168.1.100']); - $room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']); - - Redis::shouldReceive('sadd')->with('banned_ips', '192.168.1.100')->once(); - - $this->actingAs($admin); - - $response = $this->postJson("/user/{$target->username}/banip", [ - 'room_id' => $room->id, - ]); - - $response->assertStatus(200); - - $target->refresh(); - $this->assertEquals(-1, $target->user_level); - } - /** * 让指定用户先进入聊天室,满足“仅在线用户可设置状态”的前置条件。 */ @@ -646,4 +571,43 @@ class UserControllerTest extends TestCase return $room; } + + /** + * 创建带指定职务权限的测试用户。 + * + * @param list $permissions + */ + private function createUserWithPositionPermissions(array $permissions): User + { + $user = User::factory()->create([ + 'user_level' => 88, + ]); + + $department = Department::create([ + 'name' => '资料权限部'.$user->id, + 'rank' => 88, + 'color' => '#4f46e5', + 'sort_order' => 1, + 'description' => '资料权限测试部门', + ]); + + $position = Position::create([ + 'department_id' => $department->id, + 'name' => '资料权限职务'.$user->id, + 'icon' => '🛡️', + 'rank' => 88, + 'level' => 88, + 'sort_order' => 1, + 'permissions' => $permissions, + ]); + + UserPosition::create([ + 'user_id' => $user->id, + 'position_id' => $position->id, + 'appointed_at' => now(), + 'is_active' => true, + ]); + + return $user->fresh(); + } }