feat:AI 토큰 단가 설정 UI 및 DB 조회 연동

- AiPricingConfig 모델 추가 (캐시 적용 단가/환율 조회)
- AiTokenUsageController에 pricingList/pricingUpdate 메서드 추가
- AI 토큰 사용량 페이지에 설정 버튼 + 모달 UI 추가
- AiTokenHelper 하드코딩 단가를 DB 조회로 변경
- pricing 라우트 추가 (GET/PUT)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-09 09:33:56 +09:00
parent ab7c3bd494
commit 4ed902e846
5 changed files with 412 additions and 11 deletions

View File

@@ -2,6 +2,7 @@
namespace App\Helpers;
use App\Models\System\AiPricingConfig;
use App\Models\System\AiTokenUsage;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -23,9 +24,10 @@ public static function saveGeminiUsage(array $apiResult, string $model, string $
$completionTokens = $usage['candidatesTokenCount'] ?? 0;
$totalTokens = $usage['totalTokenCount'] ?? 0;
// Gemini 2.0 Flash 기준 단가
$inputPrice = 0.10 / 1_000_000;
$outputPrice = 0.40 / 1_000_000;
// DB 단가 조회 (fallback: 하드코딩 기본값)
$pricing = AiPricingConfig::getActivePricing('gemini');
$inputPrice = $pricing ? (float) $pricing->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) {
@@ -48,9 +50,10 @@ public static function saveClaudeUsage(array $apiResult, string $model, string $
$completionTokens = $usage['output_tokens'] ?? 0;
$totalTokens = $promptTokens + $completionTokens;
// Claude 3 Haiku 기준 단가
$inputPrice = 0.25 / 1_000_000;
$outputPrice = 1.25 / 1_000_000;
// DB 단가 조회 (fallback: 하드코딩 기본값)
$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) {
@@ -66,8 +69,10 @@ public static function saveGcsStorageUsage(string $menuName, int $fileSizeBytes)
{
try {
$fileSizeMB = $fileSizeBytes / (1024 * 1024);
// 업로드 API 호출 비용 ($0.005/1000 operations) + 월 스토리지 비용 ($0.02/GB, 7일 기준)
$operationCost = 0.005 / 1000;
// DB 단가 조회 (fallback: 하드코딩 기본값)
$pricing = AiPricingConfig::getActivePricing('google-gcs');
$unitPrice = $pricing ? (float) $pricing->unit_price : 0.005;
$operationCost = $unitPrice / 1000;
$storageCost = ($fileSizeMB / 1024) * 0.02 * (7 / 30); // 7일 보관 기준
$costUsd = $operationCost + $storageCost;
@@ -84,8 +89,10 @@ public static function saveGcsStorageUsage(string $menuName, int $fileSizeBytes)
public static function saveSttUsage(string $menuName, int $durationSeconds): void
{
try {
// latest_long 모델: $0.009 per 15 seconds = $0.0006 per second
$costUsd = ceil($durationSeconds / 15) * 0.009;
// DB 단가 조회 (fallback: 하드코딩 기본값)
$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) {
@@ -106,7 +113,7 @@ private static function save(
float $outputPricePerToken,
): void {
$costUsd = ($promptTokens * $inputPricePerToken) + ($completionTokens * $outputPricePerToken);
$exchangeRate = (float) config('services.gemini.exchange_rate', 1400);
$exchangeRate = AiPricingConfig::getExchangeRate();
$costKrw = $costUsd * $exchangeRate;
$tenantId = session('selected_tenant_id', 1);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\System;
use App\Http\Controllers\Controller;
use App\Models\System\AiPricingConfig;
use App\Models\System\AiTokenUsage;
use App\Models\Tenants\Tenant;
use Illuminate\Http\JsonResponse;
@@ -120,4 +121,57 @@ public function list(Request $request): JsonResponse
],
]);
}
public function pricingList(): JsonResponse
{
$configs = AiPricingConfig::orderBy('id')->get();
return response()->json([
'success' => true,
'data' => $configs->map(fn ($c) => [
'id' => $c->id,
'provider' => $c->provider,
'model_name' => $c->model_name,
'input_price_per_million' => (float) $c->input_price_per_million,
'output_price_per_million' => (float) $c->output_price_per_million,
'unit_price' => (float) $c->unit_price,
'unit_description' => $c->unit_description,
'exchange_rate' => (float) $c->exchange_rate,
'is_active' => $c->is_active,
'description' => $c->description,
]),
]);
}
public function pricingUpdate(Request $request): JsonResponse
{
$validated = $request->validate([
'configs' => 'required|array',
'configs.*.id' => 'required|integer|exists:ai_pricing_configs,id',
'configs.*.model_name' => 'required|string|max:100',
'configs.*.input_price_per_million' => 'required|numeric|min:0',
'configs.*.output_price_per_million' => 'required|numeric|min:0',
'configs.*.unit_price' => 'required|numeric|min:0',
'configs.*.exchange_rate' => 'required|numeric|min:0',
'configs.*.description' => 'nullable|string|max:255',
]);
foreach ($validated['configs'] as $item) {
AiPricingConfig::where('id', $item['id'])->update([
'model_name' => $item['model_name'],
'input_price_per_million' => $item['input_price_per_million'],
'output_price_per_million' => $item['output_price_per_million'],
'unit_price' => $item['unit_price'],
'exchange_rate' => $item['exchange_rate'],
'description' => $item['description'] ?? null,
]);
}
AiPricingConfig::clearCache();
return response()->json([
'success' => true,
'message' => '단가 설정이 저장되었습니다.',
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Models\System;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class AiPricingConfig extends Model
{
protected $table = 'ai_pricing_configs';
protected $fillable = [
'provider',
'model_name',
'input_price_per_million',
'output_price_per_million',
'unit_price',
'unit_description',
'exchange_rate',
'is_active',
'description',
];
protected $casts = [
'input_price_per_million' => 'decimal:4',
'output_price_per_million' => 'decimal:4',
'unit_price' => 'decimal:6',
'exchange_rate' => 'decimal:2',
'is_active' => 'boolean',
];
/**
* 활성 단가 설정 조회 (캐시 적용)
*/
public static function getActivePricing(string $provider): ?self
{
return Cache::remember("ai_pricing_{$provider}", 3600, function () use ($provider) {
return self::where('provider', $provider)
->where('is_active', true)
->first();
});
}
/**
* 환율 조회 (첫 번째 활성 레코드 기준)
*/
public static function getExchangeRate(): float
{
return Cache::remember('ai_pricing_exchange_rate', 3600, function () {
$config = self::where('is_active', true)->first();
return $config ? (float) $config->exchange_rate : 1400.0;
});
}
/**
* 캐시 초기화
*/
public static function clearCache(): void
{
$providers = ['gemini', 'claude', 'google-stt', 'google-gcs'];
foreach ($providers as $provider) {
Cache::forget("ai_pricing_{$provider}");
}
Cache::forget('ai_pricing_exchange_rate');
}
}

View File

@@ -99,6 +99,86 @@
color: #9ca3af;
}
.empty-state svg { margin: 0 auto 12px; }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-content {
background: white;
border-radius: 16px;
width: 90%;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 { font-size: 18px; font-weight: 700; color: #111827; }
.modal-body { padding: 24px; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
}
.pricing-table { width: 100%; border-collapse: collapse; }
.pricing-table th {
font-size: 12px;
font-weight: 600;
color: #6b7280;
padding: 8px 10px;
text-align: left;
border-bottom: 2px solid #e5e7eb;
white-space: nowrap;
}
.pricing-table td {
padding: 8px 10px;
border-bottom: 1px solid #f3f4f6;
vertical-align: middle;
}
.pricing-table input[type="text"],
.pricing-table input[type="number"] {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 5px 8px;
font-size: 13px;
width: 100%;
min-width: 80px;
}
.pricing-table input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59,130,246,0.2);
}
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
z-index: 60;
animation: slideIn 0.3s ease;
}
.toast-success { background: #dcfce7; color: #16a34a; border: 1px solid #bbf7d0; }
.toast-error { background: #fee2e2; color: #dc2626; border: 1px solid #fecaca; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
</style>
@endpush
@@ -139,6 +219,9 @@
const DollarSign = createIcon('dollar-sign');
const Hash = createIcon('hash');
const ArrowUpDown = createIcon('arrow-up-down');
const Settings = createIcon('settings');
const X = createIcon('x');
const Save = createIcon('save');
const getCategory = (menuName) => {
if (!menuName) return '-';
@@ -155,12 +238,174 @@
const fmtUsd = (n) => n != null ? '$' + Number(n).toFixed(4) : '$0.0000';
const fmtKrw = (n) => n != null ? Number(n).toLocaleString('ko-KR', { maximumFractionDigits: 0 }) + '원' : '0원';
const PROVIDER_LABELS = {
'gemini': { label: 'Gemini', type: 'token' },
'claude': { label: 'Claude', type: 'token' },
'google-stt': { label: 'Google STT', type: 'unit' },
'google-gcs': { label: 'Google GCS', type: 'unit' },
};
function PricingModal({ show, onClose, onSaved }) {
const [configs, setConfigs] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (show) {
setLoading(true);
fetch('/system/ai-token-usage/pricing')
.then(r => r.json())
.then(json => { if (json.success) setConfigs(json.data); })
.catch(err => console.error('단가 조회 실패:', err))
.finally(() => setLoading(false));
}
}, [show]);
const updateField = (idx, field, value) => {
setConfigs(prev => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
return next;
});
};
const handleSave = async () => {
setSaving(true);
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const res = await fetch('/system/ai-token-usage/pricing', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ configs }),
});
const json = await res.json();
if (json.success) {
onSaved('단가 설정이 저장되었습니다.');
onClose();
} else {
onSaved(json.message || '저장 실패', true);
}
} catch (err) {
onSaved('저장 중 오류가 발생했습니다.', true);
} finally {
setSaving(false);
}
};
if (!show) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="flex items-center gap-2">
<Settings className="w-5 h-5" />
AI 토큰 단가 설정
</h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-lg transition">
<X className="w-5 h-5" />
</button>
</div>
<div className="modal-body">
{loading ? (
<div className="text-center py-8 text-gray-400">
<div className="animate-spin inline-block w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
<p>로딩 ...</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="pricing-table">
<thead>
<tr>
<th>제공자</th>
<th>모델명</th>
<th>입력 단가<br/><span style={{fontWeight:400,fontSize:11}}>$/1M tokens</span></th>
<th>출력 단가<br/><span style={{fontWeight:400,fontSize:11}}>$/1M tokens</span></th>
<th>단위 가격<br/><span style={{fontWeight:400,fontSize:11}}>$ per unit</span></th>
<th>환율<br/><span style={{fontWeight:400,fontSize:11}}>USD→KRW</span></th>
<th>설명</th>
</tr>
</thead>
<tbody>
{configs.map((c, idx) => {
const info = PROVIDER_LABELS[c.provider] || { label: c.provider, type: 'token' };
const isUnit = info.type === 'unit';
return (
<tr key={c.id}>
<td>
<span className="badge badge-purple">{info.label}</span>
</td>
<td>
<input type="text" value={c.model_name}
onChange={e => updateField(idx, 'model_name', e.target.value)} />
</td>
<td>
<input type="number" step="0.0001" value={c.input_price_per_million}
disabled={isUnit}
style={isUnit ? {background:'#f3f4f6',color:'#9ca3af'} : {}}
onChange={e => updateField(idx, 'input_price_per_million', parseFloat(e.target.value) || 0)} />
</td>
<td>
<input type="number" step="0.0001" value={c.output_price_per_million}
disabled={isUnit}
style={isUnit ? {background:'#f3f4f6',color:'#9ca3af'} : {}}
onChange={e => updateField(idx, 'output_price_per_million', parseFloat(e.target.value) || 0)} />
</td>
<td>
<input type="number" step="0.000001" value={c.unit_price}
disabled={!isUnit}
style={!isUnit ? {background:'#f3f4f6',color:'#9ca3af'} : {}}
onChange={e => updateField(idx, 'unit_price', parseFloat(e.target.value) || 0)} />
{isUnit && c.unit_description && (
<div style={{fontSize:11,color:'#9ca3af',marginTop:2}}>{c.unit_description}</div>
)}
</td>
<td>
<input type="number" step="0.01" value={c.exchange_rate}
onChange={e => updateField(idx, 'exchange_rate', parseFloat(e.target.value) || 0)} />
</td>
<td>
<input type="text" value={c.description || ''}
onChange={e => updateField(idx, 'description', e.target.value)} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div style={{marginTop:12,padding:'10px 12px',background:'#fffbeb',borderRadius:8,fontSize:12,color:'#92400e'}}>
* 토큰 기반 모델(Gemini, Claude) 입력/출력 단가를, 단위 기반 서비스(STT, GCS) 단위 가격을 수정하세요.
<br/>* 환율 변경 모든 모델에 동일하게 적용됩니다. 저장 새로운 요청부터 변경된 단가가 적용됩니다.
</div>
</>
)}
</div>
<div className="modal-footer">
<button onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium transition">
취소
</button>
<button onClick={handleSave} disabled={saving || loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition flex items-center gap-1 disabled:opacity-50">
<Save className="w-4 h-4" />
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
);
}
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 [showPricingModal, setShowPricingModal] = useState(false);
const [toast, setToast] = useState(null);
// 필터 상태
const today = new Date();
@@ -210,14 +455,40 @@ function AiTokenUsageApp() {
};
const goPage = (p) => { fetchData(p); };
const showToast = (message, isError = false) => {
setToast({ message, isError });
setTimeout(() => setToast(null), 3000);
};
return (
<div className="space-y-4">
{/* Toast 알림 */}
{toast && (
<div className={`toast ${toast.isError ? 'toast-error' : 'toast-success'}`}>
{toast.message}
</div>
)}
{/* 단가 설정 모달 */}
<PricingModal
show={showPricingModal}
onClose={() => setShowPricingModal(false)}
onSaved={(msg, isError) => showToast(msg, isError)}
/>
{/* 페이지 헤더 */}
<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>
<button
onClick={() => setShowPricingModal(true)}
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition shadow-sm"
>
<Settings className="w-4 h-4" />
단가 설정
</button>
</div>
{/* 통계 카드 */}

View File

@@ -410,6 +410,8 @@
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');
Route::get('/pricing', [AiTokenUsageController::class, 'pricingList'])->name('pricing.list');
Route::put('/pricing', [AiTokenUsageController::class, 'pricingUpdate'])->name('pricing.update');
});
// AI 음성녹음 관리