新增职务权限管理与聊天室管理权限控制

This commit is contained in:
2026-04-21 16:43:17 +08:00
parent cfdbf387af
commit 281315d1cf
19 changed files with 1243 additions and 87 deletions
@@ -13,9 +13,14 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreBaccaratLossCoverEventRequest;
use App\Models\BaccaratLossCoverEvent;
use App\Services\BaccaratLossCoverService;
use App\Services\PositionPermissionService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 类功能:处理聊天室顶部快捷入口创建与结束百家乐买单活动。
*/
class BaccaratLossCoverEventController extends Controller
{
/**
@@ -23,6 +28,7 @@ class BaccaratLossCoverEventController extends Controller
*/
public function __construct(
private readonly BaccaratLossCoverService $lossCoverService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -30,6 +36,13 @@ class BaccaratLossCoverEventController extends Controller
*/
public function store(StoreBaccaratLossCoverEventRequest $request): JsonResponse
{
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
return response()->json([
'ok' => false,
'message' => '当前职务无权创建买单活动。',
], 403);
}
try {
$event = $this->lossCoverService->createEvent($request->user(), $request->validated());
} catch (\RuntimeException $exception) {
@@ -51,6 +64,13 @@ class BaccaratLossCoverEventController extends Controller
*/
public function close(Request $request, BaccaratLossCoverEvent $event): JsonResponse
{
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
return response()->json([
'ok' => false,
'message' => '当前职务无权结束买单活动。',
], 403);
}
$event = $this->lossCoverService->forceCloseEvent($event, $request->user());
return response()->json([
@@ -16,10 +16,15 @@ use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use App\Models\Sysparam;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 类功能:负责后台职务资料、任命白名单与聊天室权限配置的维护。
*/
class PositionController extends Controller
{
/**
@@ -29,16 +34,25 @@ class PositionController extends Controller
{
// 按部门分组展示
$departments = Department::with([
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->with('appointablePositions')->ordered(),
])->ordered()->get();
// 全部职务(供任命白名单多选框使用)
$allPositions = Position::with('department')->orderByDesc('rank')->get();
$allPositions = Position::with('department')->ordered()->get();
// 全局奖励接收次数上限(0 = 不限)
$globalRecipientDailyMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0');
return view('admin.positions.index', compact('departments', 'allPositions', 'globalRecipientDailyMax'));
$positionPermissions = PositionPermissionRegistry::groupedDefinitions();
$permissionLabels = PositionPermissionRegistry::labelMap();
return view('admin.positions.index', compact(
'departments',
'allPositions',
'globalRecipientDailyMax',
'positionPermissions',
'permissionLabels',
));
}
/**
@@ -59,10 +73,13 @@ class PositionController extends Controller
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
'permissions' => 'nullable|array',
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
$position = Position::create($data);
@@ -147,10 +164,13 @@ class PositionController extends Controller
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
'permissions' => 'nullable|array',
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
$position->update($data);
$position->appointablePositions()->sync($appointableIds);
+43 -20
View File
@@ -4,7 +4,7 @@
* 文件功能:管理员聊天室实时命令控制器
*
* 提供管理员在聊天室内对用户执行的管理操作:
* 警告(=J)、踢出(=T)、禁言(=B)、冻结(=Y)、查看私信(=S)站长公屏讲话。
* 警告(=J)、踢出(=T)、禁言(=B)、冻结(=Y)、查看私信(=S)职务公屏讲话。
*
* 对应原 ASP 文件:DOUSER.ASP / KILLUSER.ASP / LOCKIP.ASP / NEWSAY.ASP
*
@@ -24,13 +24,18 @@ use App\Models\PositionAuthorityLog;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
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\Redis;
use Illuminate\Validation\Rule;
/**
* 类功能:处理聊天室内的实时管理命令与部分职务奖励操作。
*/
class AdminCommandController extends Controller
{
/**
@@ -39,6 +44,7 @@ class AdminCommandController extends Controller
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -347,9 +353,10 @@ class AdminCommandController extends Controller
}
/**
* 站长公屏讲话
* 聊天室公屏讲话
*
* 站长发送全聊天室公告,以特殊样式显示。
* 拥有 room.public_broadcast 权限的职务可以发送全聊天室公告,
* id=1 站长仍然拥有完整兜底权限。
*
* @param Request $request 请求对象,需包含 content, room_id
* @return JsonResponse 操作结果
@@ -362,22 +369,21 @@ class AdminCommandController extends Controller
]);
$admin = Auth::user();
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可发布公屏讲话'], 403);
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403);
}
$roomId = $request->input('room_id');
$content = $request->input('content');
// 广播站长公告
// 按当前在职职务拼装发布者身份,避免继续显示为固定“站长公告
$publisherLabel = $this->buildAnnouncementPublisherLabel($admin);
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 站长 <b>{$admin->username}</b> 讲话{$content}",
'content' => "📢 <b>{$publisherLabel}</b> <b>{$admin->username}</b> 发布公告{$content}",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
@@ -390,6 +396,28 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'success', 'message' => '公告已发送']);
}
/**
* 生成公屏公告发布者身份标签。
*
* 普通在职用户按“部门+职务”显示;站长无在职职务时保持“站长”标识兜底。
*/
private function buildAnnouncementPublisherLabel(User $user): string
{
$position = $user->activePosition?->position;
if ($position) {
$departmentName = (string) ($position->department?->name ?? '');
return $departmentName.$position->name;
}
if ($user->id === 1) {
return '站长';
}
return '管理员';
}
/**
* 管理员全员清屏
*
@@ -407,11 +435,9 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$roomId = $request->input('room_id');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 需要站长权限才能全员清屏
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员清屏'], 403);
// 改为按职务权限控制聊天室顶部“清屏”按钮。
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_CLEAR_SCREEN)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权执行全员清屏'], 403);
}
// 清除 Redis 中该房间的消息缓存
@@ -427,7 +453,7 @@ class AdminCommandController extends Controller
* 管理员触发全屏特效。
*
* 向房间内所有用户广播 EffectBroadcast 事件,前端收到后播放对应 Canvas 动画。
* superlevel 等级管理员可触发。
* 拥有 room.fullscreen_effect 权限的职务可触发。
*
* @param Request $request 请求对象,需包含 room_id, type
* @return JsonResponse 操作结果
@@ -442,11 +468,8 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$roomId = $request->input('room_id');
$type = $request->input('type');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 仅 superlevel 等级可触发特效
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可触发特效'], 403);
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权触发特效'], 403);
}
// 广播特效事件给房间内所有在线用户
+12 -5
View File
@@ -26,9 +26,11 @@ use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\MessageFilterService;
use App\Services\PositionPermissionService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
@@ -58,6 +60,7 @@ class ChatController extends Controller
private readonly UserCurrencyService $currencyService,
private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -278,7 +281,9 @@ class ChatController extends Controller
];
}
// 渲染主聊天框架视图
// 渲染主聊天框架视图前,先计算当前用户的聊天室顶部管理权限。
$roomPermissionMap = $this->positionPermissionService->permissionMapForUser($user);
return view('chat.frame', [
'room' => $room,
'user' => $user,
@@ -289,6 +294,8 @@ class ChatController extends Controller
'historyMessages' => $historyMessages,
'pendingProposal' => $pendingProposalData,
'pendingDivorce' => $pendingDivorceData,
'roomPermissionMap' => $roomPermissionMap,
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
]);
}
@@ -892,7 +899,8 @@ class ChatController extends Controller
/**
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
* 需要房间主人或等级达到 level_announcement 配置值
* 需要当前在职职务拥有 room.announcement 权限,
* id=1 站长始终允许操作。
*
* @param int $id 房间ID
*/
@@ -901,9 +909,8 @@ class ChatController extends Controller
$user = Auth::user();
$room = Room::findOrFail($id);
// 权限检查:房间主人 或 等级 >= level_announcement
$requiredLevel = (int) Sysparam::getValue('level_announcement', '10');
if ($user->username !== $room->master && $user->user_level < $requiredLevel) {
// 改为统一走职务权限判断,不再给房主单独保留公告特权。
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_ANNOUNCEMENT)) {
return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403);
}
+8 -7
View File
@@ -4,7 +4,7 @@
* 文件功能:聊天室礼包(红包)控制器
*
* 提供两个核心接口:
* - send() superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
* - send() 拥有权限的职务用户凭空发出 8888 数量 10 份礼包(金币 or 经验)
* - claim() :在线用户抢礼包(先到先得,每人一份)
*
* 接入 UserCurrencyService 记录所有货币变动流水。
@@ -23,9 +23,10 @@ use App\Events\RedPacketSent;
use App\Jobs\SaveMessageJob;
use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -54,10 +55,11 @@ class RedPacketController extends Controller
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
* superlevel 站长凭空发出礼包。
* 拥有权限的职务用户凭空发出礼包。
*
* 不扣发包人自身货币,888 数量凭空发出分 10 份。
* type 参数决定本次发出的是金币(gold)还是经验(exp)。
@@ -75,10 +77,9 @@ class RedPacketController extends Controller
$roomId = (int) $request->input('room_id');
$type = $request->input('type'); // 'gold' 或 'exp'
// 权限校验:仅 superlevel 可发礼包
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($user->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可发礼包红包'], 403);
// 改为按职务权限码控制礼包发放。
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_RED_PACKET)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发礼包红包'], 403);
}
// 检查该用户在此房间是否有进行中的红包(防止刷包)
+22 -1
View File
@@ -3,7 +3,8 @@
/**
* 文件功能:职务模型
* 对应 positions 表,职务属于某个部门,包含等级、图标、人数上限和奖励上限
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
* 聊天室顶部管理权限通过 permissions JSON 字段配置
*
* @author ChatRoom Laravel
*
@@ -35,6 +36,7 @@ class Position extends Model
'daily_reward_limit',
'recipient_daily_limit',
'sort_order',
'permissions',
];
/**
@@ -50,6 +52,7 @@ class Position extends Model
'daily_reward_limit' => 'integer',
'recipient_daily_limit' => 'integer',
'sort_order' => 'integer',
'permissions' => 'array',
];
}
@@ -123,6 +126,24 @@ class Position extends Model
return $this->currentCount() >= $this->max_persons;
}
/**
* 判断当前职务是否拥有指定权限码。
*/
public function hasPermission(string $permission): bool
{
return in_array($permission, $this->permissions ?? [], true);
}
/**
* 返回当前职务的权限码列表。
*
* @return list<string>
*/
public function permissionCodes(): array
{
return array_values($this->permissions ?? []);
}
/**
* 查询范围:按位阶降序
*/
@@ -0,0 +1,92 @@
<?php
/**
* 文件功能:职务权限判断服务
* 负责把当前用户的在职职务权限转换为统一布尔判断,
* 供控制器和 Blade 视图复用。
*/
namespace App\Services;
use App\Models\User;
use App\Support\PositionPermissionRegistry;
/**
* 类功能:统一提供用户职务权限读取与校验方法。
*/
class PositionPermissionService
{
/**
* 返回当前用户拥有的全部聊天室权限码。
*
* 规则:
* - id=1 站长始终拥有全部权限
* - 其他用户仅按当前在职职务的 permissions 生效
*
* @return list<string>
*/
public function permissionsForUser(?User $user): array
{
if (! $user) {
return [];
}
if ($user->id === 1) {
return PositionPermissionRegistry::codes();
}
$position = $user->activePosition?->position;
if (! $position) {
return [];
}
return array_values(array_intersect(
PositionPermissionRegistry::codes(),
$position->permissions ?? [],
));
}
/**
* 返回当前用户全部权限的布尔映射表。
*
* @return array<string, bool>
*/
public function permissionMapForUser(?User $user): array
{
$permissionMap = array_fill_keys(PositionPermissionRegistry::codes(), false);
foreach ($this->permissionsForUser($user) as $permission) {
$permissionMap[$permission] = true;
}
return $permissionMap;
}
/**
* 判断用户是否拥有指定权限码。
*/
public function hasPermission(?User $user, string $permission): bool
{
if (! in_array($permission, PositionPermissionRegistry::codes(), true)) {
return false;
}
return in_array($permission, $this->permissionsForUser($user), true);
}
/**
* 判断用户是否至少拥有一项指定权限。
*
* @param list<string> $permissions
*/
public function hasAnyPermission(?User $user, array $permissions): bool
{
foreach ($permissions as $permission) {
if ($this->hasPermission($user, $permission)) {
return true;
}
}
return false;
}
}
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* 文件功能:职务权限码注册表
* 统一维护聊天室顶部管理菜单可配置的全部权限码、
* 中文标题、说明文案与默认分配规则,便于后续继续扩展。
*/
namespace App\Support;
/**
* 类功能:集中定义职务权限元数据与默认权限策略。
*/
class PositionPermissionRegistry
{
/**
* 房间公告权限。
*/
public const ROOM_ANNOUNCEMENT = 'room.announcement';
/**
* 房间公屏讲话权限。
*/
public const ROOM_PUBLIC_BROADCAST = 'room.public_broadcast';
/**
* 房间全员清屏权限。
*/
public const ROOM_CLEAR_SCREEN = 'room.clear_screen';
/**
* 礼包红包权限。
*/
public const ROOM_RED_PACKET = 'room.red_packet';
/**
* 百家乐买单活动权限。
*/
public const ROOM_BACCARAT_LOSS_COVER = 'room.baccarat_loss_cover';
/**
* 全屏特效权限。
*/
public const ROOM_FULLSCREEN_EFFECT = 'room.fullscreen_effect';
/**
* 返回全部权限定义。
*
* @return array<string, array{group: string, label: string, description: string}>
*/
public static function definitions(): array
{
return [
self::ROOM_ANNOUNCEMENT => [
'group' => '聊天室管理',
'label' => '设置公告',
'description' => '允许修改聊天室顶部滚动公告。',
],
self::ROOM_PUBLIC_BROADCAST => [
'group' => '聊天室管理',
'label' => '公屏讲话',
'description' => '允许在聊天室内发送管理员公屏讲话。',
],
self::ROOM_CLEAR_SCREEN => [
'group' => '聊天室管理',
'label' => '全员清屏',
'description' => '允许清除当前房间所有人的普通聊天记录。',
],
self::ROOM_RED_PACKET => [
'group' => '活动管理',
'label' => '礼包红包',
'description' => '允许在聊天室内发出金币或经验礼包。',
],
self::ROOM_BACCARAT_LOSS_COVER => [
'group' => '活动管理',
'label' => '买单活动',
'description' => '允许创建和结束百家乐买单活动。',
],
self::ROOM_FULLSCREEN_EFFECT => [
'group' => '全屏特效',
'label' => '全屏特效',
'description' => '允许触发聊天室内全部全屏动画特效。',
],
];
}
/**
* 返回全部权限码列表。
*
* @return list<string>
*/
public static function codes(): array
{
return array_keys(self::definitions());
}
/**
* 返回权限码到中文标题的映射。
*
* @return array<string, string>
*/
public static function labelMap(): array
{
$labels = [];
foreach (self::definitions() as $code => $definition) {
$labels[$code] = $definition['label'];
}
return $labels;
}
/**
* 按分组返回权限定义,供后台表单渲染。
*
* @return array<string, array<string, array{label: string, description: string}>>
*/
public static function groupedDefinitions(): array
{
$grouped = [];
foreach (self::definitions() as $code => $definition) {
$grouped[$definition['group']][$code] = [
'label' => $definition['label'],
'description' => $definition['description'],
];
}
return $grouped;
}
/**
* 将权限码数组转换为中文标题列表。
*
* @param list<string> $codes
* @return list<string>
*/
public static function summaryLabels(array $codes): array
{
$labels = self::labelMap();
return array_values(array_map(
fn (string $code): string => $labels[$code] ?? $code,
array_values(array_intersect(self::codes(), $codes))
));
}
/**
* 根据职务等级返回默认权限。
*
* 默认策略:
* - Lv.60 及以上默认拥有「设置公告」
* - Lv.97 及以上默认拥有顶部管理菜单全部权限
*
* @return list<string>
*/
public static function defaultPermissionsForLevel(int $level): array
{
if ($level >= 97) {
return self::codes();
}
if ($level >= 60) {
return [self::ROOM_ANNOUNCEMENT];
}
return [];
}
}
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:为职务表补充聊天室权限字段
* 使用 JSON 数组保存可扩展的职务权限码,
* 并为现有职务按等级回填一份默认权限。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 执行迁移:新增 permissions 字段并回填默认权限。
*/
public function up(): void
{
Schema::table('positions', function (Blueprint $table) {
$table->json('permissions')->nullable()->comment('聊天室权限码 JSON 数组');
});
$positions = DB::table('positions')->select(['id', 'level'])->get();
foreach ($positions as $position) {
DB::table('positions')
->where('id', $position->id)
->update([
'permissions' => json_encode(
$this->defaultPermissionsForLevel((int) $position->level),
JSON_UNESCAPED_UNICODE
),
]);
}
}
/**
* 回滚迁移:删除 permissions 字段。
*/
public function down(): void
{
Schema::table('positions', function (Blueprint $table) {
$table->dropColumn('permissions');
});
}
/**
* 按当前等级回填默认权限。
*
* @return list<string>
*/
private function defaultPermissionsForLevel(int $level): array
{
if ($level >= 97) {
return [
'room.announcement',
'room.public_broadcast',
'room.clear_screen',
'room.red_packet',
'room.baccarat_loss_cover',
'room.fullscreen_effect',
];
}
if ($level >= 60) {
return ['room.announcement'];
}
return [];
}
};
@@ -15,6 +15,7 @@ namespace Database\Seeders;
use App\Models\Department;
use App\Models\Position;
use App\Support\PositionPermissionRegistry;
use Illuminate\Database\Seeder;
class DepartmentPositionSeeder extends Seeder
@@ -48,6 +49,7 @@ class DepartmentPositionSeeder extends Seeder
'max_persons' => $row['max_persons'],
'max_reward' => $row['max_reward'],
'sort_order' => $row['sort_order'],
'permissions' => PositionPermissionRegistry::defaultPermissionsForLevel((int) $row['level']),
]
);
$positions["{$row['department']}::{$row['name']}"] = $position;
+77 -12
View File
@@ -16,6 +16,7 @@
showForm: false,
editing: null,
selectedIds: [],
selectedPermissions: [],
form: {
department_id: '',
name: '',
@@ -32,12 +33,14 @@
openCreate() {
this.editing = null;
this.selectedIds = [];
this.selectedPermissions = [];
this.form = { department_id: '', name: '', icon: '🎖️', rank: 50, level: 60, max_persons: 1, max_reward: '', daily_reward_limit: '', recipient_daily_limit: '', sort_order: 0 };
this.showForm = true;
},
openEdit(pos, appointableIds) {
openEdit(pos, appointableIds, permissions) {
this.editing = pos;
this.selectedIds = appointableIds;
this.selectedPermissions = permissions;
this.form = {
department_id: pos.department_id,
name: pos.name,
@@ -61,6 +64,16 @@
},
isSelected(id) {
return this.selectedIds.includes(id);
},
togglePermission(code) {
if (this.selectedPermissions.includes(code)) {
this.selectedPermissions = this.selectedPermissions.filter(item => item !== code);
} else {
this.selectedPermissions.push(code);
}
},
isPermissionSelected(code) {
return this.selectedPermissions.includes(code);
}
}">
@@ -92,15 +105,6 @@
</div>
</div>
@if (session('success'))
<div class="mb-4 px-4 py-3 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="mb-4 px-4 py-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{{ session('error') }}</div>
@endif
{{-- 全局奖励接收上限配置卡片(失焦/回车自动保存) --}}
<div class="mb-6 bg-amber-50 border border-amber-200 rounded-xl p-5" x-data="{
val: {{ $globalRecipientDailyMax }},
@@ -181,6 +185,7 @@
<th class="px-4 py-3 text-center">单次上限</th>
<th class="px-4 py-3 text-center">单日上限</th>
<th class="px-4 py-3 text-center">任命权</th>
<th class="px-4 py-3 text-center">聊天室权限</th>
@php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp
@if (Auth::user()->user_level >= $superLvl)
<th class="px-4 py-3 text-right">操作</th>
@@ -249,6 +254,34 @@
<span class="text-xs text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3">
@if (! empty($pos->permissions))
@php
$permissionSummaryLabels = collect($pos->permissions)
->map(fn ($permissionCode) => $permissionLabels[$permissionCode] ?? $permissionCode)
->values();
$permissionPreview = $permissionSummaryLabels->take(2)->implode('、');
$permissionTitle = $permissionSummaryLabels->implode(' / ');
@endphp
<div class="mx-auto max-w-[220px] rounded-xl border border-amber-100 bg-gradient-to-br from-amber-50 via-white to-orange-50 px-3 py-2 shadow-sm"
title="{{ $permissionTitle }}">
<div class="flex items-center justify-between gap-3">
<span class="text-[11px] font-semibold tracking-[0.08em] text-amber-700">已开通</span>
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-bold text-amber-700">
{{ $permissionSummaryLabels->count() }}
</span>
</div>
<div class="mt-1 text-xs leading-5 text-slate-600">
{{ $permissionPreview }}
@if ($permissionSummaryLabels->count() > 2)
<span class="text-amber-600"> {{ $permissionSummaryLabels->count() }} </span>
@endif
</div>
</div>
@else
<div class="text-center text-xs text-gray-400"></div>
@endif
</td>
<td class="px-4 py-3 text-right space-x-1">
@php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp
@if (Auth::user()->user_level >= $superLvl)
@@ -266,7 +299,7 @@
recipient_daily_limit: {{ $pos->recipient_daily_limit ?? 'null' }},
sort_order: {{ $pos->sort_order }},
requestUrl: '{{ route('admin.positions.update', $pos->id) }}'
}, {{ json_encode($appointableIds) }})"
}, {{ json_encode($appointableIds) }}, {{ json_encode($pos->permissions ?? []) }})"
class="text-xs bg-indigo-50 text-indigo-600 font-bold px-2 py-1 rounded hover:bg-indigo-600 hover:text-white transition">
编辑
</button>
@@ -286,7 +319,8 @@
</tr>
@empty
<tr>
<td colspan="9" class="px-4 py-6 text-center text-gray-400">该部门暂无职务</td>
<td colspan="{{ Auth::user()->user_level >= $superLvl ? 11 : 10 }}"
class="px-4 py-6 text-center text-gray-400">该部门暂无职务</td>
</tr>
@endforelse
</tbody>
@@ -391,6 +425,37 @@
</div>
</div>
{{-- 聊天室权限多选 --}}
<div class="border rounded-lg p-4 bg-amber-50 mt-4">
<h4 class="text-xs font-bold text-amber-800 mb-2">
权限管理
<span class="font-normal text-amber-700 ml-1">(控制聊天室输入框上方「管理」菜单中可见的功能按钮)</span>
</h4>
<div class="space-y-4">
@foreach ($positionPermissions as $groupName => $permissions)
<div>
<div class="text-xs font-bold text-amber-700 mb-2">{{ $groupName }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
@foreach ($permissions as $permissionCode => $permissionMeta)
<label
class="flex items-start gap-2 cursor-pointer rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm"
:class="isPermissionSelected('{{ $permissionCode }}') ? 'ring-2 ring-amber-300 border-amber-300' : ''">
<input type="checkbox" name="permissions[]" value="{{ $permissionCode }}"
:checked="isPermissionSelected('{{ $permissionCode }}')"
@change="togglePermission('{{ $permissionCode }}')"
class="mt-0.5 rounded text-amber-600">
<span class="min-w-0">
<span class="block font-bold text-gray-700">{{ $permissionMeta['label'] }}</span>
<span class="block text-xs text-gray-500">{{ $permissionMeta['description'] }}</span>
</span>
</label>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
<div class="flex justify-end space-x-3 pt-4 mt-4 border-t">
<button type="button" @click="showForm = false"
class="px-4 py-2 border rounded font-medium text-gray-600 hover:bg-gray-50">取消</button>
+4
View File
@@ -26,6 +26,7 @@
$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 ?? []));
@endphp
<script>
window.chatContext = {
@@ -73,6 +74,9 @@
chatBotEnabled: {{ $chatbotEnabledState ? 'true' : 'false' }},
botUser: @json($botUserData),
hasPosition: {{ Auth::user()->activePosition || Auth::user()->user_level >= $superLevel ? 'true' : 'false' }},
hasRoomManagementPermission: {{ ! empty($hasRoomManagementPermission) ? 'true' : 'false' }},
positionPermissions: @json($positionPermissions),
positionPermissionMap: @json($roomPermissionMap ?? []),
@php
$activePos = Auth::user()->activePosition;
$deptName = $activePos?->position?->department?->name ?? '';
@@ -4,9 +4,19 @@
第二行:输入框 + 发送按钮
frame.blade.php 拆分,便于独立维护
依赖变量:$user, $room, $levelKick, $levelMute, $levelBan, $levelBanip
依赖变量:$user, $room, $levelKick, $levelMute, $levelBan, $levelBanip,
$roomPermissionMap, $hasRoomManagementPermission
--}}
@php
$canSetAnnouncement = $roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_ANNOUNCEMENT] ?? false;
$canPublicBroadcast = $roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST] ?? false;
$canClearScreen = $roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_CLEAR_SCREEN] ?? false;
$canSendRedPacket = $roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_RED_PACKET] ?? false;
$canManageLossCover = $roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER] ?? false;
$canTriggerFullscreenEffect = $roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT] ?? false;
@endphp
<div class="input-bar">
<form id="chat-form" onsubmit="sendMessage(event)" enctype="multipart/form-data">
{{-- 第一行:工具选项 --}}
@@ -155,11 +165,7 @@ $welcomeMessages = [
</div>
</div>
@if (
$user->user_level >= (int) \App\Models\Sysparam::getValue('level_announcement', '10') ||
$room->master == $user->username ||
$user->user_level >= (int) \App\Models\Sysparam::getValue('superlevel', '100')
)
@if (! empty($hasRoomManagementPermission))
<div style="position:relative;display:inline-block;" id="admin-btn-wrap">
<button type="button" onclick="toggleAdminMenu(event)"
style="font-size:11px;padding:1px 6px;background:linear-gradient(135deg,#b45309,#d97706);color:#fff;border:none;border-radius:2px;cursor:pointer;font-weight:bold;">
@@ -167,10 +173,7 @@ $welcomeMessages = [
</button>
<div id="admin-menu"
style="display:none;position:absolute;bottom:calc(100% + 6px);left:0;z-index:10020;min-width:244px;max-width:min(80vw,288px);max-height:min(62vh,460px);overflow-y:auto;padding:10px;background:#fffaf0;border:1px solid #fdba74;border-radius:10px;box-shadow:0 10px 24px rgba(15,23,42,.18);">
@if (
$user->user_level >= (int) \App\Models\Sysparam::getValue('level_announcement', '10') ||
$room->master == $user->username
)
@if ($canSetAnnouncement)
<div style="font-size:10px;color:#9a3412;padding:0 2px 6px;">房间管理</div>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
<button type="button" onclick="runAdminAction('announcement')"
@@ -178,19 +181,29 @@ $welcomeMessages = [
</div>
@endif
@if ($user->user_level >= (int) \App\Models\Sysparam::getValue('superlevel', '100'))
<div style="font-size:10px;color:#9a3412;padding:10px 2px 6px;">站长操作</div>
@if ($canPublicBroadcast || $canClearScreen || $canSendRedPacket || $canManageLossCover)
<div style="font-size:10px;color:#9a3412;padding:10px 2px 6px;">聊天室管理</div>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
<button type="button" onclick="runAdminAction('announce-message')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#7c3aed;border:1px solid #c4b5fd;border-radius:6px;cursor:pointer;">📢 公屏</button>
<button type="button" onclick="runAdminAction('admin-clear')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#dc2626;border:1px solid #fca5a5;border-radius:6px;cursor:pointer;">🧹 清屏</button>
<button type="button" onclick="runAdminAction('red-packet')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#b91c1c;border:1px solid #fdba74;border-radius:6px;cursor:pointer;">🧧 礼包</button>
<button type="button" onclick="runAdminAction('loss-cover')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#15803d;border:1px solid #86efac;border-radius:6px;cursor:pointer;">🎁 买单活动</button>
@if ($canPublicBroadcast)
<button type="button" onclick="runAdminAction('announce-message')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#7c3aed;border:1px solid #c4b5fd;border-radius:6px;cursor:pointer;">📢 公屏</button>
@endif
@if ($canClearScreen)
<button type="button" onclick="runAdminAction('admin-clear')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#dc2626;border:1px solid #fca5a5;border-radius:6px;cursor:pointer;">🧹 清屏</button>
@endif
@if ($canSendRedPacket)
<button type="button" onclick="runAdminAction('red-packet')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#b91c1c;border:1px solid #fdba74;border-radius:6px;cursor:pointer;">🧧 礼包</button>
@endif
@if ($canManageLossCover)
<button type="button" onclick="runAdminAction('loss-cover')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#15803d;border:1px solid #86efac;border-radius:6px;cursor:pointer;">🎁 买单活动</button>
@endif
</div>
@endif
@if ($canTriggerFullscreenEffect)
<div style="font-size:10px;color:#9a3412;padding:10px 2px 6px;">全屏特效</div>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
<button type="button" onclick="selectEffect('fireworks')"
@@ -235,7 +248,7 @@ $welcomeMessages = [
</form>
</div>
@if ($user->user_level >= (int) \App\Models\Sysparam::getValue('superlevel', '100'))
@if ($canManageLossCover)
<div id="baccarat-loss-cover-admin-modal"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:10010; justify-content:center; align-items:center;">
<div
+2 -4
View File
@@ -328,10 +328,8 @@ Route::middleware(['chat.auth'])->group(function () {
Route::post('/command/announce', [AdminCommandController::class, 'announce'])->name('command.announce');
Route::post('/command/clear-screen', [AdminCommandController::class, 'clearScreen'])->name('command.clear_screen');
Route::post('/command/effect', [AdminCommandController::class, 'effect'])->name('command.effect');
Route::middleware('chat.level:super')->group(function () {
Route::post('/command/baccarat-loss-cover', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'store'])->name('command.baccarat_loss_cover.store');
Route::post('/command/baccarat-loss-cover/{event}/close', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'close'])->name('command.baccarat_loss_cover.close');
});
Route::post('/command/baccarat-loss-cover', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'store'])->name('command.baccarat_loss_cover.store');
Route::post('/command/baccarat-loss-cover/{event}/close', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'close'])->name('command.baccarat_loss_cover.close');
// ---- 礼包红包(superlevel 发包 / 所有登录用户可抢)----
Route::post('/command/red-packet/send', [\App\Http\Controllers\RedPacketController::class, 'send'])->name('command.red_packet.send');
@@ -16,9 +16,13 @@ use App\Jobs\SaveMessageJob;
use App\Models\BaccaratLossCoverEvent;
use App\Models\BaccaratLossCoverRecord;
use App\Models\BaccaratRound;
use App\Models\Department;
use App\Models\GameConfig;
use App\Models\Position;
use App\Models\User;
use App\Models\UserPosition;
use App\Services\BaccaratLossCoverService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
@@ -57,7 +61,7 @@ class BaccaratLossCoverControllerTest extends TestCase
{
Queue::fake();
$admin = User::factory()->create(['user_level' => 100]);
$admin = User::factory()->create(['id' => 1, 'user_level' => 100]);
$response = $this->actingAs($admin)->postJson(route('command.baccarat_loss_cover.store'), [
'title' => '你玩游戏我买单',
@@ -76,6 +80,46 @@ class BaccaratLossCoverControllerTest extends TestCase
]);
}
/**
* 验证拥有买单活动权限的职务用户也可以创建活动。
*/
public function test_position_user_with_loss_cover_permission_can_create_event(): void
{
Queue::fake();
$admin = $this->createUserWithPermissions([
PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER,
]);
$response = $this->actingAs($admin)->postJson(route('command.baccarat_loss_cover.store'), [
'title' => '职务买单活动',
'description' => '职务权限测试',
'starts_at' => now()->addMinutes(3)->toDateTimeString(),
'ends_at' => now()->addMinutes(33)->toDateTimeString(),
'claim_deadline_at' => now()->addHours(6)->toDateTimeString(),
]);
$response->assertOk()->assertJson(['ok' => true]);
}
/**
* 验证高等级但无买单活动权限的用户会被拒绝。
*/
public function test_high_level_user_without_loss_cover_permission_cannot_create_event(): void
{
$admin = User::factory()->create(['user_level' => 100]);
$response = $this->actingAs($admin)->postJson(route('command.baccarat_loss_cover.store'), [
'title' => '无权限活动',
'description' => '测试活动',
'starts_at' => now()->addMinutes(5)->toDateTimeString(),
'ends_at' => now()->addMinutes(35)->toDateTimeString(),
'claim_deadline_at' => now()->addDay()->toDateTimeString(),
]);
$response->assertStatus(403);
}
/**
* 验证活动进行中下注会挂到活动并写入用户聚合记录。
*/
@@ -331,4 +375,45 @@ class BaccaratLossCoverControllerTest extends TestCase
'source' => CurrencySource::BACCARAT_LOSS_COVER_CLAIM->value,
]);
}
/**
* 创建带指定聊天室权限的在职职务用户。
*
* @param list<string> $permissions
*/
private function createUserWithPermissions(array $permissions): User
{
$user = User::factory()->create([
'user_level' => 90,
]);
$department = Department::create([
'name' => '买单活动部'.$user->id,
'rank' => 90,
'color' => '#15803d',
'sort_order' => 1,
'description' => '买单活动权限测试',
]);
$position = Position::create([
'department_id' => $department->id,
'name' => '买单活动职务'.$user->id,
'icon' => '🎁',
'rank' => 90,
'level' => 90,
'sort_order' => 1,
'permissions' => $permissions,
]);
UserPosition::create([
'user_id' => $user->id,
'position_id' => $position->id,
'appointed_by_user_id' => null,
'appointed_at' => now(),
'remark' => '买单活动权限测试',
'is_active' => true,
]);
return $user->fresh();
}
}
+137 -6
View File
@@ -9,8 +9,12 @@
namespace Tests\Feature;
use App\Events\MessageSent;
use App\Models\Department;
use App\Models\Position;
use App\Models\Room;
use App\Models\User;
use App\Models\UserPosition;
use App\Support\PositionPermissionRegistry;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -157,6 +161,60 @@ class ChatControllerTest extends TestCase
$response->assertSee('toggleBlockedSystemSender');
}
/**
* 测试无聊天室权限的职务用户看不到顶部管理按钮。
*/
public function test_room_view_hides_admin_menu_for_position_without_room_permissions(): void
{
$room = Room::create(['room_name' => 'nomenu']);
$user = $this->createUserWithPositionPermissions([]);
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertOk();
$response->assertDontSee('🛠 管理', false);
$response->assertDontSee('🪧 设公告', false);
}
/**
* 测试只授予公告权限时,顶部管理菜单仅显示对应按钮。
*/
public function test_room_view_renders_only_granted_room_management_buttons(): void
{
$room = Room::create(['room_name' => 'annmenu']);
$user = $this->createUserWithPositionPermissions([
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
]);
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertOk();
$response->assertSee('🛠 管理', false);
$response->assertSee('🪧 设公告', false);
$response->assertDontSee("runAdminAction('announce-message')", false);
$response->assertDontSee("selectEffect('fireworks')", false);
}
/**
* 测试仅有全屏特效权限时,只显示特效分组。
*/
public function test_room_view_renders_fullscreen_effect_group_only_when_permission_exists(): void
{
$room = Room::create(['room_name' => 'effectmenu']);
$user = $this->createUserWithPositionPermissions([
PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT,
]);
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertOk();
$response->assertSee('🛠 管理', false);
$response->assertSee('全屏特效');
$response->assertSee('🎆 烟花', false);
$response->assertDontSee("runAdminAction('announcement')", false);
$response->assertDontSee("runAdminAction('announce-message')", false);
}
/**
* 测试用户可以发送普通文本消息。
*/
@@ -593,9 +651,9 @@ class ChatControllerTest extends TestCase
/**
* 测试管理员可以设置房间公告。
*/
public function test_can_set_announcement()
public function test_site_owner_can_set_announcement()
{
$user = User::factory()->create(['user_level' => 100]); // superadmin
$user = User::factory()->create(['id' => 1, 'user_level' => 100]);
$room = Room::create(['room_name' => 'test_ann', 'room_owner' => 'someone']);
$response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
@@ -609,12 +667,29 @@ class ChatControllerTest extends TestCase
}
/**
* 测试无权限用户不能设置房间公告。
* 测试拥有公告权限的职务用户可以设置房间公告。
*/
public function test_cannot_set_announcement_without_permission()
public function test_position_user_with_room_announcement_permission_can_set_announcement(): void
{
$user = User::factory()->create(['user_level' => 0]);
$room = Room::create(['room_name' => 'test_ann2', 'room_owner' => 'someone']);
$user = $this->createUserWithPositionPermissions([
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
]);
$room = Room::create(['room_name' => 'test_ann2', 'room_owner' => 'other']);
$response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
'announcement' => 'This is a new test announcement',
]);
$response->assertStatus(200);
}
/**
* 测试房主但无公告权限时也不能设置房间公告。
*/
public function test_room_owner_without_announcement_permission_cannot_set_announcement(): void
{
$user = $this->createUserWithPositionPermissions([]);
$room = Room::create(['room_name' => 'test_ann3', 'room_owner' => $user->username]);
$response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
'announcement' => 'This is a new test announcement',
@@ -622,4 +697,60 @@ class ChatControllerTest extends TestCase
$response->assertStatus(403);
}
/**
* 测试无权限用户不能设置房间公告。
*/
public function test_cannot_set_announcement_without_permission()
{
$user = User::factory()->create(['user_level' => 0]);
$room = Room::create(['room_name' => 'test_ann4', 'room_owner' => 'someone']);
$response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
'announcement' => 'This is a new test announcement',
]);
$response->assertStatus(403);
}
/**
* 创建带指定聊天室权限的在职职务用户。
*
* @param list<string> $permissions
*/
private function createUserWithPositionPermissions(array $permissions): User
{
$user = User::factory()->create([
'user_level' => 70,
]);
$department = Department::create([
'name' => '聊天室测试部门'.$user->id,
'rank' => 70,
'color' => '#1d4ed8',
'sort_order' => 1,
'description' => '聊天室权限测试',
]);
$position = Position::create([
'department_id' => $department->id,
'name' => '聊天室测试职务'.$user->id,
'icon' => '🛡️',
'rank' => 70,
'level' => 70,
'sort_order' => 1,
'permissions' => $permissions,
]);
UserPosition::create([
'user_id' => $user->id,
'position_id' => $position->id,
'appointed_by_user_id' => null,
'appointed_at' => now(),
'remark' => '聊天室权限测试',
'is_active' => true,
]);
return $user->fresh();
}
}
@@ -8,8 +8,12 @@
namespace Tests\Feature\Feature;
use App\Jobs\SaveMessageJob;
use App\Models\Department;
use App\Models\Position;
use App\Models\Room;
use App\Models\User;
use App\Models\UserPosition;
use App\Support\PositionPermissionRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
@@ -17,7 +21,7 @@ use Tests\TestCase;
/**
* 管理员聊天命令功能测试
* 覆盖全屏特效命令的新增特效校验
* 覆盖聊天室顶部管理菜单对应接口的权限校验与关键行为
*/
class AdminCommandControllerTest extends TestCase
{
@@ -38,6 +42,7 @@ class AdminCommandControllerTest extends TestCase
public function test_super_admin_can_trigger_all_new_effect_types(): void
{
$admin = User::factory()->create([
'id' => 1,
'user_level' => 100,
]);
$room = Room::create([
@@ -58,6 +63,116 @@ class AdminCommandControllerTest extends TestCase
}
}
/**
* 测试拥有全屏特效权限的职务用户可以触发特效。
*/
public function test_position_user_with_fullscreen_effect_permission_can_trigger_effect(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT,
]);
$room = Room::create([
'room_name' => '特效权限房',
]);
$response = $this->actingAs($admin)->postJson(route('command.effect'), [
'room_id' => $room->id,
'type' => 'fireworks',
]);
$response->assertOk()->assertJson([
'status' => 'success',
]);
}
/**
* 测试高等级但无全屏特效权限的用户会被拒绝。
*/
public function test_high_level_user_without_fullscreen_effect_permission_cannot_trigger_effect(): void
{
$admin = User::factory()->create([
'user_level' => 100,
]);
$room = Room::create([
'room_name' => '无权限特效房',
]);
$response = $this->actingAs($admin)->postJson(route('command.effect'), [
'room_id' => $room->id,
'type' => 'fireworks',
]);
$response->assertStatus(403);
}
/**
* 测试拥有公屏讲话权限的职务用户可以发送公屏公告。
*/
public function test_position_user_with_public_broadcast_permission_can_announce(): void
{
Queue::fake();
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST,
]);
$admin->load('activePosition.position.department');
$room = Room::create([
'room_name' => '公屏权限房',
]);
$response = $this->actingAs($admin)->postJson(route('command.announce'), [
'room_id' => $room->id,
'content' => '今晚八点准时集合',
]);
$response->assertOk()->assertJson([
'status' => 'success',
]);
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$publicMessage = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['from_user'] ?? null) === '系统公告'
&& str_contains((string) ($item['content'] ?? ''), '今晚八点准时集合'));
$this->assertNotNull($publicMessage);
$this->assertStringContainsString(
$admin->activePosition->position->department->name.$admin->activePosition->position->name,
(string) $publicMessage['content']
);
$this->assertStringContainsString(
"{$admin->username}</b> 发布公告",
(string) $publicMessage['content']
);
}
/**
* 测试拥有全员清屏权限的职务用户可以清空房间普通消息。
*/
public function test_position_user_with_clear_screen_permission_can_clear_room_messages(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_CLEAR_SCREEN,
]);
$room = Room::create([
'room_name' => '清屏权限房',
]);
Redis::rpush("room:{$room->id}:messages", json_encode([
'id' => 1,
'content' => '待清除的消息',
], JSON_UNESCAPED_UNICODE));
$response = $this->actingAs($admin)->postJson(route('command.clear_screen'), [
'room_id' => $room->id,
]);
$response->assertOk()->assertJson([
'status' => 'success',
]);
$this->assertSame([], Redis::lrange("room:{$room->id}:messages", 0, -1));
}
/**
* 测试管理操作中的奖励金币会给接收方写入带右下角提示的私聊消息。
*/
@@ -246,6 +361,47 @@ class AdminCommandControllerTest extends TestCase
return [$admin, $target, $room];
}
/**
* 创建带指定聊天室权限的职务管理员。
*
* @param list<string> $permissions
*/
private function createPositionedManager(array $permissions): User
{
$user = User::factory()->create([
'user_level' => 90,
]);
$department = Department::create([
'name' => '命令权限部'.$user->id,
'rank' => 90,
'color' => '#7c3aed',
'sort_order' => 1,
'description' => '聊天室命令权限测试',
]);
$position = Position::create([
'department_id' => $department->id,
'name' => '命令权限职务'.$user->id,
'icon' => '🛠️',
'rank' => 90,
'level' => 90,
'sort_order' => 1,
'permissions' => $permissions,
]);
UserPosition::create([
'user_id' => $user->id,
'position_id' => $position->id,
'appointed_by_user_id' => null,
'appointed_at' => now(),
'remark' => '聊天室命令权限测试',
'is_active' => true,
]);
return $user->fresh();
}
/**
* 从房间消息缓存中定位目标用户收到的系统私聊提示。
*
@@ -0,0 +1,186 @@
<?php
/**
* 文件功能:后台职务权限管理功能测试
*
* 覆盖职务权限字段的保存、更新、校验与页面展示,
* 确保后台配置可以稳定驱动聊天室顶部管理菜单。
*/
namespace Tests\Feature\Feature;
use App\Models\Department;
use App\Models\Position;
use App\Models\User;
use App\Support\PositionPermissionRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 类功能:验证后台职务权限管理页的关键行为。
*/
class AdminPositionPermissionTest extends TestCase
{
use RefreshDatabase;
/**
* 验证站长可以创建带聊天室权限的职务。
*/
public function test_site_owner_can_store_position_with_room_permissions(): void
{
$owner = $this->createSiteOwner();
$department = $this->createDepartment();
$response = $this->actingAs($owner)->post(route('admin.positions.store'), [
'department_id' => $department->id,
'name' => '值班主持',
'icon' => '🎙️',
'rank' => 66,
'level' => 66,
'max_persons' => 2,
'max_reward' => 100,
'daily_reward_limit' => 300,
'recipient_daily_limit' => 2,
'sort_order' => 8,
'permissions' => [
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST,
],
]);
$response->assertRedirect(route('admin.positions.index'));
$position = Position::query()->where('name', '值班主持')->firstOrFail();
$this->assertSame([
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST,
], $position->permissions);
}
/**
* 验证非法权限码会被后台校验拒绝。
*/
public function test_invalid_permission_code_is_rejected_when_storing_position(): void
{
$owner = $this->createSiteOwner();
$department = $this->createDepartment();
$response = $this->from(route('admin.positions.index'))->actingAs($owner)->post(route('admin.positions.store'), [
'department_id' => $department->id,
'name' => '巡查主持',
'icon' => '🛰️',
'rank' => 55,
'level' => 55,
'max_persons' => 1,
'sort_order' => 2,
'permissions' => [
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
'room.invalid_permission',
],
]);
$response->assertRedirect(route('admin.positions.index'));
$response->assertSessionHasErrors('permissions.1');
}
/**
* 验证站长可以更新职务的聊天室权限配置。
*/
public function test_site_owner_can_update_position_permissions(): void
{
$owner = $this->createSiteOwner();
$department = $this->createDepartment();
$position = Position::create([
'department_id' => $department->id,
'name' => '公告员',
'icon' => '📣',
'rank' => 60,
'level' => 60,
'sort_order' => 1,
'permissions' => [
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
],
]);
$response = $this->actingAs($owner)->put(route('admin.positions.update', $position), [
'department_id' => $department->id,
'name' => '公告员',
'icon' => '📣',
'rank' => 60,
'level' => 60,
'max_persons' => null,
'max_reward' => null,
'daily_reward_limit' => null,
'recipient_daily_limit' => null,
'sort_order' => 1,
'permissions' => [
PositionPermissionRegistry::ROOM_RED_PACKET,
PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT,
],
]);
$response->assertRedirect(route('admin.positions.index'));
$position->refresh();
$this->assertSame([
PositionPermissionRegistry::ROOM_RED_PACKET,
PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT,
], $position->permissions);
}
/**
* 验证职务管理页面会渲染权限配置与摘要文案。
*/
public function test_positions_index_renders_permission_form_and_summary(): void
{
$owner = $this->createSiteOwner();
$department = $this->createDepartment();
Position::create([
'department_id' => $department->id,
'name' => '活动主持',
'icon' => '🎁',
'rank' => 70,
'level' => 70,
'sort_order' => 1,
'permissions' => [
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
PositionPermissionRegistry::ROOM_RED_PACKET,
],
]);
$response = $this->actingAs($owner)->get(route('admin.positions.index'));
$response->assertOk();
$response->assertSee('权限管理');
$response->assertSee('设置公告');
$response->assertSee('礼包红包');
}
/**
* 创建站长账号。
*/
private function createSiteOwner(): User
{
return User::factory()->create([
'id' => 1,
'user_level' => 100,
]);
}
/**
* 创建测试用部门。
*/
private function createDepartment(): Department
{
return Department::create([
'name' => '测试部门',
'rank' => 90,
'color' => '#334155',
'sort_order' => 1,
'description' => '后台权限测试部门',
]);
}
}
+96 -6
View File
@@ -3,9 +3,13 @@
namespace Tests\Feature;
use App\Events\RedPacketClaimed;
use App\Models\Department;
use App\Models\Position;
use App\Models\RedPacketEnvelope;
use App\Models\Sysparam;
use App\Models\User;
use App\Models\UserPosition;
use App\Support\PositionPermissionRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
@@ -44,12 +48,27 @@ class RedPacketControllerTest extends TestCase
$response->assertJson(['status' => 'error']);
}
/**
* 方法功能:验证高等级但无职务权限的用户仍不能发送礼包。
*/
public function test_high_level_user_without_red_packet_permission_cannot_send_red_packet(): void
{
$user = User::factory()->create(['user_level' => 100]);
$response = $this->actingAs($user)->postJson(route('command.red_packet.send'), [
'room_id' => 1,
'type' => 'gold',
]);
$response->assertStatus(403);
}
/**
* 方法功能:验证站长可以成功发出礼包并写入 Redis 拆包结果。
*/
public function test_superadmin_can_send_red_packet(): void
{
$admin = User::factory()->create(['user_level' => 100]);
$admin = $this->createSiteOwner();
$response = $this->actingAs($admin)->postJson(route('command.red_packet.send'), [
'room_id' => 1,
@@ -71,12 +90,31 @@ class RedPacketControllerTest extends TestCase
$this->assertEquals(10, Redis::llen("red_packet:{$envelope->id}:amounts"));
}
/**
* 方法功能:验证拥有礼包权限的职务用户可以发礼包。
*/
public function test_position_user_with_red_packet_permission_can_send_red_packet(): void
{
$user = $this->createUserWithPermissions([
PositionPermissionRegistry::ROOM_RED_PACKET,
]);
$response = $this->actingAs($user)->postJson(route('command.red_packet.send'), [
'room_id' => 1,
'type' => 'exp',
]);
$response->assertOk()->assertJson([
'status' => 'success',
]);
}
/**
* 方法功能:验证同一房间内不可重复发送未结束的礼包。
*/
public function test_cannot_send_multiple_active_packets_in_same_room(): void
{
$admin = User::factory()->create(['user_level' => 100]);
$admin = $this->createSiteOwner();
$this->actingAs($admin)->postJson(route('command.red_packet.send'), [
'room_id' => 1,
@@ -96,7 +134,7 @@ class RedPacketControllerTest extends TestCase
*/
public function test_user_can_claim_red_packet(): void
{
$admin = User::factory()->create(['user_level' => 100]);
$admin = $this->createSiteOwner();
$user = User::factory()->create(['jjb' => 100]);
// Send packet
@@ -131,7 +169,7 @@ class RedPacketControllerTest extends TestCase
{
Event::fake([RedPacketClaimed::class]);
$admin = User::factory()->create(['user_level' => 100]);
$admin = $this->createSiteOwner();
$user = User::factory()->create(['jjb' => 100]);
$this->actingAs($admin)->postJson(route('command.red_packet.send'), [
@@ -165,7 +203,7 @@ class RedPacketControllerTest extends TestCase
*/
public function test_user_cannot_claim_same_packet_twice(): void
{
$admin = User::factory()->create(['user_level' => 100]);
$admin = $this->createSiteOwner();
$user = User::factory()->create();
$this->actingAs($admin)->postJson(route('command.red_packet.send'), [
@@ -194,7 +232,7 @@ class RedPacketControllerTest extends TestCase
*/
public function test_can_check_packet_status(): void
{
$admin = User::factory()->create(['user_level' => 100]);
$admin = $this->createSiteOwner();
$user = User::factory()->create();
$this->actingAs($admin)->postJson(route('command.red_packet.send'), [
@@ -224,4 +262,56 @@ class RedPacketControllerTest extends TestCase
'has_claimed' => true,
]);
}
/**
* 创建站长账号。
*/
private function createSiteOwner(): User
{
return User::factory()->create([
'id' => 1,
'user_level' => 100,
]);
}
/**
* 创建带指定聊天室权限的在职职务用户。
*
* @param list<string> $permissions
*/
private function createUserWithPermissions(array $permissions): User
{
$user = User::factory()->create([
'user_level' => 80,
]);
$department = Department::create([
'name' => '红包权限部'.$user->id,
'rank' => 80,
'color' => '#dc2626',
'sort_order' => 1,
'description' => '红包权限测试',
]);
$position = Position::create([
'department_id' => $department->id,
'name' => '红包权限职务'.$user->id,
'icon' => '🧧',
'rank' => 80,
'level' => 80,
'sort_order' => 1,
'permissions' => $permissions,
]);
UserPosition::create([
'user_id' => $user->id,
'position_id' => $position->id,
'appointed_by_user_id' => null,
'appointed_at' => now(),
'remark' => '红包权限测试',
'is_active' => true,
]);
return $user->fresh();
}
}