From f45f91967f405c56d7730f1ee213fdfca40afbb4 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:12 +0900 Subject: [PATCH] =?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=EC=B6=94=EC=A0=81=20=EA=B8=B0=EB=8A=A5=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 - ai_token_usages 테이블 마이그레이션 생성 - AiTokenUsage 모델 생성 - AiReportService에 usageMetadata 추출 및 저장 로직 추가 Co-Authored-By: Claude Opus 4.6 --- app/Models/Tenants/AiTokenUsage.php | 37 +++++++++++++++ app/Services/AiReportService.php | 45 +++++++++++++++++++ ...07_100000_create_ai_token_usages_table.php | 40 +++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 app/Models/Tenants/AiTokenUsage.php create mode 100644 database/migrations/2026_02_07_100000_create_ai_token_usages_table.php diff --git a/app/Models/Tenants/AiTokenUsage.php b/app/Models/Tenants/AiTokenUsage.php new file mode 100644 index 0000000..b3cf5e5 --- /dev/null +++ b/app/Models/Tenants/AiTokenUsage.php @@ -0,0 +1,37 @@ + 'integer', + 'completion_tokens' => 'integer', + 'total_tokens' => 'integer', + 'cost_usd' => 'decimal:6', + 'cost_krw' => 'decimal:2', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\User::class, 'created_by'); + } +} diff --git a/app/Services/AiReportService.php b/app/Services/AiReportService.php index 46a79c8..ca71c32 100644 --- a/app/Services/AiReportService.php +++ b/app/Services/AiReportService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Tenants\AiReport; +use App\Models\Tenants\AiTokenUsage; use App\Models\Tenants\Card; use App\Models\Tenants\Deposit; use App\Models\Tenants\Purchase; @@ -13,6 +14,7 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class AiReportService extends Service { @@ -361,6 +363,9 @@ private function callGeminiApi(array $inputData): array $result = $response->json(); $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? ''; + // 토큰 사용량 저장 + $this->saveTokenUsage($result, $model, 'AI리포트'); + // JSON 파싱 $parsed = json_decode($text, true); if (json_last_error() !== JSON_ERROR_NONE) { @@ -378,6 +383,46 @@ private function callGeminiApi(array $inputData): array } } + /** + * 토큰 사용량 저장 + */ + private function saveTokenUsage(array $apiResult, string $model, string $menuName): void + { + try { + $usage = $apiResult['usageMetadata'] ?? null; + if (! $usage) { + return; + } + + $promptTokens = $usage['promptTokenCount'] ?? 0; + $completionTokens = $usage['candidatesTokenCount'] ?? 0; + $totalTokens = $usage['totalTokenCount'] ?? 0; + + // Gemini 2.0 Flash 기준 단가 (USD per token) + $inputPricePerToken = 0.10 / 1_000_000; // $0.10 / 1M tokens + $outputPricePerToken = 0.40 / 1_000_000; // $0.40 / 1M tokens + + $costUsd = ($promptTokens * $inputPricePerToken) + ($completionTokens * $outputPricePerToken); + $exchangeRate = (float) config('services.gemini.exchange_rate', 1400); + $costKrw = $costUsd * $exchangeRate; + + AiTokenUsage::create([ + 'tenant_id' => $this->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' => $this->apiUserId(), + ]); + } catch (\Exception $e) { + Log::warning('AI token usage save failed', ['error' => $e->getMessage()]); + } + } + /** * AI 프롬프트 생성 */ diff --git a/database/migrations/2026_02_07_100000_create_ai_token_usages_table.php b/database/migrations/2026_02_07_100000_create_ai_token_usages_table.php new file mode 100644 index 0000000..f509328 --- /dev/null +++ b/database/migrations/2026_02_07_100000_create_ai_token_usages_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('tenant_id')->constrained()->comment('테넌트 ID'); + $table->string('model', 100)->comment('AI 모델명 (gemini-2.0-flash 등)'); + $table->string('menu_name', 100)->comment('호출 메뉴/기능명 (AI리포트, 명함OCR 등)'); + $table->unsignedInteger('prompt_tokens')->default(0)->comment('입력 토큰 수'); + $table->unsignedInteger('completion_tokens')->default(0)->comment('출력 토큰 수'); + $table->unsignedInteger('total_tokens')->default(0)->comment('전체 토큰 수'); + $table->decimal('cost_usd', 10, 6)->default(0)->comment('예상 비용 (USD)'); + $table->decimal('cost_krw', 12, 2)->default(0)->comment('예상 비용 (KRW)'); + $table->string('request_id', 100)->nullable()->comment('요청 추적 ID'); + $table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자 ID'); + $table->timestamps(); + + $table->index(['tenant_id', 'created_at'], 'idx_ai_token_tenant_date'); + $table->index(['tenant_id', 'menu_name'], 'idx_ai_token_tenant_menu'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_token_usages'); + } +};