초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
This commit is contained in:
875
voice_ai_cnslt/GCP_storage_dev.md
Normal file
875
voice_ai_cnslt/GCP_storage_dev.md
Normal file
@@ -0,0 +1,875 @@
|
||||
# GCP Storage 파일 저장 로직 개발 문서
|
||||
|
||||
## 개요
|
||||
|
||||
이 문서는 `voice_ai_cnslt` 모듈에서 Google Cloud Storage (GCS)를 사용하여 오디오 파일을 저장하고 관리하는 로직에 대한 개발 가이드입니다.
|
||||
|
||||
## 목차
|
||||
|
||||
1. [시스템 아키텍처](#시스템-아키텍처)
|
||||
2. [필수 설정](#필수-설정)
|
||||
3. [GCS 업로드 로직](#gcs-업로드-로직)
|
||||
4. [GCS 삭제 로직](#gcs-삭제-로직)
|
||||
5. [인증 메커니즘](#인증-메커니즘)
|
||||
6. [사용 시나리오](#사용-시나리오)
|
||||
7. [에러 처리](#에러-처리)
|
||||
8. [코드 예제](#코드-예제)
|
||||
|
||||
---
|
||||
|
||||
## 시스템 아키텍처
|
||||
|
||||
### 파일 저장 흐름
|
||||
|
||||
```
|
||||
[클라이언트]
|
||||
↓ (오디오 파일 업로드)
|
||||
[process_consult.php]
|
||||
↓ (로컬 서버에 임시 저장)
|
||||
[/uploads/consults/{tenant_id}/]
|
||||
↓ (Google STT API 요청 시)
|
||||
[GCS 업로드 필요 여부 확인]
|
||||
↓ (필요한 경우)
|
||||
[uploadToGCS() 함수 호출]
|
||||
↓
|
||||
[Google Cloud Storage]
|
||||
↓ (GCS URI 반환)
|
||||
[Google Speech-to-Text API]
|
||||
```
|
||||
|
||||
### 주요 파일 구조
|
||||
|
||||
- **`voice_ai_cnslt/index.php`**: 프론트엔드 UI 및 녹음 기능
|
||||
- **`voice_ai_cnslt/process_consult.php`**: 오디오 처리 및 GCS 업로드 로직
|
||||
- **`voice_ai_cnslt/delete_consult.php`**: 파일 삭제 및 GCS 삭제 로직
|
||||
- **`apikey/gcs_config.txt`**: GCS 버킷 설정 파일
|
||||
- **`apikey/google_service_account.json`**: Google 서비스 계정 인증 정보
|
||||
|
||||
---
|
||||
|
||||
## 필수 설정
|
||||
|
||||
### 1. GCS 버킷 설정 파일
|
||||
|
||||
**파일 경로**: `/apikey/gcs_config.txt`
|
||||
|
||||
**형식**:
|
||||
```ini
|
||||
bucket_name=your-bucket-name
|
||||
```
|
||||
|
||||
**예시**:
|
||||
```ini
|
||||
bucket_name=codebridge-speech-audio-files
|
||||
```
|
||||
|
||||
### 2. Google 서비스 계정 JSON 파일
|
||||
|
||||
**파일 경로**: `/apikey/google_service_account.json`
|
||||
|
||||
**필수 필드**:
|
||||
- `client_email`: 서비스 계정 이메일
|
||||
- `private_key`: RSA 개인 키 (PEM 형식)
|
||||
- `project_id`: GCP 프로젝트 ID
|
||||
|
||||
**예시 구조**:
|
||||
```json
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "your-project-id",
|
||||
"private_key_id": "...",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "your-service-account@your-project.iam.gserviceaccount.com",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 서비스 계정 권한
|
||||
|
||||
GCS 버킷에 대한 다음 권한이 필요합니다:
|
||||
- `Storage Object Admin` 또는
|
||||
- `Storage Admin`
|
||||
|
||||
---
|
||||
|
||||
## GCS 업로드 로직
|
||||
|
||||
### 함수 시그니처
|
||||
|
||||
```php
|
||||
function uploadToGCS($file_path, $bucket_name, $object_name, $service_account_path)
|
||||
```
|
||||
|
||||
### 매개변수
|
||||
|
||||
| 매개변수 | 타입 | 설명 |
|
||||
|---------|------|------|
|
||||
| `$file_path` | string | 업로드할 로컬 파일 경로 |
|
||||
| `$bucket_name` | string | GCS 버킷 이름 |
|
||||
| `$object_name` | string | GCS에 저장될 객체 이름 (경로 포함) |
|
||||
| `$service_account_path` | string | 서비스 계정 JSON 파일 경로 |
|
||||
|
||||
### 반환값
|
||||
|
||||
- **성공**: `gs://{bucket_name}/{object_name}` 형식의 GCS URI 문자열
|
||||
- **실패**: `false`
|
||||
|
||||
### 업로드 프로세스
|
||||
|
||||
1. **서비스 계정 파일 검증**
|
||||
```php
|
||||
if (!file_exists($service_account_path)) {
|
||||
error_log('GCS 업로드 실패: 서비스 계정 파일 없음');
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
2. **OAuth 2.0 토큰 생성**
|
||||
- JWT (JSON Web Token) 생성
|
||||
- 서비스 계정 개인 키로 서명
|
||||
- Google OAuth 2.0 엔드포인트에 토큰 요청
|
||||
|
||||
3. **파일 업로드**
|
||||
- 파일 내용 읽기
|
||||
- MIME 타입 자동 감지
|
||||
- GCS REST API를 통한 업로드
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```php
|
||||
// process_consult.php에서 사용
|
||||
$gcs_object_name = 'consults/' . $tenant_id . '/' . basename($file_path);
|
||||
$gcs_uri = uploadToGCS(
|
||||
$file_path,
|
||||
$bucket_name,
|
||||
$gcs_object_name,
|
||||
$googleServiceAccountFile
|
||||
);
|
||||
|
||||
if ($gcs_uri) {
|
||||
// GCS URI를 Google STT API에 전달
|
||||
$requestBody['audio'] = ['uri' => $gcs_uri];
|
||||
} else {
|
||||
// 업로드 실패 처리
|
||||
echo json_encode(['ok' => false, 'error' => 'GCS 업로드 실패']);
|
||||
}
|
||||
```
|
||||
|
||||
### 업로드 트리거 조건
|
||||
|
||||
GCS 업로드는 다음 조건에서 자동으로 트리거됩니다:
|
||||
|
||||
1. **Google STT API 오류 발생 시**
|
||||
- 오류 메시지에 "GCS URI" 또는 "duration limit" 키워드 포함
|
||||
- 오류 메시지에 "Inline audio exceeds" 포함
|
||||
|
||||
2. **파일 크기 제한**
|
||||
- 인라인 오디오 제한 (약 10MB) 초과 시
|
||||
|
||||
---
|
||||
|
||||
## GCS 삭제 로직
|
||||
|
||||
### 함수 시그니처
|
||||
|
||||
```php
|
||||
function deleteFromGCS($bucket_name, $object_name, $service_account_path = null)
|
||||
```
|
||||
|
||||
### 매개변수
|
||||
|
||||
| 매개변수 | 타입 | 설명 |
|
||||
|---------|------|------|
|
||||
| `$bucket_name` | string | GCS 버킷 이름 |
|
||||
| `$object_name` | string | 삭제할 객체 이름 (경로 포함) |
|
||||
| `$service_account_path` | string | 서비스 계정 JSON 파일 경로 (기본값: `/apikey/google_service_account.json`) |
|
||||
|
||||
### 반환값
|
||||
|
||||
- **성공**: `true`
|
||||
- **실패**: `false`
|
||||
|
||||
### 삭제 프로세스
|
||||
|
||||
1. **GCS URI 파싱**
|
||||
```php
|
||||
// DB에 저장된 경로가 gs:// 형식인지 확인
|
||||
if (strpos($consult['audio_file_path'], 'gs://') === 0) {
|
||||
if (preg_match('#gs://([^/]+)/(.+)#', $gcs_uri, $matches)) {
|
||||
$bucket_name = $matches[1];
|
||||
$object_name = $matches[2];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **OAuth 2.0 토큰 생성** (업로드와 동일)
|
||||
|
||||
3. **GCS REST API를 통한 삭제**
|
||||
- DELETE 메서드 사용
|
||||
- HTTP 204 (No Content) 또는 404 (Not Found) 응답 시 성공으로 간주
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```php
|
||||
// delete_consult.php에서 사용
|
||||
if (strpos($consult['audio_file_path'], 'gs://') === 0) {
|
||||
$gcs_uri = $consult['audio_file_path'];
|
||||
if (preg_match('#gs://([^/]+)/(.+)#', $gcs_uri, $matches)) {
|
||||
$bucket_name = $matches[1];
|
||||
$object_name = $matches[2];
|
||||
|
||||
$gcs_deleted = deleteFromGCS($bucket_name, $object_name);
|
||||
if (!$gcs_deleted) {
|
||||
error_log('GCS 파일 삭제 실패: ' . $gcs_uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 인증 메커니즘
|
||||
|
||||
### OAuth 2.0 JWT Bearer 토큰
|
||||
|
||||
GCS API 접근을 위해 OAuth 2.0 JWT Bearer 토큰 방식을 사용합니다.
|
||||
|
||||
### JWT 생성 단계
|
||||
|
||||
1. **JWT 헤더 생성**
|
||||
```php
|
||||
$jwtHeader = base64_encode(json_encode([
|
||||
'alg' => 'RS256',
|
||||
'typ' => 'JWT'
|
||||
]));
|
||||
```
|
||||
|
||||
2. **JWT 클레임 생성**
|
||||
```php
|
||||
$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, // 1시간 유효
|
||||
'iat' => $now
|
||||
]));
|
||||
```
|
||||
|
||||
3. **서명 생성**
|
||||
```php
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature);
|
||||
```
|
||||
|
||||
4. **액세스 토큰 요청**
|
||||
```php
|
||||
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
]));
|
||||
```
|
||||
|
||||
### 필요한 스코프
|
||||
|
||||
- **업로드/삭제**: `https://www.googleapis.com/auth/devstorage.full_control`
|
||||
- **읽기 전용**: `https://www.googleapis.com/auth/devstorage.read_only`
|
||||
|
||||
---
|
||||
|
||||
## 사용 시나리오
|
||||
|
||||
### 시나리오 1: 일반적인 오디오 업로드
|
||||
|
||||
1. 사용자가 오디오 파일을 녹음
|
||||
2. `process_consult.php`로 파일 업로드
|
||||
3. 로컬 서버에 임시 저장
|
||||
4. Google STT API에 인라인 오디오로 요청
|
||||
5. **성공**: 텍스트 변환 완료
|
||||
6. **실패 (GCS 필요)**: GCS에 업로드 후 재시도
|
||||
|
||||
### 시나리오 2: 대용량 오디오 파일
|
||||
|
||||
1. 오디오 파일이 10MB 이상이거나 긴 녹음
|
||||
2. Google STT API가 "GCS URI required" 오류 반환
|
||||
3. `uploadToGCS()` 함수 자동 호출
|
||||
4. GCS에 업로드 후 URI 획득
|
||||
5. GCS URI로 STT API 재요청
|
||||
|
||||
### 시나리오 3: 파일 삭제
|
||||
|
||||
1. 사용자가 업무협의록 삭제 요청
|
||||
2. `delete_consult.php` 실행
|
||||
3. DB에서 `audio_file_path` 조회
|
||||
4. 경로가 `gs://`로 시작하면 GCS 파일 삭제
|
||||
5. 로컬 서버 파일 삭제
|
||||
6. DB 레코드 삭제
|
||||
|
||||
---
|
||||
|
||||
## 에러 처리
|
||||
|
||||
### 일반적인 오류 및 해결 방법
|
||||
|
||||
#### 1. 서비스 계정 파일 없음
|
||||
```
|
||||
오류: GCS 업로드 실패: 서비스 계정 파일 없음
|
||||
해결: /apikey/google_service_account.json 파일이 존재하는지 확인
|
||||
```
|
||||
|
||||
#### 2. OAuth 토큰 요청 실패
|
||||
```
|
||||
오류: GCS 업로드 실패: OAuth 토큰 요청 실패 (HTTP 401)
|
||||
해결:
|
||||
- 서비스 계정 JSON 파일 형식 확인
|
||||
- 개인 키가 올바른지 확인
|
||||
- 서비스 계정이 활성화되어 있는지 확인
|
||||
```
|
||||
|
||||
#### 3. 버킷 접근 권한 없음
|
||||
```
|
||||
오류: GCS 업로드 실패 (HTTP 403)
|
||||
해결:
|
||||
- 서비스 계정에 Storage Object Admin 권한 부여
|
||||
- 버킷 이름이 올바른지 확인
|
||||
```
|
||||
|
||||
#### 4. 버킷이 존재하지 않음
|
||||
```
|
||||
오류: GCS 업로드 실패 (HTTP 404)
|
||||
해결:
|
||||
- GCS 버킷이 생성되어 있는지 확인
|
||||
- 버킷 이름이 올바른지 확인
|
||||
```
|
||||
|
||||
### 로깅
|
||||
|
||||
모든 GCS 관련 오류는 `error_log()` 함수를 통해 기록됩니다:
|
||||
|
||||
```php
|
||||
error_log('GCS 업로드 실패: 서비스 계정 파일 없음');
|
||||
error_log('GCS 업로드 실패 (HTTP ' . $code . '): ' . $response);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 코드 예제
|
||||
|
||||
### 전체 업로드 플로우 예제
|
||||
|
||||
```php
|
||||
<?php
|
||||
// 1. 설정 파일 읽기
|
||||
$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;
|
||||
}
|
||||
|
||||
// 2. GCS 업로드 필요 여부 확인
|
||||
if ($bucket_name && $needs_gcs) {
|
||||
// 3. GCS 객체 이름 생성
|
||||
$gcs_object_name = 'consults/' . $tenant_id . '/' . basename($file_path);
|
||||
|
||||
// 4. GCS 업로드 실행
|
||||
$gcs_uri = uploadToGCS(
|
||||
$file_path,
|
||||
$bucket_name,
|
||||
$gcs_object_name,
|
||||
$googleServiceAccountFile
|
||||
);
|
||||
|
||||
// 5. 업로드 결과 확인
|
||||
if ($gcs_uri) {
|
||||
// 성공: GCS URI 사용
|
||||
$requestBody['audio'] = ['uri' => $gcs_uri];
|
||||
} else {
|
||||
// 실패: 오류 응답
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'GCS 업로드 실패',
|
||||
'details' => 'GCS 설정을 확인해주세요.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
?>
|
||||
```
|
||||
|
||||
### 전체 삭제 플로우 예제
|
||||
|
||||
```php
|
||||
<?php
|
||||
// 1. DB에서 파일 경로 조회
|
||||
$sql = "SELECT audio_file_path FROM consult_logs WHERE id = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$consult_id]);
|
||||
$consult = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// 2. 로컬 파일 삭제
|
||||
if (!empty($consult['audio_file_path'])) {
|
||||
$file_path = $_SERVER['DOCUMENT_ROOT'] . $consult['audio_file_path'];
|
||||
if (file_exists($file_path)) {
|
||||
@unlink($file_path);
|
||||
}
|
||||
|
||||
// 3. GCS 파일 삭제 (GCS URI인 경우)
|
||||
if (strpos($consult['audio_file_path'], 'gs://') === 0) {
|
||||
$gcs_uri = $consult['audio_file_path'];
|
||||
if (preg_match('#gs://([^/]+)/(.+)#', $gcs_uri, $matches)) {
|
||||
$bucket_name = $matches[1];
|
||||
$object_name = $matches[2];
|
||||
|
||||
$gcs_deleted = deleteFromGCS($bucket_name, $object_name);
|
||||
if (!$gcs_deleted) {
|
||||
error_log('GCS 파일 삭제 실패: ' . $gcs_uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. DB 레코드 삭제
|
||||
$delete_sql = "DELETE FROM consult_logs WHERE id = ?";
|
||||
$delete_stmt = $pdo->prepare($delete_sql);
|
||||
$delete_stmt->execute([$consult_id]);
|
||||
?>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GCS 객체 이름 규칙
|
||||
|
||||
### 경로 구조
|
||||
|
||||
```
|
||||
consults/{tenant_id}/{filename}
|
||||
```
|
||||
|
||||
**예시**:
|
||||
```
|
||||
consults/user123/20241201_143022_abc123def456.webm
|
||||
```
|
||||
|
||||
### 파일명 형식
|
||||
|
||||
```php
|
||||
$file_name = date('Ymd_His') . "_" . uniqid() . ".webm";
|
||||
```
|
||||
|
||||
- `Ymd_His`: 년월일_시분초 (예: 20241201_143022)
|
||||
- `uniqid()`: 고유 ID
|
||||
- 확장자: `.webm` (기본값)
|
||||
|
||||
---
|
||||
|
||||
## 실전 적용 예제: 매니저 상담 음성 저장 로직
|
||||
|
||||
이 섹션에서는 문서의 내용을 기반으로 **매니저들의 상담 음성을 저장하는 로직**을 개발하는 방법을 설명합니다.
|
||||
|
||||
### 개발 전략
|
||||
|
||||
기존 `voice_ai_cnslt` 모듈의 로직을 재사용하되, 다음 항목만 변경하면 됩니다:
|
||||
|
||||
1. **업로드 디렉토리 경로**
|
||||
2. **GCS 객체 경로 구조**
|
||||
3. **DB 테이블명 및 필드**
|
||||
4. **파일명 접두사 (선택사항)**
|
||||
|
||||
### 1. 파일 구조 설계
|
||||
|
||||
#### 업로드 디렉토리
|
||||
```php
|
||||
// 기존: /uploads/consults/{tenant_id}/
|
||||
// 매니저 상담: /uploads/manager_consultations/{manager_id}/
|
||||
$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/manager_consultations/" . $manager_id . "/";
|
||||
```
|
||||
|
||||
#### GCS 객체 경로
|
||||
```php
|
||||
// 기존: consults/{tenant_id}/{filename}
|
||||
// 매니저 상담: manager_consultations/{manager_id}/{filename}
|
||||
$gcs_object_name = 'manager_consultations/' . $manager_id . '/' . basename($file_path);
|
||||
```
|
||||
|
||||
### 2. 완전한 구현 예제
|
||||
|
||||
#### `process_manager_consultation.php` 파일
|
||||
|
||||
```php
|
||||
<?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) {
|
||||
// ... (기존 uploadToGCS 함수 코드 그대로 사용)
|
||||
// 문서의 "GCS 업로드 로직" 섹션 참고
|
||||
}
|
||||
|
||||
// 출력 버퍼 비우기
|
||||
ob_clean();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// 1. 권한 및 세션 체크
|
||||
if (!isset($user_id) || $level > 5) {
|
||||
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$manager_id = $user_id; // 또는 별도의 매니저 ID
|
||||
$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/manager_consultations/" . $manager_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. Google STT 변환 (기존 로직과 동일)
|
||||
$googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json';
|
||||
$accessToken = null;
|
||||
|
||||
// ... (OAuth 토큰 생성 로직 - 기존과 동일)
|
||||
// ... (STT API 호출 로직 - 기존과 동일)
|
||||
|
||||
// 4. GCS 업로드 (필요한 경우)
|
||||
$file_size = filesize($file_path);
|
||||
$max_inline_size = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// STT API 오류 발생 시 GCS 업로드
|
||||
if ($needs_gcs && $operation_code !== 200) {
|
||||
$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 = 'manager_consultations/' . $manager_id . '/' . basename($file_path);
|
||||
$gcs_uri = uploadToGCS($file_path, $bucket_name, $gcs_object_name, $googleServiceAccountFile);
|
||||
|
||||
if ($gcs_uri) {
|
||||
$requestBody['audio'] = ['uri' => $gcs_uri];
|
||||
// STT API 재시도...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. STT 결과 처리 (기존과 동일)
|
||||
// ... (폴링 로직, 텍스트 추출 등)
|
||||
|
||||
// 6. DB 저장 (매니저 상담 테이블)
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
if (!$pdo) {
|
||||
throw new Exception('데이터베이스 연결 실패');
|
||||
}
|
||||
|
||||
$expiry_date = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||
|
||||
// ⭐ 로컬 경로 또는 GCS URI 저장
|
||||
$web_path = "/uploads/manager_consultations/" . $manager_id . "/" . $file_name;
|
||||
// 또는 GCS URI가 있으면: $web_path = $gcs_uri;
|
||||
|
||||
// ⭐ 매니저 상담 테이블에 저장
|
||||
$sql = "INSERT INTO manager_consultations
|
||||
(manager_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
|
||||
$executeResult = $stmt->execute([
|
||||
$manager_id,
|
||||
$title,
|
||||
$web_path, // 또는 $gcs_uri
|
||||
$transcript,
|
||||
$summary,
|
||||
$expiry_date
|
||||
]);
|
||||
|
||||
$insertId = $pdo->lastInsertId();
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'id' => $insertId,
|
||||
'title' => $title,
|
||||
'message' => '매니저 상담 음성이 성공적으로 저장되었습니다.'
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('DB 저장 오류: ' . $e->getMessage());
|
||||
echo json_encode(['ok' => false, 'error' => '데이터베이스 저장 실패']);
|
||||
}
|
||||
?>
|
||||
```
|
||||
|
||||
#### `delete_manager_consultation.php` 파일
|
||||
|
||||
```php
|
||||
<?php
|
||||
ob_start();
|
||||
error_reporting(0);
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
|
||||
|
||||
// ===== GCS 삭제 함수 (기존과 동일) =====
|
||||
function deleteFromGCS($bucket_name, $object_name, $service_account_path = null) {
|
||||
// ... (기존 deleteFromGCS 함수 코드 그대로 사용)
|
||||
// 문서의 "GCS 삭제 로직" 섹션 참고
|
||||
}
|
||||
|
||||
ob_clean();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// 권한 체크
|
||||
if (!isset($user_id) || $level > 5) {
|
||||
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$consultation_id = isset($_POST['id']) ? intval($_POST['id']) : 0;
|
||||
$manager_id = $user_id;
|
||||
|
||||
if ($consultation_id <= 0) {
|
||||
echo json_encode(['ok' => false, 'error' => '잘못된 요청입니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
|
||||
// ⭐ 매니저 상담 테이블에서 조회
|
||||
$sql = "SELECT audio_file_path FROM manager_consultations WHERE id = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$consultation_id]);
|
||||
$consultation = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$consultation) {
|
||||
echo json_encode(['ok' => false, 'error' => '상담 기록을 찾을 수 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1. 로컬 파일 삭제
|
||||
if (!empty($consultation['audio_file_path'])) {
|
||||
$file_path = $_SERVER['DOCUMENT_ROOT'] . $consultation['audio_file_path'];
|
||||
if (file_exists($file_path)) {
|
||||
@unlink($file_path);
|
||||
}
|
||||
|
||||
// 2. GCS 파일 삭제 (GCS URI인 경우)
|
||||
if (strpos($consultation['audio_file_path'], 'gs://') === 0) {
|
||||
$gcs_uri = $consultation['audio_file_path'];
|
||||
if (preg_match('#gs://([^/]+)/(.+)#', $gcs_uri, $matches)) {
|
||||
$bucket_name = $matches[1];
|
||||
$object_name = $matches[2];
|
||||
|
||||
$gcs_deleted = deleteFromGCS($bucket_name, $object_name);
|
||||
if (!$gcs_deleted) {
|
||||
error_log('GCS 파일 삭제 실패: ' . $gcs_uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. DB에서 삭제
|
||||
$delete_sql = "DELETE FROM manager_consultations WHERE id = ?";
|
||||
$delete_stmt = $pdo->prepare($delete_sql);
|
||||
$delete_stmt->execute([$consultation_id]);
|
||||
|
||||
echo json_encode(['ok' => true, 'message' => '상담 기록이 삭제되었습니다.']);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('삭제 오류: ' . $e->getMessage());
|
||||
echo json_encode(['ok' => false, 'error' => '삭제 중 오류가 발생했습니다.']);
|
||||
}
|
||||
?>
|
||||
```
|
||||
|
||||
### 3. DB 스키마 예제
|
||||
|
||||
```sql
|
||||
-- 매니저 상담 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS manager_consultations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
manager_id VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
audio_file_path TEXT,
|
||||
transcript_text TEXT,
|
||||
summary_text TEXT,
|
||||
file_expiry_date DATETIME,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_manager_id (manager_id),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_expiry_date (file_expiry_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
### 4. 주요 변경 사항 체크리스트
|
||||
|
||||
매니저 상담 음성 저장 로직을 개발할 때 다음 항목을 변경/확인하세요:
|
||||
|
||||
- [ ] **업로드 디렉토리**: `/uploads/manager_consultations/{manager_id}/`
|
||||
- [ ] **GCS 객체 경로**: `manager_consultations/{manager_id}/{filename}`
|
||||
- [ ] **DB 테이블명**: `manager_consultations`
|
||||
- [ ] **DB 필드명**: `manager_id` (기존 `tenant_id` 대신)
|
||||
- [ ] **파일명 접두사**: 필요시 `manager_` 접두사 추가
|
||||
- [ ] **권한 체크**: 매니저 권한 레벨 확인
|
||||
- [ ] **만료 정책**: 보관 기간 설정 (예: 7일, 30일 등)
|
||||
|
||||
### 5. 재사용 가능한 함수
|
||||
|
||||
다음 함수들은 **그대로 재사용** 가능합니다:
|
||||
|
||||
1. ✅ `uploadToGCS()` - GCS 업로드 함수
|
||||
2. ✅ `deleteFromGCS()` - GCS 삭제 함수
|
||||
3. ✅ OAuth 2.0 토큰 생성 로직
|
||||
4. ✅ Google STT API 호출 로직
|
||||
5. ✅ 에러 처리 로직
|
||||
|
||||
**변경이 필요한 부분만**:
|
||||
- 디렉토리 경로
|
||||
- GCS 객체 경로
|
||||
- DB 테이블/필드명
|
||||
- 비즈니스 로직 (제목 생성, 요약 등)
|
||||
|
||||
### 6. 개발 순서
|
||||
|
||||
1. **DB 테이블 생성** (`manager_consultations`)
|
||||
2. **업로드 디렉토리 생성** (`/uploads/manager_consultations/`)
|
||||
3. **`uploadToGCS()` 함수 복사** (기존 코드 그대로)
|
||||
4. **`deleteFromGCS()` 함수 복사** (기존 코드 그대로)
|
||||
5. **`process_manager_consultation.php` 작성** (경로만 변경)
|
||||
6. **`delete_manager_consultation.php` 작성** (경로만 변경)
|
||||
7. **프론트엔드 연동** (기존 `index.php` 참고)
|
||||
|
||||
### 7. 테스트 체크리스트
|
||||
|
||||
- [ ] 작은 파일 (< 10MB) 업로드 테스트
|
||||
- [ ] 큰 파일 (> 10MB) GCS 업로드 테스트
|
||||
- [ ] STT 변환 성공 확인
|
||||
- [ ] DB 저장 확인
|
||||
- [ ] GCS URI 저장 확인
|
||||
- [ ] 파일 삭제 (로컬 + GCS) 확인
|
||||
- [ ] 만료 파일 정리 확인
|
||||
|
||||
---
|
||||
|
||||
## 보안 고려사항
|
||||
|
||||
### 1. 서비스 계정 파일 보안
|
||||
|
||||
- 서비스 계정 JSON 파일은 웹에서 직접 접근 불가능한 위치에 저장
|
||||
- 파일 권한 설정: `chmod 600 google_service_account.json`
|
||||
- `.htaccess` 또는 웹 서버 설정으로 `/apikey/` 디렉토리 접근 차단
|
||||
|
||||
### 2. 버킷 접근 제어
|
||||
|
||||
- 최소 권한 원칙 적용
|
||||
- 필요한 경우 버킷 레벨 IAM 정책 설정
|
||||
- 객체 레벨 ACL 설정 고려
|
||||
|
||||
### 3. 파일 경로 검증
|
||||
|
||||
- 사용자 입력값 검증
|
||||
- 경로 조작 공격 방지
|
||||
- SQL 인젝션 방지 (PDO Prepared Statement 사용)
|
||||
|
||||
---
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 1. 토큰 캐싱
|
||||
|
||||
현재 구현은 매번 새 토큰을 생성합니다. 성능 최적화를 위해 토큰 캐싱을 고려할 수 있습니다:
|
||||
|
||||
```php
|
||||
// 토큰 캐싱 예시 (구현 필요)
|
||||
$token_cache_file = sys_get_temp_dir() . '/gcs_token_' . md5($service_account_path) . '.json';
|
||||
if (file_exists($token_cache_file)) {
|
||||
$cached_token = json_decode(file_get_contents($token_cache_file), true);
|
||||
if ($cached_token['expires_at'] > time()) {
|
||||
$accessToken = $cached_token['access_token'];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 비동기 업로드
|
||||
|
||||
대용량 파일의 경우 비동기 업로드를 고려할 수 있습니다.
|
||||
|
||||
### 3. 청크 업로드
|
||||
|
||||
매우 큰 파일의 경우 멀티파트 업로드를 사용할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 테스트 방법
|
||||
|
||||
### 1. 로컬 테스트
|
||||
|
||||
```php
|
||||
// 테스트 스크립트
|
||||
$test_file = '/path/to/test.webm';
|
||||
$bucket_name = 'test-bucket';
|
||||
$object_name = 'test/upload.webm';
|
||||
$service_account = '/path/to/google_service_account.json';
|
||||
|
||||
$result = uploadToGCS($test_file, $bucket_name, $object_name, $service_account);
|
||||
if ($result) {
|
||||
echo "업로드 성공: " . $result . "\n";
|
||||
} else {
|
||||
echo "업로드 실패\n";
|
||||
}
|
||||
```
|
||||
|
||||
### 2. GCS 콘솔 확인
|
||||
|
||||
- [Google Cloud Console](https://console.cloud.google.com/storage)에서 업로드된 파일 확인
|
||||
- 객체 메타데이터 및 접근 권한 확인
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [Google Cloud Storage REST API 문서](https://cloud.google.com/storage/docs/json_api)
|
||||
- [OAuth 2.0 for Service Accounts](https://developers.google.com/identity/protocols/oauth2/service-account)
|
||||
- [JWT (JSON Web Token) 표준](https://tools.ietf.org/html/rfc7519)
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 버전 | 변경 내용 | 작성자 |
|
||||
|------|------|----------|--------|
|
||||
| 2024-12-01 | 1.0 | 초기 문서 작성 | - |
|
||||
|
||||
---
|
||||
|
||||
## 문의 및 지원
|
||||
|
||||
GCS 저장 로직 관련 문의사항이나 개선 제안은 개발팀에 문의해주세요.
|
||||
|
||||
25
voice_ai_cnslt/cleanup_cron.php
Normal file
25
voice_ai_cnslt/cleanup_cron.php
Normal 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 consult_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 consult_logs SET audio_file_path = NULL WHERE id = ?";
|
||||
$pdo->prepare($update_sql)->execute([$file['id']]);
|
||||
|
||||
echo "Deleted: " . $full_path . "\n";
|
||||
}
|
||||
?>
|
||||
172
voice_ai_cnslt/delete_consult.php
Normal file
172
voice_ai_cnslt/delete_consult.php
Normal 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 확인
|
||||
$consult_id = isset($_POST['id']) ? intval($_POST['id']) : 0;
|
||||
$tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용
|
||||
|
||||
if ($consult_id <= 0) {
|
||||
echo json_encode(['ok' => false, 'error' => '잘못된 요청입니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
|
||||
// MVP 단계: 모든 사용자가 모든 데이터를 볼 수 있도록 tenant_id 조건 제거
|
||||
$sql = "SELECT audio_file_path FROM consult_logs WHERE id = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$consult_id]);
|
||||
$consult = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$consult) {
|
||||
echo json_encode(['ok' => false, 'error' => '업무협의록을 찾을 수 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1. 서버 파일 삭제
|
||||
if (!empty($consult['audio_file_path'])) {
|
||||
$file_path = $_SERVER['DOCUMENT_ROOT'] . $consult['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($consult['audio_file_path'], 'gs://') === 0) {
|
||||
// GCS URI 파싱
|
||||
$gcs_uri = $consult['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 consult_logs WHERE id = ?";
|
||||
$delete_stmt = $pdo->prepare($delete_sql);
|
||||
$delete_stmt->execute([$consult_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_cnslt/download_audio.php
Normal file
190
voice_ai_cnslt/download_audio.php
Normal 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 확인
|
||||
$consult_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
|
||||
$tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용
|
||||
|
||||
if ($consult_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 consult_logs
|
||||
WHERE id = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$consult_id]);
|
||||
$consult = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$consult || empty($consult['audio_file_path'])) {
|
||||
header('HTTP/1.0 404 Not Found');
|
||||
die('오디오 파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 파일 경로 구성
|
||||
$file_path = $_SERVER['DOCUMENT_ROOT'] . $consult['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 경로: ' . $consult['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('//', '/', $consult['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 = $consult['title'] ?: '업무협의녹음';
|
||||
$date = date('Ymd_His', strtotime($consult['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);
|
||||
// 파일명 처리: "업무협의록"을 "Consult"로 변환하여 브라우저 호환성 확보
|
||||
$ascii_filename = str_replace('업무협의록', 'Consult', $download_filename);
|
||||
// "Consult"가 중복되지 않도록 처리 (제목에 이미 "Consult"가 포함된 경우)
|
||||
$ascii_filename = preg_replace('/Consult[_\s]*Consult/i', 'Consult', $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_cnslt/get_consult.php
Normal file
49
voice_ai_cnslt/get_consult.php
Normal 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;
|
||||
}
|
||||
|
||||
$consult_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
|
||||
$tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용
|
||||
|
||||
if ($consult_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 consult_logs
|
||||
WHERE id = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$consult_id]);
|
||||
$consult = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$consult) {
|
||||
echo json_encode(['ok' => false, 'error' => '업무협의록을 찾을 수 없습니다.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true, 'data' => $consult]);
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['ok' => false, 'error' => 'DB 오류: ' . $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
|
||||
1451
voice_ai_cnslt/index.php
Normal file
1451
voice_ai_cnslt/index.php
Normal file
File diff suppressed because it is too large
Load Diff
808
voice_ai_cnslt/process_consult.php
Normal file
808
voice_ai_cnslt/process_consult.php
Normal file
@@ -0,0 +1,808 @@
|
||||
<?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/consults/" . $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 = 'consults/' . $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주일 보관 설정
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
if (!$pdo) {
|
||||
throw new Exception('데이터베이스 연결 실패');
|
||||
}
|
||||
|
||||
$expiry_date = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||
$web_path = "/uploads/consults/" . $tenant_id . "/" . $file_name;
|
||||
|
||||
// SQL 인젝션 방지는 PDO Prepared Statement 사용 권장
|
||||
$sql = "INSERT INTO consult_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
|
||||
if (!$stmt) {
|
||||
$errorInfo = $pdo->errorInfo();
|
||||
throw new Exception('SQL 준비 실패: ' . ($errorInfo[2] ?? '알 수 없는 오류'));
|
||||
}
|
||||
|
||||
$executeResult = $stmt->execute([$tenant_id, $tenant_id, $title, $web_path, $transcript, $summary, $expiry_date]);
|
||||
|
||||
if (!$executeResult) {
|
||||
$errorInfo = $stmt->errorInfo();
|
||||
throw new Exception('SQL 실행 실패: ' . ($errorInfo[2] ?? '알 수 없는 오류'));
|
||||
}
|
||||
|
||||
$insertId = $pdo->lastInsertId();
|
||||
|
||||
if (!$insertId) {
|
||||
throw new Exception('INSERT ID를 가져올 수 없습니다. 테이블이 존재하지 않거나 AUTO_INCREMENT가 설정되지 않았을 수 있습니다.');
|
||||
}
|
||||
|
||||
// 성공 로그
|
||||
error_log('업무협의록 저장 성공 - ID: ' . $insertId . ', 제목: ' . $title);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'id' => $insertId,
|
||||
'title' => $title,
|
||||
'transcript' => $transcript,
|
||||
'summary' => $summary,
|
||||
'message' => '업무협의록이 성공적으로 저장되었습니다.',
|
||||
'db_info' => [
|
||||
'insert_id' => $insertId,
|
||||
'file_path' => $web_path,
|
||||
'expiry_date' => $expiry_date
|
||||
]
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('DB 저장 오류 (PDOException): ' . $e->getMessage());
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => '데이터베이스 저장 실패',
|
||||
'details' => $e->getMessage(),
|
||||
'error_code' => $e->getCode(),
|
||||
'help' => 'consult_logs 테이블이 존재하는지 확인하세요. db_schema.sql 파일을 실행하여 테이블을 생성하세요.'
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('DB 저장 오류 (Exception): ' . $e->getMessage());
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => '데이터베이스 저장 실패',
|
||||
'details' => $e->getMessage(),
|
||||
'help' => 'consult_logs 테이블이 존재하는지 확인하세요. db_schema.sql 파일을 실행하여 테이블을 생성하세요.'
|
||||
]);
|
||||
}
|
||||
?>
|
||||
254
voice_ai_cnslt/record_dev.md
Normal file
254
voice_ai_cnslt/record_dev.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# 음성 인식 민감도 향상 개발 문서
|
||||
|
||||
## 개요
|
||||
음성 녹음 기능에서 작은 소리도 텍스트로 변환되도록 음성 인식 민감도를 향상시키는 개선 작업을 수행했습니다.
|
||||
|
||||
**개선 일자**: 2024년
|
||||
**대상 파일**: `voice_ai_cnslt/index.php`
|
||||
|
||||
## 문제점
|
||||
기존에는 마이크에 가까이서 말해야만 텍스트로 변환되는 문제가 있었습니다. 작은 소리나 멀리서 말한 경우 인식이 제대로 되지 않았습니다.
|
||||
|
||||
## 개선 내용
|
||||
|
||||
### 1. 오디오 증폭 (Audio Amplification)
|
||||
|
||||
#### 1.1 GainNode를 이용한 오디오 증폭
|
||||
- **위치**: `startAudioStream()` 함수
|
||||
- **기술**: Web Audio API의 `GainNode` 사용
|
||||
- **증폭 배수**: 3.0배 (기본값)
|
||||
- **목적**: 작은 소리도 인식할 수 있도록 오디오 신호 증폭
|
||||
|
||||
```javascript
|
||||
// GainNode 생성 및 증폭 설정
|
||||
gainNode = audioContext.createGain();
|
||||
gainNode.gain.value = 3.0; // 3배 증폭
|
||||
|
||||
// 오디오 체인: Source -> Gain -> Analyser
|
||||
audioSource.connect(gainNode);
|
||||
analyser = audioContext.createAnalyser();
|
||||
gainNode.connect(analyser);
|
||||
```
|
||||
|
||||
#### 1.2 변수 추가
|
||||
- `gainNode`: 오디오 증폭을 위한 GainNode 객체
|
||||
- `audioSource`: 오디오 소스 객체
|
||||
|
||||
### 2. 마이크 제약 조건 최적화
|
||||
|
||||
#### 2.1 오디오 제약 조건 설정
|
||||
- **위치**: `startAudioStream()` 함수 내 `getUserMedia()` 호출
|
||||
- **목적**: 작은 소리 감지 향상을 위한 마이크 설정 최적화
|
||||
|
||||
```javascript
|
||||
const audioConstraints = {
|
||||
audio: {
|
||||
echoCancellation: false, // 에코 캔슬 비활성화
|
||||
noiseSuppression: false, // 노이즈 억제 비활성화
|
||||
autoGainControl: true, // 자동 게인 제어 활성화
|
||||
sampleRate: 48000, // 높은 샘플레이트
|
||||
channelCount: 1 // 모노 채널
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 2.2 설정 상세 설명
|
||||
|
||||
| 설정 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| `echoCancellation` | `false` | 에코 캔슬을 비활성화하여 작은 소리도 감지하도록 함 |
|
||||
| `noiseSuppression` | `false` | 노이즈 억제를 비활성화하여 작은 소리도 감지하도록 함 |
|
||||
| `autoGainControl` | `true` | 자동 게인 제어를 활성화하여 입력 신호를 자동으로 조정 |
|
||||
| `sampleRate` | `48000` | 높은 샘플레이트로 더 정확한 오디오 캡처 |
|
||||
| `channelCount` | `1` | 모노 채널 사용 |
|
||||
|
||||
**주의사항**:
|
||||
- `echoCancellation: false`와 `noiseSuppression: false`는 작은 소리 감지에는 도움이 되지만, 주변 소음이 많은 환경에서는 노이즈가 증가할 수 있습니다.
|
||||
- 필요에 따라 환경에 맞게 조정이 필요할 수 있습니다.
|
||||
|
||||
### 3. Web Speech API 설정 최적화
|
||||
|
||||
#### 3.1 maxAlternatives 설정
|
||||
- **위치**: `initSpeechRecognition()` 함수
|
||||
- **목적**: 더 많은 대안 결과를 허용하여 인식 정확도 향상
|
||||
|
||||
```javascript
|
||||
recognition.maxAlternatives = 3; // 더 많은 대안 결과 허용
|
||||
```
|
||||
|
||||
#### 3.2 기존 설정 유지
|
||||
- `lang: 'ko-KR'`: 한국어 인식
|
||||
- `continuous: true`: 연속 인식 모드
|
||||
- `interimResults: true`: 중간 결과 표시
|
||||
|
||||
### 4. 에러 처리 개선
|
||||
|
||||
#### 4.1 자동 재시작 로직 추가
|
||||
- **위치**: `setupRecognitionHandlers()` 함수 내 `recognition.onerror` 핸들러
|
||||
- **목적**: 일시적인 오류 발생 시 자동으로 재시작하여 인식 지속성 향상
|
||||
|
||||
#### 4.2 처리하는 오류 유형
|
||||
|
||||
**1. `no-speech` 오류**
|
||||
```javascript
|
||||
if (event.error === 'no-speech') {
|
||||
// 음성이 감지되지 않음 - 자동 재시작 (작은 소리도 감지하도록 지속 시도)
|
||||
if (isRecording) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (isRecording && !isRecognitionActive) {
|
||||
recognition.start();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Recognition restart after no-speech:', e);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**2. `network` 오류**
|
||||
```javascript
|
||||
if (event.error === 'network') {
|
||||
// 네트워크 오류 시 자동 재시도
|
||||
if (isRecording) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (isRecording && !isRecognitionActive) {
|
||||
recognition.start();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Recognition restart after network error:', e);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 리소스 정리 개선
|
||||
|
||||
#### 5.1 오디오 노드 연결 해제
|
||||
- **위치**: `stopAudioStream()` 함수
|
||||
- **목적**: 메모리 누수 방지 및 리소스 정리
|
||||
|
||||
```javascript
|
||||
// 오디오 노드 연결 해제
|
||||
if (audioSource) {
|
||||
try {
|
||||
audioSource.disconnect();
|
||||
} catch (e) {
|
||||
console.log('Audio source disconnect:', e);
|
||||
}
|
||||
audioSource = null;
|
||||
}
|
||||
|
||||
if (gainNode) {
|
||||
try {
|
||||
gainNode.disconnect();
|
||||
} catch (e) {
|
||||
console.log('Gain node disconnect:', e);
|
||||
}
|
||||
gainNode = null;
|
||||
}
|
||||
|
||||
if (analyser) {
|
||||
try {
|
||||
analyser.disconnect();
|
||||
} catch (e) {
|
||||
console.log('Analyser disconnect:', e);
|
||||
}
|
||||
analyser = null;
|
||||
}
|
||||
```
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **Web Audio API**: 오디오 증폭 및 처리
|
||||
- **Web Speech API**: 음성 인식
|
||||
- **MediaRecorder API**: 오디오 녹음
|
||||
- **getUserMedia API**: 마이크 접근
|
||||
|
||||
## 테스트 방법
|
||||
|
||||
1. **기본 테스트**
|
||||
- 마이크에서 약 30cm 이상 떨어진 거리에서 말하기
|
||||
- 작은 목소리로 말하기
|
||||
- 정상적으로 텍스트로 변환되는지 확인
|
||||
|
||||
2. **민감도 조정 테스트**
|
||||
- 증폭 배수를 조정하여 테스트
|
||||
- `gainNode.gain.value` 값을 변경 (3.0 → 5.0 또는 7.0)
|
||||
- 너무 높은 값은 오디오 왜곡을 유발할 수 있으므로 주의
|
||||
|
||||
3. **환경별 테스트**
|
||||
- 조용한 환경에서 테스트
|
||||
- 소음이 있는 환경에서 테스트
|
||||
- 필요시 `echoCancellation`과 `noiseSuppression` 설정 조정
|
||||
|
||||
## 민감도 조정 가이드
|
||||
|
||||
### 증폭 배수 조정
|
||||
```javascript
|
||||
// 현재 설정 (3.0배)
|
||||
gainNode.gain.value = 3.0;
|
||||
|
||||
// 더 높은 민감도가 필요한 경우
|
||||
gainNode.gain.value = 5.0; // 5배 증폭
|
||||
gainNode.gain.value = 7.0; // 7배 증폭 (최대 권장값)
|
||||
|
||||
// 주의: 10 이상의 값은 오디오 왜곡을 유발할 수 있음
|
||||
```
|
||||
|
||||
### 마이크 제약 조건 조정
|
||||
```javascript
|
||||
// 조용한 환경에서 소음이 많은 경우
|
||||
audio: {
|
||||
echoCancellation: true, // 에코 캔슬 활성화
|
||||
noiseSuppression: true, // 노이즈 억제 활성화
|
||||
autoGainControl: true,
|
||||
sampleRate: 48000,
|
||||
channelCount: 1
|
||||
}
|
||||
|
||||
// 작은 소리 감지가 최우선인 경우 (현재 설정)
|
||||
audio: {
|
||||
echoCancellation: false, // 에코 캔슬 비활성화
|
||||
noiseSuppression: false, // 노이즈 억제 비활성화
|
||||
autoGainControl: true,
|
||||
sampleRate: 48000,
|
||||
channelCount: 1
|
||||
}
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **오디오 왜곡**: 증폭 배수가 너무 높으면 오디오가 왜곡될 수 있습니다. 권장 범위는 3.0 ~ 7.0입니다.
|
||||
|
||||
2. **노이즈 증가**: `echoCancellation`과 `noiseSuppression`을 비활성화하면 주변 소음이 증가할 수 있습니다. 환경에 따라 조정이 필요합니다.
|
||||
|
||||
3. **브라우저 호환성**:
|
||||
- Chrome 브라우저에서 최적의 성능을 보입니다
|
||||
- 다른 브라우저에서는 일부 기능이 제한될 수 있습니다
|
||||
|
||||
4. **마이크 권한**: 사용자가 마이크 권한을 허용해야 합니다.
|
||||
|
||||
## 향후 개선 방향
|
||||
|
||||
1. **동적 민감도 조정**: 사용자가 UI에서 민감도를 조정할 수 있는 슬라이더 추가
|
||||
2. **환경 감지**: 주변 소음 레벨을 감지하여 자동으로 설정 조정
|
||||
3. **다양한 마이크 타입 지원**: 다양한 마이크 타입에 최적화된 설정 제공
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [Web Audio API - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API)
|
||||
- [Web Speech API - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API)
|
||||
- [MediaDevices.getUserMedia() - MDN](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia)
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 버전 | 변경 내용 |
|
||||
|------|------|----------|
|
||||
| 2024 | 1.0 | 음성 인식 민감도 향상 기능 추가 |
|
||||
|
||||
Reference in New Issue
Block a user