feat: [menu-sync] 순서 동기화 Push + 되돌리기 기능 추가

- pushOrder: 로컬 메뉴 순서를 원격 서버에 일괄 반영
- undoOrder: 순서 동기화 취소하여 이전 상태로 복원
- reorder: 외부 API 엔드포인트 (이름 기반 매칭)
- 세션 기반 스냅샷으로 되돌리기 지원
This commit is contained in:
김보곤
2026-02-28 08:41:03 +09:00
parent e5ea72ed2a
commit 0845720a01
3 changed files with 294 additions and 0 deletions

View File

@@ -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
*/

View File

@@ -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');

View File

@@ -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');
});
});