diff --git a/plans/mng-menu-system-plan.md b/plans/mng-menu-system-plan.md index 1994d66..3ea6f86 100644 --- a/plans/mng-menu-system-plan.md +++ b/plans/mng-menu-system-plan.md @@ -1,6 +1,7 @@ # MNG 메뉴 시스템: DB 기반 동적 메뉴 전환 계획 > 작성일: 2025-12-16 +> 수정일: 2025-12-16 (Laravel 12 미들웨어 등록 방식, 확장 테이블 설계 반영) > 목적: mng 사이드바를 DB 기반으로 전환하여 직원별 권한에 따라 메뉴 동적 표시 > 선택: **Option A - DB 메뉴 기반** @@ -30,7 +31,7 @@ ┌─────────────────────────────────────────────────────────────────┐ │ 목표 mng 메뉴 시스템 │ ├─────────────────────────────────────────────────────────────────┤ -│ DB (menus 테이블, tenant_id=1) │ +│ DB (menus + menu_mng_extensions 테이블, tenant_id=1) │ │ ├── 일반 메뉴 (역할/부서/개인 권한으로 제어) │ │ ├── 개발도구 메뉴 (슈퍼관리자 전용) │ │ └── R&D Labs 메뉴 (슈퍼관리자 전용) │ @@ -42,7 +43,7 @@ ### 1.3 DB 테이블 구조 -**menus 테이블** (기존) +**menus 테이블** (기존 - 수정하지 않음) ``` id, tenant_id, parent_id, global_menu_id name, url, icon, sort_order @@ -50,6 +51,30 @@ is_active, hidden, is_customized, is_external, external_url created_by, updated_by, deleted_by, created_at, updated_at, deleted_at ``` +> ⚠️ **중요**: menus 테이블은 API, React 등 28개 이상 파일에서 사용 중이므로 직접 수정하지 않음 + +**menu_mng_extensions 테이블** (신규 - mng 전용 확장) +```sql +CREATE TABLE menu_mng_extensions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + menu_id BIGINT UNSIGNED NOT NULL, -- menus 테이블 FK + menu_type ENUM('normal', 'dev_tool', 'lab') DEFAULT 'normal', + route_name VARCHAR(100) NULL, -- Laravel 라우트 이름 + section ENUM('main', 'dev_tools', 'labs') DEFAULT 'main', + lab_tab CHAR(1) NULL, -- R&D Labs용: 's', 'a', 'm' + blade_component VARCHAR(100) NULL, -- 커스텀 Blade 컴포넌트 + css_class VARCHAR(100) NULL, -- 추가 CSS 클래스 + requires_super_admin BOOLEAN DEFAULT FALSE, -- 슈퍼관리자 전용 + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + FOREIGN KEY (menu_id) REFERENCES menus(id) ON DELETE CASCADE, + INDEX idx_menu_id (menu_id), + INDEX idx_section (section), + INDEX idx_menu_type (menu_type) +); +``` + **permissions 테이블** (Spatie) ``` id, tenant_id, name, guard_name, created_at, updated_at @@ -93,24 +118,99 @@ menu:{menu_id}.delete # 삭제 권한 ### Phase 1: DB 스키마 및 시딩 (1-2일) -#### 3.1.1 mng 메뉴용 컬럼 추가 (선택적) +#### 3.1.1 확장 테이블 마이그레이션 ```php -// 마이그레이션: add_mng_flag_to_menus_table -Schema::table('menus', function (Blueprint $table) { - $table->string('menu_type', 20)->default('normal') - ->comment('메뉴 유형: normal, dev_tool, lab') - ->after('external_url'); - $table->string('route_name', 100)->nullable() - ->comment('Laravel 라우트 이름') - ->after('menu_type'); -}); +// database/migrations/xxxx_create_menu_mng_extensions_table.php +id(); + $table->foreignId('menu_id') + ->constrained('menus') + ->onDelete('cascade'); + $table->enum('menu_type', ['normal', 'dev_tool', 'lab'])->default('normal'); + $table->string('route_name', 100)->nullable() + ->comment('Laravel 라우트 이름'); + $table->enum('section', ['main', 'dev_tools', 'labs'])->default('main'); + $table->char('lab_tab', 1)->nullable() + ->comment('R&D Labs용: s, a, m'); + $table->string('blade_component', 100)->nullable() + ->comment('커스텀 Blade 컴포넌트'); + $table->string('css_class', 100)->nullable() + ->comment('추가 CSS 클래스'); + $table->boolean('requires_super_admin')->default(false) + ->comment('슈퍼관리자 전용 여부'); + $table->timestamps(); + + $table->index('menu_id'); + $table->index('section'); + $table->index('menu_type'); + }); + } + + public function down(): void + { + Schema::dropIfExists('menu_mng_extensions'); + } +}; ``` -#### 3.1.2 mng 메뉴 시더 생성 +#### 3.1.2 확장 테이블 모델 + +```php +// app/Models/MenuMngExtension.php + 'boolean', + ]; + + public function menu(): BelongsTo + { + return $this->belongsTo(Menu::class); + } +} +``` + +#### 3.1.3 mng 메뉴 시더 생성 ```php // database/seeders/MngMenuSeeder.php + '대시보드', 'url' => '/dashboard', 'icon' => 'home', 'route_name' => 'dashboard', 'menu_type' => 'normal'], + [ + 'name' => '대시보드', + 'url' => '/dashboard', + 'icon' => 'home', + 'extension' => [ + 'route_name' => 'dashboard', + 'menu_type' => 'normal', + 'section' => 'main', + ], + ], // 그룹: 프로젝트 관리 - ['name' => '프로젝트 관리', 'url' => null, 'icon' => 'folder', 'menu_type' => 'normal', 'children' => [ - ['name' => '프로젝트 대시보드', 'url' => '/project-management', 'route_name' => 'pm.index'], - ['name' => '프로젝트', 'url' => '/project-management/projects', 'route_name' => 'pm.projects.index'], - ['name' => '일일 스크럼', 'url' => '/daily-logs', 'route_name' => 'daily-logs.index'], - ]], + [ + 'name' => '프로젝트 관리', + 'url' => null, + 'icon' => 'folder', + 'extension' => ['menu_type' => 'normal', 'section' => 'main'], + 'children' => [ + [ + 'name' => '프로젝트 대시보드', + 'url' => '/project-management', + 'extension' => ['route_name' => 'pm.index'], + ], + [ + 'name' => '프로젝트', + 'url' => '/project-management/projects', + 'extension' => ['route_name' => 'pm.projects.index'], + ], + [ + 'name' => '일일 스크럼', + 'url' => '/daily-logs', + 'extension' => ['route_name' => 'daily-logs.index'], + ], + ], + ], // ... 기타 메뉴 그룹 // 개발도구 (슈퍼관리자 전용) - ['name' => '개발 도구', 'url' => null, 'icon' => 'cog', 'menu_type' => 'dev_tool', 'children' => [ - ['name' => 'API 플로우 테스터', 'url' => '/dev-tools/flow-tester', 'route_name' => 'dev-tools.flow-tester.index'], - ['name' => 'API 요청 로그', 'url' => '/dev-tools/api-logs', 'route_name' => 'dev-tools.api-logs.index'], - ]], + [ + 'name' => '개발 도구', + 'url' => null, + 'icon' => 'cog', + 'extension' => [ + 'menu_type' => 'dev_tool', + 'section' => 'dev_tools', + 'requires_super_admin' => true, + ], + 'children' => [ + [ + 'name' => 'API 플로우 테스터', + 'url' => '/dev-tools/flow-tester', + 'extension' => [ + 'route_name' => 'dev-tools.flow-tester.index', + 'requires_super_admin' => true, + ], + ], + [ + 'name' => 'API 요청 로그', + 'url' => '/dev-tools/api-logs', + 'extension' => [ + 'route_name' => 'dev-tools.api-logs.index', + 'requires_super_admin' => true, + ], + ], + ], + ], // R&D Labs (슈퍼관리자 전용) - ['name' => 'R&D Labs', 'url' => null, 'icon' => 'beaker', 'menu_type' => 'lab', 'children' => [ - // S, A, M 탭 구조 - ]], + [ + 'name' => 'R&D Labs', + 'url' => null, + 'icon' => 'beaker', + 'extension' => [ + 'menu_type' => 'lab', + 'section' => 'labs', + 'requires_super_admin' => true, + ], + 'children' => [ + // S, A, M 탭 구조 + ], + ], ]; $this->seedMenus($tenantId, $menus); } + + private function seedMenus(int $tenantId, array $menus, ?int $parentId = null): void + { + foreach ($menus as $index => $menuData) { + $extension = $menuData['extension'] ?? []; + $children = $menuData['children'] ?? []; + unset($menuData['extension'], $menuData['children']); + + // 메뉴 생성 + $menu = Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentId, + 'name' => $menuData['name'], + 'url' => $menuData['url'], + 'icon' => $menuData['icon'] ?? null, + 'sort_order' => $index, + 'is_active' => true, + ]); + + // 확장 데이터 생성 + if (!empty($extension)) { + MenuMngExtension::create([ + 'menu_id' => $menu->id, + ...$extension, + ]); + } + + // 자식 메뉴 재귀 처리 + if (!empty($children)) { + $this->seedMenus($tenantId, $children, $menu->id); + } + } + } } ``` @@ -153,6 +347,15 @@ class MngMenuSeeder extends Seeder ```php // app/Services/SidebarMenuService.php +user(); $tenantId = session('selected_tenant_id', 1); - // 1. 테넌트의 모든 활성 메뉴 조회 - $allMenus = Menu::where('tenant_id', $tenantId) - ->where('is_active', true) - ->where('hidden', false) - ->orderBy('sort_order') + // 1. 테넌트의 모든 활성 메뉴 + 확장 데이터 조회 (LEFT JOIN) + $allMenus = Menu::where('menus.tenant_id', $tenantId) + ->where('menus.is_active', true) + ->where('menus.hidden', false) + ->leftJoin('menu_mng_extensions', 'menus.id', '=', 'menu_mng_extensions.menu_id') + ->select( + 'menus.*', + 'menu_mng_extensions.menu_type', + 'menu_mng_extensions.route_name', + 'menu_mng_extensions.section', + 'menu_mng_extensions.lab_tab', + 'menu_mng_extensions.blade_component', + 'menu_mng_extensions.css_class', + 'menu_mng_extensions.requires_super_admin' + ) + ->orderBy('menus.sort_order') ->get(); // 2. 슈퍼관리자는 모든 메뉴 표시 @@ -178,8 +392,12 @@ class SidebarMenuService // 3. 일반 사용자: 권한 기반 필터링 $permittedMenuIds = $this->getPermittedMenuIds($user, $tenantId); - // 4. 개발도구/Labs 제외 (일반 사용자) + // 4. 개발도구/Labs 및 슈퍼관리자 전용 메뉴 제외 $filteredMenus = $allMenus->filter(function ($menu) use ($permittedMenuIds) { + // 슈퍼관리자 전용 메뉴 제외 + if ($menu->requires_super_admin) { + return false; + } // 개발도구/Labs는 일반 사용자에게 표시 안함 if (in_array($menu->menu_type, ['dev_tool', 'lab'])) { return false; @@ -190,6 +408,20 @@ class SidebarMenuService return $this->buildMenuTree($filteredMenus); } + /** + * 섹션별 메뉴 조회 (main, dev_tools, labs) + */ + public function getMenusBySection(?User $user = null): array + { + $menuTree = $this->getUserMenuTree($user); + + return [ + 'main' => $menuTree->filter(fn($m) => ($m->section ?? 'main') === 'main'), + 'dev_tools' => $menuTree->filter(fn($m) => ($m->section ?? 'main') === 'dev_tools'), + 'labs' => $menuTree->filter(fn($m) => ($m->section ?? 'main') === 'labs'), + ]; + } + /** * 사용자가 접근 가능한 메뉴 ID 목록 조회 * 우선순위: 개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할 @@ -209,6 +441,78 @@ class SidebarMenuService return $this->mergePermissions($rolePermissions, $deptPermissions, $userOverrides); } + private function getRoleMenuPermissions(User $user, int $tenantId): array + { + // menu:*.view 형식의 권한에서 메뉴 ID 추출 + $permissions = $user->getPermissionsViaRoles() + ->filter(fn($p) => str_starts_with($p->name, 'menu:') && str_ends_with($p->name, '.view')) + ->pluck('name') + ->map(fn($name) => (int) explode('.', explode(':', $name)[1])[0]) + ->toArray(); + + return $permissions; + } + + private function getDepartmentMenuPermissions(User $user, int $tenantId): array + { + // 부서 권한 조회 로직 (department_permissions 테이블) + return DB::table('department_permissions') + ->where('department_id', $user->department_id) + ->where('permission_id', 'LIKE', 'menu:%') + ->get() + ->mapWithKeys(fn($row) => [ + (int) explode('.', explode(':', $row->permission_id)[1])[0] => $row->is_allowed + ]) + ->toArray(); + } + + private function getUserMenuOverrides(User $user, int $tenantId): array + { + // 개인 권한 오버라이드 조회 (user_permission_overrides 테이블) + return DB::table('user_permission_overrides') + ->where('user_id', $user->id) + ->where('permission_id', 'LIKE', 'menu:%') + ->get() + ->mapWithKeys(fn($row) => [ + (int) explode('.', explode(':', $row->permission_id)[1])[0] => $row->is_allowed + ]) + ->toArray(); + } + + private function mergePermissions(array $role, array $dept, array $user): array + { + $allMenuIds = array_unique(array_merge( + $role, + array_keys($dept), + array_keys($user) + )); + + $permitted = []; + + foreach ($allMenuIds as $menuId) { + // 우선순위: 개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할 + if (isset($user[$menuId])) { + if ($user[$menuId]) { + $permitted[] = $menuId; + } + continue; // 개인 설정이 있으면 다른 건 무시 + } + + if (isset($dept[$menuId])) { + if ($dept[$menuId]) { + $permitted[] = $menuId; + } + continue; // 부서 설정이 있으면 역할은 무시 + } + + if (in_array($menuId, $role)) { + $permitted[] = $menuId; + } + } + + return $permitted; + } + private function buildMenuTree(Collection $menus, ?int $parentId = null): Collection { return $menus->where('parent_id', $parentId) @@ -257,13 +561,27 @@ resources/views/ ```php // app/Providers/ViewServiceProvider.php +with('sidebarMenus', $menuService->getUserMenuTree()); + $menusBySection = $menuService->getMenusBySection(); + + $view->with([ + 'mainMenus' => $menusBySection['main'], + 'devToolsMenus' => $menusBySection['dev_tools'], + 'labsMenus' => $menusBySection['labs'], + ]); }); } } @@ -275,9 +593,19 @@ class ViewServiceProvider extends ServiceProvider ```php // app/Http/Middleware/CheckMenuPermission.php +user(); @@ -286,26 +614,68 @@ class CheckMenuPermission return $next($request); } - // 권한 체크 + // 라우트 이름으로 메뉴 찾기 (확장 테이블에서) $routeName = $request->route()->getName(); - $menu = Menu::where('route_name', $routeName)->first(); + $extension = MenuMngExtension::where('route_name', $routeName)->first(); - if (!$menu) { - return $next($request); // 메뉴 없으면 패스 + if (!$extension) { + return $next($request); // 메뉴 등록 안 된 라우트는 패스 } - $permissionName = $permission ?? "menu:{$menu->id}.view"; + // 슈퍼관리자 전용 메뉴 체크 + if ($extension->requires_super_admin) { + abort(403, '슈퍼관리자만 접근 가능합니다.'); + } - if (!$this->hasMenuPermission($user, $menu, $permissionName)) { + $permissionName = $permission ?? "menu:{$extension->menu_id}.view"; + + if (!$this->hasMenuPermission($user, $extension->menu_id, $permissionName)) { abort(403, '접근 권한이 없습니다.'); } return $next($request); } + + private function hasMenuPermission($user, int $menuId, string $permissionName): bool + { + // SidebarMenuService와 동일한 권한 체크 로직 사용 + // 또는 Spatie Permission 직접 체크 + return $user->can($permissionName); + } } ``` -#### 3.4.2 라우트에 미들웨어 적용 +#### 3.4.2 미들웨어 등록 (Laravel 12 방식) + +```php +// bootstrap/app.php +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + ) + ->withMiddleware(function (Middleware $middleware): void { + // 기존 미들웨어 + $middleware->alias([ + 'hq.member' => \App\Http\Middleware\EnsureHQMember::class, + 'super.admin' => \App\Http\Middleware\EnsureSuperAdmin::class, + 'password.changed' => \App\Http\Middleware\EnsurePasswordChanged::class, + // 신규 메뉴 권한 미들웨어 추가 + 'menu.permission' => \App\Http\Middleware\CheckMenuPermission::class, + ]); + }) + ->withExceptions(function ($exceptions) { + // + }) + ->create(); +``` + +#### 3.4.3 라우트에 미들웨어 적용 ```php // routes/web.php @@ -322,7 +692,8 @@ Route::middleware(['auth', 'hq.member', 'menu.permission'])->group(function () { ``` Phase 1: 준비 (하드코딩 + DB 병행) -├── DB에 mng 메뉴 시딩 +├── DB에 menu_mng_extensions 테이블 생성 +├── mng 메뉴 시딩 ├── SidebarMenuService 개발 └── 기존 sidebar.blade.php 유지 @@ -361,7 +732,8 @@ Phase 4: 안정화 | 파일 | 설명 | |------|------| -| `database/migrations/xxxx_add_menu_type_to_menus.php` | menu_type, route_name 컬럼 추가 | +| `database/migrations/xxxx_create_menu_mng_extensions_table.php` | mng 확장 테이블 생성 | +| `app/Models/MenuMngExtension.php` | 확장 테이블 모델 | | `database/seeders/MngMenuSeeder.php` | mng 메뉴 시더 | | `database/seeders/MngMenuPermissionSeeder.php` | mng 메뉴 권한 시더 | | `app/Services/SidebarMenuService.php` | 사용자별 메뉴 조회 | @@ -374,7 +746,7 @@ Phase 4: 안정화 | 파일 | 변경 내용 | |------|----------| -| `app/Http/Kernel.php` | CheckMenuPermission 미들웨어 등록 | +| `bootstrap/app.php` | CheckMenuPermission 미들웨어 등록 | | `config/app.php` | mng_dynamic_sidebar 설정 추가 | | `routes/web.php` | 미들웨어 적용 | | `resources/views/partials/sidebar.blade.php` | 조건부 렌더링 | @@ -404,7 +776,7 @@ Phase 4: 안정화 ### Phase 1 완료 조건 -- [ ] 마이그레이션 실행 성공 +- [ ] 마이그레이션 실행 성공 (menu_mng_extensions 테이블 생성) - [ ] mng 메뉴 시더 실행 성공 - [ ] DB에 모든 mng 메뉴 존재 확인 @@ -476,7 +848,7 @@ AuditLog::create([ 이 계획을 승인하시면 다음 순서로 진행합니다: 1. **현재 mng 메뉴 전체 목록 정리** (그룹/항목/라우트/아이콘) -2. **마이그레이션 및 시더 작성** +2. **마이그레이션 및 시더 작성** (menu_mng_extensions 테이블) 3. **SidebarMenuService 개발** 4. **동적 사이드바 컴포넌트 개발** 5. **권한 미들웨어 적용** diff --git a/projects/migration-5130-mng/MIGRATION_TRACKER.md b/projects/migration-5130-mng/MIGRATION_TRACKER.md index 49f7683..bb878c4 100644 --- a/projects/migration-5130-mng/MIGRATION_TRACKER.md +++ b/projects/migration-5130-mng/MIGRATION_TRACKER.md @@ -157,10 +157,10 @@ ## 변경 이력 -| 날짜 | 내용 | 작업자 | -|------|------|--------| -| 2025-12-16 | Phase 1 완료 - 13개 파일 레이아웃 변환 | Claude | -| 2025-12-16 | 작업 추적 문서 생성 | Claude | +| 날짜 | 내용 | 커밋 | +|------|------|------| +| 2025-12-16 | Phase 1 완료 - 13개 파일 레이아웃 변환 | mng: `27052af` | +| 2025-12-16 | 작업 추적 문서 생성 | docs: `5e6508c` | ---