511 lines
16 KiB
PHP
511 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Commons\Menu;
|
|
use App\Models\Tenants\Tenant;
|
|
use App\Models\Tenants\TenantSetting;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
class MenuSyncController extends Controller
|
|
{
|
|
private ?string $remoteTenantName = null;
|
|
|
|
/**
|
|
* 현재 선택된 테넌트 ID
|
|
*/
|
|
protected function getTenantId(): int
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
return ($tenantId && $tenantId !== 'all') ? (int) $tenantId : 1;
|
|
}
|
|
|
|
/**
|
|
* 환경 설정 조회
|
|
*/
|
|
private function getEnvironments(): array
|
|
{
|
|
$setting = TenantSetting::withoutGlobalScopes()
|
|
->where('tenant_id', $this->getTenantId())
|
|
->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');
|
|
|
|
// 로컬 테넌트 정보
|
|
$localTenant = Tenant::find($this->getTenantId());
|
|
|
|
// 로컬 메뉴 조회 (트리 구조)
|
|
$localMenus = $this->getMenuTree();
|
|
|
|
// 원격 메뉴 조회
|
|
$remoteMenus = [];
|
|
$remoteError = null;
|
|
$this->remoteTenantName = 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,
|
|
'localTenantName' => $localTenant?->company_name ?? '알 수 없음',
|
|
'remoteTenantName' => $this->remoteTenantName,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 환경 설정 저장
|
|
*/
|
|
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->getTenantId(),
|
|
'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);
|
|
}
|
|
|
|
// 요청에서 tenant_id를 받으면 사용, 없으면 세션 기반
|
|
$tenantId = $request->query('tenant_id') ? (int) $request->query('tenant_id') : $this->getTenantId();
|
|
|
|
$menus = $this->getMenuTreeForTenant($tenantId);
|
|
$tenant = Tenant::find($tenantId);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'environment' => config('app.env'),
|
|
'tenant_name' => $tenant?->company_name ?? '알 수 없음',
|
|
'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->getTenantId())
|
|
->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
|
|
{
|
|
return $this->getMenuTreeForTenant($this->getTenantId(), $parentId);
|
|
}
|
|
|
|
/**
|
|
* 특정 테넌트의 메뉴 트리 조회
|
|
*/
|
|
private function getMenuTreeForTenant(int $tenantId, ?int $parentId = null): array
|
|
{
|
|
$menus = Menu::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('parent_id', $parentId)
|
|
->orderBy('sort_order')
|
|
->get();
|
|
|
|
return $menus->map(function ($menu) use ($tenantId) {
|
|
return [
|
|
'id' => $menu->id,
|
|
'name' => $menu->name,
|
|
'url' => $menu->url,
|
|
'icon' => $menu->icon,
|
|
'sort_order' => $menu->sort_order,
|
|
'options' => $menu->options,
|
|
'children' => $this->getMenuTreeForTenant($tenantId, $menu->id),
|
|
];
|
|
})->toArray();
|
|
}
|
|
|
|
/**
|
|
* 자식 메뉴 데이터 조회
|
|
*/
|
|
private function getChildrenData(int $parentId): array
|
|
{
|
|
$children = Menu::withoutGlobalScopes()
|
|
->where('tenant_id', $this->getTenantId())
|
|
->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', [
|
|
'tenant_id' => $this->getTenantId(), // 현재 선택된 테넌트 전달
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
throw new \Exception('API 오류: HTTP '.$response->status());
|
|
}
|
|
|
|
$data = $response->json();
|
|
if (! isset($data['menus'])) {
|
|
throw new \Exception('잘못된 응답 형식');
|
|
}
|
|
|
|
$this->remoteTenantName = $data['tenant_name'] ?? null;
|
|
|
|
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, ?string $parentName = null): array
|
|
{
|
|
$result = [];
|
|
foreach ($menus as $menu) {
|
|
$originalChildren = $menu['children'] ?? [];
|
|
|
|
if (in_array($menu['name'], $names)) {
|
|
$menu['parent_name'] = $parentName;
|
|
// children은 flat list에서 개별 처리되므로 제거 (동명 메뉴 중복 import 방지)
|
|
$menu['children'] = [];
|
|
$result[] = $menu;
|
|
}
|
|
|
|
if (! empty($originalChildren)) {
|
|
$result = array_merge($result, $this->filterMenusByName($originalChildren, $names, $menu['name']));
|
|
}
|
|
}
|
|
|
|
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->getTenantId())
|
|
->where('name', $data['parent_name'])
|
|
->first();
|
|
$parentId = $parent?->id;
|
|
}
|
|
|
|
// name + parent_id로 매칭 (동명 메뉴가 다른 계층에 있을 때 자기참조 방지)
|
|
$menu = Menu::withoutGlobalScopes()->updateOrCreate(
|
|
[
|
|
'tenant_id' => $this->getTenantId(),
|
|
'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);
|
|
}
|
|
}
|
|
}
|
|
}
|