836 lines
34 KiB
PHP
836 lines
34 KiB
PHP
<?php
|
|
// 출력 버퍼링 시작 - 모든 출력을 캡처
|
|
while (ob_get_level()) {
|
|
ob_end_clean();
|
|
}
|
|
ob_start();
|
|
|
|
// 에러 리포팅 설정 (개발 중에는 활성화, 프로덕션에서는 비활성화)
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 0); // 출력은 버퍼로만
|
|
ini_set('log_errors', 1);
|
|
|
|
// 에러 핸들러 설정
|
|
function handleError($errno, $errstr, $errfile, $errline) {
|
|
error_log("PHP Error [$errno]: $errstr in $errfile on line $errline");
|
|
return false; // 기본 에러 핸들러도 실행
|
|
}
|
|
set_error_handler('handleError');
|
|
|
|
// 치명적 에러 핸들러
|
|
function handleFatalError() {
|
|
$error = error_get_last();
|
|
if ($error !== NULL && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
|
ob_clean();
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode([
|
|
'ok' => 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로 이동됨
|
|
}
|
|
?>
|