From c22b023310126e198ef557e1c1546231261ba06d Mon Sep 17 00:00:00 2001 From: jjstyle Date: Thu, 26 Mar 2026 11:36:21 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=95=AD=EB=AA=A9=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Commands/NaverRetry.php | 209 +++++++++ app/Commands/NaverWorker.php | 14 + app/Config/Routes.php | 9 + .../Manage/WorkerLogController.php | 366 ++++++++++++++++ app/Libraries/NaverApiClient.php | 49 +-- app/Views/manage/worker_log/failed_list.php | 410 ++++++++++++++++++ 6 files changed, 1032 insertions(+), 25 deletions(-) create mode 100644 app/Commands/NaverRetry.php create mode 100644 app/Controllers/Manage/WorkerLogController.php create mode 100644 app/Views/manage/worker_log/failed_list.php diff --git a/app/Commands/NaverRetry.php b/app/Commands/NaverRetry.php new file mode 100644 index 0000000..55eac9c --- /dev/null +++ b/app/Commands/NaverRetry.php @@ -0,0 +1,209 @@ + '재처리할 특정 로그 ID (선택사항, 없으면 모든 실패 건 재처리)' + ]; + protected $options = [ + '--limit' => '재처리할 최대 개수 (기본: 10)', + '--dry-run' => '실제 실행 없이 목록만 확인', + '--force' => '재시도 횟수 제한 무시 (3회 이상 실패한 건도 재처리)' + ]; + + public function run(array $params) + { + helper(['log']); + + $logId = $params[0] ?? null; + $limit = CLI::getOption('limit') ?? 10; + $isDryRun = CLI::getOption('dry-run') !== null; + $isForce = CLI::getOption('force') !== null; + + $logModel = model(NaverWorkerLogModel::class); + $naverService = new \App\Services\NaverService(); + + // 1. 특정 ID 재처리 + if ($logId) { + $this->retryOne($logModel, $naverService, $logId, $isDryRun, $isForce); + return; + } + + // 2. 실패한 모든 작업 재처리 + $this->retryFailed($logModel, $naverService, $limit, $isDryRun, $isForce); + } + + /** + * 특정 로그 ID 재처리 + */ + protected function retryOne($logModel, $naverService, $logId, $isDryRun, $isForce) + { + $log = $logModel->find($logId); + + if (!$log) { + CLI::error("❌ Log ID {$logId} not found"); + return; + } + + CLI::write(CLI::color("📋 Log ID: {$logId}", 'cyan')); + CLI::write(" Status: {$log['status']}"); + CLI::write(" Retry Count: " . ($log['retry_cnt'] ?? 0)); + CLI::write(" Error: {$log['error_msg']}"); + CLI::write(" Created: {$log['created_at']}"); + + // 재시도 횟수 체크 + if (!$isForce && ($log['retry_cnt'] ?? 0) >= 3) { + CLI::write(CLI::color('⚠️ 이미 3회 이상 재시도했습니다. --force 옵션으로 강제 실행 가능', 'yellow')); + return; + } + + // 재시도 불가능한 오류 체크 + if (!$isForce && $this->isNonRetryableError($log['error_msg'])) { + CLI::write(CLI::color('⚠️ 재시도 불가능한 오류입니다. 데이터를 먼저 수정해주세요.', 'yellow')); + CLI::write(CLI::color(' (--force 옵션으로 강제 실행 가능)', 'yellow')); + return; + } + + if ($isDryRun) { + CLI::write(CLI::color('🔍 DRY RUN - 재처리하지 않음', 'yellow')); + return; + } + + $this->processLog($logModel, $naverService, $log); + } + + /** + * 재시도 불가능한 오류인지 확인 + */ + protected function isNonRetryableError($errorMsg) + { + if (!$errorMsg) return false; + + // 데이터 수정이 필요한 오류 패턴 + $nonRetryablePatterns = [ + 'foreign key constraint', + 'Duplicate entry', + '빈 페이로드', + 'usr_sq.*users 테이블에 없음', // 이미 폴백 적용된 건은 재시도 가능 + ]; + + foreach ($nonRetryablePatterns as $pattern) { + if (stripos($errorMsg, $pattern) !== false) { + return true; + } + } + + return false; + } + + /** + * 실패한 모든 작업 재처리 + */ + protected function retryFailed($logModel, $naverService, $limit, $isDryRun, $isForce) + { + $query = $logModel->where('status', 'FAIL'); + + // force가 아닌 경우 재시도 횟수 3회 미만만 조회 + if (!$isForce) { + $query->groupStart() + ->where('retry_cnt IS NULL') + ->orWhere('retry_cnt <', 3) + ->groupEnd(); + } + + $failedLogs = $query->orderBy('created_at', 'ASC')->findAll($limit); + + if (empty($failedLogs)) { + CLI::write(CLI::color('✅ 재처리할 실패 작업이 없습니다.', 'green')); + return; + } + + CLI::write(CLI::color("📋 실패한 작업 {count}건 발견", 'cyan') + ->replace('{count}', count($failedLogs))); + + if ($isDryRun) { + foreach ($failedLogs as $log) { + CLI::write(" - ID: {$log['seq']} | Atcl: {$log['atcl_no']} | Error: {$log['error_msg']}"); + } + CLI::write(CLI::color('🔍 DRY RUN - 재처리하지 않음', 'yellow')); + return; + } + + // 실제 재처리 + $successCount = 0; + $failCount = 0; + + foreach ($failedLogs as $log) { + CLI::write(CLI::color("\n🔄 Retrying Log ID: {$log['seq']}", 'yellow')); + + $result = $this->processLog($logModel, $naverService, $log); + + if ($result) { + $successCount++; + } else { + $failCount++; + } + + // 과부하 방지 + sleep(1); + } + + CLI::write(CLI::color("\n✅ 재처리 완료: 성공 {$successCount}건, 실패 {$failCount}건", 'green')); + } + + /** + * 로그 데이터 재처리 + */ + protected function processLog($logModel, $naverService, $log) + { + try { + // 상태를 RETRY로 변경 + $logModel->update($log['seq'], ['status' => 'RETRY']); + + // JSON 파싱 + $responseJson = json_decode($log['raw_payload'], true); + $payload = $responseJson['request_data'] ?? []; + + if (empty($payload)) { + throw new \Exception("빈 페이로드 데이터"); + } + + CLI::write(" Article: {$payload['articleNumber']}"); + + // 재처리 + $insertId = $naverService->processArticle($payload); + + // 성공 시 로그 업데이트 + $logModel->update($log['seq'], [ + 'atcl_no' => $payload['articleNumber'] ?? null, + 'status' => 'SUCCESS', + 'target_db_id' => $insertId, + 'error_msg' => null + ]); + + CLI::write(CLI::color(" ✅ Success! DB ID: {$insertId}", 'green')); + return true; + + } catch (\Exception $e) { + // 실패 시 로그 업데이트 + $logModel->update($log['seq'], [ + 'status' => 'FAIL', + 'error_msg' => $e->getMessage() + ]); + + CLI::error(" ❌ Failed: " . $e->getMessage()); + return false; + } + } +} diff --git a/app/Commands/NaverWorker.php b/app/Commands/NaverWorker.php index 9ab0dbb..8e6866a 100644 --- a/app/Commands/NaverWorker.php +++ b/app/Commands/NaverWorker.php @@ -147,9 +147,23 @@ class NaverWorker extends BaseCommand } catch (\Exception $e) { CLI::error("❌ Task Failed: " . $e->getMessage()); + + // payload에서 매물번호 추출 시도 + $atclNo = null; + try { + if (!empty($rawData)) { + $responseJson = json_decode($rawData, true); + $payload = $responseJson['request_data'] ?? []; + $atclNo = $payload['articleNumber'] ?? null; + } + } catch (\Exception $parseEx) { + // JSON 파싱 실패는 무시 + } + // 실패 로그는 여기서 남김 // 1. DB 상태를 FAIL로 업데이트 (필수) (재연결 처리 포함) $this->safeUpdateLog($logModel, $logId, [ + 'atcl_no' => $atclNo, 'status' => 'FAIL', 'error_msg' => $e->getMessage() ]); diff --git a/app/Config/Routes.php b/app/Config/Routes.php index ec1f6ad..bd28076 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -697,3 +697,12 @@ $routes->post('/login/chkLogin', 'Login::chkLogin'); if (is_file($filepath = APPPATH . 'Config/Routes/Api.php')) { require $filepath; } + +/** + * Worker 관리 + */ +$routes->group('manage/worker', ['namespace' => 'App\Controllers\Manage'], function ($routes) { + $routes->get('failed', 'WorkerLogController::failedList'); + $routes->post('retry', 'WorkerLogController::retrySelected'); + $routes->get('detail/(:num)', 'WorkerLogController::detail/$1'); +}); \ No newline at end of file diff --git a/app/Controllers/Manage/WorkerLogController.php b/app/Controllers/Manage/WorkerLogController.php new file mode 100644 index 0000000..a592b42 --- /dev/null +++ b/app/Controllers/Manage/WorkerLogController.php @@ -0,0 +1,366 @@ +logModel = new NaverWorkerLogModel(); + } + + /** + * 실패 로그 목록 (오류 유형별 분석) + */ + public function failedList() + { + $db = \Config\Database::connect(); + + // 1. 전체 통계 + $stats = [ + 'total_fail' => $this->logModel->where('status', 'FAIL')->countAllResults(false), + 'retry_available' => $this->logModel + ->where('status', 'FAIL') + ->groupStart() + ->where('retry_cnt IS NULL') + ->orWhere('retry_cnt <', 3) + ->groupEnd() + ->countAllResults(false), + 'retry_exhausted' => $this->logModel + ->where('status', 'FAIL') + ->where('retry_cnt >=', 3) + ->countAllResults(false), + ]; + + // 2. 오류 유형별 분류 + $errorTypes = $this->classifyErrors(); + + // 3. 최근 실패 목록 + $recentFails = $this->logModel + ->select('seq, atcl_no, status, error_msg, retry_cnt, created_at') + ->where('status', 'FAIL') + ->orderBy('created_at', 'DESC') + ->findAll(50); + + // 각 오류에 대한 해결 가이드 추가 + foreach ($recentFails as &$log) { + $log['error_type'] = $this->detectErrorType($log['error_msg']); + $log['solution'] = $this->getSolution($log['error_type']); + $log['can_retry'] = $this->canRetry($log); + } + + $data = [ + 'stats' => $stats, + 'errorTypes' => $errorTypes, + 'logs' => $recentFails + ]; + + return view('manage/worker_log/failed_list', array_merge($this->data, $data)); + } + + /** + * 오류 유형 분류 + */ + protected function classifyErrors() + { + $db = \Config\Database::connect(); + + $errorPatterns = [ + 'foreign_key' => [ + 'pattern' => 'foreign key constraint', + 'label' => 'FK 제약조건 위반', + 'severity' => 'high', + 'count' => 0 + ], + 'duplicate' => [ + 'pattern' => 'Duplicate entry', + 'label' => '중복 데이터', + 'severity' => 'medium', + 'count' => 0 + ], + 'missing_user' => [ + 'pattern' => 'usr_sq.*users 테이블에 없음', + 'label' => '담당자 정보 없음', + 'severity' => 'medium', + 'count' => 0 + ], + 'empty_payload' => [ + 'pattern' => '빈 페이로드', + 'label' => '빈 데이터', + 'severity' => 'low', + 'count' => 0 + ], + 'api_error' => [ + 'pattern' => 'API.*FAIL|HTTP.*40[0-9]|HTTP.*50[0-9]', + 'label' => 'API 통신 오류', + 'severity' => 'medium', + 'count' => 0 + ], + 'unknown' => [ + 'pattern' => '', + 'label' => '기타 오류', + 'severity' => 'low', + 'count' => 0 + ] + ]; + + $failedLogs = $this->logModel + ->select('error_msg') + ->where('status', 'FAIL') + ->findAll(); + + foreach ($failedLogs as $log) { + $classified = false; + + foreach ($errorPatterns as $key => &$pattern) { + if ($key === 'unknown') continue; + + if ($pattern['pattern'] && preg_match('/' . $pattern['pattern'] . '/i', $log['error_msg'])) { + $pattern['count']++; + $classified = true; + break; + } + } + + if (!$classified) { + $errorPatterns['unknown']['count']++; + } + } + + return $errorPatterns; + } + + /** + * 오류 유형 감지 + */ + protected function detectErrorType($errorMsg) + { + if (!$errorMsg) return 'unknown'; + + if (stripos($errorMsg, 'foreign key constraint') !== false) { + return 'foreign_key'; + } + if (stripos($errorMsg, 'Duplicate entry') !== false) { + return 'duplicate'; + } + if (stripos($errorMsg, 'usr_sq') !== false && stripos($errorMsg, 'users 테이블') !== false) { + return 'missing_user'; + } + if (stripos($errorMsg, '빈 페이로드') !== false) { + return 'empty_payload'; + } + if (preg_match('/API.*FAIL|HTTP.*40[0-9]|HTTP.*50[0-9]/i', $errorMsg)) { + return 'api_error'; + } + + return 'unknown'; + } + + /** + * 오류 유형별 해결 방법 + */ + protected function getSolution($errorType) + { + $solutions = [ + 'foreign_key' => [ + 'title' => 'DB 데이터 수정 필요', + 'steps' => [ + '1. 참조하는 테이블의 데이터가 존재하는지 확인', + '2. region 테이블의 orphaned usr_sq 값 수정', + '3. TypeSParameterMapper의 폴백 로직 확인' + ], + 'auto_retry' => false + ], + 'duplicate' => [ + 'title' => '중복 데이터 처리', + 'steps' => [ + '1. 기존 데이터 확인 (atcl_no로 검색)', + '2. 중복 원인 파악 (동일 요청 2회 수신?)', + '3. 필요시 기존 데이터 삭제 후 재처리' + ], + 'auto_retry' => false + ], + 'missing_user' => [ + 'title' => '담당자 정보 수정됨 - 재처리 가능', + 'steps' => [ + '✅ TypeSParameterMapper가 자동 폴백 적용됨', + '✅ 바로 재처리 가능 (usr_sq=1로 처리됨)' + ], + 'auto_retry' => true + ], + 'empty_payload' => [ + 'title' => '잘못된 요청 데이터', + 'steps' => [ + '1. raw_payload 확인', + '2. 네이버 API 응답 확인', + '3. 재처리 불가 - 데이터 삭제 권장' + ], + 'auto_retry' => false + ], + 'api_error' => [ + 'title' => 'API 통신 오류', + 'steps' => [ + '1. 일시적 오류인지 확인 (네트워크, 타임아웃)', + '2. 네이버 API 서버 상태 확인', + '3. 일시적 오류면 재처리 가능' + ], + 'auto_retry' => true + ], + 'unknown' => [ + 'title' => '원인 미상', + 'steps' => [ + '1. error_msg 상세 확인', + '2. 로그 파일 확인', + '3. 개발팀 검토 필요' + ], + 'auto_retry' => false + ] + ]; + + return $solutions[$errorType] ?? $solutions['unknown']; + } + + /** + * 재시도 가능 여부 + */ + protected function canRetry($log) + { + // 재시도 횟수 체크 + if (($log['retry_cnt'] ?? 0) >= 3) { + return false; + } + + // 오류 유형별 재시도 가능 여부 + $errorType = $this->detectErrorType($log['error_msg']); + $solution = $this->getSolution($errorType); + + return $solution['auto_retry'] ?? false; + } + + /** + * 선택한 로그 재처리 (AJAX) + */ + public function retrySelected() + { + $logIds = $this->request->getPost('log_ids'); + + if (empty($logIds) || !is_array($logIds)) { + return $this->response->setJSON([ + 'success' => false, + 'message' => '재처리할 로그를 선택해주세요.' + ]); + } + + $naverService = new \App\Services\NaverService(); + $results = [ + 'success' => 0, + 'fail' => 0, + 'details' => [] + ]; + + foreach ($logIds as $logId) { + $log = $this->logModel->find($logId); + + if (!$log) { + $results['details'][] = [ + 'id' => $logId, + 'status' => 'error', + 'message' => '로그를 찾을 수 없습니다.' + ]; + continue; + } + + // 재시도 가능 여부 체크 + if (!$this->canRetry($log)) { + $results['details'][] = [ + 'id' => $logId, + 'atcl_no' => $log['atcl_no'], + 'status' => 'skip', + 'message' => '재시도 불가능 (횟수 초과 또는 수정 필요)' + ]; + continue; + } + + try { + // 재처리 + $this->logModel->update($logId, ['status' => 'RETRY']); + + $responseJson = json_decode($log['raw_payload'], true); + $payload = $responseJson['request_data'] ?? []; + + if (empty($payload)) { + throw new \Exception("빈 페이로드 데이터"); + } + + $insertId = $naverService->processArticle($payload); + + $this->logModel->update($logId, [ + 'atcl_no' => $payload['articleNumber'] ?? null, + 'status' => 'SUCCESS', + 'target_db_id' => $insertId, + 'error_msg' => null, + 'retry_cnt' => ($log['retry_cnt'] ?? 0) + 1 + ]); + + $results['success']++; + $results['details'][] = [ + 'id' => $logId, + 'atcl_no' => $log['atcl_no'], + 'status' => 'success', + 'message' => "성공 (DB ID: {$insertId})" + ]; + + } catch (\Exception $e) { + $this->logModel->update($logId, [ + 'status' => 'FAIL', + 'error_msg' => $e->getMessage(), + 'retry_cnt' => ($log['retry_cnt'] ?? 0) + 1 + ]); + + $results['fail']++; + $results['details'][] = [ + 'id' => $logId, + 'atcl_no' => $log['atcl_no'], + 'status' => 'fail', + 'message' => $e->getMessage() + ]; + } + } + + return $this->response->setJSON([ + 'success' => true, + 'results' => $results + ]); + } + + /** + * 특정 로그 상세 (AJAX) + */ + public function detail($id) + { + $log = $this->logModel->find($id); + + if (!$log) { + return $this->response->setJSON([ + 'success' => false, + 'message' => '로그를 찾을 수 없습니다.' + ]); + } + + // raw_payload JSON 파싱 + $log['parsed_payload'] = json_decode($log['raw_payload'], true); + $log['error_type'] = $this->detectErrorType($log['error_msg']); + $log['solution'] = $this->getSolution($log['error_type']); + $log['can_retry'] = $this->canRetry($log); + + return $this->response->setJSON([ + 'success' => true, + 'log' => $log + ]); + } +} diff --git a/app/Libraries/NaverApiClient.php b/app/Libraries/NaverApiClient.php index f2aad02..f952fda 100644 --- a/app/Libraries/NaverApiClient.php +++ b/app/Libraries/NaverApiClient.php @@ -134,6 +134,30 @@ class NaverApiClient return $this->request('POST', $url, $priceData); } + /** + * 현장확인 슬롯 동기화 + * API (가) - /kiso/center/site-slot + * 신규/변경 일자별 데이터 전송 + * @syncData array + * @param string $baseDate 기준일자(ISO 8601 형식: YYYY-MM-DD 예: 2024-01-31) + * @param string $legalDivisionNumber 구역번호 "1111000000" + * @param object slots 오전/오후/ 슬롯 정보 + [ + { + "baseDate": "2025-04-30", + "legalDivisionNumber": "1111000000", + "slots": { + "am": { "max": 10, "reserved": 0 }, + "pm": { "max": 100, "reserved": 0 } + } + } + ] + */ + public function syncSiteSlot($syncData){ + $url = $this->baseUrl . "/kiso/center/verification-site/slots"; + return $this->request('POST', $url, $syncData); + } + public function submitSyncResult(string $reserveNoList): ?array { $url = "{$this->baseUrl}/site/submitSyncResult.nhn"; @@ -457,31 +481,6 @@ class NaverApiClient return $this->request('POST', $url, $postData); } - /** - * 현장확인 슬롯 동기화 - * API (가) - /kiso/center/site-slot - * 신규/변경 일자별 데이터 전송 - * @syncData array - * @param string $baseDate 기준일자(ISO 8601 형식: YYYY-MM-DD 예: 2024-01-31) - * @param string $legalDivisionNumber 구역번호 "1111000000" - * @param object slots 오전/오후/ 슬롯 정보 - [ - { - "baseDate": "2025-04-30", - "legalDivisionNumber": "1111000000", - "slots": { - "am": { "max": 10, "reserved": 0 }, - "pm": { "max": 100, "reserved": 0 } - } - } - ] - */ - public function syncSiteSlot($syncData){ - $url = $this->baseUrl . "/kiso/center/verification-site/slots"; - return $this->request('POST', $url, $syncData); - } - - /** * CURL 공통 실행 함수 */ diff --git a/app/Views/manage/worker_log/failed_list.php b/app/Views/manage/worker_log/failed_list.php new file mode 100644 index 0000000..66f0447 --- /dev/null +++ b/app/Views/manage/worker_log/failed_list.php @@ -0,0 +1,410 @@ +extend('layouts/main') ?> + +section('page_styles') ?> + +endSection() ?> + +section('content') ?> +
+
+
+
+ +
+
+ Worker 실패 로그 관리 +
+ 처리 실패한 작업을 분석하고 재처리합니다. +
+
+
+
+
+ + +
+
+
+

전체 실패 건수

+

+
+
+
+
+

재시도 가능

+

+
+
+
+
+

재시도 횟수 초과

+

+
+
+
+ + +
+
오류 유형별 분석
+
+
+ $type): ?> + 0): ?> +
+ + + + +
+ + +
+
+
+ + +
+
+
+ 최근 실패 로그 (50건) + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ID매물번호오류 유형오류 메시지재시도발생시각액션
+ + + + + + + '기타', 'severity' => 'low']; + ?> + + + + +
+ +
+
+ 0): ?> + + + - + + + +
+ 실패한 로그가 없습니다. +
+
+
+
+ +endSection() ?> + +section('modals') ?> + + +endSection() ?> + +section('page_scripts') ?> + +endSection() ?>