Files
sam-manage/app/Services/GoogleCloudStorageService.php
pro 50becbdd28 feat:AI 설정 페이지에 GCS 스토리지 설정 통합
- AI 설정과 스토리지 설정을 탭으로 구분
- GCS 버킷 이름, 서비스 계정 (JSON 직접입력/파일경로) 설정 가능
- GCS 연결 테스트 기능 추가
- GoogleCloudStorageService가 DB 설정 우선 사용 (fallback: 레거시 파일)
- AiConfig 모델에 gcs provider 및 관련 메서드 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:22:12 +09:00

275 lines
8.8 KiB
PHP

<?php
namespace App\Services;
use App\Models\System\AiConfig;
use Illuminate\Support\Facades\Log;
/**
* Google Cloud Storage 업로드 서비스
*
* DB 설정(ai_configs 테이블)을 우선 사용하고, 없으면 레거시 파일 설정을 사용합니다.
* JWT 인증 방식 사용.
*/
class GoogleCloudStorageService
{
private ?string $bucketName = null;
private ?array $serviceAccount = null;
public function __construct()
{
$this->loadConfig();
}
/**
* GCS 설정 로드
*
* 우선순위:
* 1. DB 설정 (ai_configs 테이블의 활성화된 gcs provider)
* 2. 레거시 파일 설정 (/sales/apikey/)
*/
private function loadConfig(): void
{
// 1. DB 설정 확인
$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->serviceAccount) {
Log::debug('GCS 설정 로드: DB (활성화된 설정: ' . $dbConfig->name . ')');
return;
}
}
// 2. 레거시 파일 설정 (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) {
Log::debug('GCS 설정 로드: 레거시 파일');
}
}
/**
* 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;
}
}