- AiConfig::getActiveGemini() → config('services.gemini') 기반
- AiConfig::getActiveGcs() → config('services.google') 기반
- AiConfig::getActiveClaude() → config('services.claude') 기반
- AiConfig::getActiveNotion() → config('services.notion') 기반
- GoogleCloudStorageService: DB 우선 로직 제거, .env만 사용
- 8개 서비스 파일은 수정 없이 동작 (AiConfig 인터페이스 유지)
301 lines
8.9 KiB
PHP
301 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Google Cloud Storage 업로드 서비스
|
|
*
|
|
* 우선순위:
|
|
* 1. DB 설정 (ui에서 오버라이드)
|
|
* 2. 환경변수 (.env)
|
|
* 3. 레거시 파일 (/sales/apikey/)
|
|
*
|
|
* JWT 인증 방식 사용.
|
|
*/
|
|
class GoogleCloudStorageService
|
|
{
|
|
private ?string $bucketName = null;
|
|
|
|
private ?array $serviceAccount = null;
|
|
|
|
private string $configSource = 'none';
|
|
|
|
public function __construct()
|
|
{
|
|
$this->loadConfig();
|
|
}
|
|
|
|
/**
|
|
* GCS 설정 로드 (.env 기반)
|
|
*/
|
|
private function loadConfig(): void
|
|
{
|
|
$this->bucketName = config('services.google.storage_bucket');
|
|
$credentialsPath = config('services.google.credentials_path');
|
|
|
|
if ($credentialsPath && file_exists($credentialsPath)) {
|
|
$this->serviceAccount = json_decode(file_get_contents($credentialsPath), true);
|
|
}
|
|
|
|
// fallback: 레거시 파일 경로
|
|
if (! $this->serviceAccount) {
|
|
$legacyPath = '/var/www/sales/apikey/google_service_account.json';
|
|
if (file_exists($legacyPath)) {
|
|
$this->serviceAccount = json_decode(file_get_contents($legacyPath), true);
|
|
}
|
|
}
|
|
|
|
if ($this->bucketName && $this->serviceAccount) {
|
|
$this->configSource = 'env';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 설정 소스 반환
|
|
*/
|
|
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 객체 이름 또는 gs:// URI
|
|
* @param int $expiresInMinutes URL 유효 시간 (분)
|
|
* @return string|null 서명된 URL 또는 실패 시 null
|
|
*/
|
|
public function getSignedUrl(string $objectName, int $expiresInMinutes = 60): ?string
|
|
{
|
|
if (! $this->isAvailable()) {
|
|
return null;
|
|
}
|
|
|
|
$objectName = $this->stripGsPrefix($objectName);
|
|
|
|
$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 객체 이름 또는 gs:// URI
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function delete(string $objectName): bool
|
|
{
|
|
if (! $this->isAvailable()) {
|
|
return false;
|
|
}
|
|
|
|
$objectName = $this->stripGsPrefix($objectName);
|
|
|
|
$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), '+/', '-_'), '=');
|
|
}
|
|
|
|
/**
|
|
* gs://bucket-name/objectName → objectName 변환
|
|
*
|
|
* upload()가 gs:// URI를 반환하므로, getSignedUrl/delete에서 자동 처리
|
|
*/
|
|
private function stripGsPrefix(string $objectName): string
|
|
{
|
|
$prefix = 'gs://'.$this->bucketName.'/';
|
|
if (str_starts_with($objectName, $prefix)) {
|
|
return substr($objectName, strlen($prefix));
|
|
}
|
|
|
|
// gs://다른-버킷/ 형태도 처리
|
|
if (str_starts_with($objectName, 'gs://')) {
|
|
$objectName = preg_replace('#^gs://[^/]+/#', '', $objectName);
|
|
}
|
|
|
|
return $objectName;
|
|
}
|
|
|
|
/**
|
|
* 버킷 이름 반환
|
|
*/
|
|
public function getBucketName(): ?string
|
|
{
|
|
return $this->bucketName;
|
|
}
|
|
}
|