diff --git a/app/Http/Controllers/System/AiConfigController.php b/app/Http/Controllers/System/AiConfigController.php index 4f7c052e..cc02fdd9 100644 --- a/app/Http/Controllers/System/AiConfigController.php +++ b/app/Http/Controllers/System/AiConfigController.php @@ -20,12 +20,25 @@ public function index(Request $request): View|Response return response('', 200)->header('HX-Redirect', route('system.ai-config.index')); } - $configs = AiConfig::orderBy('provider') + // AI 설정 (gemini, claude, openai) + $aiConfigs = AiConfig::whereIn('provider', AiConfig::AI_PROVIDERS) + ->orderBy('provider') ->orderByDesc('is_active') ->orderBy('name') ->get(); - return view('system.ai-config.index', compact('configs')); + // 스토리지 설정 (gcs) + $storageConfigs = AiConfig::whereIn('provider', AiConfig::STORAGE_PROVIDERS) + ->orderBy('provider') + ->orderByDesc('is_active') + ->orderBy('name') + ->get(); + + return view('system.ai-config.index', [ + 'configs' => $aiConfigs, + 'aiConfigs' => $aiConfigs, + 'storageConfigs' => $storageConfigs, + ]); } /** @@ -35,26 +48,40 @@ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'name' => 'required|string|max:50', - 'provider' => 'required|string|in:gemini,claude,openai', + 'provider' => 'required|string|in:gemini,claude,openai,gcs', 'api_key' => 'nullable|string|max:255', - 'model' => 'required|string|max:100', + 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_active' => 'boolean', 'options' => 'nullable|array', - 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai', + 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account', 'options.project_id' => 'nullable|string|max:100', 'options.region' => 'nullable|string|max:50', 'options.service_account_path' => 'nullable|string|max:500', + 'options.bucket_name' => 'nullable|string|max:200', + 'options.service_account_json' => 'nullable|array', ]); - // Vertex AI가 아닌 경우 API 키 필수 - $authType = $validated['options']['auth_type'] ?? 'api_key'; - if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { - return response()->json([ - 'ok' => false, - 'message' => 'API 키를 입력해주세요.', - ], 422); + // GCS의 경우 별도 검증 + if ($validated['provider'] === 'gcs') { + if (empty($validated['options']['bucket_name'])) { + return response()->json([ + 'ok' => false, + 'message' => '버킷 이름을 입력해주세요.', + ], 422); + } + $validated['model'] = '-'; // GCS는 모델 불필요 + $validated['api_key'] = 'gcs_service_account'; // DB NOT NULL 제약 + } else { + // AI 설정: Vertex AI가 아닌 경우 API 키 필수 + $authType = $validated['options']['auth_type'] ?? 'api_key'; + if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { + return response()->json([ + 'ok' => false, + 'message' => 'API 키를 입력해주세요.', + ], 422); + } } // 활성화 시 동일 provider의 다른 설정 비활성화 @@ -81,26 +108,40 @@ public function update(Request $request, int $id): JsonResponse $validated = $request->validate([ 'name' => 'required|string|max:50', - 'provider' => 'required|string|in:gemini,claude,openai', + 'provider' => 'required|string|in:gemini,claude,openai,gcs', 'api_key' => 'nullable|string|max:255', - 'model' => 'required|string|max:100', + 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_active' => 'boolean', 'options' => 'nullable|array', - 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai', + 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account', 'options.project_id' => 'nullable|string|max:100', 'options.region' => 'nullable|string|max:50', 'options.service_account_path' => 'nullable|string|max:500', + 'options.bucket_name' => 'nullable|string|max:200', + 'options.service_account_json' => 'nullable|array', ]); - // Vertex AI가 아닌 경우 API 키 필수 - $authType = $validated['options']['auth_type'] ?? 'api_key'; - if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { - return response()->json([ - 'ok' => false, - 'message' => 'API 키를 입력해주세요.', - ], 422); + // GCS의 경우 별도 검증 + if ($validated['provider'] === 'gcs') { + if (empty($validated['options']['bucket_name'])) { + return response()->json([ + 'ok' => false, + 'message' => '버킷 이름을 입력해주세요.', + ], 422); + } + $validated['model'] = '-'; + $validated['api_key'] = 'gcs_service_account'; + } else { + // AI 설정: Vertex AI가 아닌 경우 API 키 필수 + $authType = $validated['options']['auth_type'] ?? 'api_key'; + if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { + return response()->json([ + 'ok' => false, + 'message' => 'API 키를 입력해주세요.', + ], 422); + } } // 활성화 시 동일 provider의 다른 설정 비활성화 @@ -225,4 +266,114 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr 'error' => 'API 응답 오류: ' . $response->status(), ]; } + + /** + * GCS 연결 테스트 + */ + public function testGcs(Request $request): JsonResponse + { + $validated = $request->validate([ + 'bucket_name' => 'required|string', + 'service_account_path' => 'nullable|string', + 'service_account_json' => 'nullable|array', + ]); + + try { + $bucketName = $validated['bucket_name']; + $serviceAccount = null; + + // 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로) + if (!empty($validated['service_account_json'])) { + $serviceAccount = $validated['service_account_json']; + } elseif (!empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) { + $serviceAccount = json_decode(file_get_contents($validated['service_account_path']), true); + } + + if (!$serviceAccount) { + return response()->json([ + 'ok' => false, + 'error' => '서비스 계정 정보를 찾을 수 없습니다.', + ]); + } + + // OAuth 토큰 획득 + $accessToken = $this->getGcsAccessToken($serviceAccount); + if (!$accessToken) { + return response()->json([ + 'ok' => false, + 'error' => 'OAuth 토큰 획득 실패', + ]); + } + + // 버킷 존재 확인 + $response = \Illuminate\Support\Facades\Http::timeout(10) + ->withHeaders(['Authorization' => 'Bearer ' . $accessToken]) + ->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}"); + + if ($response->successful()) { + return response()->json([ + 'ok' => true, + 'message' => "GCS 연결 성공! 버킷: {$bucketName}", + ]); + } + + return response()->json([ + 'ok' => false, + 'error' => '버킷 접근 실패: ' . $response->status(), + ]); + + } catch (\Exception $e) { + return response()->json([ + 'ok' => false, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * GCS OAuth 토큰 획득 + */ + private function getGcsAccessToken(array $serviceAccount): ?string + { + $now = time(); + $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $jwtClaim = $this->base64UrlEncode(json_encode([ + 'iss' => $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($serviceAccount['private_key']); + if (!$privateKey) { + 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); + + $response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ]); + + if ($response->successful()) { + return $response->json('access_token'); + } + + return null; + } + + /** + * Base64 URL 인코딩 + */ + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } } diff --git a/app/Models/System/AiConfig.php b/app/Models/System/AiConfig.php index 98d11863..36ff541f 100644 --- a/app/Models/System/AiConfig.php +++ b/app/Models/System/AiConfig.php @@ -51,6 +51,7 @@ class AiConfig extends Model 'gemini' => 'https://generativelanguage.googleapis.com/v1beta', 'claude' => 'https://api.anthropic.com/v1', 'openai' => 'https://api.openai.com/v1', + 'gcs' => 'https://storage.googleapis.com', ]; /** @@ -60,8 +61,19 @@ class AiConfig extends Model 'gemini' => 'gemini-2.0-flash', 'claude' => 'claude-sonnet-4-20250514', 'openai' => 'gpt-4o', + 'gcs' => '-', ]; + /** + * AI Provider 목록 (GCS 제외) + */ + public const AI_PROVIDERS = ['gemini', 'claude', 'openai']; + + /** + * 스토리지 Provider 목록 + */ + public const STORAGE_PROVIDERS = ['gcs']; + /** * 활성화된 Gemini 설정 조회 */ @@ -109,10 +121,53 @@ public function getProviderLabelAttribute(): string 'gemini' => 'Google Gemini', 'claude' => 'Anthropic Claude', 'openai' => 'OpenAI', + 'gcs' => 'Google Cloud Storage', default => $this->provider, }; } + /** + * 활성화된 GCS 설정 조회 + */ + public static function getActiveGcs(): ?self + { + return self::where('provider', 'gcs') + ->where('is_active', true) + ->first(); + } + + /** + * GCS 버킷 이름 + */ + public function getBucketName(): ?string + { + return $this->options['bucket_name'] ?? null; + } + + /** + * GCS 서비스 계정 JSON (직접 저장된 경우) + */ + public function getServiceAccountJson(): ?array + { + return $this->options['service_account_json'] ?? null; + } + + /** + * GCS 설정인지 확인 + */ + public function isGcs(): bool + { + return $this->provider === 'gcs'; + } + + /** + * AI 설정인지 확인 + */ + public function isAi(): bool + { + return in_array($this->provider, self::AI_PROVIDERS); + } + /** * 상태 라벨 */ diff --git a/app/Services/GoogleCloudStorageService.php b/app/Services/GoogleCloudStorageService.php index fa60cc9d..2b79e8cf 100644 --- a/app/Services/GoogleCloudStorageService.php +++ b/app/Services/GoogleCloudStorageService.php @@ -2,12 +2,13 @@ namespace App\Services; +use App\Models\System\AiConfig; use Illuminate\Support\Facades\Log; /** * Google Cloud Storage 업로드 서비스 * - * 레거시 PHP 코드와 동일한 방식으로 GCS에 파일을 업로드합니다. + * DB 설정(ai_configs 테이블)을 우선 사용하고, 없으면 레거시 파일 설정을 사용합니다. * JWT 인증 방식 사용. */ class GoogleCloudStorageService @@ -22,21 +23,47 @@ public function __construct() /** * GCS 설정 로드 + * + * 우선순위: + * 1. DB 설정 (ai_configs 테이블의 활성화된 gcs provider) + * 2. 레거시 파일 설정 (/sales/apikey/) */ private function loadConfig(): void { - // GCS 버킷 설정 + // 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 설정 로드: 레거시 파일'); + } } /** diff --git a/resources/views/system/ai-config/index.blade.php b/resources/views/system/ai-config/index.blade.php index 791a021f..dfb99fa9 100644 --- a/resources/views/system/ai-config/index.blade.php +++ b/resources/views/system/ai-config/index.blade.php @@ -62,14 +62,14 @@ @endpush @section('content') -
AI API 키 및 모델 설정을 관리합니다
+AI API 및 클라우드 스토리지 설정을 관리합니다
버킷: {{ $config->getBucketName() ?? '-' }}
+서비스 계정: + @if($config->getServiceAccountPath()) + 파일 경로: {{ $config->getServiceAccountPath() }} + @elseif($config->getServiceAccountJson()) + JSON 직접 입력됨 + @else + 미설정 + @endif +
+ @if($config->description) +설명: {{ $config->description }}
+ @endif +등록된 GCS 설정이 없습니다.
+'새 설정 추가' 버튼을 클릭하여 Google Cloud Storage를 등록하세요.
+