withShared($tenantId); // 권한 기반 필터링 적용 if (! empty($allowedMenuIds)) { $q->whereIn('id', $allowedMenuIds); } else { // 권한이 없으면 빈 결과 반환 $q->whereRaw('1 = 0'); } if (array_key_exists('parent_id', $params)) { $q->where('parent_id', $params['parent_id']); } // is_active 기본값 true (파라미터 없으면 활성화된 메뉴만) $q->where('is_active', (int) ($params['is_active'] ?? 1)); if (array_key_exists('hidden', $params)) { $q->where('hidden', (int) $params['hidden']); } $q->orderBy('parent_id')->orderBy('sort_order'); // Builder 그대로 전달해야 쿼리로그/표준응답 형식 유지 return $q->get(); } /** * 사용자가 접근 가능한 메뉴 ID 목록 조회 (mng UserPermissionService와 동일한 로직) */ protected static function getAllowedMenuIds(?int $userId, ?int $tenantId): array { if (! $userId || ! $tenantId) { return []; } $now = now(); // 1. 역할 권한 (user_roles 테이블) $rolePermissions = DB::table('user_roles') ->join('role_has_permissions', 'user_roles.role_id', '=', 'role_has_permissions.role_id') ->join('permissions', 'role_has_permissions.permission_id', '=', 'permissions.id') ->where('user_roles.user_id', $userId) ->where('user_roles.tenant_id', $tenantId) ->whereNull('user_roles.deleted_at') ->where('permissions.name', 'like', 'menu:%.view') ->pluck('permissions.name') ->toArray(); // 2. 부서 권한 (permission_overrides에서 Department 타입, effect=1) $deptPermissions = DB::table('department_user') ->join('permission_overrides', function ($join) use ($now) { $join->on('permission_overrides.model_id', '=', 'department_user.department_id') ->where('permission_overrides.model_type', '=', Department::class) ->whereNull('permission_overrides.deleted_at') ->where('permission_overrides.effect', 1) ->where(function ($q) use ($now) { $q->whereNull('permission_overrides.effective_from') ->orWhere('permission_overrides.effective_from', '<=', $now); }) ->where(function ($q) use ($now) { $q->whereNull('permission_overrides.effective_to') ->orWhere('permission_overrides.effective_to', '>=', $now); }); }) ->join('permissions', 'permissions.id', '=', 'permission_overrides.permission_id') ->whereNull('department_user.deleted_at') ->where('department_user.user_id', $userId) ->where('department_user.tenant_id', $tenantId) ->where('permission_overrides.tenant_id', $tenantId) ->where('permissions.name', 'like', 'menu:%.view') ->pluck('permissions.name') ->toArray(); // 3. 개인 ALLOW 권한 (permission_overrides에서 User 타입, effect=1) // Note: mng는 App\Models\User를 사용하므로 하드코딩 $personalAllows = DB::table('permission_overrides') ->join('permissions', 'permissions.id', '=', 'permission_overrides.permission_id') ->where('permission_overrides.model_type', 'App\\Models\\User') ->where('permission_overrides.model_id', $userId) ->where('permission_overrides.tenant_id', $tenantId) ->where('permission_overrides.effect', 1) ->whereNull('permission_overrides.deleted_at') ->where(function ($q) use ($now) { $q->whereNull('permission_overrides.effective_from') ->orWhere('permission_overrides.effective_from', '<=', $now); }) ->where(function ($q) use ($now) { $q->whereNull('permission_overrides.effective_to') ->orWhere('permission_overrides.effective_to', '>=', $now); }) ->where('permissions.name', 'like', 'menu:%.view') ->pluck('permissions.name') ->toArray(); // 4. 개인 DENY 권한 (permission_overrides에서 User 타입, effect=0) // Note: mng는 App\Models\User를 사용하므로 하드코딩 $personalDenies = DB::table('permission_overrides') ->join('permissions', 'permissions.id', '=', 'permission_overrides.permission_id') ->where('permission_overrides.model_type', 'App\\Models\\User') ->where('permission_overrides.model_id', $userId) ->where('permission_overrides.tenant_id', $tenantId) ->where('permission_overrides.effect', 0) ->whereNull('permission_overrides.deleted_at') ->where(function ($q) use ($now) { $q->whereNull('permission_overrides.effective_from') ->orWhere('permission_overrides.effective_from', '<=', $now); }) ->where(function ($q) use ($now) { $q->whereNull('permission_overrides.effective_to') ->orWhere('permission_overrides.effective_to', '>=', $now); }) ->where('permissions.name', 'like', 'menu:%.view') ->pluck('permissions.name') ->toArray(); // 5. 최종 권한 계산: (역할 OR 부서 OR 개인ALLOW) - 개인DENY $allAllowed = array_unique(array_merge($rolePermissions, $deptPermissions, $personalAllows)); $effectivePermissions = array_diff($allAllowed, $personalDenies); // 메뉴 ID 추출 $allowedMenuIds = []; foreach ($effectivePermissions as $permName) { if (preg_match('/^menu:(\d+)\.view$/', $permName, $matches)) { $allowedMenuIds[] = (int) $matches[1]; } } return $allowedMenuIds; } /** * 메뉴 단건 조회 */ public static function show(array $params) { $id = (int) ($params['id'] ?? 0); $tenantId = self::tenantId(); if (! $id) { return ['error' => 'id가 필요합니다.', 'code' => 400]; } $res = Menu::withShared($tenantId)->find($id); if (empty($res['data'])) { return ['error' => 'Menu not found', 'code' => 404]; } return $res; } /** * 메뉴 생성 */ public static function store(array $params) { $tenantId = self::tenantId(); $userId = self::actorId(); $v = Validator::make($params, [ 'parent_id' => ['nullable', 'integer'], 'name' => ['required', 'string', 'max:100'], 'url' => ['nullable', 'string', 'max:255'], 'is_active' => ['nullable', 'boolean'], 'sort_order' => ['nullable', 'integer'], 'hidden' => ['nullable', 'boolean'], 'is_external' => ['nullable', 'boolean'], 'external_url' => ['nullable', 'string', 'max:255'], 'icon' => ['nullable', 'string', 'max:50'], ]); if ($v->fails()) { return ['error' => $v->errors()->first(), 'code' => 422]; } $data = $v->validated(); $menu = new Menu; $menu->tenant_id = $tenantId; $menu->parent_id = $data['parent_id'] ?? null; $menu->name = $data['name']; $menu->url = $data['url'] ?? null; $menu->is_active = (int) ($data['is_active'] ?? 1); $menu->sort_order = (int) ($data['sort_order'] ?? 0); $menu->hidden = (int) ($data['hidden'] ?? 0); $menu->is_external = (int) ($data['is_external'] ?? 0); $menu->external_url = $data['external_url'] ?? null; $menu->icon = $data['icon'] ?? null; $menu->created_by = $userId; $menu->updated_by = $userId; $menu->save(); // 생성 결과를 그대로 전달 return $menu->fresh(); } /** * 메뉴 수정 * - global_menu_id가 있는 테넌트 메뉴 수정 시 is_customized = true 자동 설정 */ public static function update(array $params) { $id = (int) ($params['id'] ?? 0); $tenantId = self::tenantId(); $userId = self::actorId(); if (! $id) { return ['error' => 'id가 필요합니다.', 'code' => 400]; } $v = Validator::make($params, [ 'parent_id' => ['nullable', 'integer'], 'name' => ['nullable', 'string', 'max:100'], 'url' => ['nullable', 'string', 'max:255'], 'is_active' => ['nullable', 'boolean'], 'sort_order' => ['nullable', 'integer'], 'hidden' => ['nullable', 'boolean'], 'is_external' => ['nullable', 'boolean'], 'external_url' => ['nullable', 'string', 'max:255'], 'icon' => ['nullable', 'string', 'max:50'], ]); if ($v->fails()) { return ['error' => $v->errors()->first(), 'code' => 422]; } $data = $v->validated(); $menu = Menu::withShared($tenantId)->where('id', $id)->first(); if (! $menu) { return ['error' => 'Menu not found', 'code' => 404]; } $update = Arr::only($data, [ 'parent_id', 'name', 'url', 'is_active', 'sort_order', 'hidden', 'is_external', 'external_url', 'icon', ]); $update = array_filter($update, fn ($v) => ! is_null($v)); if (empty($update)) { return ['error' => '수정할 데이터가 없습니다.', 'code' => 400]; } // 글로벌 메뉴에서 복제된 테넌트 메뉴 수정 시 커스터마이징 플래그 설정 if ($menu->global_menu_id && ! $menu->is_customized) { $update['is_customized'] = true; } $update['updated_by'] = $userId; $menu->fill($update)->save(); return $menu->fresh(); } /** * 메뉴 삭제(소프트) */ public static function destroy(array $params) { $id = (int) ($params['id'] ?? 0); $tenantId = self::tenantId(); $userId = self::actorId(); if (! $id) { return ['error' => 'id가 필요합니다.', 'code' => 400]; } $menu = Menu::withShared($tenantId)->where('id', $id)->first(); if (! $menu) { return ['error' => 'Menu not found', 'code' => 404]; } $menu->deleted_by = $userId; $menu->save(); $menu->delete(); return 'success'; } /** * 정렬 일괄 변경 * $params = [ ['id'=>10, 'sort_order'=>1], ... ] */ public static function reorder(array $params) { if (! is_array($params) || empty($params)) { return ['error' => '유효한 정렬 목록이 필요합니다.', 'code' => 422]; } $tenantId = self::tenantId(); DB::transaction(function () use ($params, $tenantId) { foreach ($params as $it) { if (! isset($it['id'], $it['sort_order'])) { continue; } $menu = Menu::withShared($tenantId)->find((int) $it['id']); if ($menu) { $menu->sort_order = (int) $it['sort_order']; $menu->save(); } } }); return 'success'; } /** * 상태 토글: is_active / hidden / is_external */ public static function toggle(array $params) { $id = (int) ($params['id'] ?? 0); $tenantId = self::tenantId(); $userId = self::actorId(); if (! $id) { return ['error' => 'id가 필요합니다.', 'code' => 400]; } $payload = array_filter([ 'is_active' => array_key_exists('is_active', $params) ? (int) $params['is_active'] : null, 'hidden' => array_key_exists('hidden', $params) ? (int) $params['hidden'] : null, 'is_external' => array_key_exists('is_external', $params) ? (int) $params['is_external'] : null, ], fn ($v) => ! is_null($v)); if (empty($payload)) { return ['error' => '변경할 필드가 없습니다.', 'code' => 422]; } $menu = Menu::withShared($tenantId)->find($id); if (! $menu) { return ['error' => 'Menu not found', 'code' => 404]; } $payload['updated_by'] = $userId; $menu->fill($payload)->save(); return $menu->fresh(); } /** * 삭제된 메뉴 복원 */ public static function restore(array $params) { $id = (int) ($params['id'] ?? 0); $tenantId = self::tenantId(); $userId = self::actorId(); if (! $id) { return ['error' => 'id가 필요합니다.', 'code' => 400]; } // 삭제된 메뉴 포함하여 조회 $menu = Menu::withTrashed() ->withoutGlobalScopes() ->where(function ($q) use ($tenantId) { $q->whereNull('tenant_id') ->orWhere('tenant_id', $tenantId); }) ->where('id', $id) ->first(); if (! $menu) { return ['error' => 'Menu not found', 'code' => 404]; } if (! $menu->trashed()) { return ['error' => '삭제되지 않은 메뉴입니다.', 'code' => 400]; } $menu->restore(); $menu->deleted_by = null; $menu->updated_by = $userId; $menu->save(); return $menu->fresh(); } /** * 삭제된 메뉴 목록 조회 */ public static function trashedList(array $params = []) { $tenantId = self::tenantId(); return Menu::onlyTrashed() ->withoutGlobalScopes() ->where(function ($q) use ($tenantId) { $q->whereNull('tenant_id') ->orWhere('tenant_id', $tenantId); }) ->orderBy('deleted_at', 'desc') ->get(); } // ========================================================================= // 게시판 메뉴 연동 메서드 (테넌트 게시판 전용) // ========================================================================= /** * 테넌트 게시판용 메뉴 생성 * * @param string $boardCode 게시판 코드 * @param string $boardName 게시판 이름 * @param int $tenantId 테넌트 ID * @return Menu|null 생성된 메뉴 또는 null (중복 시) */ public static function createMenuForBoard(string $boardCode, string $boardName, int $tenantId): ?Menu { $url = '/boards/'.$boardCode; $userId = self::actorId(); // 중복 체크 if (Menu::where('url', $url)->where('tenant_id', $tenantId)->exists()) { return null; } // 부모 메뉴 찾기 $parentId = self::findParentMenuForBoard($tenantId); // 정렬 순서 계산 $maxOrder = Menu::where('tenant_id', $tenantId) ->where('parent_id', $parentId) ->max('sort_order') ?? 0; return Menu::create([ 'tenant_id' => $tenantId, 'parent_id' => $parentId, 'name' => $boardName, 'url' => $url, 'icon' => 'document-text', 'sort_order' => $maxOrder + 1, 'is_active' => true, 'hidden' => false, 'is_external' => false, 'created_by' => $userId, 'updated_by' => $userId, ]); } /** * 테넌트 게시판 메뉴의 부모 메뉴 ID 찾기 * 우선순위: /board → /boards → /customer-center → null (최상위) * * @param int $tenantId 테넌트 ID * @return int|null 부모 메뉴 ID */ protected static function findParentMenuForBoard(int $tenantId): ?int { $priorityUrls = ['/board', '/boards', '/customer-center']; foreach ($priorityUrls as $url) { $menu = Menu::where('tenant_id', $tenantId) ->where('url', $url) ->first(); if ($menu) { return $menu->id; } } return null; } /** * 테넌트 게시판용 메뉴 수정 (코드/이름 변경 시) * * @param string $oldCode 기존 게시판 코드 * @param string $newCode 새 게시판 코드 * @param string $newName 새 게시판 이름 * @param int $tenantId 테넌트 ID * @return bool 수정 성공 여부 */ public static function updateMenuForBoard(string $oldCode, string $newCode, string $newName, int $tenantId): bool { $oldUrl = '/boards/'.$oldCode; $newUrl = '/boards/'.$newCode; $menu = Menu::where('url', $oldUrl) ->where('tenant_id', $tenantId) ->first(); if (! $menu) { return false; } $menu->url = $newUrl; $menu->name = $newName; $menu->updated_by = self::actorId(); $menu->save(); return true; } /** * 테넌트 게시판용 메뉴 삭제 (Soft Delete) * * @param string $boardCode 게시판 코드 * @param int $tenantId 테넌트 ID * @param bool $forceDelete 영구 삭제 여부 * @return bool 삭제 성공 여부 */ public static function deleteMenuForBoard(string $boardCode, int $tenantId, bool $forceDelete = false): bool { $url = '/boards/'.$boardCode; $query = $forceDelete ? Menu::withTrashed()->where('url', $url)->where('tenant_id', $tenantId) : Menu::where('url', $url)->where('tenant_id', $tenantId); $menu = $query->first(); if (! $menu) { return false; } if (! $forceDelete) { $menu->deleted_by = self::actorId(); $menu->save(); } return $forceDelete ? $menu->forceDelete() : $menu->delete(); } /** * 테넌트 게시판용 메뉴 복원 * * @param string $boardCode 게시판 코드 * @param string $boardName 게시판 이름 * @param int $tenantId 테넌트 ID * @return bool 복원 성공 여부 */ public static function restoreMenuForBoard(string $boardCode, string $boardName, int $tenantId): bool { $url = '/boards/'.$boardCode; // 1. soft-deleted 메뉴 확인 $trashedMenu = Menu::onlyTrashed() ->where('url', $url) ->where('tenant_id', $tenantId) ->first(); if ($trashedMenu) { $trashedMenu->restore(); $trashedMenu->deleted_by = null; $trashedMenu->updated_by = self::actorId(); $trashedMenu->save(); return true; } // 2. 이미 활성 메뉴가 있는지 확인 if (Menu::where('url', $url)->where('tenant_id', $tenantId)->exists()) { return true; // 이미 존재하면 성공으로 처리 } // 3. 둘 다 없으면 새로 생성 $newMenu = self::createMenuForBoard($boardCode, $boardName, $tenantId); return $newMenu !== null; } }