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:
@@ -5,8 +5,10 @@
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Sales\SalesConsultation;
|
use App\Models\Sales\SalesConsultation;
|
||||||
use App\Models\Tenants\Tenant;
|
use App\Models\Tenants\Tenant;
|
||||||
|
use App\Services\GoogleCloudStorageService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\View\View;
|
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([
|
$request->validate([
|
||||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||||
@@ -109,18 +113,30 @@ public function uploadAudio(Request $request): JsonResponse
|
|||||||
// 파일 저장
|
// 파일 저장
|
||||||
$file = $request->file('audio');
|
$file = $request->file('audio');
|
||||||
$fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
|
$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에 저장
|
// DB에 저장
|
||||||
$consultation = SalesConsultation::createAudio(
|
$consultation = SalesConsultation::createAudio(
|
||||||
$tenantId,
|
$tenantId,
|
||||||
$scenarioType,
|
$scenarioType,
|
||||||
$stepId,
|
$stepId,
|
||||||
$path,
|
$localPath,
|
||||||
$fileName,
|
$fileName,
|
||||||
$file->getSize(),
|
$fileSize,
|
||||||
$transcript,
|
$transcript,
|
||||||
$duration
|
$duration,
|
||||||
|
$gcsUri
|
||||||
);
|
);
|
||||||
|
|
||||||
$consultation->load('creator');
|
$consultation->load('creator');
|
||||||
@@ -136,6 +152,7 @@ public function uploadAudio(Request $request): JsonResponse
|
|||||||
'formatted_duration' => $consultation->formatted_duration,
|
'formatted_duration' => $consultation->formatted_duration,
|
||||||
'created_by_name' => $consultation->creator->name,
|
'created_by_name' => $consultation->creator->name,
|
||||||
'created_at' => $consultation->created_at->format('Y-m-d H:i'),
|
'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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class SalesConsultation extends Model
|
|||||||
'file_type',
|
'file_type',
|
||||||
'transcript',
|
'transcript',
|
||||||
'duration',
|
'duration',
|
||||||
|
'gcs_uri',
|
||||||
'created_by',
|
'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(
|
public static function createAudio(
|
||||||
int $tenantId,
|
int $tenantId,
|
||||||
@@ -102,7 +113,8 @@ public static function createAudio(
|
|||||||
string $fileName,
|
string $fileName,
|
||||||
int $fileSize,
|
int $fileSize,
|
||||||
?string $transcript = null,
|
?string $transcript = null,
|
||||||
?int $duration = null
|
?int $duration = null,
|
||||||
|
?string $gcsUri = null
|
||||||
): self {
|
): self {
|
||||||
return self::create([
|
return self::create([
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
@@ -115,6 +127,7 @@ public static function createAudio(
|
|||||||
'file_type' => 'audio/webm',
|
'file_type' => 'audio/webm',
|
||||||
'transcript' => $transcript,
|
'transcript' => $transcript,
|
||||||
'duration' => $duration,
|
'duration' => $duration,
|
||||||
|
'gcs_uri' => $gcsUri,
|
||||||
'created_by' => auth()->id(),
|
'created_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
247
app/Services/GoogleCloudStorageService.php
Normal file
247
app/Services/GoogleCloudStorageService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
<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">
|
<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" />
|
<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">
|
<p class="text-xs text-gray-400 text-center">
|
||||||
녹음 파일은 상담 기록에 자동으로 저장됩니다.
|
녹음 파일은 상담 기록에 저장되며, 10MB 이상은 GCS에 백업됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -808,6 +808,8 @@
|
|||||||
Route::post('/upload-audio', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadAudio'])->name('upload-audio');
|
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::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::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');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 매니저 지정 변경
|
// 매니저 지정 변경
|
||||||
|
|||||||
Reference in New Issue
Block a user