feat:AI 토큰 사용량 관리 화면 추가

- AiTokenUsageController (index, list) 생성
- AiTokenUsage 모델 생성
- React 기반 토큰 사용량 조회 페이지 (필터, 통계, 페이지네이션)
- 라우트 추가 (system/ai-token-usage)
- AiTokenUsageMenuSeeder 메뉴 시더 생성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-07 09:57:25 +09:00
parent 5ca9317880
commit 55f604ce6f
5 changed files with 598 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\System;
use App\Http\Controllers\Controller;
use App\Models\System\AiTokenUsage;
use App\Models\Tenants\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class AiTokenUsageController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->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(),
],
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models\System;
use Illuminate\Database\Eloquent\Model;
class AiTokenUsage extends Model
{
protected $table = 'ai_token_usages';
protected $fillable = [
'tenant_id',
'model',
'menu_name',
'prompt_tokens',
'completion_tokens',
'total_tokens',
'cost_usd',
'cost_krw',
'request_id',
'created_by',
];
protected $casts = [
'prompt_tokens' => '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);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Database\Seeders;
use App\Models\Commons\Menu;
use Illuminate\Database\Seeder;
class AiTokenUsageMenuSeeder extends Seeder
{
public function run(): void
{
$tenantId = 1;
// 시스템 관리 부모 메뉴 찾기
$parentMenu = Menu::where('tenant_id', $tenantId)
->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})");
}
}
}

View File

@@ -0,0 +1,369 @@
@extends('layouts.app')
@section('title', 'AI 토큰 사용량')
@push('styles')
<style>
.stat-card {
background: white;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-card .label { font-size: 13px; color: #6b7280; margin-bottom: 4px; }
.stat-card .value { font-size: 22px; font-weight: 700; color: #111827; }
.stat-card .sub { font-size: 12px; color: #9ca3af; margin-top: 2px; }
.filter-bar {
background: white;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
}
.filter-bar label { display: block; font-size: 12px; color: #6b7280; margin-bottom: 4px; font-weight: 500; }
.filter-bar select,
.filter-bar input {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
min-width: 140px;
}
.filter-bar button {
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
background: #f9fafb;
font-size: 12px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.data-table td {
padding: 10px 12px;
font-size: 13px;
color: #374151;
border-bottom: 1px solid #f3f4f6;
}
.data-table tr:hover td { background: #f9fafb; }
.data-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.pagination-bar button {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.pagination-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
.pagination-bar button:not(:disabled):hover { background: #f3f4f6; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.badge-blue { background: #dbeafe; color: #1d4ed8; }
.badge-green { background: #dcfce7; color: #16a34a; }
.badge-purple { background: #f3e8ff; color: #7c3aed; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: #9ca3af;
}
.empty-state svg { margin: 0 auto 12px; }
</style>
@endpush
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="ai-token-usage-root"></div>
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
@verbatim
<script type="text/babel">
const { useState, useEffect, useRef } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const BrainCircuit = createIcon('brain-circuit');
const Search = createIcon('search');
const RotateCcw = createIcon('rotate-ccw');
const ChevronLeft = createIcon('chevron-left');
const ChevronRight = createIcon('chevron-right');
const Zap = createIcon('zap');
const DollarSign = createIcon('dollar-sign');
const Hash = createIcon('hash');
const ArrowUpDown = createIcon('arrow-up-down');
const fmt = (n) => n != null ? Number(n).toLocaleString() : '0';
const fmtUsd = (n) => n != null ? '$' + Number(n).toFixed(4) : '$0.0000';
const fmtKrw = (n) => n != null ? Number(n).toLocaleString('ko-KR', { maximumFractionDigits: 0 }) + '원' : '0원';
function AiTokenUsageApp() {
const [records, setRecords] = useState([]);
const [stats, setStats] = useState({});
const [filters, setFilters] = useState({ menu_names: [], tenants: [] });
const [pagination, setPagination] = useState({ current_page: 1, last_page: 1, per_page: 20, total: 0 });
const [loading, setLoading] = useState(true);
// 필터 상태
const today = new Date();
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
const [startDate, setStartDate] = useState(firstDay.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState(today.toISOString().split('T')[0]);
const [tenantId, setTenantId] = useState('');
const [menuName, setMenuName] = useState('');
const [page, setPage] = useState(1);
const fetchData = async (p = 1) => {
setLoading(true);
try {
const params = new URLSearchParams();
if (startDate) params.set('start_date', startDate);
if (endDate) params.set('end_date', endDate);
if (tenantId) params.set('tenant_id', tenantId);
if (menuName) params.set('menu_name', menuName);
params.set('page', p);
params.set('per_page', 20);
const res = await fetch(`/system/ai-token-usage/list?${params}`);
const json = await res.json();
if (json.success) {
setRecords(json.data);
setStats(json.stats);
setFilters(json.filters);
setPagination(json.pagination);
setPage(json.pagination.current_page);
}
} catch (err) {
console.error('데이터 조회 실패:', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, []);
const handleSearch = () => { fetchData(1); };
const handleReset = () => {
setStartDate(firstDay.toISOString().split('T')[0]);
setEndDate(today.toISOString().split('T')[0]);
setTenantId('');
setMenuName('');
setTimeout(() => fetchData(1), 0);
};
const goPage = (p) => { fetchData(p); };
return (
<div className="space-y-4">
{/* 페이지 헤더 */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
<BrainCircuit className="w-7 h-7" />
AI 토큰 사용량
</h1>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div className="stat-card">
<div className="label"> 호출 </div>
<div className="value">{fmt(stats.total_count)}</div>
<div className="sub"></div>
</div>
<div className="stat-card">
<div className="label">입력 토큰</div>
<div className="value">{fmt(stats.total_prompt_tokens)}</div>
<div className="sub">tokens</div>
</div>
<div className="stat-card">
<div className="label">출력 토큰</div>
<div className="value">{fmt(stats.total_completion_tokens)}</div>
<div className="sub">tokens</div>
</div>
<div className="stat-card">
<div className="label">전체 토큰</div>
<div className="value">{fmt(stats.total_total_tokens)}</div>
<div className="sub">tokens</div>
</div>
<div className="stat-card">
<div className="label"> 비용 (USD)</div>
<div className="value">{fmtUsd(stats.total_cost_usd)}</div>
<div className="sub">달러</div>
</div>
<div className="stat-card">
<div className="label"> 비용 (KRW)</div>
<div className="value">{fmtKrw(stats.total_cost_krw)}</div>
<div className="sub"></div>
</div>
</div>
{/* 필터 바 */}
<div className="filter-bar">
<div>
<label>시작일</label>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</div>
<div>
<label>종료일</label>
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
</div>
<div>
<label>테넌트</label>
<select value={tenantId} onChange={e => setTenantId(e.target.value)}>
<option value="">전체</option>
{filters.tenants.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div>
<label>호출 메뉴</label>
<select value={menuName} onChange={e => setMenuName(e.target.value)}>
<option value="">전체</option>
{filters.menu_names.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<button
onClick={handleSearch}
className="bg-blue-600 text-white hover:bg-blue-700 flex items-center gap-1"
>
<Search className="w-4 h-4" /> 조회
</button>
<button
onClick={handleReset}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center gap-1"
>
<RotateCcw className="w-4 h-4" /> 초기화
</button>
</div>
{/* 데이터 테이블 */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
{loading ? (
<div className="text-center py-16 text-gray-400">
<div className="animate-spin inline-block w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mb-3"></div>
<p>데이터를 불러오는 ...</p>
</div>
) : records.length === 0 ? (
<div className="empty-state">
<BrainCircuit className="w-12 h-12 text-gray-300" />
<p className="text-lg font-medium text-gray-500 mt-2">사용 내역이 없습니다</p>
<p className="text-sm">AI 기능을 사용하면 토큰 사용량이 기록됩니다.</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>테넌트</th>
<th>사용일시</th>
<th>호출메뉴</th>
<th>모델</th>
<th className="num">입력토큰</th>
<th className="num">출력토큰</th>
<th className="num">전체토큰</th>
<th className="num">비용(USD)</th>
<th className="num">비용(KRW)</th>
</tr>
</thead>
<tbody>
{records.map(r => (
<tr key={r.id}>
<td>
<span className="badge badge-blue">{r.tenant_id}</span>
{' '}{r.tenant_name}
</td>
<td>{r.created_at}</td>
<td><span className="badge badge-green">{r.menu_name}</span></td>
<td><span className="badge badge-purple">{r.model}</span></td>
<td className="num">{fmt(r.prompt_tokens)}</td>
<td className="num">{fmt(r.completion_tokens)}</td>
<td className="num font-semibold">{fmt(r.total_tokens)}</td>
<td className="num">{fmtUsd(r.cost_usd)}</td>
<td className="num font-semibold">{fmtKrw(r.cost_krw)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<div className="pagination-bar px-4">
<span className="text-sm text-gray-500">
{fmt(pagination.total)} (페이지 {pagination.current_page}/{pagination.last_page})
</span>
<div className="flex gap-2">
<button
disabled={pagination.current_page <= 1}
onClick={() => goPage(pagination.current_page - 1)}
className="flex items-center gap-1"
>
<ChevronLeft className="w-4 h-4" /> 이전
</button>
<button
disabled={pagination.current_page >= pagination.last_page}
onClick={() => goPage(pagination.current_page + 1)}
className="flex items-center gap-1"
>
다음 <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</>
)}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('ai-token-usage-root')).render(<AiTokenUsageApp />);
</script>
@endverbatim
@endpush

View File

@@ -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');