logPath = $this->resolveLogPath(); } /** * 캡처 시작 (현재 로그 파일 위치 기록) */ public function start(): void { if (file_exists($this->logPath)) { $this->startOffset = filesize($this->logPath); } else { $this->startOffset = 0; } } /** * 캡처 종료 및 로그 추출 * * @return array 파싱된 API 로그 배열 */ public function capture(): array { if (! file_exists($this->logPath)) { return []; } $currentSize = filesize($this->logPath); // 새로운 로그가 없으면 빈 배열 반환 if ($currentSize <= $this->startOffset) { return []; } // 새로 추가된 로그 읽기 $handle = fopen($this->logPath, 'r'); if (! $handle) { return []; } fseek($handle, $this->startOffset); $newLogs = fread($handle, $currentSize - $this->startOffset); fclose($handle); // Request/Response 로그만 파싱 return $this->parseLogs($newLogs); } /** * API 로그 파일 경로 결정 */ private function resolveLogPath(): string { // 1. 환경 변수로 지정된 경로 $envPath = env('API_LOG_PATH'); if ($envPath && file_exists($envPath)) { return $envPath; } // 2. 로컬 개발 환경 (상대 경로) $localPath = base_path('../api/storage/logs/laravel.log'); if (file_exists($localPath)) { return $localPath; } // 3. Docker 환경 (절대 경로) $dockerPath = '/var/www/api/storage/logs/laravel.log'; if (file_exists($dockerPath)) { return $dockerPath; } // 4. 기본값 (로컬 경로) return $localPath; } /** * 로그 텍스트 파싱 * * Laravel 로그 형식: * [2025-12-04 12:30:53] local.INFO: API Request {...} * [2025-12-04 12:30:53] local.INFO: API Response {...} */ private function parseLogs(string $rawLogs): array { $logs = []; // 전체 로그 라인 매칭 (원본 보존용) $pattern = '/(\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.\w+: (API Request|API Response) (\{.+?\}))(?=\s*\[\d{4}|\s*$)/s'; if (preg_match_all($pattern, $rawLogs, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { $rawLine = $match[1]; // 전체 원본 로그 라인 $timestamp = $match[2]; $type = $match[3] === 'API Request' ? 'request' : 'response'; $jsonStr = $match[4]; // JSON 파싱 $data = json_decode($jsonStr, true); if ($data === null) { continue; } $log = [ 'timestamp' => $timestamp, 'type' => $type, 'raw' => $this->decodeUnicodeEscapes($rawLine), // 유니코드 이스케이프 디코딩 ]; if ($type === 'request') { $log['method'] = $data['method'] ?? ''; $log['uri'] = $data['uri'] ?? ''; $log['input'] = $data['input'] ?? []; $log['ip'] = $data['ip'] ?? ''; } else { $log['uri'] = $data['uri'] ?? ''; $log['status'] = $data['status'] ?? 0; // content는 JSON 문자열이므로 파싱 $content = $data['content'] ?? ''; if (is_string($content)) { $parsedContent = json_decode($content, true); if ($parsedContent !== null) { $log['success'] = $parsedContent['success'] ?? null; $log['message'] = $parsedContent['message'] ?? ''; // 에러 정보가 있으면 포함 if (isset($parsedContent['error'])) { $log['error'] = $parsedContent['error']; } } } } $logs[] = $log; } } return $logs; } /** * 로그 파일 경로 반환 (디버깅용) */ public function getLogPath(): string { return $this->logPath; } /** * 로그 파일 존재 여부 확인 */ public function isAvailable(): bool { return file_exists($this->logPath) && is_readable($this->logPath); } /** * 유니코드 이스케이프 시퀀스 디코딩 * * JSON 내부의 이중 이스케이프된 유니코드를 실제 문자로 변환 * 예: \\u0000 또는 \\\\u0000 → 실제 유니코드 문자 */ private function decodeUnicodeEscapes(string $str): string { // 이중 이스케이프 (\\\\uXXXX) 디코딩 $str = preg_replace_callback('/\\\\\\\\u([0-9a-fA-F]{4})/', function ($match) { return json_decode('"\u'.$match[1].'"') ?? $match[0]; }, $str); // 단일 이스케이프 (\\uXXXX) 디코딩 $str = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function ($match) { return json_decode('"\u'.$match[1].'"') ?? $match[0]; }, $str); return $str; } }