feat:AI 설정 페이지에 GCS 스토리지 설정 통합
- AI 설정과 스토리지 설정을 탭으로 구분 - GCS 버킷 이름, 서비스 계정 (JSON 직접입력/파일경로) 설정 가능 - GCS 연결 테스트 기능 추가 - GoogleCloudStorageService가 DB 설정 우선 사용 (fallback: 레거시 파일) - AiConfig 모델에 gcs provider 및 관련 메서드 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 라벨
|
||||
*/
|
||||
|
||||
@@ -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 설정 로드: 레거시 파일');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,14 +62,14 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="max-w-4xl mx-auto" x-data="{ activeTab: 'ai' }">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">AI 설정 관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">AI API 키 및 모델 설정을 관리합니다</p>
|
||||
<h1 class="text-2xl font-bold text-gray-800">AI 및 스토리지 설정</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">AI API 및 클라우드 스토리지 설정을 관리합니다</p>
|
||||
</div>
|
||||
<button type="button" onclick="openModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition inline-flex items-center gap-2">
|
||||
<button type="button" @click="activeTab === 'ai' ? openModal() : openGcsModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition inline-flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
@@ -77,8 +77,34 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 설정 목록 -->
|
||||
<div id="config-list">
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="flex border-b border-gray-200 mb-6">
|
||||
<button type="button"
|
||||
@click="activeTab = 'ai'"
|
||||
:class="activeTab === 'ai' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
|
||||
class="px-6 py-3 border-b-2 font-medium text-sm transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
AI 설정
|
||||
</div>
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="activeTab = 'storage'"
|
||||
:class="activeTab === 'storage' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
|
||||
class="px-6 py-3 border-b-2 font-medium text-sm transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
스토리지 설정 (GCS)
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- AI 설정 목록 -->
|
||||
<div x-show="activeTab === 'ai'" id="config-list">
|
||||
@forelse($configs as $config)
|
||||
<div class="config-card" data-id="{{ $config->id }}">
|
||||
<div class="flex items-start justify-between">
|
||||
@@ -132,7 +158,7 @@
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- 사용 안내 -->
|
||||
<!-- AI 사용 안내 -->
|
||||
<div class="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-blue-800 mb-2">사용 안내</h3>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
@@ -142,6 +168,147 @@
|
||||
<li>테스트 버튼으로 API 연결 상태를 확인할 수 있습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스토리지 설정 (GCS) -->
|
||||
<div x-show="activeTab === 'storage'" id="storage-config-list">
|
||||
@forelse($storageConfigs as $config)
|
||||
<div class="config-card" data-id="{{ $config->id }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="font-semibold text-lg text-gray-800">{{ $config->name }}</h3>
|
||||
<span class="provider-badge" style="background: #e8f5e9; color: #2e7d32;">
|
||||
Google Cloud Storage
|
||||
</span>
|
||||
<span class="status-badge {{ $config->is_active ? 'status-active' : 'status-inactive' }}">
|
||||
{{ $config->status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<p><span class="font-medium">버킷:</span> {{ $config->getBucketName() ?? '-' }}</p>
|
||||
<p><span class="font-medium">서비스 계정:</span>
|
||||
@if($config->getServiceAccountPath())
|
||||
파일 경로: {{ $config->getServiceAccountPath() }}
|
||||
@elseif($config->getServiceAccountJson())
|
||||
JSON 직접 입력됨
|
||||
@else
|
||||
미설정
|
||||
@endif
|
||||
</p>
|
||||
@if($config->description)
|
||||
<p><span class="font-medium">설명:</span> {{ $config->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="testGcsConnection({{ $config->id }})" class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
|
||||
테스트
|
||||
</button>
|
||||
<button type="button" onclick="toggleConfig({{ $config->id }})" class="px-3 py-1.5 text-sm {{ $config->is_active ? 'bg-yellow-100 hover:bg-yellow-200 text-yellow-700' : 'bg-green-100 hover:bg-green-200 text-green-700' }} rounded-lg transition">
|
||||
{{ $config->is_active ? '비활성화' : '활성화' }}
|
||||
</button>
|
||||
<button type="button" data-config='@json($config)' onclick="editGcsConfig(this)" class="px-3 py-1.5 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
<button type="button" onclick="deleteConfig({{ $config->id }}, '{{ $config->name }}')" class="px-3 py-1.5 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<p class="text-gray-500">등록된 GCS 설정이 없습니다.</p>
|
||||
<p class="text-sm text-gray-400 mt-1">'새 설정 추가' 버튼을 클릭하여 Google Cloud Storage를 등록하세요.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
<!-- GCS 사용 안내 -->
|
||||
<div class="mt-8 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-green-800 mb-2">Google Cloud Storage 사용 안내</h3>
|
||||
<ul class="text-sm text-green-700 space-y-1">
|
||||
<li>음성 녹음 파일(10MB 이상)은 GCS에 자동 백업됩니다.</li>
|
||||
<li>GCP 콘솔에서 서비스 계정을 생성하고 Storage 권한을 부여하세요.</li>
|
||||
<li>서비스 계정 키(JSON)를 직접 입력하거나, 파일 경로를 지정할 수 있습니다.</li>
|
||||
<li>버킷은 미리 GCP 콘솔에서 생성해 두어야 합니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GCS 추가/수정 모달 -->
|
||||
<div id="gcs-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 id="gcs-modal-title" class="text-xl font-bold text-gray-800">GCS 설정 추가</h2>
|
||||
<button type="button" onclick="closeGcsModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="gcs-form" class="space-y-4">
|
||||
<input type="hidden" id="gcs-config-id" value="">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설정 이름 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="gcs-name" required placeholder="예: Production GCS" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">버킷 이름 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="gcs-bucket-name" required placeholder="예: my-bucket-name" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">GCP 콘솔에서 생성한 버킷 이름</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">인증 방식</label>
|
||||
<select id="gcs-auth-type" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="path">파일 경로</option>
|
||||
<option value="json">JSON 직접 입력</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="gcs-path-section">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">서비스 계정 파일 경로</label>
|
||||
<input type="text" id="gcs-service-account-path" placeholder="/var/www/sales/apikey/google_service_account.json" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">Docker 컨테이너 내부 경로 (기본: /var/www/sales/apikey/google_service_account.json)</p>
|
||||
</div>
|
||||
|
||||
<div id="gcs-json-section" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">서비스 계정 JSON</label>
|
||||
<textarea id="gcs-service-account-json" rows="6" placeholder='{"type": "service_account", "project_id": "...", ...}' class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">GCP에서 다운로드한 JSON 키 내용을 붙여넣기</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설명 (선택)</label>
|
||||
<textarea id="gcs-description" rows="2" placeholder="설정에 대한 설명" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="gcs-is-active" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<label for="gcs-is-active" class="ml-2 text-sm text-gray-700">활성화 (기존 GCS 설정은 비활성화됩니다)</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onclick="closeGcsModal()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" onclick="testGcsConnectionFromModal()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition">
|
||||
연결 테스트
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
@@ -539,6 +706,189 @@ function toggleAuthTypeUI(provider, authType) {
|
||||
}
|
||||
}
|
||||
|
||||
// === GCS 설정 관련 함수들 ===
|
||||
|
||||
// GCS 모달 열기
|
||||
window.openGcsModal = function(config) {
|
||||
const modal = document.getElementById('gcs-modal');
|
||||
const title = document.getElementById('gcs-modal-title');
|
||||
|
||||
if (config) {
|
||||
title.textContent = 'GCS 설정 수정';
|
||||
document.getElementById('gcs-config-id').value = config.id;
|
||||
document.getElementById('gcs-name').value = config.name;
|
||||
document.getElementById('gcs-description').value = config.description || '';
|
||||
document.getElementById('gcs-is-active').checked = config.is_active;
|
||||
|
||||
const options = config.options || {};
|
||||
document.getElementById('gcs-bucket-name').value = options.bucket_name || '';
|
||||
document.getElementById('gcs-service-account-path').value = options.service_account_path || '';
|
||||
|
||||
if (options.service_account_json) {
|
||||
document.getElementById('gcs-auth-type').value = 'json';
|
||||
document.getElementById('gcs-service-account-json').value = JSON.stringify(options.service_account_json, null, 2);
|
||||
toggleGcsAuthType('json');
|
||||
} else {
|
||||
document.getElementById('gcs-auth-type').value = 'path';
|
||||
toggleGcsAuthType('path');
|
||||
}
|
||||
} else {
|
||||
title.textContent = 'GCS 설정 추가';
|
||||
document.getElementById('gcs-form').reset();
|
||||
document.getElementById('gcs-config-id').value = '';
|
||||
document.getElementById('gcs-service-account-path').value = '/var/www/sales/apikey/google_service_account.json';
|
||||
toggleGcsAuthType('path');
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
// GCS 모달 닫기
|
||||
window.closeGcsModal = function() {
|
||||
document.getElementById('gcs-modal').classList.add('hidden');
|
||||
};
|
||||
|
||||
// GCS 인증 방식 전환
|
||||
function toggleGcsAuthType(type) {
|
||||
const pathSection = document.getElementById('gcs-path-section');
|
||||
const jsonSection = document.getElementById('gcs-json-section');
|
||||
|
||||
if (type === 'json') {
|
||||
pathSection.classList.add('hidden');
|
||||
jsonSection.classList.remove('hidden');
|
||||
} else {
|
||||
pathSection.classList.remove('hidden');
|
||||
jsonSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// GCS 수정
|
||||
window.editGcsConfig = function(btn) {
|
||||
try {
|
||||
const config = JSON.parse(btn.dataset.config);
|
||||
window.openGcsModal(config);
|
||||
} catch (e) {
|
||||
console.error('Config parse error:', e);
|
||||
showToast('설정 데이터를 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// GCS 연결 테스트 (목록)
|
||||
window.testGcsConnection = function(id) {
|
||||
showToast('GCS 수정 화면에서 테스트해주세요.', 'warning');
|
||||
};
|
||||
|
||||
// GCS 연결 테스트 (모달)
|
||||
window.testGcsConnectionFromModal = async function() {
|
||||
const authType = document.getElementById('gcs-auth-type').value;
|
||||
const data = {
|
||||
bucket_name: document.getElementById('gcs-bucket-name').value,
|
||||
};
|
||||
|
||||
if (authType === 'json') {
|
||||
try {
|
||||
const jsonText = document.getElementById('gcs-service-account-json').value;
|
||||
data.service_account_json = JSON.parse(jsonText);
|
||||
} catch (e) {
|
||||
showToast('JSON 형식이 올바르지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
data.service_account_path = document.getElementById('gcs-service-account-path').value;
|
||||
}
|
||||
|
||||
if (!data.bucket_name) {
|
||||
showToast('버킷 이름을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast('GCS 연결 테스트 중...', 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("system.ai-config.test-gcs") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.error || '연결 테스트 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('테스트 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// GCS 폼 제출
|
||||
async function handleGcsFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const id = document.getElementById('gcs-config-id').value;
|
||||
const authType = document.getElementById('gcs-auth-type').value;
|
||||
|
||||
const data = {
|
||||
provider: 'gcs',
|
||||
name: document.getElementById('gcs-name').value,
|
||||
description: document.getElementById('gcs-description').value || null,
|
||||
is_active: document.getElementById('gcs-is-active').checked,
|
||||
options: {
|
||||
bucket_name: document.getElementById('gcs-bucket-name').value,
|
||||
}
|
||||
};
|
||||
|
||||
if (authType === 'json') {
|
||||
try {
|
||||
const jsonText = document.getElementById('gcs-service-account-json').value;
|
||||
data.options.service_account_json = JSON.parse(jsonText);
|
||||
} catch (e) {
|
||||
showToast('JSON 형식이 올바르지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
data.options.service_account_path = document.getElementById('gcs-service-account-path').value;
|
||||
}
|
||||
|
||||
if (!data.options.bucket_name) {
|
||||
showToast('버킷 이름을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = id
|
||||
? `{{ url('system/ai-config') }}/${id}`
|
||||
: '{{ route("system.ai-config.store") }}';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
showToast(result.message, 'success');
|
||||
window.closeGcsModal();
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(result.message || '저장 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// DOM 로드 후 이벤트 리스너 등록
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 페이지 로드 시 모달 강제 닫기
|
||||
@@ -546,6 +896,10 @@ function toggleAuthTypeUI(provider, authType) {
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
const gcsModal = document.getElementById('gcs-modal');
|
||||
if (gcsModal) {
|
||||
gcsModal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Provider 변경 시 기본 모델 업데이트 및 UI 전환
|
||||
const providerEl = document.getElementById('config-provider');
|
||||
@@ -578,6 +932,20 @@ function toggleAuthTypeUI(provider, authType) {
|
||||
formEl.addEventListener('submit', handleFormSubmit);
|
||||
}
|
||||
|
||||
// GCS 폼 제출
|
||||
const gcsFormEl = document.getElementById('gcs-form');
|
||||
if (gcsFormEl) {
|
||||
gcsFormEl.addEventListener('submit', handleGcsFormSubmit);
|
||||
}
|
||||
|
||||
// GCS 인증 방식 변경
|
||||
const gcsAuthTypeEl = document.getElementById('gcs-auth-type');
|
||||
if (gcsAuthTypeEl) {
|
||||
gcsAuthTypeEl.addEventListener('change', function() {
|
||||
toggleGcsAuthType(this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫지 않음 (의도치 않은 닫힘 방지)
|
||||
// 닫기 버튼이나 취소 버튼으로만 닫을 수 있음
|
||||
});
|
||||
|
||||
@@ -326,6 +326,7 @@
|
||||
Route::delete('/{id}', [AiConfigController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/{id}/toggle', [AiConfigController::class, 'toggle'])->name('toggle');
|
||||
Route::post('/test', [AiConfigController::class, 'test'])->name('test');
|
||||
Route::post('/test-gcs', [AiConfigController::class, 'testGcs'])->name('test-gcs');
|
||||
});
|
||||
|
||||
// 명함 OCR API
|
||||
|
||||
Reference in New Issue
Block a user