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] =?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');