feat: [menu-sync] 순서 동기화 Push + 되돌리기 기능 추가
- pushOrder: 로컬 메뉴 순서를 원격 서버에 일괄 반영 - undoOrder: 순서 동기화 취소하여 이전 상태로 복원 - reorder: 외부 API 엔드포인트 (이름 기반 매칭) - 세션 기반 스냅샷으로 되돌리기 지원
This commit is contained in:
@@ -85,6 +85,7 @@ public function index(Request $request): View|Response
|
||||
'diff' => $diff,
|
||||
'localTenantName' => $localTenant?->company_name ?? '알 수 없음',
|
||||
'remoteTenantName' => $this->remoteTenantName,
|
||||
'hasOrderSnapshot' => session()->has("menu_order_snapshot_{$selectedEnv}"),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -470,6 +471,213 @@ private function filterMenusByName(array $menus, array $names, ?string $parentNa
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 동기화 Push (로컬 순서 → 원격 서버)
|
||||
*/
|
||||
public function pushOrder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 원격 서버 현재 순서 스냅샷 저장 (되돌리기용)
|
||||
$remoteMenus = $this->fetchRemoteMenus($env);
|
||||
$snapshot = $this->buildOrderMap($remoteMenus);
|
||||
session()->put("menu_order_snapshot_{$validated['env']}", $snapshot);
|
||||
|
||||
// 2. 로컬 메뉴 트리에서 순서 매핑 생성
|
||||
$localMenus = $this->getMenuTree();
|
||||
$orderMap = $this->buildOrderMap($localMenus);
|
||||
|
||||
// 3. 원격 서버에 순서 업데이트 전송
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(30)->post(rtrim($env['url'], '/').'/menu-sync/reorder', [
|
||||
'menus' => $orderMap,
|
||||
'tenant_id' => $this->getTenantId(),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $response->json('message', '순서 동기화 완료'),
|
||||
'hasSnapshot' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// 실패 시 스냅샷 삭제
|
||||
session()->forget("menu_order_snapshot_{$validated['env']}");
|
||||
|
||||
return response()->json([
|
||||
'error' => $response->json('error', '원격 서버 오류'),
|
||||
], $response->status());
|
||||
} catch (\Exception $e) {
|
||||
session()->forget("menu_order_snapshot_{$validated['env']}");
|
||||
|
||||
return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 동기화 되돌리기
|
||||
*/
|
||||
public function undoOrder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
$snapshot = session("menu_order_snapshot_{$validated['env']}");
|
||||
if (empty($snapshot)) {
|
||||
return response()->json(['error' => '되돌릴 스냅샷이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(30)->post(rtrim($env['url'], '/').'/menu-sync/reorder', [
|
||||
'menus' => $snapshot,
|
||||
'tenant_id' => $this->getTenantId(),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
session()->forget("menu_order_snapshot_{$validated['env']}");
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '순서가 이전 상태로 복원되었습니다.',
|
||||
'hasSnapshot' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => $response->json('error', '원격 서버 오류'),
|
||||
], $response->status());
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 순서 재정렬 API (외부 서버에서 호출)
|
||||
*/
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key');
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'menus' => 'required|array',
|
||||
'tenant_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$tenantId = $validated['tenant_id'] ?? $this->getTenantId();
|
||||
$updated = $this->applyOrder($validated['menus'], $tenantId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$updated}개 메뉴 순서가 업데이트되었습니다.",
|
||||
'updated' => $updated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 트리를 순서 매핑 배열로 변환
|
||||
*/
|
||||
private function buildOrderMap(array $menus, ?string $parentName = null): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($menus as $menu) {
|
||||
$item = [
|
||||
'name' => $menu['name'],
|
||||
'parent_name' => $parentName,
|
||||
'sort_order' => $menu['sort_order'] ?? 0,
|
||||
'children' => [],
|
||||
];
|
||||
|
||||
if (! empty($menu['children'])) {
|
||||
$item['children'] = $this->buildOrderMap($menu['children'], $menu['name']);
|
||||
}
|
||||
|
||||
$result[] = $item;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 매핑을 DB에 적용 (재귀)
|
||||
*/
|
||||
private function applyOrder(array $orderMap, int $tenantId, ?int $parentId = null): int
|
||||
{
|
||||
$updated = 0;
|
||||
|
||||
foreach ($orderMap as $item) {
|
||||
$query = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('name', $item['name']);
|
||||
|
||||
// parent_name으로 부모 매칭
|
||||
if (! empty($item['parent_name'])) {
|
||||
$parent = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('name', $item['parent_name'])
|
||||
->first();
|
||||
$resolvedParentId = $parent?->id;
|
||||
} else {
|
||||
$resolvedParentId = null;
|
||||
}
|
||||
|
||||
// parentId 파라미터가 있으면 우선 사용 (재귀 호출 시)
|
||||
$targetParentId = $parentId ?? $resolvedParentId;
|
||||
$query->where('parent_id', $targetParentId);
|
||||
|
||||
$menu = $query->first();
|
||||
if ($menu) {
|
||||
$changes = [];
|
||||
if ($menu->sort_order !== ($item['sort_order'] ?? 0)) {
|
||||
$changes['sort_order'] = $item['sort_order'] ?? 0;
|
||||
}
|
||||
if ($menu->parent_id !== $targetParentId) {
|
||||
$changes['parent_id'] = $targetParentId;
|
||||
}
|
||||
|
||||
if (! empty($changes)) {
|
||||
$menu->update($changes);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
// 자식 메뉴 처리
|
||||
if (! empty($item['children'])) {
|
||||
$updated += $this->applyOrder($item['children'], $tenantId, $menu->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 Import
|
||||
*/
|
||||
|
||||
@@ -98,6 +98,14 @@ class="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500">
|
||||
class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium rounded transition-colors flex items-center gap-1">
|
||||
Push →
|
||||
</button>
|
||||
<button type="button" onclick="pushOrder()" id="btnPushOrder"
|
||||
class="px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-xs font-medium rounded transition-colors flex items-center gap-1">
|
||||
순서 동기화 →
|
||||
</button>
|
||||
<button type="button" onclick="undoOrder()" id="btnUndoOrder"
|
||||
class="px-3 py-1.5 bg-gray-400 hover:bg-gray-500 text-white text-xs font-medium rounded transition-colors flex items-center gap-1 {{ $hasOrderSnapshot ? '' : 'hidden' }}">
|
||||
↩ 되돌리기
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -274,6 +282,81 @@ function updateSelectedCount(side) {
|
||||
}
|
||||
});
|
||||
|
||||
// 순서 동기화 Push
|
||||
async function pushOrder() {
|
||||
const envName = selectedEnv === 'dev' ? '개발' : '운영';
|
||||
if (!confirm(`로컬 메뉴 순서를 ${envName} 서버에 적용하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnPushOrder');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '동기화 중...';
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("menus.sync.push-order") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ env: selectedEnv })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '순서 동기화 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('오류 발생: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '순서 동기화 →';
|
||||
}
|
||||
}
|
||||
|
||||
// 순서 동기화 되돌리기
|
||||
async function undoOrder() {
|
||||
if (!confirm('순서 동기화를 취소하시겠습니까? 원격 서버의 메뉴 순서가 이전 상태로 복원됩니다.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnUndoOrder');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '복원 중...';
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("menus.sync.undo-order") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ env: selectedEnv })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '되돌리기 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('오류 발생: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '↩ 되돌리기';
|
||||
}
|
||||
}
|
||||
|
||||
// 상위 메뉴 체크 시 하위 메뉴도 선택/해제
|
||||
function toggleChildren(checkbox) {
|
||||
const menuGroup = checkbox.closest('.menu-group');
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
Route::prefix('menu-sync')->group(function () {
|
||||
Route::get('/export', [MenuSyncController::class, 'export']);
|
||||
Route::post('/import', [MenuSyncController::class, 'import']);
|
||||
Route::post('/reorder', [MenuSyncController::class, 'reorder']);
|
||||
});
|
||||
|
||||
Route::prefix('common-code-sync')->group(function () {
|
||||
@@ -186,6 +187,8 @@
|
||||
Route::post('/test', [MenuSyncController::class, 'testConnection'])->name('test');
|
||||
Route::post('/push', [MenuSyncController::class, 'push'])->name('push');
|
||||
Route::post('/pull', [MenuSyncController::class, 'pull'])->name('pull');
|
||||
Route::post('/push-order', [MenuSyncController::class, 'pushOrder'])->name('push-order');
|
||||
Route::post('/undo-order', [MenuSyncController::class, 'undoOrder'])->name('undo-order');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user