From dd86d70503e68ada5ec253c07d03253f21286462 Mon Sep 17 00:00:00 2001 From: pro Date: Thu, 29 Jan 2026 09:15:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EC=9D=8C=EC=84=B1=20=EB=85=B9=EC=9D=8C=20?= =?UTF-8?q?GCS=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GoogleCloudStorageService 생성 (레거시 방식 JWT 인증) - 10MB 이상 파일은 Google Cloud Storage에 백업 (본사 연구용) - 오디오/파일 다운로드 라우트 추가 - voice-recorder.blade.php 인라인 x-data로 변경 (HTMX 호환) - SalesConsultation 모델에 gcs_uri 필드 추가 Co-Authored-By: Claude Opus 4.5 --- .../Sales/ConsultationController.php | 92 ++- app/Models/Sales/SalesConsultation.php | 15 +- app/Services/GoogleCloudStorageService.php | 247 ++++++++ .../sales/modals/voice-recorder.blade.php | 531 +++++++++--------- routes/web.php | 2 + 5 files changed, 601 insertions(+), 286 deletions(-) create mode 100644 app/Services/GoogleCloudStorageService.php diff --git a/app/Http/Controllers/Sales/ConsultationController.php b/app/Http/Controllers/Sales/ConsultationController.php index 33a1e610..bb45e877 100644 --- a/app/Http/Controllers/Sales/ConsultationController.php +++ b/app/Http/Controllers/Sales/ConsultationController.php @@ -5,8 +5,10 @@ use App\Http\Controllers\Controller; use App\Models\Sales\SalesConsultation; use App\Models\Tenants\Tenant; +use App\Services\GoogleCloudStorageService; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Response; use Illuminate\Support\Facades\Storage; use Illuminate\View\View; @@ -88,8 +90,10 @@ public function destroy(int $consultationId, Request $request): JsonResponse /** * 음성 파일 업로드 + * + * 10MB 이상 파일은 Google Cloud Storage에 업로드하여 본사 연구용으로 보관합니다. */ - public function uploadAudio(Request $request): JsonResponse + public function uploadAudio(Request $request, GoogleCloudStorageService $gcs): JsonResponse { $request->validate([ 'tenant_id' => 'required|integer|exists:tenants,id', @@ -109,18 +113,30 @@ public function uploadAudio(Request $request): JsonResponse // 파일 저장 $file = $request->file('audio'); $fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension(); - $path = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local'); + $localPath = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local'); + $fileSize = $file->getSize(); + + // 10MB 이상 파일은 GCS에도 업로드 (본사 연구용) + $gcsUri = null; + $maxLocalSize = 10 * 1024 * 1024; // 10MB + + if ($fileSize > $maxLocalSize && $gcs->isAvailable()) { + $gcsObjectName = "consultations/{$tenantId}/{$scenarioType}/{$fileName}"; + $localFullPath = Storage::disk('local')->path($localPath); + $gcsUri = $gcs->upload($localFullPath, $gcsObjectName); + } // DB에 저장 $consultation = SalesConsultation::createAudio( $tenantId, $scenarioType, $stepId, - $path, + $localPath, $fileName, - $file->getSize(), + $fileSize, $transcript, - $duration + $duration, + $gcsUri ); $consultation->load('creator'); @@ -136,6 +152,7 @@ public function uploadAudio(Request $request): JsonResponse 'formatted_duration' => $consultation->formatted_duration, 'created_by_name' => $consultation->creator->name, 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + 'has_gcs' => !empty($gcsUri), ], ]); } @@ -196,4 +213,69 @@ public function deleteFile(int $fileId, Request $request): JsonResponse { return $this->destroy($fileId, $request); } + + /** + * 오디오 파일 다운로드 + */ + public function downloadAudio(int $consultationId, GoogleCloudStorageService $gcs): Response + { + $consultation = SalesConsultation::findOrFail($consultationId); + + if ($consultation->consultation_type !== 'audio') { + abort(400, '오디오 파일이 아닙니다.'); + } + + // GCS에 저장된 경우 서명된 URL로 리다이렉트 + if ($consultation->gcs_uri) { + $objectName = str_replace('gs://' . $gcs->getBucketName() . '/', '', $consultation->gcs_uri); + $signedUrl = $gcs->getSignedUrl($objectName, 60); + + if ($signedUrl) { + return response()->redirectTo($signedUrl); + } + } + + // 로컬 파일 다운로드 + $localPath = Storage::disk('local')->path($consultation->file_path); + + if (!file_exists($localPath)) { + abort(404, '파일을 찾을 수 없습니다.'); + } + + $extension = pathinfo($consultation->file_name, PATHINFO_EXTENSION) ?: 'webm'; + $mimeTypes = [ + 'webm' => 'audio/webm', + 'wav' => 'audio/wav', + 'mp3' => 'audio/mpeg', + 'ogg' => 'audio/ogg', + 'm4a' => 'audio/mp4' + ]; + $contentType = $mimeTypes[$extension] ?? 'audio/webm'; + + $downloadFileName = '상담녹음_' . $consultation->created_at->format('Ymd_His') . '.' . $extension; + + return response()->download($localPath, $downloadFileName, [ + 'Content-Type' => $contentType, + ]); + } + + /** + * 첨부파일 다운로드 + */ + public function downloadFile(int $consultationId): Response + { + $consultation = SalesConsultation::findOrFail($consultationId); + + if ($consultation->consultation_type !== 'file') { + abort(400, '첨부파일이 아닙니다.'); + } + + $localPath = Storage::disk('local')->path($consultation->file_path); + + if (!file_exists($localPath)) { + abort(404, '파일을 찾을 수 없습니다.'); + } + + return response()->download($localPath, $consultation->file_name); + } } diff --git a/app/Models/Sales/SalesConsultation.php b/app/Models/Sales/SalesConsultation.php index 5f5dbf46..0f141e40 100644 --- a/app/Models/Sales/SalesConsultation.php +++ b/app/Models/Sales/SalesConsultation.php @@ -44,6 +44,7 @@ class SalesConsultation extends Model 'file_type', 'transcript', 'duration', + 'gcs_uri', 'created_by', ]; @@ -93,6 +94,16 @@ public static function createText(int $tenantId, string $scenarioType, ?int $ste /** * 음성 상담 기록 생성 + * + * @param int $tenantId 테넌트 ID + * @param string $scenarioType 시나리오 타입 (sales/manager) + * @param int|null $stepId 단계 ID + * @param string $filePath 로컬 파일 경로 + * @param string $fileName 파일명 + * @param int $fileSize 파일 크기 + * @param string|null $transcript 음성 텍스트 변환 결과 + * @param int|null $duration 녹음 시간 (초) + * @param string|null $gcsUri GCS URI (본사 연구용 백업) */ public static function createAudio( int $tenantId, @@ -102,7 +113,8 @@ public static function createAudio( string $fileName, int $fileSize, ?string $transcript = null, - ?int $duration = null + ?int $duration = null, + ?string $gcsUri = null ): self { return self::create([ 'tenant_id' => $tenantId, @@ -115,6 +127,7 @@ public static function createAudio( 'file_type' => 'audio/webm', 'transcript' => $transcript, 'duration' => $duration, + 'gcs_uri' => $gcsUri, 'created_by' => auth()->id(), ]); } diff --git a/app/Services/GoogleCloudStorageService.php b/app/Services/GoogleCloudStorageService.php new file mode 100644 index 00000000..fa60cc9d --- /dev/null +++ b/app/Services/GoogleCloudStorageService.php @@ -0,0 +1,247 @@ +loadConfig(); + } + + /** + * GCS 설정 로드 + */ + private function loadConfig(): void + { + // GCS 버킷 설정 + $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); + } + } + + /** + * GCS가 사용 가능한지 확인 + */ + public function isAvailable(): bool + { + return $this->bucketName !== null && $this->serviceAccount !== null; + } + + /** + * GCS에 파일 업로드 + * + * @param string $filePath 로컬 파일 경로 + * @param string $objectName GCS에 저장할 객체 이름 + * @return string|null GCS URI (gs://bucket/object) 또는 실패 시 null + */ + public function upload(string $filePath, string $objectName): ?string + { + if (!$this->isAvailable()) { + Log::warning('GCS 업로드 실패: 설정되지 않음'); + return null; + } + + if (!file_exists($filePath)) { + Log::error('GCS 업로드 실패: 파일 없음 - ' . $filePath); + return null; + } + + // OAuth 2.0 토큰 생성 + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return null; + } + + // GCS에 파일 업로드 + $fileContent = file_get_contents($filePath); + $mimeType = mime_content_type($filePath) ?: 'application/octet-stream'; + + $uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/' . + urlencode($this->bucketName) . '/o?uploadType=media&name=' . + urlencode($objectName); + + $ch = curl_init($uploadUrl); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: ' . $mimeType, + 'Content-Length: ' . strlen($fileContent) + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContent); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 300); // 5분 타임아웃 + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($httpCode === 200) { + $gcsUri = 'gs://' . $this->bucketName . '/' . $objectName; + Log::info('GCS 업로드 성공: ' . $gcsUri); + return $gcsUri; + } + + Log::error('GCS 업로드 실패 (HTTP ' . $httpCode . '): ' . ($error ?: $response)); + return null; + } + + /** + * GCS에서 서명된 다운로드 URL 생성 + * + * @param string $objectName GCS 객체 이름 + * @param int $expiresInMinutes URL 유효 시간 (분) + * @return string|null 서명된 URL 또는 실패 시 null + */ + public function getSignedUrl(string $objectName, int $expiresInMinutes = 60): ?string + { + if (!$this->isAvailable()) { + return null; + } + + $expiration = time() + ($expiresInMinutes * 60); + $stringToSign = "GET\n\n\n{$expiration}\n/{$this->bucketName}/{$objectName}"; + + $privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']); + if (!$privateKey) { + Log::error('GCS URL 서명 실패: 개인 키 읽기 오류'); + return null; + } + + openssl_sign($stringToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256); + if (PHP_VERSION_ID < 80000) { + openssl_free_key($privateKey); + } + + $encodedSignature = urlencode(base64_encode($signature)); + $clientEmail = urlencode($this->serviceAccount['client_email']); + + return "https://storage.googleapis.com/{$this->bucketName}/{$objectName}" . + "?GoogleAccessId={$clientEmail}" . + "&Expires={$expiration}" . + "&Signature={$encodedSignature}"; + } + + /** + * GCS에서 파일 삭제 + * + * @param string $objectName GCS 객체 이름 + * @return bool 성공 여부 + */ + public function delete(string $objectName): bool + { + if (!$this->isAvailable()) { + return false; + } + + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return false; + } + + $deleteUrl = 'https://storage.googleapis.com/storage/v1/b/' . + urlencode($this->bucketName) . '/o/' . + urlencode($objectName); + + $ch = curl_init($deleteUrl); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken, + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return $httpCode === 204 || $httpCode === 200; + } + + /** + * OAuth 2.0 액세스 토큰 획득 + */ + private function getAccessToken(): ?string + { + // JWT 생성 + $now = time(); + $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $jwtClaim = $this->base64UrlEncode(json_encode([ + 'iss' => $this->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($this->serviceAccount['private_key']); + if (!$privateKey) { + Log::error('GCS 토큰 실패: 개인 키 읽기 오류'); + 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); + + // OAuth 토큰 요청 + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ])); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + Log::error('GCS 토큰 실패: HTTP ' . $httpCode); + return null; + } + + $data = json_decode($response, true); + return $data['access_token'] ?? null; + } + + /** + * Base64 URL 인코딩 + */ + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * 버킷 이름 반환 + */ + public function getBucketName(): ?string + { + return $this->bucketName; + } +} diff --git a/resources/views/sales/modals/voice-recorder.blade.php b/resources/views/sales/modals/voice-recorder.blade.php index f6c1041a..b90af34e 100644 --- a/resources/views/sales/modals/voice-recorder.blade.php +++ b/resources/views/sales/modals/voice-recorder.blade.php @@ -1,5 +1,254 @@ {{-- 음성 녹음 컴포넌트 --}} -
+

@@ -79,285 +328,7 @@ class="flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg ho {{-- 저장된 녹음 목록 안내 --}}

- 녹음 파일은 상담 기록에 자동으로 저장됩니다. + 녹음 파일은 상담 기록에 저장되며, 10MB 이상은 GCS에 백업됩니다.

- - diff --git a/routes/web.php b/routes/web.php index 2b66a2c1..2fb1ad02 100644 --- a/routes/web.php +++ b/routes/web.php @@ -808,6 +808,8 @@ Route::post('/upload-audio', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadAudio'])->name('upload-audio'); Route::post('/upload-file', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadFile'])->name('upload-file'); Route::delete('/file/{file}', [\App\Http\Controllers\Sales\ConsultationController::class, 'deleteFile'])->name('delete-file'); + Route::get('/download-audio/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadAudio'])->name('download-audio'); + Route::get('/download-file/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadFile'])->name('download-file'); }); // 매니저 지정 변경