feat:음성 녹음 GCS 업로드 및 다운로드 기능 추가

- GoogleCloudStorageService 생성 (레거시 방식 JWT 인증)
- 10MB 이상 파일은 Google Cloud Storage에 백업 (본사 연구용)
- 오디오/파일 다운로드 라우트 추가
- voice-recorder.blade.php 인라인 x-data로 변경 (HTMX 호환)
- SalesConsultation 모델에 gcs_uri 필드 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-29 09:15:13 +09:00
parent 13418cd3ac
commit dd86d70503
5 changed files with 601 additions and 286 deletions

View File

@@ -5,8 +5,10 @@
use App\Http\Controllers\Controller;
use App\Models\Sales\SalesConsultation;
use App\Models\Tenants\Tenant;
use App\Services\GoogleCloudStorageService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
@@ -88,8 +90,10 @@ public function destroy(int $consultationId, Request $request): JsonResponse
/**
* 음성 파일 업로드
*
* 10MB 이상 파일은 Google Cloud Storage에 업로드하여 본사 연구용으로 보관합니다.
*/
public function uploadAudio(Request $request): JsonResponse
public function uploadAudio(Request $request, GoogleCloudStorageService $gcs): JsonResponse
{
$request->validate([
'tenant_id' => 'required|integer|exists:tenants,id',
@@ -109,18 +113,30 @@ public function uploadAudio(Request $request): JsonResponse
// 파일 저장
$file = $request->file('audio');
$fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
$path = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local');
$localPath = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local');
$fileSize = $file->getSize();
// 10MB 이상 파일은 GCS에도 업로드 (본사 연구용)
$gcsUri = null;
$maxLocalSize = 10 * 1024 * 1024; // 10MB
if ($fileSize > $maxLocalSize && $gcs->isAvailable()) {
$gcsObjectName = "consultations/{$tenantId}/{$scenarioType}/{$fileName}";
$localFullPath = Storage::disk('local')->path($localPath);
$gcsUri = $gcs->upload($localFullPath, $gcsObjectName);
}
// DB에 저장
$consultation = SalesConsultation::createAudio(
$tenantId,
$scenarioType,
$stepId,
$path,
$localPath,
$fileName,
$file->getSize(),
$fileSize,
$transcript,
$duration
$duration,
$gcsUri
);
$consultation->load('creator');
@@ -136,6 +152,7 @@ public function uploadAudio(Request $request): JsonResponse
'formatted_duration' => $consultation->formatted_duration,
'created_by_name' => $consultation->creator->name,
'created_at' => $consultation->created_at->format('Y-m-d H:i'),
'has_gcs' => !empty($gcsUri),
],
]);
}
@@ -196,4 +213,69 @@ public function deleteFile(int $fileId, Request $request): JsonResponse
{
return $this->destroy($fileId, $request);
}
/**
* 오디오 파일 다운로드
*/
public function downloadAudio(int $consultationId, GoogleCloudStorageService $gcs): Response
{
$consultation = SalesConsultation::findOrFail($consultationId);
if ($consultation->consultation_type !== 'audio') {
abort(400, '오디오 파일이 아닙니다.');
}
// GCS에 저장된 경우 서명된 URL로 리다이렉트
if ($consultation->gcs_uri) {
$objectName = str_replace('gs://' . $gcs->getBucketName() . '/', '', $consultation->gcs_uri);
$signedUrl = $gcs->getSignedUrl($objectName, 60);
if ($signedUrl) {
return response()->redirectTo($signedUrl);
}
}
// 로컬 파일 다운로드
$localPath = Storage::disk('local')->path($consultation->file_path);
if (!file_exists($localPath)) {
abort(404, '파일을 찾을 수 없습니다.');
}
$extension = pathinfo($consultation->file_name, PATHINFO_EXTENSION) ?: 'webm';
$mimeTypes = [
'webm' => 'audio/webm',
'wav' => 'audio/wav',
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'm4a' => 'audio/mp4'
];
$contentType = $mimeTypes[$extension] ?? 'audio/webm';
$downloadFileName = '상담녹음_' . $consultation->created_at->format('Ymd_His') . '.' . $extension;
return response()->download($localPath, $downloadFileName, [
'Content-Type' => $contentType,
]);
}
/**
* 첨부파일 다운로드
*/
public function downloadFile(int $consultationId): Response
{
$consultation = SalesConsultation::findOrFail($consultationId);
if ($consultation->consultation_type !== 'file') {
abort(400, '첨부파일이 아닙니다.');
}
$localPath = Storage::disk('local')->path($consultation->file_path);
if (!file_exists($localPath)) {
abort(404, '파일을 찾을 수 없습니다.');
}
return response()->download($localPath, $consultation->file_name);
}
}

View File

@@ -44,6 +44,7 @@ class SalesConsultation extends Model
'file_type',
'transcript',
'duration',
'gcs_uri',
'created_by',
];
@@ -93,6 +94,16 @@ public static function createText(int $tenantId, string $scenarioType, ?int $ste
/**
* 음성 상담 기록 생성
*
* @param int $tenantId 테넌트 ID
* @param string $scenarioType 시나리오 타입 (sales/manager)
* @param int|null $stepId 단계 ID
* @param string $filePath 로컬 파일 경로
* @param string $fileName 파일명
* @param int $fileSize 파일 크기
* @param string|null $transcript 음성 텍스트 변환 결과
* @param int|null $duration 녹음 시간 (초)
* @param string|null $gcsUri GCS URI (본사 연구용 백업)
*/
public static function createAudio(
int $tenantId,
@@ -102,7 +113,8 @@ public static function createAudio(
string $fileName,
int $fileSize,
?string $transcript = null,
?int $duration = null
?int $duration = null,
?string $gcsUri = null
): self {
return self::create([
'tenant_id' => $tenantId,
@@ -115,6 +127,7 @@ public static function createAudio(
'file_type' => 'audio/webm',
'transcript' => $transcript,
'duration' => $duration,
'gcs_uri' => $gcsUri,
'created_by' => auth()->id(),
]);
}

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
/**
* Google Cloud Storage 업로드 서비스
*
* 레거시 PHP 코드와 동일한 방식으로 GCS에 파일을 업로드합니다.
* JWT 인증 방식 사용.
*/
class GoogleCloudStorageService
{
private ?string $bucketName = null;
private ?array $serviceAccount = null;
public function __construct()
{
$this->loadConfig();
}
/**
* GCS 설정 로드
*/
private function loadConfig(): void
{
// GCS 버킷 설정
$gcsConfigPath = base_path('../sales/apikey/gcs_config.txt');
if (file_exists($gcsConfigPath)) {
$config = parse_ini_file($gcsConfigPath);
$this->bucketName = $config['bucket_name'] ?? null;
}
// 서비스 계정 로드
$serviceAccountPath = base_path('../sales/apikey/google_service_account.json');
if (file_exists($serviceAccountPath)) {
$this->serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
}
}
/**
* GCS가 사용 가능한지 확인
*/
public function isAvailable(): bool
{
return $this->bucketName !== null && $this->serviceAccount !== null;
}
/**
* GCS에 파일 업로드
*
* @param string $filePath 로컬 파일 경로
* @param string $objectName GCS에 저장할 객체 이름
* @return string|null GCS URI (gs://bucket/object) 또는 실패 시 null
*/
public function upload(string $filePath, string $objectName): ?string
{
if (!$this->isAvailable()) {
Log::warning('GCS 업로드 실패: 설정되지 않음');
return null;
}
if (!file_exists($filePath)) {
Log::error('GCS 업로드 실패: 파일 없음 - ' . $filePath);
return null;
}
// OAuth 2.0 토큰 생성
$accessToken = $this->getAccessToken();
if (!$accessToken) {
return null;
}
// GCS에 파일 업로드
$fileContent = file_get_contents($filePath);
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
$uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/' .
urlencode($this->bucketName) . '/o?uploadType=media&name=' .
urlencode($objectName);
$ch = curl_init($uploadUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Content-Type: ' . $mimeType,
'Content-Length: ' . strlen($fileContent)
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContent);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 300); // 5분 타임아웃
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($httpCode === 200) {
$gcsUri = 'gs://' . $this->bucketName . '/' . $objectName;
Log::info('GCS 업로드 성공: ' . $gcsUri);
return $gcsUri;
}
Log::error('GCS 업로드 실패 (HTTP ' . $httpCode . '): ' . ($error ?: $response));
return null;
}
/**
* GCS에서 서명된 다운로드 URL 생성
*
* @param string $objectName GCS 객체 이름
* @param int $expiresInMinutes URL 유효 시간 (분)
* @return string|null 서명된 URL 또는 실패 시 null
*/
public function getSignedUrl(string $objectName, int $expiresInMinutes = 60): ?string
{
if (!$this->isAvailable()) {
return null;
}
$expiration = time() + ($expiresInMinutes * 60);
$stringToSign = "GET\n\n\n{$expiration}\n/{$this->bucketName}/{$objectName}";
$privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']);
if (!$privateKey) {
Log::error('GCS URL 서명 실패: 개인 키 읽기 오류');
return null;
}
openssl_sign($stringToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256);
if (PHP_VERSION_ID < 80000) {
openssl_free_key($privateKey);
}
$encodedSignature = urlencode(base64_encode($signature));
$clientEmail = urlencode($this->serviceAccount['client_email']);
return "https://storage.googleapis.com/{$this->bucketName}/{$objectName}" .
"?GoogleAccessId={$clientEmail}" .
"&Expires={$expiration}" .
"&Signature={$encodedSignature}";
}
/**
* GCS에서 파일 삭제
*
* @param string $objectName GCS 객체 이름
* @return bool 성공 여부
*/
public function delete(string $objectName): bool
{
if (!$this->isAvailable()) {
return false;
}
$accessToken = $this->getAccessToken();
if (!$accessToken) {
return false;
}
$deleteUrl = 'https://storage.googleapis.com/storage/v1/b/' .
urlencode($this->bucketName) . '/o/' .
urlencode($objectName);
$ch = curl_init($deleteUrl);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode === 204 || $httpCode === 200;
}
/**
* OAuth 2.0 액세스 토큰 획득
*/
private function getAccessToken(): ?string
{
// JWT 생성
$now = time();
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = $this->base64UrlEncode(json_encode([
'iss' => $this->serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
]));
$privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']);
if (!$privateKey) {
Log::error('GCS 토큰 실패: 개인 키 읽기 오류');
return null;
}
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
if (PHP_VERSION_ID < 80000) {
openssl_free_key($privateKey);
}
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
// OAuth 토큰 요청
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
Log::error('GCS 토큰 실패: HTTP ' . $httpCode);
return null;
}
$data = json_decode($response, true);
return $data['access_token'] ?? null;
}
/**
* Base64 URL 인코딩
*/
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* 버킷 이름 반환
*/
public function getBucketName(): ?string
{
return $this->bucketName;
}
}

View File

@@ -1,5 +1,254 @@
{{-- 음성 녹음 컴포넌트 --}}
<div x-data="voiceRecorder()" class="bg-white border border-gray-200 rounded-lg p-4">
<div x-data="{
tenantId: {{ $tenant->id }},
scenarioType: '{{ $scenarioType }}',
stepId: {{ $stepId ?? 'null' }},
isRecording: false,
audioBlob: null,
timer: 0,
transcript: '',
interimTranscript: '',
status: '마이크 버튼을 눌러 녹음을 시작하세요',
saving: false,
mediaRecorder: null,
audioChunks: [],
timerInterval: null,
recognition: null,
stream: null,
audioContext: null,
analyser: null,
animationId: null,
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
},
async toggleRecording() {
if (this.isRecording) {
this.stopRecording();
} else {
await this.startRecording();
}
},
async startRecording() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.status = '녹음 중...';
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
const source = this.audioContext.createMediaStreamSource(this.stream);
source.connect(this.analyser);
this.analyser.fftSize = 2048;
this.drawWaveform();
this.mediaRecorder = new MediaRecorder(this.stream);
this.audioChunks = [];
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
};
this.mediaRecorder.onstop = () => {
this.audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
};
this.mediaRecorder.start();
this.timer = 0;
this.timerInterval = setInterval(() => {
this.timer++;
}, 1000);
this.startSpeechRecognition();
this.isRecording = true;
} catch (error) {
console.error('녹음 시작 실패:', error);
this.status = '마이크 접근 권한이 필요합니다.';
}
},
stopRecording() {
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
if (this.recognition) {
this.recognition.stop();
}
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
if (this.audioContext) {
this.audioContext.close();
}
this.isRecording = false;
this.status = '녹음이 완료되었습니다. 저장하거나 취소하세요.';
},
startSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn('음성 인식이 지원되지 않습니다.');
return;
}
this.recognition = new SpeechRecognition();
this.recognition.lang = 'ko-KR';
this.recognition.continuous = true;
this.recognition.interimResults = true;
this.transcript = '';
this.interimTranscript = '';
this.recognition.onresult = (event) => {
let finalTranscript = '';
let interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcript;
} else {
interimTranscript += transcript;
}
}
if (finalTranscript) {
this.transcript += finalTranscript;
}
this.interimTranscript = interimTranscript;
};
this.recognition.onerror = (event) => {
console.warn('음성 인식 오류:', event.error);
};
this.recognition.start();
},
drawWaveform() {
if (!this.analyser || !this.$refs.waveformCanvas) return;
const canvas = this.$refs.waveformCanvas;
const ctx = canvas.getContext('2d');
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const draw = () => {
if (!this.isRecording) return;
this.analyser.getByteTimeDomainData(dataArray);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
ctx.strokeStyle = '#9333ea';
ctx.beginPath();
const sliceWidth = canvas.width / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
this.animationId = requestAnimationFrame(draw);
};
draw();
},
async saveRecording() {
if (!this.audioBlob || this.saving) return;
this.saving = true;
this.status = '저장 중... (GCS 업로드 포함)';
try {
const formData = new FormData();
formData.append('tenant_id', this.tenantId);
formData.append('scenario_type', this.scenarioType);
if (this.stepId) formData.append('step_id', this.stepId);
formData.append('audio', this.audioBlob, 'recording.webm');
formData.append('transcript', this.transcript);
formData.append('duration', this.timer);
const response = await fetch('{{ route('sales.consultations.upload-audio') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
body: formData,
});
const result = await response.json();
if (result.success) {
this.status = '저장되었습니다!' + (result.consultation.has_gcs ? ' (GCS 백업 완료)' : '');
this.cancelRecording();
htmx.ajax('GET',
'/sales/consultations/' + this.tenantId + '?scenario_type=' + this.scenarioType + '&step_id=' + (this.stepId || ''),
{ target: '#consultation-log-container', swap: 'innerHTML' }
);
} else {
this.status = '저장에 실패했습니다.';
}
} catch (error) {
console.error('녹음 저장 실패:', error);
this.status = '저장 중 오류가 발생했습니다.';
} finally {
this.saving = false;
}
},
cancelRecording() {
this.audioBlob = null;
this.timer = 0;
this.transcript = '';
this.interimTranscript = '';
this.status = '마이크 버튼을 눌러 녹음을 시작하세요';
const canvas = this.$refs.waveformCanvas;
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
}" class="bg-white border border-gray-200 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
@@ -79,285 +328,7 @@ class="flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg ho
{{-- 저장된 녹음 목록 안내 --}}
<p class="text-xs text-gray-400 text-center">
녹음 파일은 상담 기록에 자동으로 저장됩니다.
녹음 파일은 상담 기록에 저장되며, 10MB 이상은 GCS에 백업됩니다.
</p>
</div>
</div>
<script>
function voiceRecorder() {
return {
tenantId: {{ $tenant->id }},
scenarioType: '{{ $scenarioType }}',
stepId: {{ $stepId ?? 'null' }},
isRecording: false,
audioBlob: null,
timer: 0,
transcript: '',
interimTranscript: '',
status: '마이크 버튼을 눌러 녹음을 시작하세요',
saving: false,
// 내부 참조
mediaRecorder: null,
audioChunks: [],
timerInterval: null,
recognition: null,
stream: null,
audioContext: null,
analyser: null,
animationId: null,
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
},
async toggleRecording() {
if (this.isRecording) {
this.stopRecording();
} else {
await this.startRecording();
}
},
async startRecording() {
try {
// 마이크 권한 요청
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.status = '녹음 중...';
// AudioContext 및 Analyser 설정
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
const source = this.audioContext.createMediaStreamSource(this.stream);
source.connect(this.analyser);
this.analyser.fftSize = 2048;
// 파형 그리기 시작
this.drawWaveform();
// MediaRecorder 설정
this.mediaRecorder = new MediaRecorder(this.stream);
this.audioChunks = [];
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
};
this.mediaRecorder.onstop = () => {
this.audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
};
this.mediaRecorder.start();
// 타이머 시작
this.timer = 0;
this.timerInterval = setInterval(() => {
this.timer++;
}, 1000);
// 음성 인식 시작
this.startSpeechRecognition();
this.isRecording = true;
} catch (error) {
console.error('녹음 시작 실패:', error);
this.status = '마이크 접근 권한이 필요합니다.';
}
},
stopRecording() {
// MediaRecorder 중지
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
// 타이머 중지
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
// 음성 인식 중지
if (this.recognition) {
this.recognition.stop();
}
// 파형 애니메이션 중지
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
// 스트림 정리
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
// AudioContext 정리
if (this.audioContext) {
this.audioContext.close();
}
this.isRecording = false;
this.status = '녹음이 완료되었습니다. 저장하거나 취소하세요.';
},
startSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn('음성 인식이 지원되지 않습니다.');
return;
}
this.recognition = new SpeechRecognition();
this.recognition.lang = 'ko-KR';
this.recognition.continuous = true;
this.recognition.interimResults = true;
this.transcript = '';
this.interimTranscript = '';
this.recognition.onresult = (event) => {
let finalTranscript = '';
let interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcript;
} else {
interimTranscript += transcript;
}
}
if (finalTranscript) {
this.transcript += finalTranscript;
}
this.interimTranscript = interimTranscript;
};
this.recognition.onerror = (event) => {
console.warn('음성 인식 오류:', event.error);
};
this.recognition.start();
},
drawWaveform() {
if (!this.analyser || !this.$refs.waveformCanvas) return;
const canvas = this.$refs.waveformCanvas;
const ctx = canvas.getContext('2d');
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
// 캔버스 크기 설정
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const draw = () => {
if (!this.isRecording) return;
this.analyser.getByteTimeDomainData(dataArray);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
ctx.strokeStyle = '#9333ea';
ctx.beginPath();
const sliceWidth = canvas.width / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
this.animationId = requestAnimationFrame(draw);
};
draw();
},
async saveRecording() {
if (!this.audioBlob || this.saving) return;
this.saving = true;
this.status = '저장 중...';
try {
const formData = new FormData();
formData.append('tenant_id', this.tenantId);
formData.append('scenario_type', this.scenarioType);
if (this.stepId) formData.append('step_id', this.stepId);
formData.append('audio', this.audioBlob, 'recording.webm');
formData.append('transcript', this.transcript);
formData.append('duration', this.timer);
const response = await fetch('{{ route('sales.consultations.upload-audio') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: formData,
});
const result = await response.json();
if (result.success) {
this.status = '저장되었습니다!';
this.cancelRecording();
// 상담 기록 목록 새로고침
htmx.ajax('GET',
`/sales/consultations/${this.tenantId}?scenario_type=${this.scenarioType}&step_id=${this.stepId || ''}`,
{ target: '#consultation-log-container', swap: 'innerHTML' }
);
} else {
this.status = '저장에 실패했습니다.';
}
} catch (error) {
console.error('녹음 저장 실패:', error);
this.status = '저장 중 오류가 발생했습니다.';
} finally {
this.saving = false;
}
},
cancelRecording() {
this.audioBlob = null;
this.timer = 0;
this.transcript = '';
this.interimTranscript = '';
this.status = '마이크 버튼을 눌러 녹음을 시작하세요';
// 캔버스 초기화
const canvas = this.$refs.waveformCanvas;
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
};
}
</script>

View File

@@ -808,6 +808,8 @@
Route::post('/upload-audio', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadAudio'])->name('upload-audio');
Route::post('/upload-file', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadFile'])->name('upload-file');
Route::delete('/file/{file}', [\App\Http\Controllers\Sales\ConsultationController::class, 'deleteFile'])->name('delete-file');
Route::get('/download-audio/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadAudio'])->name('download-audio');
Route::get('/download-file/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadFile'])->name('download-file');
});
// 매니저 지정 변경