From 427e5852368c6e47620470c29effa6eb21271ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 18 Mar 2026 09:26:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[settings]=20AI=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EB=9F=89=20=EC=A1=B0=ED=9A=8C=20API=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 - GET /settings/ai-token-usage: 목록 + 통계 조회 - GET /settings/ai-token-usage/pricing: 단가 설정 조회 (읽기 전용) - AiTokenHelper: Gemini/Claude/R2/STT 사용량 기록 헬퍼 - AiPricingConfig 캐시에 cloudflare-r2 provider 추가 --- app/Helpers/AiTokenHelper.php | 133 ++++++++++++++++++ .../Api/V1/AiTokenUsageController.php | 31 ++++ .../Requests/V1/AiTokenUsageListRequest.php | 24 ++++ app/Models/Tenants/AiPricingConfig.php | 2 +- app/Services/AiTokenUsageService.php | 72 ++++++++++ lang/en/message.php | 6 + lang/ko/message.php | 6 + routes/api/v1/common.php | 5 + 8 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 app/Helpers/AiTokenHelper.php create mode 100644 app/Http/Controllers/Api/V1/AiTokenUsageController.php create mode 100644 app/Http/Requests/V1/AiTokenUsageListRequest.php create mode 100644 app/Services/AiTokenUsageService.php diff --git a/app/Helpers/AiTokenHelper.php b/app/Helpers/AiTokenHelper.php new file mode 100644 index 00000000..ba183cdc --- /dev/null +++ b/app/Helpers/AiTokenHelper.php @@ -0,0 +1,133 @@ +input_price_per_million / 1_000_000 : 0.10 / 1_000_000; + $outputPrice = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 0.40 / 1_000_000; + + self::save($model, $menuName, $promptTokens, $completionTokens, $totalTokens, $inputPrice, $outputPrice); + } catch (\Exception $e) { + Log::warning('AI token usage save failed (Gemini)', ['error' => $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; + + $pricing = AiPricingConfig::getActivePricing('claude'); + $inputPrice = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.25 / 1_000_000; + $outputPrice = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 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()]); + } + } + + /** + * Cloudflare R2 Storage 업로드 사용량 저장 + * Class A (PUT/POST): $0.0045 / 1,000,000건 + * Storage: $0.015 / GB / 월 + */ + public static function saveR2StorageUsage(string $menuName, int $fileSizeBytes): void + { + try { + $pricing = AiPricingConfig::getActivePricing('cloudflare-r2'); + $unitPrice = $pricing ? (float) $pricing->unit_price : 0.0045; + + $operationCost = $unitPrice / 1_000_000; + $fileSizeGB = $fileSizeBytes / (1024 * 1024 * 1024); + $storageCost = $fileSizeGB * 0.015; + $costUsd = $operationCost + $storageCost; + + self::save('cloudflare-r2', $menuName, $fileSizeBytes, 0, $fileSizeBytes, $costUsd / max($fileSizeBytes, 1), 0); + } catch (\Exception $e) { + Log::warning('AI token usage save failed (R2)', ['error' => $e->getMessage()]); + } + } + + /** + * Speech-to-Text 사용량 저장 + * STT latest_long 모델: $0.009 / 15초 + */ + public static function saveSttUsage(string $menuName, int $durationSeconds): void + { + try { + $pricing = AiPricingConfig::getActivePricing('google-stt'); + $sttUnitPrice = $pricing ? (float) $pricing->unit_price : 0.009; + $costUsd = ceil($durationSeconds / 15) * $sttUnitPrice; + + self::save('google-speech-to-text', $menuName, $durationSeconds, 0, $durationSeconds, $costUsd / max($durationSeconds, 1), 0); + } catch (\Exception $e) { + Log::warning('AI token usage save failed (STT)', ['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 = AiPricingConfig::getExchangeRate(); + $costKrw = $costUsd * $exchangeRate; + + $tenantId = app('tenant_id'); + $userId = app('api_user'); + + AiTokenUsage::create([ + 'tenant_id' => $tenantId ?: 1, + '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' => $userId ?: null, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/AiTokenUsageController.php b/app/Http/Controllers/Api/V1/AiTokenUsageController.php new file mode 100644 index 00000000..05f07523 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AiTokenUsageController.php @@ -0,0 +1,31 @@ + $this->service->list($request->validated())); + } + + /** + * AI 단가 설정 조회 (읽기 전용) + */ + public function pricing() + { + return ApiResponse::handle(fn () => $this->service->getPricing()); + } +} diff --git a/app/Http/Requests/V1/AiTokenUsageListRequest.php b/app/Http/Requests/V1/AiTokenUsageListRequest.php new file mode 100644 index 00000000..49a4757c --- /dev/null +++ b/app/Http/Requests/V1/AiTokenUsageListRequest.php @@ -0,0 +1,24 @@ + 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'menu_name' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:10|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Models/Tenants/AiPricingConfig.php b/app/Models/Tenants/AiPricingConfig.php index e1fc2508..55dc8345 100644 --- a/app/Models/Tenants/AiPricingConfig.php +++ b/app/Models/Tenants/AiPricingConfig.php @@ -58,7 +58,7 @@ public static function getExchangeRate(): float */ public static function clearCache(): void { - $providers = ['gemini', 'claude', 'google-stt', 'google-gcs']; + $providers = ['gemini', 'claude', 'google-stt', 'google-gcs', 'cloudflare-r2']; foreach ($providers as $provider) { Cache::forget("ai_pricing_{$provider}"); } diff --git a/app/Services/AiTokenUsageService.php b/app/Services/AiTokenUsageService.php new file mode 100644 index 00000000..fa0690cb --- /dev/null +++ b/app/Services/AiTokenUsageService.php @@ -0,0 +1,72 @@ +tenantId(); + + $query = AiTokenUsage::where('tenant_id', $tenantId) + ->when($params['start_date'] ?? null, fn ($q, $d) => $q->whereDate('created_at', '>=', $d)) + ->when($params['end_date'] ?? null, fn ($q, $d) => $q->whereDate('created_at', '<=', $d)) + ->when($params['menu_name'] ?? null, fn ($q, $m) => $q->where('menu_name', $m)); + + // 통계 (페이지네이션 전 전체 집계) + $stats = (clone $query)->selectRaw(' + COUNT(*) as total_count, + COALESCE(SUM(prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(completion_tokens), 0) as total_completion_tokens, + COALESCE(SUM(total_tokens), 0) as total_total_tokens, + COALESCE(SUM(cost_usd), 0) as total_cost_usd, + COALESCE(SUM(cost_krw), 0) as total_cost_krw + ')->first(); + + // 목록 (페이지네이션) + $items = $query->with('creator:id,name') + ->orderByDesc('created_at') + ->paginate($params['per_page'] ?? 20); + + // 메뉴명 필터 옵션 + $menuNames = AiTokenUsage::where('tenant_id', $tenantId) + ->select('menu_name') + ->distinct() + ->orderBy('menu_name') + ->pluck('menu_name'); + + return [ + 'items' => $items, + 'stats' => [ + 'total_count' => (int) $stats->total_count, + 'total_prompt_tokens' => (int) $stats->total_prompt_tokens, + 'total_completion_tokens' => (int) $stats->total_completion_tokens, + 'total_total_tokens' => (int) $stats->total_total_tokens, + 'total_cost_usd' => (float) $stats->total_cost_usd, + 'total_cost_krw' => (float) $stats->total_cost_krw, + ], + 'menu_names' => $menuNames, + ]; + } + + /** + * 단가 설정 조회 (읽기 전용) + */ + public function getPricing(): array + { + $configs = AiPricingConfig::where('is_active', true) + ->orderBy('provider') + ->get(); + + return [ + 'pricing' => $configs, + 'exchange_rate' => AiPricingConfig::getExchangeRate(), + ]; + } +} diff --git a/lang/en/message.php b/lang/en/message.php index f2036b5d..2d7310a0 100644 --- a/lang/en/message.php +++ b/lang/en/message.php @@ -117,4 +117,10 @@ 'cancelled' => 'Tax invoice has been cancelled.', 'bulk_issued' => 'Tax invoices have been issued in bulk.', ], + + // AI Token Usage + 'ai_token_usage' => [ + 'fetched' => 'AI token usage retrieved successfully.', + 'pricing_fetched' => 'AI pricing config retrieved successfully.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index c0fed9bf..15b69edc 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -349,6 +349,12 @@ 'generated' => 'AI 리포트가 생성되었습니다.', 'deleted' => 'AI 리포트가 삭제되었습니다.', ], + + // AI 토큰 사용량 + 'ai_token_usage' => [ + 'fetched' => 'AI 토큰 사용량을 조회했습니다.', + 'pricing_fetched' => 'AI 단가 설정을 조회했습니다.', + ], // 가지급금 관리 'loan' => [ 'fetched' => '가지급금을 조회했습니다.', diff --git a/routes/api/v1/common.php b/routes/api/v1/common.php index c37c1ef6..22d6b140 100644 --- a/routes/api/v1/common.php +++ b/routes/api/v1/common.php @@ -12,6 +12,7 @@ */ use App\Http\Controllers\Api\V1\AiReportController; +use App\Http\Controllers\Api\V1\AiTokenUsageController; use App\Http\Controllers\Api\V1\CategoryController; use App\Http\Controllers\Api\V1\CategoryFieldController; use App\Http\Controllers\Api\V1\CategoryLogController; @@ -137,6 +138,10 @@ Route::get('/notifications', [NotificationSettingController::class, 'indexGrouped'])->name('v1.settings.notifications.index'); Route::put('/notifications', [NotificationSettingController::class, 'updateGrouped'])->name('v1.settings.notifications.update'); }); + + // AI 토큰 사용량 + Route::get('/ai-token-usage', [AiTokenUsageController::class, 'index'])->name('v1.settings.ai-token-usage.index'); + Route::get('/ai-token-usage/pricing', [AiTokenUsageController::class, 'pricing'])->name('v1.settings.ai-token-usage.pricing'); }); // Push Notification API (FCM) - auth:sanctum 필수