From 55f604ce6f5c399f044ab1561d003558ed3c8f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 7 Feb 2026 09:57:25 +0900 Subject: [PATCH 01/31] =?UTF-8?q?feat:AI=20=ED=86=A0=ED=81=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiTokenUsageController (index, list) 생성 - AiTokenUsage 모델 생성 - React 기반 토큰 사용량 조회 페이지 (필터, 통계, 페이지네이션) - 라우트 추가 (system/ai-token-usage) - AiTokenUsageMenuSeeder 메뉴 시더 생성 Co-Authored-By: Claude Opus 4.6 --- .../System/AiTokenUsageController.php | 123 ++++++ app/Models/System/AiTokenUsage.php | 36 ++ database/seeders/AiTokenUsageMenuSeeder.php | 63 +++ .../system/ai-token-usage/index.blade.php | 369 ++++++++++++++++++ routes/web.php | 7 + 5 files changed, 598 insertions(+) create mode 100644 app/Http/Controllers/System/AiTokenUsageController.php create mode 100644 app/Models/System/AiTokenUsage.php create mode 100644 database/seeders/AiTokenUsageMenuSeeder.php create mode 100644 resources/views/system/ai-token-usage/index.blade.php diff --git a/app/Http/Controllers/System/AiTokenUsageController.php b/app/Http/Controllers/System/AiTokenUsageController.php new file mode 100644 index 00000000..8c9dd7d7 --- /dev/null +++ b/app/Http/Controllers/System/AiTokenUsageController.php @@ -0,0 +1,123 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('system.ai-token-usage.index')); + } + + return view('system.ai-token-usage.index'); + } + + public function list(Request $request): JsonResponse + { + $perPage = $request->input('per_page', 20); + $startDate = $request->input('start_date'); + $endDate = $request->input('end_date'); + $tenantId = $request->input('tenant_id'); + $menuName = $request->input('menu_name'); + + $query = AiTokenUsage::query() + ->orderByDesc('created_at'); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + if ($menuName) { + $query->where('menu_name', $menuName); + } + + if ($startDate) { + $query->whereDate('created_at', '>=', $startDate); + } + + if ($endDate) { + $query->whereDate('created_at', '<=', $endDate); + } + + // 통계 (필터 조건 동일하게 적용) + $statsQuery = clone $query; + $stats = $statsQuery->selectRaw(' + COUNT(*) as total_count, + SUM(prompt_tokens) as total_prompt_tokens, + SUM(completion_tokens) as total_completion_tokens, + SUM(total_tokens) as total_total_tokens, + SUM(cost_usd) as total_cost_usd, + SUM(cost_krw) as total_cost_krw + ')->first(); + + // 페이지네이션 + $records = $query->paginate($perPage); + + // 테넌트 이름 매핑 + $tenantIds = $records->pluck('tenant_id')->unique(); + $tenants = Tenant::whereIn('id', $tenantIds)->pluck('company_name', 'id'); + + $data = $records->through(function ($item) use ($tenants) { + return [ + 'id' => $item->id, + 'tenant_id' => $item->tenant_id, + 'tenant_name' => $tenants[$item->tenant_id] ?? '-', + 'model' => $item->model, + 'menu_name' => $item->menu_name, + 'prompt_tokens' => $item->prompt_tokens, + 'completion_tokens' => $item->completion_tokens, + 'total_tokens' => $item->total_tokens, + 'cost_usd' => (float) $item->cost_usd, + 'cost_krw' => (float) $item->cost_krw, + 'request_id' => $item->request_id, + 'created_at' => $item->created_at->format('Y-m-d H:i:s'), + ]; + }); + + // 필터용 메뉴 목록 + $menuNames = AiTokenUsage::select('menu_name') + ->distinct() + ->orderBy('menu_name') + ->pluck('menu_name'); + + // 필터용 테넌트 목록 + $allTenantIds = AiTokenUsage::select('tenant_id') + ->distinct() + ->pluck('tenant_id'); + $allTenants = Tenant::whereIn('id', $allTenantIds) + ->orderBy('company_name') + ->get(['id', 'company_name']); + + return response()->json([ + 'success' => true, + 'data' => $data->items(), + 'stats' => [ + 'total_count' => (int) ($stats->total_count ?? 0), + 'total_prompt_tokens' => (int) ($stats->total_prompt_tokens ?? 0), + 'total_completion_tokens' => (int) ($stats->total_completion_tokens ?? 0), + 'total_total_tokens' => (int) ($stats->total_total_tokens ?? 0), + 'total_cost_usd' => round((float) ($stats->total_cost_usd ?? 0), 6), + 'total_cost_krw' => round((float) ($stats->total_cost_krw ?? 0), 2), + ], + 'filters' => [ + 'menu_names' => $menuNames, + 'tenants' => $allTenants->map(fn ($t) => ['id' => $t->id, 'name' => $t->company_name]), + ], + 'pagination' => [ + 'current_page' => $records->currentPage(), + 'last_page' => $records->lastPage(), + 'per_page' => $records->perPage(), + 'total' => $records->total(), + ], + ]); + } +} diff --git a/app/Models/System/AiTokenUsage.php b/app/Models/System/AiTokenUsage.php new file mode 100644 index 00000000..2683eb67 --- /dev/null +++ b/app/Models/System/AiTokenUsage.php @@ -0,0 +1,36 @@ + 'integer', + 'completion_tokens' => 'integer', + 'total_tokens' => 'integer', + 'cost_usd' => 'decimal:6', + 'cost_krw' => 'decimal:2', + ]; + + public function scopeForTenant($query, int $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/database/seeders/AiTokenUsageMenuSeeder.php b/database/seeders/AiTokenUsageMenuSeeder.php new file mode 100644 index 00000000..adfa37c8 --- /dev/null +++ b/database/seeders/AiTokenUsageMenuSeeder.php @@ -0,0 +1,63 @@ +where('name', '시스템 관리') + ->first(); + + if (! $parentMenu) { + $this->command->error('시스템 관리 메뉴를 찾을 수 없습니다.'); + return; + } + + // 이미 존재하는지 확인 + $existingMenu = Menu::where('tenant_id', $tenantId) + ->where('name', 'AI 토큰 사용량') + ->where('parent_id', $parentMenu->id) + ->first(); + + if ($existingMenu) { + $this->command->info('AI 토큰 사용량 메뉴가 이미 존재합니다.'); + return; + } + + // 현재 자식 메뉴 최대 sort_order 확인 + $maxSort = Menu::where('parent_id', $parentMenu->id) + ->max('sort_order') ?? 0; + + // 메뉴 생성 + $menu = Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentMenu->id, + 'name' => 'AI 토큰 사용량', + 'url' => '/system/ai-token-usage', + 'icon' => 'brain-circuit', + 'sort_order' => $maxSort + 1, + 'is_active' => true, + ]); + + $this->command->info("메뉴 생성 완료: {$menu->name} (sort_order: {$menu->sort_order})"); + + // 하위 메뉴 목록 출력 + $this->command->info(''); + $this->command->info('=== 시스템 관리 하위 메뉴 ==='); + $children = Menu::where('parent_id', $parentMenu->id) + ->orderBy('sort_order') + ->get(['name', 'url', 'sort_order']); + + foreach ($children as $child) { + $this->command->info("{$child->sort_order}. {$child->name} ({$child->url})"); + } + } +} diff --git a/resources/views/system/ai-token-usage/index.blade.php b/resources/views/system/ai-token-usage/index.blade.php new file mode 100644 index 00000000..54aa779c --- /dev/null +++ b/resources/views/system/ai-token-usage/index.blade.php @@ -0,0 +1,369 @@ +@extends('layouts.app') + +@section('title', 'AI 토큰 사용량') + +@push('styles') + +@endpush + +@section('content') + +
+@endsection + +@push('scripts') + + + + +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index 3b6566a0..12a40bf5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,7 @@ use App\Http\Controllers\RolePermissionController; use App\Http\Controllers\Sales\SalesProductController; use App\Http\Controllers\System\AiConfigController; +use App\Http\Controllers\System\AiTokenUsageController; use App\Http\Controllers\System\HolidayController; use App\Http\Controllers\Stats\StatDashboardController; use App\Http\Controllers\System\SystemAlertController; @@ -404,6 +405,12 @@ Route::delete('/{id}', [HolidayController::class, 'destroy'])->name('destroy'); }); + // AI 토큰 사용량 관리 + Route::prefix('system/ai-token-usage')->name('system.ai-token-usage.')->group(function () { + Route::get('/', [AiTokenUsageController::class, 'index'])->name('index'); + Route::get('/list', [AiTokenUsageController::class, 'list'])->name('list'); + }); + // 명함 OCR API Route::post('/api/business-card-ocr', [BusinessCardOcrController::class, 'process'])->name('api.business-card-ocr'); From 1d12a75ac77ac8efdff07d72c843d030078c1c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 7 Feb 2026 10:36:19 +0900 Subject: [PATCH 02/31] =?UTF-8?q?fix:AI=20=ED=86=A0=ED=81=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EC=8B=9C=EB=8D=94=20-=20AI=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=8F=85=EB=A6=BD=20=EA=B7=B8=EB=A3=B9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- database/seeders/AiTokenUsageMenuSeeder.php | 69 ++++++++++++--------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/database/seeders/AiTokenUsageMenuSeeder.php b/database/seeders/AiTokenUsageMenuSeeder.php index adfa37c8..9f84b824 100644 --- a/database/seeders/AiTokenUsageMenuSeeder.php +++ b/database/seeders/AiTokenUsageMenuSeeder.php @@ -11,48 +11,61 @@ public function run(): void { $tenantId = 1; - // 시스템 관리 부모 메뉴 찾기 - $parentMenu = Menu::where('tenant_id', $tenantId) - ->where('name', '시스템 관리') + // AI 관리 부모 그룹 찾기 또는 생성 + $parentMenu = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', 'AI 관리') + ->whereNull('parent_id') + ->whereNull('deleted_at') ->first(); if (! $parentMenu) { - $this->command->error('시스템 관리 메뉴를 찾을 수 없습니다.'); - return; + $parentMenu = Menu::withoutGlobalScopes()->create([ + 'tenant_id' => $tenantId, + 'parent_id' => null, + 'name' => 'AI 관리', + 'url' => null, + 'icon' => 'brain-circuit', + 'sort_order' => 12, + 'is_active' => true, + ]); + $this->command->info("AI 관리 부모 그룹 생성 완료 (id: {$parentMenu->id})"); } - // 이미 존재하는지 확인 - $existingMenu = Menu::where('tenant_id', $tenantId) + // AI 토큰 사용량 메뉴 존재 확인 + $existingMenu = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) ->where('name', 'AI 토큰 사용량') ->where('parent_id', $parentMenu->id) + ->whereNull('deleted_at') ->first(); if ($existingMenu) { $this->command->info('AI 토큰 사용량 메뉴가 이미 존재합니다.'); - return; + } else { + $maxSort = Menu::withoutGlobalScopes() + ->where('parent_id', $parentMenu->id) + ->whereNull('deleted_at') + ->max('sort_order') ?? 0; + + Menu::withoutGlobalScopes()->create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentMenu->id, + 'name' => 'AI 토큰 사용량', + 'url' => '/system/ai-token-usage', + 'icon' => 'brain-circuit', + 'sort_order' => $maxSort + 1, + 'is_active' => true, + ]); + $this->command->info('AI 토큰 사용량 메뉴 생성 완료'); } - // 현재 자식 메뉴 최대 sort_order 확인 - $maxSort = Menu::where('parent_id', $parentMenu->id) - ->max('sort_order') ?? 0; - - // 메뉴 생성 - $menu = Menu::create([ - 'tenant_id' => $tenantId, - 'parent_id' => $parentMenu->id, - 'name' => 'AI 토큰 사용량', - 'url' => '/system/ai-token-usage', - 'icon' => 'brain-circuit', - 'sort_order' => $maxSort + 1, - 'is_active' => true, - ]); - - $this->command->info("메뉴 생성 완료: {$menu->name} (sort_order: {$menu->sort_order})"); - - // 하위 메뉴 목록 출력 + // 결과 출력 $this->command->info(''); - $this->command->info('=== 시스템 관리 하위 메뉴 ==='); - $children = Menu::where('parent_id', $parentMenu->id) + $this->command->info('=== AI 관리 하위 메뉴 ==='); + $children = Menu::withoutGlobalScopes() + ->where('parent_id', $parentMenu->id) + ->whereNull('deleted_at') ->orderBy('sort_order') ->get(['name', 'url', 'sort_order']); From a882e1f8f98d3d50b87a3e29638fe743398ff083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 7 Feb 2026 11:11:35 +0900 Subject: [PATCH 03/31] =?UTF-8?q?fix:AI=20=EB=A9=94=EB=89=B4=20=EC=8B=9C?= =?UTF-8?q?=EB=8D=94=20-=20AI=EC=84=A4=EC=A0=95=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EB=B0=B0=ED=8F=AC=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 관리 부모 그룹 생성 - AI 설정 메뉴를 기존 위치에서 AI 관리 그룹으로 이동 - AI 토큰 사용량 메뉴 생성 또는 이동 - 멱등성 보장 (재실행 안전) Co-Authored-By: Claude Opus 4.6 --- database/seeders/AiTokenUsageMenuSeeder.php | 59 ++++++++++++++------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/database/seeders/AiTokenUsageMenuSeeder.php b/database/seeders/AiTokenUsageMenuSeeder.php index 9f84b824..e5f39563 100644 --- a/database/seeders/AiTokenUsageMenuSeeder.php +++ b/database/seeders/AiTokenUsageMenuSeeder.php @@ -11,16 +11,16 @@ public function run(): void { $tenantId = 1; - // AI 관리 부모 그룹 찾기 또는 생성 - $parentMenu = Menu::withoutGlobalScopes() + // 1. AI 관리 부모 그룹 찾기 또는 생성 + $aiGroup = Menu::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('name', 'AI 관리') ->whereNull('parent_id') ->whereNull('deleted_at') ->first(); - if (! $parentMenu) { - $parentMenu = Menu::withoutGlobalScopes()->create([ + if (! $aiGroup) { + $aiGroup = Menu::withoutGlobalScopes()->create([ 'tenant_id' => $tenantId, 'parent_id' => null, 'name' => 'AI 관리', @@ -29,42 +29,63 @@ public function run(): void 'sort_order' => 12, 'is_active' => true, ]); - $this->command->info("AI 관리 부모 그룹 생성 완료 (id: {$parentMenu->id})"); + $this->command->info("AI 관리 부모 그룹 생성 완료 (id: {$aiGroup->id})"); + } else { + $this->command->info("AI 관리 부모 그룹 이미 존재 (id: {$aiGroup->id})"); } - // AI 토큰 사용량 메뉴 존재 확인 - $existingMenu = Menu::withoutGlobalScopes() + // 2. AI 설정 메뉴를 AI 관리 그룹으로 이동 (다른 그룹에 있는 경우) + $aiConfig = Menu::withoutGlobalScopes() ->where('tenant_id', $tenantId) - ->where('name', 'AI 토큰 사용량') - ->where('parent_id', $parentMenu->id) + ->where('name', 'AI 설정') ->whereNull('deleted_at') ->first(); - if ($existingMenu) { - $this->command->info('AI 토큰 사용량 메뉴가 이미 존재합니다.'); + if ($aiConfig && $aiConfig->parent_id !== $aiGroup->id) { + $aiConfig->update([ + 'parent_id' => $aiGroup->id, + 'sort_order' => 1, + ]); + $this->command->info("AI 설정 메뉴를 AI 관리 그룹으로 이동 완료"); + } elseif (! $aiConfig) { + $this->command->warn("AI 설정 메뉴가 존재하지 않습니다. (건너뜀)"); } else { - $maxSort = Menu::withoutGlobalScopes() - ->where('parent_id', $parentMenu->id) - ->whereNull('deleted_at') - ->max('sort_order') ?? 0; + $this->command->info("AI 설정 메뉴가 이미 AI 관리 그룹에 있습니다."); + } + // 3. AI 토큰 사용량 메뉴 생성 또는 이동 + $aiToken = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', 'AI 토큰 사용량') + ->whereNull('deleted_at') + ->first(); + + if ($aiToken && $aiToken->parent_id !== $aiGroup->id) { + $aiToken->update([ + 'parent_id' => $aiGroup->id, + 'sort_order' => 2, + ]); + $this->command->info("AI 토큰 사용량 메뉴를 AI 관리 그룹으로 이동 완료"); + } elseif (! $aiToken) { Menu::withoutGlobalScopes()->create([ 'tenant_id' => $tenantId, - 'parent_id' => $parentMenu->id, + 'parent_id' => $aiGroup->id, 'name' => 'AI 토큰 사용량', 'url' => '/system/ai-token-usage', 'icon' => 'brain-circuit', - 'sort_order' => $maxSort + 1, + 'sort_order' => 2, 'is_active' => true, ]); - $this->command->info('AI 토큰 사용량 메뉴 생성 완료'); + $this->command->info("AI 토큰 사용량 메뉴 생성 완료"); + } else { + $this->command->info("AI 토큰 사용량 메뉴가 이미 AI 관리 그룹에 있습니다."); } // 결과 출력 $this->command->info(''); $this->command->info('=== AI 관리 하위 메뉴 ==='); $children = Menu::withoutGlobalScopes() - ->where('parent_id', $parentMenu->id) + ->where('parent_id', $aiGroup->id) ->whereNull('deleted_at') ->orderBy('sort_order') ->get(['name', 'url', 'sort_order']); From bb81d07d61b79fc608ecb91f4837198b7802be8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 7 Feb 2026 11:27:53 +0900 Subject: [PATCH 04/31] =?UTF-8?q?feat:=EC=A0=84=EC=B2=B4=20AI=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EC=97=90=20=ED=86=A0=ED=81=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EA=B8=B0=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiTokenHelper 공통 헬퍼 생성 (Gemini/Claude 응답 파서) - BizCertOcrService (Claude) 토큰 기록 추가 - BusinessCardOcrService (Gemini) 토큰 기록 추가 - MeetingLogService (Claude) 토큰 기록 추가 Co-Authored-By: Claude Opus 4.6 --- app/Helpers/AiTokenHelper.php | 92 +++++++++++++++++++++++++ app/Services/BizCertOcrService.php | 5 ++ app/Services/BusinessCardOcrService.php | 5 ++ app/Services/MeetingLogService.php | 4 ++ 4 files changed, 106 insertions(+) create mode 100644 app/Helpers/AiTokenHelper.php diff --git a/app/Helpers/AiTokenHelper.php b/app/Helpers/AiTokenHelper.php new file mode 100644 index 00000000..11c1350e --- /dev/null +++ b/app/Helpers/AiTokenHelper.php @@ -0,0 +1,92 @@ + $e->getMessage()]); + } + } + + /** + * Claude API 응답에서 토큰 사용량 저장 + */ + public static function saveClaudeUsage(array $apiResult, string $model, string $menuName): void + { + try { + $usage = $apiResult['usage'] ?? null; + if (! $usage) { + return; + } + + $promptTokens = $usage['input_tokens'] ?? 0; + $completionTokens = $usage['output_tokens'] ?? 0; + $totalTokens = $promptTokens + $completionTokens; + + // Claude 3 Haiku 기준 단가 + $inputPrice = 0.25 / 1_000_000; + $outputPrice = 1.25 / 1_000_000; + + self::save($model, $menuName, $promptTokens, $completionTokens, $totalTokens, $inputPrice, $outputPrice); + } catch (\Exception $e) { + Log::warning('AI token usage save failed (Claude)', ['error' => $e->getMessage()]); + } + } + + /** + * 공통 저장 로직 + */ + private static function save( + string $model, + string $menuName, + int $promptTokens, + int $completionTokens, + int $totalTokens, + float $inputPricePerToken, + float $outputPricePerToken, + ): void { + $costUsd = ($promptTokens * $inputPricePerToken) + ($completionTokens * $outputPricePerToken); + $exchangeRate = (float) config('services.gemini.exchange_rate', 1400); + $costKrw = $costUsd * $exchangeRate; + + $tenantId = session('selected_tenant_id', 1); + + AiTokenUsage::create([ + 'tenant_id' => $tenantId, + 'model' => $model, + 'menu_name' => $menuName, + 'prompt_tokens' => $promptTokens, + 'completion_tokens' => $completionTokens, + 'total_tokens' => $totalTokens, + 'cost_usd' => $costUsd, + 'cost_krw' => $costKrw, + 'request_id' => Str::uuid()->toString(), + 'created_by' => auth()->id(), + ]); + } +} diff --git a/app/Services/BizCertOcrService.php b/app/Services/BizCertOcrService.php index 8140aeb7..871a8e55 100644 --- a/app/Services/BizCertOcrService.php +++ b/app/Services/BizCertOcrService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Helpers\AiTokenHelper; use App\Models\BizCert; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -88,6 +89,10 @@ public function processWithClaude(string $imageBase64, ?string $rawText = null): } $apiResponse = $response->json(); + + // 토큰 사용량 저장 + AiTokenHelper::saveClaudeUsage($apiResponse, 'claude-3-haiku-20240307', '사업자등록증OCR'); + $claudeText = $apiResponse['content'][0]['text'] ?? ''; if (empty($claudeText)) { diff --git a/app/Services/BusinessCardOcrService.php b/app/Services/BusinessCardOcrService.php index 15bf9034..0fb16654 100644 --- a/app/Services/BusinessCardOcrService.php +++ b/app/Services/BusinessCardOcrService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Helpers\AiTokenHelper; use App\Models\System\AiConfig; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; @@ -130,6 +131,10 @@ private function callGeminiApi(string $url, string $base64Image, array $headers, } $result = $response->json(); + + // 토큰 사용량 저장 + AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? 'gemini', '명함OCR'); + $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? ''; // JSON 파싱 diff --git a/app/Services/MeetingLogService.php b/app/Services/MeetingLogService.php index 85754ab0..780ee80e 100644 --- a/app/Services/MeetingLogService.php +++ b/app/Services/MeetingLogService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Helpers\AiTokenHelper; use App\Models\MeetingLog; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; @@ -171,6 +172,9 @@ private function generateSummary(string $transcript, string $summaryType = 'meet if ($response->successful()) { $data = $response->json(); + // 토큰 사용량 저장 + AiTokenHelper::saveClaudeUsage($data, 'claude-3-haiku-20240307', '회의록AI요약'); + return $data['content'][0]['text'] ?? null; } From ee9f9c128ae943eb86c30609fe63b931ce80c694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 7 Feb 2026 11:27:59 +0900 Subject: [PATCH 05/31] =?UTF-8?q?docs:GCC=20=ED=86=A0=ED=81=B0=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=B9=84=EA=B5=90=EC=9E=90=EB=A3=8C=20=EA=B8=B0?= =?UTF-8?q?=EC=88=A0=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 응답 직접 추출 vs GCC Monitoring API 비교 분석 - SAM 프로젝트 적합성 근거 정리 - 기술 구현 명세 및 향후 개선 방안 포함 Co-Authored-By: Claude Opus 4.6 --- docs/GCC토큰정책비교자료.md | 238 ++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 docs/GCC토큰정책비교자료.md diff --git a/docs/GCC토큰정책비교자료.md b/docs/GCC토큰정책비교자료.md new file mode 100644 index 00000000..154162b1 --- /dev/null +++ b/docs/GCC토큰정책비교자료.md @@ -0,0 +1,238 @@ +# AI 토큰 사용량 추적 방식 비교 분석 + +> 작성일: 2026-02-07 +> 프로젝트: SAM (Smart Automation Management) +> 목적: AI API 토큰 사용량 추적 방식 선택에 대한 기술적 근거 문서 + +--- + +## 1. 배경 + +SAM 프로젝트는 Google Gemini API와 Anthropic Claude API를 여러 기능에서 활용하고 있다. +AI API 비용 관리 및 테넌트별 사용량 분석을 위해 토큰 사용량 추적이 필요하며, +이를 구현하는 두 가지 방식을 비교 분석한다. + +### SAM 프로젝트 AI API 호출 현황 + +| 기능 | AI 서비스 | 모델 | 프로젝트 | +|------|----------|------|---------| +| AI 리포트 생성 | Google Gemini | gemini-2.0-flash | API | +| 사업자등록증 OCR | Anthropic Claude | claude-3-haiku | MNG | +| 명함 OCR | Google Gemini | gemini-2.0-flash | MNG | +| 회의록 AI 요약 | Anthropic Claude | claude-3-haiku | MNG | +| 음성 인식 (STT) | Google Cloud | Speech-to-Text | MNG | + +--- + +## 2. 방식 A: API 응답에서 직접 추출하여 DB 저장 (채택) + +### 동작 원리 + +``` +[SAM 서버] → [AI API 호출] → [응답 수신] + ↓ + usageMetadata / usage 필드 추출 + ↓ + ai_token_usages 테이블에 저장 + ↓ + MNG 관리 화면에서 조회 +``` + +### Gemini API 응답 예시 + +```json +{ + "candidates": [{ "content": { "parts": [{ "text": "..." }] } }], + "usageMetadata": { + "promptTokenCount": 1250, + "candidatesTokenCount": 523, + "totalTokenCount": 1773 + } +} +``` + +### Claude API 응답 예시 + +```json +{ + "content": [{ "type": "text", "text": "..." }], + "usage": { + "input_tokens": 1100, + "output_tokens": 480 + } +} +``` + +### 장점 + +- **비즈니스 컨텍스트 연결**: 테넌트, 기능(메뉴), 사용자 단위로 추적 가능 +- **추가 인증 불필요**: 기존 API Key만으로 동작 (Google Cloud 서비스 계정 불필요) +- **실시간 기록**: API 호출 즉시 저장 +- **다중 AI 서비스 통합**: Gemini, Claude 등 서로 다른 AI 서비스를 하나의 테이블에서 통합 관리 +- **구현 난이도 낮음**: 응답에 이미 포함된 데이터를 추출하기만 하면 됨 +- **멀티테넌트 지원**: tenant_id 기반으로 테넌트별 비용 분리 가능 + +### 단점 + +- **코드 수정 필요**: 새 AI 기능 추가 시 토큰 저장 코드를 명시적으로 추가해야 함 +- **누락 가능성**: try-catch 내부에서 저장 실패 시 데이터 누락 가능 (단, 비즈니스 로직에 영향 없음) +- **외부 호출 미추적**: SAM 외부에서 동일 API Key로 호출한 사용량은 추적 불가 +- **비용 계산은 추정치**: 모델별 단가를 코드에 하드코딩하므로 가격 변동 시 업데이트 필요 + +--- + +## 3. 방식 B: Google Cloud Monitoring API로 조회 + +### 동작 원리 + +``` +[Google Cloud Console] + ↓ +[Cloud Monitoring API] ← SAM 서버에서 주기적 조회 + ↓ + 프로젝트 전체 사용량 집계 반환 + ↓ + SAM DB에 저장 또는 직접 표시 +``` + +### 관련 Google Cloud API + +| API | 용도 | 제공 데이터 | +|-----|------|-----------| +| `monitoring.googleapis.com` | 메트릭 조회 | API 호출 수, 토큰 수 (프로젝트 단위) | +| `serviceusage.googleapis.com` | 할당량 관리 | API 사용량 및 할당량 | +| `billing.googleapis.com` | 결제 데이터 | 프로젝트 전체 과금 정보 | + +### 요구 사항 + +1. **Google Cloud 프로젝트** 필요 (API Key만으로는 불가) +2. **서비스 계정** 생성 및 IAM 역할 부여 필요 + - `roles/monitoring.viewer` (Monitoring 읽기) + - `roles/billing.viewer` (결제 읽기) +3. **OAuth2 인증** 또는 서비스 계정 JSON 키 파일 +4. Cloud Monitoring API 활성화 + +### 장점 + +- **자동 집계**: 모든 API 호출이 자동으로 집계됨 (코드 수정 불필요) +- **누락 없음**: Google 인프라에서 직접 집계하므로 데이터 손실 없음 +- **정확한 과금 데이터**: Google이 산정한 실제 비용 조회 가능 +- **외부 호출 포함**: API Key를 사용하는 모든 호출이 포함됨 + +### 단점 + +- **비즈니스 컨텍스트 부재**: 프로젝트 전체 합계만 제공 (테넌트별, 기능별, 사용자별 분리 불가) +- **추가 인프라 비용**: Cloud Monitoring API 자체도 과금 대상 +- **데이터 지연**: 실시간이 아닌 수 분~수 시간 지연 (최대 24시간) +- **복잡한 인증**: 서비스 계정 설정, IAM 역할, OAuth2 등 관리 부담 +- **Gemini만 해당**: Claude API (Anthropic) 사용량은 Google Cloud에서 조회 불가 +- **API Key 방식 제한**: 현재 SAM이 사용하는 API Key 인증으로는 일부 메트릭 접근 제한 + +--- + +## 4. 비교표 + +| 비교 항목 | 방식 A (API 응답 직접 추출) | 방식 B (GCC Monitoring API) | +|-----------|:---:|:---:| +| 테넌트별 분리 | **O** | X | +| 기능/메뉴별 분리 | **O** | X | +| 사용자별 추적 | **O** | X | +| 실시간 기록 | **O** | X (지연 있음) | +| 다중 AI 서비스 통합 | **O** (Gemini + Claude) | X (Gemini만) | +| 추가 인증/인프라 필요 | X | **O** (서비스 계정, IAM) | +| 누락 가능성 | 있음 (방어 코드 적용) | 없음 | +| 코드 수정 필요성 | 새 기능마다 추가 | 불필요 | +| 구현 난이도 | **낮음** | 높음 | +| 운영 비용 | 없음 | Cloud Monitoring 과금 | +| 비용 정확도 | 추정치 (모델별 단가 기반) | **정확** (Google 산정) | +| 외부 호출 추적 | X | O | + +--- + +## 5. SAM 프로젝트 적합성 분석 + +### 방식 A를 채택한 핵심 이유 + +1. **멀티테넌트 환경** + SAM은 여러 테넌트(회사)가 하나의 시스템을 공유하는 멀티테넌트 구조이다. + "어떤 회사(테넌트)가 얼마나 사용했는지"를 추적해야 하며, GCC는 이 정보를 제공하지 않는다. + +2. **기능별 비용 분석** + AI 리포트, 명함 OCR, 회의록 요약 등 기능별 사용량을 별도로 집계해야 한다. + GCC는 프로젝트 전체 합계만 제공하므로 기능별 분석이 불가능하다. + +3. **다중 AI 서비스** + SAM은 Gemini와 Claude를 함께 사용한다. + GCC는 Google 서비스(Gemini)만 추적 가능하고, Anthropic Claude 사용량은 별도 관리해야 한다. + +4. **인프라 단순성** + 추가 서비스 계정, IAM 설정, Cloud Monitoring API 활성화 등의 인프라 작업 없이 + 기존 API 응답에서 데이터를 추출하는 것만으로 충분하다. + +--- + +## 6. 향후 개선 방안 + +### 6.1 월말 교차 검증 (권장) + +- 방식 A의 DB 합계와 Google Cloud Console 대시보드의 사용량을 월 1회 대조 +- 누락 여부를 확인하고 차이가 크면 원인 분석 + +### 6.2 단가 자동 업데이트 + +현재 모델별 단가가 코드에 하드코딩되어 있음. 향후 개선 가능: +- `ai_configs` 테이블에 모델별 단가 컬럼 추가 +- 또는 별도 `ai_pricing` 설정 테이블 생성 + +### 6.3 알림 기능 + +- 월간 예산 임계치 초과 시 관리자 알림 +- 테넌트별 사용량 한도 설정 + +--- + +## 7. 기술 구현 명세 + +### 저장 테이블: `ai_token_usages` + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | bigint (PK) | 자동 증가 | +| tenant_id | bigint (FK) | 테넌트 ID | +| model | varchar(100) | AI 모델명 | +| menu_name | varchar(100) | 호출 기능명 | +| prompt_tokens | int unsigned | 입력 토큰 수 | +| completion_tokens | int unsigned | 출력 토큰 수 | +| total_tokens | int unsigned | 전체 토큰 수 | +| cost_usd | decimal(10,6) | 예상 비용 (USD) | +| cost_krw | decimal(12,2) | 예상 비용 (KRW) | +| request_id | varchar(100) | 요청 추적 UUID | +| created_by | bigint (FK) | 생성자 ID | +| created_at | timestamp | 생성 시각 | +| updated_at | timestamp | 수정 시각 | + +### 인덱스 + +- `(tenant_id, created_at)` - 테넌트별 기간 조회 +- `(tenant_id, menu_name)` - 테넌트별 기능 조회 + +### 비용 단가 기준 (2026-02 기준) + +| 모델 | 입력 단가 | 출력 단가 | +|------|----------|----------| +| Gemini 2.0 Flash | $0.10 / 1M tokens | $0.40 / 1M tokens | +| Claude 3 Haiku | $0.25 / 1M tokens | $1.25 / 1M tokens | + +환율: 기본 1,400원/USD (config 설정 가능) + +--- + +## 8. 결론 + +SAM 프로젝트의 멀티테넌트 아키텍처와 다중 AI 서비스 사용 환경에서, +**API 응답 직접 추출 방식(방식 A)**이 비즈니스 요구사항을 충족하는 최적의 선택이다. + +Google Cloud Monitoring API(방식 B)는 프로젝트 전체 사용량 확인에는 유용하나, +테넌트별/기능별 상세 추적이 불가능하여 SAM의 핵심 요구사항을 충족하지 못한다. + +필요시 방식 B를 월말 교차 검증 용도로 보조적으로 활용할 수 있다. From 5fe6afd9c48dd024d7512ef3458c9d3525592b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 7 Feb 2026 12:52:37 +0900 Subject: [PATCH 06/31] =?UTF-8?q?feat:AI=20=EC=9D=8C=EC=84=B1=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiVoiceRecording 모델 (상태 상수, 접근자) - AiVoiceRecordingService (GCS 업로드, STT, Gemini 분석 파이프라인) - AiVoiceRecordingController (CRUD, 녹음 처리, 상태 폴링) - React 블레이드 뷰 (녹음 UI, 파일 업로드, 목록, 상세 모달) - 라우트 추가 (system/ai-voice-recording) - 메뉴 시더에 AI 음성녹음 항목 추가 Co-Authored-By: Claude Opus 4.6 --- .../System/AiVoiceRecordingController.php | 204 +++++ app/Models/AiVoiceRecording.php | 94 +++ app/Services/AiVoiceRecordingService.php | 475 +++++++++++ database/seeders/AiTokenUsageMenuSeeder.php | 28 + .../system/ai-voice-recording/index.blade.php | 798 ++++++++++++++++++ routes/web.php | 13 + 6 files changed, 1612 insertions(+) create mode 100644 app/Http/Controllers/System/AiVoiceRecordingController.php create mode 100644 app/Models/AiVoiceRecording.php create mode 100644 app/Services/AiVoiceRecordingService.php create mode 100644 resources/views/system/ai-voice-recording/index.blade.php diff --git a/app/Http/Controllers/System/AiVoiceRecordingController.php b/app/Http/Controllers/System/AiVoiceRecordingController.php new file mode 100644 index 00000000..de7efedd --- /dev/null +++ b/app/Http/Controllers/System/AiVoiceRecordingController.php @@ -0,0 +1,204 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('system.ai-voice-recording.index')); + } + + return view('system.ai-voice-recording.index'); + } + + /** + * JSON 목록 + */ + public function list(Request $request): JsonResponse + { + $params = $request->only(['search', 'status', 'per_page']); + $recordings = $this->service->getList($params); + + return response()->json([ + 'success' => true, + 'data' => $recordings, + ]); + } + + /** + * 새 녹음 생성 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'nullable|string|max:200', + ]); + + $recording = $this->service->create($validated); + + return response()->json([ + 'success' => true, + 'message' => '녹음이 생성되었습니다.', + 'data' => $recording, + ], 201); + } + + /** + * Base64 오디오 업로드 + 처리 + */ + public function processAudio(Request $request, int $id): JsonResponse + { + $recording = AiVoiceRecording::find($id); + + if (! $recording) { + return response()->json([ + 'success' => false, + 'message' => '녹음을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'audio' => 'required|string', + 'duration' => 'required|integer|min:1', + ]); + + $result = $this->service->processAudio( + $recording, + $validated['audio'], + $validated['duration'] + ); + + if (! $result['ok']) { + return response()->json([ + 'success' => false, + 'message' => $result['error'] ?? '처리 중 오류가 발생했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => '음성 분석이 완료되었습니다.', + 'data' => $result['recording'], + ]); + } + + /** + * 파일 업로드 + 처리 + */ + public function uploadFile(Request $request): JsonResponse + { + $validated = $request->validate([ + 'audio_file' => 'required|file|mimes:webm,wav,mp3,ogg,m4a,mp4|max:102400', + 'title' => 'nullable|string|max:200', + ]); + + $recording = $this->service->create([ + 'title' => $validated['title'] ?? '업로드된 음성녹음', + ]); + + $result = $this->service->processUploadedFile( + $recording, + $request->file('audio_file') + ); + + if (! $result['ok']) { + return response()->json([ + 'success' => false, + 'message' => $result['error'] ?? '처리 중 오류가 발생했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => '음성 분석이 완료되었습니다.', + 'data' => $result['recording'], + ]); + } + + /** + * 상세 조회 + */ + public function show(int $id): JsonResponse + { + $recording = $this->service->getById($id); + + if (! $recording) { + return response()->json([ + 'success' => false, + 'message' => '녹음을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $recording, + ]); + } + + /** + * 삭제 + */ + public function destroy(int $id): JsonResponse + { + $recording = AiVoiceRecording::find($id); + + if (! $recording) { + return response()->json([ + 'success' => false, + 'message' => '녹음을 찾을 수 없습니다.', + ], 404); + } + + $this->service->delete($recording); + + return response()->json([ + 'success' => true, + 'message' => '녹음이 삭제되었습니다.', + ]); + } + + /** + * 처리 상태 폴링용 + */ + public function status(int $id): JsonResponse + { + $recording = AiVoiceRecording::find($id); + + if (! $recording) { + return response()->json([ + 'success' => false, + 'message' => '녹음을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'id' => $recording->id, + 'status' => $recording->status, + 'status_label' => $recording->status_label, + 'is_completed' => $recording->isCompleted(), + 'is_processing' => $recording->isProcessing(), + 'transcript_text' => $recording->transcript_text, + 'analysis_text' => $recording->analysis_text, + ], + ]); + } +} diff --git a/app/Models/AiVoiceRecording.php b/app/Models/AiVoiceRecording.php new file mode 100644 index 00000000..9a36cb7c --- /dev/null +++ b/app/Models/AiVoiceRecording.php @@ -0,0 +1,94 @@ + 'integer', + 'file_expiry_date' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + public const STATUS_PENDING = 'PENDING'; + + public const STATUS_PROCESSING = 'PROCESSING'; + + public const STATUS_COMPLETED = 'COMPLETED'; + + public const STATUS_FAILED = 'FAILED'; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function getFormattedDurationAttribute(): string + { + if (! $this->duration_seconds) { + return '00:00'; + } + + $minutes = floor($this->duration_seconds / 60); + $seconds = $this->duration_seconds % 60; + + return sprintf('%02d:%02d', $minutes, $seconds); + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => '대기중', + self::STATUS_PROCESSING => '처리중', + self::STATUS_COMPLETED => '완료', + self::STATUS_FAILED => '실패', + default => $this->status, + }; + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => 'badge-warning', + self::STATUS_PROCESSING => 'badge-info', + self::STATUS_COMPLETED => 'badge-success', + self::STATUS_FAILED => 'badge-error', + default => 'badge-ghost', + }; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public function isProcessing(): bool + { + return $this->status === self::STATUS_PROCESSING; + } +} diff --git a/app/Services/AiVoiceRecordingService.php b/app/Services/AiVoiceRecordingService.php new file mode 100644 index 00000000..99a6b73d --- /dev/null +++ b/app/Services/AiVoiceRecordingService.php @@ -0,0 +1,475 @@ +with('user:id,name') + ->orderBy('created_at', 'desc'); + + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('transcript_text', 'like', "%{$search}%") + ->orWhere('analysis_text', 'like', "%{$search}%"); + }); + } + + $perPage = $params['per_page'] ?? 10; + + return $query->paginate($perPage); + } + + /** + * 상세 조회 + */ + public function getById(int $id): ?AiVoiceRecording + { + return AiVoiceRecording::with('user:id,name')->find($id); + } + + /** + * 새 녹음 레코드 생성 + */ + public function create(array $data): AiVoiceRecording + { + return AiVoiceRecording::create([ + 'tenant_id' => session('selected_tenant_id'), + 'user_id' => Auth::id(), + 'title' => $data['title'] ?? '무제 음성녹음', + 'status' => AiVoiceRecording::STATUS_PENDING, + 'file_expiry_date' => now()->addDays(7), + ]); + } + + /** + * Base64 오디오 업로드 + 처리 파이프라인 + */ + public function processAudio(AiVoiceRecording $recording, string $audioBase64, int $durationSeconds): array + { + try { + $recording->update([ + 'status' => AiVoiceRecording::STATUS_PROCESSING, + 'duration_seconds' => $durationSeconds, + ]); + + // 1. GCS에 오디오 업로드 + $objectName = sprintf( + 'voice-recordings/%d/%d/%s.webm', + $recording->tenant_id, + $recording->id, + now()->format('YmdHis') + ); + + $gcsUri = $this->googleCloudService->uploadBase64Audio($audioBase64, $objectName); + + if (! $gcsUri) { + throw new \Exception('오디오 파일 업로드 실패'); + } + + $recording->update([ + 'audio_file_path' => $objectName, + 'audio_gcs_uri' => $gcsUri, + ]); + + // 2. Speech-to-Text 변환 + $transcript = $this->googleCloudService->speechToText($gcsUri); + + if (! $transcript) { + throw new \Exception('음성 인식 실패'); + } + + $recording->update(['transcript_text' => $transcript]); + + // 3. Gemini AI 분석 + $analysis = $this->analyzeWithGemini($transcript); + + $recording->update([ + 'analysis_text' => $analysis, + 'status' => AiVoiceRecording::STATUS_COMPLETED, + ]); + + return [ + 'ok' => true, + 'recording' => $recording->fresh(), + ]; + } catch (\Exception $e) { + Log::error('AiVoiceRecording 처리 실패', [ + 'recording_id' => $recording->id, + 'error' => $e->getMessage(), + ]); + + $recording->update(['status' => AiVoiceRecording::STATUS_FAILED]); + + return [ + 'ok' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * 파일 업로드 처리 + */ + public function processUploadedFile(AiVoiceRecording $recording, UploadedFile $file): array + { + try { + $recording->update(['status' => AiVoiceRecording::STATUS_PROCESSING]); + + // 임시 저장 + $tempPath = $file->store('temp', 'local'); + $fullPath = storage_path('app/' . $tempPath); + + // 파일 크기로 대략적인 재생 시간 추정 (12KB/초 기준) + $fileSize = $file->getSize(); + $estimatedDuration = max(1, intval($fileSize / 12000)); + $recording->update(['duration_seconds' => $estimatedDuration]); + + // 1. GCS에 오디오 업로드 + $extension = $file->getClientOriginalExtension() ?: 'webm'; + $objectName = sprintf( + 'voice-recordings/%d/%d/%s.%s', + $recording->tenant_id, + $recording->id, + now()->format('YmdHis'), + $extension + ); + + $gcsUri = $this->googleCloudService->uploadToStorage($fullPath, $objectName); + + if (! $gcsUri) { + @unlink($fullPath); + throw new \Exception('오디오 파일 업로드 실패'); + } + + $recording->update([ + 'audio_file_path' => $objectName, + 'audio_gcs_uri' => $gcsUri, + ]); + + // 2. Speech-to-Text 변환 + $transcript = $this->googleCloudService->speechToText($gcsUri); + + // 임시 파일 삭제 + @unlink($fullPath); + + if (! $transcript) { + throw new \Exception('음성 인식 실패'); + } + + $recording->update(['transcript_text' => $transcript]); + + // 3. Gemini AI 분석 + $analysis = $this->analyzeWithGemini($transcript); + + $recording->update([ + 'analysis_text' => $analysis, + 'status' => AiVoiceRecording::STATUS_COMPLETED, + ]); + + return [ + 'ok' => true, + 'recording' => $recording->fresh(), + ]; + } catch (\Exception $e) { + Log::error('AiVoiceRecording 파일 처리 실패', [ + 'recording_id' => $recording->id, + 'error' => $e->getMessage(), + ]); + + $recording->update(['status' => AiVoiceRecording::STATUS_FAILED]); + + return [ + 'ok' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Gemini API로 영업 시나리오 분석 + */ + private function analyzeWithGemini(string $transcript): ?string + { + $config = AiConfig::getActiveGemini(); + + if (! $config) { + Log::warning('Gemini API 설정이 없습니다.'); + return null; + } + + $prompt = $this->buildAnalysisPrompt($transcript); + + if ($config->isVertexAi()) { + return $this->callVertexAiApi($config, $prompt); + } + + return $this->callGoogleAiStudioApi($config, $prompt); + } + + /** + * Google AI Studio API 호출 + */ + private function callGoogleAiStudioApi(AiConfig $config, string $prompt): ?string + { + $model = $config->model; + $apiKey = $config->api_key; + $baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta'; + + $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; + + return $this->callGeminiApi($url, $prompt, [ + 'Content-Type' => 'application/json', + ], false); + } + + /** + * Vertex AI API 호출 + */ + private function callVertexAiApi(AiConfig $config, string $prompt): ?string + { + $model = $config->model; + $projectId = $config->getProjectId(); + $region = $config->getRegion(); + + if (! $projectId) { + Log::error('Vertex AI 프로젝트 ID가 설정되지 않았습니다.'); + return null; + } + + $accessToken = $this->getAccessToken($config); + if (! $accessToken) { + Log::error('Google Cloud 인증 실패'); + return null; + } + + $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; + + return $this->callGeminiApi($url, $prompt, [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + ], true); + } + + /** + * Gemini API 공통 호출 로직 + */ + private function callGeminiApi(string $url, string $prompt, array $headers, bool $isVertexAi = false): ?string + { + $content = [ + 'parts' => [ + ['text' => $prompt], + ], + ]; + + if ($isVertexAi) { + $content['role'] = 'user'; + } + + try { + $response = Http::timeout(120) + ->withHeaders($headers) + ->post($url, [ + 'contents' => [$content], + 'generationConfig' => [ + 'temperature' => 0.3, + 'topK' => 40, + 'topP' => 0.95, + 'maxOutputTokens' => 4096, + ], + ]); + + if (! $response->successful()) { + Log::error('Gemini API error', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return null; + } + + $result = $response->json(); + + // 토큰 사용량 저장 + AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? 'gemini', 'AI음성녹음분석'); + + return $result['candidates'][0]['content']['parts'][0]['text'] ?? null; + } catch (\Exception $e) { + Log::error('Gemini API 예외', ['error' => $e->getMessage()]); + return null; + } + } + + /** + * 서비스 계정으로 OAuth2 액세스 토큰 가져오기 + */ + private function getAccessToken(AiConfig $config): ?string + { + $configuredPath = $config->getServiceAccountPath(); + + $possiblePaths = array_filter([ + $configuredPath, + '/var/www/sales/apikey/google_service_account.json', + storage_path('app/google_service_account.json'), + ]); + + $serviceAccountPath = null; + foreach ($possiblePaths as $path) { + if ($path && file_exists($path)) { + $serviceAccountPath = $path; + break; + } + } + + if (! $serviceAccountPath) { + Log::error('Service account file not found', ['tried_paths' => $possiblePaths]); + return null; + } + + $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); + if (! $serviceAccount) { + Log::error('Service account JSON parse failed'); + return null; + } + + $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/cloud-platform', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now, + ])); + + $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); + if (! $privateKey) { + Log::error('Failed to load private key'); + return null; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature); + + try { + $response = 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'] ?? null; + } + + Log::error('OAuth token request failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return null; + } catch (\Exception $e) { + Log::error('OAuth token request exception', ['error' => $e->getMessage()]); + return null; + } + } + + /** + * Base64 URL 인코딩 + */ + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * 영업 시나리오 분석 프롬프트 + */ + private function buildAnalysisPrompt(string $transcript): string + { + return <<audio_file_path) { + $this->googleCloudService->deleteFromStorage($recording->audio_file_path); + } + + return $recording->delete(); + } + + /** + * 만료된 파일 정리 (Cron용) + */ + public function cleanupExpiredFiles(): int + { + $expired = AiVoiceRecording::where('file_expiry_date', '<=', now()) + ->whereNotNull('audio_file_path') + ->get(); + + $count = 0; + foreach ($expired as $recording) { + if ($recording->audio_file_path) { + $this->googleCloudService->deleteFromStorage($recording->audio_file_path); + $recording->update([ + 'audio_file_path' => null, + 'audio_gcs_uri' => null, + ]); + $count++; + } + } + + return $count; + } +} diff --git a/database/seeders/AiTokenUsageMenuSeeder.php b/database/seeders/AiTokenUsageMenuSeeder.php index e5f39563..905c9d4c 100644 --- a/database/seeders/AiTokenUsageMenuSeeder.php +++ b/database/seeders/AiTokenUsageMenuSeeder.php @@ -81,6 +81,34 @@ public function run(): void $this->command->info("AI 토큰 사용량 메뉴가 이미 AI 관리 그룹에 있습니다."); } + // 4. AI 음성녹음 메뉴 생성 또는 이동 + $aiVoice = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', 'AI 음성녹음') + ->whereNull('deleted_at') + ->first(); + + if ($aiVoice && $aiVoice->parent_id !== $aiGroup->id) { + $aiVoice->update([ + 'parent_id' => $aiGroup->id, + 'sort_order' => 3, + ]); + $this->command->info("AI 음성녹음 메뉴를 AI 관리 그룹으로 이동 완료"); + } elseif (! $aiVoice) { + Menu::withoutGlobalScopes()->create([ + 'tenant_id' => $tenantId, + 'parent_id' => $aiGroup->id, + 'name' => 'AI 음성녹음', + 'url' => '/system/ai-voice-recording', + 'icon' => 'mic', + 'sort_order' => 3, + 'is_active' => true, + ]); + $this->command->info("AI 음성녹음 메뉴 생성 완료"); + } else { + $this->command->info("AI 음성녹음 메뉴가 이미 AI 관리 그룹에 있습니다."); + } + // 결과 출력 $this->command->info(''); $this->command->info('=== AI 관리 하위 메뉴 ==='); diff --git a/resources/views/system/ai-voice-recording/index.blade.php b/resources/views/system/ai-voice-recording/index.blade.php new file mode 100644 index 00000000..7e979102 --- /dev/null +++ b/resources/views/system/ai-voice-recording/index.blade.php @@ -0,0 +1,798 @@ +@extends('layouts.app') + +@section('title', 'AI 음성녹음') + +@push('styles') + +@endpush + +@section('content') +
+@endsection + +@push('scripts') + + + + +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index 12a40bf5..06a71d36 100644 --- a/routes/web.php +++ b/routes/web.php @@ -35,6 +35,7 @@ use App\Http\Controllers\Sales\SalesProductController; use App\Http\Controllers\System\AiConfigController; use App\Http\Controllers\System\AiTokenUsageController; +use App\Http\Controllers\System\AiVoiceRecordingController; use App\Http\Controllers\System\HolidayController; use App\Http\Controllers\Stats\StatDashboardController; use App\Http\Controllers\System\SystemAlertController; @@ -411,6 +412,18 @@ Route::get('/list', [AiTokenUsageController::class, 'list'])->name('list'); }); + // AI 음성녹음 관리 + Route::prefix('system/ai-voice-recording')->name('system.ai-voice-recording.')->group(function () { + Route::get('/', [AiVoiceRecordingController::class, 'index'])->name('index'); + Route::get('/list', [AiVoiceRecordingController::class, 'list'])->name('list'); + Route::post('/', [AiVoiceRecordingController::class, 'store'])->name('store'); + Route::post('/upload', [AiVoiceRecordingController::class, 'uploadFile'])->name('upload'); + Route::get('/{id}', [AiVoiceRecordingController::class, 'show'])->name('show'); + Route::post('/{id}/process', [AiVoiceRecordingController::class, 'processAudio'])->name('process'); + Route::delete('/{id}', [AiVoiceRecordingController::class, 'destroy'])->name('destroy'); + Route::get('/{id}/status', [AiVoiceRecordingController::class, 'status'])->name('status'); + }); + // 명함 OCR API Route::post('/api/business-card-ocr', [BusinessCardOcrController::class, 'process'])->name('api.business-card-ocr'); From 31dc453f68893155cd5ab15f230d560d6954b215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 7 Feb 2026 12:58:24 +0900 Subject: [PATCH 07/31] =?UTF-8?q?fix:AI=20=EC=9D=8C=EC=84=B1=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20React=20=EC=8A=A4=EC=BD=94=ED=94=84=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전역 Toast 등 컴포넌트명 충돌 방지를 위해 IIFE로 감싸기 Co-Authored-By: Claude Opus 4.6 --- resources/views/system/ai-voice-recording/index.blade.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/views/system/ai-voice-recording/index.blade.php b/resources/views/system/ai-voice-recording/index.blade.php index 7e979102..721fa787 100644 --- a/resources/views/system/ai-voice-recording/index.blade.php +++ b/resources/views/system/ai-voice-recording/index.blade.php @@ -26,6 +26,7 @@ @verbatim @endverbatim @endpush From 031bcf8a4c017eb6ae3d064d4a7fa065b1fd9989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 7 Feb 2026 13:06:17 +0900 Subject: [PATCH 08/31] =?UTF-8?q?refactor:AI=20=EC=9D=8C=EC=84=B1=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20UI=20=EC=A0=84=EB=A9=B4=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Canvas 기반 실시간 파형 시각화 (Web Audio API) - 원형 녹음 버튼 (보라→빨강→녹색 상태 전환) - 저장 진행률 오버레이 (프로그레스바 + 애니메이션) - 카드형 목록 (호버 액션, 상태 아이콘) - 탭 기반 상세 모달 (AI 분석 / 녹취록 원문) - 파일 업로드 드래그&드롭 개선 - 검색/필터 UI 개선 - 전체 IIFE 스코프 격리 Co-Authored-By: Claude Opus 4.6 --- .../system/ai-voice-recording/index.blade.php | 1255 +++++++++-------- 1 file changed, 687 insertions(+), 568 deletions(-) diff --git a/resources/views/system/ai-voice-recording/index.blade.php b/resources/views/system/ai-voice-recording/index.blade.php index 721fa787..91e910e0 100644 --- a/resources/views/system/ai-voice-recording/index.blade.php +++ b/resources/views/system/ai-voice-recording/index.blade.php @@ -4,14 +4,18 @@ @push('styles') @endpush @@ -27,378 +31,485 @@ @verbatim