loadConfig(); } /** * GCS 설정 로드 * * 우선순위: * 1. DB 설정 (ai_configs 테이블의 활성화된 gcs provider) * 2. 환경변수 (.env의 GCS_BUCKET_NAME, GCS_SERVICE_ACCOUNT_PATH) * 3. 레거시 파일 설정 (/sales/apikey/) */ private function loadConfig(): void { // 1. DB 설정 확인 (GCS_USE_DB_CONFIG=true일 때만) if (config('gcs.use_db_config', true)) { $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->bucketName && $this->serviceAccount) { $this->configSource = 'db'; Log::debug('GCS 설정 로드: DB (활성화된 설정: ' . $dbConfig->name . ')'); return; } } } // 2. 환경변수 (.env) 설정 $envBucket = config('gcs.bucket_name'); $envServiceAccountPath = config('gcs.service_account_path'); if ($envBucket && $envServiceAccountPath && file_exists($envServiceAccountPath)) { $this->bucketName = $envBucket; $this->serviceAccount = json_decode(file_get_contents($envServiceAccountPath), true); if ($this->serviceAccount) { $this->configSource = 'env'; Log::debug('GCS 설정 로드: 환경변수 (.env)'); return; } } // 3. 레거시 파일 설정 (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) { $this->configSource = 'legacy'; Log::debug('GCS 설정 로드: 레거시 파일'); } } /** * 현재 설정 소스 반환 */ public function getConfigSource(): string { return $this->configSource; } /** * 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; } }