Compare commits
4 Commits
fb5a954691
...
7a0f031eba
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a0f031eba | |||
| 1d79a2989b | |||
| 5e786c26a1 | |||
| 16a54cef2d |
13
.cursorrules
Normal file
13
.cursorrules
Normal file
@@ -0,0 +1,13 @@
|
||||
# Antigravity Rules
|
||||
|
||||
## Language Preference
|
||||
- **Primary Language**: Korean (한국어)
|
||||
- All conversation, responses, task updates, and artifact documentation (plans, walkthroughs) must be provided in **Korean**.
|
||||
- Code comments should also be in Korean where appropriate.
|
||||
|
||||
## User Persona
|
||||
- Address the user politely and professionally in Korean.
|
||||
|
||||
## Environment Configuration
|
||||
- Address references for local and server environments must be based on `.env` file configurations.
|
||||
- 로컬 및 서버 환경의 주소 참조는 반드시 `.env` 파일 설정을 기준으로 해야 합니다.
|
||||
2897
0_dev_comment.php
2897
0_dev_comment.php
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
586EADF5015F45A49E3546B8271FE76B.37BE05FC9B5422D984E1830C210E1976
|
||||
comodoca.com
|
||||
@@ -1,4 +1,8 @@
|
||||
<?php
|
||||
// load .env file
|
||||
require_once __DIR__ . '/lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/.env'))->load();
|
||||
|
||||
function specialDate($inputDate) {
|
||||
// 날짜 형식을 DateTime 객체로 변환
|
||||
$date = new DateTime($inputDate);
|
||||
@@ -132,7 +136,7 @@ function calculateAnnualLeave($hireDate, $fiscalYearEnd) {
|
||||
* @return array 해당 단계의 카테고리 name 배열
|
||||
*/
|
||||
function getCategoryByName(...$names) {
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
|
||||
require_once(getenv('DOCUMENT_ROOT') . "/lib/mydb.php");
|
||||
$pdo = db_connect();
|
||||
global $DB;
|
||||
|
||||
@@ -219,7 +223,7 @@ function selectModel($selectName, $selectedValue) {
|
||||
|
||||
// $modelsList가 전역에서 아직 설정되지 않았거나, 배열이 아니거나 비어있으면 JSON 파일에서 로드합니다.
|
||||
if (!isset($modelsList) || !is_array($modelsList) || empty($modelsList)) {
|
||||
$jsonFile = $_SERVER['DOCUMENT_ROOT'].'/models/models.json';
|
||||
$jsonFile = getenv('DOCUMENT_ROOT').'/models/models.json';
|
||||
if(file_exists($jsonFile)) {
|
||||
$jsonContent = file_get_contents($jsonFile);
|
||||
$modelsList = json_decode($jsonContent, true);
|
||||
|
||||
22
config/google_speech_api.php.example
Normal file
22
config/google_speech_api.php.example
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
/**
|
||||
* Google Cloud Speech-to-Text API 설정 파일
|
||||
*
|
||||
* 사용 방법:
|
||||
* 1. 이 파일을 google_speech_api.php로 복사
|
||||
* 2. YOUR_API_KEY_HERE를 실제 Google Cloud API 키로 변경
|
||||
*
|
||||
* Google Cloud API 키 생성 방법:
|
||||
* 1. Google Cloud Console (https://console.cloud.google.com/) 접속
|
||||
* 2. 프로젝트 생성 또는 선택
|
||||
* 3. "API 및 서비스" > "라이브러리"에서 "Cloud Speech-to-Text API" 검색 및 활성화
|
||||
* 4. "API 및 서비스" > "사용자 인증 정보"에서 API 키 생성
|
||||
* 5. 생성된 API 키를 아래에 입력
|
||||
*/
|
||||
|
||||
// Google Cloud Speech-to-Text API 키
|
||||
$GOOGLE_SPEECH_API_KEY = 'YOUR_API_KEY_HERE';
|
||||
|
||||
// 또는 서비스 계정 키 파일 경로 사용 (더 안전)
|
||||
// $GOOGLE_SPEECH_SERVICE_ACCOUNT_KEY = '/path/to/service-account-key.json';
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
|
||||
require_once('barobill_account_config.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/session.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/lib/mydb.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
try {
|
||||
// 1. 로컬 DB의 company_accounts 테이블에서 계좌 정보 가져오기
|
||||
@@ -222,8 +225,12 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// 바로빌 API에 없는 로컬 계좌는 경고와 함께 추가 (사용 불가능)
|
||||
// 바로빌 API에 없는 로컬 계좌는 경고와 함께 추가
|
||||
if (!$matched) {
|
||||
$isApiError = !empty($barobillError);
|
||||
$statusText = $isApiError ? '상태 확인 불가' : '바로빌 미등록';
|
||||
$sourceText = $isApiError ? 'barobill_api_error' : 'local_db_only';
|
||||
|
||||
$allAccounts[] = [
|
||||
'id' => $localAcc['id'],
|
||||
'bankAccountNum' => $localAcc['account_num'],
|
||||
@@ -235,10 +242,11 @@ try {
|
||||
'issueDate' => '',
|
||||
'balance' => 0,
|
||||
'status' => '',
|
||||
'statusText' => '바로빌 미등록',
|
||||
'source' => 'local_db_only', // 로컬 DB에만 있음 (바로빌 API 미등록)
|
||||
'statusText' => $statusText,
|
||||
'source' => $sourceText, // 상태 확인 필요
|
||||
'hasPassword' => !empty($localAcc['account_pwd']),
|
||||
'warning' => true // 경고 표시용
|
||||
'warning' => true, // 경고 표시용
|
||||
'api_error' => $isApiError // 프론트엔드에서 구분하기 위함
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,11 +138,69 @@ try {
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
// API 호출 실패 시 (예: SoapClient 미설치, 통신 등) 로컬 DB에서 조회
|
||||
error_log('바로빌 API 호출 실패, 로컬 DB 조회 시도: ' . $result['error']);
|
||||
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
$accounts = [];
|
||||
$selectedTenantId = $_SESSION['eaccount_tenant_id'] ?? null;
|
||||
|
||||
if ($selectedTenantId) {
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
if ($pdo) {
|
||||
// 로컬 DB에서 계좌 정보 조회
|
||||
$sql = "SELECT id, company_id, bank_code, account_num, account_pwd
|
||||
FROM {$DB}.company_accounts
|
||||
WHERE company_id = ?
|
||||
ORDER BY id DESC";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$selectedTenantId]);
|
||||
$localAccounts = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($localAccounts as $acc) {
|
||||
// 은행명 변환
|
||||
$bankName = getBankName($acc['bank_code']);
|
||||
|
||||
$accounts[] = [
|
||||
'bankAccountNum' => $acc['account_num'],
|
||||
'bankCode' => $acc['bank_code'],
|
||||
'bankName' => $bankName,
|
||||
'accountName' => $bankName . ' ' . $acc['account_num'],
|
||||
'accountType' => '', // 로컬 정보 없음
|
||||
'currency' => 'KRW',
|
||||
'issueDate' => '',
|
||||
'balance' => 0, // 잔액 정보 없음
|
||||
'status' => 1, // 기본값: 사용중
|
||||
'source' => 'local_db_fallback',
|
||||
'error_message' => 'API 연동 실패로 로컬 데이터 표시'
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Exception $dbEx) {
|
||||
error_log('로컬 DB 조회 실패: ' . $dbEx->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 데이터가 있으면 성공으로 masquerade
|
||||
if (!empty($accounts)) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'accounts' => $accounts,
|
||||
'count' => count($accounts),
|
||||
'message' => '바로빌 API 연동에 실패하여 로컬 저장된 계좌 목록을 표시합니다.',
|
||||
'api_error' => $result['error']
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// 로컬 데이터도 없으면 에러 리턴
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
|
||||
@@ -14,10 +14,16 @@
|
||||
*/
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
$certKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_api_key.txt';
|
||||
$corpNumFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_test_mode.txt';
|
||||
// load .env file
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
$documentRoot = getenv('DOCUMENT_ROOT');
|
||||
$certKeyFile = $documentRoot . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $documentRoot . '/apikey/barobill_api_key.txt';
|
||||
$corpNumFile = $documentRoot . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $documentRoot . '/apikey/barobill_test_mode.txt';
|
||||
|
||||
// CERTKEY 읽기
|
||||
$barobillCertKey = '';
|
||||
@@ -82,7 +88,7 @@ if (file_exists($testModeFile)) {
|
||||
|
||||
// 바로빌 사용자 ID (계좌 사용내역 조회에 필요)
|
||||
// 빈 값이면 전체 계좌 조회, 특정 사용자만 조회하려면 사용자 ID 입력
|
||||
$barobillUserIdFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_user_id.txt';
|
||||
$barobillUserIdFile = getenv('DOCUMENT_ROOT') . '/apikey/barobill_user_id.txt';
|
||||
$barobillUserId = '';
|
||||
if (file_exists($barobillUserIdFile)) {
|
||||
$content = trim(file_get_contents($barobillUserIdFile));
|
||||
@@ -92,8 +98,8 @@ if (file_exists($barobillUserIdFile)) {
|
||||
}
|
||||
|
||||
// 테넌트별 설정 (DB에서 가져오기)
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/session.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/lib/mydb.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
$selectedTenantId = $_SESSION['eaccount_tenant_id'] ?? null;
|
||||
|
||||
@@ -183,7 +189,7 @@ if (!empty($barobillCertKey) || $isTestMode) {
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_NONE // WSDL 캐시 비활성화
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
$barobillInitError = $e->getMessage();
|
||||
error_log('바로빌 계좌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
@@ -208,7 +214,7 @@ function callBarobillAccountSOAP($method, $params = []) {
|
||||
'success' => false,
|
||||
'error' => $errorMsg,
|
||||
'error_detail' => [
|
||||
'cert_key_file' => $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt',
|
||||
'cert_key_file' => getenv('DOCUMENT_ROOT') . '/apikey/barobill_cert_key.txt',
|
||||
'soap_url' => $barobillAccountSoapUrl,
|
||||
'init_error' => $barobillInitError,
|
||||
'test_mode' => $isTestMode
|
||||
@@ -350,10 +356,10 @@ function callBarobillAccountSOAP($method, $params = []) {
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류: ' . $e->getMessage()
|
||||
'error' => 'API 호출 오류 (치명적): ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,16 @@
|
||||
*/
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
$certKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_api_key.txt';
|
||||
$corpNumFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_test_mode.txt';
|
||||
// load .env file
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
$documentRoot = getenv('DOCUMENT_ROOT');
|
||||
$certKeyFile = $documentRoot . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $documentRoot . '/apikey/barobill_api_key.txt';
|
||||
$corpNumFile = $documentRoot . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $documentRoot . '/apikey/barobill_test_mode.txt';
|
||||
|
||||
// CERTKEY 읽기
|
||||
$barobillCertKey = '';
|
||||
@@ -50,7 +56,7 @@ if (file_exists($testModeFile)) {
|
||||
|
||||
// 바로빌 사용자 ID (카드 사용내역 조회에 필요)
|
||||
// 빈 값이면 전체 카드 조회, 특정 사용자만 조회하려면 사용자 ID 입력
|
||||
$barobillUserIdFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_user_id.txt';
|
||||
$barobillUserIdFile = getenv('DOCUMENT_ROOT') . '/apikey/barobill_user_id.txt';
|
||||
$barobillUserId = '';
|
||||
if (file_exists($barobillUserIdFile)) {
|
||||
$content = trim(file_get_contents($barobillUserIdFile));
|
||||
@@ -82,7 +88,7 @@ if (!empty($barobillCertKey) || $isTestMode) {
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
error_log('바로빌 카드 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -102,7 +108,7 @@ function callBarobillCardSOAP($method, $params = []) {
|
||||
'success' => false,
|
||||
'error' => '바로빌 카드 SOAP 클라이언트가 초기화되지 않았습니다. CERTKEY를 확인하세요.',
|
||||
'error_detail' => [
|
||||
'cert_key_file' => $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt',
|
||||
'cert_key_file' => getenv('DOCUMENT_ROOT') . '/apikey/barobill_cert_key.txt',
|
||||
'soap_url' => $isTestMode ? 'https://testws.baroservice.com/CARD.asmx?WSDL' : 'https://ws.baroservice.com/CARD.asmx?WSDL'
|
||||
]
|
||||
];
|
||||
@@ -165,10 +171,10 @@ function callBarobillCardSOAP($method, $params = []) {
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류: ' . $e->getMessage()
|
||||
'error' => 'API 호출 오류 (치명적): ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,48 +9,49 @@ require_once('barobill_account_config.php');
|
||||
|
||||
// 전역 변수 접근
|
||||
global $barobillCertKey, $barobillCorpNum, $barobillUserId, $isTestMode, $barobillAccountSoapUrl;
|
||||
$documentRoot = getenv('DOCUMENT_ROOT');
|
||||
|
||||
$diagnostics = [
|
||||
'cert_key' => [
|
||||
'file_exists' => file_exists($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt'),
|
||||
'file_path' => $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt',
|
||||
'file_exists' => file_exists($documentRoot . '/apikey/barobill_cert_key.txt'),
|
||||
'file_path' => $documentRoot . '/apikey/barobill_cert_key.txt',
|
||||
'is_set' => !empty($barobillCertKey),
|
||||
'length' => strlen($barobillCertKey),
|
||||
'preview' => !empty($barobillCertKey) ? substr($barobillCertKey, 0, 8) . '...' . substr($barobillCertKey, -4) : 'NOT SET',
|
||||
'raw_content' => file_exists($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt')
|
||||
? file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt')
|
||||
'raw_content' => file_exists($documentRoot . '/apikey/barobill_cert_key.txt')
|
||||
? file_get_contents($documentRoot . '/apikey/barobill_cert_key.txt')
|
||||
: 'FILE NOT FOUND',
|
||||
'is_placeholder' => !empty($barobillCertKey) ? false : (
|
||||
file_exists($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt')
|
||||
? (strpos(file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt'), '[여기에') !== false
|
||||
|| strpos(file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt'), '바로빌 CERTKEY') !== false
|
||||
|| strpos(file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt'), '================================') !== false)
|
||||
file_exists($documentRoot . '/apikey/barobill_cert_key.txt')
|
||||
? (strpos(file_get_contents($documentRoot . '/apikey/barobill_cert_key.txt'), '[여기에') !== false
|
||||
|| strpos(file_get_contents($documentRoot . '/apikey/barobill_cert_key.txt'), '바로빌 CERTKEY') !== false
|
||||
|| strpos(file_get_contents($documentRoot . '/apikey/barobill_cert_key.txt'), '================================') !== false)
|
||||
: false
|
||||
)
|
||||
],
|
||||
'corp_num' => [
|
||||
'file_exists' => file_exists($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_corp_num.txt'),
|
||||
'file_path' => $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_corp_num.txt',
|
||||
'file_exists' => file_exists($documentRoot . '/apikey/barobill_corp_num.txt'),
|
||||
'file_path' => $documentRoot . '/apikey/barobill_corp_num.txt',
|
||||
'is_set' => !empty($barobillCorpNum),
|
||||
'value' => $barobillCorpNum,
|
||||
'raw_content' => file_exists($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_corp_num.txt')
|
||||
? file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_corp_num.txt')
|
||||
'raw_content' => file_exists($documentRoot . '/apikey/barobill_corp_num.txt')
|
||||
? file_get_contents($documentRoot . '/apikey/barobill_corp_num.txt')
|
||||
: 'FILE NOT FOUND'
|
||||
],
|
||||
'user_id' => [
|
||||
'file_exists' => file_exists($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_user_id.txt'),
|
||||
'file_path' => $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_user_id.txt',
|
||||
'file_exists' => file_exists($documentRoot . '/apikey/barobill_user_id.txt'),
|
||||
'file_path' => $documentRoot . '/apikey/barobill_user_id.txt',
|
||||
'is_set' => !empty($barobillUserId),
|
||||
'value' => $barobillUserId,
|
||||
'raw_content' => file_exists($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_user_id.txt')
|
||||
? file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_user_id.txt')
|
||||
'raw_content' => file_exists($documentRoot . '/apikey/barobill_user_id.txt')
|
||||
? file_get_contents($documentRoot . '/apikey/barobill_user_id.txt')
|
||||
: 'FILE NOT FOUND'
|
||||
],
|
||||
'test_mode' => [
|
||||
'file_exists' => file_exists($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_test_mode.txt'),
|
||||
'file_exists' => file_exists($documentRoot . '/apikey/barobill_test_mode.txt'),
|
||||
'is_active' => $isTestMode,
|
||||
'raw_content' => file_exists($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_test_mode.txt')
|
||||
? file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_test_mode.txt')
|
||||
'raw_content' => file_exists($documentRoot . '/apikey/barobill_test_mode.txt')
|
||||
? file_get_contents($documentRoot . '/apikey/barobill_test_mode.txt')
|
||||
: 'FILE NOT FOUND'
|
||||
],
|
||||
'soap_client' => [
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
|
||||
require_once('barobill_account_config.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/session.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/lib/mydb.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
$debug = [
|
||||
'step' => [],
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/session.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/lib/mydb.php');
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/session.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/lib/mydb.php');
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
$tenantId = $_POST['tenant_id'] ?? $_GET['tenant_id'] ?? '';
|
||||
|
||||
|
||||
@@ -470,23 +470,46 @@ try {
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} else {
|
||||
// API Error Handling (Graceful Fallback)
|
||||
// If API fails (e.g., SoapClient missing), return empty list with warning
|
||||
// so the UI doesn't break.
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
'success' => true, // Masquerade as success to render empty table
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => 1,
|
||||
'maxIndex' => 0
|
||||
],
|
||||
'summary' => [
|
||||
'totalDeposit' => 0,
|
||||
'totalWithdraw' => 0,
|
||||
'count' => 0
|
||||
]
|
||||
],
|
||||
'warning' => 'API 연동 실패: ' . $result['error'], // Custom warning field
|
||||
'api_error_code' => $result['error_code'] ?? null
|
||||
];
|
||||
|
||||
// 디버그 정보 추가
|
||||
// Add debug info if available
|
||||
if (isset($result['debug'])) {
|
||||
$response['debug'] = $result['debug'];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
// Global Exception/Error Handling
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
'success' => true, // Return true to avoid UI breakage
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1],
|
||||
'summary' => ['totalDeposit' => 0, 'totalWithdraw' => 0, 'count' => 0]
|
||||
],
|
||||
'warning' => '시스템 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// load .env
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
|
||||
require_once('barobill_card_config.php');
|
||||
|
||||
// 디버그 모드
|
||||
@@ -259,10 +263,28 @@ try {
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// API Error Handling (Graceful Fallback)
|
||||
// If API fails (e.g., SoapClient missing), return empty list with warning
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
'success' => true, // Masquerade as success to render empty table
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => 1,
|
||||
'totalCount' => 0
|
||||
],
|
||||
'summary' => [
|
||||
'totalAmount' => 0,
|
||||
'totalAmountFormatted' => '0',
|
||||
'count' => 0,
|
||||
'approvalCount' => 0,
|
||||
'cancelCount' => 0
|
||||
]
|
||||
],
|
||||
'warning' => 'API 연동 실패: ' . $result['error'],
|
||||
'api_error_code' => $result['error_code'] ?? null
|
||||
];
|
||||
|
||||
// 디버그 정보 추가
|
||||
@@ -275,10 +297,15 @@ try {
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
'success' => true, // Return true to avoid UI breakage
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1, 'totalCount' => 0],
|
||||
'summary' => ['totalAmount' => 0, 'count' => 0]
|
||||
],
|
||||
'warning' => '시스템 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
|
||||
@@ -1263,12 +1263,14 @@
|
||||
acc.source === 'local_db_only' ? 'bg-red-100 text-red-700' :
|
||||
acc.source === 'barobill_api' ? 'bg-green-100 text-green-700' :
|
||||
acc.source === 'both' ? 'bg-blue-100 text-blue-700' :
|
||||
acc.source === 'barobill_api_error' ? 'bg-orange-100 text-orange-700' :
|
||||
acc.source === 'local_db' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{acc.source === 'local_db_only' ? '⚠️ 로컬만' :
|
||||
acc.source === 'barobill_api' ? '✓ 바로빌' :
|
||||
acc.source === 'both' ? '✓ 통합' :
|
||||
acc.source === 'barobill_api_error' ? '❓ 상태미확인' :
|
||||
acc.source === 'local_db' ? '로컬' : '알 수 없음'}
|
||||
</span>
|
||||
)}
|
||||
@@ -1282,15 +1284,21 @@
|
||||
acc.status == 0 ? 'bg-yellow-100 text-yellow-700' :
|
||||
acc.statusText === '바로빌 미등록' ? 'bg-red-100 text-red-700' :
|
||||
acc.source === 'local_db_only' ? 'bg-red-100 text-red-700' :
|
||||
acc.source === 'barobill_api_error' ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{acc.statusText}
|
||||
</span>
|
||||
{acc.warning && (
|
||||
{acc.warning && !acc.api_error && (
|
||||
<div className="mt-1 text-xs text-red-600">
|
||||
⚠️ 바로빌 API에 등록 필요
|
||||
</div>
|
||||
)}
|
||||
{acc.api_error && (
|
||||
<div className="mt-1 text-xs text-orange-600">
|
||||
⚠️ API 연동 실패
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{acc.issueDate || '-'}</td>
|
||||
</tr>
|
||||
|
||||
@@ -14,11 +14,17 @@
|
||||
* 3. 테스트 환경인 경우 apikey/barobill_test_mode.txt 파일에 "test" 또는 "true"를 저장하세요
|
||||
*/
|
||||
|
||||
|
||||
// load .env file
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
$certKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_api_key.txt'; // 기존 호환성
|
||||
$corpNumFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_test_mode.txt';
|
||||
$documentRoot = getenv('DOCUMENT_ROOT');
|
||||
$certKeyFile = $documentRoot . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $documentRoot . '/apikey/barobill_api_key.txt'; // 기존 호환성
|
||||
$corpNumFile = $documentRoot . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $documentRoot . '/apikey/barobill_test_mode.txt';
|
||||
|
||||
// CERTKEY 읽기 (인증서 키)
|
||||
// 우선순위: barobill_cert_key.txt > barobill_api_key.txt (기존 호환성)
|
||||
@@ -71,8 +77,8 @@ if (!empty($barobillCertKey) || $isTestMode) {
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// SOAP 클라이언트 생성 실패 시 null 유지
|
||||
} catch (Throwable $e) {
|
||||
// SOAP 클라이언트 생성 실패 시 null 유지 (Class not found 등 Fatal Error 포함)
|
||||
error_log('바로빌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -92,7 +98,7 @@ function callBarobillSOAP($method, $params = []) {
|
||||
'success' => false,
|
||||
'error' => '바로빌 SOAP 클라이언트가 초기화되지 않았습니다. CERTKEY를 확인하세요.',
|
||||
'error_detail' => [
|
||||
'cert_key_file' => $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt',
|
||||
'cert_key_file' => getenv('DOCUMENT_ROOT') . '/apikey/barobill_cert_key.txt',
|
||||
'soap_url' => $isTestMode ? 'https://testws.baroservice.com/TI.asmx?WSDL' : 'https://ws.baroservice.com/TI.asmx?WSDL'
|
||||
]
|
||||
];
|
||||
@@ -220,10 +226,10 @@ function callBarobillSOAP($method, $params = []) {
|
||||
'soap_response' => $barobillSoapClient ? $barobillSoapClient->__getLastResponse() : null
|
||||
]
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류: ' . $e->getMessage(),
|
||||
'error' => 'API 호출 오류 (치명적): ' . $e->getMessage(),
|
||||
'error_detail' => [
|
||||
'exception_type' => get_class($e),
|
||||
'soap_request' => $barobillSoapClient ? $barobillSoapClient->__getLastRequest() : null,
|
||||
|
||||
34
etax/api/debug_test.php
Normal file
34
etax/api/debug_test.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
ini_set('display_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
echo "Current Dir: " . __DIR__ . "\n";
|
||||
echo "DotEnv Path: " . __DIR__ . '/../../lib/DotEnv.php' . "\n";
|
||||
echo "Env File Path: " . __DIR__ . '/../../.env' . "\n";
|
||||
|
||||
if (file_exists(__DIR__ . '/../../lib/DotEnv.php')) {
|
||||
echo "DotEnv file exists.\n";
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
echo "DotEnv loaded.\n";
|
||||
} else {
|
||||
echo "DotEnv file NOT found.\n";
|
||||
}
|
||||
|
||||
if (file_exists(__DIR__ . '/../../.env')) {
|
||||
echo ".env file exists.\n";
|
||||
try {
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
echo ".env loaded.\n";
|
||||
} catch (Exception $e) {
|
||||
echo "Error loading .env: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo ".env file NOT found.\n";
|
||||
}
|
||||
|
||||
$root = getenv('DOCUMENT_ROOT');
|
||||
echo "DOCUMENT_ROOT from getenv: " . var_export($root, true) . "\n";
|
||||
echo "DOCUMENT_ROOT from \$_ENV: " . var_export($_ENV['DOCUMENT_ROOT'] ?? 'unset', true) . "\n";
|
||||
echo "DOCUMENT_ROOT from \$_SERVER: " . var_export($_SERVER['DOCUMENT_ROOT'] ?? 'unset', true) . "\n";
|
||||
@@ -313,6 +313,124 @@
|
||||
"memo": "긴급 납품",
|
||||
"createdAt": "2025-12-10T11:29:14",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765869643",
|
||||
"issueKey": "BARO-2025-8437",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "843-22-01859",
|
||||
"recipientName": "조은지게차",
|
||||
"supplyDate": "2025-11-24",
|
||||
"items": [
|
||||
{
|
||||
"name": "샤워기",
|
||||
"qty": 68,
|
||||
"unitPrice": 82775,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 5628700,
|
||||
"vat": 562870,
|
||||
"total": 6191570
|
||||
},
|
||||
{
|
||||
"name": "수도꼭지",
|
||||
"qty": 67,
|
||||
"unitPrice": 188668,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 12640756,
|
||||
"vat": 1264075,
|
||||
"total": 13904831
|
||||
},
|
||||
{
|
||||
"name": "방수재",
|
||||
"qty": 16,
|
||||
"unitPrice": 470138,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 7522208,
|
||||
"vat": 752220,
|
||||
"total": 8274428
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 25791664,
|
||||
"totalVat": 2579165,
|
||||
"total": 28370829,
|
||||
"status": "issued",
|
||||
"memo": "시공 납품",
|
||||
"createdAt": "2025-12-16T16:20:43",
|
||||
"barobillInvoiceId": "BB-6941084b4185e"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765869648",
|
||||
"issueKey": "BARO-2025-0676",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "107-81-78114",
|
||||
"recipientName": "(주)이상네트웍스",
|
||||
"supplyDate": "2025-12-10",
|
||||
"items": [
|
||||
{
|
||||
"name": "배관자재",
|
||||
"qty": 52,
|
||||
"unitPrice": 64100,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 3333200,
|
||||
"vat": 333320,
|
||||
"total": 3666520
|
||||
},
|
||||
{
|
||||
"name": "수도꼭지",
|
||||
"qty": 100,
|
||||
"unitPrice": 487879,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 48787900,
|
||||
"vat": 4878790,
|
||||
"total": 53666690
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 52121100,
|
||||
"totalVat": 5212110,
|
||||
"total": 57333210,
|
||||
"status": "issued",
|
||||
"memo": "보수 납품",
|
||||
"createdAt": "2025-12-16T16:20:48",
|
||||
"barobillInvoiceId": "BB-69410850c94ef"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765872376",
|
||||
"issueKey": "MGT202512161706166177",
|
||||
"mgtKey": "MGT202512161706166177",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "843-22-01859",
|
||||
"recipientName": "조은지게차",
|
||||
"supplyDate": "2025-12-11",
|
||||
"items": [
|
||||
{
|
||||
"name": "욕조",
|
||||
"qty": 90,
|
||||
"unitPrice": 453160,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 40784400,
|
||||
"vat": 4078440,
|
||||
"total": 44862840
|
||||
},
|
||||
{
|
||||
"name": "시멘트 50kg",
|
||||
"qty": 53,
|
||||
"unitPrice": 380513,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 20167189,
|
||||
"vat": 2016718,
|
||||
"total": 22183907
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 60951589,
|
||||
"totalVat": 6095158,
|
||||
"total": 67046747,
|
||||
"status": "issued",
|
||||
"memo": "시공 납품",
|
||||
"createdAt": "2025-12-16T17:06:16",
|
||||
"barobillInvoiceId": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
require_once __DIR__ . '/../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../.env'))->load();
|
||||
require_once(getenv('DOCUMENT_ROOT') . "/session.php");
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
||||
27
etax/dev.md
Normal file
27
etax/dev.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Etax 개발 노트
|
||||
|
||||
## API 오류 해결 가이드
|
||||
|
||||
### 바로빌 SOAP 클라이언트 미설치 오류 (500 Error)
|
||||
|
||||
**문제 상황:**
|
||||
서버에 PHP SOAP 확장 모듈이 설치되어 있지 않은 경우(`Class 'SoapClient' not found`), `new SoapClient()` 호출 시 치명적인 오류(Fatal Error)가 발생하여 HTTP 500 상태 코드를 반환합니다.
|
||||
|
||||
**해결 방법:**
|
||||
`soapClient` 생성 로직을 `try-catch` 블록으로 감싸되, `Exception`이 아닌 **`Throwable`**을 catch해야 합니다. PHP 7+에서는 치명적인 오류가 `Error` 객체로 던져지며, 이는 `Exception`이 아닌 `Throwable` 인터페이스를 구현하기 때문입니다.
|
||||
|
||||
**수정 예시:**
|
||||
```php
|
||||
$barobillSoapClient = null;
|
||||
try {
|
||||
$barobillSoapClient = new SoapClient($url, $options);
|
||||
} catch (Throwable $e) {
|
||||
// Class not found 등의 Fatal Error도 여기서 잡힘
|
||||
error_log('SOAP Client 생성 실패: ' . $e->getMessage());
|
||||
// 이후 로직에서 $barobillSoapClient가 null일 경우의 대체 로직(예: 시뮬레이션 모드) 수행
|
||||
}
|
||||
```
|
||||
|
||||
**적용 파일:**
|
||||
- `etax/api/barobill_config.php`
|
||||
- `etax/api/issue.php` (전역 에러 핸들링)
|
||||
@@ -5,10 +5,26 @@ ini_set('display_errors', 1);
|
||||
echo "Starting Geo Attendance DB Fix...\n";
|
||||
|
||||
try {
|
||||
// Connect directly
|
||||
// Define DOCUMENT_ROOT if not set (for CLI execution)
|
||||
if (!isset($_SERVER['DOCUMENT_ROOT']) || empty($_SERVER['DOCUMENT_ROOT'])) {
|
||||
$_SERVER['DOCUMENT_ROOT'] = dirname(__DIR__); // Assumes script is in /geoattendance/
|
||||
}
|
||||
|
||||
// Include mydb.php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
|
||||
|
||||
try {
|
||||
// Try connecting using the standard application logic (loads .env)
|
||||
$pdo = db_connect();
|
||||
echo "Connected via db_connect() (standard).\n";
|
||||
} catch (Exception $e) {
|
||||
echo "Standard connection failed. Trying local fallback...\n";
|
||||
// Fallback for Local Development (CLI) where .env might have internal Docker hostnames
|
||||
$dsn = "mysql:host=127.0.0.1;dbname=chandj;charset=utf8";
|
||||
$pdo = new PDO($dsn, 'root', 'root');
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
echo "Connected via Local Fallback (127.0.0.1).\n";
|
||||
}
|
||||
|
||||
// 1. Check for ID 0 and move it if exists
|
||||
// We use a subquery to find a safe new ID
|
||||
|
||||
@@ -158,15 +158,19 @@
|
||||
const [office, setOffice] = useState(DEFAULT_OFFICE);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState(null);
|
||||
const [noticeMsg, setNoticeMsg] = useState(null);
|
||||
const [distance, setDistance] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('home');
|
||||
|
||||
const [permissionStatus, setPermissionStatus] = useState('unknown');
|
||||
const [isSecure, setIsSecure] = useState(window.isSecureContext);
|
||||
const [geoMode, setGeoMode] = useState('알 수 없음');
|
||||
const [geoRetryCount, setGeoRetryCount] = useState(0);
|
||||
const [lastLocationAt, setLastLocationAt] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
lucide.createIcons();
|
||||
}, [activeTab, records]);
|
||||
}, [activeTab, records, errorMsg, noticeMsg]);
|
||||
|
||||
// Load records on mount
|
||||
useEffect(() => {
|
||||
@@ -191,59 +195,130 @@
|
||||
|
||||
// Start watching location with fallback logic
|
||||
useEffect(() => {
|
||||
let watchId = null;
|
||||
const watchIdRef = { current: null };
|
||||
const retryTimerRef = { current: null };
|
||||
const profileIdxRef = { current: 0 };
|
||||
const retryCountRef = { current: 0 };
|
||||
|
||||
const startWatch = (highAccuracy = true) => {
|
||||
if (watchId) navigator.geolocation.clearWatch(watchId);
|
||||
const profiles = [
|
||||
{
|
||||
label: '고정밀(High Accuracy)',
|
||||
options: { enableHighAccuracy: true, maximumAge: 0, timeout: 20000 }
|
||||
},
|
||||
{
|
||||
label: '저전력(Low Accuracy)',
|
||||
// Cached/network location can be very helpful on desktop/indoors
|
||||
options: { enableHighAccuracy: false, maximumAge: 600000, timeout: 30000 }
|
||||
}
|
||||
];
|
||||
|
||||
if ('geolocation' in navigator) {
|
||||
watchId = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
const newLoc = {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy
|
||||
};
|
||||
setCurrentLocation(newLoc);
|
||||
setErrorMsg(null);
|
||||
},
|
||||
(err) => {
|
||||
console.error(err);
|
||||
|
||||
// On Timeout (code 3) and currently using High Accuracy, try fallback
|
||||
if (err.code === 3 && highAccuracy) {
|
||||
console.warn("High accuracy timed out. Falling back to low accuracy.");
|
||||
startWatch(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let msg = "위치를 가져올 수 없습니다.";
|
||||
switch(err.code) {
|
||||
case 1: msg = "위치 정보 권한이 거부되었습니다. 브라우저 설정에서 권한을 허용해주세요."; break;
|
||||
case 2: msg = "위치 정보를 사용할 수 없습니다. GPS 신호를 확인해주세요."; break;
|
||||
case 3: msg = "위치 정보 요청 시간이 초과되었습니다. (Low Accuracy 시도 실패)"; break;
|
||||
default: msg = "알 수 없는 오류가 발생했습니다. (" + err.message + ")"; break;
|
||||
}
|
||||
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
||||
msg += " (주의: 보안 연결(HTTPS)이 아니면 위치 정보가 차단될 수 있습니다)";
|
||||
}
|
||||
setErrorMsg(msg);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: highAccuracy,
|
||||
maximumAge: 10000,
|
||||
timeout: 15000 // 15 seconds timeout
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setErrorMsg("Geolocation is not supported by your browser.");
|
||||
const clearWatch = () => {
|
||||
if (watchIdRef.current) {
|
||||
try { navigator.geolocation.clearWatch(watchIdRef.current); } catch (e) {}
|
||||
watchIdRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
startWatch(true); // Start with High Accuracy
|
||||
const clearRetryTimer = () => {
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRetry = (baseDelayMs) => {
|
||||
clearRetryTimer();
|
||||
const attempt = retryCountRef.current;
|
||||
const delay = Math.min(baseDelayMs * Math.max(1, attempt), 30000);
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
startWatch(0);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const startWatch = (profileIdx = 0) => {
|
||||
clearRetryTimer();
|
||||
profileIdxRef.current = profileIdx;
|
||||
setGeoMode(profiles[profileIdx]?.label || '알 수 없음');
|
||||
|
||||
if (!('geolocation' in navigator)) {
|
||||
setErrorMsg("Geolocation is not supported by your browser.");
|
||||
return;
|
||||
}
|
||||
|
||||
clearWatch();
|
||||
|
||||
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
retryCountRef.current = 0;
|
||||
setGeoRetryCount(0);
|
||||
|
||||
const newLoc = {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy
|
||||
};
|
||||
setCurrentLocation(newLoc);
|
||||
setLastLocationAt(Date.now());
|
||||
setErrorMsg(null);
|
||||
setNoticeMsg(null);
|
||||
},
|
||||
(err) => {
|
||||
console.error(err);
|
||||
|
||||
// Permission denied -> hard error (no auto retry)
|
||||
if (err.code === 1) {
|
||||
setNoticeMsg(null);
|
||||
setErrorMsg("위치 정보 권한이 거부되었습니다. 브라우저 설정에서 권한을 허용해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Timeout -> try fallback profile first, then soft retry
|
||||
if (err.code === 3) {
|
||||
if (profileIdxRef.current < profiles.length - 1) {
|
||||
setErrorMsg(null);
|
||||
setNoticeMsg("정확한 위치가 지연되어 저전력(Low Accuracy) 모드로 전환합니다...");
|
||||
startWatch(profileIdxRef.current + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
retryCountRef.current += 1;
|
||||
setGeoRetryCount(retryCountRef.current);
|
||||
setErrorMsg(null);
|
||||
setNoticeMsg(`위치 응답이 지연되고 있습니다. 자동 재시도 중... (${retryCountRef.current}회)`);
|
||||
scheduleRetry(5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Position unavailable -> soft retry (indoors/desktop common)
|
||||
if (err.code === 2) {
|
||||
retryCountRef.current += 1;
|
||||
setGeoRetryCount(retryCountRef.current);
|
||||
setErrorMsg(null);
|
||||
setNoticeMsg(`위치 정보를 사용할 수 없습니다. GPS/네트워크 상태를 확인 중... (${retryCountRef.current}회 재시도)`);
|
||||
scheduleRetry(8000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown error -> show message, but still retry once in a while
|
||||
retryCountRef.current += 1;
|
||||
setGeoRetryCount(retryCountRef.current);
|
||||
|
||||
let msg = "알 수 없는 오류가 발생했습니다. (" + (err.message || "unknown") + ")";
|
||||
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
||||
msg += " (주의: 보안 연결(HTTPS)이 아니면 위치 정보가 차단될 수 있습니다)";
|
||||
}
|
||||
setErrorMsg(msg);
|
||||
scheduleRetry(10000);
|
||||
},
|
||||
profiles[profileIdx]?.options || { enableHighAccuracy: true, maximumAge: 0, timeout: 20000 }
|
||||
);
|
||||
};
|
||||
|
||||
startWatch(0); // Start with High Accuracy profile
|
||||
|
||||
return () => {
|
||||
if (watchId) navigator.geolocation.clearWatch(watchId);
|
||||
clearRetryTimer();
|
||||
clearWatch();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -362,6 +437,14 @@
|
||||
{/* Main Content Area */}
|
||||
<main className="p-4 space-y-6">
|
||||
|
||||
{/* Notice Banner (soft errors / retry status) */}
|
||||
{noticeMsg && (
|
||||
<div className="bg-amber-50 border border-amber-200 text-amber-800 p-4 rounded-xl flex items-start gap-3 text-sm">
|
||||
<i data-lucide="info" className="shrink-0 mt-0.5 w-4 h-4"></i>
|
||||
<p>{noticeMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Banner */}
|
||||
{errorMsg && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 p-4 rounded-xl flex items-start gap-3 text-sm">
|
||||
@@ -507,6 +590,9 @@
|
||||
주의: HTTPS가 아니면 최신 브라우저에서 GPS가 차단됩니다.
|
||||
</p>
|
||||
)}
|
||||
<p>위치 모드: {geoMode}</p>
|
||||
<p>재시도 횟수: {geoRetryCount}</p>
|
||||
<p>마지막 수신: {lastLocationAt ? new Date(lastLocationAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', second: '2-digit'}) : '없음'}</p>
|
||||
<p>거리: {distance ? distance.toFixed(1) : '알 수 없음'} 미터</p>
|
||||
<p>반경 제한: {office.allowedRadiusMeters} 미터</p>
|
||||
<p>GPS 정확도: {currentLocation?.accuracy ? currentLocation.accuracy.toFixed(1) + 'm' : '알 수 없음'}</p>
|
||||
|
||||
45
lib/DotEnv.php
Normal file
45
lib/DotEnv.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
if (!class_exists('DotEnv')) {
|
||||
class DotEnv
|
||||
{
|
||||
/**
|
||||
* The directory where the .env file is located.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $path;
|
||||
|
||||
public function __construct(string $path)
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
throw new \InvalidArgumentException(sprintf('%s does not exist', $path));
|
||||
}
|
||||
$this->path = $path;
|
||||
}
|
||||
|
||||
public function load(): void
|
||||
{
|
||||
if (!is_readable($this->path)) {
|
||||
throw new \RuntimeException(sprintf('%s file is not readable', $this->path));
|
||||
}
|
||||
|
||||
$lines = file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos(trim($line), '#') === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
list($name, $value) = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
|
||||
putenv(sprintf('%s=%s', $name, $value));
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
<div class="nav-item dropdown flex-fill me-3">
|
||||
<!-- 드롭다운 메뉴-->
|
||||
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">
|
||||
LOT&수입검사
|
||||
LOT/수입
|
||||
</a>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="<?=$root_dir?>/prodcode/list.php?header=header">
|
||||
@@ -215,7 +215,7 @@ require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
<div class="nav-item dropdown flex-fill me-3">
|
||||
<!-- 드롭다운 메뉴-->
|
||||
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">
|
||||
품질관리
|
||||
품질
|
||||
</a>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="<?=$root_dir?>/output/list_document.php">
|
||||
@@ -262,7 +262,7 @@ require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
<div class="nav-item dropdown flex-fill me-3">
|
||||
<!-- 드롭다운 메뉴-->
|
||||
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">
|
||||
절곡품
|
||||
절곡
|
||||
</a>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="<?=$root_dir?>/bending/list.php?header=header">
|
||||
@@ -297,7 +297,7 @@ require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
<div class="nav-item dropdown flex-fill me-3">
|
||||
<!-- 드롭다운 메뉴-->
|
||||
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">
|
||||
차량/지게차
|
||||
차량등
|
||||
</a>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="<?=$root_dir?>/car/list.php">
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
function db_connect(){ //DB연결을 함수로 정의
|
||||
$db_user ="juil"; //추가한 계정이름(사용자명)
|
||||
$db_pass ="123456"; //비밀번호
|
||||
$db_host ="localhost";
|
||||
$db_name ="juildb";
|
||||
$db_type ="mysql";
|
||||
$dsn ="$db_type:host=$db_host;db_name=$db_name;charset=utf8";
|
||||
|
||||
try{
|
||||
$pdo=new PDO($dsn,$db_user,$db_pass);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION
|
||||
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES,FALSE);
|
||||
|
||||
} catch (PDOException $Exception) {
|
||||
die('오류:'.$Exception->getMessage());
|
||||
}
|
||||
return $pdo;
|
||||
?>
|
||||
36
test_soap_account.php
Normal file
36
test_soap_account.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
ini_set('display_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
$_SERVER['DOCUMENT_ROOT'] = 'c:/Users/light/sam/5130';
|
||||
|
||||
require_once 'c:/Users/light/sam/5130/eaccount/api/barobill_account_config.php';
|
||||
|
||||
echo "1. SoapClient Check:\n";
|
||||
if (class_exists('SoapClient')) {
|
||||
echo "SoaptClient is available.\n";
|
||||
} else {
|
||||
echo "SoapClient is NOT available.\n";
|
||||
}
|
||||
|
||||
echo "\n2. Global Client Check:\n";
|
||||
global $barobillAccountSoapClient;
|
||||
if ($barobillAccountSoapClient) {
|
||||
echo "Global \$barobillAccountSoapClient is initialized.\n";
|
||||
} else {
|
||||
echo "Global \$barobillAccountSoapClient is NULL.\n";
|
||||
global $barobillInitError;
|
||||
echo "Init Error: $barobillInitError\n";
|
||||
}
|
||||
|
||||
echo "\n3. Call GetBankAccountEx:\n";
|
||||
$result = callBarobillAccountSOAP('GetBankAccountEx', ['AvailOnly' => 0]);
|
||||
|
||||
echo "Result Success: " . ($result['success'] ? 'Yes' : 'No') . "\n";
|
||||
if (!$result['success']) {
|
||||
echo "Error: " . $result['error'] . "\n";
|
||||
} else {
|
||||
echo "Data Type: " . gettype($result['data']) . "\n";
|
||||
print_r($result['data']);
|
||||
}
|
||||
?>
|
||||
BIN
uploads/consults/1807013631/20251215_062001_693f2a0108b7a.webm
Normal file
BIN
uploads/consults/1807013631/20251215_062001_693f2a0108b7a.webm
Normal file
Binary file not shown.
BIN
uploads/consults/1807013631/20251215_062343_693f2adf8181f.webm
Normal file
BIN
uploads/consults/1807013631/20251215_062343_693f2adf8181f.webm
Normal file
Binary file not shown.
BIN
uploads/consults/1807013631/20251215_072732_693f39d406093.webm
Normal file
BIN
uploads/consults/1807013631/20251215_072732_693f39d406093.webm
Normal file
Binary file not shown.
BIN
uploads/consults/1807013631/20251215_073257_693f3b19a012b.webm
Normal file
BIN
uploads/consults/1807013631/20251215_073257_693f3b19a012b.webm
Normal file
Binary file not shown.
BIN
uploads/consults/1807013631/20251215_080130_693f41ca902f5.webm
Normal file
BIN
uploads/consults/1807013631/20251215_080130_693f41ca902f5.webm
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
uploads/meetings/1807013631/20251214_201636_693e9c9421c1f.webm
Normal file
BIN
uploads/meetings/1807013631/20251214_201636_693e9c9421c1f.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/1807013631/20251214_201833_693e9d09d5f4c.webm
Normal file
BIN
uploads/meetings/1807013631/20251214_201833_693e9d09d5f4c.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/1807013631/20251214_202433_693e9e71e4184.webm
Normal file
BIN
uploads/meetings/1807013631/20251214_202433_693e9e71e4184.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/1807013631/20251214_203205_693ea035828b4.webm
Normal file
BIN
uploads/meetings/1807013631/20251214_203205_693ea035828b4.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/1807013631/20251214_210427_693ea7cb01dd9.webm
Normal file
BIN
uploads/meetings/1807013631/20251214_210427_693ea7cb01dd9.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/1807013631/20251214_223414_693ebcd679d10.webm
Normal file
BIN
uploads/meetings/1807013631/20251214_223414_693ebcd679d10.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/pro/20251214_194527_693e954734e73.webm
Normal file
BIN
uploads/meetings/pro/20251214_194527_693e954734e73.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/pro/20251214_200012_693e98bc68799.webm
Normal file
BIN
uploads/meetings/pro/20251214_200012_693e98bc68799.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/pro/20251214_200636_693e9a3c94519.webm
Normal file
BIN
uploads/meetings/pro/20251214_200636_693e9a3c94519.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/pro/20251214_201016_693e9b18aa6bb.webm
Normal file
BIN
uploads/meetings/pro/20251214_201016_693e9b18aa6bb.webm
Normal file
Binary file not shown.
BIN
uploads/meetings/pro/20251214_201227_693e9b9baaf6d.webm
Normal file
BIN
uploads/meetings/pro/20251214_201227_693e9b9baaf6d.webm
Normal file
Binary file not shown.
88
voice/GOOGLE_SPEECH_API_PLAN.md
Normal file
88
voice/GOOGLE_SPEECH_API_PLAN.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Google Cloud Speech-to-Text API 전환 계획
|
||||
|
||||
## 목표
|
||||
Web Speech API의 모바일 제한을 해결하고, Google Cloud Speech-to-Text API로 전환하여 모바일과 웹에서 모두 안정적으로 작동하도록 개선
|
||||
|
||||
## 현재 문제점
|
||||
1. 모바일 Chrome에서 Web Speech API의 `onresult` 이벤트가 발생하지 않음
|
||||
2. 웹 버전에서 텍스트 중복 문제 발생 (수정 완료)
|
||||
|
||||
## 해결 방안
|
||||
|
||||
### 1. Google Cloud Speech-to-Text API 사용
|
||||
- **장점:**
|
||||
- 모바일과 웹에서 모두 안정적으로 작동
|
||||
- 더 높은 정확도
|
||||
- 실시간 스트리밍 지원
|
||||
- 한국어 지원 우수
|
||||
|
||||
- **단점:**
|
||||
- 서버 측 구현 필요
|
||||
- Google Cloud API 키 필요
|
||||
- API 사용량에 따른 비용 발생 (무료 할당량 있음)
|
||||
|
||||
### 2. 구현 계획
|
||||
|
||||
#### 2.1 서버 측 (PHP)
|
||||
- `api/speech_to_text.php` 생성
|
||||
- Google Cloud Speech-to-Text API 연동
|
||||
- 오디오 파일 수신 및 변환
|
||||
- JSON 응답 반환
|
||||
|
||||
#### 2.2 클라이언트 측 (JavaScript)
|
||||
- MediaRecorder API로 오디오 녹음
|
||||
- 실시간 스트리밍 또는 청크 단위 전송
|
||||
- 서버 응답 받아서 텍스트 표시
|
||||
- 기존 UI/UX 유지
|
||||
|
||||
### 3. 구현 단계
|
||||
|
||||
#### Phase 1: 서버 측 API 구현
|
||||
1. Google Cloud Speech-to-Text API 설정
|
||||
2. PHP API 엔드포인트 생성
|
||||
3. 오디오 파일 수신 및 변환 로직
|
||||
|
||||
#### Phase 2: 클라이언트 측 구현
|
||||
1. MediaRecorder API로 오디오 녹음
|
||||
2. 실시간 또는 청크 단위 전송
|
||||
3. 서버 응답 처리 및 텍스트 표시
|
||||
|
||||
#### Phase 3: 통합 및 테스트
|
||||
1. 모바일/웹 호환성 테스트
|
||||
2. 에러 처리 강화
|
||||
3. 사용자 경험 개선
|
||||
|
||||
### 4. 필요한 설정
|
||||
|
||||
#### Google Cloud 설정
|
||||
1. Google Cloud 프로젝트 생성
|
||||
2. Speech-to-Text API 활성화
|
||||
3. 서비스 계정 키 생성
|
||||
4. PHP에서 사용할 수 있도록 설정
|
||||
|
||||
#### 서버 설정
|
||||
1. PHP cURL 확장 확인
|
||||
2. Google Cloud PHP 클라이언트 라이브러리 설치 (선택사항)
|
||||
3. API 키 또는 서비스 계정 키 파일 위치 설정
|
||||
|
||||
### 5. API 사용 방법
|
||||
|
||||
#### 실시간 스트리밍 (권장)
|
||||
- WebSocket 또는 Server-Sent Events 사용
|
||||
- 실시간으로 오디오 전송 및 텍스트 수신
|
||||
|
||||
#### 청크 단위 전송
|
||||
- 일정 간격으로 오디오 청크 전송
|
||||
- 각 청크에 대한 텍스트 수신 및 누적
|
||||
|
||||
### 6. 비용 고려사항
|
||||
- Google Cloud Speech-to-Text 무료 할당량: 월 60분
|
||||
- 이후 사용량에 따른 과금
|
||||
- 대안: 무료 할당량 내에서 사용하거나 유료 플랜 고려
|
||||
|
||||
## 다음 단계
|
||||
1. Google Cloud 프로젝트 설정 확인
|
||||
2. 서버 측 API 엔드포인트 구현
|
||||
3. 클라이언트 측 오디오 녹음 및 전송 로직 구현
|
||||
4. 통합 테스트
|
||||
|
||||
104
voice/README_GOOGLE_API.md
Normal file
104
voice/README_GOOGLE_API.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Google Cloud Speech-to-Text API 실행 가이드
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 1. Google Cloud API 키 설정
|
||||
|
||||
1. **설정 파일 생성**
|
||||
```bash
|
||||
# config/google_speech_api.php.example을 복사
|
||||
cp config/google_speech_api.php.example config/google_speech_api.php
|
||||
```
|
||||
|
||||
2. **Google Cloud Console에서 API 키 생성**
|
||||
- https://console.cloud.google.com/ 접속
|
||||
- 프로젝트 생성 또는 선택
|
||||
- "API 및 서비스" > "라이브러리"
|
||||
- "Cloud Speech-to-Text API" 검색 및 활성화
|
||||
- "API 및 서비스" > "사용자 인증 정보" > "사용자 인증 정보 만들기" > "API 키"
|
||||
- 생성된 API 키 복사
|
||||
|
||||
3. **설정 파일에 API 키 입력**
|
||||
```php
|
||||
<?php
|
||||
$GOOGLE_SPEECH_API_KEY = '여기에_생성한_API_키_입력';
|
||||
```
|
||||
|
||||
### 2. 실행 방법
|
||||
|
||||
1. **웹 서버 실행** (이미 실행 중이면 생략)
|
||||
- Apache/Nginx 등 웹 서버가 실행 중이어야 합니다.
|
||||
|
||||
2. **브라우저에서 접속**
|
||||
- `http://localhost/5130/voice/index.php` 또는 실제 서버 주소
|
||||
|
||||
3. **테스트**
|
||||
- **웹**: Web Speech API 사용 (기존 방식)
|
||||
- **모바일**: 자동으로 Google Cloud Speech-to-Text API 사용
|
||||
|
||||
### 3. 동작 방식
|
||||
|
||||
#### 웹 (데스크탑)
|
||||
- Web Speech API 사용 (기존 방식 유지)
|
||||
- 실시간 음성 인식
|
||||
- 중복 텍스트 문제 해결됨
|
||||
|
||||
#### 모바일
|
||||
- Google Cloud Speech-to-Text API 자동 사용
|
||||
- MediaRecorder로 오디오 녹음
|
||||
- 3초마다 서버로 전송하여 텍스트 변환
|
||||
- 실시간 텍스트 업데이트
|
||||
|
||||
### 4. 문제 해결
|
||||
|
||||
#### API 키 오류
|
||||
```
|
||||
에러: Google Cloud Speech-to-Text API 키가 설정되지 않았습니다.
|
||||
```
|
||||
- `config/google_speech_api.php` 파일 확인
|
||||
- API 키가 올바르게 설정되었는지 확인
|
||||
- Google Cloud Console에서 API가 활성화되었는지 확인
|
||||
|
||||
#### 오디오 전송 실패
|
||||
- 네트워크 연결 확인
|
||||
- 서버 로그 확인 (`api/speech_to_text.php`의 에러 로그)
|
||||
- 브라우저 콘솔 확인 (디버그 패널 사용)
|
||||
|
||||
#### 텍스트 변환 실패
|
||||
- 마이크 권한 확인
|
||||
- 오디오 형식 지원 확인 (webm, wav, ogg 등)
|
||||
- Google Cloud API 할당량 확인 (무료: 월 60분)
|
||||
|
||||
### 5. 비용 정보
|
||||
|
||||
- **무료 할당량**: 월 60분
|
||||
- **초과 시**: 사용량에 따른 과금
|
||||
- **비용 절감**: 짧은 오디오만 전송하도록 최적화됨 (3초 청크)
|
||||
|
||||
### 6. 디버깅
|
||||
|
||||
디버그 패널 사용:
|
||||
1. 우측 하단 디버그 버튼 클릭
|
||||
2. 모든 로그 확인
|
||||
3. 복사 버튼으로 로그 복사하여 공유
|
||||
|
||||
### 7. 파일 구조
|
||||
|
||||
```
|
||||
5130/voice/
|
||||
├── index.php (메인 페이지)
|
||||
├── api/
|
||||
│ └── speech_to_text.php (Google API 엔드포인트)
|
||||
├── config/
|
||||
│ └── google_speech_api.php (API 키 설정 - 생성 필요)
|
||||
└── GOOGLE_SPEECH_API_PLAN.md (상세 계획)
|
||||
```
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. ✅ API 키 설정
|
||||
2. ✅ 웹에서 테스트
|
||||
3. ✅ 모바일에서 테스트
|
||||
4. ✅ 정확도 확인
|
||||
5. ✅ 필요시 추가 최적화
|
||||
|
||||
511
voice/api/speech_to_text.php
Normal file
511
voice/api/speech_to_text.php
Normal file
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
/**
|
||||
* Google Cloud Speech-to-Text API 엔드포인트
|
||||
* 오디오 파일을 받아서 텍스트로 변환
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: POST');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
|
||||
// 권한 체크
|
||||
if ($level > 5) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '접근 권한이 없습니다.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST 요청만 허용
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'POST 요청만 허용됩니다.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 오디오 파일 확인
|
||||
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '오디오 파일이 전송되지 않았습니다.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$audioFile = $_FILES['audio'];
|
||||
$audioPath = $audioFile['tmp_name'];
|
||||
$audioType = $audioFile['type'];
|
||||
|
||||
// 오디오 파일 유효성 검사
|
||||
$allowedTypes = ['audio/webm', 'audio/wav', 'audio/ogg', 'audio/mp3', 'audio/mpeg', 'audio/x-flac'];
|
||||
if (!in_array($audioType, $allowedTypes)) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '지원하지 않는 오디오 형식입니다. (webm, wav, ogg, mp3, flac 지원)'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Google Cloud Speech-to-Text API 설정
|
||||
$googleApiKey = '';
|
||||
$accessToken = null; // 서비스 계정용 OAuth 토큰
|
||||
$useServiceAccount = false;
|
||||
$googleApiUrl = 'https://speech.googleapis.com/v1/speech:recognize';
|
||||
|
||||
// API 키 파일 경로 (우선순위 순)
|
||||
// 상대 경로와 절대 경로 모두 확인
|
||||
$docRoot = $_SERVER['DOCUMENT_ROOT'];
|
||||
$apiKeyPaths = [
|
||||
$docRoot . '/5130/apikey/google_api.txt',
|
||||
$docRoot . '/apikey/google_api.txt',
|
||||
dirname($docRoot) . '/5130/apikey/google_api.txt', // 상위 디렉토리에서 확인
|
||||
dirname($docRoot) . '/apikey/google_api.txt',
|
||||
__DIR__ . '/../../apikey/google_api.txt', // 현재 파일 기준 상대 경로
|
||||
__DIR__ . '/../../../apikey/google_api.txt',
|
||||
$docRoot . '/5130/config/google_speech_api.php',
|
||||
$docRoot . '/config/google_speech_api.php',
|
||||
];
|
||||
|
||||
// 서비스 계정 파일 경로
|
||||
$serviceAccountPaths = [
|
||||
$docRoot . '/5130/apikey/google_service_account.json',
|
||||
$docRoot . '/apikey/google_service_account.json',
|
||||
dirname($docRoot) . '/5130/apikey/google_service_account.json',
|
||||
dirname($docRoot) . '/apikey/google_service_account.json',
|
||||
__DIR__ . '/../../apikey/google_service_account.json',
|
||||
__DIR__ . '/../../../apikey/google_service_account.json',
|
||||
];
|
||||
|
||||
// API 키 파일에서 읽기
|
||||
$foundKeyPath = null;
|
||||
$checkedPaths = [];
|
||||
|
||||
foreach ($apiKeyPaths as $keyPath) {
|
||||
$checkedPaths[] = $keyPath . ' (exists: ' . (file_exists($keyPath) ? 'yes' : 'no') . ')';
|
||||
|
||||
if (file_exists($keyPath)) {
|
||||
$foundKeyPath = $keyPath;
|
||||
if (pathinfo($keyPath, PATHINFO_EXTENSION) === 'php') {
|
||||
// PHP 설정 파일인 경우
|
||||
require_once($keyPath);
|
||||
if (isset($GOOGLE_SPEECH_API_KEY)) {
|
||||
$googleApiKey = trim($GOOGLE_SPEECH_API_KEY);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 텍스트 파일인 경우 (google_api.txt)
|
||||
$keyContent = file_get_contents($keyPath);
|
||||
$googleApiKey = trim($keyContent);
|
||||
|
||||
// 빈 줄이나 주석 제거
|
||||
$lines = explode("\n", $googleApiKey);
|
||||
$googleApiKey = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (!empty($line) && !preg_match('/^#/', $line)) {
|
||||
$googleApiKey = $line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($googleApiKey)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 계정 파일 찾기
|
||||
$serviceAccountPath = null;
|
||||
foreach ($serviceAccountPaths as $saPath) {
|
||||
if (file_exists($saPath)) {
|
||||
$serviceAccountPath = $saPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth 2.0 토큰 생성 함수 (서비스 계정용)
|
||||
function getServiceAccountToken($serviceAccountPath) {
|
||||
if (!file_exists($serviceAccountPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
if (!$serviceAccount || !isset($serviceAccount['private_key']) || !isset($serviceAccount['client_email'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Base64 URL 인코딩 함수
|
||||
$base64UrlEncode = function($data) {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
};
|
||||
|
||||
// JWT 생성
|
||||
$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
|
||||
];
|
||||
|
||||
$encodedHeader = $base64UrlEncode(json_encode($jwtHeader));
|
||||
$encodedClaim = $base64UrlEncode(json_encode($jwtClaim));
|
||||
|
||||
// 서명 생성
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$signature = '';
|
||||
$signData = $encodedHeader . '.' . $encodedClaim;
|
||||
if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
|
||||
openssl_free_key($privateKey);
|
||||
return null;
|
||||
}
|
||||
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);
|
||||
$tokenHttpCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
|
||||
curl_close($tokenCh);
|
||||
|
||||
if ($tokenHttpCode === 200) {
|
||||
$tokenData = json_decode($tokenResponse, true);
|
||||
if (isset($tokenData['access_token'])) {
|
||||
return $tokenData['access_token'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// API 키가 없거나 형식이 잘못된 경우 서비스 계정 시도
|
||||
if (empty($googleApiKey) || strlen($googleApiKey) < 20) {
|
||||
if ($serviceAccountPath) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$useServiceAccount) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Google Cloud Speech-to-Text API 인증 정보가 없습니다.',
|
||||
'hint' => 'API 키 파일 또는 서비스 계정 파일을 찾을 수 없습니다.',
|
||||
'checked_paths' => $checkedPaths,
|
||||
'service_account_path' => $serviceAccountPath,
|
||||
'document_root' => $docRoot,
|
||||
'current_dir' => __DIR__
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// API 키 형식 확인 (Google API 키는 보통 AIza로 시작)
|
||||
$isValidFormat = (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35);
|
||||
if (!$isValidFormat && !$useServiceAccount) {
|
||||
// API 키 형식이 잘못된 경우 서비스 계정 시도
|
||||
if ($serviceAccountPath) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
$googleApiKey = ''; // API 키 사용 안 함
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 계정이 있으면 우선 사용 (API 키보다 안정적)
|
||||
// API 키는 간헐적으로 오류가 발생할 수 있으므로 서비스 계정을 기본으로 사용
|
||||
if (!$useServiceAccount && $serviceAccountPath && file_exists($serviceAccountPath)) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
// API 키는 백업으로 유지하되 서비스 계정 우선 사용
|
||||
error_log('Using service account for authentication (more reliable than API key)');
|
||||
}
|
||||
}
|
||||
|
||||
// API 키 디버깅 정보
|
||||
$debugInfo = [
|
||||
'api_key_path' => $foundKeyPath,
|
||||
'api_key_length' => strlen($googleApiKey),
|
||||
'api_key_prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
|
||||
'api_key_suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
|
||||
'use_service_account' => $useServiceAccount,
|
||||
'service_account_path' => $serviceAccountPath,
|
||||
'document_root' => $docRoot,
|
||||
'checked_paths' => $checkedPaths
|
||||
];
|
||||
|
||||
try {
|
||||
// 오디오 파일을 base64로 인코딩
|
||||
$audioContent = file_get_contents($audioPath);
|
||||
$audioBase64 = base64_encode($audioContent);
|
||||
|
||||
// 오디오 형식에 따른 encoding 설정
|
||||
$encoding = 'WEBM_OPUS';
|
||||
$sampleRate = 16000; // 기본값
|
||||
|
||||
if (strpos($audioType, 'wav') !== false) {
|
||||
$encoding = 'LINEAR16';
|
||||
$sampleRate = 16000;
|
||||
} elseif (strpos($audioType, 'ogg') !== false) {
|
||||
$encoding = 'OGG_OPUS';
|
||||
$sampleRate = 48000; // Opus는 보통 48kHz
|
||||
} elseif (strpos($audioType, 'flac') !== false) {
|
||||
$encoding = 'FLAC';
|
||||
$sampleRate = 16000;
|
||||
} elseif (strpos($audioType, 'webm') !== false) {
|
||||
$encoding = 'WEBM_OPUS';
|
||||
$sampleRate = 48000; // WebM Opus는 보통 48kHz
|
||||
}
|
||||
|
||||
// Google Cloud Speech-to-Text API 요청 데이터
|
||||
$requestData = [
|
||||
'config' => [
|
||||
'encoding' => $encoding,
|
||||
'sampleRateHertz' => $sampleRate,
|
||||
'languageCode' => 'ko-KR',
|
||||
'enableAutomaticPunctuation' => true,
|
||||
'enableWordTimeOffsets' => false,
|
||||
'model' => 'latest_long', // 긴 오디오에 적합
|
||||
'alternativeLanguageCodes' => ['ko'], // 추가 언어 코드
|
||||
],
|
||||
'audio' => [
|
||||
'content' => $audioBase64
|
||||
]
|
||||
];
|
||||
|
||||
// 디버깅 정보 (개발 환경에서만)
|
||||
if (isset($_GET['debug'])) {
|
||||
error_log('Google Speech API Request: ' . json_encode([
|
||||
'encoding' => $encoding,
|
||||
'sampleRate' => $sampleRate,
|
||||
'audioSize' => strlen($audioBase64),
|
||||
'audioType' => $audioType
|
||||
]));
|
||||
}
|
||||
|
||||
// cURL로 API 호출
|
||||
$headers = ['Content-Type: application/json'];
|
||||
$apiUrl = $googleApiUrl;
|
||||
|
||||
if ($useServiceAccount && $accessToken) {
|
||||
// 서비스 계정 사용: Bearer 토큰 사용
|
||||
$headers[] = 'Authorization: Bearer ' . $accessToken;
|
||||
} else if (!empty($googleApiKey)) {
|
||||
// API 키 사용: 쿼리 파라미터로 전달
|
||||
$apiUrl = $googleApiUrl . '?key=' . urlencode($googleApiKey);
|
||||
} else {
|
||||
throw new Exception('인증 정보가 없습니다. API 키 또는 서비스 계정이 필요합니다.');
|
||||
}
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
throw new Exception('cURL 오류: ' . $curlError);
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$errorData = json_decode($response, true);
|
||||
$errorMessage = 'API 오류 (HTTP ' . $httpCode . ')';
|
||||
$debugInfo = [];
|
||||
|
||||
if (isset($errorData['error'])) {
|
||||
$errorMessage .= ': ' . ($errorData['error']['message'] ?? '알 수 없는 오류');
|
||||
$debugInfo['error_details'] = $errorData['error'];
|
||||
|
||||
// API 키 오류인 경우 서비스 계정으로 재시도
|
||||
if (isset($errorData['error']['message']) &&
|
||||
(stripos($errorData['error']['message'], 'API key') !== false ||
|
||||
stripos($errorData['error']['message'], 'not valid') !== false ||
|
||||
stripos($errorData['error']['message'], 'invalid') !== false ||
|
||||
stripos($errorData['error']['message'], 'unauthorized') !== false)) {
|
||||
|
||||
// 서비스 계정으로 재시도
|
||||
if ($serviceAccountPath && file_exists($serviceAccountPath)) {
|
||||
// 서비스 계정을 사용 중이었는데도 오류가 발생하면 토큰 재생성
|
||||
if ($useServiceAccount) {
|
||||
error_log('Service account token may have expired, regenerating...');
|
||||
}
|
||||
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
// 서비스 계정으로 재시도
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $accessToken
|
||||
];
|
||||
|
||||
$ch = curl_init($googleApiUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
// 서비스 계정으로 성공
|
||||
$result = json_decode($response, true);
|
||||
if (isset($result['results']) && count($result['results']) > 0) {
|
||||
$transcript = '';
|
||||
foreach ($result['results'] as $resultItem) {
|
||||
if (isset($resultItem['alternatives'][0]['transcript'])) {
|
||||
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
|
||||
}
|
||||
}
|
||||
|
||||
$transcript = trim($transcript);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'transcript' => $transcript,
|
||||
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
|
||||
? $result['results'][0]['alternatives'][0]['confidence']
|
||||
: null,
|
||||
'auth_method' => 'service_account',
|
||||
'retry_success' => true
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 재시도 실패 시 상세 안내
|
||||
$errorMessage .= "\n\n⚠️ API 키 오류 해결 방법:\n";
|
||||
$errorMessage .= "1. Google Cloud Console (https://console.cloud.google.com/) 접속\n";
|
||||
$errorMessage .= "2. 프로젝트 선택: codebridge-chatbot\n";
|
||||
$errorMessage .= "3. 'API 및 서비스' > '라이브러리'에서 'Cloud Speech-to-Text API' 검색 및 활성화\n";
|
||||
$errorMessage .= "4. 'API 및 서비스' > '사용자 인증 정보'에서 API 키 확인\n";
|
||||
$errorMessage .= "5. API 키 제한 설정에서 'Cloud Speech-to-Text API' 허용 확인\n";
|
||||
$errorMessage .= "\n현재 API 키 파일: " . ($foundKeyPath ?? 'N/A');
|
||||
$errorMessage .= "\nAPI 키 길이: " . strlen($googleApiKey) . " 문자";
|
||||
if ($serviceAccountPath) {
|
||||
$errorMessage .= "\n서비스 계정 파일: " . $serviceAccountPath;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$errorMessage .= ': ' . substr($response, 0, 200);
|
||||
}
|
||||
|
||||
// 디버그 정보에 API 키 정보 추가
|
||||
$debugInfo['api_key_info'] = [
|
||||
'path' => $foundKeyPath,
|
||||
'length' => strlen($googleApiKey),
|
||||
'prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
|
||||
'suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
|
||||
'format_valid' => !empty($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35) : false,
|
||||
'use_service_account' => $useServiceAccount,
|
||||
'service_account_path' => $serviceAccountPath
|
||||
];
|
||||
|
||||
if (isset($_GET['debug'])) {
|
||||
$debugInfo['full_response'] = $response;
|
||||
$debugInfo['request_url'] = $useServiceAccount ? $googleApiUrl : ($googleApiUrl . '?key=' . substr($googleApiKey, 0, 10) . '...');
|
||||
}
|
||||
|
||||
throw new Exception($errorMessage);
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
// 결과 처리
|
||||
if (isset($result['results']) && count($result['results']) > 0) {
|
||||
$transcript = '';
|
||||
foreach ($result['results'] as $resultItem) {
|
||||
if (isset($resultItem['alternatives'][0]['transcript'])) {
|
||||
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
|
||||
}
|
||||
}
|
||||
|
||||
$transcript = trim($transcript);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'transcript' => $transcript,
|
||||
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
|
||||
? $result['results'][0]['alternatives'][0]['confidence']
|
||||
: null,
|
||||
'auth_method' => $useServiceAccount ? 'service_account' : 'api_key'
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '음성을 인식할 수 없었습니다.',
|
||||
'debug' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errorResponse = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
|
||||
// 항상 API 키 정보 포함 (보안을 위해 일부만 표시)
|
||||
$errorResponse['debug'] = [
|
||||
'api_key_path' => $foundKeyPath ?? null,
|
||||
'api_key_length' => isset($googleApiKey) ? strlen($googleApiKey) : 0,
|
||||
'api_key_prefix' => isset($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
|
||||
'api_key_suffix' => isset($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
|
||||
'api_key_format' => isset($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 ? 'Google API Key (AIza...)' : 'Other format') : 'N/A',
|
||||
'use_service_account' => isset($useServiceAccount) ? $useServiceAccount : false,
|
||||
'service_account_path' => isset($serviceAccountPath) ? $serviceAccountPath : null,
|
||||
'document_root' => $docRoot ?? null,
|
||||
'checked_paths_count' => count($checkedPaths ?? [])
|
||||
];
|
||||
|
||||
// 상세 디버그 모드일 때 추가 정보
|
||||
if (isset($_GET['debug'])) {
|
||||
$errorResponse['debug']['exception_class'] = get_class($e);
|
||||
$errorResponse['debug']['file'] = $e->getFile();
|
||||
$errorResponse['debug']['line'] = $e->getLine();
|
||||
$errorResponse['debug']['checked_paths'] = $checkedPaths ?? [];
|
||||
}
|
||||
|
||||
echo json_encode($errorResponse, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
541
voice/dev.md
541
voice/dev.md
@@ -638,6 +638,15 @@ function cleanup() {
|
||||
|
||||
## 버전 히스토리
|
||||
|
||||
### v1.1.0 (2025-12-14)
|
||||
- ✅ 모바일 환경 지원: Google Cloud Speech-to-Text API 통합
|
||||
- ✅ 모바일 햄버거 메뉴 구현 (사이드바 네비게이션)
|
||||
- ✅ 모바일 디버그 패널 (온스크린 콘솔 로그)
|
||||
- ✅ 서비스 계정 인증 지원 (OAuth 2.0)
|
||||
- ✅ API 키 오류 시 자동 서비스 계정 전환
|
||||
- ✅ 모바일 화면 최적화 (뷰포트 설정, 확대 방지)
|
||||
- ✅ 실시간 오디오 청크 전송 (3초 간격)
|
||||
|
||||
### v1.0.0 (2025-11-05)
|
||||
- ✅ Web Speech API 기반 실시간 음성 인식
|
||||
- ✅ 오디오 파형 시각화
|
||||
@@ -679,6 +688,534 @@ function cleanup() {
|
||||
|
||||
---
|
||||
|
||||
**문서 버전**: 1.0
|
||||
**최종 업데이트**: 2025-11-05
|
||||
---
|
||||
|
||||
## 모바일 환경 지원 (v1.1.0)
|
||||
|
||||
### 개요
|
||||
모바일 브라우저에서 Web Speech API가 불안정하거나 작동하지 않는 문제를 해결하기 위해 Google Cloud Speech-to-Text API를 통합했습니다. 또한 모바일 환경에서 개발 및 디버깅을 위한 온스크린 디버그 패널과 햄버거 메뉴를 구현했습니다.
|
||||
|
||||
### 주요 변경 사항
|
||||
|
||||
#### 1. 모바일 환경 감지 및 API 자동 전환
|
||||
```javascript
|
||||
// 모바일 감지 함수
|
||||
function isMobileDevice() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
// 모바일에서는 자동으로 Google API 사용
|
||||
if (isMobile) {
|
||||
useGoogleAPI = true;
|
||||
console.log('모바일 감지: Google Cloud Speech-to-Text API 사용');
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Google Cloud Speech-to-Text API 통합
|
||||
|
||||
**클라이언트 사이드 (index.php):**
|
||||
```javascript
|
||||
// Google API로 오디오 녹음 시작
|
||||
async function startGoogleRecognition() {
|
||||
// 1. 마이크 권한 요청
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 16000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true
|
||||
}
|
||||
});
|
||||
|
||||
// 2. MediaRecorder 설정
|
||||
const options = {
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
audioBitsPerSecond: 16000
|
||||
};
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, options);
|
||||
audioChunks = [];
|
||||
|
||||
// 3. 오디오 데이터 수집
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 주기적으로 서버로 전송 (3초 간격)
|
||||
recordingInterval = setInterval(async () => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
mediaRecorder.start(3000);
|
||||
|
||||
if (audioChunks.length > 0) {
|
||||
const chunkBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
||||
audioChunks = [];
|
||||
await sendAudioToServer(chunkBlob, true); // 실시간 전송
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
mediaRecorder.start(3000); // 3초마다 데이터 수집
|
||||
}
|
||||
```
|
||||
|
||||
**서버 사이드 (api/speech_to_text.php):**
|
||||
```php
|
||||
// 서비스 계정 인증 (우선 사용)
|
||||
if ($serviceAccountPath && file_exists($serviceAccountPath)) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth 2.0 토큰 생성
|
||||
function getServiceAccountToken($serviceAccountPath) {
|
||||
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
|
||||
// JWT 생성
|
||||
$jwtHeader = base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$jwtClaim = base64UrlEncode(json_encode([
|
||||
'iss' => $serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => time() + 3600,
|
||||
'iat' => time()
|
||||
]));
|
||||
|
||||
// 서명 생성
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64UrlEncode($signature);
|
||||
|
||||
// OAuth 토큰 요청
|
||||
$ch = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
]));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$tokenData = json_decode($response, true);
|
||||
|
||||
return $tokenData['access_token'] ?? null;
|
||||
}
|
||||
|
||||
// API 호출 (서비스 계정 또는 API 키)
|
||||
if ($useServiceAccount && $accessToken) {
|
||||
$headers[] = 'Authorization: Bearer ' . $accessToken;
|
||||
} else {
|
||||
$apiUrl = $googleApiUrl . '?key=' . urlencode($googleApiKey);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 모바일 햄버거 메뉴
|
||||
|
||||
**HTML 구조:**
|
||||
```html
|
||||
<!-- 모바일 햄버거 메뉴 버튼 -->
|
||||
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="메뉴 열기">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
|
||||
<!-- 모바일 메뉴 오버레이 -->
|
||||
<div class="mobile-menu-overlay" id="mobile-menu-overlay"></div>
|
||||
|
||||
<!-- 모바일 사이드 메뉴 -->
|
||||
<div class="mobile-menu-sidebar" id="mobile-menu-sidebar">
|
||||
<div class="mobile-menu-header">
|
||||
<h4>메뉴</h4>
|
||||
<button class="mobile-menu-close" id="mobile-menu-close">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-menu-content">
|
||||
<nav class="navbar-nav">
|
||||
<!-- 원본 nav 내용이 여기에 복사됨 -->
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**CSS 스타일:**
|
||||
```css
|
||||
/* 햄버거 버튼 (모바일에서만 표시) */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navbar-custom {
|
||||
display: none; /* 데스크탑 메뉴 숨김 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 사이드 메뉴 */
|
||||
.mobile-menu-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -100%;
|
||||
width: 280px;
|
||||
max-width: 85%;
|
||||
height: 100%;
|
||||
background: white;
|
||||
z-index: 1000;
|
||||
transition: right 0.3s ease;
|
||||
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.mobile-menu-sidebar.active {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* 오버레이 */
|
||||
.mobile-menu-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
```
|
||||
|
||||
**JavaScript 기능:**
|
||||
```javascript
|
||||
function initMobileMenu() {
|
||||
const menuToggle = document.getElementById('mobile-menu-toggle');
|
||||
const menuSidebar = document.getElementById('mobile-menu-sidebar');
|
||||
const menuOverlay = document.getElementById('mobile-menu-overlay');
|
||||
|
||||
// 원본 nav 내용을 모바일 메뉴로 복사
|
||||
const originalNav = document.querySelector('.navbar-custom .navbar-nav');
|
||||
const menuContent = document.querySelector('.mobile-menu-content .navbar-nav');
|
||||
if (originalNav && menuContent) {
|
||||
menuContent.innerHTML = originalNav.innerHTML;
|
||||
}
|
||||
|
||||
// 메뉴 토글
|
||||
function toggleMenu() {
|
||||
const isOpen = menuSidebar.classList.contains('active');
|
||||
if (isOpen) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
menuSidebar.classList.add('active');
|
||||
menuOverlay.style.display = 'block';
|
||||
document.body.style.overflow = 'hidden'; // 스크롤 방지
|
||||
menuToggle.innerHTML = '<i class="bi bi-x-lg"></i>'; // X 아이콘으로 변경
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuSidebar.classList.remove('active');
|
||||
menuOverlay.style.display = 'none';
|
||||
document.body.style.overflow = ''; // 스크롤 복원
|
||||
menuToggle.innerHTML = '<i class="bi bi-list"></i>'; // 햄버거 아이콘으로 변경
|
||||
}
|
||||
|
||||
menuToggle.addEventListener('click', toggleMenu);
|
||||
menuOverlay.addEventListener('click', closeMenu);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 모바일 디버그 패널
|
||||
|
||||
**목적**: 모바일 환경에서 브라우저 콘솔을 볼 수 없어 디버깅이 어려운 문제를 해결하기 위해 온스크린 디버그 패널을 구현했습니다.
|
||||
|
||||
**HTML 구조:**
|
||||
```html
|
||||
<!-- 디버그 패널 토글 버튼 -->
|
||||
<button class="debug-toggle-btn" id="debug-toggle-btn" title="디버그 패널 열기/닫기">
|
||||
<i class="bi bi-bug"></i>
|
||||
</button>
|
||||
|
||||
<!-- 디버그 패널 -->
|
||||
<div class="debug-panel" id="debug-panel">
|
||||
<div class="debug-panel-header">
|
||||
<span class="debug-panel-title">🔍 디버그 콘솔</span>
|
||||
<div class="debug-panel-controls">
|
||||
<button class="debug-panel-btn" id="debug-copy-btn" title="로그 복사">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
<button class="debug-panel-btn" id="debug-clear-btn" title="로그 지우기">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<button class="debug-panel-btn" id="debug-close-btn" title="패널 닫기">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debug-panel-content" id="debug-content">
|
||||
<!-- 로그가 여기에 표시됨 -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**JavaScript 구현:**
|
||||
```javascript
|
||||
// 디버그 패널 기능
|
||||
(function() {
|
||||
const debugPanel = document.getElementById('debug-panel');
|
||||
const debugContent = document.getElementById('debug-content');
|
||||
const maxLogs = 100;
|
||||
|
||||
// 로그 추가
|
||||
function addLog(message, type = 'log') {
|
||||
const logDiv = document.createElement('div');
|
||||
logDiv.className = `debug-log ${type}`;
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString('ko-KR');
|
||||
const logMessage = typeof message === 'object' ? JSON.stringify(message, null, 2) : String(message);
|
||||
|
||||
logDiv.innerHTML = `<span style="color: #888; font-size: 10px;">[${timestamp}]</span> ${logMessage}`;
|
||||
|
||||
debugContent.appendChild(logDiv);
|
||||
|
||||
// 최대 로그 개수 제한
|
||||
while (debugContent.children.length > maxLogs) {
|
||||
debugContent.removeChild(debugContent.firstChild);
|
||||
}
|
||||
|
||||
// 자동 스크롤
|
||||
debugContent.scrollTop = debugContent.scrollHeight;
|
||||
}
|
||||
|
||||
// 로그 복사
|
||||
async function copyLogs() {
|
||||
const logs = Array.from(debugContent.children).map(log => {
|
||||
const type = log.className.replace('debug-log ', '');
|
||||
const text = log.textContent;
|
||||
return `[${type.toUpperCase()}] ${text}`;
|
||||
});
|
||||
|
||||
const logText = `=== 디버그 로그 ===\n생성 시간: ${new Date().toLocaleString('ko-KR')}\n로그 개수: ${logs.length}\n==================\n\n${logs.map((log, i) => `${i + 1}. ${log}`).join('\n')}`;
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(logText);
|
||||
// 복사 성공 피드백
|
||||
debugCopyBtn.innerHTML = '<i class="bi bi-check"></i>';
|
||||
debugCopyBtn.style.background = 'rgba(76, 175, 80, 0.3)';
|
||||
setTimeout(() => {
|
||||
debugCopyBtn.innerHTML = '<i class="bi bi-clipboard"></i>';
|
||||
debugCopyBtn.style.background = '';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// console 오버라이드
|
||||
const originalConsole = {
|
||||
log: console.log.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console)
|
||||
};
|
||||
|
||||
console.log = function(...args) {
|
||||
originalConsole.log(...args);
|
||||
addLog(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '), 'log');
|
||||
};
|
||||
|
||||
console.info = function(...args) {
|
||||
originalConsole.info(...args);
|
||||
addLog(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '), 'info');
|
||||
};
|
||||
|
||||
console.warn = function(...args) {
|
||||
originalConsole.warn(...args);
|
||||
addLog(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '), 'warn');
|
||||
};
|
||||
|
||||
console.error = function(...args) {
|
||||
originalConsole.error(...args);
|
||||
addLog(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '), 'error');
|
||||
};
|
||||
|
||||
// 전역 함수로 export
|
||||
window.debugLog = addLog;
|
||||
window.copyDebugLogs = copyLogs;
|
||||
})();
|
||||
```
|
||||
|
||||
#### 5. 모바일 화면 최적화
|
||||
|
||||
**뷰포트 설정 (확대 방지):**
|
||||
```html
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
```
|
||||
|
||||
**반응형 CSS:**
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.voice-container {
|
||||
padding: 15px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.record-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
width: 100%;
|
||||
min-height: 48px; /* 터치 영역 최소 크기 */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.voice-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.record-button {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 서비스 계정 설정
|
||||
|
||||
#### 1. Google Cloud Console에서 서비스 계정 생성
|
||||
1. [Google Cloud Console](https://console.cloud.google.com/) 접속
|
||||
2. 프로젝트 선택: `codebridge-chatbot`
|
||||
3. "API 및 서비스" > "사용자 인증 정보"
|
||||
4. "사용자 인증 정보 만들기" > "서비스 계정"
|
||||
5. 서비스 계정 이름 입력 후 생성
|
||||
6. 역할: "Cloud Speech-to-Text API 사용자" 또는 "Cloud Platform" 권한 부여
|
||||
7. "키" 탭에서 "키 추가" > "JSON" 선택하여 다운로드
|
||||
|
||||
#### 2. 서비스 계정 파일 배치
|
||||
```bash
|
||||
# 서비스 계정 JSON 파일을 apikey 디렉토리에 저장
|
||||
cp ~/Downloads/your-service-account.json /path/to/5130/apikey/google_service_account.json
|
||||
chmod 600 /path/to/5130/apikey/google_service_account.json
|
||||
```
|
||||
|
||||
#### 3. API 활성화
|
||||
- Google Cloud Console > "API 및 서비스" > "라이브러리"
|
||||
- "Cloud Speech-to-Text API" 검색 및 활성화
|
||||
|
||||
### 인증 우선순위
|
||||
|
||||
1. **서비스 계정** (우선 사용)
|
||||
- `google_service_account.json` 파일이 있으면 자동으로 사용
|
||||
- OAuth 2.0 토큰 생성 (1시간 유효)
|
||||
- API 키보다 안정적이고 보안이 강함
|
||||
|
||||
2. **API 키** (백업)
|
||||
- 서비스 계정이 없을 때만 사용
|
||||
- `google_api.txt` 파일에서 읽기
|
||||
- 형식: `AIzaSy...` (39자 이상)
|
||||
|
||||
3. **자동 재시도**
|
||||
- API 키 오류 발생 시 서비스 계정으로 자동 전환
|
||||
- 서비스 계정 토큰 만료 시 자동 재생성
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
voice/
|
||||
├── index.php # 메인 UI (모바일 지원 포함)
|
||||
├── api/
|
||||
│ └── speech_to_text.php # Google Cloud Speech-to-Text API 엔드포인트
|
||||
├── dev.md # 개발 문서 (이 파일)
|
||||
└── ...
|
||||
|
||||
apikey/
|
||||
├── google_service_account.json # 서비스 계정 키 (우선 사용)
|
||||
└── google_api.txt # API 키 (백업)
|
||||
```
|
||||
|
||||
### 모바일 테스트 방법
|
||||
|
||||
1. **디버그 패널 사용**
|
||||
- 모바일 화면 우측 하단의 버그 아이콘 클릭
|
||||
- 디버그 패널에서 모든 로그 확인
|
||||
- "복사" 버튼으로 로그 전체 복사 가능
|
||||
|
||||
2. **네트워크 확인**
|
||||
- 모바일에서 HTTPS 연결 확인
|
||||
- 마이크 권한 허용 확인
|
||||
- Google API 응답 확인 (디버그 패널에서)
|
||||
|
||||
3. **인증 방법 확인**
|
||||
- 디버그 로그에서 `auth_method: 'service_account'` 확인
|
||||
- API 키 오류 발생 시 자동으로 서비스 계정으로 전환되는지 확인
|
||||
|
||||
### 주의사항
|
||||
|
||||
1. **서비스 계정 키 보안**
|
||||
- 서비스 계정 JSON 파일은 절대 공개 저장소에 커밋하지 않기
|
||||
- 파일 권한: `chmod 600`
|
||||
- `.gitignore`에 추가
|
||||
|
||||
2. **API 할당량**
|
||||
- Google Cloud Speech-to-Text API는 유료 서비스
|
||||
- 무료 할당량: 월 60분 (첫 12개월)
|
||||
- 이후: $0.006 per 15초
|
||||
|
||||
3. **모바일 브라우저 호환성**
|
||||
- Android Chrome: 완전 지원
|
||||
- iOS Safari: 제한적 지원 (Web Speech API 미지원)
|
||||
- 권장: Android Chrome 사용
|
||||
|
||||
### 트러블슈팅
|
||||
|
||||
#### 1. "API key not valid" 오류
|
||||
**원인**: API 키가 유효하지 않거나 형식이 잘못됨
|
||||
**해결**:
|
||||
- 서비스 계정 파일 확인 (`google_service_account.json`)
|
||||
- 서비스 계정이 있으면 자동으로 사용됨
|
||||
- API 키 형식 확인: `AIzaSy...`로 시작해야 함
|
||||
|
||||
#### 2. 모바일에서 텍스트 변환 안 됨
|
||||
**원인**: Web Speech API가 모바일에서 작동하지 않음
|
||||
**해결**:
|
||||
- 모바일에서는 자동으로 Google API 사용
|
||||
- 디버그 패널에서 "Google Cloud Speech-to-Text API 시작" 로그 확인
|
||||
- 서비스 계정 파일 경로 확인
|
||||
|
||||
#### 3. 디버그 패널이 보이지 않음
|
||||
**원인**: CSS 또는 JavaScript 오류
|
||||
**해결**:
|
||||
- 브라우저 콘솔에서 오류 확인
|
||||
- `debug-toggle-btn` 요소가 존재하는지 확인
|
||||
- 모바일에서만 표시되는지 확인 (데스크탑에서는 숨김)
|
||||
|
||||
---
|
||||
|
||||
**문서 버전**: 1.1
|
||||
**최종 업데이트**: 2025-12-14
|
||||
**작성자**: Claude Code Development Team
|
||||
|
||||
1768
voice/index.php
1768
voice/index.php
File diff suppressed because it is too large
Load Diff
527
voice_ai/api/speech_to_text.php
Normal file
527
voice_ai/api/speech_to_text.php
Normal file
@@ -0,0 +1,527 @@
|
||||
<?php
|
||||
/**
|
||||
* Google Cloud Speech-to-Text API 엔드포인트
|
||||
* 오디오 파일을 받아서 텍스트로 변환
|
||||
*/
|
||||
|
||||
// 타임아웃 설정 (504 오류 방지)
|
||||
ini_set('max_execution_time', 120); // 120초
|
||||
set_time_limit(120);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: POST');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
|
||||
// 권한 체크
|
||||
if ($level > 5) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '접근 권한이 없습니다.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 세션 쓰기 닫기 (이후 긴 작업 시 세션 잠금 방지)
|
||||
session_write_close();
|
||||
|
||||
// POST 요청만 허용
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'POST 요청만 허용됩니다.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 오디오 파일 확인
|
||||
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '오디오 파일이 전송되지 않았습니다.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$audioFile = $_FILES['audio'];
|
||||
$audioPath = $audioFile['tmp_name'];
|
||||
$audioType = $audioFile['type'];
|
||||
|
||||
// 오디오 파일 유효성 검사
|
||||
$allowedTypes = ['audio/webm', 'audio/wav', 'audio/ogg', 'audio/mp3', 'audio/mpeg', 'audio/x-flac'];
|
||||
if (!in_array($audioType, $allowedTypes)) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '지원하지 않는 오디오 형식입니다. (webm, wav, ogg, mp3, flac 지원)'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Google Cloud Speech-to-Text API 설정
|
||||
$googleApiKey = '';
|
||||
$accessToken = null; // 서비스 계정용 OAuth 토큰
|
||||
$useServiceAccount = false;
|
||||
$googleApiUrl = 'https://speech.googleapis.com/v1/speech:recognize';
|
||||
|
||||
// API 키 파일 경로 (우선순위 순)
|
||||
// 상대 경로와 절대 경로 모두 확인
|
||||
$docRoot = $_SERVER['DOCUMENT_ROOT'];
|
||||
$apiKeyPaths = [
|
||||
$docRoot . '/5130/apikey/google_api.txt',
|
||||
$docRoot . '/apikey/google_api.txt',
|
||||
dirname($docRoot) . '/5130/apikey/google_api.txt', // 상위 디렉토리에서 확인
|
||||
dirname($docRoot) . '/apikey/google_api.txt',
|
||||
__DIR__ . '/../../apikey/google_api.txt', // 현재 파일 기준 상대 경로
|
||||
__DIR__ . '/../../../apikey/google_api.txt',
|
||||
$docRoot . '/5130/config/google_speech_api.php',
|
||||
$docRoot . '/config/google_speech_api.php',
|
||||
];
|
||||
|
||||
// 서비스 계정 파일 경로
|
||||
$serviceAccountPaths = [
|
||||
$docRoot . '/5130/apikey/google_service_account.json',
|
||||
$docRoot . '/apikey/google_service_account.json',
|
||||
dirname($docRoot) . '/5130/apikey/google_service_account.json',
|
||||
dirname($docRoot) . '/apikey/google_service_account.json',
|
||||
__DIR__ . '/../../apikey/google_service_account.json',
|
||||
__DIR__ . '/../../../apikey/google_service_account.json',
|
||||
];
|
||||
|
||||
// API 키 파일에서 읽기
|
||||
$foundKeyPath = null;
|
||||
$checkedPaths = [];
|
||||
|
||||
foreach ($apiKeyPaths as $keyPath) {
|
||||
$checkedPaths[] = $keyPath . ' (exists: ' . (file_exists($keyPath) ? 'yes' : 'no') . ')';
|
||||
|
||||
if (file_exists($keyPath)) {
|
||||
$foundKeyPath = $keyPath;
|
||||
if (pathinfo($keyPath, PATHINFO_EXTENSION) === 'php') {
|
||||
// PHP 설정 파일인 경우
|
||||
require_once($keyPath);
|
||||
if (isset($GOOGLE_SPEECH_API_KEY)) {
|
||||
$googleApiKey = trim($GOOGLE_SPEECH_API_KEY);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 텍스트 파일인 경우 (google_api.txt)
|
||||
$keyContent = file_get_contents($keyPath);
|
||||
$googleApiKey = trim($keyContent);
|
||||
|
||||
// 빈 줄이나 주석 제거
|
||||
$lines = explode("\n", $googleApiKey);
|
||||
$googleApiKey = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (!empty($line) && !preg_match('/^#/', $line)) {
|
||||
$googleApiKey = $line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($googleApiKey)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 계정 파일 찾기
|
||||
$serviceAccountPath = null;
|
||||
foreach ($serviceAccountPaths as $saPath) {
|
||||
if (file_exists($saPath)) {
|
||||
$serviceAccountPath = $saPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth 2.0 토큰 생성 함수 (서비스 계정용)
|
||||
function getServiceAccountToken($serviceAccountPath) {
|
||||
if (!file_exists($serviceAccountPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
if (!$serviceAccount || !isset($serviceAccount['private_key']) || !isset($serviceAccount['client_email'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Base64 URL 인코딩 함수
|
||||
$base64UrlEncode = function($data) {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
};
|
||||
|
||||
// JWT 생성
|
||||
$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
|
||||
];
|
||||
|
||||
$encodedHeader = $base64UrlEncode(json_encode($jwtHeader));
|
||||
$encodedClaim = $base64UrlEncode(json_encode($jwtClaim));
|
||||
|
||||
// 서명 생성
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$signature = '';
|
||||
$signData = $encodedHeader . '.' . $encodedClaim;
|
||||
if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
|
||||
openssl_free_key($privateKey);
|
||||
return null;
|
||||
}
|
||||
openssl_free_key($privateKey);
|
||||
|
||||
$encodedSignature = $base64UrlEncode($signature);
|
||||
$jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature;
|
||||
|
||||
// OAuth 2.0 토큰 요청
|
||||
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt($tokenCh, CURLOPT_POST, true);
|
||||
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
]));
|
||||
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
|
||||
// 타임아웃 설정
|
||||
curl_setopt($tokenCh, CURLOPT_TIMEOUT, 30); // 30초
|
||||
curl_setopt($tokenCh, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
|
||||
|
||||
$tokenResponse = curl_exec($tokenCh);
|
||||
$tokenHttpCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
|
||||
curl_close($tokenCh);
|
||||
|
||||
if ($tokenHttpCode === 200) {
|
||||
$tokenData = json_decode($tokenResponse, true);
|
||||
if (isset($tokenData['access_token'])) {
|
||||
return $tokenData['access_token'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// API 키가 없거나 형식이 잘못된 경우 서비스 계정 시도
|
||||
if (empty($googleApiKey) || strlen($googleApiKey) < 20) {
|
||||
if ($serviceAccountPath) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$useServiceAccount) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Google Cloud Speech-to-Text API 인증 정보가 없습니다.',
|
||||
'hint' => 'API 키 파일 또는 서비스 계정 파일을 찾을 수 없습니다.',
|
||||
'checked_paths' => $checkedPaths,
|
||||
'service_account_path' => $serviceAccountPath,
|
||||
'document_root' => $docRoot,
|
||||
'current_dir' => __DIR__
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// API 키 형식 확인 (Google API 키는 보통 AIza로 시작)
|
||||
$isValidFormat = (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35);
|
||||
if (!$isValidFormat && !$useServiceAccount) {
|
||||
// API 키 형식이 잘못된 경우 서비스 계정 시도
|
||||
if ($serviceAccountPath) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
$googleApiKey = ''; // API 키 사용 안 함
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 계정이 있으면 우선 사용 (API 키보다 안정적)
|
||||
// API 키는 간헐적으로 오류가 발생할 수 있으므로 서비스 계정을 기본으로 사용
|
||||
if (!$useServiceAccount && $serviceAccountPath && file_exists($serviceAccountPath)) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
// API 키는 백업으로 유지하되 서비스 계정 우선 사용
|
||||
error_log('Using service account for authentication (more reliable than API key)');
|
||||
}
|
||||
}
|
||||
|
||||
// API 키 디버깅 정보
|
||||
$debugInfo = [
|
||||
'api_key_path' => $foundKeyPath,
|
||||
'api_key_length' => strlen($googleApiKey),
|
||||
'api_key_prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
|
||||
'api_key_suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
|
||||
'use_service_account' => $useServiceAccount,
|
||||
'service_account_path' => $serviceAccountPath,
|
||||
'document_root' => $docRoot,
|
||||
'checked_paths' => $checkedPaths
|
||||
];
|
||||
|
||||
try {
|
||||
// 오디오 파일을 base64로 인코딩
|
||||
$audioContent = file_get_contents($audioPath);
|
||||
$audioBase64 = base64_encode($audioContent);
|
||||
|
||||
// 오디오 형식에 따른 encoding 설정
|
||||
$encoding = 'WEBM_OPUS';
|
||||
$sampleRate = 16000; // 기본값
|
||||
|
||||
if (strpos($audioType, 'wav') !== false) {
|
||||
$encoding = 'LINEAR16';
|
||||
$sampleRate = 16000;
|
||||
} elseif (strpos($audioType, 'ogg') !== false) {
|
||||
$encoding = 'OGG_OPUS';
|
||||
$sampleRate = 48000; // Opus는 보통 48kHz
|
||||
} elseif (strpos($audioType, 'flac') !== false) {
|
||||
$encoding = 'FLAC';
|
||||
$sampleRate = 16000;
|
||||
} elseif (strpos($audioType, 'webm') !== false) {
|
||||
$encoding = 'WEBM_OPUS';
|
||||
$sampleRate = 48000; // WebM Opus는 보통 48kHz
|
||||
}
|
||||
|
||||
// Google Cloud Speech-to-Text API 요청 데이터
|
||||
$requestData = [
|
||||
'config' => [
|
||||
'encoding' => $encoding,
|
||||
'sampleRateHertz' => $sampleRate,
|
||||
'languageCode' => 'ko-KR',
|
||||
'enableAutomaticPunctuation' => true,
|
||||
'enableWordTimeOffsets' => false,
|
||||
'model' => 'latest_long', // 긴 오디오에 적합
|
||||
'alternativeLanguageCodes' => ['ko'], // 추가 언어 코드
|
||||
],
|
||||
'audio' => [
|
||||
'content' => $audioBase64
|
||||
]
|
||||
];
|
||||
|
||||
// 디버깅 정보 (개발 환경에서만)
|
||||
if (isset($_GET['debug'])) {
|
||||
error_log('Google Speech API Request: ' . json_encode([
|
||||
'encoding' => $encoding,
|
||||
'sampleRate' => $sampleRate,
|
||||
'audioSize' => strlen($audioBase64),
|
||||
'audioType' => $audioType
|
||||
]));
|
||||
}
|
||||
|
||||
// cURL로 API 호출
|
||||
$headers = ['Content-Type: application/json'];
|
||||
$apiUrl = $googleApiUrl;
|
||||
|
||||
if ($useServiceAccount && $accessToken) {
|
||||
// 서비스 계정 사용: Bearer 토큰 사용
|
||||
$headers[] = 'Authorization: Bearer ' . $accessToken;
|
||||
} else if (!empty($googleApiKey)) {
|
||||
// API 키 사용: 쿼리 파라미터로 전달
|
||||
$apiUrl = $googleApiUrl . '?key=' . urlencode($googleApiKey);
|
||||
} else {
|
||||
throw new Exception('인증 정보가 없습니다. API 키 또는 서비스 계정이 필요합니다.');
|
||||
}
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
// 타임아웃 설정 (504 오류 방지)
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 60초
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
throw new Exception('cURL 오류: ' . $curlError);
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$errorData = json_decode($response, true);
|
||||
$errorMessage = 'API 오류 (HTTP ' . $httpCode . ')';
|
||||
$debugInfo = [];
|
||||
|
||||
if (isset($errorData['error'])) {
|
||||
$errorMessage .= ': ' . ($errorData['error']['message'] ?? '알 수 없는 오류');
|
||||
$debugInfo['error_details'] = $errorData['error'];
|
||||
|
||||
// API 키 오류인 경우 서비스 계정으로 재시도
|
||||
if (isset($errorData['error']['message']) &&
|
||||
(stripos($errorData['error']['message'], 'API key') !== false ||
|
||||
stripos($errorData['error']['message'], 'not valid') !== false ||
|
||||
stripos($errorData['error']['message'], 'invalid') !== false ||
|
||||
stripos($errorData['error']['message'], 'unauthorized') !== false)) {
|
||||
|
||||
// 서비스 계정으로 재시도
|
||||
if ($serviceAccountPath && file_exists($serviceAccountPath)) {
|
||||
// 서비스 계정을 사용 중이었는데도 오류가 발생하면 토큰 재생성
|
||||
if ($useServiceAccount) {
|
||||
error_log('Service account token may have expired, regenerating...');
|
||||
}
|
||||
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
// 서비스 계정으로 재시도
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $accessToken
|
||||
];
|
||||
|
||||
$ch = curl_init($googleApiUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
// 타임아웃 설정 (504 오류 방지)
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 60초
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
// 서비스 계정으로 성공
|
||||
$result = json_decode($response, true);
|
||||
if (isset($result['results']) && count($result['results']) > 0) {
|
||||
$transcript = '';
|
||||
foreach ($result['results'] as $resultItem) {
|
||||
if (isset($resultItem['alternatives'][0]['transcript'])) {
|
||||
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
|
||||
}
|
||||
}
|
||||
|
||||
$transcript = trim($transcript);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'transcript' => $transcript,
|
||||
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
|
||||
? $result['results'][0]['alternatives'][0]['confidence']
|
||||
: null,
|
||||
'auth_method' => 'service_account',
|
||||
'retry_success' => true
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 재시도 실패 시 상세 안내
|
||||
$errorMessage .= "\n\n⚠️ API 키 오류 해결 방법:\n";
|
||||
$errorMessage .= "1. Google Cloud Console (https://console.cloud.google.com/) 접속\n";
|
||||
$errorMessage .= "2. 프로젝트 선택: codebridge-chatbot\n";
|
||||
$errorMessage .= "3. 'API 및 서비스' > '라이브러리'에서 'Cloud Speech-to-Text API' 검색 및 활성화\n";
|
||||
$errorMessage .= "4. 'API 및 서비스' > '사용자 인증 정보'에서 API 키 확인\n";
|
||||
$errorMessage .= "5. API 키 제한 설정에서 'Cloud Speech-to-Text API' 허용 확인\n";
|
||||
$errorMessage .= "\n현재 API 키 파일: " . ($foundKeyPath ?? 'N/A');
|
||||
$errorMessage .= "\nAPI 키 길이: " . strlen($googleApiKey) . " 문자";
|
||||
if ($serviceAccountPath) {
|
||||
$errorMessage .= "\n서비스 계정 파일: " . $serviceAccountPath;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$errorMessage .= ': ' . substr($response, 0, 200);
|
||||
}
|
||||
|
||||
// 디버그 정보에 API 키 정보 추가
|
||||
$debugInfo['api_key_info'] = [
|
||||
'path' => $foundKeyPath,
|
||||
'length' => strlen($googleApiKey),
|
||||
'prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
|
||||
'suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
|
||||
'format_valid' => !empty($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35) : false,
|
||||
'use_service_account' => $useServiceAccount,
|
||||
'service_account_path' => $serviceAccountPath
|
||||
];
|
||||
|
||||
if (isset($_GET['debug'])) {
|
||||
$debugInfo['full_response'] = $response;
|
||||
$debugInfo['request_url'] = $useServiceAccount ? $googleApiUrl : ($googleApiUrl . '?key=' . substr($googleApiKey, 0, 10) . '...');
|
||||
}
|
||||
|
||||
throw new Exception($errorMessage);
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
// 결과 처리
|
||||
if (isset($result['results']) && count($result['results']) > 0) {
|
||||
$transcript = '';
|
||||
foreach ($result['results'] as $resultItem) {
|
||||
if (isset($resultItem['alternatives'][0]['transcript'])) {
|
||||
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
|
||||
}
|
||||
}
|
||||
|
||||
$transcript = trim($transcript);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'transcript' => $transcript,
|
||||
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
|
||||
? $result['results'][0]['alternatives'][0]['confidence']
|
||||
: null,
|
||||
'auth_method' => $useServiceAccount ? 'service_account' : 'api_key'
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '음성을 인식할 수 없었습니다.',
|
||||
'debug' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errorResponse = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
|
||||
// 항상 API 키 정보 포함 (보안을 위해 일부만 표시)
|
||||
$errorResponse['debug'] = [
|
||||
'api_key_path' => $foundKeyPath ?? null,
|
||||
'api_key_length' => isset($googleApiKey) ? strlen($googleApiKey) : 0,
|
||||
'api_key_prefix' => isset($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
|
||||
'api_key_suffix' => isset($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
|
||||
'api_key_format' => isset($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 ? 'Google API Key (AIza...)' : 'Other format') : 'N/A',
|
||||
'use_service_account' => isset($useServiceAccount) ? $useServiceAccount : false,
|
||||
'service_account_path' => isset($serviceAccountPath) ? $serviceAccountPath : null,
|
||||
'document_root' => $docRoot ?? null,
|
||||
'checked_paths_count' => count($checkedPaths ?? [])
|
||||
];
|
||||
|
||||
// 상세 디버그 모드일 때 추가 정보
|
||||
if (isset($_GET['debug'])) {
|
||||
$errorResponse['debug']['exception_class'] = get_class($e);
|
||||
$errorResponse['debug']['file'] = $e->getFile();
|
||||
$errorResponse['debug']['line'] = $e->getLine();
|
||||
$errorResponse['debug']['checked_paths'] = $checkedPaths ?? [];
|
||||
}
|
||||
|
||||
echo json_encode($errorResponse, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
370
voice_ai/check_meeting_status.php
Normal file
370
voice_ai/check_meeting_status.php
Normal file
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
// 출력 버퍼링 시작
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
ob_start();
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('log_errors', 1);
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
|
||||
|
||||
// 에러 응답 함수
|
||||
function sendErrorResponse($message, $details = null) {
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$response = ['ok' => false, 'error' => $message];
|
||||
if ($details !== null) {
|
||||
$response['details'] = $details;
|
||||
}
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 출력 버퍼 비우기
|
||||
ob_clean();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
// 1. 권한 체크
|
||||
if (!isset($user_id) || $level > 5) {
|
||||
sendErrorResponse('접근 권한이 없습니다.');
|
||||
}
|
||||
|
||||
// 2. 파라미터 확인
|
||||
$meeting_id = isset($_GET['meeting_id']) ? (int)$_GET['meeting_id'] : 0;
|
||||
$operation_name = isset($_GET['operation_name']) ? trim($_GET['operation_name']) : '';
|
||||
|
||||
if (!$meeting_id || !$operation_name) {
|
||||
sendErrorResponse('필수 파라미터가 없습니다.', 'meeting_id와 operation_name이 필요합니다.');
|
||||
}
|
||||
|
||||
// 3. Google API 인증 정보 가져오기
|
||||
$googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json';
|
||||
$googleApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_api.txt';
|
||||
|
||||
$accessToken = null;
|
||||
$googleApiKey = null;
|
||||
|
||||
// 서비스 계정 우선 사용
|
||||
if (file_exists($googleServiceAccountFile)) {
|
||||
$serviceAccount = json_decode(file_get_contents($googleServiceAccountFile), true);
|
||||
if ($serviceAccount) {
|
||||
// OAuth 2.0 토큰 생성
|
||||
$now = time();
|
||||
$jwtHeader = ['alg' => 'RS256', 'typ' => '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
|
||||
];
|
||||
|
||||
$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) {
|
||||
$signature = '';
|
||||
$signData = $encodedHeader . '.' . $encodedClaim;
|
||||
if (openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
|
||||
openssl_free_key($privateKey);
|
||||
$encodedSignature = $base64UrlEncode($signature);
|
||||
$jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature;
|
||||
|
||||
// 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) {
|
||||
$tokenData = json_decode($tokenResponse, true);
|
||||
if (isset($tokenData['access_token'])) {
|
||||
$accessToken = $tokenData['access_token'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API 키 사용
|
||||
if (!$accessToken && file_exists($googleApiKeyFile)) {
|
||||
$googleApiKey = trim(file_get_contents($googleApiKeyFile));
|
||||
}
|
||||
|
||||
if (!$accessToken && !$googleApiKey) {
|
||||
sendErrorResponse('Google API 인증 정보가 없습니다.');
|
||||
}
|
||||
|
||||
// 4. Google API에서 작업 상태 확인
|
||||
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);
|
||||
}
|
||||
curl_setopt($poll_ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$poll_response = curl_exec($poll_ch);
|
||||
$poll_code = curl_getinfo($poll_ch, CURLINFO_HTTP_CODE);
|
||||
$poll_error = curl_error($poll_ch);
|
||||
curl_close($poll_ch);
|
||||
|
||||
if ($poll_code !== 200) {
|
||||
sendErrorResponse('작업 상태 확인 실패 (HTTP ' . $poll_code . ')', $poll_error ?: substr($poll_response, 0, 500));
|
||||
}
|
||||
|
||||
$poll_data = json_decode($poll_response, true);
|
||||
if (!$poll_data) {
|
||||
sendErrorResponse('작업 상태 응답 파싱 실패', substr($poll_response, 0, 500));
|
||||
}
|
||||
|
||||
// 5. 작업 완료 여부 확인
|
||||
if (!isset($poll_data['done']) || $poll_data['done'] !== true) {
|
||||
// 아직 처리 중
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'processing' => true,
|
||||
'done' => false,
|
||||
'message' => '음성 인식 처리 중입니다. 잠시 후 다시 확인해주세요.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 6. 작업 완료 - 결과 처리
|
||||
if (isset($poll_data['error'])) {
|
||||
// 오류 발생
|
||||
$pdo = db_connect();
|
||||
$errorMsg = isset($poll_data['error']['message']) ? $poll_data['error']['message'] : '알 수 없는 오류';
|
||||
|
||||
$updateSql = "UPDATE meeting_logs SET
|
||||
title = ?,
|
||||
summary_text = ?
|
||||
WHERE id = ?";
|
||||
$updateStmt = $pdo->prepare($updateSql);
|
||||
$updateStmt->execute(['오류 발생', 'Google STT 변환 실패: ' . $errorMsg, $meeting_id]);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'Google STT 변환 실패',
|
||||
'details' => $errorMsg
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 응답 구조 확인 및 로깅
|
||||
error_log('Google STT 응답 구조: ' . json_encode([
|
||||
'has_response' => isset($poll_data['response']),
|
||||
'has_results' => isset($poll_data['response']['results']),
|
||||
'results_count' => isset($poll_data['response']['results']) ? count($poll_data['response']['results']) : 0,
|
||||
'response_keys' => isset($poll_data['response']) ? array_keys($poll_data['response']) : [],
|
||||
'poll_data_keys' => array_keys($poll_data)
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// response 필드가 없는 경우
|
||||
if (!isset($poll_data['response'])) {
|
||||
error_log('Google STT 응답에 response 필드가 없습니다. 전체 응답: ' . json_encode($poll_data, JSON_UNESCAPED_UNICODE));
|
||||
sendErrorResponse('Google STT 응답 구조 오류', '응답에 response 필드가 없습니다. 작업이 완료되었지만 결과를 가져올 수 없습니다.');
|
||||
}
|
||||
|
||||
// results가 없는 경우
|
||||
if (!isset($poll_data['response']['results'])) {
|
||||
error_log('Google STT 응답에 results 필드가 없습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE));
|
||||
sendErrorResponse('Google STT 응답에 결과가 없습니다.', '응답에 results 필드가 없습니다. 음성이 인식되지 않았을 수 있습니다.');
|
||||
}
|
||||
|
||||
// results가 비어있는 경우
|
||||
if (empty($poll_data['response']['results'])) {
|
||||
error_log('Google STT 응답에 results가 비어있습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// 빈 결과를 DB에 저장하고 사용자에게 알림
|
||||
$pdo = db_connect();
|
||||
$updateSql = "UPDATE meeting_logs SET
|
||||
title = ?,
|
||||
transcript_text = ?,
|
||||
summary_text = ?
|
||||
WHERE id = ?";
|
||||
$updateStmt = $pdo->prepare($updateSql);
|
||||
$updateStmt->execute(['음성 인식 실패', '음성이 인식되지 않았습니다.', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.', $meeting_id]);
|
||||
|
||||
sendErrorResponse('음성 인식 실패', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.');
|
||||
}
|
||||
|
||||
// 7. 텍스트 변환
|
||||
$stt_data = ['results' => $poll_data['response']['results']];
|
||||
$transcript = '';
|
||||
foreach ($stt_data['results'] as $result) {
|
||||
if (isset($result['alternatives'][0]['transcript'])) {
|
||||
$transcript .= $result['alternatives'][0]['transcript'] . ' ';
|
||||
}
|
||||
}
|
||||
$transcript = trim($transcript);
|
||||
|
||||
if (empty($transcript)) {
|
||||
sendErrorResponse('인식된 텍스트가 없습니다.');
|
||||
}
|
||||
|
||||
// 8. Claude API로 요약 생성
|
||||
$claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
|
||||
$claudeKey = '';
|
||||
if (file_exists($claudeKeyFile)) {
|
||||
$claudeKey = trim(file_get_contents($claudeKeyFile));
|
||||
}
|
||||
|
||||
$title = '무제 회의록';
|
||||
$summary = '';
|
||||
|
||||
if (!empty($claudeKey)) {
|
||||
$prompt = "다음 회의 녹취록을 분석하여 JSON 형식으로 응답해주세요.
|
||||
|
||||
요구사항:
|
||||
1. \"title\": 회의 내용을 요약한 제목 (최대 20자, 한글 기준)
|
||||
2. \"summary\": [회의 개요 / 주요 안건 / 결정 사항 / 향후 계획] 형식의 상세 요약
|
||||
|
||||
반드시 다음 JSON 형식으로만 응답해주세요:
|
||||
{
|
||||
\"title\": \"제목 (최대 20자)\",
|
||||
\"summary\": \"상세 요약 내용\"
|
||||
}
|
||||
|
||||
회의 녹취록:
|
||||
" . $transcript;
|
||||
|
||||
$ch2 = curl_init('https://api.anthropic.com/v1/messages');
|
||||
$requestBody = [
|
||||
'model' => 'claude-3-5-haiku-20241022',
|
||||
'max_tokens' => 2048,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]]
|
||||
];
|
||||
|
||||
curl_setopt($ch2, CURLOPT_POST, true);
|
||||
curl_setopt($ch2, CURLOPT_HTTPHEADER, [
|
||||
'x-api-key: ' . $claudeKey,
|
||||
'anthropic-version: 2023-06-01',
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($requestBody));
|
||||
curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$ai_response = curl_exec($ch2);
|
||||
$ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch2);
|
||||
|
||||
if ($ai_code === 200) {
|
||||
$ai_data = json_decode($ai_response, true);
|
||||
if (isset($ai_data['content'][0]['text'])) {
|
||||
$ai_text = trim($ai_data['content'][0]['text']);
|
||||
$ai_text = preg_replace('/^```(?:json)?\s*/m', '', $ai_text);
|
||||
$ai_text = preg_replace('/\s*```$/m', '', $ai_text);
|
||||
$ai_text = trim($ai_text);
|
||||
|
||||
$parsed_data = json_decode($ai_text, true);
|
||||
|
||||
if (is_array($parsed_data)) {
|
||||
if (isset($parsed_data['title']) && !empty($parsed_data['title'])) {
|
||||
$title = mb_substr(trim($parsed_data['title']), 0, 20, 'UTF-8');
|
||||
}
|
||||
if (isset($parsed_data['summary']) && !empty($parsed_data['summary'])) {
|
||||
$summary = $parsed_data['summary'];
|
||||
} else {
|
||||
$summary = $ai_text;
|
||||
}
|
||||
} else {
|
||||
$summary = $ai_text;
|
||||
if (preg_match('/["\']?title["\']?\s*[:=]\s*["\']([^"\']{1,20})["\']/', $ai_text, $matches)) {
|
||||
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
|
||||
} elseif (preg_match('/제목[:\s]+([^\n]{1,20})/', $ai_text, $matches)) {
|
||||
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
|
||||
} else {
|
||||
$lines = explode("\n", $ai_text);
|
||||
$first_line = trim($lines[0]);
|
||||
if (!empty($first_line) && mb_strlen($first_line, 'UTF-8') <= 20) {
|
||||
$title = $first_line;
|
||||
} elseif (!empty($first_line)) {
|
||||
$title = mb_substr($first_line, 0, 20, 'UTF-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($title) || $title === '무제 회의록') {
|
||||
if (!empty($summary)) {
|
||||
$summary_lines = explode("\n", $summary);
|
||||
$first_summary_line = trim($summary_lines[0]);
|
||||
if (!empty($first_summary_line)) {
|
||||
$first_summary_line = preg_replace('/[\[\(].*?[\]\)]/', '', $first_summary_line);
|
||||
$first_summary_line = trim($first_summary_line);
|
||||
if (mb_strlen($first_summary_line, 'UTF-8') <= 20) {
|
||||
$title = $first_summary_line;
|
||||
} else {
|
||||
$title = mb_substr($first_summary_line, 0, 20, 'UTF-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$title = mb_substr(trim($title), 0, 20, 'UTF-8');
|
||||
if (empty($title)) {
|
||||
$title = '무제 회의록';
|
||||
}
|
||||
|
||||
if (empty($summary)) {
|
||||
$summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)";
|
||||
}
|
||||
|
||||
// 9. DB 업데이트
|
||||
$pdo = db_connect();
|
||||
$updateSql = "UPDATE meeting_logs SET
|
||||
title = ?,
|
||||
transcript_text = ?,
|
||||
summary_text = ?
|
||||
WHERE id = ?";
|
||||
$updateStmt = $pdo->prepare($updateSql);
|
||||
$updateStmt->execute([$title, $transcript, $summary, $meeting_id]);
|
||||
|
||||
// 10. 완료 응답
|
||||
@ob_clean();
|
||||
@ob_end_clean();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'processing' => false,
|
||||
'done' => true,
|
||||
'meeting_id' => $meeting_id,
|
||||
'title' => $title,
|
||||
'transcript' => $transcript,
|
||||
'summary' => $summary,
|
||||
'message' => '음성 인식 및 요약이 완료되었습니다.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
|
||||
114
voice_ai/fix_meeting_db.php
Normal file
114
voice_ai/fix_meeting_db.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
// fix_meeting_db.php
|
||||
// meeting_logs 테이블의 id 컬럼에 AUTO_INCREMENT 속성을 추가하고
|
||||
// 중복된 id=0 레코드를 수정하는 스크립트
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
// DB 연결 설정
|
||||
$host = '127.0.0.1';
|
||||
$dbname = 'checker'; // .env를 로드하지 못할 경우를 대비한 기본값
|
||||
$username = 'root';
|
||||
$password = 'root';
|
||||
|
||||
echo "Database Fix Script for meeting_logs\n";
|
||||
echo "=====================================\n";
|
||||
|
||||
// 1. .env 파일 로드 시도
|
||||
$envPath = __DIR__ . '/../.env';
|
||||
if (file_exists($envPath)) {
|
||||
echo "Loading .env file...\n";
|
||||
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0) continue;
|
||||
list($name, $value) = explode('=', $line, 2);
|
||||
$_ENV[trim($name)] = trim($value);
|
||||
}
|
||||
|
||||
if (isset($_ENV['DB_HOST'])) $host = $_ENV['DB_HOST'];
|
||||
if (isset($_ENV['DB_NAME'])) $dbname = $_ENV['DB_NAME'];
|
||||
if (isset($_ENV['DB_USER'])) $username = $_ENV['DB_USER'];
|
||||
if (isset($_ENV['DB_PASSWORD'])) $password = $_ENV['DB_PASSWORD'];
|
||||
} else {
|
||||
echo "Warning: .env file not found. Using default credentials.\n";
|
||||
}
|
||||
|
||||
// 2. DB 연결
|
||||
try {
|
||||
echo "Connecting to database ($host, $dbname)...\n";
|
||||
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8", $username, $password);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
echo "Connected successfully.\n";
|
||||
|
||||
// 3. 중복된 ID 확인 (id=0)
|
||||
echo "Checking for duplicate id=0 in meeting_logs...\n";
|
||||
$stmt = $pdo->query("SELECT COUNT(*) FROM meeting_logs WHERE id = 0");
|
||||
$count = $stmt->fetchColumn();
|
||||
echo "Found $count records with id=0.\n";
|
||||
|
||||
if ($count > 0) {
|
||||
// 4. MAX(id) 확인
|
||||
$stmt = $pdo->query("SELECT MAX(id) FROM meeting_logs");
|
||||
$maxId = $stmt->fetchColumn();
|
||||
if (!$maxId) $maxId = 0;
|
||||
echo "Current MAX(id) is $maxId.\n";
|
||||
|
||||
// 5. id=0인 레코드들 업데이트
|
||||
$stmt = $pdo->query("SELECT * FROM meeting_logs WHERE id = 0");
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$newId = $maxId + 1;
|
||||
foreach ($rows as $row) {
|
||||
// 중복된 id=0 레코드를 삭제하고 새로운 ID로 다시 삽입하는 것이 안전함
|
||||
// (PRIMARY KEY 제약 조건 때문에 UPDATE가 실패할 수 있음)
|
||||
|
||||
// 데이터 복사
|
||||
$insertSql = "INSERT INTO meeting_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, created_at, file_expiry_date)
|
||||
VALUES (:tenant_id, :user_id, :title, :audio_file_path, :transcript_text, :summary_text, :created_at, :file_expiry_date)";
|
||||
|
||||
$insertStmt = $pdo->prepare($insertSql);
|
||||
$insertStmt->execute([
|
||||
':tenant_id' => $row['tenant_id'],
|
||||
':user_id' => $row['user_id'],
|
||||
':title' => $row['title'],
|
||||
':audio_file_path' => $row['audio_file_path'],
|
||||
':transcript_text' => $row['transcript_text'],
|
||||
':summary_text' => $row['summary_text'],
|
||||
':created_at' => $row['created_at'],
|
||||
':file_expiry_date' => $row['file_expiry_date']
|
||||
]);
|
||||
|
||||
echo "Moved record (old id=0) to new id=" . $pdo->lastInsertId() . "\n";
|
||||
}
|
||||
|
||||
// 원래 id=0 레코드 삭제
|
||||
$pdo->exec("DELETE FROM meeting_logs WHERE id = 0");
|
||||
echo "Deleted old records with id=0.\n";
|
||||
}
|
||||
|
||||
// 6. AUTO_INCREMENT 속성 추가
|
||||
echo "Applying AUTO_INCREMENT to meeting_logs.id...\n";
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE meeting_logs MODIFY id INT AUTO_INCREMENT PRIMARY KEY");
|
||||
echo "Success: AUTO_INCREMENT applied.\n";
|
||||
} catch (PDOException $e) {
|
||||
// 이미 설정되어 있거나 다른 문제가 있는 경우
|
||||
echo "Note: " . $e->getMessage() . "\n";
|
||||
|
||||
// PRIMARY KEY가 이미 있다면 MODIFY만 시도
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE meeting_logs MODIFY id INT AUTO_INCREMENT");
|
||||
echo "Success: Mofified column to AUTO_INCREMENT.\n";
|
||||
} catch (PDOException $e2) {
|
||||
echo "Error applying AUTO_INCREMENT: " . $e2->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo "Database Error: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
?>
|
||||
1471
voice_ai/index.php
1471
voice_ai/index.php
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
527
voice_ai_cnslt/api/speech_to_text.php
Normal file
527
voice_ai_cnslt/api/speech_to_text.php
Normal file
@@ -0,0 +1,527 @@
|
||||
<?php
|
||||
/**
|
||||
* Google Cloud Speech-to-Text API 엔드포인트
|
||||
* 오디오 파일을 받아서 텍스트로 변환
|
||||
*/
|
||||
|
||||
// 타임아웃 설정 (504 오류 방지)
|
||||
ini_set('max_execution_time', 120); // 120초
|
||||
set_time_limit(60); // PHP 실행 시간 60초로 제한
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: POST');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
|
||||
// 권한 체크
|
||||
if ($level > 5) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '접근 권한이 없습니다.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 세션 쓰기 닫기 (이후 긴 작업 시 세션 잠금 방지)
|
||||
session_write_close();
|
||||
|
||||
// POST 요청만 허용
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'POST 요청만 허용됩니다.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 오디오 파일 확인
|
||||
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '오디오 파일이 전송되지 않았습니다.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$audioFile = $_FILES['audio'];
|
||||
$audioPath = $audioFile['tmp_name'];
|
||||
$audioType = $audioFile['type'];
|
||||
|
||||
// 오디오 파일 유효성 검사
|
||||
$allowedTypes = ['audio/webm', 'audio/wav', 'audio/ogg', 'audio/mp3', 'audio/mpeg', 'audio/x-flac'];
|
||||
if (!in_array($audioType, $allowedTypes)) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '지원하지 않는 오디오 형식입니다. (webm, wav, ogg, mp3, flac 지원)'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Google Cloud Speech-to-Text API 설정
|
||||
$googleApiKey = '';
|
||||
$accessToken = null; // 서비스 계정용 OAuth 토큰
|
||||
$useServiceAccount = false;
|
||||
$googleApiUrl = 'https://speech.googleapis.com/v1/speech:recognize';
|
||||
|
||||
// API 키 파일 경로 (우선순위 순)
|
||||
// 상대 경로와 절대 경로 모두 확인
|
||||
$docRoot = $_SERVER['DOCUMENT_ROOT'];
|
||||
$apiKeyPaths = [
|
||||
$docRoot . '/5130/apikey/google_api.txt',
|
||||
$docRoot . '/apikey/google_api.txt',
|
||||
dirname($docRoot) . '/5130/apikey/google_api.txt', // 상위 디렉토리에서 확인
|
||||
dirname($docRoot) . '/apikey/google_api.txt',
|
||||
__DIR__ . '/../../apikey/google_api.txt', // 현재 파일 기준 상대 경로
|
||||
__DIR__ . '/../../../apikey/google_api.txt',
|
||||
$docRoot . '/5130/config/google_speech_api.php',
|
||||
$docRoot . '/config/google_speech_api.php',
|
||||
];
|
||||
|
||||
// 서비스 계정 파일 경로
|
||||
$serviceAccountPaths = [
|
||||
$docRoot . '/5130/apikey/google_service_account.json',
|
||||
$docRoot . '/apikey/google_service_account.json',
|
||||
dirname($docRoot) . '/5130/apikey/google_service_account.json',
|
||||
dirname($docRoot) . '/apikey/google_service_account.json',
|
||||
__DIR__ . '/../../apikey/google_service_account.json',
|
||||
__DIR__ . '/../../../apikey/google_service_account.json',
|
||||
];
|
||||
|
||||
// API 키 파일에서 읽기
|
||||
$foundKeyPath = null;
|
||||
$checkedPaths = [];
|
||||
|
||||
foreach ($apiKeyPaths as $keyPath) {
|
||||
$checkedPaths[] = $keyPath . ' (exists: ' . (file_exists($keyPath) ? 'yes' : 'no') . ')';
|
||||
|
||||
if (file_exists($keyPath)) {
|
||||
$foundKeyPath = $keyPath;
|
||||
if (pathinfo($keyPath, PATHINFO_EXTENSION) === 'php') {
|
||||
// PHP 설정 파일인 경우
|
||||
require_once($keyPath);
|
||||
if (isset($GOOGLE_SPEECH_API_KEY)) {
|
||||
$googleApiKey = trim($GOOGLE_SPEECH_API_KEY);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 텍스트 파일인 경우 (google_api.txt)
|
||||
$keyContent = file_get_contents($keyPath);
|
||||
$googleApiKey = trim($keyContent);
|
||||
|
||||
// 빈 줄이나 주석 제거
|
||||
$lines = explode("\n", $googleApiKey);
|
||||
$googleApiKey = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (!empty($line) && !preg_match('/^#/', $line)) {
|
||||
$googleApiKey = $line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($googleApiKey)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 계정 파일 찾기
|
||||
$serviceAccountPath = null;
|
||||
foreach ($serviceAccountPaths as $saPath) {
|
||||
if (file_exists($saPath)) {
|
||||
$serviceAccountPath = $saPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth 2.0 토큰 생성 함수 (서비스 계정용)
|
||||
function getServiceAccountToken($serviceAccountPath) {
|
||||
if (!file_exists($serviceAccountPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
if (!$serviceAccount || !isset($serviceAccount['private_key']) || !isset($serviceAccount['client_email'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Base64 URL 인코딩 함수
|
||||
$base64UrlEncode = function($data) {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
};
|
||||
|
||||
// JWT 생성
|
||||
$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
|
||||
];
|
||||
|
||||
$encodedHeader = $base64UrlEncode(json_encode($jwtHeader));
|
||||
$encodedClaim = $base64UrlEncode(json_encode($jwtClaim));
|
||||
|
||||
// 서명 생성
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$signature = '';
|
||||
$signData = $encodedHeader . '.' . $encodedClaim;
|
||||
if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
|
||||
openssl_free_key($privateKey);
|
||||
return null;
|
||||
}
|
||||
openssl_free_key($privateKey);
|
||||
|
||||
$encodedSignature = $base64UrlEncode($signature);
|
||||
$jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature;
|
||||
|
||||
// OAuth 2.0 토큰 요청
|
||||
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt($tokenCh, CURLOPT_POST, true);
|
||||
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
]));
|
||||
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
|
||||
// 타임아웃 설정
|
||||
curl_setopt($tokenCh, CURLOPT_TIMEOUT, 30); // 30초
|
||||
curl_setopt($tokenCh, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
|
||||
|
||||
$tokenResponse = curl_exec($tokenCh);
|
||||
$tokenHttpCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
|
||||
curl_close($tokenCh);
|
||||
|
||||
if ($tokenHttpCode === 200) {
|
||||
$tokenData = json_decode($tokenResponse, true);
|
||||
if (isset($tokenData['access_token'])) {
|
||||
return $tokenData['access_token'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// API 키가 없거나 형식이 잘못된 경우 서비스 계정 시도
|
||||
if (empty($googleApiKey) || strlen($googleApiKey) < 20) {
|
||||
if ($serviceAccountPath) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$useServiceAccount) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Google Cloud Speech-to-Text API 인증 정보가 없습니다.',
|
||||
'hint' => 'API 키 파일 또는 서비스 계정 파일을 찾을 수 없습니다.',
|
||||
'checked_paths' => $checkedPaths,
|
||||
'service_account_path' => $serviceAccountPath,
|
||||
'document_root' => $docRoot,
|
||||
'current_dir' => __DIR__
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// API 키 형식 확인 (Google API 키는 보통 AIza로 시작)
|
||||
$isValidFormat = (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35);
|
||||
if (!$isValidFormat && !$useServiceAccount) {
|
||||
// API 키 형식이 잘못된 경우 서비스 계정 시도
|
||||
if ($serviceAccountPath) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
$googleApiKey = ''; // API 키 사용 안 함
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 계정이 있으면 우선 사용 (API 키보다 안정적)
|
||||
// API 키는 간헐적으로 오류가 발생할 수 있으므로 서비스 계정을 기본으로 사용
|
||||
if (!$useServiceAccount && $serviceAccountPath && file_exists($serviceAccountPath)) {
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
$useServiceAccount = true;
|
||||
// API 키는 백업으로 유지하되 서비스 계정 우선 사용
|
||||
error_log('Using service account for authentication (more reliable than API key)');
|
||||
}
|
||||
}
|
||||
|
||||
// API 키 디버깅 정보
|
||||
$debugInfo = [
|
||||
'api_key_path' => $foundKeyPath,
|
||||
'api_key_length' => strlen($googleApiKey),
|
||||
'api_key_prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
|
||||
'api_key_suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
|
||||
'use_service_account' => $useServiceAccount,
|
||||
'service_account_path' => $serviceAccountPath,
|
||||
'document_root' => $docRoot,
|
||||
'checked_paths' => $checkedPaths
|
||||
];
|
||||
|
||||
try {
|
||||
// 오디오 파일을 base64로 인코딩
|
||||
$audioContent = file_get_contents($audioPath);
|
||||
$audioBase64 = base64_encode($audioContent);
|
||||
|
||||
// 오디오 형식에 따른 encoding 설정
|
||||
$encoding = 'WEBM_OPUS';
|
||||
$sampleRate = 16000; // 기본값
|
||||
|
||||
if (strpos($audioType, 'wav') !== false) {
|
||||
$encoding = 'LINEAR16';
|
||||
$sampleRate = 16000;
|
||||
} elseif (strpos($audioType, 'ogg') !== false) {
|
||||
$encoding = 'OGG_OPUS';
|
||||
$sampleRate = 48000; // Opus는 보통 48kHz
|
||||
} elseif (strpos($audioType, 'flac') !== false) {
|
||||
$encoding = 'FLAC';
|
||||
$sampleRate = 16000;
|
||||
} elseif (strpos($audioType, 'webm') !== false) {
|
||||
$encoding = 'WEBM_OPUS';
|
||||
$sampleRate = 48000; // WebM Opus는 보통 48kHz
|
||||
}
|
||||
|
||||
// Google Cloud Speech-to-Text API 요청 데이터
|
||||
$requestData = [
|
||||
'config' => [
|
||||
'encoding' => $encoding,
|
||||
'sampleRateHertz' => $sampleRate,
|
||||
'languageCode' => 'ko-KR',
|
||||
'enableAutomaticPunctuation' => true,
|
||||
'enableWordTimeOffsets' => false,
|
||||
'model' => 'latest_long', // 긴 오디오에 적합
|
||||
'alternativeLanguageCodes' => ['ko'], // 추가 언어 코드
|
||||
],
|
||||
'audio' => [
|
||||
'content' => $audioBase64
|
||||
]
|
||||
];
|
||||
|
||||
// 디버깅 정보 (개발 환경에서만)
|
||||
if (isset($_GET['debug'])) {
|
||||
error_log('Google Speech API Request: ' . json_encode([
|
||||
'encoding' => $encoding,
|
||||
'sampleRate' => $sampleRate,
|
||||
'audioSize' => strlen($audioBase64),
|
||||
'audioType' => $audioType
|
||||
]));
|
||||
}
|
||||
|
||||
// cURL로 API 호출
|
||||
$headers = ['Content-Type: application/json'];
|
||||
$apiUrl = $googleApiUrl;
|
||||
|
||||
if ($useServiceAccount && $accessToken) {
|
||||
// 서비스 계정 사용: Bearer 토큰 사용
|
||||
$headers[] = 'Authorization: Bearer ' . $accessToken;
|
||||
} else if (!empty($googleApiKey)) {
|
||||
// API 키 사용: 쿼리 파라미터로 전달
|
||||
$apiUrl = $googleApiUrl . '?key=' . urlencode($googleApiKey);
|
||||
} else {
|
||||
throw new Exception('인증 정보가 없습니다. API 키 또는 서비스 계정이 필요합니다.');
|
||||
}
|
||||
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
// 타임아웃 설정 (504 오류 방지)
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 50); // 50초
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
throw new Exception('cURL 오류: ' . $curlError);
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$errorData = json_decode($response, true);
|
||||
$errorMessage = 'API 오류 (HTTP ' . $httpCode . ')';
|
||||
$debugInfo = [];
|
||||
|
||||
if (isset($errorData['error'])) {
|
||||
$errorMessage .= ': ' . ($errorData['error']['message'] ?? '알 수 없는 오류');
|
||||
$debugInfo['error_details'] = $errorData['error'];
|
||||
|
||||
// API 키 오류인 경우 서비스 계정으로 재시도
|
||||
if (isset($errorData['error']['message']) &&
|
||||
(stripos($errorData['error']['message'], 'API key') !== false ||
|
||||
stripos($errorData['error']['message'], 'not valid') !== false ||
|
||||
stripos($errorData['error']['message'], 'invalid') !== false ||
|
||||
stripos($errorData['error']['message'], 'unauthorized') !== false)) {
|
||||
|
||||
// 서비스 계정으로 재시도
|
||||
if ($serviceAccountPath && file_exists($serviceAccountPath)) {
|
||||
// 서비스 계정을 사용 중이었는데도 오류가 발생하면 토큰 재생성
|
||||
if ($useServiceAccount) {
|
||||
error_log('Service account token may have expired, regenerating...');
|
||||
}
|
||||
|
||||
$accessToken = getServiceAccountToken($serviceAccountPath);
|
||||
if ($accessToken) {
|
||||
// 서비스 계정으로 재시도
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $accessToken
|
||||
];
|
||||
|
||||
$ch = curl_init($googleApiUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
// 타임아웃 설정 (504 오류 방지)
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 50); // 50초
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
// 서비스 계정으로 성공
|
||||
$result = json_decode($response, true);
|
||||
if (isset($result['results']) && count($result['results']) > 0) {
|
||||
$transcript = '';
|
||||
foreach ($result['results'] as $resultItem) {
|
||||
if (isset($resultItem['alternatives'][0]['transcript'])) {
|
||||
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
|
||||
}
|
||||
}
|
||||
|
||||
$transcript = trim($transcript);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'transcript' => $transcript,
|
||||
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
|
||||
? $result['results'][0]['alternatives'][0]['confidence']
|
||||
: null,
|
||||
'auth_method' => 'service_account',
|
||||
'retry_success' => true
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 재시도 실패 시 상세 안내
|
||||
$errorMessage .= "\n\n⚠️ API 키 오류 해결 방법:\n";
|
||||
$errorMessage .= "1. Google Cloud Console (https://console.cloud.google.com/) 접속\n";
|
||||
$errorMessage .= "2. 프로젝트 선택: codebridge-chatbot\n";
|
||||
$errorMessage .= "3. 'API 및 서비스' > '라이브러리'에서 'Cloud Speech-to-Text API' 검색 및 활성화\n";
|
||||
$errorMessage .= "4. 'API 및 서비스' > '사용자 인증 정보'에서 API 키 확인\n";
|
||||
$errorMessage .= "5. API 키 제한 설정에서 'Cloud Speech-to-Text API' 허용 확인\n";
|
||||
$errorMessage .= "\n현재 API 키 파일: " . ($foundKeyPath ?? 'N/A');
|
||||
$errorMessage .= "\nAPI 키 길이: " . strlen($googleApiKey) . " 문자";
|
||||
if ($serviceAccountPath) {
|
||||
$errorMessage .= "\n서비스 계정 파일: " . $serviceAccountPath;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$errorMessage .= ': ' . substr($response, 0, 200);
|
||||
}
|
||||
|
||||
// 디버그 정보에 API 키 정보 추가
|
||||
$debugInfo['api_key_info'] = [
|
||||
'path' => $foundKeyPath,
|
||||
'length' => strlen($googleApiKey),
|
||||
'prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
|
||||
'suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
|
||||
'format_valid' => !empty($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35) : false,
|
||||
'use_service_account' => $useServiceAccount,
|
||||
'service_account_path' => $serviceAccountPath
|
||||
];
|
||||
|
||||
if (isset($_GET['debug'])) {
|
||||
$debugInfo['full_response'] = $response;
|
||||
$debugInfo['request_url'] = $useServiceAccount ? $googleApiUrl : ($googleApiUrl . '?key=' . substr($googleApiKey, 0, 10) . '...');
|
||||
}
|
||||
|
||||
throw new Exception($errorMessage);
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
// 결과 처리
|
||||
if (isset($result['results']) && count($result['results']) > 0) {
|
||||
$transcript = '';
|
||||
foreach ($result['results'] as $resultItem) {
|
||||
if (isset($resultItem['alternatives'][0]['transcript'])) {
|
||||
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
|
||||
}
|
||||
}
|
||||
|
||||
$transcript = trim($transcript);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'transcript' => $transcript,
|
||||
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
|
||||
? $result['results'][0]['alternatives'][0]['confidence']
|
||||
: null,
|
||||
'auth_method' => $useServiceAccount ? 'service_account' : 'api_key'
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '음성을 인식할 수 없었습니다.',
|
||||
'debug' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errorResponse = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
|
||||
// 항상 API 키 정보 포함 (보안을 위해 일부만 표시)
|
||||
$errorResponse['debug'] = [
|
||||
'api_key_path' => $foundKeyPath ?? null,
|
||||
'api_key_length' => isset($googleApiKey) ? strlen($googleApiKey) : 0,
|
||||
'api_key_prefix' => isset($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
|
||||
'api_key_suffix' => isset($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
|
||||
'api_key_format' => isset($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 ? 'Google API Key (AIza...)' : 'Other format') : 'N/A',
|
||||
'use_service_account' => isset($useServiceAccount) ? $useServiceAccount : false,
|
||||
'service_account_path' => isset($serviceAccountPath) ? $serviceAccountPath : null,
|
||||
'document_root' => $docRoot ?? null,
|
||||
'checked_paths_count' => count($checkedPaths ?? [])
|
||||
];
|
||||
|
||||
// 상세 디버그 모드일 때 추가 정보
|
||||
if (isset($_GET['debug'])) {
|
||||
$errorResponse['debug']['exception_class'] = get_class($e);
|
||||
$errorResponse['debug']['file'] = $e->getFile();
|
||||
$errorResponse['debug']['line'] = $e->getLine();
|
||||
$errorResponse['debug']['checked_paths'] = $checkedPaths ?? [];
|
||||
}
|
||||
|
||||
echo json_encode($errorResponse, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
373
voice_ai_cnslt/check_consult_status.php
Normal file
373
voice_ai_cnslt/check_consult_status.php
Normal file
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
// 출력 버퍼링 시작
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
ob_start();
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('log_errors', 1);
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
|
||||
|
||||
// 에러 응답 함수
|
||||
function sendErrorResponse($message, $details = null) {
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$response = ['ok' => false, 'error' => $message];
|
||||
if ($details !== null) {
|
||||
$response['details'] = $details;
|
||||
}
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 출력 버퍼 비우기
|
||||
ob_clean();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
// 1. 권한 체크
|
||||
if (!isset($user_id) || $level > 5) {
|
||||
sendErrorResponse('접근 권한이 없습니다.');
|
||||
}
|
||||
|
||||
// 2. 파라미터 확인
|
||||
$consult_id = isset($_GET['consult_id']) ? (int)$_GET['consult_id'] : 0;
|
||||
$operation_name = isset($_GET['operation_name']) ? trim($_GET['operation_name']) : '';
|
||||
|
||||
if (!$consult_id || !$operation_name) {
|
||||
sendErrorResponse('필수 파라미터가 없습니다.', 'consult_id와 operation_name이 필요합니다.');
|
||||
}
|
||||
|
||||
// 3. Google API 인증 정보 가져오기
|
||||
$googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json';
|
||||
$googleApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_api.txt';
|
||||
|
||||
$accessToken = null;
|
||||
$googleApiKey = null;
|
||||
|
||||
// 서비스 계정 우선 사용
|
||||
if (file_exists($googleServiceAccountFile)) {
|
||||
$serviceAccount = json_decode(file_get_contents($googleServiceAccountFile), true);
|
||||
if ($serviceAccount) {
|
||||
// OAuth 2.0 토큰 생성
|
||||
$now = time();
|
||||
$jwtHeader = ['alg' => 'RS256', 'typ' => '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
|
||||
];
|
||||
|
||||
$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) {
|
||||
$signature = '';
|
||||
$signData = $encodedHeader . '.' . $encodedClaim;
|
||||
if (openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
|
||||
openssl_free_key($privateKey);
|
||||
$encodedSignature = $base64UrlEncode($signature);
|
||||
$jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature;
|
||||
|
||||
// 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']);
|
||||
// 타임아웃 설정
|
||||
curl_setopt($tokenCh, CURLOPT_TIMEOUT, 30); // 30초
|
||||
curl_setopt($tokenCh, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
|
||||
|
||||
$tokenResponse = curl_exec($tokenCh);
|
||||
$tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
|
||||
curl_close($tokenCh);
|
||||
|
||||
if ($tokenCode === 200) {
|
||||
$tokenData = json_decode($tokenResponse, true);
|
||||
if (isset($tokenData['access_token'])) {
|
||||
$accessToken = $tokenData['access_token'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API 키 사용
|
||||
if (!$accessToken && file_exists($googleApiKeyFile)) {
|
||||
$googleApiKey = trim(file_get_contents($googleApiKeyFile));
|
||||
}
|
||||
|
||||
if (!$accessToken && !$googleApiKey) {
|
||||
sendErrorResponse('Google API 인증 정보가 없습니다.');
|
||||
}
|
||||
|
||||
// 4. Google API에서 작업 상태 확인
|
||||
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);
|
||||
}
|
||||
curl_setopt($poll_ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$poll_response = curl_exec($poll_ch);
|
||||
$poll_code = curl_getinfo($poll_ch, CURLINFO_HTTP_CODE);
|
||||
$poll_error = curl_error($poll_ch);
|
||||
curl_close($poll_ch);
|
||||
|
||||
if ($poll_code !== 200) {
|
||||
sendErrorResponse('작업 상태 확인 실패 (HTTP ' . $poll_code . ')', $poll_error ?: substr($poll_response, 0, 500));
|
||||
}
|
||||
|
||||
$poll_data = json_decode($poll_response, true);
|
||||
if (!$poll_data) {
|
||||
sendErrorResponse('작업 상태 응답 파싱 실패', substr($poll_response, 0, 500));
|
||||
}
|
||||
|
||||
// 5. 작업 완료 여부 확인
|
||||
if (!isset($poll_data['done']) || $poll_data['done'] !== true) {
|
||||
// 아직 처리 중
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'processing' => true,
|
||||
'done' => false,
|
||||
'message' => '음성 인식 처리 중입니다. 잠시 후 다시 확인해주세요.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 6. 작업 완료 - 결과 처리
|
||||
if (isset($poll_data['error'])) {
|
||||
// 오류 발생
|
||||
$pdo = db_connect();
|
||||
$errorMsg = isset($poll_data['error']['message']) ? $poll_data['error']['message'] : '알 수 없는 오류';
|
||||
|
||||
$updateSql = "UPDATE consult_logs SET
|
||||
title = ?,
|
||||
summary_text = ?
|
||||
WHERE id = ?";
|
||||
$updateStmt = $pdo->prepare($updateSql);
|
||||
$updateStmt->execute(['오류 발생', 'Google STT 변환 실패: ' . $errorMsg, $consult_id]);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'Google STT 변환 실패',
|
||||
'details' => $errorMsg
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 응답 구조 확인 및 로깅
|
||||
error_log('Google STT 응답 구조: ' . json_encode([
|
||||
'has_response' => isset($poll_data['response']),
|
||||
'has_results' => isset($poll_data['response']['results']),
|
||||
'results_count' => isset($poll_data['response']['results']) ? count($poll_data['response']['results']) : 0,
|
||||
'response_keys' => isset($poll_data['response']) ? array_keys($poll_data['response']) : [],
|
||||
'poll_data_keys' => array_keys($poll_data)
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// response 필드가 없는 경우
|
||||
if (!isset($poll_data['response'])) {
|
||||
error_log('Google STT 응답에 response 필드가 없습니다. 전체 응답: ' . json_encode($poll_data, JSON_UNESCAPED_UNICODE));
|
||||
sendErrorResponse('Google STT 응답 구조 오류', '응답에 response 필드가 없습니다. 작업이 완료되었지만 결과를 가져올 수 없습니다.');
|
||||
}
|
||||
|
||||
// results가 없는 경우
|
||||
if (!isset($poll_data['response']['results'])) {
|
||||
error_log('Google STT 응답에 results 필드가 없습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE));
|
||||
sendErrorResponse('Google STT 응답에 결과가 없습니다.', '응답에 results 필드가 없습니다. 음성이 인식되지 않았을 수 있습니다.');
|
||||
}
|
||||
|
||||
// results가 비어있는 경우
|
||||
if (empty($poll_data['response']['results'])) {
|
||||
error_log('Google STT 응답에 results가 비어있습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// 빈 결과를 DB에 저장하고 사용자에게 알림
|
||||
$pdo = db_connect();
|
||||
$updateSql = "UPDATE consult_logs SET
|
||||
title = ?,
|
||||
transcript_text = ?,
|
||||
summary_text = ?
|
||||
WHERE id = ?";
|
||||
$updateStmt = $pdo->prepare($updateSql);
|
||||
$updateStmt->execute(['음성 인식 실패', '음성이 인식되지 않았습니다.', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.', $consult_id]);
|
||||
|
||||
sendErrorResponse('음성 인식 실패', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.');
|
||||
}
|
||||
|
||||
// 7. 텍스트 변환
|
||||
$stt_data = ['results' => $poll_data['response']['results']];
|
||||
$transcript = '';
|
||||
foreach ($stt_data['results'] as $result) {
|
||||
if (isset($result['alternatives'][0]['transcript'])) {
|
||||
$transcript .= $result['alternatives'][0]['transcript'] . ' ';
|
||||
}
|
||||
}
|
||||
$transcript = trim($transcript);
|
||||
|
||||
if (empty($transcript)) {
|
||||
sendErrorResponse('인식된 텍스트가 없습니다.');
|
||||
}
|
||||
|
||||
// 8. Claude API로 요약 생성
|
||||
$claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
|
||||
$claudeKey = '';
|
||||
if (file_exists($claudeKeyFile)) {
|
||||
$claudeKey = trim(file_get_contents($claudeKeyFile));
|
||||
}
|
||||
|
||||
$title = '무제 업무협의록';
|
||||
$summary = '';
|
||||
|
||||
if (!empty($claudeKey)) {
|
||||
$prompt = "다음 업무협의 녹취록을 분석하여 JSON 형식으로 응답해주세요.
|
||||
|
||||
요구사항:
|
||||
1. \"title\": 업무협의 내용을 요약한 제목 (최대 20자, 한글 기준)
|
||||
2. \"summary\": [업무협의 개요 / 주요 안건 / 결정 사항 / 향후 계획] 형식의 상세 요약
|
||||
|
||||
반드시 다음 JSON 형식으로만 응답해주세요:
|
||||
{
|
||||
\"title\": \"제목 (최대 20자)\",
|
||||
\"summary\": \"상세 요약 내용\"
|
||||
}
|
||||
|
||||
업무협의 녹취록:
|
||||
" . $transcript;
|
||||
|
||||
$ch2 = curl_init('https://api.anthropic.com/v1/messages');
|
||||
$requestBody = [
|
||||
'model' => 'claude-3-5-haiku-20241022',
|
||||
'max_tokens' => 2048,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]]
|
||||
];
|
||||
|
||||
curl_setopt($ch2, CURLOPT_POST, true);
|
||||
curl_setopt($ch2, CURLOPT_HTTPHEADER, [
|
||||
'x-api-key: ' . $claudeKey,
|
||||
'anthropic-version: 2023-06-01',
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($requestBody));
|
||||
curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$ai_response = curl_exec($ch2);
|
||||
$ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch2);
|
||||
|
||||
if ($ai_code === 200) {
|
||||
$ai_data = json_decode($ai_response, true);
|
||||
if (isset($ai_data['content'][0]['text'])) {
|
||||
$ai_text = trim($ai_data['content'][0]['text']);
|
||||
$ai_text = preg_replace('/^```(?:json)?\s*/m', '', $ai_text);
|
||||
$ai_text = preg_replace('/\s*```$/m', '', $ai_text);
|
||||
$ai_text = trim($ai_text);
|
||||
|
||||
$parsed_data = json_decode($ai_text, true);
|
||||
|
||||
if (is_array($parsed_data)) {
|
||||
if (isset($parsed_data['title']) && !empty($parsed_data['title'])) {
|
||||
$title = mb_substr(trim($parsed_data['title']), 0, 20, 'UTF-8');
|
||||
}
|
||||
if (isset($parsed_data['summary']) && !empty($parsed_data['summary'])) {
|
||||
$summary = $parsed_data['summary'];
|
||||
} else {
|
||||
$summary = $ai_text;
|
||||
}
|
||||
} else {
|
||||
$summary = $ai_text;
|
||||
if (preg_match('/["\']?title["\']?\s*[:=]\s*["\']([^"\']{1,20})["\']/', $ai_text, $matches)) {
|
||||
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
|
||||
} elseif (preg_match('/제목[:\s]+([^\n]{1,20})/', $ai_text, $matches)) {
|
||||
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
|
||||
} else {
|
||||
$lines = explode("\n", $ai_text);
|
||||
$first_line = trim($lines[0]);
|
||||
if (!empty($first_line) && mb_strlen($first_line, 'UTF-8') <= 20) {
|
||||
$title = $first_line;
|
||||
} elseif (!empty($first_line)) {
|
||||
$title = mb_substr($first_line, 0, 20, 'UTF-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($title) || $title === '무제 업무협의록') {
|
||||
if (!empty($summary)) {
|
||||
$summary_lines = explode("\n", $summary);
|
||||
$first_summary_line = trim($summary_lines[0]);
|
||||
if (!empty($first_summary_line)) {
|
||||
$first_summary_line = preg_replace('/[\[\(].*?[\]\)]/', '', $first_summary_line);
|
||||
$first_summary_line = trim($first_summary_line);
|
||||
if (mb_strlen($first_summary_line, 'UTF-8') <= 20) {
|
||||
$title = $first_summary_line;
|
||||
} else {
|
||||
$title = mb_substr($first_summary_line, 0, 20, 'UTF-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$title = mb_substr(trim($title), 0, 20, 'UTF-8');
|
||||
if (empty($title)) {
|
||||
$title = '무제 업무협의록';
|
||||
}
|
||||
|
||||
if (empty($summary)) {
|
||||
$summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)";
|
||||
}
|
||||
|
||||
// 9. DB 업데이트
|
||||
$pdo = db_connect();
|
||||
$updateSql = "UPDATE consult_logs SET
|
||||
title = ?,
|
||||
transcript_text = ?,
|
||||
summary_text = ?
|
||||
WHERE id = ?";
|
||||
$updateStmt = $pdo->prepare($updateSql);
|
||||
$updateStmt->execute([$title, $transcript, $summary, $consult_id]);
|
||||
|
||||
// 10. 완료 응답
|
||||
@ob_clean();
|
||||
@ob_end_clean();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'processing' => false,
|
||||
'done' => true,
|
||||
'consult_id' => $consult_id,
|
||||
'title' => $title,
|
||||
'transcript' => $transcript,
|
||||
'summary' => $summary,
|
||||
'message' => '음성 인식 및 요약이 완료되었습니다.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user