false, 'error' => '서버 오류가 발생했습니다.', 'details' => $error['message'], 'file' => basename($error['file']), 'line' => $error['line'] ], JSON_UNESCAPED_UNICODE); exit; } } register_shutdown_function('handleFatalError'); // 에러 응답 함수 (require 전에 정의) function sendErrorResponse($message, $details = null) { while (ob_get_level()) { ob_end_clean(); } header('Content-Type: application/json; charset=utf-8'); $response = ['ok' => false, 'error' => $message]; if ($details !== null) { $response['details'] = $details; } echo json_encode($response, JSON_UNESCAPED_UNICODE); exit; } // 출력 버퍼 비우기 ob_clean(); header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Origin: *'); // 1. 권한 체크 require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php"); require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php"); if (!isset($user_id) || $level > 5) { sendErrorResponse('접근 권한이 없습니다.'); } // 2. POST 요청 확인 if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sendErrorResponse('POST 요청만 허용됩니다.'); } // 3. 파일 업로드 확인 if (!isset($_FILES['audio_file']) || $_FILES['audio_file']['error'] !== UPLOAD_ERR_OK) { $errorMsg = '오디오 파일 업로드 실패'; if (isset($_FILES['audio_file']['error'])) { switch ($_FILES['audio_file']['error']) { case UPLOAD_ERR_INI_SIZE: case UPLOAD_ERR_FORM_SIZE: $errorMsg = '파일 크기가 너무 큽니다.'; break; case UPLOAD_ERR_PARTIAL: $errorMsg = '파일이 부분적으로만 업로드되었습니다.'; break; case UPLOAD_ERR_NO_FILE: $errorMsg = '파일이 업로드되지 않았습니다.'; break; case UPLOAD_ERR_NO_TMP_DIR: $errorMsg = '임시 폴더가 없습니다.'; break; case UPLOAD_ERR_CANT_WRITE: $errorMsg = '파일 쓰기 실패.'; break; case UPLOAD_ERR_EXTENSION: $errorMsg = '파일 업로드가 확장에 의해 중지되었습니다.'; break; } } sendErrorResponse($errorMsg); } // tenant_id 확인 및 변환 $tenant_id = isset($tenant_id) ? $tenant_id : (isset($user_id) ? $user_id : null); // tenant_id가 null이거나 정수가 아닌 경우 처리 if (empty($tenant_id)) { sendErrorResponse('유효하지 않은 사용자 ID입니다.', 'tenant_id와 user_id가 모두 없습니다.'); } // tenant_id가 정수가 아닌 경우 (예: "pro") crc32 해시로 변환 if (!filter_var($tenant_id, FILTER_VALIDATE_INT)) { $original_tenant_id = $tenant_id; // null이 아닌 문자열로 변환 $tenant_id_str = (string)$tenant_id; $tenant_id = crc32($tenant_id_str); // 최소 1000 이상으로 보장 (낮은 숫자와의 충돌 방지) if ($tenant_id < 1000) { $tenant_id = abs($tenant_id) + 1000; } error_log("tenant_id 변환: '$original_tenant_id' -> $tenant_id"); } if (empty($tenant_id) || $tenant_id <= 0) { sendErrorResponse('유효하지 않은 사용자 ID입니다.', 'tenant_id: ' . var_export($tenant_id, true)); } // 정수로 변환 $tenant_id = (int)$tenant_id; // 업로드 디렉토리 생성 $upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/consults/" . $tenant_id . "/"; if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true); if ($_FILES['audio_file']['size'] == 0) { sendErrorResponse('오디오 파일이 비어있습니다. 녹음이 제대로 되지 않았을 수 있습니다.'); } $file_name = date('Ymd_His') . "_" . uniqid() . ".webm"; $file_path = $upload_dir . $file_name; if (!move_uploaded_file($_FILES['audio_file']['tmp_name'], $file_path)) { sendErrorResponse('파일 저장 실패', '업로드 디렉토리 권한을 확인해주세요.'); } // 3. STT 변환 (Google Cloud Speech-to-Text API) // 서비스 계정 JSON 파일 또는 API 키 사용 $googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json'; $googleApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_api.txt'; $accessToken = null; // 서비스 계정 JSON 파일이 있으면 OAuth 2.0 토큰 생성 if (file_exists($googleServiceAccountFile)) { $serviceAccount = json_decode(file_get_contents($googleServiceAccountFile), true); if (!$serviceAccount) { sendErrorResponse('서비스 계정 JSON 파일 형식이 올바르지 않습니다.'); } // JWT 생성 및 OAuth 2.0 토큰 요청 $now = time(); // JWT 헤더 $jwtHeader = [ 'alg' => 'RS256', 'typ' => 'JWT' ]; // JWT 클레임 $jwtClaim = [ 'iss' => $serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/cloud-platform', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now ]; // Base64 URL 인코딩 (표준 JWT 형식) $base64UrlEncode = function($data) { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }; $encodedHeader = $base64UrlEncode(json_encode($jwtHeader)); $encodedClaim = $base64UrlEncode(json_encode($jwtClaim)); // 서명 생성 $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); if (!$privateKey) { sendErrorResponse('서비스 계정 개인 키를 읽을 수 없습니다.'); } $signature = ''; $signData = $encodedHeader . '.' . $encodedClaim; if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { openssl_free_key($privateKey); sendErrorResponse('JWT 서명 생성 실패'); } openssl_free_key($privateKey); $encodedSignature = $base64UrlEncode($signature); $jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature; // OAuth 2.0 토큰 요청 $tokenCh = curl_init('https://oauth2.googleapis.com/token'); curl_setopt($tokenCh, CURLOPT_POST, true); curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt ])); curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true); curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); // 타임아웃 설정 curl_setopt($tokenCh, CURLOPT_TIMEOUT, 30); // 30초 curl_setopt($tokenCh, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초 $tokenResponse = curl_exec($tokenCh); $tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE); $tokenError = curl_error($tokenCh); curl_close($tokenCh); if ($tokenCode === 200) { $tokenData = json_decode($tokenResponse, true); if (isset($tokenData['access_token'])) { $accessToken = $tokenData['access_token']; error_log('OAuth 토큰 생성 성공'); } else { error_log('OAuth 토큰 응답 오류: ' . $tokenResponse); sendErrorResponse('OAuth 토큰을 받을 수 없습니다.', substr($tokenResponse, 0, 500)); } } else { error_log('OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . '): ' . $tokenResponse); $errorDetails = json_decode($tokenResponse, true); $errorMessage = isset($errorDetails['error_description']) ? $errorDetails['error_description'] : (isset($errorDetails['error']) ? $errorDetails['error'] : substr($tokenResponse, 0, 500)); sendErrorResponse('OAuth 토큰 요청 실패', $errorMessage); } } // API 키 사용 (서비스 계정이 없는 경우) $googleApiKey = null; if (!$accessToken && file_exists($googleApiKeyFile)) { $googleApiKey = trim(file_get_contents($googleApiKeyFile)); if (strlen($googleApiKey) < 20) { sendErrorResponse('Google API 키 형식이 올바르지 않습니다.'); } } if (!$accessToken && !$googleApiKey) { sendErrorResponse('Google API 인증 정보가 없습니다. 서비스 계정 JSON 파일 또는 API 키가 필요합니다.'); } // 오디오 파일 읽기 $file_size = filesize($file_path); $audio_content = file_get_contents($file_path); // 파일 크기 확인 (60MB 제한) if ($file_size > 60 * 1024 * 1024) { sendErrorResponse('오디오 파일이 너무 큽니다. (최대 60MB)', 'file_size_mb: ' . round($file_size / 1024 / 1024, 2)); } // 오디오 인코딩 및 샘플레이트 자동 감지 $audio_encoding = 'WEBM_OPUS'; $sample_rate = 48000; // 파일 확장자나 MIME 타입으로 인코딩 추정 $file_ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION)); if ($file_ext === 'wav') { $audio_encoding = 'LINEAR16'; $sample_rate = 16000; } elseif ($file_ext === 'flac') { $audio_encoding = 'FLAC'; $sample_rate = 48000; } elseif ($file_ext === 'mp3' || $file_ext === 'm4a') { $audio_encoding = 'MP3'; $sample_rate = 44100; } // 짧은 오디오(약 1MB 이하, 약 1분 이하)는 recognize API 사용 (즉시 결과) // 긴 오디오는 longrunningrecognize API 사용 (비동기 처리) $useRecognizeAPI = $file_size <= 1024 * 1024; // 1MB 이하 if ($useRecognizeAPI) { // 짧은 오디오: recognize API 사용 (동기, 즉시 결과) $apiUrl = 'https://speech.googleapis.com/v1/speech:recognize'; error_log('짧은 오디오 감지: recognize API 사용 (파일 크기: ' . round($file_size / 1024, 2) . ' KB)'); } else { // 긴 오디오: longrunningrecognize API 사용 (비동기) $apiUrl = 'https://speech.googleapis.com/v1/speech:longrunningrecognize'; error_log('긴 오디오 감지: longrunningrecognize API 사용 (파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB)'); } $requestBody = [ 'config' => [ 'encoding' => $audio_encoding, 'sampleRateHertz' => $sample_rate, 'languageCode' => 'ko-KR', 'enableAutomaticPunctuation' => true, 'enableWordTimeOffsets' => false, 'model' => $useRecognizeAPI ? 'latest_short' : 'latest_long' // 짧은 오디오는 latest_short 모델 사용 ], 'audio' => [ 'content' => base64_encode($audio_content) ] ]; $headers = ['Content-Type: application/json']; if ($accessToken) { $headers[] = 'Authorization: Bearer ' . $accessToken; } else { $apiUrl .= '?key=' . urlencode($googleApiKey); } $ch = curl_init($apiUrl); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // 타임아웃 설정 (짧은 오디오는 더 짧게, 긴 오디오는 길게) curl_setopt($ch, CURLOPT_TIMEOUT, $useRecognizeAPI ? 30 : 50); // recognize는 30초, longrunning은 50초 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); $operation_response = curl_exec($ch); $operation_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $operation_error = curl_error($ch); curl_close($ch); // 오류 처리 if ($operation_code !== 200) { $error_message = ''; $error_data = json_decode($operation_response, true); if (isset($error_data['error']['message'])) { $error_message = $error_data['error']['message']; } // 상세 오류 로깅 error_log('Google STT API 오류 (HTTP ' . $operation_code . '): ' . $error_message); error_log('응답 전체: ' . substr($operation_response, 0, 1000)); error_log('파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB'); // 파일이 너무 크거나 오디오 길이가 긴 경우 GCS 사용 시도 $shouldTryGCS = false; if ($operation_code === 400) { // 오류 메시지에 다음 키워드가 포함되면 GCS 시도 $gcs_keywords = [ 'too large', 'exceeds', 'size limit', 'duration limit', 'Inline audio exceeds', 'Please use a GCS URI', 'GCS URI' ]; foreach ($gcs_keywords as $keyword) { if (stripos($error_message, $keyword) !== false) { $shouldTryGCS = true; error_log('GCS 업로드 필요 감지: ' . $keyword . ' 키워드 발견'); break; } } // 파일 크기가 10MB 이상이면 GCS 시도 if (!$shouldTryGCS && $file_size > 10 * 1024 * 1024) { $shouldTryGCS = true; error_log('GCS 업로드 필요 감지: 파일 크기 ' . round($file_size / 1024 / 1024, 2) . ' MB'); } } if ($shouldTryGCS) { // Google Cloud Storage 업로드 시도 $gcs_config_paths = [ $_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt', $_SERVER['DOCUMENT_ROOT'] . '/5130/apikey/gcs_config.txt', dirname($_SERVER['DOCUMENT_ROOT']) . '/apikey/gcs_config.txt', dirname($_SERVER['DOCUMENT_ROOT']) . '/5130/apikey/gcs_config.txt', ]; $gcs_config_file = null; $bucket_name = ''; // GCS 설정 파일 찾기 foreach ($gcs_config_paths as $path) { if (file_exists($path)) { $gcs_config_file = $path; $gcs_config = json_decode(file_get_contents($path), true); if ($gcs_config && isset($gcs_config['bucket_name'])) { $bucket_name = $gcs_config['bucket_name']; } break; } } if (empty($bucket_name)) { // GCS 설정이 없는 경우 $error_details = '원래 오류: ' . $error_message . ' / 파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB' . ' / Google Cloud Storage 설정이 필요합니다.'; if ($gcs_config_file) { $error_details .= ' / GCS 설정 파일은 찾았지만 bucket_name이 설정되지 않았습니다: ' . $gcs_config_file; } else { $error_details .= ' / 다음 경로에서 GCS 설정 파일을 찾을 수 없습니다: ' . implode(', ', $gcs_config_paths); } $error_details .= ' / /apikey/gcs_config.txt 파일을 생성하고 {"bucket_name": "your-bucket-name"} 형식으로 설정해주세요.'; sendErrorResponse('Google STT 변환 실패', $error_details); } // GCS에 업로드 $gcs_object_name = 'consults/' . $tenant_id . '/' . $file_name; $gcs_uri = 'gs://' . $bucket_name . '/' . $gcs_object_name; // 간단한 GCS 업로드 (서비스 계정 사용) if ($accessToken) { $gcs_upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' . $bucket_name . '/o?uploadType=media&name=' . urlencode($gcs_object_name); $gcs_headers = [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: audio/webm' ]; error_log('GCS 업로드 시도: ' . $gcs_upload_url); error_log('GCS 버킷: ' . $bucket_name); error_log('GCS 객체명: ' . $gcs_object_name); error_log('파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB'); $gcs_ch = curl_init($gcs_upload_url); curl_setopt($gcs_ch, CURLOPT_POST, true); curl_setopt($gcs_ch, CURLOPT_HTTPHEADER, $gcs_headers); curl_setopt($gcs_ch, CURLOPT_POSTFIELDS, $audio_content); curl_setopt($gcs_ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($gcs_ch, CURLOPT_TIMEOUT, 60); curl_setopt($gcs_ch, CURLOPT_CONNECTTIMEOUT, 10); $gcs_response = curl_exec($gcs_ch); $gcs_code = curl_getinfo($gcs_ch, CURLINFO_HTTP_CODE); $gcs_error = curl_error($gcs_ch); curl_close($gcs_ch); if ($gcs_code === 200) { // GCS 업로드 성공 error_log('GCS 업로드 성공: ' . $gcs_uri); } else { $gcs_error_data = json_decode($gcs_response, true); $gcs_error_message = isset($gcs_error_data['error']['message']) ? $gcs_error_data['error']['message'] : ''; error_log('GCS 업로드 실패 (HTTP ' . $gcs_code . '): ' . $gcs_response); error_log('GCS cURL 오류: ' . $gcs_error); // GCS 업로드 실패 상세 정보 $gcs_failure_details = 'GCS 업로드 실패 (HTTP ' . $gcs_code . ')'; if ($gcs_error_message) { $gcs_failure_details .= ': ' . $gcs_error_message; } if ($gcs_error) { $gcs_failure_details .= ' / cURL 오류: ' . $gcs_error; } $gcs_failure_details .= ' / 버킷: ' . $bucket_name; $gcs_failure_details .= ' / 서비스 계정에 Storage 권한이 있는지 확인해주세요.'; sendErrorResponse('Google STT 변환 실패', '원래 오류: ' . $error_message . ' / 파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB' . ' / ' . $gcs_failure_details); } } else { // accessToken이 없는 경우 sendErrorResponse('Google STT 변환 실패', '원래 오류: ' . $error_message . ' / 파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB' . ' / GCS 업로드를 위해 서비스 계정 인증이 필요합니다. accessToken을 생성할 수 없습니다.'); } // GCS URI 사용하여 재시도 $requestBody['audio'] = ['uri' => $gcs_uri]; // 재시도 $ch = curl_init($apiUrl); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // 타임아웃 설정 (504 오류 방지) curl_setopt($ch, CURLOPT_TIMEOUT, 50); // 50초 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초 $operation_response = curl_exec($ch); $operation_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $operation_error = curl_error($ch); curl_close($ch); // 재시도도 실패한 경우 if ($operation_code !== 200) { $retry_error_data = json_decode($operation_response, true); $retry_error_message = isset($retry_error_data['error']['message']) ? $retry_error_data['error']['message'] : ''; error_log('Google STT 재시도 실패 (GCS URI, HTTP ' . $operation_code . '): ' . $retry_error_message); sendErrorResponse('Google STT 변환 실패 (HTTP ' . $operation_code . ')', 'GCS URI 사용 후에도 실패했습니다. 원래 오류: ' . $error_message . ($retry_error_message ? ' / 재시도 오류: ' . $retry_error_message : '') . ' / GCS URI: ' . $gcs_uri); } // 재시도 성공 시 계속 진행 } else { // 다른 오류인 경우 - 상세 오류 메시지 반환 $full_error = $error_message ?: ($operation_response ? substr($operation_response, 0, 500) : '응답 없음'); if ($operation_error) { $full_error .= ' / cURL 오류: ' . $operation_error; } sendErrorResponse('Google STT 변환 실패 (HTTP ' . $operation_code . ')', $full_error); } } $operation_data = json_decode($operation_response, true); // $web_path 정의 $web_path = "/uploads/consults/" . $tenant_id . "/" . $file_name; if ($useRecognizeAPI) { // recognize API: 즉시 결과 반환 if (!isset($operation_data['results']) || empty($operation_data['results'])) { error_log('Google STT recognize API 결과 없음: ' . $operation_response); sendErrorResponse('음성 인식 결과가 없습니다.', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.'); } // 텍스트 변환 $transcript = ''; foreach ($operation_data['results'] as $result) { if (isset($result['alternatives'][0]['transcript'])) { $transcript .= $result['alternatives'][0]['transcript'] . ' '; } } $transcript = trim($transcript); if (empty($transcript)) { sendErrorResponse('인식된 텍스트가 없습니다.', '녹음된 오디오에서 텍스트를 추출할 수 없었습니다.'); } // Claude API로 요약 생성 $claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt'; $claudeKey = ''; if (file_exists($claudeKeyFile)) { $claudeKey = trim(file_get_contents($claudeKeyFile)); } $title = '무제 업무협의록'; $summary = ''; if (!empty($claudeKey)) { $prompt = "다음 업무협의 녹취록을 분석하여 JSON 형식으로 응답해주세요. 요구사항: 1. \"title\": 업무협의 내용을 요약한 제목 (최대 20자, 한글 기준) 2. \"summary\": [업무협의 개요 / 주요 안건 / 결정 사항 / 향후 계획] 형식의 상세 요약 반드시 다음 JSON 형식으로만 응답해주세요: { \"title\": \"제목 (최대 20자)\", \"summary\": \"상세 요약 내용\" } 업무협의 녹취록: " . $transcript; $ch2 = curl_init('https://api.anthropic.com/v1/messages'); $requestBody = [ 'model' => 'claude-3-5-haiku-20241022', 'max_tokens' => 2048, 'messages' => [['role' => 'user', 'content' => $prompt]] ]; curl_setopt($ch2, CURLOPT_POST, true); curl_setopt($ch2, CURLOPT_HTTPHEADER, [ 'x-api-key: ' . $claudeKey, 'anthropic-version: 2023-06-01', 'Content-Type: application/json' ]); curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($requestBody)); curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch2, CURLOPT_TIMEOUT, 30); $ai_response = curl_exec($ch2); $ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE); curl_close($ch2); if ($ai_code === 200) { $ai_data = json_decode($ai_response, true); if (isset($ai_data['content'][0]['text'])) { $ai_text = trim($ai_data['content'][0]['text']); $ai_text = preg_replace('/^```(?:json)?\s*/m', '', $ai_text); $ai_text = preg_replace('/\s*```$/m', '', $ai_text); $ai_text = trim($ai_text); $parsed_data = json_decode($ai_text, true); if (is_array($parsed_data)) { if (isset($parsed_data['title']) && !empty($parsed_data['title'])) { $title = mb_substr(trim($parsed_data['title']), 0, 20, 'UTF-8'); } if (isset($parsed_data['summary']) && !empty($parsed_data['summary'])) { $summary = $parsed_data['summary']; } else { $summary = $ai_text; } } else { $summary = $ai_text; if (preg_match('/["\']?title["\']?\s*[:=]\s*["\']([^"\']{1,20})["\']/', $ai_text, $matches)) { $title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8'); } elseif (preg_match('/제목[:\s]+([^\n]{1,20})/', $ai_text, $matches)) { $title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8'); } else { $lines = explode("\n", $ai_text); $first_line = trim($lines[0]); if (!empty($first_line) && mb_strlen($first_line, 'UTF-8') <= 20) { $title = $first_line; } elseif (!empty($first_line)) { $title = mb_substr($first_line, 0, 20, 'UTF-8'); } } } } } } if (empty($title) || $title === '무제 업무협의록') { if (!empty($summary)) { $summary_lines = explode("\n", $summary); $first_summary_line = trim($summary_lines[0]); if (!empty($first_summary_line)) { $first_summary_line = preg_replace('/[\[\(].*?[\]\)]/', '', $first_summary_line); $first_summary_line = trim($first_summary_line); if (mb_strlen($first_summary_line, 'UTF-8') <= 20) { $title = $first_summary_line; } else { $title = mb_substr($first_summary_line, 0, 20, 'UTF-8'); } } } } $title = mb_substr(trim($title), 0, 20, 'UTF-8'); if (empty($title)) { $title = '무제 업무협의록'; } if (empty($summary)) { $summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)"; } // DB 연결 및 consult_logs 테이블 확인/생성 $pdo = db_connect(); if (!$pdo) { sendErrorResponse('데이터베이스 연결 실패'); } // 테이블 존재 확인 및 AUTO_INCREMENT 처리 (기존 로직 재사용) try { $checkTable = $pdo->query("SHOW TABLES LIKE 'consult_logs'"); $tableExists = $checkTable->rowCount() > 0; if (!$tableExists) { $createTableSql = "CREATE TABLE IF NOT EXISTS consult_logs ( id INT AUTO_INCREMENT PRIMARY KEY, tenant_id INT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) NOT NULL, audio_file_path VARCHAR(500), transcript_text TEXT, summary_text TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, file_expiry_date DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $pdo->exec($createTableSql); error_log('consult_logs 테이블 생성 완료'); } } catch (PDOException $e) { error_log('테이블 확인/생성 오류: ' . $e->getMessage()); } // DB에 저장 $insertSql = "INSERT INTO consult_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date) VALUES (?, ?, ?, ?, ?, ?, ?)"; $insertStmt = $pdo->prepare($insertSql); $expiry = date('Y-m-d H:i:s', strtotime('+7 days')); $insertStmt->execute([$tenant_id, $tenant_id, $title, $web_path, $transcript, $summary, $expiry]); $consult_id = $pdo->lastInsertId(); if (!$consult_id) { sendErrorResponse('데이터베이스 저장 실패', 'INSERT ID를 가져올 수 없습니다.'); } // 즉시 완료 응답 @ob_clean(); @ob_end_clean(); header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'processing' => false, 'done' => true, 'consult_id' => $consult_id, 'title' => $title, 'transcript' => $transcript, 'summary' => $summary, 'message' => '음성 인식 및 요약이 완료되었습니다.' ], JSON_UNESCAPED_UNICODE); exit; } else { // longrunningrecognize API: 비동기 처리 if (!isset($operation_data['name'])) { error_log('Google STT longrunningrecognize 작업 ID 없음: ' . $operation_response); sendErrorResponse('Google STT 작업 시작 실패', substr($operation_response, 0, 500)); } $operation_name = $operation_data['name']; // 504 Gateway Timeout 방지를 위해 비동기 처리로 변경 // 작업을 시작하고 즉시 응답, 클라이언트에서 폴링하도록 변경 // 작업 정보를 임시로 DB에 저장 // DB 연결 및 consult_logs 테이블 확인/생성 $pdo_temp = db_connect(); if (!$pdo_temp) { sendErrorResponse('데이터베이스 연결 실패'); } // 테이블 존재 확인 및 AUTO_INCREMENT 처리 try { // 테이블 존재 여부 확인 $checkTable = $pdo_temp->query("SHOW TABLES LIKE 'consult_logs'"); $tableExists = $checkTable->rowCount() > 0; if (!$tableExists) { // 테이블 생성 $createTableSql = "CREATE TABLE IF NOT EXISTS consult_logs ( id INT AUTO_INCREMENT PRIMARY KEY, tenant_id INT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) NOT NULL, audio_file_path VARCHAR(500), transcript_text TEXT, summary_text TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, file_expiry_date DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $pdo_temp->exec($createTableSql); error_log('consult_logs 테이블 생성 완료'); } else { // 테이블이 존재하지만 AUTO_INCREMENT가 없는 경우 수정 $checkColumn = $pdo_temp->query("SHOW COLUMNS FROM consult_logs WHERE Field = 'id'"); $columnInfo = $checkColumn->fetch(PDO::FETCH_ASSOC); if ($columnInfo && strpos($columnInfo['Extra'], 'auto_increment') === false) { // AUTO_INCREMENT가 없으면 추가 try { $pdo_temp->exec("ALTER TABLE consult_logs MODIFY id INT AUTO_INCREMENT PRIMARY KEY"); error_log('consult_logs 테이블 AUTO_INCREMENT 추가 완료'); } catch (PDOException $e) { error_log('AUTO_INCREMENT 추가 실패: ' . $e->getMessage()); // PRIMARY KEY가 이미 있는 경우 MODIFY만 시도 try { $pdo_temp->exec("ALTER TABLE consult_logs MODIFY id INT AUTO_INCREMENT"); error_log('consult_logs 테이블 AUTO_INCREMENT 수정 완료'); } catch (PDOException $e2) { error_log('AUTO_INCREMENT 수정 실패: ' . $e2->getMessage()); } } } // id=0 또는 NULL인 레코드 삭제 (PRIMARY KEY 충돌 방지) $pdo_temp->exec("DELETE FROM consult_logs WHERE id = 0 OR id IS NULL"); // AUTO_INCREMENT 값 확인 및 수정 $checkAutoIncrement = $pdo_temp->query("SHOW TABLE STATUS LIKE 'consult_logs'"); $tableStatus = $checkAutoIncrement->fetch(PDO::FETCH_ASSOC); if ($tableStatus && ($tableStatus['Auto_increment'] == 0 || $tableStatus['Auto_increment'] == null)) { // 최대 ID 확인 $maxIdResult = $pdo_temp->query("SELECT MAX(id) as max_id FROM consult_logs"); $maxIdRow = $maxIdResult->fetch(PDO::FETCH_ASSOC); $maxId = $maxIdRow['max_id'] ? (int)$maxIdRow['max_id'] : 0; if ($maxId > 0) { $pdo_temp->exec("ALTER TABLE consult_logs AUTO_INCREMENT = " . ($maxId + 1)); } else { $pdo_temp->exec("ALTER TABLE consult_logs AUTO_INCREMENT = 1"); } error_log('consult_logs AUTO_INCREMENT 값 수정: ' . ($maxId + 1)); } } } catch (PDOException $e) { error_log('테이블 확인/생성 오류: ' . $e->getMessage()); // 오류가 있어도 계속 진행 (테이블이 이미 존재할 수 있음) } // 임시 레코드 삽입 $temp_sql = "INSERT INTO consult_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date) VALUES (?, ?, ?, ?, ?, ?, ?)"; $temp_stmt = $pdo_temp->prepare($temp_sql); $temp_title = '처리 중...'; $temp_summary = 'Google Speech-to-Text API 처리 중입니다. 잠시 후 다시 시도해주세요.'; $temp_expiry = date('Y-m-d H:i:s', strtotime('+7 days')); // transcript_text에 operation_name 저장 (나중에 업데이트) $temp_stmt->execute([$tenant_id, $tenant_id, $temp_title, $web_path, 'OPERATION_NAME:' . $operation_name, $temp_summary, $temp_expiry]); $temp_consult_id = $pdo_temp->lastInsertId(); if (!$temp_consult_id) { // lastInsertId 실패 시 에러 로그 및 스키마 정보 출력 $errorInfo = $pdo_temp->errorInfo(); error_log('lastInsertId 실패 - errorInfo: ' . print_r($errorInfo, true)); // 테이블 스키마 확인 try { $schemaResult = $pdo_temp->query("SHOW CREATE TABLE consult_logs"); $schemaRow = $schemaResult->fetch(PDO::FETCH_ASSOC); if ($schemaRow) { error_log('consult_logs 테이블 스키마: ' . $schemaRow['Create Table']); } } catch (PDOException $e) { error_log('스키마 확인 실패: ' . $e->getMessage()); } sendErrorResponse('데이터베이스 저장 실패', 'INSERT ID를 가져올 수 없습니다. 테이블의 AUTO_INCREMENT 설정을 확인해주세요.'); } // 즉시 응답 (클라이언트에서 폴링하도록) @ob_clean(); @ob_end_clean(); header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'processing' => true, 'consult_id' => $temp_consult_id, 'operation_name' => $operation_name, 'message' => '음성 인식 작업이 시작되었습니다. 처리 완료까지 시간이 걸릴 수 있습니다.', 'poll_url' => 'check_consult_status.php?consult_id=' . $temp_consult_id . '&operation_name=' . urlencode($operation_name), 'access_token_available' => !empty($accessToken) ], JSON_UNESCAPED_UNICODE); exit; // 비동기 처리: 작업이 시작되었으므로 즉시 응답하고 종료 // 나머지 처리는 check_consult_status.php에서 클라이언트 폴링으로 처리됨 // 이전 동기 처리 코드는 check_consult_status.php로 이동됨 } ?>