isVertexAi()) { return $this->callVertexAiApi($config, $base64Image); } return $this->callGoogleAiStudioApi($config, $base64Image); } /** * Vertex AI API 호출 (Google Cloud - 서비스 계정 인증) */ private function callVertexAiApi(AiConfig $config, string $base64Image): array { $model = $config->model; $projectId = $config->getProjectId(); $region = $config->getRegion(); if (! $projectId) { throw new \RuntimeException('Vertex AI 프로젝트 ID가 설정되지 않았습니다.'); } // 액세스 토큰 가져오기 $accessToken = $this->getAccessToken($config); if (! $accessToken) { throw new \RuntimeException('Google Cloud 인증 실패'); } // 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', ], true); // Vertex AI } /** * 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', ], false); // Google AI Studio } /** * Gemini API 공통 호출 로직 */ private function callGeminiApi(string $url, string $base64Image, array $headers, bool $isVertexAi = false): array { // Base64 데이터에서 prefix 제거 $imageData = $base64Image; $mimeType = 'image/jpeg'; if (preg_match('/^data:(image\/\w+);base64,/', $base64Image, $matches)) { $mimeType = $matches[1]; $imageData = preg_replace('/^data:image\/\w+;base64,/', '', $base64Image); } $prompt = $this->buildPrompt(); // Vertex AI는 role 필드 필요 $content = [ 'parts' => [ [ 'inlineData' => [ 'mimeType' => $mimeType, 'data' => $imageData, ], ], [ 'text' => $prompt, ], ], ]; if ($isVertexAi) { $content['role'] = 'user'; } try { $response = Http::timeout(30) ->withHeaders($headers) ->post($url, [ 'contents' => [$content], 'generationConfig' => [ 'temperature' => 0.1, 'topK' => 40, 'topP' => 0.95, 'maxOutputTokens' => 1024, 'responseMimeType' => 'application/json', ], ]); if (! $response->successful()) { Log::error('Gemini API error', [ 'status' => $response->status(), 'body' => $response->body(), ]); throw new \RuntimeException('AI API 호출 실패: '.$response->status()); } $result = $response->json(); // 토큰 사용량 저장 AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? 'gemini', '명함OCR'); $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? ''; // JSON 파싱 $parsed = json_decode($text, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::warning('AI response JSON parse failed', ['text' => $text]); throw new \RuntimeException('AI 응답 파싱 실패'); } return [ 'ok' => true, 'data' => $this->normalizeData($parsed), 'raw_response' => $text, ]; } catch (ConnectionException $e) { Log::error('Gemini API connection failed', ['error' => $e->getMessage()]); throw new \RuntimeException('AI API 연결 실패'); } } /** * 서비스 계정으로 OAuth2 액세스 토큰 가져오기 */ private function getAccessToken(AiConfig $config): ?string { // DB에서 서비스 계정 경로 가져오기 $configuredPath = $config->getServiceAccountPath(); // 여러 경로에서 서비스 계정 파일 찾기 $possiblePaths = array_filter([ $configuredPath, // DB에 설정된 경로 우선 '/var/www/sales/apikey/google_service_account.json', storage_path('app/google_service_account.json'), ]); $serviceAccountPath = null; foreach ($possiblePaths as $path) { if ($path && file_exists($path)) { $serviceAccountPath = $path; break; } } if (! $serviceAccountPath) { Log::error('Service account file not found', ['tried_paths' => $possiblePaths]); return null; } $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); if (! $serviceAccount) { Log::error('Service account JSON parse failed'); return null; } // JWT 생성 $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/cloud-platform', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now, ])); $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); if (! $privateKey) { Log::error('Failed to load private key'); return null; } openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); $jwt = $jwtHeader.'.'.$jwtClaim.'.'.$this->base64UrlEncode($signature); // OAuth 토큰 요청 try { $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]); if ($response->successful()) { $data = $response->json(); return $data['access_token'] ?? null; } Log::error('OAuth token request failed', [ 'status' => $response->status(), 'body' => $response->body(), ]); return null; } catch (\Exception $e) { Log::error('OAuth token request exception', ['error' => $e->getMessage()]); return null; } } /** * Base64 URL 인코딩 */ private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } /** * OCR 프롬프트 생성 */ private function buildPrompt(): string { return <<<'PROMPT' 이 명함 이미지에서 다음 정보를 추출해주세요. ## 추출 항목 1. company_name: 회사명/상호 2. ceo_name: 대표자명/담당자명 (명함에 있는 이름) 3. business_number: 사업자등록번호 (000-00-00000 형식) 4. contact_phone: 연락처/전화번호 5. contact_email: 이메일 6. address: 주소 7. position: 직책 8. department: 부서 ## 규칙 1. 정보가 없으면 빈 문자열("")로 응답 2. 사업자번호는 10자리 숫자를 000-00-00000 형식으로 변환 3. 전화번호는 하이픈 포함 형식 유지 4. 한국어로 된 정보를 우선 추출 ## 출력 형식 (JSON) { "company_name": "", "ceo_name": "", "business_number": "", "contact_phone": "", "contact_email": "", "address": "", "position": "", "department": "" } JSON 형식으로만 응답하세요. PROMPT; } /** * 추출된 데이터 정규화 */ private function normalizeData(array $data): array { // 사업자번호 정규화 if (! empty($data['business_number'])) { $digits = preg_replace('/\D/', '', $data['business_number']); if (strlen($digits) === 10) { $data['business_number'] = substr($digits, 0, 3).'-'.substr($digits, 3, 2).'-'.substr($digits, 5); } } return [ 'company_name' => trim($data['company_name'] ?? ''), 'ceo_name' => trim($data['ceo_name'] ?? ''), 'business_number' => trim($data['business_number'] ?? ''), 'contact_phone' => trim($data['contact_phone'] ?? ''), 'contact_email' => trim($data['contact_email'] ?? ''), 'address' => trim($data['address'] ?? ''), 'position' => trim($data['position'] ?? ''), 'department' => trim($data['department'] ?? ''), ]; } }