Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-02-09 10:46:09 +09:00
8 changed files with 377 additions and 21 deletions

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Models\Tenants;
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

@@ -0,0 +1,37 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
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 creator(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class, 'created_by');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class AiVoiceRecording extends Model
{
use SoftDeletes;
protected $table = 'ai_voice_recordings';
protected $fillable = [
'tenant_id',
'user_id',
'title',
'interview_template_id',
'audio_file_path',
'audio_gcs_uri',
'transcript_text',
'analysis_text',
'status',
'duration_seconds',
'file_expiry_date',
];
protected $casts = [
'duration_seconds' => 'integer',
'file_expiry_date' => 'datetime',
];
public const STATUS_PENDING = 'PENDING';
public const STATUS_PROCESSING = 'PROCESSING';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_FAILED = 'FAILED';
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class);
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Services;
use App\Models\Tenants\AiPricingConfig;
use App\Models\Tenants\AiReport;
use App\Models\Tenants\AiTokenUsage;
use App\Models\Tenants\Card;
use App\Models\Tenants\Deposit;
use App\Models\Tenants\Purchase;
@@ -13,6 +15,7 @@
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class AiReportService extends Service
{
@@ -361,6 +364,9 @@ private function callGeminiApi(array $inputData): array
$result = $response->json();
$text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '';
// 토큰 사용량 저장
$this->saveTokenUsage($result, $model, 'AI리포트');
// JSON 파싱
$parsed = json_decode($text, true);
if (json_last_error() !== JSON_ERROR_NONE) {
@@ -378,6 +384,47 @@ private function callGeminiApi(array $inputData): array
}
}
/**
* 토큰 사용량 저장
*/
private function saveTokenUsage(array $apiResult, string $model, string $menuName): void
{
try {
$usage = $apiResult['usageMetadata'] ?? null;
if (! $usage) {
return;
}
$promptTokens = $usage['promptTokenCount'] ?? 0;
$completionTokens = $usage['candidatesTokenCount'] ?? 0;
$totalTokens = $usage['totalTokenCount'] ?? 0;
// DB 단가 조회 (fallback: 하드코딩 기본값)
$pricing = AiPricingConfig::getActivePricing('gemini');
$inputPricePerToken = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.10 / 1_000_000;
$outputPricePerToken = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 0.40 / 1_000_000;
$costUsd = ($promptTokens * $inputPricePerToken) + ($completionTokens * $outputPricePerToken);
$exchangeRate = AiPricingConfig::getExchangeRate();
$costKrw = $costUsd * $exchangeRate;
AiTokenUsage::create([
'tenant_id' => $this->tenantId(),
'model' => $model,
'menu_name' => $menuName,
'prompt_tokens' => $promptTokens,
'completion_tokens' => $completionTokens,
'total_tokens' => $totalTokens,
'cost_usd' => $costUsd,
'cost_krw' => $costKrw,
'request_id' => Str::uuid()->toString(),
'created_by' => $this->apiUserId(),
]);
} catch (\Exception $e) {
Log::warning('AI token usage save failed', ['error' => $e->getMessage()]);
}
}
/**
* AI 프롬프트 생성
*/