diff --git a/app/Http/Controllers/System/AiConfigController.php b/app/Http/Controllers/System/AiConfigController.php index 3c74fdbb..4f7c052e 100644 --- a/app/Http/Controllers/System/AiConfigController.php +++ b/app/Http/Controllers/System/AiConfigController.php @@ -36,13 +36,27 @@ public function store(Request $request): JsonResponse $validated = $request->validate([ 'name' => 'required|string|max:50', 'provider' => 'required|string|in:gemini,claude,openai', - 'api_key' => 'required|string|max:255', + 'api_key' => 'nullable|string|max:255', 'model' => 'required|string|max:100', - 'base_url' => 'nullable|string|max:255|url', + '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.project_id' => 'nullable|string|max:100', + 'options.region' => 'nullable|string|max:50', + 'options.service_account_path' => 'nullable|string|max:500', ]); + // 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의 다른 설정 비활성화 if ($validated['is_active'] ?? false) { AiConfig::where('provider', $validated['provider']) @@ -68,13 +82,27 @@ public function update(Request $request, int $id): JsonResponse $validated = $request->validate([ 'name' => 'required|string|max:50', 'provider' => 'required|string|in:gemini,claude,openai', - 'api_key' => 'required|string|max:255', + 'api_key' => 'nullable|string|max:255', 'model' => 'required|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.project_id' => 'nullable|string|max:100', + 'options.region' => 'nullable|string|max:50', + 'options.service_account_path' => 'nullable|string|max:500', ]); + // 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의 다른 설정 비활성화 if ($validated['is_active'] ?? false) { AiConfig::where('provider', $validated['provider']) diff --git a/app/Models/System/AiConfig.php b/app/Models/System/AiConfig.php index 7fe5b9fd..98d11863 100644 --- a/app/Models/System/AiConfig.php +++ b/app/Models/System/AiConfig.php @@ -140,4 +140,47 @@ public function getMaskedApiKeyAttribute(): string return substr($this->api_key, 0, 8) . str_repeat('*', 8) . '...'; } + + /** + * Vertex AI 사용 여부 + */ + public function isVertexAi(): bool + { + return ($this->options['auth_type'] ?? 'api_key') === 'vertex_ai'; + } + + /** + * Vertex AI 프로젝트 ID + */ + public function getProjectId(): ?string + { + return $this->options['project_id'] ?? null; + } + + /** + * Vertex AI 리전 + */ + public function getRegion(): string + { + return $this->options['region'] ?? 'us-central1'; + } + + /** + * 서비스 계정 파일 경로 + */ + public function getServiceAccountPath(): ?string + { + return $this->options['service_account_path'] ?? null; + } + + /** + * 인증 방식 라벨 + */ + public function getAuthTypeLabelAttribute(): string + { + if ($this->isVertexAi()) { + return 'Vertex AI (서비스 계정)'; + } + return 'API 키'; + } } diff --git a/app/Services/BusinessCardOcrService.php b/app/Services/BusinessCardOcrService.php index 50fe53b9..d3ff331b 100644 --- a/app/Services/BusinessCardOcrService.php +++ b/app/Services/BusinessCardOcrService.php @@ -9,17 +9,6 @@ class BusinessCardOcrService { - /** - * Vertex AI 서비스 계정 파일 경로 - */ - private const SERVICE_ACCOUNT_PATH = '/var/www/html/storage/app/google_service_account.json'; - - /** - * Vertex AI 설정 - */ - private const VERTEX_AI_PROJECT_ID = 'codebridge-chatbot'; - private const VERTEX_AI_REGION = 'us-central1'; - /** * 명함 이미지에서 정보 추출 */ @@ -31,20 +20,29 @@ public function extractFromImage(string $base64Image): array throw new \RuntimeException('Gemini API 설정이 없습니다. 시스템 설정에서 AI 설정을 추가해주세요.'); } - return $this->callVertexAiApi($config, $base64Image); + // 인증 방식에 따라 다른 API 호출 + if ($config->isVertexAi()) { + return $this->callVertexAiApi($config, $base64Image); + } + + return $this->callGoogleAiStudioApi($config, $base64Image); } /** - * Vertex AI API 호출 (Google Cloud) + * Vertex AI API 호출 (Google Cloud - 서비스 계정 인증) */ private function callVertexAiApi(AiConfig $config, string $base64Image): array { $model = $config->model; - $projectId = self::VERTEX_AI_PROJECT_ID; - $region = self::VERTEX_AI_REGION; + $projectId = $config->getProjectId(); + $region = $config->getRegion(); + + if (!$projectId) { + throw new \RuntimeException('Vertex AI 프로젝트 ID가 설정되지 않았습니다.'); + } // 액세스 토큰 가져오기 - $accessToken = $this->getAccessToken(); + $accessToken = $this->getAccessToken($config); if (!$accessToken) { throw new \RuntimeException('Google Cloud 인증 실패'); } @@ -52,6 +50,33 @@ private function callVertexAiApi(AiConfig $config, string $base64Image): array // Vertex AI 엔드포인트 $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; + return $this->callGeminiApi($url, $base64Image, [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + ]); + } + + /** + * Google AI Studio API 호출 (API 키 인증) + */ + private function callGoogleAiStudioApi(AiConfig $config, string $base64Image): array + { + $model = $config->model; + $apiKey = $config->api_key; + $baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta'; + + $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; + + return $this->callGeminiApi($url, $base64Image, [ + 'Content-Type' => 'application/json', + ]); + } + + /** + * Gemini API 공통 호출 로직 + */ + private function callGeminiApi(string $url, string $base64Image, array $headers): array + { // Base64 데이터에서 prefix 제거 $imageData = $base64Image; $mimeType = 'image/jpeg'; @@ -65,10 +90,7 @@ private function callVertexAiApi(AiConfig $config, string $base64Image): array try { $response = Http::timeout(30) - ->withHeaders([ - 'Authorization' => 'Bearer ' . $accessToken, - 'Content-Type' => 'application/json', - ]) + ->withHeaders($headers) ->post($url, [ 'contents' => [ [ @@ -95,7 +117,7 @@ private function callVertexAiApi(AiConfig $config, string $base64Image): array ]); if (! $response->successful()) { - Log::error('Vertex AI API error', [ + Log::error('Gemini API error', [ 'status' => $response->status(), 'body' => $response->body(), ]); @@ -118,7 +140,7 @@ private function callVertexAiApi(AiConfig $config, string $base64Image): array 'raw_response' => $text, ]; } catch (ConnectionException $e) { - Log::error('Vertex AI API connection failed', ['error' => $e->getMessage()]); + Log::error('Gemini API connection failed', ['error' => $e->getMessage()]); throw new \RuntimeException('AI API 연결 실패'); } } @@ -126,20 +148,21 @@ private function callVertexAiApi(AiConfig $config, string $base64Image): array /** * 서비스 계정으로 OAuth2 액세스 토큰 가져오기 */ - private function getAccessToken(): ?string + private function getAccessToken(AiConfig $config): ?string { - // 여러 경로에서 서비스 계정 파일 찾기 (Docker 컨테이너 및 호스트) - $possiblePaths = [ - '/var/www/sales/apikey/google_service_account.json', // Docker 컨테이너 내 sales 볼륨 - self::SERVICE_ACCOUNT_PATH, + // DB에서 서비스 계정 경로 가져오기 + $configuredPath = $config->getServiceAccountPath(); + + // 여러 경로에서 서비스 계정 파일 찾기 + $possiblePaths = array_filter([ + $configuredPath, // DB에 설정된 경로 우선 + '/var/www/sales/apikey/google_service_account.json', storage_path('app/google_service_account.json'), - base_path('../sales/apikey/google_service_account.json'), - '/home/aweso/sam/sales/apikey/google_service_account.json', - ]; + ]); $serviceAccountPath = null; foreach ($possiblePaths as $path) { - if (file_exists($path)) { + if ($path && file_exists($path)) { $serviceAccountPath = $path; break; } diff --git a/resources/views/system/ai-config/index.blade.php b/resources/views/system/ai-config/index.blade.php index 6f0b2378..ca37c24c 100644 --- a/resources/views/system/ai-config/index.blade.php +++ b/resources/views/system/ai-config/index.blade.php @@ -94,7 +94,12 @@

모델: {{ $config->model }}

+

인증: {{ $config->auth_type_label }}

+ @if($config->isVertexAi()) +

프로젝트: {{ $config->getProjectId() }} ({{ $config->getRegion() }})

+ @else

API 키: {{ $config->masked_api_key }}

+ @endif @if($config->description)

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

@endif @@ -168,9 +173,42 @@
-
+ + + + +
- + +
+ + +
@@ -179,7 +217,7 @@

기본값: gemini-2.0-flash

-
+
@@ -249,6 +287,37 @@ function showToast(message, type = 'info') { openai: 'gpt-4o' }; + // 인증 방식에 따른 UI 전환 + function toggleAuthTypeUI(provider, authType) { + const authTypeSection = document.getElementById('auth-type-section'); + const apiKeySection = document.getElementById('api-key-section'); + const vertexAiSection = document.getElementById('vertex-ai-section'); + const baseUrlSection = document.getElementById('base-url-section'); + + // Gemini만 인증 방식 선택 가능 + if (provider === 'gemini') { + authTypeSection.classList.remove('hidden'); + + if (authType === 'vertex_ai') { + apiKeySection.classList.add('hidden'); + vertexAiSection.classList.remove('hidden'); + baseUrlSection.classList.add('hidden'); + document.getElementById('config-api-key').removeAttribute('required'); + } else { + apiKeySection.classList.remove('hidden'); + vertexAiSection.classList.add('hidden'); + baseUrlSection.classList.remove('hidden'); + document.getElementById('config-api-key').setAttribute('required', 'required'); + } + } else { + authTypeSection.classList.add('hidden'); + apiKeySection.classList.remove('hidden'); + vertexAiSection.classList.add('hidden'); + baseUrlSection.classList.remove('hidden'); + document.getElementById('config-api-key').setAttribute('required', 'required'); + } + } + // 모달 열기 window.openModal = function(config) { const modal = document.getElementById('config-modal'); @@ -260,15 +329,27 @@ function showToast(message, type = 'info') { document.getElementById('config-id').value = config.id; document.getElementById('config-provider').value = config.provider; document.getElementById('config-name').value = config.name; - document.getElementById('config-api-key').value = config.api_key; + document.getElementById('config-api-key').value = config.api_key || ''; document.getElementById('config-model').value = config.model; document.getElementById('config-base-url').value = config.base_url || ''; document.getElementById('config-description').value = config.description || ''; document.getElementById('config-is-active').checked = config.is_active; + + // Vertex AI 옵션 로드 + const options = config.options || {}; + const authType = options.auth_type || 'api_key'; + document.getElementById('config-auth-type').value = authType; + document.getElementById('config-project-id').value = options.project_id || ''; + document.getElementById('config-region').value = options.region || 'us-central1'; + document.getElementById('config-service-account-path').value = options.service_account_path || ''; + + toggleAuthTypeUI(config.provider, authType); } else { title.textContent = '새 설정 추가'; form.reset(); document.getElementById('config-id').value = ''; + document.getElementById('config-auth-type').value = 'api_key'; + toggleAuthTypeUI('gemini', 'api_key'); } modal.classList.remove('hidden'); @@ -390,16 +471,45 @@ function showToast(message, type = 'info') { e.preventDefault(); const id = document.getElementById('config-id').value; + const provider = document.getElementById('config-provider').value; + const authType = document.getElementById('config-auth-type').value; + const data = { - provider: document.getElementById('config-provider').value, + provider: provider, name: document.getElementById('config-name').value, - api_key: document.getElementById('config-api-key').value, model: document.getElementById('config-model').value, - base_url: document.getElementById('config-base-url').value || null, description: document.getElementById('config-description').value || null, - is_active: document.getElementById('config-is-active').checked + is_active: document.getElementById('config-is-active').checked, + options: {} }; + // Gemini + Vertex AI인 경우 + if (provider === 'gemini' && authType === 'vertex_ai') { + data.api_key = ''; // Vertex AI는 API 키 불필요 + data.base_url = null; + data.options = { + auth_type: 'vertex_ai', + project_id: document.getElementById('config-project-id').value, + region: document.getElementById('config-region').value, + service_account_path: document.getElementById('config-service-account-path').value + }; + + // 필수 값 검증 + if (!data.options.project_id || !data.options.service_account_path) { + showToast('프로젝트 ID와 서비스 계정 경로를 입력해주세요.', 'error'); + return; + } + } else { + data.api_key = document.getElementById('config-api-key').value; + data.base_url = document.getElementById('config-base-url').value || null; + data.options = { auth_type: 'api_key' }; + + if (!data.api_key) { + showToast('API 키를 입력해주세요.', 'error'); + return; + } + } + try { const url = id ? `{{ url('system/ai-config') }}/${id}` @@ -437,13 +547,28 @@ function showToast(message, type = 'info') { modal.classList.add('hidden'); } - // Provider 변경 시 기본 모델 업데이트 + // Provider 변경 시 기본 모델 업데이트 및 UI 전환 const providerEl = document.getElementById('config-provider'); if (providerEl) { providerEl.addEventListener('change', function() { const provider = this.value; document.getElementById('default-model').textContent = defaultModels[provider]; document.getElementById('config-model').placeholder = '예: ' + defaultModels[provider]; + + // Gemini가 아니면 API 키 모드로 강제 + if (provider !== 'gemini') { + document.getElementById('config-auth-type').value = 'api_key'; + } + toggleAuthTypeUI(provider, document.getElementById('config-auth-type').value); + }); + } + + // 인증 방식 변경 시 UI 전환 + const authTypeEl = document.getElementById('config-auth-type'); + if (authTypeEl) { + authTypeEl.addEventListener('change', function() { + const provider = document.getElementById('config-provider').value; + toggleAuthTypeUI(provider, this.value); }); }