feat:AI 음성녹음 기능 추가
- AiVoiceRecording 모델 (상태 상수, 접근자) - AiVoiceRecordingService (GCS 업로드, STT, Gemini 분석 파이프라인) - AiVoiceRecordingController (CRUD, 녹음 처리, 상태 폴링) - React 블레이드 뷰 (녹음 UI, 파일 업로드, 목록, 상세 모달) - 라우트 추가 (system/ai-voice-recording) - 메뉴 시더에 AI 음성녹음 항목 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
204
app/Http/Controllers/System/AiVoiceRecordingController.php
Normal file
204
app/Http/Controllers/System/AiVoiceRecordingController.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\System;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiVoiceRecording;
|
||||
use App\Services\AiVoiceRecordingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AiVoiceRecordingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiVoiceRecordingService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* React 뷰 반환
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('system.ai-voice-recording.index'));
|
||||
}
|
||||
|
||||
return view('system.ai-voice-recording.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 목록
|
||||
*/
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->only(['search', 'status', 'per_page']);
|
||||
$recordings = $this->service->getList($params);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $recordings,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 녹음 생성
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
$recording = $this->service->create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '녹음이 생성되었습니다.',
|
||||
'data' => $recording,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 오디오 업로드 + 처리
|
||||
*/
|
||||
public function processAudio(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$recording = AiVoiceRecording::find($id);
|
||||
|
||||
if (! $recording) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '녹음을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'audio' => 'required|string',
|
||||
'duration' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$result = $this->service->processAudio(
|
||||
$recording,
|
||||
$validated['audio'],
|
||||
$validated['duration']
|
||||
);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $result['error'] ?? '처리 중 오류가 발생했습니다.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '음성 분석이 완료되었습니다.',
|
||||
'data' => $result['recording'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 + 처리
|
||||
*/
|
||||
public function uploadFile(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'audio_file' => 'required|file|mimes:webm,wav,mp3,ogg,m4a,mp4|max:102400',
|
||||
'title' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
$recording = $this->service->create([
|
||||
'title' => $validated['title'] ?? '업로드된 음성녹음',
|
||||
]);
|
||||
|
||||
$result = $this->service->processUploadedFile(
|
||||
$recording,
|
||||
$request->file('audio_file')
|
||||
);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $result['error'] ?? '처리 중 오류가 발생했습니다.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '음성 분석이 완료되었습니다.',
|
||||
'data' => $result['recording'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$recording = $this->service->getById($id);
|
||||
|
||||
if (! $recording) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '녹음을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $recording,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$recording = AiVoiceRecording::find($id);
|
||||
|
||||
if (! $recording) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '녹음을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->service->delete($recording);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '녹음이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 상태 폴링용
|
||||
*/
|
||||
public function status(int $id): JsonResponse
|
||||
{
|
||||
$recording = AiVoiceRecording::find($id);
|
||||
|
||||
if (! $recording) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '녹음을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'id' => $recording->id,
|
||||
'status' => $recording->status,
|
||||
'status_label' => $recording->status_label,
|
||||
'is_completed' => $recording->isCompleted(),
|
||||
'is_processing' => $recording->isProcessing(),
|
||||
'transcript_text' => $recording->transcript_text,
|
||||
'analysis_text' => $recording->analysis_text,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
94
app/Models/AiVoiceRecording.php
Normal file
94
app/Models/AiVoiceRecording.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AiVoiceRecording extends Model
|
||||
{
|
||||
use BelongsToTenant, 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',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'deleted_at' => '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(User::class);
|
||||
}
|
||||
|
||||
public function getFormattedDurationAttribute(): string
|
||||
{
|
||||
if (! $this->duration_seconds) {
|
||||
return '00:00';
|
||||
}
|
||||
|
||||
$minutes = floor($this->duration_seconds / 60);
|
||||
$seconds = $this->duration_seconds % 60;
|
||||
|
||||
return sprintf('%02d:%02d', $minutes, $seconds);
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_PENDING => '대기중',
|
||||
self::STATUS_PROCESSING => '처리중',
|
||||
self::STATUS_COMPLETED => '완료',
|
||||
self::STATUS_FAILED => '실패',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_PENDING => 'badge-warning',
|
||||
self::STATUS_PROCESSING => 'badge-info',
|
||||
self::STATUS_COMPLETED => 'badge-success',
|
||||
self::STATUS_FAILED => 'badge-error',
|
||||
default => 'badge-ghost',
|
||||
};
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public function isProcessing(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PROCESSING;
|
||||
}
|
||||
}
|
||||
475
app/Services/AiVoiceRecordingService.php
Normal file
475
app/Services/AiVoiceRecordingService.php
Normal file
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Helpers\AiTokenHelper;
|
||||
use App\Models\AiVoiceRecording;
|
||||
use App\Models\System\AiConfig;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AiVoiceRecordingService
|
||||
{
|
||||
public function __construct(
|
||||
private GoogleCloudService $googleCloudService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 목록 조회
|
||||
*/
|
||||
public function getList(array $params = []): LengthAwarePaginator
|
||||
{
|
||||
$query = AiVoiceRecording::query()
|
||||
->with('user:id,name')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (! empty($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
if (! empty($params['search'])) {
|
||||
$search = $params['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('transcript_text', 'like', "%{$search}%")
|
||||
->orWhere('analysis_text', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = $params['per_page'] ?? 10;
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 조회
|
||||
*/
|
||||
public function getById(int $id): ?AiVoiceRecording
|
||||
{
|
||||
return AiVoiceRecording::with('user:id,name')->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 녹음 레코드 생성
|
||||
*/
|
||||
public function create(array $data): AiVoiceRecording
|
||||
{
|
||||
return AiVoiceRecording::create([
|
||||
'tenant_id' => session('selected_tenant_id'),
|
||||
'user_id' => Auth::id(),
|
||||
'title' => $data['title'] ?? '무제 음성녹음',
|
||||
'status' => AiVoiceRecording::STATUS_PENDING,
|
||||
'file_expiry_date' => now()->addDays(7),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 오디오 업로드 + 처리 파이프라인
|
||||
*/
|
||||
public function processAudio(AiVoiceRecording $recording, string $audioBase64, int $durationSeconds): array
|
||||
{
|
||||
try {
|
||||
$recording->update([
|
||||
'status' => AiVoiceRecording::STATUS_PROCESSING,
|
||||
'duration_seconds' => $durationSeconds,
|
||||
]);
|
||||
|
||||
// 1. GCS에 오디오 업로드
|
||||
$objectName = sprintf(
|
||||
'voice-recordings/%d/%d/%s.webm',
|
||||
$recording->tenant_id,
|
||||
$recording->id,
|
||||
now()->format('YmdHis')
|
||||
);
|
||||
|
||||
$gcsUri = $this->googleCloudService->uploadBase64Audio($audioBase64, $objectName);
|
||||
|
||||
if (! $gcsUri) {
|
||||
throw new \Exception('오디오 파일 업로드 실패');
|
||||
}
|
||||
|
||||
$recording->update([
|
||||
'audio_file_path' => $objectName,
|
||||
'audio_gcs_uri' => $gcsUri,
|
||||
]);
|
||||
|
||||
// 2. Speech-to-Text 변환
|
||||
$transcript = $this->googleCloudService->speechToText($gcsUri);
|
||||
|
||||
if (! $transcript) {
|
||||
throw new \Exception('음성 인식 실패');
|
||||
}
|
||||
|
||||
$recording->update(['transcript_text' => $transcript]);
|
||||
|
||||
// 3. Gemini AI 분석
|
||||
$analysis = $this->analyzeWithGemini($transcript);
|
||||
|
||||
$recording->update([
|
||||
'analysis_text' => $analysis,
|
||||
'status' => AiVoiceRecording::STATUS_COMPLETED,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'recording' => $recording->fresh(),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('AiVoiceRecording 처리 실패', [
|
||||
'recording_id' => $recording->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$recording->update(['status' => AiVoiceRecording::STATUS_FAILED]);
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 처리
|
||||
*/
|
||||
public function processUploadedFile(AiVoiceRecording $recording, UploadedFile $file): array
|
||||
{
|
||||
try {
|
||||
$recording->update(['status' => AiVoiceRecording::STATUS_PROCESSING]);
|
||||
|
||||
// 임시 저장
|
||||
$tempPath = $file->store('temp', 'local');
|
||||
$fullPath = storage_path('app/' . $tempPath);
|
||||
|
||||
// 파일 크기로 대략적인 재생 시간 추정 (12KB/초 기준)
|
||||
$fileSize = $file->getSize();
|
||||
$estimatedDuration = max(1, intval($fileSize / 12000));
|
||||
$recording->update(['duration_seconds' => $estimatedDuration]);
|
||||
|
||||
// 1. GCS에 오디오 업로드
|
||||
$extension = $file->getClientOriginalExtension() ?: 'webm';
|
||||
$objectName = sprintf(
|
||||
'voice-recordings/%d/%d/%s.%s',
|
||||
$recording->tenant_id,
|
||||
$recording->id,
|
||||
now()->format('YmdHis'),
|
||||
$extension
|
||||
);
|
||||
|
||||
$gcsUri = $this->googleCloudService->uploadToStorage($fullPath, $objectName);
|
||||
|
||||
if (! $gcsUri) {
|
||||
@unlink($fullPath);
|
||||
throw new \Exception('오디오 파일 업로드 실패');
|
||||
}
|
||||
|
||||
$recording->update([
|
||||
'audio_file_path' => $objectName,
|
||||
'audio_gcs_uri' => $gcsUri,
|
||||
]);
|
||||
|
||||
// 2. Speech-to-Text 변환
|
||||
$transcript = $this->googleCloudService->speechToText($gcsUri);
|
||||
|
||||
// 임시 파일 삭제
|
||||
@unlink($fullPath);
|
||||
|
||||
if (! $transcript) {
|
||||
throw new \Exception('음성 인식 실패');
|
||||
}
|
||||
|
||||
$recording->update(['transcript_text' => $transcript]);
|
||||
|
||||
// 3. Gemini AI 분석
|
||||
$analysis = $this->analyzeWithGemini($transcript);
|
||||
|
||||
$recording->update([
|
||||
'analysis_text' => $analysis,
|
||||
'status' => AiVoiceRecording::STATUS_COMPLETED,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'recording' => $recording->fresh(),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('AiVoiceRecording 파일 처리 실패', [
|
||||
'recording_id' => $recording->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$recording->update(['status' => AiVoiceRecording::STATUS_FAILED]);
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API로 영업 시나리오 분석
|
||||
*/
|
||||
private function analyzeWithGemini(string $transcript): ?string
|
||||
{
|
||||
$config = AiConfig::getActiveGemini();
|
||||
|
||||
if (! $config) {
|
||||
Log::warning('Gemini API 설정이 없습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = $this->buildAnalysisPrompt($transcript);
|
||||
|
||||
if ($config->isVertexAi()) {
|
||||
return $this->callVertexAiApi($config, $prompt);
|
||||
}
|
||||
|
||||
return $this->callGoogleAiStudioApi($config, $prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google AI Studio API 호출
|
||||
*/
|
||||
private function callGoogleAiStudioApi(AiConfig $config, string $prompt): ?string
|
||||
{
|
||||
$model = $config->model;
|
||||
$apiKey = $config->api_key;
|
||||
$baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta';
|
||||
|
||||
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
|
||||
|
||||
return $this->callGeminiApi($url, $prompt, [
|
||||
'Content-Type' => 'application/json',
|
||||
], false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertex AI API 호출
|
||||
*/
|
||||
private function callVertexAiApi(AiConfig $config, string $prompt): ?string
|
||||
{
|
||||
$model = $config->model;
|
||||
$projectId = $config->getProjectId();
|
||||
$region = $config->getRegion();
|
||||
|
||||
if (! $projectId) {
|
||||
Log::error('Vertex AI 프로젝트 ID가 설정되지 않았습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessToken = $this->getAccessToken($config);
|
||||
if (! $accessToken) {
|
||||
Log::error('Google Cloud 인증 실패');
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent";
|
||||
|
||||
return $this->callGeminiApi($url, $prompt, [
|
||||
'Authorization' => 'Bearer ' . $accessToken,
|
||||
'Content-Type' => 'application/json',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 공통 호출 로직
|
||||
*/
|
||||
private function callGeminiApi(string $url, string $prompt, array $headers, bool $isVertexAi = false): ?string
|
||||
{
|
||||
$content = [
|
||||
'parts' => [
|
||||
['text' => $prompt],
|
||||
],
|
||||
];
|
||||
|
||||
if ($isVertexAi) {
|
||||
$content['role'] = 'user';
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(120)
|
||||
->withHeaders($headers)
|
||||
->post($url, [
|
||||
'contents' => [$content],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.3,
|
||||
'topK' => 40,
|
||||
'topP' => 0.95,
|
||||
'maxOutputTokens' => 4096,
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Gemini API error', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $response->json();
|
||||
|
||||
// 토큰 사용량 저장
|
||||
AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? 'gemini', 'AI음성녹음분석');
|
||||
|
||||
return $result['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Gemini API 예외', ['error' => $e->getMessage()]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 계정으로 OAuth2 액세스 토큰 가져오기
|
||||
*/
|
||||
private function getAccessToken(AiConfig $config): ?string
|
||||
{
|
||||
$configuredPath = $config->getServiceAccountPath();
|
||||
|
||||
$possiblePaths = array_filter([
|
||||
$configuredPath,
|
||||
'/var/www/sales/apikey/google_service_account.json',
|
||||
storage_path('app/google_service_account.json'),
|
||||
]);
|
||||
|
||||
$serviceAccountPath = null;
|
||||
foreach ($possiblePaths as $path) {
|
||||
if ($path && file_exists($path)) {
|
||||
$serviceAccountPath = $path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $serviceAccountPath) {
|
||||
Log::error('Service account file not found', ['tried_paths' => $possiblePaths]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
if (! $serviceAccount) {
|
||||
Log::error('Service account JSON parse failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$jwtClaim = $this->base64UrlEncode(json_encode([
|
||||
'iss' => $serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (! $privateKey) {
|
||||
Log::error('Failed to load private key');
|
||||
return null;
|
||||
}
|
||||
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
|
||||
|
||||
try {
|
||||
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json()['access_token'] ?? null;
|
||||
}
|
||||
|
||||
Log::error('OAuth token request failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('OAuth token request exception', ['error' => $e->getMessage()]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL 인코딩
|
||||
*/
|
||||
private function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업 시나리오 분석 프롬프트
|
||||
*/
|
||||
private function buildAnalysisPrompt(string $transcript): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
다음은 회의 또는 영업 통화 녹취록을 텍스트로 변환한 내용입니다. 이 내용을 분석하여 구조화된 피드백을 제공해주세요.
|
||||
|
||||
## 분석 요청사항:
|
||||
|
||||
### 1. 전체 요약
|
||||
- 녹취록의 핵심 내용을 3-5문장으로 요약해주세요.
|
||||
|
||||
### 2. 영업 강점 분석
|
||||
- 잘한 점, 효과적인 커뮤니케이션, 성공적인 전략 등을 구체적으로 분석해주세요.
|
||||
|
||||
### 3. 개선이 필요한 부분
|
||||
- 놓친 기회, 개선할 수 있는 표현, 더 효과적인 접근 방법 등을 제안해주세요.
|
||||
|
||||
### 4. 고객 반응/니즈 파악
|
||||
- 고객이 표현한 관심사, 우려사항, 요구사항을 정리해주세요.
|
||||
- 숨겨진 니즈나 잠재적 기회를 파악해주세요.
|
||||
|
||||
### 5. 다음 단계 제안
|
||||
- 후속 조치 사항을 구체적으로 제안해주세요.
|
||||
- 우선순위와 시기를 포함해주세요.
|
||||
|
||||
## 녹취록:
|
||||
{$transcript}
|
||||
|
||||
## 분석 결과:
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제
|
||||
*/
|
||||
public function delete(AiVoiceRecording $recording): bool
|
||||
{
|
||||
if ($recording->audio_file_path) {
|
||||
$this->googleCloudService->deleteFromStorage($recording->audio_file_path);
|
||||
}
|
||||
|
||||
return $recording->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료된 파일 정리 (Cron용)
|
||||
*/
|
||||
public function cleanupExpiredFiles(): int
|
||||
{
|
||||
$expired = AiVoiceRecording::where('file_expiry_date', '<=', now())
|
||||
->whereNotNull('audio_file_path')
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($expired as $recording) {
|
||||
if ($recording->audio_file_path) {
|
||||
$this->googleCloudService->deleteFromStorage($recording->audio_file_path);
|
||||
$recording->update([
|
||||
'audio_file_path' => null,
|
||||
'audio_gcs_uri' => null,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,34 @@ public function run(): void
|
||||
$this->command->info("AI 토큰 사용량 메뉴가 이미 AI 관리 그룹에 있습니다.");
|
||||
}
|
||||
|
||||
// 4. AI 음성녹음 메뉴 생성 또는 이동
|
||||
$aiVoice = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('name', 'AI 음성녹음')
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($aiVoice && $aiVoice->parent_id !== $aiGroup->id) {
|
||||
$aiVoice->update([
|
||||
'parent_id' => $aiGroup->id,
|
||||
'sort_order' => 3,
|
||||
]);
|
||||
$this->command->info("AI 음성녹음 메뉴를 AI 관리 그룹으로 이동 완료");
|
||||
} elseif (! $aiVoice) {
|
||||
Menu::withoutGlobalScopes()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $aiGroup->id,
|
||||
'name' => 'AI 음성녹음',
|
||||
'url' => '/system/ai-voice-recording',
|
||||
'icon' => 'mic',
|
||||
'sort_order' => 3,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$this->command->info("AI 음성녹음 메뉴 생성 완료");
|
||||
} else {
|
||||
$this->command->info("AI 음성녹음 메뉴가 이미 AI 관리 그룹에 있습니다.");
|
||||
}
|
||||
|
||||
// 결과 출력
|
||||
$this->command->info('');
|
||||
$this->command->info('=== AI 관리 하위 메뉴 ===');
|
||||
|
||||
798
resources/views/system/ai-voice-recording/index.blade.php
Normal file
798
resources/views/system/ai-voice-recording/index.blade.php
Normal file
@@ -0,0 +1,798 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'AI 음성녹음')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.recording-pulse { animation: pulse 1.5s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.waveform-bar { transition: height 0.1s ease; }
|
||||
.fade-in { animation: fadeIn 0.2s ease-in; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
.modal-overlay { background: rgba(0,0,0,0.5); }
|
||||
.drop-zone { border: 2px dashed #d1d5db; transition: all 0.2s; }
|
||||
.drop-zone.drag-over { border-color: #3b82f6; background: #eff6ff; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div id="ai-voice-recording-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, useCallback } = React;
|
||||
|
||||
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
const BASE_URL = '/system/ai-voice-recording';
|
||||
|
||||
// ============================================================
|
||||
// API
|
||||
// ============================================================
|
||||
const api = {
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': CSRF_TOKEN,
|
||||
'Accept': 'application/json',
|
||||
}),
|
||||
async get(url) {
|
||||
const res = await fetch(url, { headers: this.headers() });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
async post(url, data) {
|
||||
const res = await fetch(url, { method: 'POST', headers: this.headers(), body: JSON.stringify(data) });
|
||||
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
},
|
||||
async del(url) {
|
||||
const res = await fetch(url, { method: 'DELETE', headers: this.headers() });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
async upload(url, formData) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': CSRF_TOKEN, 'Accept': 'application/json' },
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Lucide
|
||||
// ============================================================
|
||||
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 IconMic = createIcon('mic');
|
||||
const IconMicOff = createIcon('mic-off');
|
||||
const IconSquare = createIcon('square');
|
||||
const IconPlay = createIcon('play');
|
||||
const IconPause = createIcon('pause');
|
||||
const IconUpload = createIcon('upload');
|
||||
const IconTrash = createIcon('trash-2');
|
||||
const IconEye = createIcon('eye');
|
||||
const IconX = createIcon('x');
|
||||
const IconLoader = createIcon('loader');
|
||||
const IconCopy = createIcon('copy');
|
||||
const IconCheck = createIcon('check');
|
||||
const IconFile = createIcon('file-audio');
|
||||
const IconRefresh = createIcon('refresh-cw');
|
||||
|
||||
// ============================================================
|
||||
// Toast
|
||||
// ============================================================
|
||||
function Toast({ message, type = 'success', onClose }) {
|
||||
useEffect(() => { const t = setTimeout(onClose, 3000); return () => clearTimeout(t); }, []);
|
||||
const bg = type === 'error' ? 'bg-red-500' : type === 'warning' ? 'bg-yellow-500' : 'bg-green-500';
|
||||
return (
|
||||
<div className={`fixed top-4 right-4 z-50 ${bg} text-white px-4 py-3 rounded-lg shadow-lg fade-in flex items-center gap-2`}>
|
||||
<span>{message}</span>
|
||||
<button onClick={onClose} className="ml-2 hover:opacity-80"><IconX className="w-4 h-4" /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// StatusBadge
|
||||
// ============================================================
|
||||
function StatusBadge({ status }) {
|
||||
const map = {
|
||||
PENDING: { label: '대기중', cls: 'badge badge-warning badge-sm' },
|
||||
PROCESSING: { label: '처리중', cls: 'badge badge-info badge-sm' },
|
||||
COMPLETED: { label: '완료', cls: 'badge badge-success badge-sm' },
|
||||
FAILED: { label: '실패', cls: 'badge badge-error badge-sm' },
|
||||
};
|
||||
const s = map[status] || { label: status, cls: 'badge badge-ghost badge-sm' };
|
||||
return <span className={s.cls}>{s.label}</span>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RecordingPanel - 브라우저 녹음
|
||||
// ============================================================
|
||||
function RecordingPanel({ onComplete, onCancel }) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [title, setTitle] = useState('');
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
const streamRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
const analyserRef = useRef(null);
|
||||
const animFrameRef = useRef(null);
|
||||
|
||||
const formatTime = (s) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
// 오디오 레벨 분석
|
||||
const audioCtx = new AudioContext();
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
const analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const updateLevel = () => {
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(data);
|
||||
const avg = data.reduce((a, b) => a + b, 0) / data.length;
|
||||
setAudioLevel(avg / 255);
|
||||
animFrameRef.current = requestAnimationFrame(updateLevel);
|
||||
};
|
||||
updateLevel();
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus' : 'audio/webm'
|
||||
});
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
onComplete({
|
||||
title: title || '무제 음성녹음',
|
||||
audioBase64: reader.result,
|
||||
duration: seconds,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
cleanup();
|
||||
};
|
||||
|
||||
mediaRecorder.start(1000);
|
||||
setIsRecording(true);
|
||||
setSeconds(0);
|
||||
timerRef.current = setInterval(() => setSeconds(s => s + 1), 1000);
|
||||
} catch (err) {
|
||||
alert('마이크 접근 권한이 필요합니다. 브라우저 설정을 확인해주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
const togglePause = () => {
|
||||
if (!mediaRecorderRef.current) return;
|
||||
if (isPaused) {
|
||||
mediaRecorderRef.current.resume();
|
||||
timerRef.current = setInterval(() => setSeconds(s => s + 1), 1000);
|
||||
} else {
|
||||
mediaRecorderRef.current.pause();
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(timerRef.current);
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(t => t.stop());
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isRecording && mediaRecorderRef.current) {
|
||||
mediaRecorderRef.current.ondataavailable = null;
|
||||
mediaRecorderRef.current.onstop = null;
|
||||
if (mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
}
|
||||
cleanup();
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
setSeconds(0);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
useEffect(() => { return () => cleanup(); }, []);
|
||||
|
||||
// 파형 바 생성
|
||||
const bars = Array.from({ length: 24 }, (_, i) => {
|
||||
const h = isRecording && !isPaused
|
||||
? Math.max(4, audioLevel * 40 + Math.random() * 12)
|
||||
: 4;
|
||||
return h;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-sm border mb-4">
|
||||
<div className="card-body p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-base">녹음</h3>
|
||||
<button onClick={handleCancel} className="btn btn-ghost btn-xs"><IconX className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text" value={title} onChange={e => setTitle(e.target.value)}
|
||||
placeholder="녹음 제목을 입력하세요" disabled={isRecording}
|
||||
className="input input-bordered input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파형 */}
|
||||
<div className="flex items-center justify-center gap-[2px] h-12 mb-3 bg-base-200 rounded-lg px-2">
|
||||
{bars.map((h, i) => (
|
||||
<div key={i} className="waveform-bar w-1.5 rounded-full"
|
||||
style={{
|
||||
height: `${h}px`,
|
||||
backgroundColor: isRecording ? (isPaused ? '#f59e0b' : '#ef4444') : '#d1d5db'
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 타이머 + 상태 */}
|
||||
<div className="text-center mb-3">
|
||||
<span className={`text-2xl font-mono font-bold ${isRecording ? 'text-red-500' : ''}`}>
|
||||
{formatTime(seconds)}
|
||||
</span>
|
||||
{isRecording && (
|
||||
<span className="ml-2 recording-pulse text-red-500 text-sm">
|
||||
{isPaused ? '일시정지' : '녹음중'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-center gap-2">
|
||||
{!isRecording ? (
|
||||
<button onClick={startRecording} className="btn btn-error btn-sm gap-1">
|
||||
<IconMic className="w-4 h-4" /> 녹음 시작
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={togglePause} className="btn btn-warning btn-sm gap-1">
|
||||
{isPaused ? <><IconPlay className="w-4 h-4" /> 재개</> : <><IconPause className="w-4 h-4" /> 일시정지</>}
|
||||
</button>
|
||||
<button onClick={stopRecording} className="btn btn-neutral btn-sm gap-1">
|
||||
<IconSquare className="w-4 h-4" /> 녹음 완료
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FileUploadPanel - 파일 업로드
|
||||
// ============================================================
|
||||
function FileUploadPanel({ onComplete, onCancel }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [file, setFile] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const acceptTypes = '.webm,.wav,.mp3,.ogg,.m4a,.mp4';
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) setFile(f);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio_file', file);
|
||||
formData.append('title', title || file.name);
|
||||
const result = await api.upload(`${BASE_URL}/upload`, formData);
|
||||
onComplete(result);
|
||||
} catch (err) {
|
||||
onComplete({ success: false, message: err.message });
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-sm border mb-4">
|
||||
<div className="card-body p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-base">파일 업로드</h3>
|
||||
<button onClick={onCancel} className="btn btn-ghost btn-xs"><IconX className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text" value={title} onChange={e => setTitle(e.target.value)}
|
||||
placeholder="제목 (비워두면 파일명 사용)"
|
||||
className="input input-bordered input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`drop-zone rounded-lg p-6 text-center cursor-pointer mb-3 ${dragOver ? 'drag-over' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input ref={fileInputRef} type="file" accept={acceptTypes} className="hidden"
|
||||
onChange={e => setFile(e.target.files[0])} />
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<IconFile className="w-5 h-5 text-blue-500" />
|
||||
<span className="text-sm font-medium">{file.name}</span>
|
||||
<span className="text-xs text-gray-500">({(file.size / 1024 / 1024).toFixed(1)} MB)</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<IconUpload className="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">파일을 드래그하거나 클릭하여 선택</p>
|
||||
<p className="text-xs text-gray-400 mt-1">webm, wav, mp3, ogg, m4a, mp4 (최대 100MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onCancel} className="btn btn-ghost btn-sm">취소</button>
|
||||
<button onClick={handleSubmit} disabled={!file || uploading}
|
||||
className="btn btn-primary btn-sm gap-1">
|
||||
{uploading ? <><IconLoader className="w-4 h-4 animate-spin" /> 처리중...</>
|
||||
: <><IconUpload className="w-4 h-4" /> 업로드 및 분석</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DetailModal - 상세 보기
|
||||
// ============================================================
|
||||
function DetailModal({ recording, onClose }) {
|
||||
const [copied, setCopied] = useState(null);
|
||||
|
||||
const copyText = (text, field) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(field);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (!recording) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="bg-base-100 rounded-xl shadow-2xl w-full max-w-3xl max-h-[85vh] flex flex-col mx-4 fade-in"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{recording.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-gray-500">
|
||||
<StatusBadge status={recording.status} />
|
||||
{recording.formatted_duration && <span>{recording.formatted_duration}</span>}
|
||||
<span>{new Date(recording.created_at).toLocaleString('ko-KR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn btn-ghost btn-sm btn-circle"><IconX className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-4">
|
||||
{/* 녹취록 */}
|
||||
{recording.transcript_text && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-semibold text-sm text-gray-600">녹취록</h4>
|
||||
<button onClick={() => copyText(recording.transcript_text, 'transcript')}
|
||||
className="btn btn-ghost btn-xs gap-1">
|
||||
{copied === 'transcript' ? <><IconCheck className="w-3 h-3" /> 복사됨</> : <><IconCopy className="w-3 h-3" /> 복사</>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-base-200 rounded-lg p-3 text-sm whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{recording.transcript_text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI 분석 결과 */}
|
||||
{recording.analysis_text && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-semibold text-sm text-gray-600">AI 분석 결과</h4>
|
||||
<button onClick={() => copyText(recording.analysis_text, 'analysis')}
|
||||
className="btn btn-ghost btn-xs gap-1">
|
||||
{copied === 'analysis' ? <><IconCheck className="w-3 h-3" /> 복사됨</> : <><IconCopy className="w-3 h-3" /> 복사</>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-sm whitespace-pre-wrap max-h-96 overflow-y-auto prose prose-sm max-w-none">
|
||||
{recording.analysis_text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recording.status === 'PROCESSING' && (
|
||||
<div className="text-center py-8">
|
||||
<IconLoader className="w-8 h-8 animate-spin mx-auto text-blue-500 mb-2" />
|
||||
<p className="text-sm text-gray-500">음성을 분석하고 있습니다...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recording.status === 'FAILED' && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-red-500">처리 중 오류가 발생했습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end p-4 border-t">
|
||||
<button onClick={onClose} className="btn btn-sm">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// App
|
||||
// ============================================================
|
||||
function App() {
|
||||
const [recordings, setRecordings] = useState([]);
|
||||
const [pagination, setPagination] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [showRecorder, setShowRecorder] = useState(false);
|
||||
const [showUploader, setShowUploader] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [selectedRecording, setSelectedRecording] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const pollingRef = useRef({});
|
||||
|
||||
const showToast = (message, type = 'success') => setToast({ message, type });
|
||||
|
||||
// 목록 조회
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
params.set('per_page', '10');
|
||||
params.set('page', page);
|
||||
const res = await api.get(`${BASE_URL}/list?${params}`);
|
||||
if (res.success) {
|
||||
setRecordings(res.data.data || []);
|
||||
setPagination({
|
||||
current_page: res.data.current_page,
|
||||
last_page: res.data.last_page,
|
||||
total: res.data.total,
|
||||
});
|
||||
// PROCESSING 상태인 항목 폴링 시작
|
||||
(res.data.data || []).forEach(r => {
|
||||
if (r.status === 'PROCESSING') startPolling(r.id);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('목록 조회 실패: ' + err.message, 'error');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [search, statusFilter, page]);
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
// 폴링
|
||||
const startPolling = (id) => {
|
||||
if (pollingRef.current[id]) return;
|
||||
pollingRef.current[id] = setInterval(async () => {
|
||||
try {
|
||||
const res = await api.get(`${BASE_URL}/${id}/status`);
|
||||
if (res.success && !res.data.is_processing) {
|
||||
clearInterval(pollingRef.current[id]);
|
||||
delete pollingRef.current[id];
|
||||
fetchList();
|
||||
if (res.data.is_completed) {
|
||||
showToast('음성 분석이 완료되었습니다.');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollingRef.current[id]);
|
||||
delete pollingRef.current[id];
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(pollingRef.current).forEach(clearInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 녹음 완료
|
||||
const handleRecordingComplete = async ({ title, audioBase64, duration }) => {
|
||||
setShowRecorder(false);
|
||||
setProcessing(true);
|
||||
try {
|
||||
// 1. 레코드 생성
|
||||
const createRes = await api.post(BASE_URL, { title });
|
||||
if (!createRes.success) throw new Error(createRes.message);
|
||||
|
||||
const recordingId = createRes.data.id;
|
||||
showToast('녹음이 저장되었습니다. 분석을 시작합니다...');
|
||||
|
||||
// 목록 새로고침
|
||||
await fetchList();
|
||||
|
||||
// 2. 오디오 처리 (비동기)
|
||||
api.post(`${BASE_URL}/${recordingId}/process`, {
|
||||
audio: audioBase64,
|
||||
duration: duration,
|
||||
}).then(() => {
|
||||
fetchList();
|
||||
}).catch(err => {
|
||||
showToast('분석 처리 실패: ' + err.message, 'error');
|
||||
fetchList();
|
||||
});
|
||||
|
||||
startPolling(recordingId);
|
||||
} catch (err) {
|
||||
showToast('녹음 저장 실패: ' + err.message, 'error');
|
||||
}
|
||||
setProcessing(false);
|
||||
};
|
||||
|
||||
// 파일 업로드 완료
|
||||
const handleUploadComplete = (result) => {
|
||||
setShowUploader(false);
|
||||
if (result.success) {
|
||||
showToast(result.message || '파일 업로드 및 분석이 완료되었습니다.');
|
||||
} else {
|
||||
showToast(result.message || '업로드 실패', 'error');
|
||||
}
|
||||
fetchList();
|
||||
};
|
||||
|
||||
// 상세 보기
|
||||
const handleView = async (id) => {
|
||||
try {
|
||||
const res = await api.get(`${BASE_URL}/${id}`);
|
||||
if (res.success) setSelectedRecording(res.data);
|
||||
} catch (err) {
|
||||
showToast('상세 조회 실패: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api.del(`${BASE_URL}/${id}`);
|
||||
showToast('삭제되었습니다.');
|
||||
fetchList();
|
||||
} catch (err) {
|
||||
showToast('삭제 실패: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d) => d ? new Date(d).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-';
|
||||
const formatDuration = (s) => {
|
||||
if (!s) return '-';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-6xl mx-auto">
|
||||
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-bold">AI 음성녹음</h1>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { setShowRecorder(!showRecorder); setShowUploader(false); }}
|
||||
className={`btn btn-sm gap-1 ${showRecorder ? 'btn-error' : 'btn-primary'}`}>
|
||||
<IconMic className="w-4 h-4" /> {showRecorder ? '녹음 닫기' : '새 녹음'}
|
||||
</button>
|
||||
<button onClick={() => { setShowUploader(!showUploader); setShowRecorder(false); }}
|
||||
className={`btn btn-sm gap-1 ${showUploader ? 'btn-warning' : 'btn-outline'}`}>
|
||||
<IconUpload className="w-4 h-4" /> {showUploader ? '업로드 닫기' : '파일 업로드'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 녹음 패널 */}
|
||||
{showRecorder && (
|
||||
<RecordingPanel
|
||||
onComplete={handleRecordingComplete}
|
||||
onCancel={() => setShowRecorder(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 업로드 패널 */}
|
||||
{showUploader && (
|
||||
<FileUploadPanel
|
||||
onComplete={handleUploadComplete}
|
||||
onCancel={() => setShowUploader(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 검색/필터 */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="text" value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
placeholder="제목, 내용 검색..."
|
||||
className="input input-bordered input-sm flex-1 max-w-xs"
|
||||
/>
|
||||
<select value={statusFilter} onChange={e => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="select select-bordered select-sm">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="PENDING">대기중</option>
|
||||
<option value="PROCESSING">처리중</option>
|
||||
<option value="COMPLETED">완료</option>
|
||||
<option value="FAILED">실패</option>
|
||||
</select>
|
||||
<button onClick={fetchList} className="btn btn-ghost btn-sm"><IconRefresh className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="card bg-base-100 shadow-sm border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>제목</th>
|
||||
<th className="text-center w-20">녹음시간</th>
|
||||
<th className="text-center w-20">상태</th>
|
||||
<th className="text-center w-28">작성자</th>
|
||||
<th className="text-center w-32">생성일시</th>
|
||||
<th className="text-center w-28">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="6" className="text-center py-8">
|
||||
<IconLoader className="w-6 h-6 animate-spin mx-auto" />
|
||||
</td></tr>
|
||||
) : recordings.length === 0 ? (
|
||||
<tr><td colSpan="6" className="text-center py-8 text-gray-400">
|
||||
녹음 데이터가 없습니다.
|
||||
</td></tr>
|
||||
) : recordings.map(r => (
|
||||
<tr key={r.id} className="hover">
|
||||
<td>
|
||||
<span className="font-medium cursor-pointer hover:text-blue-600"
|
||||
onClick={() => handleView(r.id)}>
|
||||
{r.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center font-mono text-sm">{formatDuration(r.duration_seconds)}</td>
|
||||
<td className="text-center">
|
||||
<StatusBadge status={r.status} />
|
||||
{r.status === 'PROCESSING' && (
|
||||
<IconLoader className="w-3 h-3 animate-spin inline ml-1" />
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center text-sm">{r.user?.name || '-'}</td>
|
||||
<td className="text-center text-sm">{formatDate(r.created_at)}</td>
|
||||
<td className="text-center">
|
||||
<div className="flex justify-center gap-1">
|
||||
<button onClick={() => handleView(r.id)}
|
||||
className="btn btn-ghost btn-xs" title="상세보기">
|
||||
<IconEye className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(r.id)}
|
||||
className="btn btn-ghost btn-xs text-red-500" title="삭제">
|
||||
<IconTrash className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{pagination && pagination.last_page > 1 && (
|
||||
<div className="flex items-center justify-between p-3 border-t">
|
||||
<span className="text-sm text-gray-500">
|
||||
총 {pagination.total}건
|
||||
</span>
|
||||
<div className="join">
|
||||
{Array.from({ length: pagination.last_page }, (_, i) => i + 1).map(p => (
|
||||
<button key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`join-item btn btn-xs ${p === pagination.current_page ? 'btn-active' : ''}`}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상세 모달 */}
|
||||
{selectedRecording && (
|
||||
<DetailModal
|
||||
recording={selectedRecording}
|
||||
onClose={() => setSelectedRecording(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mount
|
||||
// ============================================================
|
||||
const root = ReactDOM.createRoot(document.getElementById('ai-voice-recording-root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
@endverbatim
|
||||
@endpush
|
||||
@@ -35,6 +35,7 @@
|
||||
use App\Http\Controllers\Sales\SalesProductController;
|
||||
use App\Http\Controllers\System\AiConfigController;
|
||||
use App\Http\Controllers\System\AiTokenUsageController;
|
||||
use App\Http\Controllers\System\AiVoiceRecordingController;
|
||||
use App\Http\Controllers\System\HolidayController;
|
||||
use App\Http\Controllers\Stats\StatDashboardController;
|
||||
use App\Http\Controllers\System\SystemAlertController;
|
||||
@@ -411,6 +412,18 @@
|
||||
Route::get('/list', [AiTokenUsageController::class, 'list'])->name('list');
|
||||
});
|
||||
|
||||
// AI 음성녹음 관리
|
||||
Route::prefix('system/ai-voice-recording')->name('system.ai-voice-recording.')->group(function () {
|
||||
Route::get('/', [AiVoiceRecordingController::class, 'index'])->name('index');
|
||||
Route::get('/list', [AiVoiceRecordingController::class, 'list'])->name('list');
|
||||
Route::post('/', [AiVoiceRecordingController::class, 'store'])->name('store');
|
||||
Route::post('/upload', [AiVoiceRecordingController::class, 'uploadFile'])->name('upload');
|
||||
Route::get('/{id}', [AiVoiceRecordingController::class, 'show'])->name('show');
|
||||
Route::post('/{id}/process', [AiVoiceRecordingController::class, 'processAudio'])->name('process');
|
||||
Route::delete('/{id}', [AiVoiceRecordingController::class, 'destroy'])->name('destroy');
|
||||
Route::get('/{id}/status', [AiVoiceRecordingController::class, 'status'])->name('status');
|
||||
});
|
||||
|
||||
// 명함 OCR API
|
||||
Route::post('/api/business-card-ocr', [BusinessCardOcrController::class, 'process'])->name('api.business-card-ocr');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user