- MNG→API+React 이관 기획 (설정 > 토큰사용량) - GCS→R2 저장소 비용 추적 차이점 정리 - API 엔드포인트, 서비스, FormRequest 설계 - React 컴포넌트, Server Actions, 타입 정의
682 lines
24 KiB
Markdown
682 lines
24 KiB
Markdown
# 서비스 AI 토큰사용량 기능 기획
|
|
|
|
> **작성일**: 2026-03-18
|
|
> **상태**: 기획
|
|
> **대상**: sam/api + sam/react
|
|
> **원본**: sam/mng `system/ai-token-usage` 메뉴
|
|
|
|
---
|
|
|
|
## 1. 개요
|
|
|
|
### 1.1 목적
|
|
|
|
MNG(백오피스)의 AI 토큰사용량 조회 기능을 서비스(API+React)의 **설정 > 토큰사용량** 메뉴로 이관한다. 테넌트 사용자가 자기 테넌트의 AI 사용량과 비용을 직접 확인할 수 있도록 한다.
|
|
|
|
### 1.2 핵심 차이점 (MNG vs 서비스)
|
|
|
|
| 항목 | MNG (백오피스) | 서비스 (API+React) |
|
|
|------|---------------|-------------------|
|
|
| **사용자** | 시스템 관리자 | 테넌트 사용자 |
|
|
| **테넌트 필터** | 전체 테넌트 조회 가능 | 자기 테넌트만 조회 (자동 필터) |
|
|
| **저장소 비용** | GCS (Google Cloud Storage) | R2 (Cloudflare R2) |
|
|
| **단가 관리** | 모달에서 직접 편집 | 조회만 (단가 변경은 MNG에서) |
|
|
| **UI** | Blade + React (HTMX) | Next.js (SSR) |
|
|
| **인증** | 세션 기반 | Sanctum 토큰 기반 |
|
|
|
|
### 1.3 저장소 정책 차이
|
|
|
|
> MNG는 음성녹음/회의록에 **GCS(Google Cloud Storage)**를 사용하지만,
|
|
> 서비스(API)는 파일 저장에 **R2(Cloudflare R2)**를 사용한다.
|
|
|
|
| 항목 | MNG (GCS) | 서비스 (R2) |
|
|
|------|-----------|------------|
|
|
| **저장소** | Google Cloud Storage | Cloudflare R2 (S3 호환) |
|
|
| **과금 단위** | Class A $0.005/1000건 + $0.02/GB/월 | Class A $0.0045/M건 + $0.015/GB/월 |
|
|
| **비용 추적 메서드** | `saveGcsStorageUsage()` | `saveR2StorageUsage()` (신규) |
|
|
| **pricing provider** | `google-gcs` | `cloudflare-r2` (신규) |
|
|
| **STT** | Google Speech-to-Text | 서비스에서 STT 미사용 (해당 없음) |
|
|
|
|
---
|
|
|
|
## 2. sam/api 기획
|
|
|
|
### 2.1 API 엔드포인트
|
|
|
|
```
|
|
GET /api/v1/settings/ai-token-usage # 토큰 사용량 목록 + 통계
|
|
GET /api/v1/settings/ai-token-usage/pricing # 단가 설정 조회
|
|
```
|
|
|
|
> 단가 수정은 MNG 전용이므로 서비스 API에는 PUT 엔드포인트를 두지 않는다.
|
|
|
|
### 2.2 라우트 등록
|
|
|
|
```php
|
|
// routes/api/v1/common.php — Settings 그룹 내부에 추가
|
|
Route::prefix('settings')->group(function () {
|
|
// ... 기존 설정 라우트 ...
|
|
|
|
// 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');
|
|
});
|
|
```
|
|
|
|
### 2.3 컨트롤러
|
|
|
|
**파일**: `app/Http/Controllers/Api/V1/AiTokenUsageController.php`
|
|
|
|
```php
|
|
class AiTokenUsageController extends Controller
|
|
{
|
|
public function __construct(
|
|
private AiTokenUsageService $service
|
|
) {}
|
|
|
|
// GET /api/v1/settings/ai-token-usage
|
|
public function index(AiTokenUsageListRequest $request)
|
|
{
|
|
return ApiResponse::handle(fn () =>
|
|
$this->service->list($request->validated())
|
|
);
|
|
}
|
|
|
|
// GET /api/v1/settings/ai-token-usage/pricing
|
|
public function pricing()
|
|
{
|
|
return ApiResponse::handle(fn () =>
|
|
$this->service->getPricing()
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.4 서비스
|
|
|
|
**파일**: `app/Services/AiTokenUsageService.php`
|
|
|
|
```php
|
|
class AiTokenUsageService extends Service
|
|
{
|
|
/**
|
|
* 토큰 사용량 목록 + 통계
|
|
* - tenant_id는 Service 부모 클래스의 tenantId()로 자동 적용
|
|
* - 테넌트 필터 없음 (자기 테넌트만)
|
|
*/
|
|
public function list(array $params): array
|
|
{
|
|
// 1. 사용량 목록 (페이지네이션)
|
|
$query = AiTokenUsage::where('tenant_id', $this->tenantId())
|
|
->when($params['start_date'] ?? null, fn ($q, $d) => $q->where('created_at', '>=', $d))
|
|
->when($params['end_date'] ?? null, fn ($q, $d) => $q->where('created_at', '<=', $d . ' 23:59:59'))
|
|
->when($params['menu_name'] ?? null, fn ($q, $m) => $q->where('menu_name', $m))
|
|
->with('creator:id,name')
|
|
->orderByDesc('created_at');
|
|
|
|
$items = $query->paginate($params['per_page'] ?? 20);
|
|
|
|
// 2. 통계 (동일 필터 조건, 전체 집계)
|
|
$statsQuery = AiTokenUsage::where('tenant_id', $this->tenantId())
|
|
->when($params['start_date'] ?? null, fn ($q, $d) => $q->where('created_at', '>=', $d))
|
|
->when($params['end_date'] ?? null, fn ($q, $d) => $q->where('created_at', '<=', $d . ' 23:59:59'))
|
|
->when($params['menu_name'] ?? null, fn ($q, $m) => $q->where('menu_name', $m));
|
|
|
|
$stats = [
|
|
'total_count' => $statsQuery->count(),
|
|
'total_prompt_tokens' => (int) $statsQuery->sum('prompt_tokens'),
|
|
'total_completion_tokens' => (int) $statsQuery->sum('completion_tokens'),
|
|
'total_total_tokens' => (int) $statsQuery->sum('total_tokens'),
|
|
'total_cost_usd' => (float) $statsQuery->sum('cost_usd'),
|
|
'total_cost_krw' => (float) $statsQuery->sum('cost_krw'),
|
|
];
|
|
|
|
// 3. 메뉴명 필터 옵션 (해당 테넌트의 고유 menu_name 목록)
|
|
$menuNames = AiTokenUsage::where('tenant_id', $this->tenantId())
|
|
->select('menu_name')
|
|
->distinct()
|
|
->orderBy('menu_name')
|
|
->pluck('menu_name');
|
|
|
|
return [
|
|
'items' => $items,
|
|
'stats' => $stats,
|
|
'menu_names' => $menuNames,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 단가 설정 조회 (읽기 전용)
|
|
*/
|
|
public function getPricing(): array
|
|
{
|
|
$configs = AiPricingConfig::where('is_active', true)
|
|
->orderBy('provider')
|
|
->get();
|
|
|
|
return [
|
|
'pricing' => $configs,
|
|
'exchange_rate' => AiPricingConfig::getExchangeRate(),
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.5 FormRequest
|
|
|
|
**파일**: `app/Http/Requests/V1/AiTokenUsageListRequest.php`
|
|
|
|
```php
|
|
class AiTokenUsageListRequest extends FormRequest
|
|
{
|
|
public function rules(): array
|
|
{
|
|
return [
|
|
'start_date' => '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',
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.6 모델 (기존 활용)
|
|
|
|
> `ai_token_usages`, `ai_pricing_configs` 테이블은 이미 API에서 마이그레이션 관리 중이다.
|
|
> 모델도 `app/Models/Tenants/AiTokenUsage.php`, `AiPricingConfig.php`에 존재한다.
|
|
|
|
**추가 필요 없음** — 기존 모델을 그대로 사용한다.
|
|
|
|
### 2.7 AiTokenHelper 이관
|
|
|
|
> MNG의 `AiTokenHelper`를 API에도 동일하게 구현하되, GCS 대신 R2 저장소 비용 추적 메서드를 추가한다.
|
|
|
|
**파일**: `app/Helpers/AiTokenHelper.php`
|
|
|
|
기존 MNG와 동일한 메서드 + R2 전용 메서드:
|
|
|
|
| 메서드 | 출처 | 서비스 적용 |
|
|
|--------|------|-----------|
|
|
| `saveGeminiUsage()` | MNG 동일 | AI 리포트 등 Gemini 호출 시 |
|
|
| `saveClaudeUsage()` | MNG 동일 | Claude 호출 시 |
|
|
| `saveR2StorageUsage()` | 신규 | R2 파일 업로드 시 |
|
|
| `saveSttUsage()` | MNG 동일 | 서비스에서 STT 사용 시 (현재 미사용) |
|
|
|
|
**R2 저장소 비용 계산 공식**:
|
|
|
|
```php
|
|
public static function saveR2StorageUsage(string $menuName, int $fileSizeBytes): void
|
|
{
|
|
// Cloudflare R2 요금 (2026년 기준)
|
|
// Class A (PUT/POST): $0.0045 / 1,000,000건 → 건당 $0.0000000045
|
|
// Storage: $0.015 / GB / 월
|
|
$pricing = AiPricingConfig::getActivePricing('cloudflare-r2');
|
|
$unitPrice = $pricing ? (float) $pricing->unit_price : 0.0045;
|
|
|
|
$operationCost = $unitPrice / 1_000_000; // 1건당 오퍼레이션 비용
|
|
$fileSizeGB = $fileSizeBytes / (1024 * 1024 * 1024);
|
|
$storageCost = $fileSizeGB * 0.015 * (30 / 30); // 월간 보관 기준
|
|
|
|
$costUsd = $operationCost + $storageCost;
|
|
|
|
self::save('cloudflare-r2', $menuName, $fileSizeBytes, 0, $fileSizeBytes,
|
|
$costUsd / max($fileSizeBytes, 1), 0);
|
|
}
|
|
```
|
|
|
|
### 2.8 AiPricingConfig 시드 데이터 추가
|
|
|
|
> `cloudflare-r2` provider를 `ai_pricing_configs` 테이블에 추가한다.
|
|
|
|
```sql
|
|
INSERT INTO ai_pricing_configs
|
|
(provider, model_name, input_price_per_million, output_price_per_million,
|
|
unit_price, unit_description, exchange_rate, is_active, description)
|
|
VALUES
|
|
('cloudflare-r2', 'cloud-storage', 0, 0,
|
|
0.0045, 'per 1,000,000 Class A operations', 1400, 1,
|
|
'Cloudflare R2 Storage (S3 compatible)');
|
|
```
|
|
|
|
> tinker 또는 마이그레이션으로 추가. 시더 실행 금지 원칙에 따라 tinker 사용 권장.
|
|
|
|
### 2.9 API 응답 형식
|
|
|
|
#### `GET /api/v1/settings/ai-token-usage`
|
|
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"items": {
|
|
"data": [
|
|
{
|
|
"id": 1,
|
|
"model": "gemini-2.0-flash",
|
|
"menu_name": "AI리포트-일간",
|
|
"prompt_tokens": 2500,
|
|
"completion_tokens": 800,
|
|
"total_tokens": 3300,
|
|
"cost_usd": "0.001320",
|
|
"cost_krw": "1.85",
|
|
"request_id": "uuid-...",
|
|
"created_by": 5,
|
|
"creator": { "id": 5, "name": "홍길동" },
|
|
"created_at": "2026-03-18T10:30:00"
|
|
}
|
|
],
|
|
"current_page": 1,
|
|
"last_page": 3,
|
|
"per_page": 20,
|
|
"total": 45
|
|
},
|
|
"stats": {
|
|
"total_count": 45,
|
|
"total_prompt_tokens": 125000,
|
|
"total_completion_tokens": 38000,
|
|
"total_total_tokens": 163000,
|
|
"total_cost_usd": 0.065200,
|
|
"total_cost_krw": 91.28
|
|
},
|
|
"menu_names": [
|
|
"AI리포트-일간",
|
|
"AI리포트-주간",
|
|
"AI리포트-월간",
|
|
"명함OCR"
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
#### `GET /api/v1/settings/ai-token-usage/pricing`
|
|
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"pricing": [
|
|
{
|
|
"provider": "gemini",
|
|
"model_name": "gemini-2.0-flash",
|
|
"input_price_per_million": "0.1000",
|
|
"output_price_per_million": "0.4000",
|
|
"unit_price": null,
|
|
"exchange_rate": "1400.00",
|
|
"is_active": true
|
|
},
|
|
{
|
|
"provider": "claude",
|
|
"model_name": "claude-3-haiku",
|
|
"input_price_per_million": "0.2500",
|
|
"output_price_per_million": "1.2500",
|
|
"unit_price": null,
|
|
"exchange_rate": "1400.00",
|
|
"is_active": true
|
|
},
|
|
{
|
|
"provider": "cloudflare-r2",
|
|
"model_name": "cloud-storage",
|
|
"input_price_per_million": null,
|
|
"output_price_per_million": null,
|
|
"unit_price": "0.004500",
|
|
"unit_description": "per 1,000,000 Class A operations",
|
|
"exchange_rate": "1400.00",
|
|
"is_active": true
|
|
}
|
|
],
|
|
"exchange_rate": 1400.0
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.10 파일 목록 (sam/api)
|
|
|
|
| 상태 | 파일 | 설명 |
|
|
|------|------|------|
|
|
| 신규 | `app/Http/Controllers/Api/V1/AiTokenUsageController.php` | 컨트롤러 |
|
|
| 신규 | `app/Services/AiTokenUsageService.php` | 서비스 |
|
|
| 신규 | `app/Http/Requests/V1/AiTokenUsageListRequest.php` | FormRequest |
|
|
| 신규 | `app/Helpers/AiTokenHelper.php` | 토큰 사용량 기록 헬퍼 |
|
|
| 수정 | `routes/api/v1/common.php` | 라우트 추가 |
|
|
| 기존 | `app/Models/Tenants/AiTokenUsage.php` | 모델 (수정 불필요) |
|
|
| 기존 | `app/Models/Tenants/AiPricingConfig.php` | 모델 (수정 불필요) |
|
|
|
|
---
|
|
|
|
## 3. sam/react 기획
|
|
|
|
### 3.1 메뉴 위치
|
|
|
|
```
|
|
설정 (Settings)
|
|
├── 계좌관리
|
|
├── 권한관리
|
|
├── 직급관리
|
|
├── 직위관리
|
|
├── ...
|
|
└── 토큰사용량 ← 신규 추가
|
|
```
|
|
|
|
### 3.2 페이지 구조
|
|
|
|
**라우트**: `/settings/ai-token-usage`
|
|
|
|
```
|
|
/settings/ai-token-usage/page.tsx → AiTokenUsagePage 컴포넌트 호출
|
|
/components/settings/AiTokenUsage/
|
|
├── index.tsx → 메인 컴포넌트
|
|
├── actions.ts → Server Actions (API 호출)
|
|
├── types.ts → TypeScript 타입 정의
|
|
├── AiTokenUsageConfig.ts → 상수, 카테고리 매핑
|
|
└── PricingModal.tsx → 단가 조회 모달
|
|
```
|
|
|
|
### 3.3 화면 구성
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ AI 토큰 사용량 [단가 확인] │
|
|
├─────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
|
│ │ 호출수│ │ 입력 │ │ 출력 │ │ 비용 │ │ 비용 │ │
|
|
│ │ 45 │ │ 125K │ │ 38K │ │$0.065│ │ 91원 │ │
|
|
│ │ │ │토큰 │ │토큰 │ │(USD) │ │(KRW) │ │
|
|
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
|
|
│ │
|
|
│ 시작일 [____] 종료일 [____] 메뉴 [▼____] [조회][초기화]│
|
|
│ │
|
|
│ ┌─────┬──────┬──────┬──────┬──────┬──────┬──────┐ │
|
|
│ │ No. │ 일시 │카테고리│ 메뉴 │ 모델 │ 토큰 │ 비용 │ │
|
|
│ ├─────┼──────┼──────┼──────┼──────┼──────┼──────┤ │
|
|
│ │ 1 │03-18│AI리포트│일간 │gemini│3,300 │$0.001│ │
|
|
│ │ 2 │03-17│명함OCR│인식 │gemini│1,200 │$0.000│ │
|
|
│ │ ... │ ... │ ... │ ... │ ... │ ... │ ... │ │
|
|
│ └─────┴──────┴──────┴──────┴──────┴──────┴──────┘ │
|
|
│ │
|
|
│ ◀ 1 2 3 ▶ 20건 / 총 45건 │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 3.4 컴포넌트 설계
|
|
|
|
#### 3.4.1 메인 컴포넌트 (`index.tsx`)
|
|
|
|
`UniversalListPage` 패턴을 따르되, **통계 카드 + 필터 + 테이블**을 포함한다.
|
|
|
|
```typescript
|
|
// 통계 카드 5개
|
|
computeStats: (data, totalCount, stats) => [
|
|
{ label: '총 호출 수', value: stats.total_count, icon: Activity },
|
|
{ label: '입력 토큰', value: formatNumber(stats.total_prompt_tokens), icon: ArrowUpRight },
|
|
{ label: '출력 토큰', value: formatNumber(stats.total_completion_tokens), icon: ArrowDownRight },
|
|
{ label: '총 비용 (USD)', value: formatUsd(stats.total_cost_usd), icon: DollarSign },
|
|
{ label: '총 비용 (KRW)', value: formatKrw(stats.total_cost_krw), icon: Coins },
|
|
]
|
|
```
|
|
|
|
#### 3.4.2 테이블 컬럼
|
|
|
|
```typescript
|
|
columns: [
|
|
{ key: 'no', label: 'No.', className: 'text-center w-[50px]' },
|
|
{ key: 'created_at', label: '사용일시', className: 'min-w-[140px]' },
|
|
{ key: 'category', label: '카테고리', className: 'min-w-[100px]' },
|
|
{ key: 'menu_name', label: '호출메뉴', className: 'min-w-[120px]' },
|
|
{ key: 'model', label: '모델', className: 'min-w-[140px]' },
|
|
{ key: 'prompt_tokens', label: '입력토큰', className: 'text-right min-w-[90px]' },
|
|
{ key: 'completion_tokens', label: '출력토큰', className: 'text-right min-w-[90px]' },
|
|
{ key: 'total_tokens', label: '전체토큰', className: 'text-right min-w-[90px]' },
|
|
{ key: 'cost_usd', label: '비용(USD)', className: 'text-right min-w-[90px]' },
|
|
{ key: 'cost_krw', label: '비용(KRW)', className: 'text-right min-w-[90px]' },
|
|
{ key: 'creator', label: '사용자', className: 'min-w-[80px]' },
|
|
]
|
|
```
|
|
|
|
#### 3.4.3 필터 (서버 사이드)
|
|
|
|
```typescript
|
|
// 서버 사이드 필터링 — API에 쿼리 파라미터로 전달
|
|
interface AiTokenUsageFilters {
|
|
start_date?: string; // YYYY-MM-DD
|
|
end_date?: string; // YYYY-MM-DD
|
|
menu_name?: string; // 드롭다운 선택
|
|
per_page?: number; // 기본 20
|
|
page?: number;
|
|
}
|
|
```
|
|
|
|
> MNG와 달리 **테넌트 필터 없음** — API가 `tenantId()`로 자동 필터링한다.
|
|
|
|
#### 3.4.4 카테고리 매핑 (`AiTokenUsageConfig.ts`)
|
|
|
|
```typescript
|
|
// MNG와 동일한 카테고리 분류 로직
|
|
export function getCategory(menuName: string): string {
|
|
if (!menuName) return '-';
|
|
if (menuName.startsWith('AI리포트')) return 'AI리포트';
|
|
if (menuName.includes('명함')) return '명함OCR';
|
|
if (menuName.includes('사업자등록증')) return 'OCR';
|
|
if (menuName.startsWith('파일업로드')) return '파일저장소';
|
|
return menuName;
|
|
}
|
|
|
|
// 포맷팅 유틸
|
|
export const formatNumber = (n: number) => n.toLocaleString('ko-KR');
|
|
export const formatUsd = (n: number) => `$${n.toFixed(4)}`;
|
|
export const formatKrw = (n: number) => `${Math.round(n).toLocaleString('ko-KR')}원`;
|
|
```
|
|
|
|
#### 3.4.5 Server Actions (`actions.ts`)
|
|
|
|
```typescript
|
|
'use server';
|
|
|
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
|
import { buildApiUrl } from '@/lib/api/build-api-url';
|
|
|
|
export async function getAiTokenUsageList(params: AiTokenUsageFilters) {
|
|
return executeServerAction({
|
|
url: buildApiUrl('/api/v1/settings/ai-token-usage', params),
|
|
errorMessage: 'AI 토큰 사용량 조회에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
export async function getAiTokenUsagePricing() {
|
|
return executeServerAction({
|
|
url: buildApiUrl('/api/v1/settings/ai-token-usage/pricing'),
|
|
errorMessage: 'AI 단가 조회에 실패했습니다.',
|
|
});
|
|
}
|
|
```
|
|
|
|
#### 3.4.6 단가 조회 모달 (`PricingModal.tsx`)
|
|
|
|
MNG의 단가 설정 모달과 동일한 UI이지만 **읽기 전용**:
|
|
|
|
```
|
|
┌─────────────────────────────────────────────┐
|
|
│ AI 단가 설정 (읽기 전용) [닫기] │
|
|
├─────────────────────────────────────────────┤
|
|
│ │
|
|
│ ■ AI 토큰 단가 │
|
|
│ ┌───────┬──────┬──────────┬──────────┐ │
|
|
│ │Provider│ 모델 │입력($/1M)│출력($/1M)│ │
|
|
│ ├───────┼──────┼──────────┼──────────┤ │
|
|
│ │Gemini │flash │ $0.10 │ $0.40 │ │
|
|
│ │Claude │haiku │ $0.25 │ $1.25 │ │
|
|
│ └───────┴──────┴──────────┴──────────┘ │
|
|
│ │
|
|
│ ■ 저장소/서비스 단가 │
|
|
│ ┌───────┬─────────┬────────┐ │
|
|
│ │Provider│ 단위가격 │ 단위 │ │
|
|
│ ├───────┼─────────┼────────┤ │
|
|
│ │R2 │ $0.0045 │1M 작업 │ │
|
|
│ └───────┴─────────┴────────┘ │
|
|
│ │
|
|
│ 환율: 1 USD = 1,400 KRW │
|
|
│ │
|
|
│ ※ 단가 변경은 관리자에게 문의하세요 │
|
|
└─────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 3.5 타입 정의 (`types.ts`)
|
|
|
|
```typescript
|
|
export interface AiTokenUsageItem {
|
|
id: number;
|
|
model: string;
|
|
menu_name: string;
|
|
prompt_tokens: number;
|
|
completion_tokens: number;
|
|
total_tokens: number;
|
|
cost_usd: string;
|
|
cost_krw: string;
|
|
request_id: string;
|
|
created_by: number;
|
|
creator?: { id: number; name: string };
|
|
created_at: string;
|
|
}
|
|
|
|
export interface AiTokenUsageStats {
|
|
total_count: number;
|
|
total_prompt_tokens: number;
|
|
total_completion_tokens: number;
|
|
total_total_tokens: number;
|
|
total_cost_usd: number;
|
|
total_cost_krw: number;
|
|
}
|
|
|
|
export interface AiPricingItem {
|
|
provider: string;
|
|
model_name: string;
|
|
input_price_per_million: string | null;
|
|
output_price_per_million: string | null;
|
|
unit_price: string | null;
|
|
unit_description: string | null;
|
|
exchange_rate: string;
|
|
is_active: boolean;
|
|
}
|
|
```
|
|
|
|
### 3.6 파일 목록 (sam/react)
|
|
|
|
| 상태 | 파일 | 설명 |
|
|
|------|------|------|
|
|
| 신규 | `src/app/[locale]/(protected)/settings/ai-token-usage/page.tsx` | 페이지 |
|
|
| 신규 | `src/components/settings/AiTokenUsage/index.tsx` | 메인 컴포넌트 |
|
|
| 신규 | `src/components/settings/AiTokenUsage/actions.ts` | Server Actions |
|
|
| 신규 | `src/components/settings/AiTokenUsage/types.ts` | 타입 정의 |
|
|
| 신규 | `src/components/settings/AiTokenUsage/AiTokenUsageConfig.ts` | 상수/유틸 |
|
|
| 신규 | `src/components/settings/AiTokenUsage/PricingModal.tsx` | 단가 조회 모달 |
|
|
|
|
---
|
|
|
|
## 4. DB 변경
|
|
|
|
### 4.1 테이블 변경 없음
|
|
|
|
`ai_token_usages`, `ai_pricing_configs` 테이블은 이미 존재하며 변경 불필요.
|
|
|
|
### 4.2 데이터 추가
|
|
|
|
`ai_pricing_configs` 테이블에 `cloudflare-r2` provider 레코드 1건 추가:
|
|
|
|
```bash
|
|
docker exec sam-api-1 php artisan tinker --execute="
|
|
\App\Models\Tenants\AiPricingConfig::create([
|
|
'provider' => 'cloudflare-r2',
|
|
'model_name' => 'cloud-storage',
|
|
'input_price_per_million' => 0,
|
|
'output_price_per_million' => 0,
|
|
'unit_price' => 0.0045,
|
|
'unit_description' => 'per 1,000,000 Class A operations',
|
|
'exchange_rate' => 1400,
|
|
'is_active' => true,
|
|
'description' => 'Cloudflare R2 Storage (S3 compatible)',
|
|
]);
|
|
"
|
|
```
|
|
|
|
### 4.3 AiPricingConfig 캐시 키 확장
|
|
|
|
`AiPricingConfig::clearCache()`에 `cloudflare-r2` provider 추가:
|
|
|
|
```php
|
|
// 기존: ['gemini', 'claude', 'google-stt', 'google-gcs']
|
|
// 변경: ['gemini', 'claude', 'google-stt', 'google-gcs', 'cloudflare-r2']
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 메뉴 등록
|
|
|
|
### 5.1 React 메뉴 (서비스)
|
|
|
|
서비스 메뉴 DB에 등록 (tinker 사용):
|
|
|
|
```bash
|
|
# 설정 부모 메뉴 ID 조회 후 하위에 추가
|
|
# (React 메뉴 체계는 별도 확인 필요)
|
|
```
|
|
|
|
---
|
|
|
|
## 6. MNG vs 서비스 기능 비교
|
|
|
|
| 기능 | MNG | 서비스 | 비고 |
|
|
|------|:---:|:------:|------|
|
|
| 사용량 목록 조회 | ✅ | ✅ | |
|
|
| 통계 카드 (6종) | ✅ | ✅ (5종) | 서비스: 전체토큰 카드 제외 (간결화) |
|
|
| 기간 필터 | ✅ | ✅ | |
|
|
| 테넌트 필터 | ✅ | ❌ | 서비스: 자기 테넌트 자동 필터 |
|
|
| 메뉴명 필터 | ✅ | ✅ | |
|
|
| 카테고리 분류 | ✅ | ✅ | 동일 로직 |
|
|
| 녹음시간 표시 | ✅ | ❌ | 서비스: STT 미사용 |
|
|
| 단가 설정 편집 | ✅ | ❌ | 서비스: 조회만 (MNG에서 관리) |
|
|
| 단가 조회 | ✅ | ✅ | |
|
|
| GCS 비용 추적 | ✅ | ❌ | 서비스: R2 비용 추적 |
|
|
| R2 비용 추적 | ❌ | ✅ | 신규 |
|
|
| 페이지네이션 | ✅ | ✅ | |
|
|
| 사용자명 표시 | ❌ | ✅ | creator 관계 로드 |
|
|
|
|
---
|
|
|
|
## 7. 구현 순서
|
|
|
|
### Phase 1: API (sam/api)
|
|
|
|
1. `AiTokenHelper` 생성 (MNG 코드 기반 + `saveR2StorageUsage` 추가)
|
|
2. `AiTokenUsageService` 생성
|
|
3. `AiTokenUsageListRequest` 생성
|
|
4. `AiTokenUsageController` 생성
|
|
5. `common.php` 라우트 등록
|
|
6. `ai_pricing_configs`에 `cloudflare-r2` 데이터 추가 (tinker)
|
|
7. Swagger 문서 작성
|
|
|
|
### Phase 2: React (sam/react)
|
|
|
|
1. 타입 정의 (`types.ts`)
|
|
2. 설정/유틸 (`AiTokenUsageConfig.ts`)
|
|
3. Server Actions (`actions.ts`)
|
|
4. 단가 조회 모달 (`PricingModal.tsx`)
|
|
5. 메인 컴포넌트 (`index.tsx`)
|
|
6. 페이지 파일 (`page.tsx`)
|
|
7. 메뉴 등록 (DB tinker)
|
|
|
|
---
|
|
|
|
## 관련 문서
|
|
|
|
- [features/ai/README.md](../features/ai/README.md) — AI 분석 리포트 기능 문서
|
|
- [dev/guides/file-storage-guide.md](../dev/guides/file-storage-guide.md) — 파일 저장소 정책
|
|
- [guides/ai-management.md](../guides/ai-management.md) — AI 관리 가이드
|
|
|
|
---
|
|
|
|
**최종 업데이트**: 2026-03-18
|