초기 커밋: 5130 레거시 시스템

- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경
- DB 연결 하드코딩 → .env 기반으로 변경
- MySQL strict mode DATE 오류 수정
This commit is contained in:
2025-12-10 20:14:31 +09:00
commit aca1767eb9
6728 changed files with 1863265 additions and 0 deletions

25
voice_ai/cleanup_cron.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
// DB 연결 필요
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 1주일 지난 파일 조회
$sql = "SELECT id, audio_file_path FROM meeting_logs WHERE file_expiry_date < NOW() AND audio_file_path IS NOT NULL";
$stmt = $pdo->query($sql);
$expired_files = $stmt->fetchAll();
foreach ($expired_files as $file) {
$full_path = $_SERVER['DOCUMENT_ROOT'] . $file['audio_file_path'];
// 파일 삭제
if (file_exists($full_path)) {
unlink($full_path);
}
// DB 업데이트 (파일 경로는 지우고 기록은 남김)
$update_sql = "UPDATE meeting_logs SET audio_file_path = NULL WHERE id = ?";
$pdo->prepare($update_sql)->execute([$file['id']]);
echo "Deleted: " . $full_path . "\n";
}
?>

172
voice_ai/delete_meeting.php Normal file
View File

@@ -0,0 +1,172 @@
<?php
// 출력 버퍼링 시작
ob_start();
error_reporting(0);
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
// 권한 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']);
exit;
}
// 회의 ID 확인
$meeting_id = isset($_POST['id']) ? intval($_POST['id']) : 0;
$tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용
if ($meeting_id <= 0) {
echo json_encode(['ok' => false, 'error' => '잘못된 요청입니다.']);
exit;
}
try {
$pdo = db_connect();
// MVP 단계: 모든 사용자가 모든 데이터를 볼 수 있도록 tenant_id 조건 제거
$sql = "SELECT audio_file_path FROM meeting_logs WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$meeting_id]);
$meeting = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$meeting) {
echo json_encode(['ok' => false, 'error' => '회의록을 찾을 수 없습니다.']);
exit;
}
// 1. 서버 파일 삭제
if (!empty($meeting['audio_file_path'])) {
$file_path = $_SERVER['DOCUMENT_ROOT'] . $meeting['audio_file_path'];
$file_path = str_replace('\\', '/', $file_path);
$file_path = preg_replace('#/+#', '/', $file_path);
if (file_exists($file_path)) {
@unlink($file_path);
}
// 2. GCS 파일 삭제 (GCS URI인 경우)
if (strpos($meeting['audio_file_path'], 'gs://') === 0) {
// GCS URI 파싱
$gcs_uri = $meeting['audio_file_path'];
if (preg_match('#gs://([^/]+)/(.+)#', $gcs_uri, $matches)) {
$bucket_name = $matches[1];
$object_name = $matches[2];
// GCS 삭제 함수 호출
$gcs_deleted = deleteFromGCS($bucket_name, $object_name);
if (!$gcs_deleted) {
error_log('GCS 파일 삭제 실패: ' . $gcs_uri);
}
}
}
}
// 3. DB에서 삭제 (MVP 단계: tenant_id 조건 제거)
$delete_sql = "DELETE FROM meeting_logs WHERE id = ?";
$delete_stmt = $pdo->prepare($delete_sql);
$delete_stmt->execute([$meeting_id]);
echo json_encode(['ok' => true, 'message' => '회의록이 삭제되었습니다.']);
} catch (Exception $e) {
error_log('회의록 삭제 오류: ' . $e->getMessage());
echo json_encode(['ok' => false, 'error' => '삭제 중 오류가 발생했습니다: ' . $e->getMessage()]);
}
// GCS 파일 삭제 함수
function deleteFromGCS($bucket_name, $object_name, $service_account_path = null) {
if (!$service_account_path) {
$service_account_path = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json';
}
if (!file_exists($service_account_path)) {
error_log('GCS 삭제 실패: 서비스 계정 파일 없음');
return false;
}
$serviceAccount = json_decode(file_get_contents($service_account_path), true);
if (!$serviceAccount) {
error_log('GCS 삭제 실패: 서비스 계정 JSON 파싱 오류');
return false;
}
// OAuth 2.0 토큰 생성
$now = time();
$jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = base64_encode(json_encode([
'iss' => $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($serviceAccount['private_key']);
if (!$privateKey) {
error_log('GCS 삭제 실패: 개인 키 읽기 오류');
return false;
}
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKey);
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature);
// OAuth 토큰 요청
$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']);
$tokenResponse = curl_exec($tokenCh);
$tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenCode !== 200) {
error_log('GCS 삭제 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')');
return false;
}
$tokenData = json_decode($tokenResponse, true);
if (!isset($tokenData['access_token'])) {
error_log('GCS 삭제 실패: OAuth 토큰 없음');
return false;
}
$accessToken = $tokenData['access_token'];
// GCS에서 파일 삭제
$delete_url = 'https://storage.googleapis.com/storage/v1/b/' .
urlencode($bucket_name) . '/o/' .
urlencode($object_name);
$ch = curl_init($delete_url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 204 No Content 또는 404 Not Found는 성공으로 간주
if ($code === 204 || $code === 404) {
return true;
} else {
error_log('GCS 삭제 실패 (HTTP ' . $code . '): ' . $response);
return false;
}
}

190
voice_ai/download_audio.php Normal file
View File

@@ -0,0 +1,190 @@
<?php
// 출력 버퍼링 시작
ob_start();
error_reporting(0);
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 권한 체크
if (!isset($user_id) || $level > 5) {
header('HTTP/1.0 403 Forbidden');
die('접근 권한이 없습니다.');
}
// 회의 ID 확인
$meeting_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
$tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용
if ($meeting_id <= 0) {
header('HTTP/1.0 400 Bad Request');
die('잘못된 요청입니다.');
}
try {
$pdo = db_connect();
// MVP 단계: 모든 사용자가 모든 데이터를 볼 수 있도록 tenant_id 조건 제거
$sql = "SELECT audio_file_path, title, created_at
FROM meeting_logs
WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$meeting_id]);
$meeting = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$meeting || empty($meeting['audio_file_path'])) {
header('HTTP/1.0 404 Not Found');
die('오디오 파일을 찾을 수 없습니다.');
}
// 파일 경로 구성
$file_path = $_SERVER['DOCUMENT_ROOT'] . $meeting['audio_file_path'];
// 경로 정규화 (슬래시 통일)
$file_path = str_replace('\\', '/', $file_path);
$file_path = preg_replace('#/+#', '/', $file_path);
// 파일 존재 확인
if (!file_exists($file_path)) {
// 디버깅 정보
error_log('다운로드 실패 - 파일 경로: ' . $file_path);
error_log('다운로드 실패 - DB 경로: ' . $meeting['audio_file_path']);
error_log('다운로드 실패 - DOCUMENT_ROOT: ' . $_SERVER['DOCUMENT_ROOT']);
error_log('다운로드 실패 - 파일 존재 여부: ' . (file_exists($file_path) ? 'YES' : 'NO'));
// 대체 경로 시도 (경로가 잘못된 경우)
$alt_path = $_SERVER['DOCUMENT_ROOT'] . str_replace('//', '/', $meeting['audio_file_path']);
if (file_exists($alt_path)) {
$file_path = $alt_path;
} else {
header('HTTP/1.0 404 Not Found');
header('Content-Type: text/plain; charset=utf-8');
die('오디오 파일이 서버에 존재하지 않습니다.\n경로: ' . $file_path);
}
}
// 파일 확장자 확인 및 MIME 타입 설정
// 파일 경로에서 직접 확장자 추출
$file_extension = '';
if (preg_match('/\.([a-z0-9]+)$/i', $file_path, $matches)) {
$file_extension = strtolower($matches[1]);
}
// 확장자가 없으면 경로에서 추출 시도
if (empty($file_extension)) {
$path_info = pathinfo($file_path);
$file_extension = isset($path_info['extension']) ? strtolower($path_info['extension']) : '';
}
// 확장자가 여전히 없으면 webm으로 기본 설정
if (empty($file_extension)) {
$file_extension = 'webm';
}
$mime_types = [
'webm' => 'audio/webm',
'wav' => 'audio/wav',
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'm4a' => 'audio/mp4'
];
$content_type = isset($mime_types[$file_extension])
? $mime_types[$file_extension]
: 'audio/webm'; // 기본값을 audio/webm으로 설정
// 다운로드 파일명 생성 (회의록 + 제목 + 날짜 + 확장자)
$title = $meeting['title'] ?: '회의녹음';
$date = date('Ymd_His', strtotime($meeting['created_at']));
// 파일명 안전하게 처리 (특수문자 제거, 공백을 언더스코어로)
$safe_title = preg_replace('/[^a-zA-Z0-9가-힣_\-]/u', '_', $title);
$safe_title = preg_replace('/\s+/', '_', $safe_title);
$safe_title = trim($safe_title, '_');
// 제목이 비어있거나 언더스코어만 있는 경우 기본값 사용
if (empty($safe_title) || preg_match('/^_+$/', $safe_title)) {
$safe_title = '회의녹음';
}
// 제목에 "회의록"이 이미 포함되어 있으면 제거 (중복 방지)
$safe_title = preg_replace('/^회의록[_\s]*/u', '', $safe_title);
$safe_title = preg_replace('/[_\s]*회의록$/u', '', $safe_title);
$safe_title = trim($safe_title, '_');
// 제목이 비어있으면 기본값 사용
if (empty($safe_title)) {
$safe_title = '회의녹음';
}
// 파일명 앞에 "회의록" 추가 (한 번만)
$download_filename = '회의록_' . $safe_title . '_' . $date . '.' . $file_extension;
// 출력 버퍼 비우기
ob_clean();
// 파일명이 확장자를 포함하는지 최종 확인
if (!preg_match('/\.' . preg_quote($file_extension, '/') . '$/i', $download_filename)) {
$download_filename .= '.' . $file_extension;
}
// 헤더 설정 (브라우저 호환성을 위해 파일명 처리)
header('Content-Type: ' . $content_type);
// 파일명 처리: "회의록"을 "Meeting"으로 변환하여 브라우저 호환성 확보
$ascii_filename = str_replace('회의록', 'Meeting', $download_filename);
// "Meeting"이 중복되지 않도록 처리 (제목에 이미 "Meeting"이 포함된 경우)
$ascii_filename = preg_replace('/Meeting[_\s]*Meeting/i', 'Meeting', $ascii_filename);
// 나머지 한글과 특수문자를 언더스코어로 변환
$ascii_filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $ascii_filename);
// 연속된 언더스코어를 하나로 통합
$ascii_filename = preg_replace('/_+/', '_', $ascii_filename);
// 확장자가 확실히 포함되도록 재확인
if (!preg_match('/\.' . preg_quote($file_extension, '/') . '$/i', $ascii_filename)) {
$ascii_filename .= '.' . $file_extension;
}
// 파일 크기 재확인
$file_size = filesize($file_path);
if ($file_size === false || $file_size == 0) {
header('HTTP/1.0 500 Internal Server Error');
header('Content-Type: text/plain; charset=utf-8');
die('파일을 읽을 수 없습니다. (크기: ' . $file_size . ')');
}
// Content-Disposition 헤더를 더 명확하게 설정 (RFC 5987 형식)
// 브라우저 호환성을 위해 두 가지 형식 모두 사용
$encoded_filename = rawurlencode($ascii_filename);
header('Content-Disposition: attachment; filename="' . $ascii_filename . '"; filename*=UTF-8\'\'' . $encoded_filename);
header('Content-Length: ' . $file_size);
header('Content-Transfer-Encoding: binary');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header('Expires: 0');
header('X-Content-Type-Options: nosniff');
// 디버깅용 (필요시 주석 해제)
// error_log('Download: ' . $download_filename . ' (extension: ' . $file_extension . ', path: ' . $file_path . ', size: ' . $file_size . ')');
// 파일 출력 (청크 단위로 읽어서 메모리 효율성 향상)
$handle = @fopen($file_path, 'rb');
if ($handle === false) {
header('HTTP/1.0 500 Internal Server Error');
header('Content-Type: text/plain; charset=utf-8');
die('파일을 열 수 없습니다.');
}
// 청크 단위로 출력 (8KB씩)
while (!feof($handle)) {
$chunk = fread($handle, 8192);
if ($chunk === false) {
break;
}
echo $chunk;
flush();
}
fclose($handle);
exit;
} catch (Exception $e) {
header('HTTP/1.0 500 Internal Server Error');
die('파일 다운로드 중 오류가 발생했습니다.');
}

49
voice_ai/get_meeting.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
// 출력 버퍼링 시작 및 에러 리포팅 비활성화
error_reporting(0);
ob_start();
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
// 권한 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']);
exit;
}
$meeting_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
$tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용
if ($meeting_id <= 0) {
echo json_encode(['ok' => false, 'error' => '잘못된 요청입니다.']);
exit;
}
try {
$pdo = db_connect();
// MVP 단계: 모든 사용자가 모든 데이터를 볼 수 있도록 tenant_id 조건 제거
$sql = "SELECT id, title, transcript_text, summary_text, audio_file_path, created_at
FROM meeting_logs
WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$meeting_id]);
$meeting = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$meeting) {
echo json_encode(['ok' => false, 'error' => '회의록을 찾을 수 없습니다.']);
exit;
}
echo json_encode(['ok' => true, 'data' => $meeting]);
} catch (Exception $e) {
echo json_encode(['ok' => false, 'error' => 'DB 오류: ' . $e->getMessage()]);
}
?>

1436
voice_ai/index.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,759 @@
<?php
// 출력 버퍼링 시작 및 에러 리포팅 비활성화
error_reporting(0);
ob_start();
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// GCS 업로드 함수
function uploadToGCS($file_path, $bucket_name, $object_name, $service_account_path) {
if (!file_exists($service_account_path)) {
error_log('GCS 업로드 실패: 서비스 계정 파일 없음');
return false;
}
$serviceAccount = json_decode(file_get_contents($service_account_path), true);
if (!$serviceAccount) {
error_log('GCS 업로드 실패: 서비스 계정 JSON 파싱 오류');
return false;
}
// OAuth 2.0 토큰 생성
$now = time();
$jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = base64_encode(json_encode([
'iss' => $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($serviceAccount['private_key']);
if (!$privateKey) {
error_log('GCS 업로드 실패: 개인 키 읽기 오류');
return false;
}
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKey);
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature);
// OAuth 토큰 요청
$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']);
$tokenResponse = curl_exec($tokenCh);
$tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenCode !== 200) {
error_log('GCS 업로드 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')');
return false;
}
$tokenData = json_decode($tokenResponse, true);
if (!isset($tokenData['access_token'])) {
error_log('GCS 업로드 실패: OAuth 토큰 없음');
return false;
}
$accessToken = $tokenData['access_token'];
// GCS에 파일 업로드
$file_content = file_get_contents($file_path);
$mime_type = mime_content_type($file_path) ?: 'audio/webm';
$upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' .
urlencode($bucket_name) . '/o?uploadType=media&name=' .
urlencode($object_name);
$ch = curl_init($upload_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Content-Type: ' . $mime_type,
'Content-Length: ' . strlen($file_content)
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
return 'gs://' . $bucket_name . '/' . $object_name;
} else {
error_log('GCS 업로드 실패 (HTTP ' . $code . '): ' . $response);
return false;
}
}
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
// 1. 권한 및 세션 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']);
exit;
}
$tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용
$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/meetings/" . $tenant_id . "/";
// 2. 파일 업로드 처리
if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true);
if (!isset($_FILES['audio_file'])) {
echo json_encode(['ok' => false, 'error' => '오디오 파일이 없습니다.']);
exit;
}
// 파일 크기 확인
if ($_FILES['audio_file']['size'] == 0) {
echo json_encode(['ok' => false, 'error' => '오디오 파일이 비어있습니다. 녹음이 제대로 되지 않았을 수 있습니다.']);
exit;
}
$file_name = date('Ymd_His') . "_" . uniqid() . ".webm";
$file_path = $upload_dir . $file_name;
if (!move_uploaded_file($_FILES['audio_file']['tmp_name'], $file_path)) {
echo json_encode(['ok' => false, 'error' => '파일 저장 실패']);
exit;
}
// 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) {
echo json_encode(['ok' => false, 'error' => '서비스 계정 JSON 파일 형식이 올바르지 않습니다.']);
exit;
}
// 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) {
echo json_encode(['ok' => false, 'error' => '서비스 계정 개인 키를 읽을 수 없습니다.']);
exit;
}
$signature = '';
$signData = $encodedHeader . '.' . $encodedClaim;
if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
openssl_free_key($privateKey);
echo json_encode(['ok' => false, 'error' => 'JWT 서명 생성 실패']);
exit;
}
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']);
$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);
echo json_encode(['ok' => false, 'error' => 'OAuth 토큰을 받을 수 없습니다.', 'details' => substr($tokenResponse, 0, 500)]);
exit;
}
} 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));
echo json_encode(['ok' => false, 'error' => 'OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')', 'details' => $errorMessage]);
exit;
}
}
// OAuth 토큰이 없고 API 키 파일이 있으면 API 키 사용
if (!$accessToken && file_exists($googleApiKeyFile)) {
$googleApiKey = trim(file_get_contents($googleApiKeyFile));
if (!empty($googleApiKey)) {
// API 키 방식 사용 (기존 코드)
} else {
echo json_encode(['ok' => false, 'error' => 'Google API 키가 비어있습니다.']);
exit;
}
} elseif (!$accessToken) {
echo json_encode(['ok' => false, 'error' => 'Google 서비스 계정 JSON 파일 또는 API 키 파일이 필요합니다.']);
exit;
}
// 오디오 파일 크기 확인
$file_size = filesize($file_path);
$max_inline_size = 10 * 1024 * 1024; // 10MB (인라인 오디오 제한)
// 파일 확장자 확인
$file_extension = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
// 인코딩 자동 감지 (파일 확장자 기반)
$encoding = null;
$sample_rate = null;
switch ($file_extension) {
case 'webm':
$encoding = 'WEBM_OPUS';
$sample_rate = 48000;
break;
case 'wav':
$encoding = 'LINEAR16'; // WAV 파일은 LINEAR16
$sample_rate = 16000; // WAV는 보통 16kHz
break;
case 'mp3':
$encoding = 'MP3';
$sample_rate = null; // MP3는 자동 감지
break;
case 'ogg':
$encoding = 'OGG_OPUS';
$sample_rate = 48000;
break;
case 'm4a':
$encoding = 'MP4';
$sample_rate = null;
break;
default:
// 기본값: webm으로 가정
$encoding = 'WEBM_OPUS';
$sample_rate = 48000;
}
// API 요청 본문 구성
$config = [
'languageCode' => 'ko-KR', // 한국어
'enableAutomaticPunctuation' => true,
'model' => 'latest_long', // 긴 오디오용 모델
'audioChannelCount' => 1, // 모노 채널
'enableWordTimeOffsets' => false // 단어별 타임스탬프는 필요시에만
];
// 인코딩 설정 (일부 형식은 생략 가능)
if ($encoding) {
$config['encoding'] = $encoding;
}
// 샘플레이트 설정 (필요한 경우만)
if ($sample_rate) {
$config['sampleRateHertz'] = $sample_rate;
}
$requestBody = ['config' => $config];
// 오디오 소스 설정: 작은 파일은 인라인, 큰 파일은 GCS URI 사용
// 먼저 인라인으로 시도하고, 오류 발생 시 재시도 로직으로 처리
$audioContent = base64_encode(file_get_contents($file_path));
$requestBody['audio'] = ['content' => $audioContent];
// 모든 오디오 길이를 지원하기 위해 longrunningrecognize API 사용
// (짧은 오디오도 처리 가능하며, 1분 제한이 없음)
// longrunningrecognize는 비동기 작업이므로 작업을 시작하고 폴링해야 함
if ($accessToken) {
$apiUrl = 'https://speech.googleapis.com/v1/speech:longrunningrecognize';
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $accessToken
];
} else {
$apiUrl = 'https://speech.googleapis.com/v1/speech:longrunningrecognize?key=' . urlencode($googleApiKey);
$headers = ['Content-Type: application/json'];
}
$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);
$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_log('Google STT longrunningrecognize 시작 실패: ' . $operation_response);
// 오류 응답 파싱
$error_data = json_decode($operation_response, true);
$error_message = '';
$needs_gcs = false;
$needs_encoding_fix = false;
if (isset($error_data['error']['message'])) {
$error_message = $error_data['error']['message'];
// 인코딩 오류 감지
if (strpos($error_message, 'Encoding') !== false ||
strpos($error_message, 'LINEAR16') !== false ||
strpos($error_message, 'must either be omitted') !== false) {
$needs_encoding_fix = true;
}
// GCS URI 필요 오류인 경우 감지
if (strpos($error_message, 'GCS URI') !== false ||
strpos($error_message, 'duration limit') !== false ||
strpos($error_message, 'exceeds duration limit') !== false ||
strpos($error_message, 'Inline audio exceeds') !== false) {
$needs_gcs = true;
}
}
// 인코딩 오류인 경우, 인코딩을 생략하고 재시도
if ($needs_encoding_fix) {
// 인코딩과 샘플레이트를 제거하고 자동 감지하도록 설정
unset($requestBody['config']['encoding']);
unset($requestBody['config']['sampleRateHertz']);
// 재시도
$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);
$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) {
// 성공했으므로 이후 폴링 로직으로 진행
$needs_gcs = false;
} else {
error_log('Google STT 재시도 실패 (인코딩 수정): ' . $operation_response);
// 인코딩 수정 후에도 실패한 경우, 오류 데이터 다시 파싱
$error_data = json_decode($operation_response, true);
if (isset($error_data['error']['message'])) {
$error_message = $error_data['error']['message'];
// GCS URI 필요 오류인지 다시 확인
if (strpos($error_message, 'GCS URI') !== false ||
strpos($error_message, 'duration limit') !== false ||
strpos($error_message, 'exceeds duration limit') !== false ||
strpos($error_message, 'Inline audio exceeds') !== false) {
$needs_gcs = true;
}
}
}
}
// GCS URI가 필요한 경우, GCS에 업로드 후 URI 사용
if ($needs_gcs && $operation_code !== 200) {
// GCS 설정 확인
$gcs_config_file = $_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt';
$bucket_name = null;
if (file_exists($gcs_config_file)) {
$gcs_config = parse_ini_file($gcs_config_file);
$bucket_name = isset($gcs_config['bucket_name']) ? $gcs_config['bucket_name'] : null;
}
if ($bucket_name) {
// GCS에 파일 업로드
$gcs_object_name = 'meetings/' . $tenant_id . '/' . basename($file_path);
$gcs_uri = uploadToGCS($file_path, $bucket_name, $gcs_object_name, $googleServiceAccountFile);
if ($gcs_uri) {
// GCS URI 사용
$requestBody['audio'] = ['uri' => $gcs_uri];
} else {
// GCS 업로드 실패
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패',
'details' => 'GCS 업로드에 실패했습니다. GCS 설정을 확인해주세요.',
'file_size_mb' => round($file_size / 1024 / 1024, 2)
]);
exit;
}
} else {
// GCS 설정이 없는 경우
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패',
'details' => '오디오 파일이 너무 깁니다. Google Cloud Storage 설정이 필요합니다. /apikey/gcs_config.txt 파일을 생성하고 bucket_name을 설정해주세요.',
'file_size_mb' => round($file_size / 1024 / 1024, 2),
'help' => '자세한 설정 방법은 voice_ai/구글클라우드스토리지설정방법.md 파일을 참고하세요.'
]);
exit;
}
// 재시도
$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);
$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_log('Google STT 재시도 실패 (GCS URI): ' . $operation_response);
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패 (HTTP ' . $operation_code . ')',
'details' => '오디오 파일이 너무 깁니다. Google Cloud Storage 설정이 필요할 수 있습니다.',
'curl_error' => $operation_error,
'file_size_mb' => round($file_size / 1024 / 1024, 2)
]);
exit;
}
// 재시도 성공 시 계속 진행
} else {
// 다른 오류인 경우
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패 (HTTP ' . $operation_code . ')',
'details' => $error_message ?: ($operation_response ? substr($operation_response, 0, 500) : '응답 없음'),
'curl_error' => $operation_error
]);
exit;
}
}
$operation_data = json_decode($operation_response, true);
if (!isset($operation_data['name'])) {
error_log('Google STT longrunningrecognize 작업 ID 없음: ' . $operation_response);
echo json_encode([
'ok' => false,
'error' => 'Google STT 작업 시작 실패',
'details' => substr($operation_response, 0, 500)
]);
exit;
}
$operation_name = $operation_data['name'];
// 작업 완료까지 폴링 (오디오 길이에 따라 동적으로 타임아웃 설정)
// 4시간 오디오는 처리에 약 1~2시간 소요될 수 있음
// 최대 4시간 대기 (2880회 * 5초 = 4시간)
// PHP 실행 시간 제한도 충분히 설정 필요 (ini_set('max_execution_time', 14400))
ini_set('max_execution_time', 14400); // 4시간
set_time_limit(14400); // 4시간
$max_polls = 2880; // 2880회 * 5초 = 4시간
$poll_count = 0;
$operation_done = false;
while ($poll_count < $max_polls && !$operation_done) {
sleep(5); // 5초 대기
$poll_count++;
// 작업 상태 확인
if ($accessToken) {
$poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name);
$poll_headers = ['Authorization: Bearer ' . $accessToken];
} else {
$poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name) . '?key=' . urlencode($googleApiKey);
$poll_headers = [];
}
$poll_ch = curl_init($poll_url);
curl_setopt($poll_ch, CURLOPT_RETURNTRANSFER, true);
if (!empty($poll_headers)) {
curl_setopt($poll_ch, CURLOPT_HTTPHEADER, $poll_headers);
}
$poll_response = curl_exec($poll_ch);
$poll_code = curl_getinfo($poll_ch, CURLINFO_HTTP_CODE);
curl_close($poll_ch);
if ($poll_code === 200) {
$poll_data = json_decode($poll_response, true);
if (isset($poll_data['done']) && $poll_data['done'] === true) {
if (isset($poll_data['error'])) {
error_log('Google STT longrunningrecognize 오류: ' . json_encode($poll_data['error']));
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패',
'details' => isset($poll_data['error']['message']) ? $poll_data['error']['message'] : '알 수 없는 오류'
]);
exit;
}
if (isset($poll_data['response']['results'])) {
$stt_data = ['results' => $poll_data['response']['results']];
$operation_done = true;
} else {
error_log('Google STT longrunningrecognize 결과 없음: ' . $poll_response);
echo json_encode([
'ok' => false,
'error' => 'Google STT 응답에 결과가 없습니다.',
'details' => substr($poll_response, 0, 500)
]);
exit;
}
}
} else {
error_log('Google STT longrunningrecognize 폴링 실패 (HTTP ' . $poll_code . '): ' . $poll_response);
}
}
if (!$operation_done) {
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환이 시간 초과되었습니다. (4시간 이상 소요)',
'details' => '작업이 아직 완료되지 않았습니다. 매우 긴 오디오(4시간 이상)는 처리 시간이 오래 걸릴 수 있습니다. 작업 ID: ' . $operation_name,
'operation_name' => $operation_name,
'poll_count' => $poll_count,
'elapsed_time_minutes' => round($poll_count * 5 / 60, 1)
]);
exit;
}
// Google Speech-to-Text 응답 형식 처리
if (!isset($stt_data['results']) || empty($stt_data['results'])) {
// 오류 응답인 경우 처리
if (isset($stt_data['error'])) {
$errorMsg = isset($stt_data['error']['message']) ? $stt_data['error']['message'] : '알 수 없는 오류';
echo json_encode([
'ok' => false,
'error' => 'Google STT API 오류: ' . $errorMsg,
'response' => substr($stt_response, 0, 500)
]);
} else {
echo json_encode([
'ok' => false,
'error' => 'Google STT 응답에 결과가 없습니다. (오디오가 너무 짧거나 인식할 수 없는 형식일 수 있습니다)',
'response' => substr($stt_response, 0, 500)
]);
}
exit;
}
// 모든 결과를 합쳐서 텍스트 생성
$transcript = '';
foreach ($stt_data['results'] as $result) {
if (isset($result['alternatives'][0]['transcript'])) {
$transcript .= $result['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
if (empty($transcript)) {
echo json_encode([
'ok' => false,
'error' => '인식된 텍스트가 없습니다. (오디오에 음성이 없거나 너무 작을 수 있습니다)',
'response' => substr($stt_response, 0, 500)
]);
exit;
}
// 4. 회의록 요약 및 제목 생성 (Claude API)
$claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
if (!file_exists($claudeKeyFile)) {
// Claude API 키가 없어도 요약은 실패하지만 계속 진행
$claudeKey = '';
} else {
$claudeKey = trim(file_get_contents($claudeKeyFile));
}
// Claude API에 JSON 형식으로 응답 요청
$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);
$ai_response = curl_exec($ch2);
$ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
curl_close($ch2);
// 기본값 설정
$title = '무제 회의록';
$summary = '';
if ($ai_code !== 200 || empty($claudeKey)) {
if (empty($claudeKey)) {
$summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)";
} else {
$summary = "요약 실패 (원문 저장됨)";
}
} else {
$ai_data = json_decode($ai_response, true);
if (isset($ai_data['content'][0]['text'])) {
$ai_text = $ai_data['content'][0]['text'];
// JSON 추출 시도 (코드 블록이나 마크다운 제거)
$ai_text = trim($ai_text);
// ```json 또는 ``` 제거
$ai_text = preg_replace('/^```(?:json)?\s*/m', '', $ai_text);
$ai_text = preg_replace('/\s*```$/m', '', $ai_text);
$ai_text = trim($ai_text);
// JSON 파싱 시도
$parsed_data = json_decode($ai_text, true);
if (is_array($parsed_data)) {
// JSON 파싱 성공
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; // JSON 형식이 아니면 전체 텍스트 사용
}
} else {
// JSON 파싱 실패 - 텍스트에서 제목 추출 시도
$summary = $ai_text;
// 텍스트에서 "title" 또는 "제목" 키워드로 제목 추출 시도
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');
}
}
}
}
} else {
$summary = "요약 응답 형식 오류 (원문 저장됨)";
}
}
// 제목 최종 검증 (20자 제한, 빈 값 방지)
$title = mb_substr(trim($title), 0, 20, 'UTF-8');
if (empty($title)) {
$title = '무제 회의록';
}
// 5. DB 저장 및 1주일 보관 설정
$pdo = db_connect();
$expiry_date = date('Y-m-d H:i:s', strtotime('+7 days'));
$web_path = "/uploads/meetings/" . $tenant_id . "/" . $file_name;
// SQL 인젝션 방지는 PDO Prepared Statement 사용 권장
$sql = "INSERT INTO meeting_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([$tenant_id, $tenant_id, $title, $web_path, $transcript, $summary, $expiry_date]);
echo json_encode([
'ok' => true,
'id' => $pdo->lastInsertId(),
'title' => $title,
'transcript' => $transcript,
'summary' => $summary
]);
?>

View File

@@ -0,0 +1,305 @@
# Google Cloud Storage (GCS) 설정 방법
## 개요
Google Speech-to-Text API에서 긴 오디오 파일(약 1분 이상)을 처리하려면 Google Cloud Storage에 파일을 업로드하고 GCS URI를 사용해야 합니다.
---
## 1. Google Cloud Storage 버킷 생성
### 1-1. Google Cloud Console 접속
1. [Google Cloud Console](https://console.cloud.google.com/) 접속
2. 프로젝트 선택 (예: `codebridge-chatbot`)
### 1-2. Storage 버킷 생성
1. 왼쪽 메뉴에서 **Cloud Storage** > **버킷** 클릭
2. **버킷 만들기** 클릭
3. 버킷 정보 입력:
- **이름**: `speech-audio-files` (또는 원하는 이름)
- **위치 유형**: **리전** 선택
- **위치**: `asia-northeast3` (서울) 또는 가까운 리전 선택
- **스토리지 클래스**: **Standard** (기본값)
- **액세스 제어**: **균일하게 적용** 선택
4. **만들기** 클릭
---
## 2. 서비스 계정에 Storage 권한 부여
### 2-1. IAM 권한 추가
1. **IAM 및 관리자** > **서비스 계정** 메뉴로 이동
2. 사용 중인 서비스 계정 선택 (예: `vertex-ai-client`)
3. **권한** 탭 클릭
4. **역할 부여** 클릭
5. 다음 역할 추가:
- **Storage 객체 관리자** (`roles/storage.objectAdmin`)
- 또는 **Storage 객체 생성자** (`roles/storage.objectCreator`) + **Storage 객체 뷰어** (`roles/storage.objectViewer`)
6. **저장** 클릭
### 2-2. 버킷별 권한 설정 (선택사항)
1. **Cloud Storage** > **버킷** 메뉴로 이동
2. 생성한 버킷 선택
3. **권한** 탭 클릭
4. **주 구성원 추가** 클릭
5. 서비스 계정 이메일 입력 (예: `vertex-ai-client@codebridge-chatbot.iam.gserviceaccount.com`)
6. 역할: **Storage 객체 관리자** 선택
7. **저장** 클릭
---
## 3. PHP 코드에 GCS 업로드 기능 추가
### 3-1. 방법 1: REST API 직접 사용 (추천 - 라이브러리 불필요)
서비스 계정을 사용하여 OAuth 2.0 토큰을 생성하고, Google Cloud Storage REST API를 직접 호출합니다.
**장점**:
- 추가 라이브러리 불필요
- 가볍고 빠름
- 기존 코드와 일관성 유지
**GCS 업로드 함수 (REST API 사용)**:
```php
function uploadToGCS($file_path, $bucket_name, $object_name, $service_account_path) {
// 1. OAuth 2.0 토큰 생성 (기존 코드 재사용)
$serviceAccount = json_decode(file_get_contents($service_account_path), true);
$now = time();
$jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = base64_encode(json_encode([
'iss' => $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($serviceAccount['private_key']);
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKey);
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature);
// OAuth 토큰 요청
$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']);
$tokenResponse = curl_exec($tokenCh);
$tokenData = json_decode($tokenResponse, true);
$accessToken = $tokenData['access_token'];
curl_close($tokenCh);
// 2. GCS에 파일 업로드
$file_content = file_get_contents($file_path);
$mime_type = mime_content_type($file_path) ?: 'audio/webm';
$upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' .
urlencode($bucket_name) . '/o?uploadType=media&name=' .
urlencode($object_name);
$ch = curl_init($upload_url);
curl_setopt($ch, CURLOPT_PUT, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Content-Type: ' . $mime_type,
'Content-Length: ' . strlen($file_content)
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
return 'gs://' . $bucket_name . '/' . $object_name;
} else {
error_log('GCS 업로드 실패: ' . $response);
return false;
}
}
```
### 3-2. 방법 2: PHP 클라이언트 라이브러리 사용
Google Cloud Storage PHP 클라이언트 라이브러리가 필요한 경우:
```bash
composer require google/cloud-storage
```
**GCS 업로드 함수 (라이브러리 사용)**:
```php
function uploadToGCS($file_path, $bucket_name, $object_name, $service_account_path) {
require_once __DIR__ . '/vendor/autoload.php';
// 서비스 계정 인증
$client = new \Google\Cloud\Storage\StorageClient([
'keyFilePath' => $service_account_path
]);
$bucket = $client->bucket($bucket_name);
$object = $bucket->upload(
fopen($file_path, 'r'),
['name' => $object_name]
);
// GCS URI 생성
$gcs_uri = 'gs://' . $bucket_name . '/' . $object_name;
return $gcs_uri;
}
```
---
## 4. process_meeting.php에 GCS 통합
### 4-1. 설정 파일 생성
`/apikey/gcs_config.txt` 파일 생성:
```
bucket_name=speech-audio-files
project_id=codebridge-chatbot
```
### 4-2. 코드 수정
큰 파일(10MB 이상 또는 1분 이상)인 경우:
1. 파일을 GCS에 업로드
2. GCS URI (`gs://bucket-name/object-name`) 생성
3. Speech-to-Text API에 GCS URI 전달
---
## 5. GCS URI 형식
### 5-1. GCS URI 형식
```
gs://[BUCKET_NAME]/[OBJECT_NAME]
```
예시:
```
gs://speech-audio-files/meetings/20241201_143022_abc123.webm
```
### 5-2. 공개 URL vs GCS URI
- **GCS URI**: `gs://bucket/object` (Google API에서 직접 접근)
- **공개 URL**: `https://storage.googleapis.com/bucket/object` (웹 브라우저 접근)
**참고**: Speech-to-Text API는 **GCS URI**만 지원합니다. 공개 URL은 작동하지 않습니다.
---
## 6. 비용 고려사항
### 6-1. Storage 비용
- **Standard 스토리지**: 약 $0.020/GB/월
- **네트워크 전송**: 약 $0.12/GB (아시아 리전 간)
### 6-2. 최적화 방법
1. **수명 주기 규칙 설정**: 7일 후 자동 삭제
2. **Nearline 또는 Coldline 스토리지**: 장기 보관 시 사용
3. **자동 삭제**: STT 처리 후 즉시 삭제
---
## 7. 자동 삭제 설정 (수명 주기 규칙)
### 7-1. 수명 주기 규칙 생성
1. **Cloud Storage** > **버킷** 메뉴로 이동
2. 버킷 선택
3. **수명 주기** 탭 클릭
4. **규칙 추가** 클릭
5. 규칙 설정:
- **조건**: 객체가 7일 이상 되었을 때
- **작업**: **삭제** 선택
6. **만들기** 클릭
### 7-2. 코드에서 삭제
STT 처리 완료 후 PHP 코드에서 삭제:
```php
$object->delete();
```
---
## 8. 테스트 방법
### 8-1. 수동 테스트
1. Google Cloud Console에서 버킷에 파일 업로드
2. GCS URI 확인: `gs://bucket-name/file-name`
3. Speech-to-Text API에 GCS URI 전달하여 테스트
### 8-2. 코드 테스트
1. 작은 파일(30초 이하)로 먼저 테스트
2. 큰 파일(1분 이상)로 GCS 업로드 테스트
3. 오류 로그 확인
---
## 9. 문제 해결
### 9-1. 권한 오류
- **증상**: "Permission denied" 오류
- **해결**: 서비스 계정에 Storage 권한 확인
### 9-2. 버킷을 찾을 수 없음
- **증상**: "Bucket not found" 오류
- **해결**: 버킷 이름과 프로젝트 ID 확인
### 9-3. 파일 업로드 실패
- **증상**: 업로드 중 오류
- **해결**:
- 파일 크기 확인
- 네트워크 연결 확인
- 서비스 계정 권한 확인
---
## 10. 보안 고려사항
### 10-1. 버킷 액세스 제어
- **공개 버킷**: 모든 사용자가 읽기 가능 (비추천)
- **비공개 버킷**: 서비스 계정만 접근 (권장)
### 10-2. 서명된 URL (선택사항)
임시 접근 URL 생성 (1시간 유효):
```php
$url = $object->signedUrl(new \DateTime('+1 hour'));
```
---
## 11. 요약 체크리스트
- [ ] Google Cloud Storage 버킷 생성
- [ ] 서비스 계정에 Storage 권한 부여
- [ ] 버킷 이름과 프로젝트 ID 확인
- [ ] PHP 코드에 GCS 업로드 기능 추가
- [ ] 수명 주기 규칙 설정 (자동 삭제)
- [ ] 테스트 실행 및 확인
---
## 12. 참고 자료
- [Google Cloud Storage 문서](https://cloud.google.com/storage/docs)
- [PHP 클라이언트 라이브러리](https://github.com/googleapis/google-cloud-php-storage)
- [Speech-to-Text API 문서](https://cloud.google.com/speech-to-text/docs)
- [GCS URI 사용 가이드](https://cloud.google.com/speech-to-text/docs/async-recognize)
---
**작성일**: 2024년
**프로젝트**: 5130 (voice_ai 시스템)