where('tenant_id', $this->tenantId) ->where('setting_group', 'menu_sync') ->where('setting_key', 'environments') ->first(); return $setting?->setting_value ?? [ 'dev' => ['name' => '개발', 'url' => '', 'api_key' => ''], 'prod' => ['name' => '운영', 'url' => '', 'api_key' => ''], ]; } /** * 메뉴 동기화 페이지 */ public function index(Request $request): View|Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('menus.sync.index')); } $environments = $this->getEnvironments(); $selectedEnv = $request->get('env', 'dev'); // 로컬 메뉴 조회 (트리 구조) $localMenus = $this->getMenuTree(); // 원격 메뉴 조회 $remoteMenus = []; $remoteError = null; if (! empty($environments[$selectedEnv]['url'])) { try { $remoteMenus = $this->fetchRemoteMenus($environments[$selectedEnv]); } catch (\Exception $e) { $remoteError = $e->getMessage(); } } // 차이점 계산 $diff = $this->calculateDiff($localMenus, $remoteMenus); return view('menus.sync', [ 'environments' => $environments, 'selectedEnv' => $selectedEnv, 'localMenus' => $localMenus, 'remoteMenus' => $remoteMenus, 'remoteError' => $remoteError, 'diff' => $diff, ]); } /** * 환경 설정 저장 */ public function saveSettings(Request $request): JsonResponse { $validated = $request->validate([ 'environments' => 'required|array', 'environments.*.name' => 'required|string|max:50', 'environments.*.url' => 'nullable|url|max:255', 'environments.*.api_key' => 'nullable|string|max:255', ]); TenantSetting::withoutGlobalScopes()->updateOrCreate( [ 'tenant_id' => $this->tenantId, 'setting_group' => 'menu_sync', 'setting_key' => 'environments', ], [ 'setting_value' => $validated['environments'], 'description' => '메뉴 동기화 환경 설정', ] ); return response()->json(['success' => true, 'message' => '환경 설정이 저장되었습니다.']); } /** * 메뉴 Export API (다른 환경에서 호출) */ public function export(Request $request): JsonResponse { // API Key 검증 $apiKey = $request->header('X-Menu-Sync-Key'); $validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY')); if (empty($validKey) || $apiKey !== $validKey) { return response()->json(['error' => 'Unauthorized'], 401); } $menus = $this->getMenuTree(); return response()->json([ 'success' => true, 'environment' => config('app.env'), 'exported_at' => now()->toIso8601String(), 'menus' => $menus, ]); } /** * 메뉴 Import API (다른 환경에서 호출) */ public function import(Request $request): JsonResponse { // API Key 검증 $apiKey = $request->header('X-Menu-Sync-Key'); $validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY')); if (empty($validKey) || $apiKey !== $validKey) { return response()->json(['error' => 'Unauthorized'], 401); } $validated = $request->validate([ 'menus' => 'required|array', 'menus.*.name' => 'required|string|max:100', 'menus.*.url' => 'required|string|max:255', 'menus.*.icon' => 'nullable|string|max:50', 'menus.*.sort_order' => 'nullable|integer', 'menus.*.options' => 'nullable|array', 'menus.*.parent_name' => 'nullable|string', // 부모 메뉴 이름으로 연결 'menus.*.children' => 'nullable|array', ]); $imported = 0; foreach ($validated['menus'] as $menuData) { $this->importMenu($menuData); $imported++; } return response()->json([ 'success' => true, 'message' => "{$imported}개 메뉴가 동기화되었습니다.", 'imported' => $imported, ]); } /** * 개별 메뉴 Push (로컬 → 원격) */ public function push(Request $request): JsonResponse { $validated = $request->validate([ 'env' => 'required|string|in:dev,prod', 'menu_ids' => 'required|array|min:1', 'menu_ids.*' => 'integer', ]); $environments = $this->getEnvironments(); $env = $environments[$validated['env']] ?? null; if (! $env || empty($env['url'])) { return response()->json(['error' => '환경 설정이 없습니다.'], 400); } // 선택된 메뉴 조회 $menus = Menu::withoutGlobalScopes() ->where('tenant_id', $this->tenantId) ->whereIn('id', $validated['menu_ids']) ->get(); if ($menus->isEmpty()) { return response()->json(['error' => '선택된 메뉴가 없습니다.'], 400); } // 메뉴 데이터 준비 (부모 정보 포함) $menuData = $menus->map(function ($menu) { $parent = $menu->parent_id ? Menu::withoutGlobalScopes()->find($menu->parent_id) : null; return [ 'name' => $menu->name, 'url' => $menu->url, 'icon' => $menu->icon, 'sort_order' => $menu->sort_order, 'options' => $menu->options, 'parent_name' => $parent?->name, 'children' => $this->getChildrenData($menu->id), ]; })->toArray(); // 원격 서버로 전송 try { $response = Http::withHeaders([ 'X-Menu-Sync-Key' => $env['api_key'], 'Accept' => 'application/json', ])->post(rtrim($env['url'], '/') . '/menu-sync/import', [ 'menus' => $menuData, ]); if ($response->successful()) { return response()->json([ 'success' => true, 'message' => $response->json('message', '동기화 완료'), ]); } return response()->json([ 'error' => $response->json('error', '원격 서버 오류'), ], $response->status()); } catch (\Exception $e) { return response()->json(['error' => '연결 실패: ' . $e->getMessage()], 500); } } /** * 개별 메뉴 Pull (원격 → 로컬) */ public function pull(Request $request): JsonResponse { $validated = $request->validate([ 'env' => 'required|string|in:dev,prod', 'menu_names' => 'required|array|min:1', 'menu_names.*' => 'string', ]); $environments = $this->getEnvironments(); $env = $environments[$validated['env']] ?? null; if (! $env || empty($env['url'])) { return response()->json(['error' => '환경 설정이 없습니다.'], 400); } // 원격 메뉴 조회 try { $remoteMenus = $this->fetchRemoteMenus($env); } catch (\Exception $e) { return response()->json(['error' => $e->getMessage()], 500); } // 선택된 메뉴만 필터링 $selectedMenus = $this->filterMenusByName($remoteMenus, $validated['menu_names']); if (empty($selectedMenus)) { return response()->json(['error' => '선택된 메뉴를 찾을 수 없습니다.'], 400); } // 로컬에 Import $imported = 0; foreach ($selectedMenus as $menuData) { $this->importMenu($menuData); $imported++; } return response()->json([ 'success' => true, 'message' => "{$imported}개 메뉴가 동기화되었습니다.", ]); } /** * 연결 테스트 */ public function testConnection(Request $request): JsonResponse { $validated = $request->validate([ 'url' => 'required|url', 'api_key' => 'required|string', ]); try { $response = Http::withHeaders([ 'X-Menu-Sync-Key' => $validated['api_key'], 'Accept' => 'application/json', ])->timeout(10)->get(rtrim($validated['url'], '/') . '/menu-sync/export'); if ($response->successful()) { $data = $response->json(); return response()->json([ 'success' => true, 'message' => '연결 성공', 'environment' => $data['environment'] ?? 'unknown', 'menu_count' => count($data['menus'] ?? []), ]); } return response()->json([ 'success' => false, 'message' => 'API 오류: ' . $response->status(), ]); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => '연결 실패: ' . $e->getMessage(), ]); } } /** * 메뉴 트리 조회 */ private function getMenuTree(?int $parentId = null): array { $menus = Menu::withoutGlobalScopes() ->where('tenant_id', $this->tenantId) ->where('parent_id', $parentId) ->orderBy('sort_order') ->get(); return $menus->map(function ($menu) { return [ 'id' => $menu->id, 'name' => $menu->name, 'url' => $menu->url, 'icon' => $menu->icon, 'sort_order' => $menu->sort_order, 'options' => $menu->options, 'children' => $this->getMenuTree($menu->id), ]; })->toArray(); } /** * 자식 메뉴 데이터 조회 */ private function getChildrenData(int $parentId): array { $children = Menu::withoutGlobalScopes() ->where('tenant_id', $this->tenantId) ->where('parent_id', $parentId) ->orderBy('sort_order') ->get(); return $children->map(function ($menu) { return [ 'name' => $menu->name, 'url' => $menu->url, 'icon' => $menu->icon, 'sort_order' => $menu->sort_order, 'options' => $menu->options, 'children' => $this->getChildrenData($menu->id), ]; })->toArray(); } /** * 원격 메뉴 조회 */ private function fetchRemoteMenus(array $env): array { $response = Http::withHeaders([ 'X-Menu-Sync-Key' => $env['api_key'], 'Accept' => 'application/json', ])->timeout(10)->get(rtrim($env['url'], '/') . '/menu-sync/export'); if (! $response->successful()) { throw new \Exception('API 오류: HTTP ' . $response->status()); } $data = $response->json(); if (! isset($data['menus'])) { throw new \Exception('잘못된 응답 형식'); } return $data['menus']; } /** * 메뉴 차이점 계산 */ private function calculateDiff(array $localMenus, array $remoteMenus): array { $localNames = $this->flattenMenuNames($localMenus); $remoteNames = $this->flattenMenuNames($remoteMenus); return [ 'local_only' => array_diff($localNames, $remoteNames), 'remote_only' => array_diff($remoteNames, $localNames), 'both' => array_intersect($localNames, $remoteNames), ]; } /** * 메뉴 이름 평탄화 */ private function flattenMenuNames(array $menus, string $prefix = ''): array { $names = []; foreach ($menus as $menu) { $fullName = $prefix ? "{$prefix} > {$menu['name']}" : $menu['name']; $names[] = $menu['name']; if (! empty($menu['children'])) { $names = array_merge($names, $this->flattenMenuNames($menu['children'], $fullName)); } } return $names; } /** * 이름으로 메뉴 필터링 */ private function filterMenusByName(array $menus, array $names): array { $result = []; foreach ($menus as $menu) { if (in_array($menu['name'], $names)) { $result[] = $menu; } if (! empty($menu['children'])) { $result = array_merge($result, $this->filterMenusByName($menu['children'], $names)); } } return $result; } /** * 메뉴 Import */ private function importMenu(array $data, ?int $parentId = null): void { // 부모 메뉴 찾기 if (! $parentId && ! empty($data['parent_name'])) { $parent = Menu::withoutGlobalScopes() ->where('tenant_id', $this->tenantId) ->where('name', $data['parent_name']) ->first(); $parentId = $parent?->id; } // 기존 메뉴 찾기 또는 생성 $menu = Menu::withoutGlobalScopes()->updateOrCreate( [ 'tenant_id' => $this->tenantId, 'name' => $data['name'], ], [ 'parent_id' => $parentId, 'url' => $data['url'], 'icon' => $data['icon'] ?? null, 'sort_order' => $data['sort_order'] ?? 0, 'options' => $data['options'] ?? null, 'is_active' => true, ] ); // 자식 메뉴 처리 if (! empty($data['children'])) { foreach ($data['children'] as $child) { $this->importMenu($child, $menu->id); } } } }