From 50becbdd28c4796ac202f0408813f26c06ceb28a Mon Sep 17 00:00:00 2001 From: pro Date: Thu, 29 Jan 2026 09:22:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:AI=20=EC=84=A4=EC=A0=95=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=20GCS=20=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=84=A4=EC=A0=95=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 설정과 스토리지 설정을 탭으로 구분 - GCS 버킷 이름, 서비스 계정 (JSON 직접입력/파일경로) 설정 가능 - GCS 연결 테스트 기능 추가 - GoogleCloudStorageService가 DB 설정 우선 사용 (fallback: 레거시 파일) - AiConfig 모델에 gcs provider 및 관련 메서드 추가 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/System/AiConfigController.php | 195 ++++++++- app/Models/System/AiConfig.php | 55 +++ app/Services/GoogleCloudStorageService.php | 33 +- .../views/system/ai-config/index.blade.php | 382 +++++++++++++++++- routes/web.php | 1 + 5 files changed, 634 insertions(+), 32 deletions(-) diff --git a/app/Http/Controllers/System/AiConfigController.php b/app/Http/Controllers/System/AiConfigController.php index 4f7c052e..cc02fdd9 100644 --- a/app/Http/Controllers/System/AiConfigController.php +++ b/app/Http/Controllers/System/AiConfigController.php @@ -20,12 +20,25 @@ public function index(Request $request): View|Response return response('', 200)->header('HX-Redirect', route('system.ai-config.index')); } - $configs = AiConfig::orderBy('provider') + // AI 설정 (gemini, claude, openai) + $aiConfigs = AiConfig::whereIn('provider', AiConfig::AI_PROVIDERS) + ->orderBy('provider') ->orderByDesc('is_active') ->orderBy('name') ->get(); - return view('system.ai-config.index', compact('configs')); + // 스토리지 설정 (gcs) + $storageConfigs = AiConfig::whereIn('provider', AiConfig::STORAGE_PROVIDERS) + ->orderBy('provider') + ->orderByDesc('is_active') + ->orderBy('name') + ->get(); + + return view('system.ai-config.index', [ + 'configs' => $aiConfigs, + 'aiConfigs' => $aiConfigs, + 'storageConfigs' => $storageConfigs, + ]); } /** @@ -35,26 +48,40 @@ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'name' => 'required|string|max:50', - 'provider' => 'required|string|in:gemini,claude,openai', + 'provider' => 'required|string|in:gemini,claude,openai,gcs', 'api_key' => 'nullable|string|max:255', - 'model' => 'required|string|max:100', + 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_active' => 'boolean', 'options' => 'nullable|array', - 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai', + 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account', 'options.project_id' => 'nullable|string|max:100', 'options.region' => 'nullable|string|max:50', 'options.service_account_path' => 'nullable|string|max:500', + 'options.bucket_name' => 'nullable|string|max:200', + 'options.service_account_json' => 'nullable|array', ]); - // Vertex AI가 아닌 경우 API 키 필수 - $authType = $validated['options']['auth_type'] ?? 'api_key'; - if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { - return response()->json([ - 'ok' => false, - 'message' => 'API 키를 입력해주세요.', - ], 422); + // GCS의 경우 별도 검증 + if ($validated['provider'] === 'gcs') { + if (empty($validated['options']['bucket_name'])) { + return response()->json([ + 'ok' => false, + 'message' => '버킷 이름을 입력해주세요.', + ], 422); + } + $validated['model'] = '-'; // GCS는 모델 불필요 + $validated['api_key'] = 'gcs_service_account'; // DB NOT NULL 제약 + } else { + // AI 설정: Vertex AI가 아닌 경우 API 키 필수 + $authType = $validated['options']['auth_type'] ?? 'api_key'; + if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { + return response()->json([ + 'ok' => false, + 'message' => 'API 키를 입력해주세요.', + ], 422); + } } // 활성화 시 동일 provider의 다른 설정 비활성화 @@ -81,26 +108,40 @@ public function update(Request $request, int $id): JsonResponse $validated = $request->validate([ 'name' => 'required|string|max:50', - 'provider' => 'required|string|in:gemini,claude,openai', + 'provider' => 'required|string|in:gemini,claude,openai,gcs', 'api_key' => 'nullable|string|max:255', - 'model' => 'required|string|max:100', + 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_active' => 'boolean', 'options' => 'nullable|array', - 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai', + 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account', 'options.project_id' => 'nullable|string|max:100', 'options.region' => 'nullable|string|max:50', 'options.service_account_path' => 'nullable|string|max:500', + 'options.bucket_name' => 'nullable|string|max:200', + 'options.service_account_json' => 'nullable|array', ]); - // Vertex AI가 아닌 경우 API 키 필수 - $authType = $validated['options']['auth_type'] ?? 'api_key'; - if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { - return response()->json([ - 'ok' => false, - 'message' => 'API 키를 입력해주세요.', - ], 422); + // GCS의 경우 별도 검증 + if ($validated['provider'] === 'gcs') { + if (empty($validated['options']['bucket_name'])) { + return response()->json([ + 'ok' => false, + 'message' => '버킷 이름을 입력해주세요.', + ], 422); + } + $validated['model'] = '-'; + $validated['api_key'] = 'gcs_service_account'; + } else { + // AI 설정: Vertex AI가 아닌 경우 API 키 필수 + $authType = $validated['options']['auth_type'] ?? 'api_key'; + if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { + return response()->json([ + 'ok' => false, + 'message' => 'API 키를 입력해주세요.', + ], 422); + } } // 활성화 시 동일 provider의 다른 설정 비활성화 @@ -225,4 +266,114 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr 'error' => 'API 응답 오류: ' . $response->status(), ]; } + + /** + * GCS 연결 테스트 + */ + public function testGcs(Request $request): JsonResponse + { + $validated = $request->validate([ + 'bucket_name' => 'required|string', + 'service_account_path' => 'nullable|string', + 'service_account_json' => 'nullable|array', + ]); + + try { + $bucketName = $validated['bucket_name']; + $serviceAccount = null; + + // 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로) + if (!empty($validated['service_account_json'])) { + $serviceAccount = $validated['service_account_json']; + } elseif (!empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) { + $serviceAccount = json_decode(file_get_contents($validated['service_account_path']), true); + } + + if (!$serviceAccount) { + return response()->json([ + 'ok' => false, + 'error' => '서비스 계정 정보를 찾을 수 없습니다.', + ]); + } + + // OAuth 토큰 획득 + $accessToken = $this->getGcsAccessToken($serviceAccount); + if (!$accessToken) { + return response()->json([ + 'ok' => false, + 'error' => 'OAuth 토큰 획득 실패', + ]); + } + + // 버킷 존재 확인 + $response = \Illuminate\Support\Facades\Http::timeout(10) + ->withHeaders(['Authorization' => 'Bearer ' . $accessToken]) + ->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}"); + + if ($response->successful()) { + return response()->json([ + 'ok' => true, + 'message' => "GCS 연결 성공! 버킷: {$bucketName}", + ]); + } + + return response()->json([ + 'ok' => false, + 'error' => '버킷 접근 실패: ' . $response->status(), + ]); + + } catch (\Exception $e) { + return response()->json([ + 'ok' => false, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * GCS OAuth 토큰 획득 + */ + private function getGcsAccessToken(array $serviceAccount): ?string + { + $now = time(); + $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $jwtClaim = $this->base64UrlEncode(json_encode([ + 'iss' => $serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/devstorage.full_control', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now + ])); + + $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); + if (!$privateKey) { + return null; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + if (PHP_VERSION_ID < 80000) { + openssl_free_key($privateKey); + } + + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature); + + $response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ]); + + if ($response->successful()) { + return $response->json('access_token'); + } + + return null; + } + + /** + * Base64 URL 인코딩 + */ + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } } diff --git a/app/Models/System/AiConfig.php b/app/Models/System/AiConfig.php index 98d11863..36ff541f 100644 --- a/app/Models/System/AiConfig.php +++ b/app/Models/System/AiConfig.php @@ -51,6 +51,7 @@ class AiConfig extends Model 'gemini' => 'https://generativelanguage.googleapis.com/v1beta', 'claude' => 'https://api.anthropic.com/v1', 'openai' => 'https://api.openai.com/v1', + 'gcs' => 'https://storage.googleapis.com', ]; /** @@ -60,8 +61,19 @@ class AiConfig extends Model 'gemini' => 'gemini-2.0-flash', 'claude' => 'claude-sonnet-4-20250514', 'openai' => 'gpt-4o', + 'gcs' => '-', ]; + /** + * AI Provider 목록 (GCS 제외) + */ + public const AI_PROVIDERS = ['gemini', 'claude', 'openai']; + + /** + * 스토리지 Provider 목록 + */ + public const STORAGE_PROVIDERS = ['gcs']; + /** * 활성화된 Gemini 설정 조회 */ @@ -109,10 +121,53 @@ public function getProviderLabelAttribute(): string 'gemini' => 'Google Gemini', 'claude' => 'Anthropic Claude', 'openai' => 'OpenAI', + 'gcs' => 'Google Cloud Storage', default => $this->provider, }; } + /** + * 활성화된 GCS 설정 조회 + */ + public static function getActiveGcs(): ?self + { + return self::where('provider', 'gcs') + ->where('is_active', true) + ->first(); + } + + /** + * GCS 버킷 이름 + */ + public function getBucketName(): ?string + { + return $this->options['bucket_name'] ?? null; + } + + /** + * GCS 서비스 계정 JSON (직접 저장된 경우) + */ + public function getServiceAccountJson(): ?array + { + return $this->options['service_account_json'] ?? null; + } + + /** + * GCS 설정인지 확인 + */ + public function isGcs(): bool + { + return $this->provider === 'gcs'; + } + + /** + * AI 설정인지 확인 + */ + public function isAi(): bool + { + return in_array($this->provider, self::AI_PROVIDERS); + } + /** * 상태 라벨 */ diff --git a/app/Services/GoogleCloudStorageService.php b/app/Services/GoogleCloudStorageService.php index fa60cc9d..2b79e8cf 100644 --- a/app/Services/GoogleCloudStorageService.php +++ b/app/Services/GoogleCloudStorageService.php @@ -2,12 +2,13 @@ namespace App\Services; +use App\Models\System\AiConfig; use Illuminate\Support\Facades\Log; /** * Google Cloud Storage 업로드 서비스 * - * 레거시 PHP 코드와 동일한 방식으로 GCS에 파일을 업로드합니다. + * DB 설정(ai_configs 테이블)을 우선 사용하고, 없으면 레거시 파일 설정을 사용합니다. * JWT 인증 방식 사용. */ class GoogleCloudStorageService @@ -22,21 +23,47 @@ public function __construct() /** * GCS 설정 로드 + * + * 우선순위: + * 1. DB 설정 (ai_configs 테이블의 활성화된 gcs provider) + * 2. 레거시 파일 설정 (/sales/apikey/) */ private function loadConfig(): void { - // GCS 버킷 설정 + // 1. DB 설정 확인 + $dbConfig = AiConfig::getActiveGcs(); + + if ($dbConfig) { + $this->bucketName = $dbConfig->getBucketName(); + + // 서비스 계정: JSON 직접 입력 또는 파일 경로 + if ($dbConfig->getServiceAccountJson()) { + $this->serviceAccount = $dbConfig->getServiceAccountJson(); + } elseif ($dbConfig->getServiceAccountPath() && file_exists($dbConfig->getServiceAccountPath())) { + $this->serviceAccount = json_decode(file_get_contents($dbConfig->getServiceAccountPath()), true); + } + + if ($this->serviceAccount) { + Log::debug('GCS 설정 로드: DB (활성화된 설정: ' . $dbConfig->name . ')'); + return; + } + } + + // 2. 레거시 파일 설정 (fallback) $gcsConfigPath = base_path('../sales/apikey/gcs_config.txt'); if (file_exists($gcsConfigPath)) { $config = parse_ini_file($gcsConfigPath); $this->bucketName = $config['bucket_name'] ?? null; } - // 서비스 계정 로드 $serviceAccountPath = base_path('../sales/apikey/google_service_account.json'); if (file_exists($serviceAccountPath)) { $this->serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); } + + if ($this->bucketName && $this->serviceAccount) { + Log::debug('GCS 설정 로드: 레거시 파일'); + } } /** diff --git a/resources/views/system/ai-config/index.blade.php b/resources/views/system/ai-config/index.blade.php index 791a021f..dfb99fa9 100644 --- a/resources/views/system/ai-config/index.blade.php +++ b/resources/views/system/ai-config/index.blade.php @@ -62,14 +62,14 @@ @endpush @section('content') -
+
-

AI 설정 관리

-

AI API 키 및 모델 설정을 관리합니다

+

AI 및 스토리지 설정

+

AI API 및 클라우드 스토리지 설정을 관리합니다

-
- -
+ +
+ + +
+ + +
@forelse($configs as $config)
@@ -132,7 +158,7 @@ @endforelse
- +

사용 안내

    @@ -142,6 +168,147 @@
  • 테스트 버튼으로 API 연결 상태를 확인할 수 있습니다.
+
+ + +
+ @forelse($storageConfigs as $config) +
+
+
+
+

{{ $config->name }}

+ + Google Cloud Storage + + + {{ $config->status_label }} + +
+
+

버킷: {{ $config->getBucketName() ?? '-' }}

+

서비스 계정: + @if($config->getServiceAccountPath()) + 파일 경로: {{ $config->getServiceAccountPath() }} + @elseif($config->getServiceAccountJson()) + JSON 직접 입력됨 + @else + 미설정 + @endif +

+ @if($config->description) +

설명: {{ $config->description }}

+ @endif +
+
+
+ + + + +
+
+
+ @empty +
+ + + +

등록된 GCS 설정이 없습니다.

+

'새 설정 추가' 버튼을 클릭하여 Google Cloud Storage를 등록하세요.

+
+ @endforelse + + +
+

Google Cloud Storage 사용 안내

+
    +
  • 음성 녹음 파일(10MB 이상)은 GCS에 자동 백업됩니다.
  • +
  • GCP 콘솔에서 서비스 계정을 생성하고 Storage 권한을 부여하세요.
  • +
  • 서비스 계정 키(JSON)를 직접 입력하거나, 파일 경로를 지정할 수 있습니다.
  • +
  • 버킷은 미리 GCP 콘솔에서 생성해 두어야 합니다.
  • +
+
+
+
+ + + @@ -539,6 +706,189 @@ function toggleAuthTypeUI(provider, authType) { } } + // === GCS 설정 관련 함수들 === + + // GCS 모달 열기 + window.openGcsModal = function(config) { + const modal = document.getElementById('gcs-modal'); + const title = document.getElementById('gcs-modal-title'); + + if (config) { + title.textContent = 'GCS 설정 수정'; + document.getElementById('gcs-config-id').value = config.id; + document.getElementById('gcs-name').value = config.name; + document.getElementById('gcs-description').value = config.description || ''; + document.getElementById('gcs-is-active').checked = config.is_active; + + const options = config.options || {}; + document.getElementById('gcs-bucket-name').value = options.bucket_name || ''; + document.getElementById('gcs-service-account-path').value = options.service_account_path || ''; + + if (options.service_account_json) { + document.getElementById('gcs-auth-type').value = 'json'; + document.getElementById('gcs-service-account-json').value = JSON.stringify(options.service_account_json, null, 2); + toggleGcsAuthType('json'); + } else { + document.getElementById('gcs-auth-type').value = 'path'; + toggleGcsAuthType('path'); + } + } else { + title.textContent = 'GCS 설정 추가'; + document.getElementById('gcs-form').reset(); + document.getElementById('gcs-config-id').value = ''; + document.getElementById('gcs-service-account-path').value = '/var/www/sales/apikey/google_service_account.json'; + toggleGcsAuthType('path'); + } + + modal.classList.remove('hidden'); + }; + + // GCS 모달 닫기 + window.closeGcsModal = function() { + document.getElementById('gcs-modal').classList.add('hidden'); + }; + + // GCS 인증 방식 전환 + function toggleGcsAuthType(type) { + const pathSection = document.getElementById('gcs-path-section'); + const jsonSection = document.getElementById('gcs-json-section'); + + if (type === 'json') { + pathSection.classList.add('hidden'); + jsonSection.classList.remove('hidden'); + } else { + pathSection.classList.remove('hidden'); + jsonSection.classList.add('hidden'); + } + } + + // GCS 수정 + window.editGcsConfig = function(btn) { + try { + const config = JSON.parse(btn.dataset.config); + window.openGcsModal(config); + } catch (e) { + console.error('Config parse error:', e); + showToast('설정 데이터를 불러올 수 없습니다.', 'error'); + } + }; + + // GCS 연결 테스트 (목록) + window.testGcsConnection = function(id) { + showToast('GCS 수정 화면에서 테스트해주세요.', 'warning'); + }; + + // GCS 연결 테스트 (모달) + window.testGcsConnectionFromModal = async function() { + const authType = document.getElementById('gcs-auth-type').value; + const data = { + bucket_name: document.getElementById('gcs-bucket-name').value, + }; + + if (authType === 'json') { + try { + const jsonText = document.getElementById('gcs-service-account-json').value; + data.service_account_json = JSON.parse(jsonText); + } catch (e) { + showToast('JSON 형식이 올바르지 않습니다.', 'error'); + return; + } + } else { + data.service_account_path = document.getElementById('gcs-service-account-path').value; + } + + if (!data.bucket_name) { + showToast('버킷 이름을 입력해주세요.', 'error'); + return; + } + + showToast('GCS 연결 테스트 중...', 'info'); + + try { + const response = await fetch('{{ route("system.ai-config.test-gcs") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.ok) { + showToast(result.message, 'success'); + } else { + showToast(result.error || '연결 테스트 실패', 'error'); + } + } catch (error) { + showToast('테스트 중 오류가 발생했습니다.', 'error'); + } + }; + + // GCS 폼 제출 + async function handleGcsFormSubmit(e) { + e.preventDefault(); + + const id = document.getElementById('gcs-config-id').value; + const authType = document.getElementById('gcs-auth-type').value; + + const data = { + provider: 'gcs', + name: document.getElementById('gcs-name').value, + description: document.getElementById('gcs-description').value || null, + is_active: document.getElementById('gcs-is-active').checked, + options: { + bucket_name: document.getElementById('gcs-bucket-name').value, + } + }; + + if (authType === 'json') { + try { + const jsonText = document.getElementById('gcs-service-account-json').value; + data.options.service_account_json = JSON.parse(jsonText); + } catch (e) { + showToast('JSON 형식이 올바르지 않습니다.', 'error'); + return; + } + } else { + data.options.service_account_path = document.getElementById('gcs-service-account-path').value; + } + + if (!data.options.bucket_name) { + showToast('버킷 이름을 입력해주세요.', 'error'); + return; + } + + try { + const url = id + ? `{{ url('system/ai-config') }}/${id}` + : '{{ route("system.ai-config.store") }}'; + const method = id ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.ok) { + showToast(result.message, 'success'); + window.closeGcsModal(); + location.reload(); + } else { + showToast(result.message || '저장 실패', 'error'); + } + } catch (error) { + showToast('저장 중 오류가 발생했습니다.', 'error'); + } + } + // DOM 로드 후 이벤트 리스너 등록 document.addEventListener('DOMContentLoaded', function() { // 페이지 로드 시 모달 강제 닫기 @@ -546,6 +896,10 @@ function toggleAuthTypeUI(provider, authType) { if (modal) { modal.classList.add('hidden'); } + const gcsModal = document.getElementById('gcs-modal'); + if (gcsModal) { + gcsModal.classList.add('hidden'); + } // Provider 변경 시 기본 모델 업데이트 및 UI 전환 const providerEl = document.getElementById('config-provider'); @@ -578,6 +932,20 @@ function toggleAuthTypeUI(provider, authType) { formEl.addEventListener('submit', handleFormSubmit); } + // GCS 폼 제출 + const gcsFormEl = document.getElementById('gcs-form'); + if (gcsFormEl) { + gcsFormEl.addEventListener('submit', handleGcsFormSubmit); + } + + // GCS 인증 방식 변경 + const gcsAuthTypeEl = document.getElementById('gcs-auth-type'); + if (gcsAuthTypeEl) { + gcsAuthTypeEl.addEventListener('change', function() { + toggleGcsAuthType(this.value); + }); + } + // 모달 외부 클릭 시 닫지 않음 (의도치 않은 닫힘 방지) // 닫기 버튼이나 취소 버튼으로만 닫을 수 있음 }); diff --git a/routes/web.php b/routes/web.php index 2fb1ad02..45f4211f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -326,6 +326,7 @@ Route::delete('/{id}', [AiConfigController::class, 'destroy'])->name('destroy'); Route::post('/{id}/toggle', [AiConfigController::class, 'toggle'])->name('toggle'); Route::post('/test', [AiConfigController::class, 'test'])->name('test'); + Route::post('/test-gcs', [AiConfigController::class, 'testGcs'])->name('test-gcs'); }); // 명함 OCR API