loadServiceAccount(); } /** * 서비스 계정 로드 */ private function loadServiceAccount(): void { $path = config('services.google.credentials_path'); if ($path && file_exists($path)) { $this->serviceAccount = json_decode(file_get_contents($path), true); } } /** * OAuth 토큰 발급 */ private function getAccessToken(): ?string { // 캐시된 토큰이 유효하면 재사용 if ($this->accessToken && $this->tokenExpiry && time() < $this->tokenExpiry - 60) { return $this->accessToken; } if (! $this->serviceAccount) { Log::error('Google Cloud: 서비스 계정 파일이 없습니다.'); return null; } try { $now = time(); $jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); $jwtClaim = base64_encode(json_encode([ 'iss' => $this->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($this->serviceAccount['private_key']); if (! $privateKey) { Log::error('Google Cloud: 개인 키 읽기 실패'); return null; } openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); $jwt = $jwtHeader.'.'.$jwtClaim.'.'.base64_encode($signature); $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(); $this->accessToken = $data['access_token']; $this->tokenExpiry = $now + ($data['expires_in'] ?? 3600); return $this->accessToken; } Log::error('Google Cloud: OAuth 토큰 발급 실패', ['response' => $response->body()]); return null; } catch (\Exception $e) { Log::error('Google Cloud: OAuth 토큰 발급 예외', ['error' => $e->getMessage()]); return null; } } /** * GCS에 파일 업로드 */ public function uploadToStorage(string $localPath, string $objectName): ?string { $token = $this->getAccessToken(); if (! $token) { return null; } $bucket = config('services.google.storage_bucket'); if (! $bucket) { Log::error('Google Cloud: Storage 버킷 설정 없음'); return null; } try { $fileContent = file_get_contents($localPath); $mimeType = mime_content_type($localPath) ?: 'audio/webm'; $uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/'. urlencode($bucket).'/o?uploadType=media&name='. urlencode($objectName); $response = Http::withToken($token) ->withHeaders(['Content-Type' => $mimeType]) ->withBody($fileContent, $mimeType) ->post($uploadUrl); if ($response->successful()) { return 'gs://'.$bucket.'/'.$objectName; } Log::error('Google Cloud: Storage 업로드 실패', ['response' => $response->body()]); return null; } catch (\Exception $e) { Log::error('Google Cloud: Storage 업로드 예외', ['error' => $e->getMessage()]); return null; } } /** * Base64 오디오를 GCS에 업로드 */ public function uploadBase64Audio(string $base64Audio, string $objectName): ?string { // Base64 데이터 파싱 $audioData = $base64Audio; if (preg_match('/^data:audio\/\w+;base64,(.+)$/', $base64Audio, $matches)) { $audioData = $matches[1]; } // 임시 파일 생성 $tempPath = storage_path('app/temp/'.uniqid('audio_').'.webm'); $tempDir = dirname($tempPath); if (! is_dir($tempDir)) { mkdir($tempDir, 0755, true); } file_put_contents($tempPath, base64_decode($audioData)); // GCS 업로드 $gcsUri = $this->uploadToStorage($tempPath, $objectName); // 임시 파일 삭제 @unlink($tempPath); return $gcsUri; } /** * Speech-to-Text API 호출 */ public function speechToText(string $gcsUri, string $languageCode = 'ko-KR'): ?string { $token = $this->getAccessToken(); if (! $token) { return null; } try { // 긴 오디오는 비동기 처리 (LongRunningRecognize) $response = Http::withToken($token) ->post('https://speech.googleapis.com/v1/speech:longrunningrecognize', [ 'config' => [ 'encoding' => 'WEBM_OPUS', 'sampleRateHertz' => 48000, 'languageCode' => $languageCode, 'enableAutomaticPunctuation' => true, 'model' => 'latest_long', ], 'audio' => [ 'uri' => $gcsUri, ], ]); if (! $response->successful()) { Log::error('Google Cloud: STT 요청 실패', ['response' => $response->body()]); return null; } $operation = $response->json(); $operationName = $operation['name'] ?? null; if (! $operationName) { Log::error('Google Cloud: STT 작업 이름 없음'); return null; } // 작업 완료 대기 (폴링) return $this->waitForSttOperation($operationName); } catch (\Exception $e) { Log::error('Google Cloud: STT 예외', ['error' => $e->getMessage()]); return null; } } /** * STT 작업 완료 대기 */ private function waitForSttOperation(string $operationName, int $maxAttempts = 60): ?string { $token = $this->getAccessToken(); if (! $token) { return null; } for ($i = 0; $i < $maxAttempts; $i++) { sleep(5); // 5초 대기 $response = Http::withToken($token) ->get("https://speech.googleapis.com/v1/operations/{$operationName}"); if (! $response->successful()) { continue; } $result = $response->json(); if (isset($result['done']) && $result['done']) { if (isset($result['error'])) { Log::error('Google Cloud: STT 작업 실패', ['error' => $result['error']]); return null; } // 결과 텍스트 추출 $transcript = ''; $results = $result['response']['results'] ?? []; foreach ($results as $res) { $alternatives = $res['alternatives'] ?? []; if (! empty($alternatives)) { $transcript .= $alternatives[0]['transcript'] ?? ''; } } return $transcript; } } Log::error('Google Cloud: STT 작업 타임아웃'); return null; } /** * GCS 파일 삭제 */ public function deleteFromStorage(string $objectName): bool { $token = $this->getAccessToken(); if (! $token) { return false; } $bucket = config('services.google.storage_bucket'); if (! $bucket) { return false; } try { $deleteUrl = 'https://storage.googleapis.com/storage/v1/b/'. urlencode($bucket).'/o/'.urlencode($objectName); $response = Http::withToken($token)->delete($deleteUrl); return $response->successful(); } catch (\Exception $e) { Log::error('Google Cloud: Storage 삭제 예외', ['error' => $e->getMessage()]); return false; } } /** * 서비스 사용 가능 여부 */ public function isAvailable(): bool { return $this->serviceAccount !== null && $this->getAccessToken() !== null; } }