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:
김보곤
2026-02-07 12:52:37 +09:00
parent ee9f9c128a
commit 5fe6afd9c4
6 changed files with 1612 additions and 0 deletions

View 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,
],
]);
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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 관리 하위 메뉴 ===');

View 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

View File

@@ -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');