feat:공통코드/카테고리 동기화 기능 추가
- CommonCodeSyncController, CategorySyncController 생성 - 환경설정은 메뉴 동기화와 공유 (TenantSetting) - Export/Import API 추가 (/common-code-sync, /category-sync) - Push(로컬→원격), Pull(원격→로컬) 양방향 동기화 - 동일 코드 존재 시 체크박스 비활성화 (충돌 방지) - 글로벌 + 테넌트 코드 모두 동기화 가능 - 공통코드/카테고리 관리 페이지에 동기화 버튼 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
490
app/Http/Controllers/CategorySyncController.php
Normal file
490
app/Http/Controllers/CategorySyncController.php
Normal file
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\GlobalCategory;
|
||||
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 CategorySyncController extends Controller
|
||||
{
|
||||
/**
|
||||
* 현재 선택된 테넌트 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('categories.sync.index'));
|
||||
}
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$selectedEnv = $request->get('env', 'dev');
|
||||
|
||||
// 로컬 카테고리 조회
|
||||
$localCategories = $this->getCategoryList();
|
||||
|
||||
// 원격 카테고리 조회
|
||||
$remoteCategories = [];
|
||||
$remoteError = null;
|
||||
|
||||
if (! empty($environments[$selectedEnv]['url'])) {
|
||||
try {
|
||||
$remoteCategories = $this->fetchRemoteCategories($environments[$selectedEnv]);
|
||||
} catch (\Exception $e) {
|
||||
$remoteError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 차이점 계산
|
||||
$diff = $this->calculateDiff($localCategories, $remoteCategories);
|
||||
|
||||
return view('categories.sync', [
|
||||
'environments' => $environments,
|
||||
'selectedEnv' => $selectedEnv,
|
||||
'localCategories' => $localCategories,
|
||||
'remoteCategories' => $remoteCategories,
|
||||
'remoteError' => $remoteError,
|
||||
'diff' => $diff,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 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);
|
||||
}
|
||||
|
||||
$categories = $this->getCategoryList();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'environment' => config('app.env'),
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'categories' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 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([
|
||||
'categories' => 'required|array',
|
||||
'categories.*.is_global' => 'required|boolean',
|
||||
'categories.*.tenant_id' => 'nullable|integer',
|
||||
'categories.*.code_group' => 'required|string|max:50',
|
||||
'categories.*.code' => 'required|string|max:50',
|
||||
'categories.*.name' => 'required|string|max:100',
|
||||
'categories.*.parent_code' => 'nullable|string|max:50',
|
||||
'categories.*.sort_order' => 'nullable|integer',
|
||||
'categories.*.description' => 'nullable|string',
|
||||
'categories.*.is_active' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($validated['categories'] as $catData) {
|
||||
if ($catData['is_global']) {
|
||||
// 글로벌 카테고리
|
||||
$exists = GlobalCategory::where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 부모 찾기
|
||||
$parentId = null;
|
||||
if (! empty($catData['parent_code'])) {
|
||||
$parent = GlobalCategory::where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['parent_code'])
|
||||
->first();
|
||||
$parentId = $parent?->id;
|
||||
}
|
||||
|
||||
GlobalCategory::create([
|
||||
'parent_id' => $parentId,
|
||||
'code_group' => $catData['code_group'],
|
||||
'code' => $catData['code'],
|
||||
'name' => $catData['name'],
|
||||
'sort_order' => $catData['sort_order'] ?? 0,
|
||||
'description' => $catData['description'] ?? null,
|
||||
'is_active' => $catData['is_active'] ?? true,
|
||||
]);
|
||||
} else {
|
||||
// 테넌트 카테고리
|
||||
$tenantId = $catData['tenant_id'] ?? $this->getTenantId();
|
||||
|
||||
$exists = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 부모 찾기
|
||||
$parentId = null;
|
||||
if (! empty($catData['parent_code'])) {
|
||||
$parent = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['parent_code'])
|
||||
->first();
|
||||
$parentId = $parent?->id;
|
||||
}
|
||||
|
||||
Category::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $parentId,
|
||||
'code_group' => $catData['code_group'],
|
||||
'code' => $catData['code'],
|
||||
'name' => $catData['name'],
|
||||
'sort_order' => $catData['sort_order'] ?? 0,
|
||||
'description' => $catData['description'] ?? null,
|
||||
'is_active' => $catData['is_active'] ?? true,
|
||||
]);
|
||||
}
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$imported}개 카테고리가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push (로컬 → 원격)
|
||||
*/
|
||||
public function push(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
'category_keys' => 'required|array|min:1',
|
||||
'category_keys.*' => 'string',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 선택된 카테고리 조회
|
||||
$localCategories = $this->getCategoryList();
|
||||
$selectedCategories = array_filter($localCategories, function ($cat) use ($validated) {
|
||||
$key = $this->makeCategoryKey($cat);
|
||||
return in_array($key, $validated['category_keys']);
|
||||
});
|
||||
|
||||
if (empty($selectedCategories)) {
|
||||
return response()->json(['error' => '선택된 카테고리가 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 원격 서버로 전송
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->post(rtrim($env['url'], '/') . '/category-sync/import', [
|
||||
'categories' => array_values($selectedCategories),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $response->json('message', '동기화 완료'),
|
||||
'imported' => $response->json('imported', 0),
|
||||
'skipped' => $response->json('skipped', 0),
|
||||
]);
|
||||
}
|
||||
|
||||
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',
|
||||
'category_keys' => 'required|array|min:1',
|
||||
'category_keys.*' => 'string',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 원격 카테고리 조회
|
||||
try {
|
||||
$remoteCategories = $this->fetchRemoteCategories($env);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
// 선택된 카테고리만 필터링
|
||||
$selectedCategories = array_filter($remoteCategories, function ($cat) use ($validated) {
|
||||
$key = $this->makeCategoryKey($cat);
|
||||
return in_array($key, $validated['category_keys']);
|
||||
});
|
||||
|
||||
if (empty($selectedCategories)) {
|
||||
return response()->json(['error' => '선택된 카테고리를 찾을 수 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 로컬에 Import
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($selectedCategories as $catData) {
|
||||
if ($catData['is_global']) {
|
||||
$exists = GlobalCategory::where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentId = null;
|
||||
if (! empty($catData['parent_code'])) {
|
||||
$parent = GlobalCategory::where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['parent_code'])
|
||||
->first();
|
||||
$parentId = $parent?->id;
|
||||
}
|
||||
|
||||
GlobalCategory::create([
|
||||
'parent_id' => $parentId,
|
||||
'code_group' => $catData['code_group'],
|
||||
'code' => $catData['code'],
|
||||
'name' => $catData['name'],
|
||||
'sort_order' => $catData['sort_order'] ?? 0,
|
||||
'description' => $catData['description'] ?? null,
|
||||
'is_active' => $catData['is_active'] ?? true,
|
||||
]);
|
||||
} else {
|
||||
$tenantId = $this->getTenantId();
|
||||
|
||||
$exists = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentId = null;
|
||||
if (! empty($catData['parent_code'])) {
|
||||
$parent = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['parent_code'])
|
||||
->first();
|
||||
$parentId = $parent?->id;
|
||||
}
|
||||
|
||||
Category::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $parentId,
|
||||
'code_group' => $catData['code_group'],
|
||||
'code' => $catData['code'],
|
||||
'name' => $catData['name'],
|
||||
'sort_order' => $catData['sort_order'] ?? 0,
|
||||
'description' => $catData['description'] ?? null,
|
||||
'is_active' => $catData['is_active'] ?? true,
|
||||
]);
|
||||
}
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$imported}개 카테고리가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회 (글로벌 + 테넌트)
|
||||
*/
|
||||
private function getCategoryList(): array
|
||||
{
|
||||
$tenantId = $this->getTenantId();
|
||||
$categories = [];
|
||||
|
||||
// 글로벌 카테고리
|
||||
$globalCategories = GlobalCategory::whereNull('deleted_at')
|
||||
->orderBy('code_group')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
foreach ($globalCategories as $cat) {
|
||||
$parentCode = null;
|
||||
if ($cat->parent_id) {
|
||||
$parent = GlobalCategory::find($cat->parent_id);
|
||||
$parentCode = $parent?->code;
|
||||
}
|
||||
|
||||
$categories[] = [
|
||||
'is_global' => true,
|
||||
'tenant_id' => null,
|
||||
'code_group' => $cat->code_group,
|
||||
'code' => $cat->code,
|
||||
'name' => $cat->name,
|
||||
'parent_code' => $parentCode,
|
||||
'sort_order' => $cat->sort_order,
|
||||
'description' => $cat->description,
|
||||
'is_active' => $cat->is_active,
|
||||
];
|
||||
}
|
||||
|
||||
// 테넌트 카테고리
|
||||
$tenantCategories = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('code_group')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
foreach ($tenantCategories as $cat) {
|
||||
$parentCode = null;
|
||||
if ($cat->parent_id) {
|
||||
$parent = Category::withoutGlobalScopes()->find($cat->parent_id);
|
||||
$parentCode = $parent?->code;
|
||||
}
|
||||
|
||||
$categories[] = [
|
||||
'is_global' => false,
|
||||
'tenant_id' => $cat->tenant_id,
|
||||
'code_group' => $cat->code_group,
|
||||
'code' => $cat->code,
|
||||
'name' => $cat->name,
|
||||
'parent_code' => $parentCode,
|
||||
'sort_order' => $cat->sort_order,
|
||||
'description' => $cat->description,
|
||||
'is_active' => $cat->is_active,
|
||||
];
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원격 카테고리 조회
|
||||
*/
|
||||
private function fetchRemoteCategories(array $env): array
|
||||
{
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->get(rtrim($env['url'], '/') . '/category-sync/export');
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \Exception('API 오류: HTTP ' . $response->status());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
if (! isset($data['categories'])) {
|
||||
throw new \Exception('잘못된 응답 형식');
|
||||
}
|
||||
|
||||
return $data['categories'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 키 생성 (유니크 식별자)
|
||||
*/
|
||||
private function makeCategoryKey(array $cat): string
|
||||
{
|
||||
$typePart = $cat['is_global'] ? 'global' : "tenant:{$cat['tenant_id']}";
|
||||
return "{$typePart}:{$cat['code_group']}:{$cat['code']}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 차이점 계산
|
||||
*/
|
||||
private function calculateDiff(array $localCategories, array $remoteCategories): array
|
||||
{
|
||||
$localKeys = array_map(fn($c) => $this->makeCategoryKey($c), $localCategories);
|
||||
$remoteKeys = array_map(fn($c) => $this->makeCategoryKey($c), $remoteCategories);
|
||||
|
||||
return [
|
||||
'local_only' => array_values(array_diff($localKeys, $remoteKeys)),
|
||||
'remote_only' => array_values(array_diff($remoteKeys, $localKeys)),
|
||||
'both' => array_values(array_intersect($localKeys, $remoteKeys)),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user