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:
@@ -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);
|
||||
|
||||
@@ -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' => '단가 설정이 저장되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
67
app/Models/System/AiPricingConfig.php
Normal file
67
app/Models/System/AiPricingConfig.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
|
||||
@@ -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 음성녹음 관리
|
||||
|
||||
Reference in New Issue
Block a user