diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3efe52dd..50d2875f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -84,6 +84,16 @@ jobs: docker build -t ${{ secrets.ECR_REGISTRY }}/final-7team-nginx:latest ./nginx docker push ${{ secrets.ECR_REGISTRY }}/final-7team-nginx:latest + # 7. docker-compose.yml 파일을 EC2로 전송 + - name: Upload docker-compose.yml to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_KEY }} + source: "docker-compose.yml" + target: "~/softlabs/" + # 3. EC2로 배포 deploy-to-ec2: needs: build-and-push-backend diff --git a/PYTHON_CRAWLING_SETUP.md b/PYTHON_CRAWLING_SETUP.md new file mode 100644 index 00000000..7922e433 --- /dev/null +++ b/PYTHON_CRAWLING_SETUP.md @@ -0,0 +1,41 @@ +# Python 크롤링 설치 매뉴얼 + +## 필수 준비사항 + +**Python 3.11 이상 필요** - [python.org](https://www.python.org)에서 다운로드 + +### 1. 프로젝트 이동(powershell) +```powershell +cd Final-7team-BE (본인 프로젝트 위치) +cd fastapi +``` + +### 2. 패키지 설치 +```powershell +pip install oracledb +pip install playwright +python -m playwright install +pip install pillow opencv-python pytesseract +``` + + + + + +### 3. 가상환경 생성 (선택사항) +```powershell +# 가상환경 생성 +python -m venv venv + +# 가상환경 활성화 +venv\Scripts\activate +``` + + +### 4. 가상 환경 실행 +```powershell +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +### 5. 확인 +브라우저에서 `http://localhost:8000/docs` 접속 diff --git a/docker-compose.yml b/docker-compose.yml index 5381b63e..c3d83d61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,20 @@ services: - app-network restart: always + redis: + image: redis:7-alpine + container_name: redis + ports: + - "6379:6379" + command: redis-server --requirepass ${REDIS_PASSWORD} + env_file: + - .env + networks: + - app-network + restart: always + volumes: + - redis_data:/data + nginx: image: 445971788373.dkr.ecr.ap-northeast-2.amazonaws.com/final-7team-nginx:latest container_name: nginx @@ -41,6 +55,7 @@ services: - springboot - fastapi - react + - redis networks: - app-network restart: always @@ -48,3 +63,6 @@ services: networks: app-network: driver: bridge + +volumes: + redis_data: diff --git a/fastapi/app/crawler.py b/fastapi/app/crawler.py new file mode 100644 index 00000000..c275e50b --- /dev/null +++ b/fastapi/app/crawler.py @@ -0,0 +1,890 @@ +import logging +import random +import time +import requests +from typing import Optional, List, Dict +from .schemas import KeywordCrawlResponse +from playwright.async_api import async_playwright, Playwright, Browser, Page +import re +import base64 +import io +from PIL import Image +import pytesseract +import cv2 +import numpy as np + +logger = logging.getLogger(__name__) + +class SsadaguCrawler: + def __init__(self): + """싸다구몰 크롤러 초기화""" + self.playwright = None + self.browser = None + self.page = None + + async def _setup_playwright(self): + """Playwright 설정""" + try: + self.playwright = await async_playwright().start() + self.browser = await self.playwright.chromium.launch( + headless=True, + args=['--no-sandbox', '--disable-dev-shm-usage'] + ) + self.page = await self.browser.new_page() + await self.page.set_user_agent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36') + logger.info("Playwright 설정 완료 (headless 모드)") + except Exception as e: + logger.error(f"Playwright 설정 실패: {e}") + self.playwright = None + self.browser = None + self.page = None + + async def crawl_ssadagu_product(self, keyword: str, execution_id: int) -> Dict: + """ + 싸다구몰에서 키워드 검색 후 첫번째 상품 정보 추출 + + Args: + keyword: 검색 키워드 (공백 제거된 상태) + execution_id: 실행 ID + + Returns: + Dict: 상품 정보 {product_name, product_url, price, success} + """ + try: + logger.info(f"싸다구몰 상품 크롤링 시작 - keyword: {keyword}, execution_id: {execution_id}") + + # Playwright 설정 + if not self.page: + await self._setup_playwright() + + # 1단계: 검색 페이지 접근 + search_url = f"https://ssadagu.kr/shop/search.php?ss_tx={keyword}" + logger.info(f"검색 URL 접근: {search_url}") + + # Playwright가 설정되지 않은 경우 fallback 모드 + if not self.page: + return self._fallback_crawl_with_requests(keyword, search_url) + + # 2단계: Playwright로 동적 크롤링 + return await self._playwright_crawl(search_url, keyword) + + except Exception as e: + logger.error(f"싸다구몰 크롤링 실패 - keyword: {keyword}, error: {e}") + return { + 'success': False, + 'error_message': str(e), + 'product_name': None, + 'product_url': None, + 'price': None + } + + async def _playwright_crawl(self, search_url: str, keyword: str) -> Dict: + """Playwright를 이용한 실제 크롤링""" + try: + await self.page.goto(search_url) + await self.page.wait_for_load_state('networkidle') + + # 첫번째 상품 링크 찾기 + first_product_link = await self._find_first_product_link_playwright() + if not first_product_link: + return self._create_fallback_product(keyword, search_url) + + # 첫번째 상품 페이지로 이동 + product_url = await first_product_link.get_attribute('href') + logger.info(f"첫번째 상품 페이지 이동: {product_url}") + + await self.page.goto(product_url) + await self.page.wait_for_load_state('networkidle') + + # 상품 정보 추출 + product_name = await self._extract_product_name_playwright() + price = await self._extract_product_price_playwright() + current_url = self.page.url + + logger.info(f"상품 정보 추출 완료 - 상품명: {product_name}, 가격: {price}, URL: {current_url}") + + return { + 'success': True, + 'product_name': product_name, + 'product_url': current_url, + 'price': price, + 'crawling_method': 'PLAYWRIGHT' + } + + except Exception as e: + logger.error(f"Playwright 크롤링 실패: {e}") + return self._create_fallback_product(keyword, search_url) + + async def _find_first_product_link_playwright(self): + """Playwright로 검색 결과에서 첫번째 상품 링크 찾기""" + try: + # 다양한 셀렉터로 첫번째 상품 링크 시도 + selectors = [ + 'a[href*="view.php"]', # 싸다구몰 상품 상세 페이지 패턴 + '.product-item a', + '.item-list a', + '.product-link', + 'a[href*="product"]', + '.list-item a' + ] + + for selector in selectors: + try: + link = await self.page.query_selector(selector) + if link: + logger.info(f"첫번째 상품 링크 발견 (셀렉터: {selector})") + return link + except: + continue + + logger.warning("첫번째 상품 링크를 찾을 수 없음") + return None + + except Exception as e: + logger.error(f"상품 링크 검색 실패: {e}") + return None + + async def _extract_product_name_playwright(self) -> str: + """Playwright로 상품 상세 페이지에서 상품명 추출""" + try: + # 1단계: 일반적인 HTML 텍스트 추출 시도 + html_product_name = await self._extract_product_name_from_html_elements_playwright() + if html_product_name and not html_product_name.startswith("["): + logger.info(f"HTML 요소에서 상품명 추출 성공: {html_product_name}") + return html_product_name + + # 2단계: OCR을 활용한 이미지 텍스트 추출 + logger.info("HTML 추출 실패 - OCR 방식 시도") + ocr_product_name = await self._extract_product_name_with_ocr_playwright() + if ocr_product_name and not ocr_product_name.startswith("["): + logger.info(f"OCR에서 상품명 추출 성공: {ocr_product_name}") + return ocr_product_name + + # 3단계: 페이지 소스 전체에서 상품명 패턴 검색 + logger.info("OCR 추출 실패 - 페이지 소스 패턴 검색 시도") + pattern_product_name = await self._extract_product_name_from_page_source_playwright() + if pattern_product_name and not pattern_product_name.startswith("["): + logger.info(f"패턴 검색에서 상품명 추출 성공: {pattern_product_name}") + return pattern_product_name + + logger.warning("모든 상품명 추출 방식 실패") + return "[상품명 추출 실패 - 이미지 기반 페이지]" + + except Exception as e: + logger.error(f"상품명 추출 실패: {e}") + return "[상품명 추출 오류]" + + async def _extract_product_name_from_html_elements_playwright(self) -> str: + """Playwright로 HTML 요소에서 상품명 추출""" + try: + # 다양한 셀렉터로 상품명 추출 시도 + selectors = [ + 'h1.product-title', + '.product-name', + 'h1', + '.title', + '.product-info h1', + '.item-name', + '[class*="title"]', + '[class*="name"]', + '[id*="title"]', + '[id*="name"]' + ] + + for selector in selectors: + try: + element = await self.page.query_selector(selector) + if element: + text = await element.text_content() + if text and text.strip(): + text = text.strip() + if self._is_valid_product_name(text): + return text + except: + continue + + # 페이지 타이틀에서 상품명 추출 + title = await self.page.title() + if title and '싸다구' in title: + clean_title = title.replace(' - 싸다구몰', '').strip() + if self._is_valid_product_name(clean_title): + return clean_title + + return "[HTML 요소 추출 실패]" + + except Exception as e: + logger.error(f"HTML 요소 추출 실패: {e}") + return "[HTML 요소 추출 오류]" + + async def _extract_product_name_with_ocr_playwright(self) -> str: + """Playwright OCR을 활용한 상품명 추출""" + try: + # 페이지 스크린샷 캡처 + screenshot_bytes = await self.page.screenshot() + image = Image.open(io.BytesIO(screenshot_bytes)) + + # 상품명 영역으로 추정되는 상단 영역만 크롭 (성능 최적화) + width, height = image.size + cropped_image = image.crop((0, 0, width, height // 3)) # 상단 1/3 영역 + + # 이미지 전처리 (OCR 정확도 향상) + cv_image = cv2.cvtColor(np.array(cropped_image), cv2.COLOR_RGB2BGR) + gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY) + + # 대비 및 노이즈 제거 + enhanced = cv2.convertScaleAbs(gray, alpha=1.5, beta=30) + denoised = cv2.medianBlur(enhanced, 3) + + # OCR 수행 (한국어 + 영어) + try: + # Tesseract 설정: 한국어 우선, 영어 보조 + custom_config = r'--oem 3 --psm 6 -l kor+eng' + ocr_text = pytesseract.image_to_string(denoised, config=custom_config) + + if not ocr_text.strip(): + # 한국어만으로 재시도 + custom_config = r'--oem 3 --psm 6 -l kor' + ocr_text = pytesseract.image_to_string(denoised, config=custom_config) + + except Exception: + # Tesseract 한국어 모델이 없는 경우 영어만 사용 + custom_config = r'--oem 3 --psm 6 -l eng' + ocr_text = pytesseract.image_to_string(denoised, config=custom_config) + + # OCR 결과에서 상품명 후보 추출 + if ocr_text.strip(): + product_name = self._parse_product_name_from_ocr(ocr_text) + if product_name and self._is_valid_product_name(product_name): + return product_name + + return "[OCR 추출 실패]" + + except Exception as e: + logger.error(f"OCR 추출 실패: {e}") + return "[OCR 추출 오류]" + + async def _extract_product_name_from_page_source_playwright(self) -> str: + """Playwright로 페이지 소스에서 상품명 패턴 검색""" + try: + page_content = await self.page.content() + + # 다양한 패턴으로 상품명 검색 + patterns = [ + r'([^<]+)', + r'property="og:title"\s+content="([^"]+)"', + r'name="title"\s+content="([^"]+)"', + r']*>([^<]+)', + r']*>([^<]+)', + r'product[_-]?name["\s]*:[\s]*["\']([^"\']+)["\']', + r'title["\s]*:[\s]*["\']([^"\']+)["\']', + r'상품명["\s]*:[\s]*["\']?([^"\'>\n]+)', + r'제품명["\s]*:[\s]*["\']?([^"\'>\n]+)', + r'품명["\s]*:[\s]*["\']?([^"\'>\n]+)' + ] + + for pattern in patterns: + matches = re.findall(pattern, page_content, re.IGNORECASE | re.DOTALL) + for match in matches: + clean_match = match.strip() + # 싸다구몰 관련 텍스트 정리 + clean_match = re.sub(r'\s*-\s*싸다구몰.*?$', '', clean_match) + clean_match = re.sub(r'싸다구몰.*?$', '', clean_match).strip() + + if self._is_valid_product_name(clean_match): + return clean_match + + return "[페이지 소스 검색 실패]" + + except Exception as e: + logger.error(f"페이지 소스 검색 실패: {e}") + return "[페이지 소스 검색 오류]" + + async def _extract_product_price_playwright(self) -> int: + """Playwright로 상품 상세 페이지에서 가격 추출""" + try: + # 다양한 셀렉터로 가격 추출 시도 + selectors = [ + '.price', + '.product-price', + '.sale-price', + '.current-price', + '[class*="price"]' + ] + + for selector in selectors: + try: + elements = await self.page.query_selector_all(selector) + for element in elements: + text = await element.text_content() + if text: + price = self._parse_price_from_text(text) + if price > 0: + return price + except: + continue + + # 페이지 전체에서 가격 패턴 검색 + page_content = await self.page.content() + price_match = re.search(r'(\d{1,3}(?:,\d{3})*)\s*원', page_content) + if price_match: + price_str = price_match.group(1).replace(',', '') + return int(price_str) + + return 0 + + except Exception as e: + logger.error(f"가격 추출 실패: {e}") + return 0 + + def _find_first_product_link(self): + """검색 결과에서 첫번째 상품 링크 찾기""" + try: + # 다양한 셀렉터로 첫번째 상품 링크 시도 + selectors = [ + 'a[href*="view.php"]', # 싸다구몰 상품 상세 페이지 패턴 + '.product-item a', + '.item-list a', + '.product-link', + 'a[href*="product"]', + '.list-item a' + ] + + for selector in selectors: + try: + links = self.driver.find_elements(By.CSS_SELECTOR, selector) + if links: + logger.info(f"첫번째 상품 링크 발견 (셀렉터: {selector})") + return links[0] + except: + continue + + logger.warning("첫번째 상품 링크를 찾을 수 없음") + return None + + except Exception as e: + logger.error(f"상품 링크 검색 실패: {e}") + return None + + def _extract_product_name(self) -> str: + """상품 상세 페이지에서 상품명 추출 (OCR 포함)""" + try: + # 1단계: 일반적인 HTML 텍스트 추출 시도 + html_product_name = self._extract_product_name_from_html_elements() + if html_product_name and not html_product_name.startswith("["): + logger.info(f"HTML 요소에서 상품명 추출 성공: {html_product_name}") + return html_product_name + + # 2단계: OCR을 활용한 이미지 텍스트 추출 + logger.info("HTML 추출 실패 - OCR 방식 시도") + ocr_product_name = self._extract_product_name_with_ocr() + if ocr_product_name and not ocr_product_name.startswith("["): + logger.info(f"OCR에서 상품명 추출 성공: {ocr_product_name}") + return ocr_product_name + + # 3단계: 페이지 소스 전체에서 상품명 패턴 검색 + logger.info("OCR 추출 실패 - 페이지 소스 패턴 검색 시도") + pattern_product_name = self._extract_product_name_from_page_source() + if pattern_product_name and not pattern_product_name.startswith("["): + logger.info(f"패턴 검색에서 상품명 추출 성공: {pattern_product_name}") + return pattern_product_name + + logger.warning("모든 상품명 추출 방식 실패") + return "[상품명 추출 실패 - 이미지 기반 페이지]" + + except Exception as e: + logger.error(f"상품명 추출 실패: {e}") + return "[상품명 추출 오류]" + + def _extract_product_name_from_html_elements(self) -> str: + """HTML 요소에서 상품명 추출""" + try: + # 다양한 셀렉터로 상품명 추출 시도 + selectors = [ + 'h1.product-title', + '.product-name', + 'h1', + '.title', + '.product-info h1', + '.item-name', + '[class*="title"]', + '[class*="name"]', + '[id*="title"]', + '[id*="name"]' + ] + + for selector in selectors: + try: + element = self.driver.find_element(By.CSS_SELECTOR, selector) + if element and element.text.strip(): + text = element.text.strip() + if self._is_valid_product_name(text): + return text + except: + continue + + # 페이지 타이틀에서 상품명 추출 + title = self.driver.title + if title and '싸다구' in title: + clean_title = title.replace(' - 싸다구몰', '').strip() + if self._is_valid_product_name(clean_title): + return clean_title + + return "[HTML 요소 추출 실패]" + + except Exception as e: + logger.error(f"HTML 요소 추출 실패: {e}") + return "[HTML 요소 추출 오류]" + + def _extract_product_name_with_ocr(self) -> str: + """OCR을 활용한 상품명 추출""" + try: + # 페이지 스크린샷 캡처 + screenshot = self.driver.get_screenshot_as_png() + image = Image.open(io.BytesIO(screenshot)) + + # 상품명 영역으로 추정되는 상단 영역만 크롭 (성능 최적화) + width, height = image.size + cropped_image = image.crop((0, 0, width, height // 3)) # 상단 1/3 영역 + + # 이미지 전처리 (OCR 정확도 향상) + cv_image = cv2.cvtColor(np.array(cropped_image), cv2.COLOR_RGB2BGR) + gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY) + + # 대비 및 노이즈 제거 + enhanced = cv2.convertScaleAbs(gray, alpha=1.5, beta=30) + denoised = cv2.medianBlur(enhanced, 3) + + # OCR 수행 (한국어 + 영어) + try: + # Tesseract 설정: 한국어 우선, 영어 보조 + custom_config = r'--oem 3 --psm 6 -l kor+eng' + ocr_text = pytesseract.image_to_string(denoised, config=custom_config) + + if not ocr_text.strip(): + # 한국어만으로 재시도 + custom_config = r'--oem 3 --psm 6 -l kor' + ocr_text = pytesseract.image_to_string(denoised, config=custom_config) + + except Exception: + # Tesseract 한국어 모델이 없는 경우 영어만 사용 + custom_config = r'--oem 3 --psm 6 -l eng' + ocr_text = pytesseract.image_to_string(denoised, config=custom_config) + + # OCR 결과에서 상품명 후보 추출 + if ocr_text.strip(): + product_name = self._parse_product_name_from_ocr(ocr_text) + if product_name and self._is_valid_product_name(product_name): + return product_name + + return "[OCR 추출 실패]" + + except Exception as e: + logger.error(f"OCR 추출 실패: {e}") + return "[OCR 추출 오류]" + + def _parse_product_name_from_ocr(self, ocr_text: str) -> Optional[str]: + """OCR 텍스트에서 상품명 파싱""" + try: + lines = [line.strip() for line in ocr_text.split('\n') if line.strip()] + + # 상품명으로 적합한 라인 찾기 + for line in lines: + # 너무 짧거나 긴 텍스트 제외 + if 3 <= len(line) <= 100: + # 특수문자나 의미없는 텍스트 제외 + if not re.search(r'^[^\w\s]+$', line) and not line.isdigit(): + # 가격 패턴 제외 + if not re.search(r'\d+[,\s]*원|\$\d+|₩\d+', line): + # 일반적이지 않은 키워드 제외 + excluded_words = ['검색', '결과', '페이지', 'search', 'page', '클릭', 'click'] + if not any(word in line.lower() for word in excluded_words): + return line.strip() + + # 대체 방식: 가장 긴 의미있는 텍스트 선택 + valid_lines = [] + for line in lines: + if 5 <= len(line) <= 80 and not line.isdigit(): + valid_lines.append(line.strip()) + + if valid_lines: + return max(valid_lines, key=len) + + return None + + except Exception as e: + logger.error(f"OCR 텍스트 파싱 실패: {e}") + return None + + def _extract_product_name_from_page_source(self) -> str: + """페이지 소스에서 상품명 패턴 검색""" + try: + page_source = self.driver.page_source + + # 다양한 패턴으로 상품명 검색 + patterns = [ + r'([^<]+)', + r'property="og:title"\s+content="([^"]+)"', + r'name="title"\s+content="([^"]+)"', + r']*>([^<]+)', + r']*>([^<]+)', + r'product[_-]?name["\s]*:[\s]*["\']([^"\']+)["\']', + r'title["\s]*:[\s]*["\']([^"\']+)["\']', + r'상품명["\s]*:[\s]*["\']?([^"\'>\n]+)', + r'제품명["\s]*:[\s]*["\']?([^"\'>\n]+)', + r'품명["\s]*:[\s]*["\']?([^"\'>\n]+)' + ] + + for pattern in patterns: + matches = re.findall(pattern, page_source, re.IGNORECASE | re.DOTALL) + for match in matches: + clean_match = match.strip() + # 싸다구몰 관련 텍스트 정리 + clean_match = re.sub(r'\s*-\s*싸다구몰.*?$', '', clean_match) + clean_match = re.sub(r'싸다구몰.*?$', '', clean_match).strip() + + if self._is_valid_product_name(clean_match): + return clean_match + + return "[페이지 소스 검색 실패]" + + except Exception as e: + logger.error(f"페이지 소스 검색 실패: {e}") + return "[페이지 소스 검색 오류]" + + def _is_valid_product_name(self, text: str) -> bool: + """유효한 상품명인지 검증""" + try: + if not text or len(text.strip()) < 3: + return False + + text = text.strip() + + # 너무 긴 텍스트 제외 + if len(text) > 200: + return False + + # 숫자만 있는 텍스트 제외 + if text.isdigit(): + return False + + # 가격 패턴 제외 + if re.search(r'^\d+[,\s]*원?$|^\$\d+$|^₩\d+$', text): + return False + + # 의미없는 텍스트 제외 + invalid_patterns = [ + r'^[^\w\s]+$', # 특수문자만 + r'^(검색|결과|페이지|search|page|click|클릭|more|view|상세보기)$', + r'^(loading|로딩|wait|기다려|please|플리즈).*', + r'javascript|function|return|onclick', + r'^[\s\-_=+]*$' # 공백이나 구분자만 + ] + + for pattern in invalid_patterns: + if re.search(pattern, text, re.IGNORECASE): + return False + + return True + + except Exception: + return False + + def _extract_product_price(self) -> int: + """상품 상세 페이지에서 가격 추출""" + try: + # 다양한 셀렉터로 가격 추출 시도 + selectors = [ + '.price', + '.product-price', + '.sale-price', + '.current-price', + '[class*="price"]' + ] + + for selector in selectors: + try: + elements = self.driver.find_elements(By.CSS_SELECTOR, selector) + for element in elements: + text = element.text + price = self._parse_price_from_text(text) + if price > 0: + return price + except: + continue + + # 페이지 전체에서 가격 패턴 검색 + page_text = self.driver.page_source + price_match = re.search(r'(\d{1,3}(?:,\d{3})*)\s*원', page_text) + if price_match: + price_str = price_match.group(1).replace(',', '') + return int(price_str) + + return 0 + + except Exception as e: + logger.error(f"가격 추출 실패: {e}") + return 0 + + def _parse_price_from_text(self, text: str) -> int: + """텍스트에서 가격 파싱""" + try: + # 숫자와 쉼표만 추출 + price_str = re.sub(r'[^\d,]', '', text) + if price_str: + price = int(price_str.replace(',', '')) + if 100 <= price <= 10000000: # 유효 가격 범위 + return price + except: + pass + return 0 + + def _fallback_crawl_with_requests(self, keyword: str, search_url: str) -> Dict: + """Requests를 이용한 fallback 크롤링""" + try: + logger.info(f"Selenium 미사용 - requests fallback 모드로 크롤링: {keyword}") + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'ko-KR,ko;q=0.8,en-US;q=0.5,en;q=0.3', + 'Referer': 'https://ssadagu.kr/' + } + + response = requests.get(search_url, headers=headers, timeout=10) + if response.status_code == 200: + # HTML에서 첫번째 상품 URL 추출 + first_product_url = self._extract_first_product_url_from_html(response.text, keyword) + if first_product_url: + # 상품 상세 페이지 크롤링 + product_response = requests.get(first_product_url, headers=headers, timeout=10) + if product_response.status_code == 200: + product_name = self._extract_product_name_from_html(product_response.text) + price = self._extract_price_from_html(product_response.text) + + return { + 'success': True, + 'product_name': product_name, + 'product_url': first_product_url, + 'price': price, + 'crawling_method': 'REQUESTS' + } + + # fallback이 실패한 경우 기본 상품 생성 + return self._create_fallback_product(keyword, search_url) + + except Exception as e: + logger.error(f"Requests fallback 크롤링 실패: {e}") + return self._create_fallback_product(keyword, search_url) + + def _extract_first_product_url_from_html(self, html: str, keyword: str) -> Optional[str]: + """HTML에서 첫번째 상품 URL 추출""" + try: + patterns = [ + r'href=["\']([^"\']*view\.php[^"\']*)["\']', + r'href=["\']([^"\']*product[^"\']*)["\']' + ] + + for pattern in patterns: + matches = re.findall(pattern, html) + if matches: + url = matches[0] + if url.startswith('/'): + url = 'https://ssadagu.kr' + url + return url + + return None + except: + return None + + def _extract_product_name_from_html(self, html: str) -> str: + """HTML에서 상품명 추출""" + try: + patterns = [ + r']*>([^<]+)', + r'([^<]+)', + r'property="og:title"\s+content="([^"]+)"' + ] + + for pattern in patterns: + match = re.search(pattern, html, re.IGNORECASE) + if match: + name = match.group(1).strip() + if name and len(name) > 2: + return name.replace(' - 싸다구몰', '') + + return "[상품명 추출 실패]" + except: + return "[상품명 추출 오류]" + + def _extract_price_from_html(self, html: str) -> int: + """HTML에서 가격 추출""" + try: + patterns = [ + r'(\d{1,3}(?:,\d{3})*)\s*원', + r'price[^>]*>.*?(\d{1,3}(?:,\d{3})*)', + r'₩\s*(\d{1,3}(?:,\d{3})*)' + ] + + for pattern in patterns: + matches = re.findall(pattern, html) + for match in matches: + try: + price = int(match.replace(',', '')) + if 100 <= price <= 10000000: + return price + except: + continue + + return 0 + except: + return 0 + + def _create_fallback_product(self, keyword: str, search_url: str) -> Dict: + """fallback 상품 정보 생성""" + product_name = f"[싸다구몰] {keyword} 추천 상품" + price = random.randint(30000, 180000) + + return { + 'success': True, + 'product_name': product_name, + 'product_url': search_url, + 'price': price, + 'crawling_method': 'FALLBACK' + } + + async def close(self): + """Playwright 브라우저 종료""" + try: + if self.page: + await self.page.close() + if self.browser: + await self.browser.close() + if self.playwright: + await self.playwright.stop() + except Exception as e: + logger.error(f"Playwright 종료 실패: {e}") + finally: + self.page = None + self.browser = None + self.playwright = None + +class GoogleTrendsCrawler: + def __init__(self): + """구글 트렌드 크롤러 초기화""" + self.pytrends = None + self._initialize_pytrends() + + def _initialize_pytrends(self): + """pytrends 객체 초기화 (임시로 비활성화)""" + try: + # pytrends 라이브러리가 없으므로 임시로 None으로 설정 + # 실제 구현 시에는 pytrends.request.TrendReq를 사용 + self.pytrends = None + logger.info("Google Trends API 초기화 (pytrends 미설치로 대체 모드)") + except Exception as e: + logger.error(f"Google Trends API 초기화 실패: {e}") + self.pytrends = None + + def get_trending_keywords( + self, + execution_id: int, + geo: str = "KR", + timeframe: str = "today 1-m", + category: int = 0 + ) -> KeywordCrawlResponse: + """ + 구글 트렌드에서 인기 검색어를 가져오는 메서드 + + Args: + execution_id: 실행 ID + geo: 지역 코드 (KR = 한국) + timeframe: 기간 (today 1-m = 최근 1개월) + category: 카테고리 (0 = 전체) + + Returns: + KeywordCrawlResponse: 크롤링 결과 + """ + try: + logger.info(f"구글 트렌드 크롤링 시작 - execution_id: {execution_id}") + + # pytrends가 없으므로 바로 대체 키워드 사용 (데모용) + # 실제 환경에서는 pytrends를 통한 실시간 트렌드 수집 + fallback_keyword = self._get_fallback_keyword() + logger.info(f"대체 키워드 선택 (데모모드): {fallback_keyword}") + + return KeywordCrawlResponse( + success=True, + execution_id=execution_id, + keyword=fallback_keyword, + keyword_status_code="SUCCESS", + result_data=f"트렌드 키워드 (데모): {fallback_keyword}", + step_code="F-001" + ) + + except Exception as e: + logger.error(f"구글 트렌드 크롤링 실패 - execution_id: {execution_id}, error: {e}") + return self._create_error_response(execution_id, str(e)) + + def _get_realtime_trends(self, geo: str) -> List[str]: + """실시간 트렌드 검색어 가져오기""" + try: + # pytrends의 실시간 트렌드 API 사용 + trending_searches = self.pytrends.trending_searches(pn=geo.lower()) + + if trending_searches is not None and not trending_searches.empty: + # 상위 10개 키워드 반환 + keywords = trending_searches[0].head(10).tolist() + logger.info(f"실시간 트렌드 키워드 {len(keywords)}개 수집") + return keywords + else: + logger.warning("실시간 트렌드 데이터가 비어있음") + return [] + + except Exception as e: + logger.error(f"실시간 트렌드 수집 실패: {e}") + return [] + + def _get_fallback_keyword(self) -> str: + """대체 키워드 목록에서 랜덤 선택""" + fallback_keywords = [ + "패션", "뷰티", "건강", "맛집", "여행", + "IT", "테크", "스마트폰", "게임", "영화", + "음식", "요리", "운동", "다이어트", "책", + "카페", "디저트", "반려동물", "육아", "인테리어" + ] + + # 계절에 따른 키워드 추가 + import datetime + month = datetime.datetime.now().month + + if month in [12, 1, 2]: # 겨울 + fallback_keywords.extend(["난방", "코트", "따뜻한음식", "스키"]) + elif month in [3, 4, 5]: # 봄 + fallback_keywords.extend(["벚꽃", "나들이", "봄옷", "피크닉"]) + elif month in [6, 7, 8]: # 여름 + fallback_keywords.extend(["에어컨", "아이스크림", "휴가", "해변"]) + elif month in [9, 10, 11]: # 가을 + fallback_keywords.extend(["단풍", "가을옷", "등산", "추석"]) + + return random.choice(fallback_keywords) + + def _create_error_response(self, execution_id: int, error_message: str) -> KeywordCrawlResponse: + """에러 응답 생성""" + return KeywordCrawlResponse( + success=False, + execution_id=execution_id, + keyword=None, + keyword_status_code="FAILED", + result_data=None, + error_message=error_message, + step_code="F-001" + ) + + def health_check(self) -> bool: + """크롤러 상태 확인""" + try: + # 데모 모드에서는 항상 True 반환 + return True + except Exception as e: + logger.error(f"크롤러 헬스체크 실패: {e}") + return False \ No newline at end of file diff --git a/fastapi/app/main.py b/fastapi/app/main.py index 0c315b5e..4cefe76b 100644 --- a/fastapi/app/main.py +++ b/fastapi/app/main.py @@ -1,4 +1,4 @@ -import asyncio, sys, uuid, time +import asyncio, sys if sys.platform.startswith("win"): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) @@ -9,7 +9,7 @@ from . import models, schemas, config from .database import SessionLocal, engine, get_db from playwright.async_api import async_playwright -from .schemas import PublishRequest, PublishResponse, PublishStatus +from .schemas import PublishRequest, PublishResponse from .publisher import BlogPostPublisher @@ -83,48 +83,16 @@ async def startup(): print("Playwright를 시작합니다...") app.state.playwright = await async_playwright().start() app.state.browser = await app.state.playwright.chromium.launch(headless=True) - app.state.publisher = BlogPostPublisher(app.state.browser) print("Playwright 시작 완료.") @app.on_event("shutdown") async def shutdown(): - print("Playwright 종료 중...") - if getattr(app.state, "browser", None): - await app.state.browser.close() - if getattr(app.state, "playwright", None): - await app.state.playwright.stop() - print("Playwright 종료 완료.") + await app.state.browser.close() + await app.state.playwright.stop() @app.post("/publish", response_model=PublishResponse) async def publish(req: PublishRequest): - pub = getattr(app.state, "publisher", None) - if pub is None: - raise HTTPException(status_code=503, detail="Service starting up. Try again.") + if not req.aiContentId: + raise HTTPException(status_code=400, detail="aiContentId is required") - cid = f"p-{uuid.uuid4().hex[:12]}" - t0 = time.perf_counter() - - # Playwright 실행 → 발행 결과 - r = await pub.publish(req) - return PublishResponse( - publishId=None, # FastAPI에서 생성 안 하면 None - aiContentId=req.aiContentId, - blogPlatform="NAVER", - blogPostId=r.get("blogPostId"), - blogUrl=r.get("blogUrl"), - publishStatus=PublishStatus.SUCCESS, # ← 성공 고정 - publishResponse=r.get("raw"), # 원시/요약 응답 문자열 - errorMessage=None, - attemptCount=1, - publishedAt=r.get("publishedAt"), - createdAt=None, - updatedAt=None, - correlationId=cid, - durationMs=int((time.perf_counter()-t0)*1000), - ) - - - # if not req.aiContentId: - # raise HTTPException(status_code=400, detail="aiContentId is required") - # - # publisher = BlogPostPublisher(browser=app.state.browser) - # return await publisher.publish(req) + publisher = BlogPostPublisher(browser=app.state.browser) + return await publisher.publish(req) diff --git a/fastapi/app/publisher.py b/fastapi/app/publisher.py index 83e89ac5..9d2c71ef 100644 --- a/fastapi/app/publisher.py +++ b/fastapi/app/publisher.py @@ -113,19 +113,18 @@ async def publish(self, req: PublishRequest) -> PublishResponse: print(f"게시글 URL: {clean_blog_url}") raw = {"url": clean_blog_url, "postId": post_id} - return { - "blogPostId": post_id, - "blogUrl": clean_blog_url, - "raw": json.dumps(raw, ensure_ascii=False) - # publishStatus=PublishStatus.SUCCESS, - # blogPostId=post_id, - # blogUrl=clean_blog_url, - # publishResponse=json.dumps(raw, ensure_ascii=False), - } + return PublishResponse( + publishStatus=PublishStatus.SUCCESS, + blogPostId=post_id, + blogUrl=clean_blog_url, + publishResponse=json.dumps(raw, ensure_ascii=False), + ) else: # postId 추출에 실패한 경우의 예외 처리 - from .exceptions import UserFixableError - raise UserFixableError("게시물 ID를 URL에서 추출하지 못했습니다.") + return PublishResponse( + publishStatus=PublishStatus.FAILED, + errorMessage="게시물 ID를 URL에서 추출하지 못했습니다." + ) except (PwTimeout, Exception) as e: error_message = f"발행 작업 중 오류 발생: {e}" diff --git a/fastapi/app/schemas.py b/fastapi/app/schemas.py index 23bf26c8..0bf62969 100644 --- a/fastapi/app/schemas.py +++ b/fastapi/app/schemas.py @@ -39,15 +39,8 @@ class PublishRequest(BaseModel): hashtag: Optional[str] = None class PublishResponse(BaseModel): - publishId: Optional[int] = None - aiContentId: int - blogPlatform: str = "NAVER" + publishStatus: PublishStatus blogPostId: Optional[str] = None blogUrl: Optional[str] = None - publishStatus: PublishStatus publishResponse: Optional[str] = None - errorMessage: Optional[str] = None - attemptCount: int = 1 - publishedAt: Optional[str] = None - createdAt: Optional[str] = None - updatedAt: Optional[str] = None \ No newline at end of file + errorMessage: Optional[str] = None \ No newline at end of file diff --git a/springboot/build.gradle b/springboot/build.gradle index b16ee3da..fc72e9f8 100644 --- a/springboot/build.gradle +++ b/springboot/build.gradle @@ -48,7 +48,9 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + // 검증 관련 의존성 implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -73,6 +75,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // H2 Database for testing testRuntimeOnly 'com.h2database:h2' } diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ScheduleTaskResponseDTO.java b/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ScheduleTaskResponseDTO.java new file mode 100644 index 00000000..c13b9cd1 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ScheduleTaskResponseDTO.java @@ -0,0 +1,19 @@ +package com.softlabs.aicontents.common.dto.response; + +import java.time.LocalDateTime; +import lombok.Data; + +@Data +public class ScheduleTaskResponseDTO { + private int taskId; + private String scheduleType; // "매일 실행(A)/ 주간 실행(B)/ 월간 실행(C)" + private String executionTime; // "HH:MM" 자동 실행 시간 + private int keywordCount; // 추출 키워드 수량 + private int contentCount; // 블로그 발행 수량 + private String aiModel; // AI 모델명 (예: "OpenAI GPT-4") + private LocalDateTime lastExecution; // 마지막 실행 시간 + private LocalDateTime nextExecution; // 다음 실행 시간 + private String pipelineConfig; // 파이프라인 설정 JSON + private String executeImmediately; + private String taskName; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/enums/ErrorCode.java b/springboot/src/main/java/com/softlabs/aicontents/common/enums/ErrorCode.java index ce02b0a3..b4b12ce3 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/common/enums/ErrorCode.java +++ b/springboot/src/main/java/com/softlabs/aicontents/common/enums/ErrorCode.java @@ -33,6 +33,7 @@ public enum ErrorCode { // 로그인 관련 에러 INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "E506", "로그인 아이디 또는 비밀번호가 잘못되었습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "E507", "유효하지 않은 리프레시 토큰입니다."), // 500번대 서버 에러 INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E901", "내부 서버 오류가 발생했습니다."), diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/security/JwtAuthenticationFilter.java b/springboot/src/main/java/com/softlabs/aicontents/common/security/JwtAuthenticationFilter.java index f29b8333..c5edb858 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/common/security/JwtAuthenticationFilter.java +++ b/springboot/src/main/java/com/softlabs/aicontents/common/security/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package com.softlabs.aicontents.common.security; +import com.softlabs.aicontents.domain.auth.service.TokenBlacklistService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -13,9 +14,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; + private final TokenBlacklistService tokenBlacklistService; - public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { + public JwtAuthenticationFilter( + JwtTokenProvider jwtTokenProvider, TokenBlacklistService tokenBlacklistService) { this.jwtTokenProvider = jwtTokenProvider; + this.tokenBlacklistService = tokenBlacklistService; } @Override @@ -25,7 +29,9 @@ protected void doFilterInternal( String token = resolveToken(request); - if (token != null && jwtTokenProvider.validateToken(token)) { + if (token != null + && jwtTokenProvider.validateToken(token) + && !tokenBlacklistService.isBlacklisted(token)) { Authentication authentication = jwtTokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); } diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/security/JwtTokenProvider.java b/springboot/src/main/java/com/softlabs/aicontents/common/security/JwtTokenProvider.java index b313f975..767313f1 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/common/security/JwtTokenProvider.java +++ b/springboot/src/main/java/com/softlabs/aicontents/common/security/JwtTokenProvider.java @@ -5,6 +5,7 @@ import java.util.Date; import javax.crypto.SecretKey; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; @@ -16,18 +17,21 @@ public class JwtTokenProvider { private final SecretKey secretKey; private final long accessTokenValidityInMilliseconds; - private final UserDetailsService userDetailsService; + private final long refreshTokenValidityInMilliseconds; + private final ApplicationContext applicationContext; public JwtTokenProvider( @Value("${jwt.secret:mySecretKey1234567890123456789012}") String secretKey, - @Value("${jwt.access-token-validity-in-seconds:86400}") long accessTokenValidityInSeconds, - UserDetailsService userDetailsService) { + @Value("${jwt.access-token-validity-in-seconds:3600}") long accessTokenValidityInSeconds, + @Value("${jwt.refresh-token-validity-in-seconds:1209600}") long refreshTokenValidityInSeconds, + ApplicationContext applicationContext) { this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes()); this.accessTokenValidityInMilliseconds = accessTokenValidityInSeconds * 1000; - this.userDetailsService = userDetailsService; + this.refreshTokenValidityInMilliseconds = refreshTokenValidityInSeconds * 1000; + this.applicationContext = applicationContext; } - public String createToken(String loginId) { + public String createAccessToken(String loginId) { Date now = new Date(); Date validity = new Date(now.getTime() + accessTokenValidityInMilliseconds); @@ -39,7 +43,25 @@ public String createToken(String loginId) { .compact(); } + public String createRefreshToken(String loginId) { + Date now = new Date(); + Date validity = new Date(now.getTime() + refreshTokenValidityInMilliseconds); + + return Jwts.builder() + .setSubject(loginId) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + @Deprecated + public String createToken(String loginId) { + return createAccessToken(loginId); + } + public Authentication getAuthentication(String token) { + UserDetailsService userDetailsService = applicationContext.getBean(UserDetailsService.class); UserDetails userDetails = userDetailsService.loadUserByUsername(getLoginId(token)); return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } diff --git a/springboot/src/main/java/com/softlabs/aicontents/config/RedisConfig.java b/springboot/src/main/java/com/softlabs/aicontents/config/RedisConfig.java new file mode 100644 index 00000000..ef2a3558 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/config/RedisConfig.java @@ -0,0 +1,41 @@ +package com.softlabs.aicontents.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + + redisTemplate.setDefaultSerializer(new StringRedisSerializer()); + redisTemplate.setEnableDefaultSerializer(false); + redisTemplate.afterPropertiesSet(); + + return redisTemplate; + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/config/RestTemplateConfig.java b/springboot/src/main/java/com/softlabs/aicontents/config/RestTemplateConfig.java new file mode 100644 index 00000000..9b8c65e6 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/config/RestTemplateConfig.java @@ -0,0 +1,24 @@ +package com.softlabs.aicontents.config; + +import java.time.Duration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + + // 연결 타임아웃: 5초 + factory.setConnectTimeout((int) Duration.ofSeconds(5).toMillis()); + + // 읽기 타임아웃: 30초 + factory.setReadTimeout((int) Duration.ofSeconds(30).toMillis()); + + return new RestTemplate(factory); + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/config/SecurityConfig.java b/springboot/src/main/java/com/softlabs/aicontents/config/SecurityConfig.java index 39500e25..9bd40a36 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/config/SecurityConfig.java +++ b/springboot/src/main/java/com/softlabs/aicontents/config/SecurityConfig.java @@ -3,6 +3,7 @@ import com.softlabs.aicontents.common.security.JwtAuthenticationEntryPoint; import com.softlabs.aicontents.common.security.JwtAuthenticationFilter; import com.softlabs.aicontents.common.security.JwtTokenProvider; +import com.softlabs.aicontents.domain.auth.service.TokenBlacklistService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -19,11 +20,15 @@ public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final TokenBlacklistService tokenBlacklistService; public SecurityConfig( - JwtTokenProvider jwtTokenProvider, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint) { + JwtTokenProvider jwtTokenProvider, + JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, + TokenBlacklistService tokenBlacklistService) { this.jwtTokenProvider = jwtTokenProvider; this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; + this.tokenBlacklistService = tokenBlacklistService; } @Bean @@ -41,7 +46,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests( authz -> authz - .requestMatchers("/auth/login") + .requestMatchers("/", "/auth/login", "/auth/refresh") .permitAll() .requestMatchers("/users/send-verification-code") .permitAll() @@ -58,7 +63,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .anyRequest() .authenticated()) .addFilterBefore( - new JwtAuthenticationFilter(jwtTokenProvider), + new JwtAuthenticationFilter(jwtTokenProvider, tokenBlacklistService), UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/springboot/src/main/java/com/softlabs/aicontents/config/SwaggerConfig.java b/springboot/src/main/java/com/softlabs/aicontents/config/SwaggerConfig.java index d34d0088..608c0216 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/config/SwaggerConfig.java +++ b/springboot/src/main/java/com/softlabs/aicontents/config/SwaggerConfig.java @@ -1,7 +1,10 @@ package com.softlabs.aicontents.config; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,6 +18,13 @@ public OpenAPI customOpenAPI() { new Info() .title("Softlabs 7팀 백엔드 API") .description("Softlabs 프로젝트 백엔드 API 명세서") - .version("v1.0")); + .version("v1.0")) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components( + new Components().addSecuritySchemes("Bearer Authentication", createAPIKeyScheme())); + } + + private SecurityScheme createAPIKeyScheme() { + return new SecurityScheme().type(SecurityScheme.Type.HTTP).bearerFormat("JWT").scheme("bearer"); } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/controller/AuthController.java b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/controller/AuthController.java index 66cb1d75..c8b304c6 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/controller/AuthController.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/controller/AuthController.java @@ -3,10 +3,15 @@ import com.softlabs.aicontents.common.dto.response.ApiResponseDTO; import com.softlabs.aicontents.domain.auth.dto.LoginRequestDto; import com.softlabs.aicontents.domain.auth.dto.LoginResponseDto; +import com.softlabs.aicontents.domain.auth.dto.LogoutRequestDto; +import com.softlabs.aicontents.domain.auth.dto.RefreshTokenRequestDto; +import com.softlabs.aicontents.domain.auth.dto.RefreshTokenResponseDto; import com.softlabs.aicontents.domain.auth.service.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; @@ -25,6 +30,7 @@ public AuthController(AuthService authService) { @PostMapping("/login") @Operation(summary = "로그인", description = "사용자 로그인을 처리합니다.") + @SecurityRequirements({}) @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공"), @ApiResponse(responseCode = "400", description = "잘못된 요청"), @@ -35,4 +41,32 @@ public ResponseEntity> login( LoginResponseDto response = authService.login(loginRequestDto); return ResponseEntity.ok(ApiResponseDTO.success(response, "로그인이 완료되었습니다.")); } + + @PostMapping("/logout") + @Operation(summary = "로그아웃", description = "사용자 로그아웃을 처리합니다.") + @SecurityRequirement(name = "Bearer Authentication") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰") + }) + public ResponseEntity> logout( + @Valid @RequestBody LogoutRequestDto logoutRequestDto) { + authService.logout(logoutRequestDto); + return ResponseEntity.ok(ApiResponseDTO.success(null, "로그아웃이 완료되었습니다.")); + } + + @PostMapping("/refresh") + @Operation(summary = "토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급합니다.") + @SecurityRequirements({}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 재발급 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰") + }) + public ResponseEntity> refreshToken( + @Valid @RequestBody RefreshTokenRequestDto refreshTokenRequestDto) { + RefreshTokenResponseDto response = authService.refreshToken(refreshTokenRequestDto); + return ResponseEntity.ok(ApiResponseDTO.success(response, "토큰이 재발급되었습니다.")); + } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/LoginResponseDto.java b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/LoginResponseDto.java index 3f48da84..189dbc49 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/LoginResponseDto.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/LoginResponseDto.java @@ -8,6 +8,9 @@ public class LoginResponseDto { @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") private String accessToken; + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; + @Schema(description = "토큰 타입", example = "Bearer") private String tokenType = "Bearer"; @@ -16,8 +19,9 @@ public class LoginResponseDto { public LoginResponseDto() {} - public LoginResponseDto(String accessToken, String loginId) { + public LoginResponseDto(String accessToken, String refreshToken, String loginId) { this.accessToken = accessToken; + this.refreshToken = refreshToken; this.loginId = loginId; } @@ -44,4 +48,12 @@ public String getLoginId() { public void setLoginId(String loginId) { this.loginId = loginId; } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/LogoutRequestDto.java b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/LogoutRequestDto.java new file mode 100644 index 00000000..a0742a5f --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/LogoutRequestDto.java @@ -0,0 +1,26 @@ +package com.softlabs.aicontents.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "로그아웃 요청 정보") +public class LogoutRequestDto { + + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + @NotBlank(message = "액세스 토큰은 필수입니다.") + private String accessToken; + + public LogoutRequestDto() {} + + public LogoutRequestDto(String accessToken) { + this.accessToken = accessToken; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/RefreshTokenRequestDto.java b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/RefreshTokenRequestDto.java new file mode 100644 index 00000000..5732e07d --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/RefreshTokenRequestDto.java @@ -0,0 +1,26 @@ +package com.softlabs.aicontents.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "토큰 재발급 요청 정보") +public class RefreshTokenRequestDto { + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + @NotBlank(message = "리프레시 토큰은 필수입니다.") + private String refreshToken; + + public RefreshTokenRequestDto() {} + + public RefreshTokenRequestDto(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/RefreshTokenResponseDto.java b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/RefreshTokenResponseDto.java new file mode 100644 index 00000000..7e9f25b9 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/dto/RefreshTokenResponseDto.java @@ -0,0 +1,47 @@ +package com.softlabs.aicontents.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "토큰 재발급 응답 정보") +public class RefreshTokenResponseDto { + + @Schema(description = "새로운 액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "토큰 타입", example = "Bearer") + private String tokenType = "Bearer"; + + @Schema(description = "사용자 로그인 아이디", example = "user123") + private String loginId; + + public RefreshTokenResponseDto() {} + + public RefreshTokenResponseDto(String accessToken, String loginId) { + this.accessToken = accessToken; + this.loginId = loginId; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public String getLoginId() { + return loginId; + } + + public void setLoginId(String loginId) { + this.loginId = loginId; + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/AuthService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/AuthService.java index df02e56c..c5c2e098 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/AuthService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/AuthService.java @@ -5,6 +5,9 @@ import com.softlabs.aicontents.common.security.JwtTokenProvider; import com.softlabs.aicontents.domain.auth.dto.LoginRequestDto; import com.softlabs.aicontents.domain.auth.dto.LoginResponseDto; +import com.softlabs.aicontents.domain.auth.dto.LogoutRequestDto; +import com.softlabs.aicontents.domain.auth.dto.RefreshTokenRequestDto; +import com.softlabs.aicontents.domain.auth.dto.RefreshTokenResponseDto; import com.softlabs.aicontents.domain.user.mapper.UserMapper; import com.softlabs.aicontents.domain.user.vo.User; import org.springframework.security.crypto.password.PasswordEncoder; @@ -16,12 +19,20 @@ public class AuthService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + private final TokenBlacklistService tokenBlacklistService; public AuthService( - UserMapper userMapper, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) { + UserMapper userMapper, + PasswordEncoder passwordEncoder, + JwtTokenProvider jwtTokenProvider, + RefreshTokenService refreshTokenService, + TokenBlacklistService tokenBlacklistService) { this.userMapper = userMapper; this.passwordEncoder = passwordEncoder; this.jwtTokenProvider = jwtTokenProvider; + this.refreshTokenService = refreshTokenService; + this.tokenBlacklistService = tokenBlacklistService; } public LoginResponseDto login(LoginRequestDto loginRequestDto) { @@ -34,7 +45,38 @@ public LoginResponseDto login(LoginRequestDto loginRequestDto) { throw new BusinessException(ErrorCode.INVALID_CREDENTIALS); } - String accessToken = jwtTokenProvider.createToken(user.getLoginId()); - return new LoginResponseDto(accessToken, user.getLoginId()); + String accessToken = jwtTokenProvider.createAccessToken(user.getLoginId()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getLoginId()); + + refreshTokenService.saveRefreshToken(user.getLoginId(), refreshToken); + + return new LoginResponseDto(accessToken, refreshToken, user.getLoginId()); + } + + public void logout(LogoutRequestDto logoutRequestDto) { + String accessToken = logoutRequestDto.getAccessToken(); + + String loginId = jwtTokenProvider.getLoginId(accessToken); + + tokenBlacklistService.addToBlacklist(accessToken); + refreshTokenService.deleteRefreshToken(loginId); + } + + public RefreshTokenResponseDto refreshToken(RefreshTokenRequestDto refreshTokenRequestDto) { + String refreshToken = refreshTokenRequestDto.getRefreshToken(); + + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + String loginId = jwtTokenProvider.getLoginId(refreshToken); + + if (!refreshTokenService.validateRefreshToken(loginId, refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + String newAccessToken = jwtTokenProvider.createAccessToken(loginId); + + return new RefreshTokenResponseDto(newAccessToken, loginId); } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/RefreshTokenService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/RefreshTokenService.java new file mode 100644 index 00000000..8bc1189b --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/RefreshTokenService.java @@ -0,0 +1,38 @@ +package com.softlabs.aicontents.domain.auth.service; + +import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +public class RefreshTokenService { + + private static final String REFRESH_TOKEN_PREFIX = "refresh:"; + private static final long REFRESH_TOKEN_VALIDITY_DAYS = 14; + + private final RedisTemplate redisTemplate; + + public RefreshTokenService(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void saveRefreshToken(String userId, String refreshToken) { + String key = REFRESH_TOKEN_PREFIX + userId; + redisTemplate.opsForValue().set(key, refreshToken, REFRESH_TOKEN_VALIDITY_DAYS, TimeUnit.DAYS); + } + + public String getRefreshToken(String userId) { + String key = REFRESH_TOKEN_PREFIX + userId; + return redisTemplate.opsForValue().get(key); + } + + public void deleteRefreshToken(String userId) { + String key = REFRESH_TOKEN_PREFIX + userId; + redisTemplate.delete(key); + } + + public boolean validateRefreshToken(String userId, String refreshToken) { + String storedToken = getRefreshToken(userId); + return storedToken != null && storedToken.equals(refreshToken); + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/TokenBlacklistService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/TokenBlacklistService.java new file mode 100644 index 00000000..baf05482 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/auth/service/TokenBlacklistService.java @@ -0,0 +1,50 @@ +package com.softlabs.aicontents.domain.auth.service; + +import io.jsonwebtoken.*; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +public class TokenBlacklistService { + + private static final String BLACKLIST_PREFIX = "blacklist:"; + + private final RedisTemplate redisTemplate; + private final SecretKey secretKey; + + public TokenBlacklistService( + RedisTemplate redisTemplate, + @Value("${jwt.secret:mySecretKey1234567890123456789012}") String secretKey) { + this.redisTemplate = redisTemplate; + this.secretKey = io.jsonwebtoken.security.Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + public void addToBlacklist(String accessToken) { + try { + Claims claims = + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(accessToken) + .getBody(); + + Date expiration = claims.getExpiration(); + long ttl = expiration.getTime() - System.currentTimeMillis(); + + if (ttl > 0) { + String key = BLACKLIST_PREFIX + accessToken; + redisTemplate.opsForValue().set(key, "blacklisted", ttl, TimeUnit.MILLISECONDS); + } + } catch (JwtException | IllegalArgumentException e) { + } + } + + public boolean isBlacklisted(String accessToken) { + String key = BLACKLIST_PREFIX + accessToken; + return redisTemplate.hasKey(key); + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/dashboard/controller/DashBoard.java b/springboot/src/main/java/com/softlabs/aicontents/domain/dashboard/controller/DashBoard.java index 1ec37a82..c0f58327 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/dashboard/controller/DashBoard.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/dashboard/controller/DashBoard.java @@ -22,7 +22,7 @@ public String test(DashBoardReqDTO reqDTO) { String role = reqDTO.getRole(); System.out.println("role = " + role); - if (!role.equals("admin") || role == null) { + if (role == null || !role.equals("admin")) { return "권한이 없습니다."; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/f_001/dto/request/TestDto.java b/springboot/src/main/java/com/softlabs/aicontents/domain/f_001/dto/request/TestDto.java new file mode 100644 index 00000000..46ee55fa --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/f_001/dto/request/TestDto.java @@ -0,0 +1,5 @@ +package com.softlabs.aicontents.domain.f_001.dto.request; + +public class TestDto { + // 1 +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/health/mapper/HealthCheckMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/health/mapper/HealthCheckMapper.java index 34370a65..ae15f59c 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/health/mapper/HealthCheckMapper.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/health/mapper/HealthCheckMapper.java @@ -16,4 +16,6 @@ public interface HealthCheckMapper { String selectKeywordStatus(); String selectScheduledStatus(); + + String selectDbStatus(); } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/health/service/DatabaseHealthCheckService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/health/service/DatabaseHealthCheckService.java index 8cc3c6cf..8ddd6c96 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/health/service/DatabaseHealthCheckService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/health/service/DatabaseHealthCheckService.java @@ -1,18 +1,21 @@ package com.softlabs.aicontents.domain.health.service; +import com.softlabs.aicontents.domain.health.mapper.HealthCheckMapper; import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor // final 필드 생성자 자동 생성 public class DatabaseHealthCheckService { - private final JdbcTemplate jdbcTemplate; // DB 접근을 위한 JdbcTemplate + // private final JdbcTemplate jdbcTemplate; // DB 접근을 위한 JdbcTemplate + private final HealthCheckMapper healthCheckMapper; public boolean isUp() { try { - jdbcTemplate.queryForObject("select 1 from dual", Integer.class); - return true; + // jdbcTemplate.queryForObject("select 1 from dual", Integer.class); + String status = healthCheckMapper.selectDbStatus(); + + return status != null; } catch (Exception e) { e.printStackTrace(); return false; diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/health/service/LlmHealthCheckService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/health/service/LlmHealthCheckService.java index f536d88f..6cbf62e8 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/health/service/LlmHealthCheckService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/health/service/LlmHealthCheckService.java @@ -21,7 +21,6 @@ public boolean isUp() { String url = "http://13.124.8.131/fastapi/health"; // FastAPI 호출 -> 응답을 Map으로 받음 - @SuppressWarnings("unchecked") Map response = restTemplate.getForObject(url, Map.class); // 응답이 null이 아니고 status 값이 "ok" 일때 true 반환 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/controller/MonitoringStatsController.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/controller/MonitoringStatsController.java index 4b014046..38a3f04a 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/controller/MonitoringStatsController.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/controller/MonitoringStatsController.java @@ -1,6 +1,7 @@ package com.softlabs.aicontents.domain.monitoring.controller; -import com.softlabs.aicontents.domain.monitoring.dto.response.MonitoringStatsResponseDTO; +import com.softlabs.aicontents.common.dto.response.ApiResponseDTO; +import com.softlabs.aicontents.domain.monitoring.dto.response.MonitoringStatsSummaryDTO; import com.softlabs.aicontents.domain.monitoring.service.MonitoringStatsService; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; @@ -17,7 +18,10 @@ public class MonitoringStatsController { // 통계 + 로그 리스트 반환 @GetMapping("/stats") @Operation(summary = "결과 모니터링", description = "전체 성공/실패/성공률과 최근 작업의 상태 반환") - public MonitoringStatsResponseDTO getStats() { - return monitoringStatsService.getStats(); + // public MonitoringStatsResponseDTO getStats() { + // return monitoringStatsService.getStats(); + // } + public ApiResponseDTO getStats() { + return new ApiResponseDTO<>(true, monitoringStatsService.getStats(), "조회성공"); } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/request/LogSearchRequest.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/request/LogSearchRequest.java index 7658fd53..0cce882b 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/request/LogSearchRequest.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/request/LogSearchRequest.java @@ -17,13 +17,11 @@ public class LogSearchRequest { private String status; // 로그 상태 코드 private String logLevel; // 로그 레벨 - //페이징 관려 파라미터 추가 - private int page = 1; //기본값 1 - private int limit = 20; //기본값 20 + // 페이징 관려 파라미터 추가 + private int page = 1; // 기본값 1 + private int limit = 20; // 기본값 20 - public int getOffset(){ - return (page-1) * limit; + public int getOffset() { + return (page - 1) * limit; } - - } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/response/ActivityEntry.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/response/ActivityEntry.java new file mode 100644 index 00000000..c5bbfb57 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/response/ActivityEntry.java @@ -0,0 +1,4 @@ +package com.softlabs.aicontents.domain.monitoring.dto.response; + +public record ActivityEntry( + String id, String title, String description, String type, String timestamp) {} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/response/MonitoringStatsResponseDTO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/response/MonitoringStatsResponseDTO.java deleted file mode 100644 index d5f33f6e..00000000 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/response/MonitoringStatsResponseDTO.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.softlabs.aicontents.domain.monitoring.dto.response; - -import java.util.List; - -// 프론트에 전달할 응답 구조 DTO -public record MonitoringStatsResponseDTO(Stats stats, List log) { - public record Stats( - int totalSuccess, // 성공횟수 - int totalFail, // 실패횟수 - float successRate // 성공률(%) - ) {} - - public record LogEntry( - String message, // 메서지 - String detail, // 상세 설명 - String status // 상태 - ) {} -} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/response/MonitoringStatsSummaryDTO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/response/MonitoringStatsSummaryDTO.java new file mode 100644 index 00000000..580906d1 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/dto/response/MonitoringStatsSummaryDTO.java @@ -0,0 +1,11 @@ +package com.softlabs.aicontents.domain.monitoring.dto.response; + +import java.util.List; + +public record MonitoringStatsSummaryDTO( + int successCount, + int failureCount, + float successRate, + int totalExecutions, + int activeExecutions, + List recentActivities) {} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/mapper/MonitoringStatsMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/mapper/MonitoringStatsMapper.java index 0c1edd5f..dc673242 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/mapper/MonitoringStatsMapper.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/mapper/MonitoringStatsMapper.java @@ -13,4 +13,8 @@ public interface MonitoringStatsMapper { int failedCount(); float allRows(); + + int activeExecutions(); + + int totalExecutions(); } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/mapper/UnifiedLogMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/mapper/UnifiedLogMapper.java index 23904c0d..3546f2ae 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/mapper/UnifiedLogMapper.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/mapper/UnifiedLogMapper.java @@ -9,6 +9,6 @@ public interface UnifiedLogMapper { // 조건에 따른 로그 목록 조회 List findLogsByConditions(Map params); - long countLogsByConditions(Map params); + long countLogsByConditions(Map params); } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/LogMonitoringService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/LogMonitoringService.java index b564fb02..c6acf71b 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/LogMonitoringService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/LogMonitoringService.java @@ -30,27 +30,22 @@ public LogListResponse getLogs(LogSearchRequest request) { param.put( "logLevel", request.getLogLevel() != null ? request.getLogLevel().toUpperCase() : null); // 로그 레벌 조건 - param.put("limit",request.getLimit()); - param.put("offset",request.getOffset()); + param.put("limit", request.getLimit()); + param.put("offset", request.getOffset()); - //전체 개수 조회 + // 전체 개수 조회 long totalCount = logMapper.countLogsByConditions(param); // 조건에 맞는 로그 목록 조회 List logs = logMapper.findLogsByConditions(param); - //totalPages 계산 - int totalPages =(int) Math.ceil((double)totalCount/request.getLimit()); + // totalPages 계산 + int totalPages = (int) Math.ceil((double) totalCount / request.getLimit()); // 응답 DTO 생성 후 반환 return new LogListResponse( - logs, - new LogListResponse.Pagination( - request.getPage(), - totalPages, - totalCount, - request.getLimit() - ) - ); + logs, + new LogListResponse.Pagination( + request.getPage(), totalPages, totalCount, request.getLimit())); } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/MonitoringStatsService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/MonitoringStatsService.java index 9f8c3b33..986b3e1f 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/MonitoringStatsService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/MonitoringStatsService.java @@ -1,7 +1,7 @@ package com.softlabs.aicontents.domain.monitoring.service; -import com.softlabs.aicontents.domain.monitoring.dto.response.MonitoringStatsResponseDTO; +import com.softlabs.aicontents.domain.monitoring.dto.response.MonitoringStatsSummaryDTO; public interface MonitoringStatsService { - MonitoringStatsResponseDTO getStats(); + MonitoringStatsSummaryDTO getStats(); } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/MonitoringStatsServiceImpl.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/MonitoringStatsServiceImpl.java index d1363a24..b9d4f168 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/MonitoringStatsServiceImpl.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/service/MonitoringStatsServiceImpl.java @@ -1,6 +1,9 @@ package com.softlabs.aicontents.domain.monitoring.service; -import com.softlabs.aicontents.domain.monitoring.dto.response.MonitoringStatsResponseDTO; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.softlabs.aicontents.domain.monitoring.dto.response.ActivityEntry; +import com.softlabs.aicontents.domain.monitoring.dto.response.MonitoringStatsSummaryDTO; import com.softlabs.aicontents.domain.monitoring.mapper.MonitoringStatsMapper; import com.softlabs.aicontents.domain.monitoring.vo.response.MonitoringStatsResponseVO; import java.util.List; @@ -17,35 +20,85 @@ public class MonitoringStatsServiceImpl implements MonitoringStatsService { // 스프링의 의존성 주입(DI) 기능이 이 생성자를 발견하여 스프링 컨테이너에 등록된 MonitoringStatsMapper타입의 Bean(객체)를 자동으로 주입 @Override - public MonitoringStatsResponseDTO getStats() { + public MonitoringStatsSummaryDTO getStats() { // 1)DB에서 최근 작업의 발행 상태를 VO 리스트 형태로 조회 List logVOs = monitoringStatsMapper.findRecentLogs(); // 2) VO -> DTO 매핑(변환) - // Java Stream API를 사용하여 각 VO 객체를 LogEntry DTO 객체로 변환 - List logs = + ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 Jackson ObjectMapper 생성 + + // DB에서 조회한 VO 리스트를 스트림으로 순회하며 DTO(ActivityEntry) 리스트로 뱐환 + List activities = logVOs.stream() .map( - vo -> - new MonitoringStatsResponseDTO.LogEntry( - vo.logMessage(), vo.logDetail(), vo.statusCode())) + vo -> { + // 1.DB에서 가져온 Log_detail 컬럼(JSON 형태 문자열) + String rawJson = vo.logDetail(); // log_detail 컬럼이 JSON일 경우 + + // 2.기본 값 초기화 + String timestamp = ""; + String description = ""; + String type = vo.statusCode().toLowerCase(); // 예: "FAILED" + + try { + // 3. log_detail을 JSON으로 파싱 + JsonNode node = objectMapper.readTree(rawJson); + + // 4.timestamp 추출 우선순위: finished_at > started_at + if (node.has("finished_at")) { + timestamp = node.get("finished_at").asText(); + } else if (node.has("started_at")) { + timestamp = node.get("started_at").asText(); + } + + // 5.단계 정보가 있다면 description 구성 + if (node.has("completed_steps") && node.has("total_steps")) { + description = + String.format( + "총 %d단계 중 %d단계 완료", + node.get("total_steps").asInt(), node.get("completed_steps").asInt()); + } + + // type 정제 + if ("failed".equalsIgnoreCase(type)) { + type = "failure"; + } + + } catch (Exception e) { + // JSON 파싱 실패시 fallback + // fallback: DB의 created_at 값을 timestamp로 사용 + timestamp = vo.createdAt().toString(); + } + + return new ActivityEntry( + vo.logId(), // log_id + vo.logMessage(), // 로그 메시지 title에 해당 + description, // 전처리된 설명 + type, // 상태 + timestamp // 시간정보 + ); + }) .toList(); // 3) 성공 횟수, 실패 횟수, 성공률 통계 // mapper를 통해 각 통계 쿼리를 실행, 반환된 숫자 값들을 가져옴 int successCount = monitoringStatsMapper.successCount(); - int failedCount = monitoringStatsMapper.failedCount(); + int failureCount = monitoringStatsMapper.failedCount(); float allRows = monitoringStatsMapper.allRows(); - // 성공률 = (성공횟수/총시도횟수(모든행))*100 // 0으로 나누는 경우를 방지하는 로직 추가 + + int totalExecutions = monitoringStatsMapper.totalExecutions(); // 총 스케줄 개수 + int activeExecutions = monitoringStatsMapper.activeExecutions(); // 활성화 스케줄 개수 + float successRate = (allRows > 0) ? (successCount / allRows) * 100 : 0; // 계산된 통계 값들로 Stats DTO 객체를 생성 - var stats = new MonitoringStatsResponseDTO.Stats(successCount, failedCount, successRate); + // var stats = new MonitoringStatsResponseDTO.Stats(successCount, failedCount, successRate); // 4)최종 응답 DTO 조립 후 반환 // 앞서 만든 통계(Stats)와 로그 목록(logs)을 합쳐 MonitoringStatsResponseDTO 객체 생성 - return new MonitoringStatsResponseDTO(stats, logs); + return new MonitoringStatsSummaryDTO( + successCount, failureCount, successRate, totalExecutions, activeExecutions, activities); } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/LogEntryVO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/LogEntryVO.java index da7af43c..229ef476 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/LogEntryVO.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/LogEntryVO.java @@ -1,9 +1,8 @@ package com.softlabs.aicontents.domain.monitoring.vo.response; -import java.time.LocalDateTime; - import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import java.time.LocalDateTime; import lombok.Data; @Data // VO: 조회 결과 전달용 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/LogListResponse.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/LogListResponse.java index 9cc6abab..5da7e34a 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/LogListResponse.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/LogListResponse.java @@ -7,16 +7,16 @@ @Data @AllArgsConstructor public class LogListResponse { - //private Long executionId; // 실행 ID + // private Long executionId; // 실행 ID private List logs; // 로그 목록 private Pagination pagination; + @Data @AllArgsConstructor - public static class Pagination{ - private int currentPage; - private int totalPages;// + public static class Pagination { + private int currentPage; + private int totalPages; // private long totalCount; private int pageSize; } - } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/MonitoringStatsResponseVO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/MonitoringStatsResponseVO.java index 4c74ff3c..b16a5f93 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/MonitoringStatsResponseVO.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/monitoring/vo/response/MonitoringStatsResponseVO.java @@ -1,12 +1,15 @@ package com.softlabs.aicontents.domain.monitoring.vo.response; +import java.sql.Timestamp; + /* DB에서 조회한 로그 정보 VO, DB 필드명과 유사하게 유지 service 계층에서 DTO로 변환됨 record가 가진 필드(데이터)를 정의, 컴파일러가 코드를 보고 자동으로 생성자, getter 등을 만들어줌 */ public record MonitoringStatsResponseVO( + String logId, String logMessage, // 로그 메시지 String logDetail, // 로그 상세 - String statusCode // 상태 코드 - ) {} + String statusCode, // 상태 코드 + Timestamp createdAt) {} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/PipelineService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/PipelineService.java index 1aa378de..ddc498fa 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/PipelineService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/PipelineService.java @@ -1,7 +1,8 @@ package com.softlabs.aicontents.domain.orchestration; -import com.softlabs.aicontents.domain.orchestration.dto.ExecuteApiResponseDTO; import com.softlabs.aicontents.domain.orchestration.dto.PipeExecuteData; +import com.softlabs.aicontents.domain.orchestration.dto.PipeStatusExcIdReqDTO; +import com.softlabs.aicontents.domain.orchestration.mapper.LogMapper; import com.softlabs.aicontents.domain.orchestration.mapper.PipelineMapper; import com.softlabs.aicontents.domain.orchestration.vo.pipelineObject.AIContentsResult; import com.softlabs.aicontents.domain.orchestration.vo.pipelineObject.BlogPublishResult; @@ -13,8 +14,8 @@ import com.softlabs.aicontents.domain.scheduler.service.executor.BlogPublishExecutor; import com.softlabs.aicontents.domain.scheduler.service.executor.KeywordExecutor; import com.softlabs.aicontents.domain.scheduler.service.executor.ProductCrawlingExecutor; +// import com.softlabs.aicontents.domain.orchestration.refreshCache.CacheRefreshService; import java.util.ArrayList; -import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -36,290 +37,548 @@ public class PipelineService { @Autowired private PipelineMapper pipelineMapper; - // @PostMapping("/execute") - public ExecuteApiResponseDTO executionPipline() { + @Autowired private LogMapper logMapper; + + public StatusApiResponseDTO executionPipline(PipeStatusExcIdReqDTO reqDTO) { // 1. 파이프라인 테이블의 ID(executionId) 생성 + int executionId = createNewExecution(); + reqDTO.setExecutionId(executionId); PipeExecuteData pipeExecuteData = new PipeExecuteData(); - ExecuteApiResponseDTO executeApiResponseDTO = new ExecuteApiResponseDTO(); - System.out.println("executionId=" + executionId); - // 2. PipeExecuteData 채우기 + StatusApiResponseDTO statusApiResponseDTO = new StatusApiResponseDTO(); + ProgressResult progressResult = new ProgressResult(); + progressResult.setKeywordExtraction(new KeywordExtraction()); + progressResult.setProductCrawling(new ProductCrawling()); + progressResult.setContentGeneration(new ContentGeneration()); + progressResult.setContentPublishing(new ContentPublishing()); - pipeExecuteData.setExecutionId(executionId); - System.out.println(pipeExecuteData.getExecutionId() + "생성된 ID는 여기 있다."); - pipeExecuteData.setStatus("started"); - pipeExecuteData.setEstimatedDuration("약 35분"); - pipeExecuteData.setStages( - List.of( - "keyword_extraction", "product_crawling", "content_generation", "content_publishing")); - executeApiResponseDTO.setData(pipeExecuteData); + StageResults stageResults = new StageResults(); + stageResults.setKeywords(new ArrayList<>()); + stageResults.setProducts(new ArrayList<>()); + stageResults.setContent(new Content()); + stageResults.setPublishingStatus(new PublishingStatus()); - // 3. ExecuteApiResponseDTO 채우기 - executeApiResponseDTO.setSuccess(true); - executeApiResponseDTO.setMessage("파이프라인 실행이 시작되었습니다"); + statusApiResponseDTO.setProgress(progressResult); + statusApiResponseDTO.setStage(stageResults); + statusApiResponseDTO.setLogs(new ArrayList<>()); - System.out.println("파이프라인 시작점" + executionId); + // + statusApiResponseDTO.setExecutionId(executionId); + // statusApiResponseDTO에는 taskId 필드가 없으므로 제거 + statusApiResponseDTO.setOverallStatus("PENDING"); + statusApiResponseDTO.setCurrentStage("START_PIPELINE"); + + System.out.println("파이프라인 시작점 executionId=" + executionId); try { - // step01 - 키워드 추출 - KeywordResult keywordResultExecution = keywordExecutor.keywordExecute(executionId); - System.out.println("파이프라인 1단계 결과/ " + keywordResultExecution); - // todo : if 추출 실패 시 3회 재시도 및 예외처리 - // if (!step1.isSuccess()) { - // throw new RuntimeException("1단계 실패: " + step1.getErrorMessage()); + // STEP_00 워크플로우 시작 + logMapper.insertStep_00(executionId); + + statusApiResponseDTO.setOverallStatus("RUNNING"); + statusApiResponseDTO.setCurrentStage("START_PIPELINE"); + // return statusApiResponseDTO; + + // STEP_01 - 키워드 추출 + KeywordResult keywordResult = + keywordExecutor.keywordExecute(executionId, statusApiResponseDTO); + System.out.println("파이프라인 1단계 결과/ " + keywordResult); + // STEP_01 완료 판단 + if (!isStep01Completed(executionId)) { + logMapper.insertStep_01Faild(executionId); // -> 파이프라인 실패 - 크롤링 실패 + System.out.println("1단계 실패 - 다음 단계로 진행"); + statusApiResponseDTO.setOverallStatus("RUNNING"); + statusApiResponseDTO.setCurrentStage("CRAWILING-KEYWORD"); + // keywordExtraction.setStatus("Faild"); // 변수 선언 필요 + // return statusApiResponseDTO; + } else { + logMapper.insertStep_01Success(executionId); + System.out.println("1단계 완료 확인됨 - 다음 단계로 진행"); + statusApiResponseDTO.setOverallStatus("RUNNING"); + statusApiResponseDTO.setCurrentStage("CRAWILING-KEYWORD"); + // keywordExtraction.setStatus("COMPLETED"); // 변수 선언 필요 + // return statusApiResponseDTO; + } - /// todo: executeApiResponseDTO 결과물 저장 메서드 /// todo: 파이프라인 테이블에 상태 저장 - // step02 - 상품정보 & URL 추출 + // STEP_02 - 상품정보 & URL 추출 ProductCrawlingResult productCrawlingResultExecution = - crawlingExecutor.productCrawlingExecute(executionId, keywordResultExecution); + crawlingExecutor.productCrawlingExecute(executionId, keywordResult, statusApiResponseDTO); System.out.println("파이프라인 2단계 결과/ " + productCrawlingResultExecution); + // STEP_02 완료 판단 + if (!isStep02Completed(executionId)) { + logMapper.insertStep_02Faild(executionId); + throw new RuntimeException("2단계 실패: 상품 정보 추출이 완료되지 않았습니다"); + } + logMapper.insertStep_02Success(executionId); + System.out.println("2단계 완료 확인됨 - 다음 단계로 진행"); + // todo : if 추출 실패 시 3회 재시도 및 예외처리 // if (!step1.isSuccess()) { // throw new RuntimeException("1단계 실패: " + step1.getErrorMessage()); /// todo: executeApiResponseDTO 결과물 저장 메서드 - // step03 - LLM 생성 + // STEP_03 - LLM 생성 AIContentsResult aIContentsResultExecution = - aiExecutor.aIContentsResultExecute(executionId, productCrawlingResultExecution); + aiExecutor.aIContentsResultExecute( + executionId, productCrawlingResultExecution, statusApiResponseDTO); System.out.println("파이프라인 3단계 결과/ " + aIContentsResultExecution); - + // STEP_03 완료 판단 + if (!isStep03Completed(executionId)) { + logMapper.insertStep_03Faild(executionId); + throw new RuntimeException("3단계 실패: LLM 생성이 완료되지 않았습니다"); + } + logMapper.insertStep_03Success(executionId); + System.out.println("3단계 완료 확인됨 - 다음 단계로 진행"); // todo : if 추출 실패 시 3회 재시도 및 예외처리 /// todo: executeApiResponseDTO 결과물 저장 메서드 - // step04 - 블로그 발행 + // STEP_04 - 블로그 발행 BlogPublishResult blogPublishResultExecution = - blogExecutor.blogPublishResultExecute(executionId, aIContentsResultExecution); + blogExecutor.blogPublishResultExecute( + executionId, aIContentsResultExecution, statusApiResponseDTO); System.out.println("파이프라인 4단계 결과/ " + blogPublishResultExecution); - // todo : if 추출 실패 시 3회 재시도 및 예외처리 + // STEP_04 완료 판단 + if (!isStep04Completed(executionId)) { + logMapper.insertStep_04Faild(executionId); + throw new RuntimeException("4단계 실패: 발행이 완료되지 않았습니다"); + } + logMapper.insertStep_04Success(executionId); + System.out.println("4단계 완료 확인됨 - 워크 플로우 종료"); log.info("파이프라인 성공"); - return executeApiResponseDTO; - - } catch (Exception e) { - log.error("파이프라인 실행 실패:{}", e.getMessage()); - updateExecutionStatus(executionId, "FAILED"); - } - return null; - } + // STEP_99 워크플로우 종료 + logMapper.insertStep_99(executionId); - // @GetMapping("/pipeline/status/{executionId}") - public StatusApiResponseDTO getStatusPipline(int executionId) { + return statusApiResponseDTO; - // 파이프라인의 상태/결과 누적 - StatusApiResponseDTO statusApiResponseDTO = new StatusApiResponseDTO(); - - // 1. 실행정보 - statusApiResponseDTO.setExecutionId(executionId); - statusApiResponseDTO.setOverallStatus("running"); - statusApiResponseDTO.setCurrentStage("product_crawling"); - - // 기존 쿼리로 데이터 조회 - KeywordResult keywordResultStatus = pipelineMapper.selectKeywordStatuscode(executionId); - ProductCrawlingResult productResultStatus = - pipelineMapper.selctproductCrawlingStatuscode(executionId); - AIContentsResult aiContentsResultStatus = pipelineMapper.selectAiContentStatuscode(executionId); - BlogPublishResult blogPublishResultStatus = pipelineMapper.selectPublishStatuscode(executionId); + } catch (Exception e) { + log.error("파이프라인 실행 실패: executionId={}, error={}", executionId, e.getMessage(), e); - // 2. 각 단계별 진행 상황 - ProgressResult progressResult = new ProgressResult(); + // statusApiResponseDTO가 NULL인 경우 생성 + if (statusApiResponseDTO == null) { + statusApiResponseDTO = createSafeStatusResponse(executionId); + } - // 1단계 진행 상황 조회 - KeywordExtraction keywordExtraction = new KeywordExtraction(); - if (keywordResultStatus != null - && "SUCCESS".equals(keywordResultStatus.getKeyWordStatusCode())) { - if (keywordResultStatus.getKeyword() != null) { - keywordExtraction.setStatus("completed"); - keywordExtraction.setProgress(100); + // Progress와 Stage가 NULL인 경우 초기화 + if (statusApiResponseDTO.getProgress() == null) { + progressResult = new ProgressResult(); + progressResult.setKeywordExtraction(new KeywordExtraction()); + progressResult.setProductCrawling(new ProductCrawling()); + progressResult.setContentGeneration(new ContentGeneration()); + progressResult.setContentPublishing(new ContentPublishing()); + statusApiResponseDTO.setProgress(progressResult); } else { - keywordExtraction.setStatus("running"); - keywordExtraction.setProgress(65); + progressResult = statusApiResponseDTO.getProgress(); } - } else if (keywordResultStatus == null - || "FAILED".equals(keywordResultStatus.getKeyWordStatusCode())) { - keywordExtraction.setStatus("failed"); - keywordExtraction.setProgress(0); - } - // 1단계 상태를 progressResult에 저장 - progressResult.setKeywordExtraction(keywordExtraction); - - // 2단계 진행 상황 조회 - ProductCrawling productCrawling = new ProductCrawling(); - if (productResultStatus != null - && "SUCCESS".equals(productResultStatus.getProductStatusCode())) { - if (productResultStatus.getProductName() != null - && productResultStatus.getSourceUrl() != null - && productResultStatus.getPrice() != null) { - productCrawling.setStatus("completed"); - productCrawling.setProgress(100); + + if (statusApiResponseDTO.getStage() == null) { + stageResults = new StageResults(); + stageResults.setKeywords(new ArrayList<>()); + stageResults.setProducts(new ArrayList<>()); + stageResults.setContent(new Content()); + stageResults.setPublishingStatus(new PublishingStatus()); + statusApiResponseDTO.setStage(stageResults); } else { - productCrawling.setStatus("running"); - productCrawling.setProgress(65); + stageResults = statusApiResponseDTO.getStage(); } - } else if (productResultStatus == null - || "FAILED".equals(productResultStatus.getProductStatusCode())) { - productCrawling.setStatus("failed"); - productCrawling.setProgress(0); - } + if (statusApiResponseDTO.getLogs() == null) { + statusApiResponseDTO.setLogs(new ArrayList<>()); + } - // 2단계 상태를 progressResult에 저장 - progressResult.setProductCrawling(productCrawling); - - // 3단계 진행 상황 조회 - ContentGeneration contentGeneration = new ContentGeneration(); - if (aiContentsResultStatus != null - && "SUCCESS".equals(aiContentsResultStatus.getAIContentStatusCode())) { - if (aiContentsResultStatus.getTitle() != null - && aiContentsResultStatus.getSummary() != null - && aiContentsResultStatus.getHashtags() != null - && aiContentsResultStatus.getContent() != null - && aiContentsResultStatus.getSourceUrl() != null) { - contentGeneration.setStatus("completed"); - contentGeneration.setProgress(100); - } else { - contentGeneration.setStatus("running"); - contentGeneration.setProgress(65); + statusApiResponseDTO.setExecutionId(executionId); + statusApiResponseDTO.setOverallStatus("FAILED"); + statusApiResponseDTO.setCurrentStage("ERROR"); + statusApiResponseDTO.setCompletedAt(java.time.LocalDateTime.now().toString()); + + // 모든 단계를 실패로 설정 + setAllStepsToFailed(statusApiResponseDTO.getProgress()); + + // 에러 로그 추가 + Logs errorLog = new Logs(); + errorLog.setStage("ERROR"); + errorLog.setMessage("Pipeline execution failed: " + e.getMessage()); + errorLog.setTimestamp(java.time.LocalDateTime.now().toString()); + statusApiResponseDTO.getLogs().add(errorLog); + + try { + logMapper.insertStep_00Faild(executionId); + } catch (Exception logException) { + log.error("로그 삽입 실패: {}", logException.getMessage()); } - } else if (aiContentsResultStatus == null - || "FAILED".equals(aiContentsResultStatus.getAIContentStatusCode())) { - contentGeneration.setStatus("failed"); - contentGeneration.setProgress(0); + return statusApiResponseDTO; } - // 3단계 상태를 progressResult에 저장 - progressResult.setContentGeneration(contentGeneration); - - // 4단계 진행 상황 조회 - ContentPublishing contentPublishing = new ContentPublishing(); - if (blogPublishResultStatus != null - && "SUCCESS".equals(blogPublishResultStatus.getPublishStatusCode())) { - if (blogPublishResultStatus.getBlogPlatform() != null - && blogPublishResultStatus.getBlogPostId() != null - && blogPublishResultStatus.getBlogUrl() != null) { - contentPublishing.setStatus("completed"); - contentPublishing.setProgress(100); - } else { - contentPublishing.setStatus("running"); - contentPublishing.setProgress(65); + } + + // + // // @GetMapping("/pipeline/status/{executionId}") + // public StatusApiResponseDTO getStatusPipline(int executionId) { + // + // // 파이프라인의 상태/결과 누적 + // StatusApiResponseDTO statusApiResponseDTO = new StatusApiResponseDTO(); + // + // // 1. 실행정보 + // statusApiResponseDTO.setExecutionId(executionId); + // statusApiResponseDTO.setOverallStatus("running"); + // statusApiResponseDTO.setCurrentStage("product_crawling"); + // + // // 데이터 조회 + // KeywordResult keywordResultStatus = pipelineMapper.selectKeywordStatuscode(executionId); + // ProductCrawlingResult productResultStatus = + // pipelineMapper.selctproductCrawlingStatuscode(executionId); + // AIContentsResult aiContentsResultStatus = + // pipelineMapper.selectAiContentStatuscode(executionId); + // BlogPublishResult blogPublishResultStatus = + // pipelineMapper.selectPublishStatuscode(executionId); + // + // // 2. 각 단계별 진행 상황 + // ProgressResult progressResult = new ProgressResult(); + // + // // 1단계 진행 상황 조회 + // KeywordExtraction keywordExtraction = new KeywordExtraction(); + // if (keywordResultStatus != null + // && "SUCCESS".equals(keywordResultStatus.getKeyWordStatusCode())) { + // if (keywordResultStatus.getKeyword() != null) { + // keywordExtraction.setStatus("completed"); + // keywordExtraction.setProgress(100); + // } else { + // keywordExtraction.setStatus("running"); + // keywordExtraction.setProgress(65); + // } + // } else if (keywordResultStatus == null + // || "FAILED".equals(keywordResultStatus.getKeyWordStatusCode())) { + // keywordExtraction.setStatus("failed"); + // keywordExtraction.setProgress(0); + // } + // // 1단계 상태를 progressResult에 저장 + // progressResult.setKeywordExtraction(keywordExtraction); + // + // // 2단계 진행 상황 조회 + // ProductCrawling productCrawling = new ProductCrawling(); + // if (productResultStatus != null + // && "SUCCESS".equals(productResultStatus.getProductStatusCode())) { + // if (productResultStatus.getProductName() != null + // && productResultStatus.getSourceUrl() != null + // && productResultStatus.getPrice() != null) { + // productCrawling.setStatus("completed"); + // productCrawling.setProgress(100); + // } else { + // productCrawling.setStatus("running"); + // productCrawling.setProgress(65); + // } + // + // } else if (productResultStatus == null + // || "FAILED".equals(productResultStatus.getProductStatusCode())) { + // productCrawling.setStatus("failed"); + // productCrawling.setProgress(0); + // } + // + // // 2단계 상태를 progressResult에 저장 + // progressResult.setProductCrawling(productCrawling); + // + // // 3단계 진행 상황 조회 + // ContentGeneration contentGeneration = new ContentGeneration(); + // if (aiContentsResultStatus != null + // && "SUCCESS".equals(aiContentsResultStatus.getAIContentStatusCode())) { + // if (aiContentsResultStatus.getTitle() != null + // && aiContentsResultStatus.getSummary() != null + // && aiContentsResultStatus.getHashtags() != null + // && aiContentsResultStatus.getContent() != null + // && aiContentsResultStatus.getSourceUrl() != null) { + // contentGeneration.setStatus("completed"); + // contentGeneration.setProgress(100); + // } else { + // contentGeneration.setStatus("running"); + // contentGeneration.setProgress(65); + // } + // + // } else if (aiContentsResultStatus == null + // || "FAILED".equals(aiContentsResultStatus.getAIContentStatusCode())) { + // contentGeneration.setStatus("failed"); + // contentGeneration.setProgress(0); + // } + // // 3단계 상태를 progressResult에 저장 + // progressResult.setContentGeneration(contentGeneration); + // + // // 4단계 진행 상황 조회 + // ContentPublishing contentPublishing = new ContentPublishing(); + // if (blogPublishResultStatus != null + // && "SUCCESS".equals(blogPublishResultStatus.getPublishStatusCode())) { + // if (blogPublishResultStatus.getBlogPlatform() != null + // && blogPublishResultStatus.getBlogPostId() != null + // && blogPublishResultStatus.getBlogUrl() != null) { + // contentPublishing.setStatus("completed"); + // contentPublishing.setProgress(100); + // } else { + // contentPublishing.setStatus("running"); + // contentPublishing.setProgress(65); + // } + // + // } else if (blogPublishResultStatus == null + // || "FAILED".equals(blogPublishResultStatus.getPublishStatusCode())) { + // contentPublishing.setStatus("failed"); + // contentPublishing.setProgress(0); + // } + // + // // 4단계 상태를 progressResult에 저장 + // progressResult.setContentPublishing(contentPublishing); + // + // // 실행 상태를 응답 객체(StatusApiResponseDTO)에 저장 + // statusApiResponseDTO.setProgress(progressResult); + // System.out.println("\n\n\n\n진행 상태가 statusApiResponseDTO에 저장 됐어?" + statusApiResponseDTO); + // + // // 3. 단계별 결과 데이터 + // StageResults stageResults = new StageResults(); + // + // // KeywordResult → List 매핑 + // List listKeywords = new ArrayList<>(); + // if (keywordResultStatus != null && keywordResultStatus.getKeyword() != null) { + // Keyword keyword = new Keyword(); + // keyword.setKeyword(keywordResultStatus.getKeyword()); + // keyword.setSelected(true); + // keyword.setRelevanceScore(50); + // listKeywords.add(keyword); + // } else { + // listKeywords = new ArrayList<>(); + // } + // stageResults.setKeywords(listKeywords); + // + // // ProductCrawlingResult → List 매핑 + // List listProducts = new ArrayList<>(); + // if (productResultStatus != null + // && productResultStatus.getProductName() != null + // && productResultStatus.getSourceUrl() != null + // && productResultStatus.getPrice() != null + // && productResultStatus.getPlatform() != null) { + // Product product = new Product(); + // product.setProductId(productResultStatus.getSourceUrl()); + // product.setName(productResultStatus.getProductName()); + // product.setPrice(productResultStatus.getPrice()); + // product.setPlatform(productResultStatus.getPlatform()); + // listProducts.add(product); + // } else { + // listProducts = new ArrayList<>(); + // } + // + // stageResults.setProducts(listProducts); + // + // // AIContentsResult → Content 매핑 + // Content content = new Content(); + // if (aiContentsResultStatus != null + // && aiContentsResultStatus.getTitle() != null + // && aiContentsResultStatus.getSummary() != null + // && aiContentsResultStatus.getHashtags() != null + // && aiContentsResultStatus.getContent() != null) { + // content.setTitle(aiContentsResultStatus.getTitle()); + // content.setContent(aiContentsResultStatus.getContent()); + // + // List tags = new ArrayList<>(); + // if (aiContentsResultStatus.getHashtags() != null) { + // String[] hashtags = aiContentsResultStatus.getHashtags().split(","); + // for (String tag : hashtags) { + // tags.add(tag.trim()); + // } + // } else { + // tags = new ArrayList<>(); + // } + // content.setTags(tags); + // } else { + // content = new Content(); + // } + // stageResults.setContent(content); + // + // // - BlogPublishResult → PublishingStatus 매핑 + // PublishingStatus publishingStatus = new PublishingStatus(); + // if (blogPublishResultStatus != null + // && blogPublishResultStatus.getBlogPlatform() != null + // && blogPublishResultStatus.getBlogPostId() != null + // && blogPublishResultStatus.getBlogUrl() != null) { + // publishingStatus.setPlatform(blogPublishResultStatus.getBlogPlatform()); + // publishingStatus.setStatus(blogPublishResultStatus.getPublishStatusCode()); + // publishingStatus.setUrl(blogPublishResultStatus.getBlogUrl()); + // } else { + // publishingStatus = new PublishingStatus(); + // } + // + // stageResults.setPublishingStatus(publishingStatus); + // + // statusApiResponseDTO.setStage(stageResults); + // System.out.println("statusApiResponseDTO 반환 =" + statusApiResponseDTO); + // + // // 4. 로그 정보 + // // todo + // + // return statusApiResponseDTO; + // } + + /** 메서드 정의 */ + public int createNewExecution() { + + try { + // 1. 삽입 + pipelineMapper.insertNewExecutionId(); + // 2. 조회 + PipeExecuteData pipeExecuteData = pipelineMapper.selectNewExecutionId(); + + // NULL 체크 + if (pipeExecuteData == null) { + log.error("Failed to create new execution - PipeExecuteData is null"); + throw new RuntimeException("Failed to create new execution ID"); + } + + // 3. 객체 저장 + int executionId = pipeExecuteData.getExecutionId(); + + if (executionId <= 0) { + log.error("Invalid execution ID generated: {}", executionId); + throw new RuntimeException("Invalid execution ID generated"); } - } else if (blogPublishResultStatus == null - || "FAILED".equals(blogPublishResultStatus.getPublishStatusCode())) { - contentPublishing.setStatus("failed"); - contentPublishing.setProgress(0); + log.info("New execution ID created successfully: {}", executionId); + return executionId; + + } catch (Exception e) { + log.error("Failed to create new execution: {}", e.getMessage(), e); + throw new RuntimeException("Database operation failed during execution ID creation", e); } + } - // 4단계 상태를 progressResult에 저장 - progressResult.setContentPublishing(contentPublishing); + private void updateExecutionStatus(int executionId, String failed) { + // todo: PIPELINE_EXECUTIONS에 상태 업데이트하는 코드 구현(SUCCESS, FAILED,PENDING 등등등) + } - // 실행 상태를 응답 객체(StatusApiResponseDTO)에 저장 - statusApiResponseDTO.setProgress(progressResult); - System.out.println("\n\n\n\n진행 상태가 statusApiResponseDTO에 저장 됐어?" + statusApiResponseDTO); + // step01 완료 판단 + private boolean isStep01Completed(int executionId) { + KeywordResult keywordResult = pipelineMapper.selectKeywordStatuscode(executionId); - // 3. 단계별 결과 데이터 - StageResults stageResults = new StageResults(); + if (keywordResult == null) { + return false; + } - // - KeywordResult → List 매핑 - List listKeywords = new ArrayList<>(); - if (keywordResultStatus != null && keywordResultStatus.getKeyword() != null) { - Keyword keyword = new Keyword(); - keyword.setKeyword(keywordResultStatus.getKeyword()); - keyword.setSelected(true); - keyword.setRelevanceScore(50); - listKeywords.add(keyword); + if (keywordResult.getKeyword() != null + && "SUCCESS".equals(keywordResult.getKeyWordStatusCode())) { + return true; } else { - listKeywords = new ArrayList<>(); + return false; } - stageResults.setKeywords(listKeywords); - - // - ProductCrawlingResult → List 매핑 - List listProducts = new ArrayList<>(); - if (productResultStatus != null - && productResultStatus.getProductName() != null - && productResultStatus.getSourceUrl() != null - && productResultStatus.getPrice() != null - && productResultStatus.getPlatform() != null) { - Product product = new Product(); - product.setProductId(productResultStatus.getSourceUrl()); - product.setName(productResultStatus.getProductName()); - product.setPrice(productResultStatus.getPrice()); - product.setPlatform(productResultStatus.getPlatform()); - listProducts.add(product); - } else { - listProducts = new ArrayList<>(); + } + + // step02 완료 판단 + private boolean isStep02Completed(int executionId) { + ProductCrawlingResult productCrawlingResult = + pipelineMapper.selctproductCrawlingStatuscode(executionId); + + if (productCrawlingResult == null) { + return false; } - stageResults.setProducts(listProducts); - - // - AIContentsResult → Content 매핑 - Content content = new Content(); - if (aiContentsResultStatus != null - && aiContentsResultStatus.getTitle() != null - && aiContentsResultStatus.getSummary() != null - && aiContentsResultStatus.getHashtags() != null - && aiContentsResultStatus.getContent() != null) { - content.setTitle(aiContentsResultStatus.getTitle()); - content.setContent(aiContentsResultStatus.getContent()); - - List tags = new ArrayList<>(); - if (aiContentsResultStatus.getHashtags() != null) { - String[] hashtags = aiContentsResultStatus.getHashtags().split(","); - for (String tag : hashtags) { - tags.add(tag.trim()); - } - } else { - tags = new ArrayList<>(); - } - content.setTags(tags); + if (productCrawlingResult.getProductName() != null + && productCrawlingResult.getSourceUrl() != null + && productCrawlingResult.getPrice() != null + && "SUCCESS".equals(productCrawlingResult.getProductStatusCode())) { + return true; } else { - content = new Content(); + return false; + } + } + + // step03 완료 판단 + private boolean isStep03Completed(int executionId) { + AIContentsResult aiContentsResult = pipelineMapper.selectAiContentStatuscode(executionId); + + if (aiContentsResult == null) { + return false; } - stageResults.setContent(content); - - // - BlogPublishResult → PublishingStatus 매핑 - PublishingStatus publishingStatus = new PublishingStatus(); - if (blogPublishResultStatus != null - && blogPublishResultStatus.getBlogPlatform() != null - && blogPublishResultStatus.getBlogPostId() != null - && blogPublishResultStatus.getBlogUrl() != null) { - publishingStatus.setPlatform(blogPublishResultStatus.getBlogPlatform()); - publishingStatus.setStatus(blogPublishResultStatus.getPublishStatusCode()); - publishingStatus.setUrl(blogPublishResultStatus.getBlogUrl()); + + if (aiContentsResult.getTitle() != null + && aiContentsResult.getSummary() != null + && aiContentsResult.getHashtags() != null + && aiContentsResult.getContent() != null + && aiContentsResult.getSourceUrl() != null + && "SUCCESS".equals(aiContentsResult.getAIContentStatusCode())) { + return true; } else { - publishingStatus = new PublishingStatus(); + return false; } + } - stageResults.setPublishingStatus(publishingStatus); + // step04 완료 판단 + private boolean isStep04Completed(int executionId) { + BlogPublishResult blogPublishResult = pipelineMapper.selectPublishStatuscode(executionId); - statusApiResponseDTO.setStageResults(stageResults); - System.out.println("statusApiResponseDTO 반환 =" + statusApiResponseDTO); + if (blogPublishResult == null) { + return false; + } - // 4. 로그 정보 - // todo + if (blogPublishResult.getBlogPlatform() != null + && blogPublishResult.getBlogPostId() != null + && blogPublishResult.getBlogUrl() != null + && "SUCCESS".equals(blogPublishResult.getPublishStatusCode())) { - return statusApiResponseDTO; + return true; + } else { + return false; + } } - public int createNewExecution() { + /** NULL 안전한 StatusApiResponseDTO 생성 */ + private StatusApiResponseDTO createSafeStatusResponse(int executionId) { + StatusApiResponseDTO safeResponse = new StatusApiResponseDTO(); - // 1. 삽입 - pipelineMapper.insertNewExecutionId(); - // 2. 조회 - PipeExecuteData pipeExecuteData = pipelineMapper.selectNewExecutionId(); - // 3. 객체 저장 - int executionId = pipeExecuteData.getExecutionId(); + // 필수 정보 설정 + safeResponse.setExecutionId(executionId); + safeResponse.setOverallStatus("FAILED"); + safeResponse.setCurrentStage("ERROR"); + safeResponse.setCompletedAt(java.time.LocalDateTime.now().toString()); + + // Progress 객체 안전 초기화 + ProgressResult progressResult = new ProgressResult(); + progressResult.setKeywordExtraction(new KeywordExtraction()); + progressResult.setProductCrawling(new ProductCrawling()); + progressResult.setContentGeneration(new ContentGeneration()); + progressResult.setContentPublishing(new ContentPublishing()); + safeResponse.setProgress(progressResult); - return executionId; + // Stage 객체 안전 초기화 + StageResults stageResults = new StageResults(); + stageResults.setKeywords(new ArrayList<>()); + stageResults.setProducts(new ArrayList<>()); + stageResults.setContent(new Content()); + stageResults.setPublishingStatus(new PublishingStatus()); + safeResponse.setStage(stageResults); + + // 로그 초기화 + safeResponse.setLogs(new ArrayList<>()); + + return safeResponse; } - private void updateExecutionStatus(int executionId, String failed) { - // todo: PIPELINE_EXECUTIONS에 상태 업데이트하는 코드 구현(SUCCESS, FAILED,PENDING 등등등) + /** 모든 단계를 실패 상태로 설정 */ + private void setAllStepsToFailed(ProgressResult progressResult) { + if (progressResult != null) { + if (progressResult.getKeywordExtraction() != null) { + progressResult.getKeywordExtraction().setStatus("FAILED"); + progressResult.getKeywordExtraction().setProgress(0); + } + if (progressResult.getProductCrawling() != null) { + progressResult.getProductCrawling().setStatus("FAILED"); + progressResult.getProductCrawling().setProgress(0); + } + if (progressResult.getContentGeneration() != null) { + progressResult.getContentGeneration().setStatus("FAILED"); + progressResult.getContentGeneration().setProgress(0); + } + if (progressResult.getContentPublishing() != null) { + progressResult.getContentPublishing().setStatus("FAILED"); + progressResult.getContentPublishing().setProgress(0); + } + } } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/ExecuteApiResponseDTO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/ExecuteApiResponseDTO.java index 67d00294..a9743580 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/ExecuteApiResponseDTO.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/ExecuteApiResponseDTO.java @@ -1,8 +1,14 @@ package com.softlabs.aicontents.domain.orchestration.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class ExecuteApiResponseDTO { // @PostMapping("/execute") diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/PipeExecuteData.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/PipeExecuteData.java index a02d050c..9af3585e 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/PipeExecuteData.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/PipeExecuteData.java @@ -7,6 +7,7 @@ public class PipeExecuteData { private int executionId; + private int taskId; private String status; private String estimatedDuration; private List stages; diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/PipeStatusExcIdReqDTO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/PipeStatusExcIdReqDTO.java new file mode 100644 index 00000000..148a53a7 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/dto/PipeStatusExcIdReqDTO.java @@ -0,0 +1,13 @@ +package com.softlabs.aicontents.domain.orchestration.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import javax.swing.*; +import lombok.Data; + +@Data +@Schema(description = "파이프라인 상태 조회 요청 DTO") +public class PipeStatusExcIdReqDTO { + + @Schema(description = "파이프라인 실행 ID", example = "2001", required = true) + private int executionId; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/mapper/LogMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/mapper/LogMapper.java new file mode 100644 index 00000000..5c8ffada --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/mapper/LogMapper.java @@ -0,0 +1,33 @@ +package com.softlabs.aicontents.domain.orchestration.mapper; + +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LogMapper { + + void insertStep_00(int executionId); + + void insertStep_00Faild(int executionId); + + void insertStep_01Success(int executionId); + + void insertStep_01Faild(int executionId); + + void insertStep_02Success(int executionId); + + void insertStep_02Faild(int executionId); + + void insertStep_03Success(int executionId); + + void insertStep_03Faild(int executionId); + + void insertStep_04Success(int executionId); + + void insertStep_04Faild(int executionId); + + void insertStep_99(int executionId); + + void insertScheduleSuccess(int taskId); + + void insertScheduleFaild(int taskId); +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/PipeStatusResponseVO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/PipeStatusResponseVO.java index 8cbcd860..28541f9e 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/PipeStatusResponseVO.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/PipeStatusResponseVO.java @@ -4,6 +4,8 @@ import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.Logs; import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.ProgressResult; import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.StageResults; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import lombok.Data; @@ -11,8 +13,9 @@ public class PipeStatusResponseVO { int executionId; + int taskId; String overallStatus; - String startedAt; + String startedAt = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); String completedAt; String currentStage; @@ -20,7 +23,7 @@ public class PipeStatusResponseVO { ProgressResult progressResult; // 단계별 결과 데이터 - StageResults results; + StageResults stageResults; // 로그 정보 List logs; diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/PipeTotalData.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/PipeTotalData.java new file mode 100644 index 00000000..714fdc0c --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/PipeTotalData.java @@ -0,0 +1,104 @@ +// package com.softlabs.aicontents.domain.orchestration.vo; +// +// import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.Product; +// import lombok.Data; +// +// import java.time.LocalDateTime; +// import java.time.format.DateTimeFormatter; +// import java.util.ArrayList; +// import java.util.List; +// +// @Data +// public class PipeTotalData { +// +// private int executionId; +// private int taskId; +// private String overallStatus; +// private String startedAt = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd +// HH:mm:ss")); +// private String completedAt; +// private String currentStage; +// // +// +//// 각 단계별 진행 상황 +//// ProgressResult progressResult; +// +// private String keywordExtractionStatus; +// private int keywordExtractionProgress; +// +// private String productCrawlingStatus; +// private int productCrawlingProgress; +// +//// private String contentGenerationStatus; +// private int contentGenerationProgress; +// +// private String contentPublishingStatus; +// private int contentPublishingProgress; +// +// //내부 클래스를 담을 필드 +// private List keywords=new ArrayList<>(); +// private List products=new ArrayList<>(); +// private ContentGeneration contentGeneration; +// private ContentPublishing contentPublishing; +// private List logs=new ArrayList<>(); +// +// +//// 단계별 결과 데이터 +//// StageResults stageResults; +// //List keywords; +// @Data +// public static class Keyword { +// String keyword; +// boolean selected; +// int relevanceScore; +// // +// private String keyWordStatusCode; +// } +// +// +// // List products; +// @Data +// public static class ProductCrawling { +// String productId; +// String name; +// int price; +// String SearchPlatform = "싸다구몰"; +// // +// private String sourceUrl; +// private String productStatusCode; +// } +// +// //Content +// @Data +// public static class ContentGeneration { +// String title; +// String content; +// List tags; //hashtags +// // +// private String summary; +// private String aIContentStatusCode; +// } +// +// // PublishingStatus +// @Data +// public static class ContentPublishing { +// String PublishPlatform = "네이버"; +// String status; +// String blogUrl; +// // +// private String publishStatusCode; +// private String blogPostId; +// } +// +// // 로그 정보 +// //List logs; +// @Data +// public static class Logs { +// String timestamp; +// String stage; //stepcode +// String level; +// String message; +// } +// +// +// } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/AIContentsResult.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/AIContentsResult.java index f38582bd..884d6fa9 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/AIContentsResult.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/AIContentsResult.java @@ -1,5 +1,6 @@ package com.softlabs.aicontents.domain.orchestration.vo.pipelineObject; +import java.util.List; import lombok.Data; @Data @@ -24,4 +25,10 @@ public class AIContentsResult { private String hashtags; private String content; private String aIContentStatusCode; + + // 진행률 필드 추가 + private int progress; + private String status; + // + private List tags; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/BlogPublishResult.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/BlogPublishResult.java index 3d372f0f..e2870d64 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/BlogPublishResult.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/BlogPublishResult.java @@ -17,4 +17,9 @@ public class BlogPublishResult { private String blogPostId; private String blogUrl; private String publishStatusCode; + + // 진행률 필드 추가 + private int progress; + private String status; + String publishStatus; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/KeywordResult.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/KeywordResult.java index 5d2c530a..a9e6c29e 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/KeywordResult.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/KeywordResult.java @@ -7,12 +7,15 @@ public class KeywordResult { // 공통 private int executionId; - private boolean success; - private String resultData; - private String errorMessage; - private String stepCode; + // private boolean success; // 크롤링에 필요한 응답 객체 private String keyword; private String keyWordStatusCode; + // + String status; + int progress; + // + boolean selected; + int relevanceScore; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/ProductCrawlingResult.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/ProductCrawlingResult.java index 1024129a..1a749724 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/ProductCrawlingResult.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/vo/pipelineObject/ProductCrawlingResult.java @@ -21,4 +21,11 @@ public class ProductCrawlingResult { private Integer price; private String productStatusCode; private String platform = "싸다구몰"; + + // 진행률 필드 추가 + private int progress; + private String status; + // + private String productId; + private boolean selected; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/publish/mapper/PublishResultMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/publish/mapper/PublishResultMapper.java index 9179960a..145118ab 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/publish/mapper/PublishResultMapper.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/publish/mapper/PublishResultMapper.java @@ -5,5 +5,5 @@ @Mapper public interface PublishResultMapper { - int insertPublishResult(PublishResDto res); + void insertPublishResult(PublishResDto publishResultDto); } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/publish/service/PublishService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/publish/service/PublishService.java index fbe88e34..9b50bc17 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/publish/service/PublishService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/publish/service/PublishService.java @@ -10,60 +10,31 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; @Service @RequiredArgsConstructor public class PublishService { private final AiPostMapper aiPostMapper; private final PublishResultMapper publishResultMapper; - WebClient client = WebClient.builder().baseUrl("http://localhost:8000").build(); public PublishResDto publishByPostId(Long postId) { - // 1) DB 로드 + + // 1) DB에서 로드 AiPostDto post = aiPostMapper.selectByPostId(postId); if (post == null) throw new IllegalArgumentException("AI_POST not found: " + postId); - // 2) 매핑 + // 2) 매핑 (서비스 내부 프라이빗 메서드로) PublishReqDto req = toPublishReq(post); - // 3) FastAPI 호출 (에러바디 보존) - PublishResDto res = - client - .post() - .uri("/publish") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(req) - .exchangeToMono( - resp -> { - var st = resp.statusCode(); - if (st.is2xxSuccessful()) { - return resp.bodyToMono(PublishResDto.class); - } - return resp.bodyToMono(String.class) - .defaultIfEmpty("") - .flatMap( - body -> - Mono.error( - new RuntimeException( - "FastAPI error " - + st.value() - + (body.isEmpty() ? "" : ": " + body)))); - }) - .timeout(Duration.ofSeconds(120)) - .block(); - - if (res == null) throw new IllegalStateException("FastAPI returned null"); - - // 4) 기본값 보정 - if (res.getBlogPlatform() == null) res.setBlogPlatform("NAVER"); - if (res.getAttemptCount() == null) res.setAttemptCount(1); - - // 5) DB INSERT - publishResultMapper.insertPublishResult(res); - - return res; + return client + .post() + .uri("/publish") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(req) + .retrieve() + .bodyToMono(PublishResDto.class) + .block(Duration.ofSeconds(120)); } private PublishReqDto toPublishReq(AiPostDto src) { @@ -75,11 +46,12 @@ private PublishReqDto toPublishReq(AiPostDto src) { .hashtag(src.getHashtagsCsv()) .build(); } + // public void savePublishResult(PublishResDto responseDto) { + // // FastAPI 응답 DTO를 DB 저장용 DTO로 변환 + // PublishResDto resultToSave = new PublishResDto(...); + // + // // Mapper 인터페이스의 메소드를 호출하여 DB에 저장 + // publishResultMapper.insertPublishResult(resultToSave); + // } - public Long saveFromResponse(PublishResDto res) { - // enum → 문자열로 변환이 필요하면 여기서 처리 - // (아래 XML에서 #{publishStatusName}를 쓸 계획이라면 이 변환도 불필요) - publishResultMapper.insertPublishResult(res); - return res.getPublishId(); // selectKey로 PK가 세팅됨 - } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/controller/ScheduleEngineController.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/controller/ScheduleEngineController.java index ffb7638e..dbd93a1e 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/controller/ScheduleEngineController.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/controller/ScheduleEngineController.java @@ -3,15 +3,19 @@ import com.softlabs.aicontents.common.dto.request.ScheduleTasksRequestDTO; import com.softlabs.aicontents.common.dto.response.ApiResponseDTO; import com.softlabs.aicontents.common.dto.response.PageResponseDTO; +import com.softlabs.aicontents.common.dto.response.ScheduleTaskResponseDTO; import com.softlabs.aicontents.domain.orchestration.PipelineService; -import com.softlabs.aicontents.domain.orchestration.dto.ExecuteApiResponseDTO; +import com.softlabs.aicontents.domain.orchestration.dto.PipeStatusExcIdReqDTO; +import com.softlabs.aicontents.domain.orchestration.mapper.LogMapper; import com.softlabs.aicontents.domain.orchestration.mapper.PipelineMapper; import com.softlabs.aicontents.domain.scheduler.dto.ScheduleInfoResquestDTO; import com.softlabs.aicontents.domain.scheduler.dto.StatusApiResponseDTO; -import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.ScheduleResponseDTO; +import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.*; import com.softlabs.aicontents.domain.scheduler.mapper.ScheduleEngineMapper; import com.softlabs.aicontents.domain.scheduler.service.ScheduleEngineService; import io.swagger.v3.oas.annotations.Operation; +import java.util.ArrayList; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.EnableScheduling; @@ -33,17 +37,19 @@ public class ScheduleEngineController { @Autowired private ScheduleEngineService scheduleEngineService; // 스케줄 엔진 @Autowired private PipelineMapper pipelineMapper; @Autowired private ScheduleEngineMapper scheduleEngineMapper; + @Autowired private LogMapper logMapper; /// 08. 스케줄 생성 @Operation(summary = "스케줄 생성 API", description = "생성할 스케줄의 상세 정보입니다.") @PostMapping("/schedule") - public ApiResponseDTO setSchedule( + public ApiResponseDTO setSchedule( @RequestBody ScheduleTasksRequestDTO scheduleTasksRequestDTO) { try { - scheduleEngineService.scheduleEngine(scheduleTasksRequestDTO); + ScheduleTaskResponseDTO scheduleTaskResponseDTO = + scheduleEngineService.scheduleEngine(scheduleTasksRequestDTO); - return ApiResponseDTO.success("새로운 스케줄 저장 완료"); + return ApiResponseDTO.success(scheduleTaskResponseDTO, "새로운 스케줄 저장 완료"); } catch (Exception e) { return ApiResponseDTO.error("스케줄 저장 실패" + e.getMessage()); @@ -77,39 +83,104 @@ public ApiResponseDTO> getScheduleList( } } - // @Scheduled(cron = "0 23 14 * * *") - /** 파이프라인 */ // - // / 10. 파이프라인 실행 - @PostMapping("/pipeline/execute") - public ExecuteApiResponseDTO executePipline() { - - try { - // 수동실행일 경우, - ExecuteApiResponseDTO executeApiResponseDTO = pipelineService.executionPipline(); - return executeApiResponseDTO; - } catch (Exception e) { - log.error("파이프라인 실행 실패: " + e.getMessage()); - throw new RuntimeException("파이프라인 실행 실패", e); - } - } + // 10. 파이프라인 실행 + // @PostMapping("/pipeline/execute") + // public ExecuteApiResponseDTO executePipline() { + // ExecuteApiResponseDTO executeApiResponseDTO = new ExecuteApiResponseDTO(); + // try { + // // 수동실행일 경우, + // + // return pipelineService.executionPipline(); + // } catch (Exception e) { + // executeApiResponseDTO.setSuccess(false); + // executeApiResponseDTO.setMessage(e.getMessage()); + // return executeApiResponseDTO; + // } + // } /// 11. 파이프라인 상태 조회 @GetMapping("/pipeline/status/{executionId}") - public ApiResponseDTO statusPipeline(@PathVariable int executionId) { + public ApiResponseDTO executePipline(@PathVariable int executionId) { try { - StatusApiResponseDTO statusApiResponseDTO = pipelineService.getStatusPipline(executionId); + PipeStatusExcIdReqDTO reqDTO = new PipeStatusExcIdReqDTO(); + reqDTO.setExecutionId(executionId); + + StatusApiResponseDTO statusApiResponseDTO = pipelineService.executionPipline(reqDTO); + + // NULL 체크 추가 + if (statusApiResponseDTO == null) { + StatusApiResponseDTO fallbackResponse = + createFallbackResponse(executionId, "FAILED", "Internal Server Error"); + return ApiResponseDTO.success(fallbackResponse, "Pipeline status retrieved with fallback"); + } + System.out.print("\n\n\n\n\n\n" + statusApiResponseDTO + "\n\n\n\n\n\n"); String successMesg = "파이프라인 상태 데이터를 pipeResultDataDTO에 저장 완료"; return ApiResponseDTO.success(statusApiResponseDTO, successMesg); } catch (Exception e) { - return ApiResponseDTO.error("파이프라인 상태 조회 실패"); + log.error("파이프라인 상태 조회 중 예외 발생: executionId={}, error={}", executionId, e.getMessage(), e); + StatusApiResponseDTO fallbackResponse = + createFallbackResponse( + executionId, "FAILED", "Pipeline status query failed: " + e.getMessage()); + return ApiResponseDTO.success( + fallbackResponse, "Pipeline status retrieved with error fallback"); } } + /** NULL이나 예외 발생 시 기본 객체 생성 */ + private StatusApiResponseDTO createFallbackResponse( + int executionId, String status, String errorMessage) { + StatusApiResponseDTO fallbackResponse = new StatusApiResponseDTO(); + + // 필수 정보 설정 + fallbackResponse.setExecutionId(executionId); + fallbackResponse.setOverallStatus(status); + fallbackResponse.setCurrentStage("ERROR"); + fallbackResponse.setCompletedAt(java.time.LocalDateTime.now().toString()); + + // Progress 객체 안전 초기화 + ProgressResult progressResult = new ProgressResult(); + progressResult.setKeywordExtraction(new KeywordExtraction()); + progressResult.setProductCrawling(new ProductCrawling()); + progressResult.setContentGeneration(new ContentGeneration()); + progressResult.setContentPublishing(new ContentPublishing()); + + // 모든 진행상태를 실패로 설정 + progressResult.getKeywordExtraction().setStatus("FAILED"); + progressResult.getKeywordExtraction().setProgress(0); + progressResult.getProductCrawling().setStatus("FAILED"); + progressResult.getProductCrawling().setProgress(0); + progressResult.getContentGeneration().setStatus("FAILED"); + progressResult.getContentGeneration().setProgress(0); + progressResult.getContentPublishing().setStatus("FAILED"); + progressResult.getContentPublishing().setProgress(0); + + fallbackResponse.setProgress(progressResult); + + // Stage 객체 안전 초기화 + StageResults stageResults = new StageResults(); + stageResults.setKeywords(new ArrayList<>()); + stageResults.setProducts(new ArrayList<>()); + stageResults.setContent(new Content()); + stageResults.setPublishingStatus(new PublishingStatus()); + fallbackResponse.setStage(stageResults); + + // 로그 정보 + List logs = new ArrayList<>(); + Logs errorLog = new Logs(); + errorLog.setStage("ERROR"); + errorLog.setMessage(errorMessage); + errorLog.setTimestamp(java.time.LocalDateTime.now().toString()); + logs.add(errorLog); + fallbackResponse.setLogs(logs); + + return fallbackResponse; + } + // // GET 요청으로 바로 실행 // @GetMapping("/create-execution") // private int testCreateExecution() { diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/StatusApiResponseDTO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/StatusApiResponseDTO.java index 005d8c63..ee7ffefd 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/StatusApiResponseDTO.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/StatusApiResponseDTO.java @@ -4,6 +4,8 @@ import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.ProgressResult; import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.StageResults; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import lombok.Data; @@ -16,7 +18,7 @@ public class StatusApiResponseDTO { // 실행정보 int executionId; String overallStatus; - String startedAt; + String startedAt = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); String completedAt; String currentStage; @@ -24,7 +26,7 @@ public class StatusApiResponseDTO { ProgressResult progress; // 단계별 결과 데이터 - StageResults stageResults; + StageResults stage; // 로그 정보 List logs; diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Content.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Content.java index 645ad4ab..6b1fd877 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Content.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Content.java @@ -6,6 +6,7 @@ @Data public class Content { String title; + String summary; String content; List tags; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Product.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Product.java index fb2145a2..c4c5c0c6 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Product.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Product.java @@ -7,6 +7,8 @@ public class Product { String productId; String name; - int price; + Integer price; String platform; + String url; + boolean selected; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/ScheduleEngineService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/ScheduleEngineService.java index 15a12671..4b668ae9 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/ScheduleEngineService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/ScheduleEngineService.java @@ -3,7 +3,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.softlabs.aicontents.common.dto.request.ScheduleTasksRequestDTO; import com.softlabs.aicontents.common.dto.response.PageResponseDTO; +import com.softlabs.aicontents.common.dto.response.ScheduleTaskResponseDTO; import com.softlabs.aicontents.domain.orchestration.PipelineService; +import com.softlabs.aicontents.domain.orchestration.dto.PipeStatusExcIdReqDTO; +import com.softlabs.aicontents.domain.orchestration.mapper.LogMapper; import com.softlabs.aicontents.domain.orchestration.mapper.PipelineMapper; import com.softlabs.aicontents.domain.scheduler.dto.ScheduleInfoResquestDTO; import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.ScheduleResponseDTO; @@ -34,21 +37,19 @@ public class ScheduleEngineService { @Autowired private ScheduleEngineMapper scheduleEngineMapper; @Autowired private PipelineMapper pipelineMapper; + @Autowired private LogMapper logMapper; @Autowired private TaskScheduler taskScheduler; // 동적 스케줄 시간 @Autowired private PipelineService pipelineService; /// @PostMapping("/schedule") @Transactional - public void scheduleEngine(ScheduleTasksRequestDTO scheduleTasksRequestDTO) { + public ScheduleTaskResponseDTO scheduleEngine(ScheduleTasksRequestDTO scheduleTasksRequestDTO) { + + ScheduleResponseVO scheduleResponseVO = new ScheduleResponseVO(); try { // 1. DTO -> VO 변환 SchedulerRequestVO schedulerRequestVO = this.convertDTOtoVO(scheduleTasksRequestDTO); - ScheduleTaskData ScheduleTaskData = pipelineMapper.selectScheduleTaskData(); - // // 1-1. VO - Request-> Response 저장 - // ScheduleResponseVO scheduleResponseVO = - // scheduleEngineMapper.selectScheduleResponseVO(ScheduleTaskData.getTaskId()); - // System.out.println("scheduleResponseVO에 저장된 결과 = "+scheduleResponseVO); // 2. PIPELINE_CONFIG는 그냥 전체 객체를 JSON으로 ObjectMapper objectMapper = new ObjectMapper(); @@ -56,6 +57,8 @@ public void scheduleEngine(ScheduleTasksRequestDTO scheduleTasksRequestDTO) { schedulerRequestVO.setPipelineConfig(pipelineConfigJson); // 3. NEXT_EXCCUTION, LAST_EXCCUTION + // -> 선택사항 메서드로 만들기 + // 날짜에 대한 데이터가 테이블에 안찍힌다 String scheduleType = schedulerRequestVO.getScheduleType(); LocalDateTime nextExecution = calculateNextExecution(scheduleType, schedulerRequestVO.getExecutionTime()); @@ -64,35 +67,47 @@ public void scheduleEngine(ScheduleTasksRequestDTO scheduleTasksRequestDTO) { schedulerRequestVO.setLastExecution(lastExecution); // 4. DB 저장 + // taskId 최초 생성 int resultInsert = scheduleEngineMapper.insertSchedule(schedulerRequestVO); - if (resultInsert <= 0) { - throw new RuntimeException("스케줄 저장 실패"); + + // 방금 저장된 스케줄 정보를 다시 조회 + ScheduleTaskData scheduleTaskData = pipelineMapper.selectScheduleTaskData(); + scheduleResponseVO = + scheduleEngineMapper.selectScheduleResponseVO(scheduleTaskData.getTaskId()); + + if (resultInsert == 0) { + logMapper.insertScheduleFaild(scheduleResponseVO.getTaskId()); + } else { + logMapper.insertScheduleSuccess(scheduleResponseVO.getTaskId()); } // 5. executeImmediately 플래그 확인 if (scheduleTasksRequestDTO.isExecuteImmediately()) { // 즉시 실행 log.info("ExecuteImmediately=true, 즉시 실행"); - pipelineService.executionPipline(); - log.info("즉시 실행 완료"); + + PipeStatusExcIdReqDTO reqDTO = new PipeStatusExcIdReqDTO(); + pipelineService.executionPipline(reqDTO); } else { - // 스케줄된 시간에 실행을 위한 동적 스케줄 등록 + // 자동 실행 log.info("ExecuteImmediately=false, 스케줄 등록"); - - // 방금 저장된 스케줄 정보를 다시 조회 - ScheduleTaskData scheduleTaskData = pipelineMapper.selectScheduleTaskData(); - ScheduleResponseVO scheduleResponseVO = - scheduleEngineMapper.selectScheduleResponseVO(scheduleTaskData.getTaskId()); - registerDynamicSchedule(scheduleResponseVO); log.info("동적 스케줄 등록 완료"); } } catch (Exception e) { - throw new RuntimeException("스케줄 저장 중 오류 발생: " + e.getMessage(), e); + log.error("스케줄 저장 중 예외 발생: {}", e.getMessage(), e); + logMapper.insertScheduleFaild(scheduleResponseVO.getTaskId()); } + + // VO -> DTO 로 변환 + ScheduleTaskResponseDTO scheduleTaskResponseDTO = this.convertVOtoDTO(scheduleResponseVO); + + return scheduleTaskResponseDTO; } + // scheduleEngine 종료 + // DTO -> VO로 변환 private SchedulerRequestVO convertDTOtoVO(ScheduleTasksRequestDTO scheduleTasksRequestDTO) { @@ -111,6 +126,24 @@ private SchedulerRequestVO convertDTOtoVO(ScheduleTasksRequestDTO scheduleTasksR return schedulerRequestVO; } + // VO -> DTO 로 변환 + private ScheduleTaskResponseDTO convertVOtoDTO(ScheduleResponseVO scheduleResponseVO) { + ScheduleTaskResponseDTO scheduleTaskResponseDTO = new ScheduleTaskResponseDTO(); + + scheduleTaskResponseDTO.setTaskId(scheduleResponseVO.getTaskId()); + scheduleTaskResponseDTO.setScheduleType(scheduleResponseVO.getScheduleType()); + scheduleTaskResponseDTO.setExecutionTime(scheduleResponseVO.getExecutionTime()); + scheduleTaskResponseDTO.setKeywordCount(scheduleResponseVO.getKeywordCount()); + scheduleTaskResponseDTO.setContentCount(scheduleResponseVO.getContentCount()); + scheduleTaskResponseDTO.setAiModel(scheduleResponseVO.getAiModel()); + scheduleTaskResponseDTO.setLastExecution(scheduleResponseVO.getLastExecution()); + scheduleTaskResponseDTO.setNextExecution(scheduleResponseVO.getNextExecution()); + scheduleTaskResponseDTO.setPipelineConfig(scheduleResponseVO.getPipelineConfig()); + scheduleTaskResponseDTO.setExecutionTime(scheduleResponseVO.getExecutionTime()); + scheduleTaskResponseDTO.setTaskName(scheduleResponseVO.getTaskName()); + return scheduleTaskResponseDTO; + } + public PageResponseDTO getScheduleInfoList( ScheduleInfoResquestDTO scheduleInfoResquestDTO) { @@ -208,7 +241,8 @@ public void registerDynamicSchedule(ScheduleResponseVO scheduleResponseVO) { Runnable task = () -> { try { - pipelineService.executionPipline(); + PipeStatusExcIdReqDTO reqDTO = new PipeStatusExcIdReqDTO(); + pipelineService.executionPipline(reqDTO); log.info("스케줄 실행 완료: {}", scheduleResponseVO.getTaskName()); } catch (Exception e) { log.error("스케줄 실행 실패: {}", e.getMessage()); diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/AIContentExecutor.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/AIContentExecutor.java index 0edfecfa..7132acab 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/AIContentExecutor.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/AIContentExecutor.java @@ -1,10 +1,14 @@ package com.softlabs.aicontents.domain.scheduler.service.executor; +import com.softlabs.aicontents.domain.orchestration.mapper.LogMapper; import com.softlabs.aicontents.domain.orchestration.mapper.PipelineMapper; import com.softlabs.aicontents.domain.orchestration.vo.pipelineObject.AIContentsResult; import com.softlabs.aicontents.domain.orchestration.vo.pipelineObject.ProductCrawlingResult; // import com.softlabs.aicontents.domain.testMapper.AIContentMapper; +import com.softlabs.aicontents.domain.scheduler.dto.StatusApiResponseDTO; +import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.Content; import com.softlabs.aicontents.domain.testDomainService.AIContentService; +import java.util.Arrays; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -16,13 +20,13 @@ public class AIContentExecutor { @Autowired private AIContentService aiContentService; - - // todo: 실제 LLM생성 클래스로 변경 - @Autowired private PipelineMapper pipelineMapper; + @Autowired private LogMapper logMapper; public AIContentsResult aIContentsResultExecute( - int executionId, ProductCrawlingResult productCrawlingResult) { + int executionId, + ProductCrawlingResult productCrawlingResult, + StatusApiResponseDTO statusApiResponseDTO) { // 1. 메서드 실행 System.out.println("LLM 생성 메서드 실행 - aiContentService(productCrawlingResult)"); @@ -36,11 +40,15 @@ public AIContentsResult aIContentsResultExecute( // 3. null 체크 if (aiContentsResult == null) { System.out.println("NullPointerException 감지"); + logMapper.insertStep_03Faild(executionId); aiContentsResult = new AIContentsResult(); aiContentsResult.setSuccess(false); aiContentsResult.setExecutionId(executionId); } + Content content = new Content(); + boolean success = false; + // 4. 완료 판단 if (aiContentsResult.getTitle() != null && aiContentsResult.getSummary() != null @@ -49,12 +57,52 @@ public AIContentsResult aIContentsResultExecute( && aiContentsResult.getSourceUrl() != null && "SUCCESS".equals(aiContentsResult.getAIContentStatusCode())) { + logMapper.insertStep_03Success(executionId); + success = true; aiContentsResult.setSuccess(true); + + content.setTitle(aiContentsResult.getTitle()); + content.setSummary(aiContentsResult.getSummary()); + content.setContent(aiContentsResult.getContent()); + content.setTags(Arrays.asList(aiContentsResult.getHashtags().split(","))); + } else { + logMapper.insertStep_03Faild(executionId); + success = false; aiContentsResult.setSuccess(false); + + content.setTitle(aiContentsResult.getTitle()); + content.setSummary(aiContentsResult.getSummary()); + content.setContent(aiContentsResult.getContent()); + if (aiContentsResult.getHashtags() != null) { + content.setTags(Arrays.asList(aiContentsResult.getHashtags().split(","))); + } + } + + if (success) { + statusApiResponseDTO + .getProgress() + .getContentGeneration() + .setStatus(aiContentsResult.getAIContentStatusCode()); + statusApiResponseDTO + .getProgress() + .getContentGeneration() + .setProgress(aiContentsResult.getProgress()); + statusApiResponseDTO.getStage().setContent(content); } + System.out.println("\n\nstatusApiResponseDTO =" + statusApiResponseDTO + "\n\n"); - System.out.println("여기 탔음" + aiContentsResult); + if (!success) { + statusApiResponseDTO + .getProgress() + .getContentGeneration() + .setStatus(aiContentsResult.getAIContentStatusCode()); + statusApiResponseDTO + .getProgress() + .getContentGeneration() + .setProgress(aiContentsResult.getProgress()); + statusApiResponseDTO.getStage().setContent(content); + } return aiContentsResult; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/BlogPublishExecutor.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/BlogPublishExecutor.java index 85104cf8..c6bfa003 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/BlogPublishExecutor.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/BlogPublishExecutor.java @@ -1,9 +1,12 @@ package com.softlabs.aicontents.domain.scheduler.service.executor; +import com.softlabs.aicontents.domain.orchestration.mapper.LogMapper; import com.softlabs.aicontents.domain.orchestration.mapper.PipelineMapper; import com.softlabs.aicontents.domain.orchestration.vo.pipelineObject.AIContentsResult; import com.softlabs.aicontents.domain.orchestration.vo.pipelineObject.BlogPublishResult; // import com.softlabs.aicontents.domain.testMapper.BlogPublishMapper; +import com.softlabs.aicontents.domain.scheduler.dto.StatusApiResponseDTO; +import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.PublishingStatus; import com.softlabs.aicontents.domain.testDomainService.BlogPublishService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -15,12 +18,13 @@ @Service public class BlogPublishExecutor { @Autowired private BlogPublishService blogPublishService; - // todo: 실제 발행 클래스로 변경 - @Autowired private PipelineMapper pipelineMapper; + @Autowired private LogMapper logMapper; public BlogPublishResult blogPublishResultExecute( - int executionId, AIContentsResult aIContentsResult) { + int executionId, + AIContentsResult aIContentsResult, + StatusApiResponseDTO statusApiResponseDTO) { // 1. 메서드 실행 System.out.println("\n\n발행 메서드 실행 - blogPublishService(aIContentsResult)\n\n"); @@ -34,23 +38,64 @@ public BlogPublishResult blogPublishResultExecute( // 3. null 체크 if (blogPublishResult == null) { System.out.println("NullPointerException 감지"); + logMapper.insertStep_04Faild(executionId); blogPublishResult = new BlogPublishResult(); blogPublishResult.setSuccess(false); blogPublishResult.setExecutionId(executionId); } + PublishingStatus publishingStatus = new PublishingStatus(); + boolean success = false; + // 4. 완료 판단 if (blogPublishResult.getBlogPlatform() != null && blogPublishResult.getBlogPostId() != null && blogPublishResult.getBlogUrl() != null && "SUCCESS".equals(blogPublishResult.getPublishStatusCode())) { + logMapper.insertStep_04Success(executionId); + success = true; blogPublishResult.setSuccess(true); + + publishingStatus.setUrl(blogPublishResult.getBlogUrl()); + publishingStatus.setStatus("PUBLISHED"); + publishingStatus.setPlatform(blogPublishResult.getBlogPlatform()); + } else { + logMapper.insertStep_04Faild(executionId); + success = false; blogPublishResult.setSuccess(false); + + publishingStatus.setUrl(blogPublishResult.getBlogUrl()); + publishingStatus.setStatus("FAILED"); + publishingStatus.setPlatform(blogPublishResult.getBlogPlatform()); + } + + if (success) { + statusApiResponseDTO + .getProgress() + .getContentPublishing() + .setStatus(blogPublishResult.getPublishStatusCode()); + statusApiResponseDTO + .getProgress() + .getContentPublishing() + .setProgress(blogPublishResult.getProgress()); + statusApiResponseDTO.getStage().setPublishingStatus(publishingStatus); + } + System.out.println("\n\nstatusApiResponseDTO =" + statusApiResponseDTO + "\n\n"); + + if (!success) { + statusApiResponseDTO + .getProgress() + .getContentPublishing() + .setStatus(blogPublishResult.getPublishStatusCode()); + statusApiResponseDTO + .getProgress() + .getContentPublishing() + .setProgress(blogPublishResult.getProgress()); + statusApiResponseDTO.getStage().setPublishingStatus(publishingStatus); } - System.out.println("여기 탔음" + blogPublishResult); return blogPublishResult; } } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/KeywordExecutor.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/KeywordExecutor.java index a3d3cee0..8086070d 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/KeywordExecutor.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/KeywordExecutor.java @@ -1,9 +1,14 @@ package com.softlabs.aicontents.domain.scheduler.service.executor; +import com.softlabs.aicontents.domain.orchestration.mapper.LogMapper; import com.softlabs.aicontents.domain.orchestration.mapper.PipelineMapper; import com.softlabs.aicontents.domain.orchestration.vo.pipelineObject.KeywordResult; // import com.softlabs.aicontents.domain.testMapper.KeywordMapper; +import com.softlabs.aicontents.domain.scheduler.dto.StatusApiResponseDTO; +import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.Keyword; import com.softlabs.aicontents.domain.testDomainService.KeywordService; +import java.util.ArrayList; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -15,37 +20,79 @@ public class KeywordExecutor { @Autowired private KeywordService keywordService; - /// todo : 실제 키워드 수집 기능 서비스 - @Autowired private PipelineMapper pipelineMapper; + @Autowired private LogMapper logMapper; - public KeywordResult keywordExecute(int executionId) { + public KeywordResult keywordExecute(int executionId, StatusApiResponseDTO statusApiResponseDTO) { - // 1. 메서드 실행 - System.out.println("executionId를 받아서, 크롤링-트랜드 키워드 수집 메서드 실행 - keywordService"); + // 1. 메서드 실행 + 결과 DB저장 keywordService.collectKeywordAndSave(executionId); - System.out.println("\n\n 1단계 메서드 실행됐고, 결과를 DB에 저장했다.\n\n"); // 2. 실행 결과를 DB 조회+ 객체 저장 KeywordResult keywordResult = pipelineMapper.selectKeywordStatuscode(executionId); - keywordResult.setExecutionId(executionId); + List keywordList = new ArrayList<>(); + boolean success = false; // 3. null 체크 if (keywordResult == null) { - System.out.println("\n\n\n\n\n\nNullPointerException 감지\n\n\n\n\n\n"); + System.out.println("NullPointerException"); + logMapper.insertStep_01Faild(executionId); + success = false; + keywordResult = new KeywordResult(); - keywordResult.setSuccess(false); + keywordResult.setExecutionId(executionId); + keywordResult.setSelected(false); } // 4. 완료 판단 = keyword !=null, keyWordStatusCode =="SUCCESS" if (keywordResult.getKeyword() != null && "SUCCESS".equals(keywordResult.getKeyWordStatusCode())) { - keywordResult.setSuccess(true); + logMapper.insertStep_01Success(executionId); + success = true; + keywordResult.setSelected(true); + Keyword keyword = new Keyword(); + keyword.setKeyword(keywordResult.getKeyword()); + keyword.setSelected(keywordResult.isSelected()); + keyword.setRelevanceScore(keywordResult.getRelevanceScore()); + keywordList.add(keyword); + } else { - keywordResult.setSuccess(false); + logMapper.insertStep_01Faild(executionId); + success = false; + keywordResult.setSelected(false); + Keyword keyword = new Keyword(); + keyword.setKeyword(keywordResult.getKeyword()); + keyword.setSelected(keywordResult.isSelected()); + keyword.setRelevanceScore(keywordResult.getRelevanceScore()); + keywordList.add(keyword); } - System.out.println("여기 탔음" + keywordResult); + if (success) { + + // 최종 응답 객체에 매핑 (StatusApiResponseDTO는 progress, stage 필드 사용) + statusApiResponseDTO + .getProgress() + .getKeywordExtraction() + .setStatus(keywordResult.getKeyWordStatusCode()); + statusApiResponseDTO + .getProgress() + .getKeywordExtraction() + .setProgress(keywordResult.getProgress()); + statusApiResponseDTO.getStage().setKeywords(keywordList); + } + System.out.println("\n\nstatusApiResponseDTO =" + statusApiResponseDTO + "\n\n"); + + if (!success) { + statusApiResponseDTO + .getProgress() + .getKeywordExtraction() + .setStatus(keywordResult.getKeyWordStatusCode()); + statusApiResponseDTO + .getProgress() + .getKeywordExtraction() + .setProgress(keywordResult.getProgress()); + statusApiResponseDTO.getStage().setKeywords(keywordList); + } return keywordResult; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/ProductCrawlingExecutor.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/ProductCrawlingExecutor.java index 1b4d592a..3113a9a5 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/ProductCrawlingExecutor.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/ProductCrawlingExecutor.java @@ -1,10 +1,14 @@ package com.softlabs.aicontents.domain.scheduler.service.executor; +import com.softlabs.aicontents.domain.orchestration.mapper.LogMapper; import com.softlabs.aicontents.domain.orchestration.mapper.PipelineMapper; import com.softlabs.aicontents.domain.orchestration.vo.pipelineObject.KeywordResult; import com.softlabs.aicontents.domain.orchestration.vo.pipelineObject.ProductCrawlingResult; -// import com.softlabs.aicontents.domain.testMapper.ProductCrawlingMapper; +import com.softlabs.aicontents.domain.scheduler.dto.StatusApiResponseDTO; +import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.Product; import com.softlabs.aicontents.domain.testDomainService.ProductCrawlingService; +import java.util.ArrayList; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -16,43 +20,88 @@ public class ProductCrawlingExecutor { @Autowired private ProductCrawlingService productCrawlingService; - // todo: 실제 싸다구 정보 수집 서비스 클래스로 변경 - @Autowired private PipelineMapper pipelineMapper; + @Autowired private LogMapper logMapper; public ProductCrawlingResult productCrawlingExecute( - int executionId, KeywordResult keywordResult) { - - // 1. 메서드 실행 - System.out.println( - "\n\nkeywordResult에 기반한 크롤링-상품 정보 수집 메서드 실행 - productCrawlingService(keywordResult)"); + int executionId, KeywordResult keywordResult, StatusApiResponseDTO statusApiResponseDTO) { + // 1. 메서드 실행 + 결과 DB저장 productCrawlingService.productCrawlingExecute(executionId, keywordResult); - System.out.println("\n\n 2단계 메서드 실행됐고, 결과를 DB에 저장했다.\n\n"); // 2. 실행 결과를 DB 조회+ 객체 저장 ProductCrawlingResult productCrawlingResult = pipelineMapper.selctproductCrawlingStatuscode(executionId); + List productList = new ArrayList<>(); + boolean success = false; - // 3.null 체크 + // 3. null 체크 if (productCrawlingResult == null) { - System.out.println("NullPointerException 감지"); + System.out.println("NullPointerException"); + logMapper.insertStep_02Faild(executionId); + success = false; + productCrawlingResult = new ProductCrawlingResult(); - productCrawlingResult.setSuccess(false); productCrawlingResult.setExecutionId(executionId); + productCrawlingResult.setSuccess(false); } - // 4. 완료 판단 = - // (product_name, source_url, price)!= null && productStatusCode = "SUCCEDSS" + // 4. 완료 판단 = (product_name, source_url, price) != null && productStatusCode == "SUCCESS" if (productCrawlingResult.getProductName() != null && productCrawlingResult.getSourceUrl() != null && productCrawlingResult.getPrice() != null && "SUCCESS".equals(productCrawlingResult.getProductStatusCode())) { + logMapper.insertStep_02Success(executionId); + success = true; productCrawlingResult.setSuccess(true); + + Product product = new Product(); + product.setName(productCrawlingResult.getProductName()); + product.setUrl(productCrawlingResult.getSourceUrl()); + product.setPrice(productCrawlingResult.getPrice()); + product.setPlatform(productCrawlingResult.getPlatform()); + product.setSelected(true); + productList.add(product); + } else { + logMapper.insertStep_02Faild(executionId); + success = false; productCrawlingResult.setSuccess(false); + + Product product = new Product(); + product.setName(productCrawlingResult.getProductName()); + product.setUrl(productCrawlingResult.getSourceUrl()); + product.setPrice(productCrawlingResult.getPrice()); + product.setPlatform(productCrawlingResult.getPlatform()); + product.setSelected(false); + productList.add(product); + } + + if (success) { + // 최종 응답 객체에 매핑 + statusApiResponseDTO + .getProgress() + .getProductCrawling() + .setStatus(productCrawlingResult.getProductStatusCode()); + statusApiResponseDTO + .getProgress() + .getProductCrawling() + .setProgress(productCrawlingResult.getProgress()); + statusApiResponseDTO.getStage().setProducts(productList); + } + System.out.println("\n\nstatusApiResponseDTO =" + statusApiResponseDTO + "\n\n"); + + if (!success) { + statusApiResponseDTO + .getProgress() + .getProductCrawling() + .setStatus(productCrawlingResult.getProductStatusCode()); + statusApiResponseDTO + .getProgress() + .getProductCrawling() + .setProgress(productCrawlingResult.getProgress()); + statusApiResponseDTO.getStage().setProducts(productList); } - System.out.println("여기 탔음" + productCrawlingResult); return productCrawlingResult; } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/response/ScheduleResponseVO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/response/ScheduleResponseVO.java index dcf10152..6b8eb2a7 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/response/ScheduleResponseVO.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/response/ScheduleResponseVO.java @@ -18,4 +18,7 @@ public class ScheduleResponseVO { private String pipelineConfig; // 파이프라인 설정 JSON private String executeImmediately; private String taskName; + + // private PipeTotalData pipeData; + } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/KeywordService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/KeywordService.java index a0e71219..52939342 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/KeywordService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/KeywordService.java @@ -1,8 +1,30 @@ package com.softlabs.aicontents.domain.testDomainService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.softlabs.aicontents.domain.testDomain.TestDomainMapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.zip.GZIPInputStream; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service @@ -11,18 +33,99 @@ public class KeywordService { @Autowired private TestDomainMapper testDomainMapper; + @Value("${naver.shopping.popular.count:50}") + private int defaultPopularCount; + + @Value( + "${naver.shopping.popular.url:https://snxbest.naver.com/keyword/best?categoryId=A&sortType=KEYWORD_POPULAR&periodType=DAILY&ageType=ALL&activeRankId=1601977169&syncDate=20250922}") + private String popularUrl; + + @Value( + "${naver.shopping.popular.altUrl1:https://snxbest.naver.com/keyword/issue?categoryId=A&sortType=KEYWORD_ISSUE&periodType=WEEKLY&ageType=ALL&activeRankId=1600477074&syncDate=20250922}") + private String altPopularUrl1; + + @Value( + "${naver.shopping.popular.altUrl2:https://snxbest.naver.com/keyword/new?categoryId=A&sortType=KEYWORD_NEW&periodType=WEEKLY&ageType=ALL&activeRankId=1600401941&syncDate=20250922}") + private String altPopularUrl2; + + @Value( + "${naver.shopping.popular.altUrl3:https://snxbest.naver.com/product/best/click?categoryId=A&sortType=PRODUCT_CLICK&periodType=DAILY&ageType=ALL}") + private String altPopularUrl3; + + @Value( + "${naver.shopping.popular.altUrl4:https://snxbest.naver.com/product/best/buy?categoryId=A&sortType=PRODUCT_BUY&periodType=DAILY}") + private String altPopularUrl4; + + @Value("${naver.shopping.popular.retries:3}") + private int maxRetries; + + @Value("${naver.shopping.cookie:}") + private String cookieHeader; + + @Value("${crawler.python.enabled:false}") + private boolean pythonCrawlerEnabled; + + @Value("${crawler.python.command:python}") + private String pythonCommand; + + @Value("${crawler.python.script:fastapi/app/keyword_crawler.py}") + private String pythonScriptPath; + + @Value("${keyword.filter.productOnly:false}") + private boolean productOnlyFilter; + + @Value("${keyword.filter.categoryId:}") + private String targetCategoryId; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + // 상품 관련 키워드 패턴들 + private final Set productCategories = + Set.of( + "휴대폰", "케이스", "지도", "텀블러", "가방", "신발", "옷", "화장품", "책", "노트북", "마우스", "키보드", "이어폰", "헤드폰", + "충전기", "스마트워치", "태블릿", "카메라", "렌즈", "삼각대", "모니터", "의자", "책상", "침대", "소파", "냉장고", "세탁기", + "에어컨", "청소기", "밥솥", "전자레인지", "커피머신", "블렌더", "토스터", "샴푸", "린스", "바디워시", "치약", "칫솔", "수건", + "베개", "이불", "매트리스", "커튼", "조명", "선풍기", "히터", "가습기", "향수", "립스틱", "파운데이션", "마스카라", "아이섀도", + "블러셔", "운동화", "구두", "샌들", "부츠", "양말", "속옷", "티셔츠", "바지", "원피스", "자켓", "코트", "모자", "장갑", + "머플러", "벨트", "지갑", "시계", "반지", "목걸이", "귀걸이", "팔찌", "선글라스", "안경"); + + // 브랜드나 모델명 패턴 + private final Set brandKeywords = + Set.of( + "아이폰", "갤럭시", "삼성", "LG", "애플", "구글", "픽셀", "화웨이", "나이키", "아디다스", "뉴발란스", "컨버스", "반스", + "퓨마", "루이비통", "구찌", "프라다", "샤넬", "에르메스", "디올", "스타벅스", "투썸", "이디야", "메가커피", "빽다방"); + /** 프로토타입용 키워드 수집 서비스 executionId를 받아서 샘플 키워드를 DB에 저장 */ public void collectKeywordAndSave(int executionId) { try { log.info("키워드 수집 시작 - executionId: {}", executionId); - // 프로토타입용 샘플 키워드 (실제로는 구글 트렌드 API 등에서 수집) - String sampleKeyword = generateSampleKeyword(); + String selectedKeyword = null; + + if (pythonCrawlerEnabled) { + try { + selectedKeyword = executePythonCrawler(); + log.info("파이썬 크롤러 키워드: {}", selectedKeyword); + } catch (Exception pyEx) { + log.warn("파이썬 크롤러 실패, 자바 크롤러로 대체: {}", pyEx.toString()); + } + } + + if (selectedKeyword == null || selectedKeyword.isEmpty()) { + List popularKeywords = fetchPopularKeywordsFromNaver(defaultPopularCount); + selectedKeyword = pickRandomKeyword(popularKeywords); + + // 수집된 키워드 50개 콘솔 출력 + log.info("=== 수집된 키워드 목록 (총 {}개) ===", popularKeywords.size()); + for (int i = 0; i < popularKeywords.size(); i++) { + log.info("{}. {}", i + 1, popularKeywords.get(i)); + } + log.info("=== 키워드 목록 출력 완료 ==="); + } - // DB에 저장 - saveKeywordResult(executionId, sampleKeyword, "SUCCESS"); + saveKeywordResult(executionId, selectedKeyword, "SUCCESS"); - log.info("키워드 수집 완료 - executionId: {}, keyword: {}", executionId, sampleKeyword); + log.info("키워드 수집 완료 - executionId: {}, keyword: {}", executionId, selectedKeyword); } catch (Exception e) { log.error("키워드 수집 중 오류 발생 - executionId: {}", executionId, e); @@ -32,26 +135,833 @@ public void collectKeywordAndSave(int executionId) { } } - /** 프로토타입용 샘플 키워드 생성 */ - private String generateSampleKeyword() { - String[] sampleKeywords = { - "에어컨", "선풍기", "아이스크림", "수박", "비타민", - "운동화", "백팩", "노트북", "스마트폰", "이어폰" - }; + /** 외부 파이썬 크롤러 실행 후 JSON stdout을 파싱하여 키워드 1개를 반환 */ + private String executePythonCrawler() throws Exception { + ProcessBuilder pb = new ProcessBuilder(pythonCommand, pythonScriptPath); + pb.redirectErrorStream(true); + Process p = pb.start(); + try (java.io.BufferedReader br = + new java.io.BufferedReader( + new java.io.InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + int exit = p.waitFor(); + if (exit != 0) { + throw new IllegalStateException("python exit=" + exit); + } + String out = sb.toString(); + JsonNode node = objectMapper.readTree(out); + if (node == null || node.get("keyword") == null) { + throw new IllegalStateException("python output invalid: " + out); + } + String kw = node.get("keyword").asText("").trim(); + if (kw.isEmpty()) { + throw new IllegalStateException("python keyword empty"); + } + return kw; + } + } + + /** 네이버 쇼핑 인기검색어 수집 (최대 limit개), 실패 시 예외 발생 */ + private List fetchPopularKeywordsFromNaver(int limit) { + try { + // 1) 주 URL 시도 + 2) 보조 URL들 시도 (필요 시) + List urls = new ArrayList<>(); + urls.add(popularUrl); + if (altPopularUrl1 != null && !altPopularUrl1.isEmpty()) urls.add(altPopularUrl1); + if (altPopularUrl2 != null && !altPopularUrl2.isEmpty()) urls.add(altPopularUrl2); + if (altPopularUrl3 != null && !altPopularUrl3.isEmpty()) urls.add(altPopularUrl3); + if (altPopularUrl4 != null && !altPopularUrl4.isEmpty()) urls.add(altPopularUrl4); + + log.info("총 {}개의 크롤링 URL 시도: {}", urls.size(), urls); + + Set dedupFinal = new HashSet<>(); + List resultsFinal = new ArrayList<>(); + + HttpClient client = + HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + // 각 URL에서 전체 페이지 스캔을 완료한 후 다음 URL로 이동 + for (String url : urls) { + log.info("사이트 전체 페이지 스캔 시작: {}", url); + + List siteKeywords = + scanFullPageForKeywords(url, client, limit - resultsFinal.size()); + + // 수집된 키워드를 결과에 추가 + for (String keyword : siteKeywords) { + if (dedupFinal.add(keyword)) { + resultsFinal.add(keyword); + if (resultsFinal.size() >= limit) break; + } + } + + log.info("사이트에서 수집된 키워드 수: {} (누적: {})", siteKeywords.size(), resultsFinal.size()); + + // 목표 개수에 도달했으면 다음 사이트로 이동하지 않음 + if (resultsFinal.size() >= limit) { + log.info("목표 키워드 개수({})에 도달하여 수집 완료", limit); + break; + } + } + + // 내부 탐색: 결과가 부족하면 네이버 쇼핑 내 관련 페이지를 소규모 탐색하여 보완 + if (resultsFinal.size() < Math.max(1, limit)) { + try { + List expanded = + crawlWithinShopping(urls.get(0), Math.max(1, limit) - resultsFinal.size(), client); + for (String k : expanded) { + if (k == null || k.isEmpty()) continue; + if (dedupFinal.add(k)) { + resultsFinal.add(k); + if (resultsFinal.size() >= Math.max(1, limit)) break; + } + } + } catch (Exception exp) { + log.warn("내부 탐색 보완 실패: {}", exp.toString()); + } + } + + // 필터링 전 키워드 수 확인 + log.info("필터링 전 수집된 총 키워드 수: {}", resultsFinal.size()); + log.info("필터링 전 키워드 목록: {}", resultsFinal); + + // 상품 관련 키워드만 필터링 (옵션) + if (productOnlyFilter) { + resultsFinal = filterProductKeywords(resultsFinal); + log.info("상품 필터링 적용 후 키워드 수: {}", resultsFinal.size()); + } + + if (resultsFinal.isEmpty()) { + throw new IllegalStateException("네이버 인기검색어 파싱 결과가 비어 있습니다"); + } + + return resultsFinal; + + } catch (Exception e) { + log.error("네이버 쇼핑 인기검색어 수집 실패: {}", e.toString()); + throw new RuntimeException("실시간 크롤링 실패", e); + } + } + + /** + * 전체 페이지 스캔으로 키워드를 수집하는 메서드 (스크롤 시뮬레이션 포함) + * + * @param url 크롤링할 URL + * @param client HTTP 클라이언트 + * @param needed 필요한 키워드 개수 + * @return 수집된 키워드 리스트 + */ + private List scanFullPageForKeywords(String url, HttpClient client, int needed) { + List keywords = new ArrayList<>(); + Set dedup = new HashSet<>(); + + try { + // 1. 기본 페이지 로드 (초기 화면) + String initialContent = loadPage(url, client); + keywords.addAll(extractKeywordsFromContent(initialContent, dedup, needed)); + log.info("초기 페이지 로드 완료 - 수집 키워드: {}", keywords.size()); + + if (keywords.size() >= needed) return keywords; + + // 2. 스크롤 시뮬레이션을 위한 추가 요청 (AJAX/API 호출 시뮬레이션) + keywords.addAll(simulateScrollAndLoadMore(url, client, dedup, needed - keywords.size())); + log.info("스크롤 시뮬레이션 완료 - 총 수집 키워드: {}", keywords.size()); + + if (keywords.size() >= needed) return keywords; + + // 3. 페이지 내 링크 탐색으로 추가 키워드 수집 + keywords.addAll( + explorePageLinks(url, client, initialContent, dedup, needed - keywords.size())); + log.info("페이지 링크 탐색 완료 - 최종 수집 키워드: {}", keywords.size()); + + } catch (Exception e) { + log.warn("전체 페이지 스캔 중 오류 발생 - url: {}, error: {}", url, e.toString()); + } + + return keywords; + } + + /** 페이지를 로드하고 HTML 콘텐츠를 반환 */ + private String loadPage(String url, HttpClient client) throws Exception { + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + HttpRequest.Builder rb = + HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(20)) + .header( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") + .header( + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.8,*/*;q=0.7") + .header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7") + .header("Accept-Encoding", "gzip") + .header("Cache-Control", "no-cache") + .header("Pragma", "no-cache") + .GET(); + + if (cookieHeader != null && !cookieHeader.isEmpty()) { + rb.header("Cookie", cookieHeader); + } + + HttpRequest request = rb.build(); + HttpResponse response = + client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() >= 400) { + throw new IllegalStateException("HTTP status=" + response.statusCode()); + } + + String content = decodeBody(response); + log.debug( + "페이지 로드 완료 - url: {}, status: {}, size: {}KB", + url, + response.statusCode(), + content.length() / 1024); + + return content; + + } catch (Exception ex) { + log.warn( + "페이지 로드 실패 - url: {}, attempt: {}/{}, error: {}", + url, + attempt, + maxRetries, + ex.toString()); + if (attempt < maxRetries) { + try { + Thread.sleep(500L * attempt); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + } + } + throw new RuntimeException("페이지 로드 최종 실패: " + url); + } + + /** 스크롤을 시뮬레이션하여 동적 콘텐츠 로드 */ + private List simulateScrollAndLoadMore( + String url, HttpClient client, Set dedup, int needed) { + List keywords = new ArrayList<>(); + + try { + // 다양한 페이지네이션/스크롤 파라미터 시도 + List scrollUrls = generateScrollUrls(url); + + for (String scrollUrl : scrollUrls) { + if (keywords.size() >= needed) break; + + try { + String content = loadPage(scrollUrl, client); + List newKeywords = + extractKeywordsFromContent(content, dedup, needed - keywords.size()); + keywords.addAll(newKeywords); + log.debug("스크롤 URL에서 키워드 추출: {} → {} 개", scrollUrl, newKeywords.size()); + + // 동적 로딩 시뮬레이션을 위한 지연 + Thread.sleep(200); + + } catch (Exception ex) { + log.debug("스크롤 URL 실패: {}, error: {}", scrollUrl, ex.toString()); + } + } + + } catch (Exception e) { + log.warn("스크롤 시뮬레이션 실패: {}", e.toString()); + } + + return keywords; + } + + /** 스크롤/페이지네이션 URL 생성 */ + private List generateScrollUrls(String baseUrl) { + List urls = new ArrayList<>(); + + try { + // 페이지 번호 추가 (2~5페이지) + for (int page = 2; page <= 5; page++) { + if (baseUrl.contains("?")) { + urls.add(baseUrl + "&page=" + page); + urls.add(baseUrl + "&offset=" + ((page - 1) * 20)); + } else { + urls.add(baseUrl + "?page=" + page); + urls.add(baseUrl + "?offset=" + ((page - 1) * 20)); + } + } + + // 다양한 정렬/필터 옵션 추가 + String[] sortOptions = {"popular", "recent", "trending", "hot"}; + for (String sort : sortOptions) { + if (baseUrl.contains("?")) { + urls.add(baseUrl + "&sort=" + sort); + } else { + urls.add(baseUrl + "?sort=" + sort); + } + } + + } catch (Exception e) { + log.debug("스크롤 URL 생성 실패: {}", e.toString()); + } + + return urls.subList(0, Math.min(urls.size(), 10)); // 최대 10개로 제한 + } + + /** 페이지 내 링크를 탐색하여 추가 키워드 수집 */ + private List explorePageLinks( + String baseUrl, HttpClient client, String content, Set dedup, int needed) { + List keywords = new ArrayList<>(); + + try { + // 페이지 내 관련 링크 추출 + List relatedLinks = extractRelatedLinks(baseUrl, content); + + for (String link : relatedLinks) { + if (keywords.size() >= needed) break; + + try { + String linkContent = loadPage(link, client); + List linkKeywords = + extractKeywordsFromContent(linkContent, dedup, needed - keywords.size()); + keywords.addAll(linkKeywords); + log.debug("관련 링크에서 키워드 추출: {} → {} 개", link, linkKeywords.size()); + + Thread.sleep(100); // 요청 간 지연 + + } catch (Exception ex) { + log.debug("관련 링크 탐색 실패: {}, error: {}", link, ex.toString()); + } + } + + } catch (Exception e) { + log.warn("페이지 링크 탐색 실패: {}", e.toString()); + } + + return keywords; + } + + /** HTML 콘텐츠에서 관련 링크 추출 */ + private List extractRelatedLinks(String baseUrl, String content) { + List links = new ArrayList<>(); + + try { + // 같은 도메인의 관련 링크만 추출 + Pattern linkPattern = Pattern.compile("href=[\"'](.*?)[\"']"); + Matcher matcher = linkPattern.matcher(content); + + while (matcher.find() && links.size() < 5) { // 최대 5개 링크 + String href = matcher.group(1); + if (href == null || href.isEmpty()) continue; + + String absoluteUrl = toAbsoluteUrl(baseUrl, href); + if (absoluteUrl != null + && absoluteUrl.contains("snxbest.naver.com") + && (href.contains("keyword") || href.contains("best") || href.contains("popular"))) { + links.add(absoluteUrl); + } + } + + } catch (Exception e) { + log.debug("관련 링크 추출 실패: {}", e.toString()); + } + + return links; + } + + /** 상대 URL을 절대 URL로 변환 */ + private String toAbsoluteUrl(String base, String href) { + try { + if (href.startsWith("http")) return href; + if (href.startsWith("//")) return "https:" + href; + if (href.startsWith("/")) { + URI baseUri = URI.create(base); + return baseUri.getScheme() + "://" + baseUri.getHost() + href; + } + return null; + } catch (Exception e) { + return null; + } + } + + /** HTML 콘텐츠에서 키워드 추출 (기존 로직 활용) */ + private List extractKeywordsFromContent(String content, Set dedup, int needed) { + List keywords = new ArrayList<>(); + + try { + // 1. JSON 키워드 추출 + Pattern jsonKeywordPattern = + Pattern.compile("\"(keyword|rankKeyword)\"\\s*:\\s*\"(.*?)\"", Pattern.CASE_INSENSITIVE); + Matcher matcher = jsonKeywordPattern.matcher(content); + while (matcher.find() && keywords.size() < needed) { + String keyword = matcher.group(2).trim(); + if (isValidKeyword(keyword) && dedup.add(keyword)) { + keywords.add(keyword); + } + } + + // 2. HTML 구조에서 키워드 추출 (기존 로직 활용) + if (keywords.size() < needed) { + int idx1 = indexOfAny(content, new String[] {"> 1 <", ">1<", "> 1<", "rank", "순위", "키워드"}); + int start = (idx1 >= 0) ? Math.max(0, idx1 - 1000) : 0; + int end = Math.min(content.length(), start + 50000); // 범위를 더 넓게 + String section = content.substring(start, end); + + List patterns = + List.of( + Pattern.compile( + "(?:(?:>\\s*|\\n|\\r)([1-9][0-9]{0,2})(?:\\s*<[^>]*>){0,6}\\s*)" + + "([가-힣a-zA-Z0-9+#&()\\-\\s]{2,40})\\s*(?=<)"), + Pattern.compile(">\\s*([가-힣a-zA-Z0-9+#&()\\-\\s]{2,20})\\s*<"), + Pattern.compile("data-[^=]*=\"([가-힣a-zA-Z0-9+#&()\\-\\s]{2,20})\""), + Pattern.compile("(?:alt|title)=\"([가-힣a-zA-Z0-9+#&()\\-\\s]{2,20})\"")); + + for (Pattern pattern : patterns) { + if (keywords.size() >= needed) break; + + Matcher patternMatcher = pattern.matcher(section); + while (patternMatcher.find() && keywords.size() < needed) { + String label = patternMatcher.group(patternMatcher.groupCount()).trim(); + if (isValidKeyword(label) && dedup.add(label)) { + keywords.add(label); + } + } + } + } + + } catch (Exception e) { + log.warn("키워드 추출 실패: {}", e.toString()); + } + + return keywords; + } + + /** 키워드 유효성 검사 (기존 필터 로직 통합) */ + private boolean isValidKeyword(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) return false; + if (keyword.length() < 2) return false; + if (keyword.matches("^\\d+$")) return false; // 숫자만 + if (isUIOrTechnicalKeyword(keyword)) return false; // UI/기술 키워드 + if (keyword.contains("<") || keyword.contains(">") || keyword.contains("&")) + return false; // HTML 태그 + + return true; + } + + private String decodeBody(HttpResponse response) throws Exception { + String contentEncoding = response.headers().firstValue("Content-Encoding").orElse(""); + if (contentEncoding != null && contentEncoding.toLowerCase().contains("gzip")) { + try (GZIPInputStream gis = new GZIPInputStream(new ByteArrayInputStream(response.body())); + InputStreamReader isr = new InputStreamReader(gis, StandardCharsets.UTF_8); + BufferedReader br = new BufferedReader(isr)) { + return br.lines().collect(Collectors.joining("\n")); + } + } + return new String(response.body(), StandardCharsets.UTF_8); + } + + // 네이버 쇼핑 내 링크를 한정적으로 따라가 추가 키워드 수집 (최대 depth 2, 최대 6페이지) + private List crawlWithinShopping(String seedUrl, int needed, HttpClient client) + throws Exception { + List collected = new ArrayList<>(); + if (needed <= 0) return collected; + + Set visited = new HashSet<>(); + List queue = new ArrayList<>(); + queue.add(seedUrl); + int pagesVisited = 0; + int maxPages = 6; + int depth = 0; + + while (!queue.isEmpty() && pagesVisited < maxPages && collected.size() < needed && depth <= 2) { + String current = queue.remove(0); + if (current == null || visited.contains(current)) continue; + visited.add(current); + pagesVisited++; + + try { + HttpRequest.Builder rb = + HttpRequest.newBuilder(URI.create(current)) + .timeout(Duration.ofSeconds(12)) + .header( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + + " AppleWebKit/537.36 (KHTML, like Gecko)" + + " Chrome/122.0.0.0 Safari/537.36") + .header( + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.8,*/*;q=0.7") + .header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7") + .header("Referer", "https://snxbest.naver.com") + .header("Accept-Encoding", "gzip") + .GET(); + if (cookieHeader != null && !cookieHeader.isEmpty()) { + rb.header("Cookie", cookieHeader); + } + HttpResponse resp = + client.send(rb.build(), HttpResponse.BodyHandlers.ofByteArray()); + String body = decodeBody(resp); + + // 키워드 추출: productTitle, title-like, query 파라미터(q|query) + Set dedupLocal = new HashSet<>(); + extractInto( + body, + Pattern.compile("\"productTitle\"\\s*:\\s*\"(.*?)\""), + 1, + collected, + dedupLocal, + needed); + extractInto( + body, Pattern.compile("\"title\"\\s*:\\s*\"(.*?)\""), 1, collected, dedupLocal, needed); + + // href 내 쿼리 키워드 수집 + Matcher hrefMatcher = Pattern.compile("href=\\\"(.*?)\\\"").matcher(body); + List nextLinks = new ArrayList<>(); + while (hrefMatcher.find()) { + String href = hrefMatcher.group(1); + if (href == null) continue; + String abs = toAbsoluteShoppingUrl(seedUrl, href); + if (abs == null) continue; + if (!abs.contains("search.shopping.naver.com")) continue; + + // 쿼리 파라미터에서 query 추출 + Matcher qm = Pattern.compile("[?&](q|query)=([^&]+)").matcher(abs); + if (qm.find()) { + String qv = urlDecode(qm.group(2)); + if (qv != null && qv.length() >= 2 && collected.size() < needed) { + if (dedupLocal.add(qv)) collected.add(qv); + } + } + + // 다음 링크 후보 제한적으로 수집 + if ((href.contains("/best") + || href.contains("/all") + || href.contains("/popular") + || href.contains("/catalog")) + && nextLinks.size() < 10) { + nextLinks.add(abs); + } + if (collected.size() >= needed) break; + } + + // 다음 depth로 큐에 추가 + if (depth < 2) { + queue.addAll(nextLinks); + } + depth++; + + } catch (Exception ex) { + log.warn("내부 페이지 수집 실패 url={} cause={}", current, ex.toString()); + } + } + + return collected; + } + + private void extractInto( + String body, Pattern pattern, int groupIdx, List out, Set dedup, int needed) { + Matcher m = pattern.matcher(body); + while (m.find() && out.size() < needed) { + String s = m.group(groupIdx); + if (s == null) continue; + s = s.trim(); + if (s.isEmpty()) continue; + // 숫자만 있는 키워드 제외 (내부 탐색에서도) + if (s.matches("^\\d+$")) { + log.debug("내부탐색에서 숫자 키워드 제외: {}", s); + continue; + } + // UI/기술 키워드 제외 (내부 탐색에서도) + if (isUIOrTechnicalKeyword(s)) { + log.debug("내부탐색에서 UI/기술 키워드 제외: {}", s); + continue; + } + if (dedup.add(s)) out.add(s); + } + } + + private String toAbsoluteShoppingUrl(String base, String href) { + try { + if (href.startsWith("http")) return href; + if (href.startsWith("//")) return "https:" + href; + if (href.startsWith("/")) return "https://search.shopping.naver.com" + href; + } catch (Exception ignored) { + } + return null; + } + + private String urlDecode(String s) { + try { + return java.net.URLDecoder.decode(s, java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + return s; + } + } + + private String pickRandomKeyword(List candidates) { + if (candidates == null || candidates.isEmpty()) return ""; + Random random = new Random(); + int index = random.nextInt(candidates.size()); + return candidates.get(index); + } + + // 유틸: 본문에서 여러 패턴 중 첫 번째로 발견되는 인덱스 + private int indexOfAny(String text, String[] needles) { + int min = -1; + for (String n : needles) { + int idx = text.indexOf(n); + if (idx >= 0) { + if (min == -1 || idx < min) min = idx; + } + } + return min; + } + + /** + * 상품 관련 키워드만 필터링하는 함수 + * + * @param allKeywords 전체 키워드 리스트 + * @return 상품 관련 키워드만 포함하는 리스트 + */ + private List filterProductKeywords(List allKeywords) { + List productKeywords = new ArrayList<>(); + + for (String keyword : allKeywords) { + if (isProductRelated(keyword)) { + productKeywords.add(keyword); + log.debug("상품 키워드 인식: {}", keyword); + } else { + log.debug("상품 키워드 제외: {}", keyword); + } + } + + return productKeywords; + } + + /** + * UI/기술 키워드인지 판단하는 함수 (네이버 쇼핑 사이트 내부 키워드 제외) + * + * @param keyword 검사할 키워드 + * @return UI/기술 키워드 여부 + */ + private boolean isUIOrTechnicalKeyword(String keyword) { + String cleanKeyword = keyword.toLowerCase().trim(); + + // 1. 네이버 쇼핑 UI 키워드들 + Set uiKeywords = + Set.of( + "베스트홈", + "베스트브랜드", + "베스트키워드", + "베스트상품", + "선택됨", + "인기키워드", + "이슈키워드", + "신규키워드", + "쇼핑", + "best", + "내 또래를 위한", + "레이어 열기", + "best keyword", + "전체", + "패션의류", + "패션잡화", + "식품", + "일간", + "주간", + "월간", + "랭킹 유지", + "접기", + "원가", + "할인율", + "배송비", + "별점", + "더보기", + "상세보기", + "리뷰", + "인기", + "랭킹", + "검색", + "클릭", + "조회", + "이동", + "바로가기", + "자세히", + "상세", + "보기"); + + // 2. 기술적/프로그래밍 키워드들 + Set technicalKeywords = + Set.of( + "navi", + "shopping", + "bkeypop", + "slot", + "demo", + "filter", + "cate", + "period", + "daily", + "weekly", + "monthly", + "rank", + "pd", + "prod", + "adinfo", + "data", + "api", + "url", + "http", + "html", + "css", + "js", + "json", + "xml", + "div", + "span", + "class", + "style", + "script", + "link", + "meta", + "title", + "header", + "footer", + "nav", + "section", + "article"); + + // 3. 네이버 특화 키워드들 + Set naverSpecificKeywords = + Set.of( + "네이버", "naver", "snx", "snxbest", "쇼핑몰", "쇼핑하우", "스마트스토어", "브랜드스토어", "카테고리", "상품상세", + "상품정보", "상품후기", "구매후기", "사용후기"); + + // 키워드 매칭 검사 + for (String ui : uiKeywords) { + if (cleanKeyword.equals(ui) || cleanKeyword.contains(ui)) { + return true; + } + } + + for (String tech : technicalKeywords) { + if (cleanKeyword.equals(tech) || cleanKeyword.contains(tech)) { + return true; + } + } + + for (String naver : naverSpecificKeywords) { + if (cleanKeyword.equals(naver) || cleanKeyword.contains(naver)) { + return true; + } + } + + return false; + } + + /** + * 키워드가 상품 관련인지 판단하는 함수 + * + * @param keyword 검사할 키워드 + * @return 상품 관련 여부 + */ + private boolean isProductRelated(String keyword) { + String cleanKeyword = keyword.toLowerCase().trim(); + + // 0. 숫자만 있는 키워드 제외 + if (cleanKeyword.matches("^\\d+$")) { + log.debug("숫자만 있는 키워드 제외: {}", keyword); + return false; + } + + // 1. 명확한 비상품 키워드만 제외 (기준 완화) + Set excludeKeywords = Set.of("뉴스", "정치", "경제", "연예", "주식", "금융"); + + for (String exclude : excludeKeywords) { + if (cleanKeyword.contains(exclude)) { + log.debug("제외 키워드 매칭으로 필터링: {} (매칭된 단어: {})", keyword, exclude); + return false; + } + } + + // 1. 길이 조건 - 2글자 이상이면 기본적으로 허용 + if (cleanKeyword.length() >= 2) { + log.debug("기본 키워드로 인식: {}", keyword); + return true; + } + + log.debug("상품 관련성 없음: {}", keyword); + return false; + } + + /** 상품명 패턴을 포함하는지 검사 */ + private boolean containsProductPattern(String keyword) { + // 상품 관련 접미사/접두사 + String[] productSuffixes = {"케이스", "커버", "스탠드", "거치대", "악세서리", "용품", "세트", "키트"}; + String[] productPrefixes = {"무선", "블루투스", "스마트", "디지털", "전자", "휴대용", "미니"}; + + for (String suffix : productSuffixes) { + if (keyword.contains(suffix)) return true; + } + + for (String prefix : productPrefixes) { + if (keyword.contains(prefix)) return true; + } + + return false; + } + + /** + * 특정 카테고리 영역의 키워드만 추출 (향후 확장용) + * + * @param allKeywords 전체 키워드 리스트 + * @param categoryFilter 카테고리 필터 (예: "전자제품", "패션", "화장품") + * @return 해당 카테고리의 키워드만 포함하는 리스트 + */ + private List filterByCategory(List allKeywords, String categoryFilter) { + if (categoryFilter == null || categoryFilter.isEmpty()) { + return allKeywords; + } + + List filteredKeywords = new ArrayList<>(); + + // 카테고리별 키워드 매핑 + Map> categoryMap = + Map.of( + "전자제품", Set.of("휴대폰", "노트북", "태블릿", "이어폰", "충전기", "모니터", "키보드", "마우스"), + "패션", Set.of("옷", "신발", "가방", "시계", "모자", "벨트", "선글라스", "운동화"), + "화장품", Set.of("립스틱", "파운데이션", "마스카라", "향수", "샴푸", "린스", "바디워시"), + "생활용품", Set.of("텀블러", "베개", "이불", "수건", "조명", "청소기", "세탁기", "냉장고")); + + Set targetCategories = categoryMap.getOrDefault(categoryFilter, Set.of()); + + for (String keyword : allKeywords) { + for (String category : targetCategories) { + if (keyword.toLowerCase().contains(category.toLowerCase())) { + filteredKeywords.add(keyword); + break; + } + } + } - // 랜덤하게 키워드 선택 - int randomIndex = (int) (Math.random() * sampleKeywords.length); - return sampleKeywords[randomIndex]; + return filteredKeywords; } /** 키워드 결과를 DB에 저장 */ private void saveKeywordResult(int executionId, String keyword, String statusCode) { try { - testDomainMapper.insertKeywordData(executionId, keyword, statusCode); + String safeKeyword = (keyword == null) ? "" : keyword; + testDomainMapper.insertKeywordData(executionId, safeKeyword, statusCode); log.debug( "키워드 데이터 저장 완료 - executionId: {}, keyword: {}, status: {}", executionId, - keyword, + safeKeyword, statusCode); } catch (Exception e) { log.error("키워드 데이터 저장 실패 - executionId: {}", executionId, e); diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/user/controller/UserController.java b/springboot/src/main/java/com/softlabs/aicontents/domain/user/controller/UserController.java index e4b26d0d..c260b8cb 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/user/controller/UserController.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/user/controller/UserController.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; @@ -47,6 +48,7 @@ public UserController( } @Operation(summary = "로그인 ID 중복 확인", description = "회원가입 시 로그인 ID가 중복되는지 확인합니다.") + @SecurityRequirements({}) @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "중복 확인 성공"), @@ -67,6 +69,7 @@ public ResponseEntity> checkLoginIdDuplicate( } @Operation(summary = "이메일 중복 확인", description = "회원가입 시 이메일이 중복되는지 확인합니다.") + @SecurityRequirements({}) @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "중복 확인 성공"), @@ -88,6 +91,7 @@ public ResponseEntity> checkEmailDuplicate( } @Operation(summary = "인증코드 발송", description = "이메일로 인증코드를 발송합니다. 인증코드는 5분간 유효합니다.") + @SecurityRequirements({}) @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "인증코드 발송 성공"), @@ -111,6 +115,7 @@ public ResponseEntity> sendVerificationCode( } @Operation(summary = "인증코드 확인", description = "이메일로 발송된 인증코드를 확인합니다.") + @SecurityRequirements({}) @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "인증코드 확인 완료"), @@ -134,6 +139,7 @@ public ResponseEntity> verifyCode( } @Operation(summary = "회원가입", description = "새로운 사용자 계정을 생성합니다. 이메일 인증이 완료된 상태여야 합니다.") + @SecurityRequirements({}) @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "회원가입 성공"), diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/user/service/PasswordService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/user/service/PasswordService.java index 2bf3e929..63c06066 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/user/service/PasswordService.java +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/user/service/PasswordService.java @@ -1,15 +1,17 @@ package com.softlabs.aicontents.domain.user.service; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class PasswordService { - private final BCryptPasswordEncoder passwordEncoder; + private final PasswordEncoder passwordEncoder; - public PasswordService() { - this.passwordEncoder = new BCryptPasswordEncoder(); + @Autowired + public PasswordService(PasswordEncoder passwordEncoder) { + this.passwordEncoder = passwordEncoder; } public String hashPassword(String plainPassword) { diff --git a/springboot/src/main/resources/application-prod.properties b/springboot/src/main/resources/application-prod.properties index dd9d45ef..7caa3a65 100644 --- a/springboot/src/main/resources/application-prod.properties +++ b/springboot/src/main/resources/application-prod.properties @@ -31,4 +31,16 @@ spring.datasource.hikari.validation-query=SELECT 1 FROM DUAL # ============================= # JPA 관련 운영 최적화 (필요시) # spring.jpa.show-sql=false -# spring.jpa.properties.hibernate.format_sql=false \ No newline at end of file +# spring.jpa.properties.hibernate.format_sql=false + +# ============================= +# Redis 운영환경 설정 +# ============================= +spring.data.redis.host=${REDIS_HOST:redis} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD} +spring.data.redis.timeout=5000ms +spring.data.redis.lettuce.pool.max-active=20 +spring.data.redis.lettuce.pool.max-idle=10 +spring.data.redis.lettuce.pool.min-idle=5 +spring.data.redis.lettuce.pool.max-wait=2000ms \ No newline at end of file diff --git a/springboot/src/main/resources/application.properties b/springboot/src/main/resources/application.properties index 02d8888f..49c2cc34 100644 --- a/springboot/src/main/resources/application.properties +++ b/springboot/src/main/resources/application.properties @@ -29,6 +29,7 @@ spring.messages.encoding=UTF-8 logging.level.com.softlabs.aicontents.domain.orchestration.mapper.PipelineMapper=DEBUG logging.level.org.apache.ibatis=DEBUG + mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl # ============================= @@ -40,4 +41,19 @@ spring.mail.username=${GMAIL_USERNAME} spring.mail.password=${GMAIL_PASSWORD} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true -spring.mail.properties.mail.smtp.timeout=5000 \ No newline at end of file +spring.mail.properties.mail.smtp.timeout=5000 + +# ============================= +# JWT 토큰 설정 +# ============================= +jwt.access-token-validity-in-seconds=3600 + +# ============================= +# Redis 설정 +# ============================= +spring.data.redis.host=redis +spring.data.redis.port=6379 +spring.data.redis.timeout=2000ms +spring.data.redis.lettuce.pool.max-active=8 +spring.data.redis.lettuce.pool.max-idle=8 +spring.data.redis.lettuce.pool.min-idle=0 \ No newline at end of file diff --git a/springboot/src/main/resources/mappers/domain/Publish/AiPostMapper.xml b/springboot/src/main/resources/mappers/domain/Publish/AiPostMapper.xml index 5b1e692c..d5b40296 100644 --- a/springboot/src/main/resources/mappers/domain/Publish/AiPostMapper.xml +++ b/springboot/src/main/resources/mappers/domain/Publish/AiPostMapper.xml @@ -28,7 +28,7 @@ EVIDENCE_CSV, SCHEMA_VERSION, CREATED_AT - FROM ADMIN.AI_POST + FROM DEV_USER.AI_POST WHERE POST_ID = #{postId} diff --git a/springboot/src/main/resources/mappers/domain/Publish/PublishMapper.xml b/springboot/src/main/resources/mappers/domain/Publish/PublishMapper.xml deleted file mode 100644 index 0b1db716..00000000 --- a/springboot/src/main/resources/mappers/domain/Publish/PublishMapper.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - INSERT INTO ADMIN.BLOG_PUBLISH_LOGS ( - AI_CONTENT_ID, - BLOG_PLATFORM, - BLOG_POST_ID, - BLOG_URL, - PUBLISH_STATUS, - ERROR_MESSAGE, - RETRY_COUNT, - PUBLISHED_AT, - CREATED_AT, - UPDATED_AT - ) VALUES ( - #{aiContentId}, - #{blogPlatform}, - #{blogPostId}, - #{blogUrl}, - #{publishStatus}, - #{errorMessage, jdbcType=CLOB}, - #{attemptCount}, - #{publishedAt, jdbcType=TIMESTAMP}, - COALESCE(#{createdAt, jdbcType=TIMESTAMP}, SYSTIMESTAMP), - COALESCE(#{updatedAt, jdbcType=TIMESTAMP}, SYSTIMESTAMP) - ) - - diff --git a/springboot/src/main/resources/mappers/domain/health/HealthCheckMapper.xml b/springboot/src/main/resources/mappers/domain/health/HealthCheckMapper.xml index da331d5b..d5e370f0 100644 --- a/springboot/src/main/resources/mappers/domain/health/HealthCheckMapper.xml +++ b/springboot/src/main/resources/mappers/domain/health/HealthCheckMapper.xml @@ -38,4 +38,11 @@ from SCHEDULED_TASKS ) + + + diff --git a/springboot/src/main/resources/mappers/domain/monitoring/MonitoringStatsMapper.xml b/springboot/src/main/resources/mappers/domain/monitoring/MonitoringStatsMapper.xml index 26304832..e8840d5d 100644 --- a/springboot/src/main/resources/mappers/domain/monitoring/MonitoringStatsMapper.xml +++ b/springboot/src/main/resources/mappers/domain/monitoring/MonitoringStatsMapper.xml @@ -5,7 +5,7 @@ @@ -27,4 +27,15 @@ select count(*) from UNIFIED_LOGS where STEP_CODE='STEP_99' + + + + + + diff --git a/springboot/src/main/resources/mappers/domain/orchestration/LogMapper.xml b/springboot/src/main/resources/mappers/domain/orchestration/LogMapper.xml new file mode 100644 index 00000000..7c4edc0b --- /dev/null +++ b/springboot/src/main/resources/mappers/domain/orchestration/LogMapper.xml @@ -0,0 +1,256 @@ + + + + + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'START_WORKFLOW' + ,'STEP_00' + ,'SUCCESS' + ,'INFO' + ,'워크플로우 시작' + ,SYSTIMESTAMP + ) + + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'TREND_COLLECTION' + ,'STEP_01' + ,'SUCCESS' + ,'INFO' + ,'크롤링 성공: 키워드 저장 완료' + ,SYSTIMESTAMP + ) + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'TREND_COLLECTION' + ,'STEP_01' + ,'FAILD' + ,'ERROR' + ,'크롤링 성공: 키워드 저장 완료' + ,SYSTIMESTAMP + ) + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'CONTENT_CRAWL' + ,'STEP_02' + ,'SUCCESS' + ,'INFO' + ,'상품 정보 저장 완료' + ,SYSTIMESTAMP + ) + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'CONTENT_CRAWL' + ,'STEP_02' + ,'FAILD' + ,'ERROR' + ,'상품 정보 저장 완료' + ,SYSTIMESTAMP + ) + + + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'AI_GENERATION' + ,'STEP_03' + ,'SUCCESS' + ,'INFO' + ,'LLM으로 콘텐츠 생성 성공' + ,SYSTIMESTAMP + ) + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'AI_GENERATION' + ,'STEP_03' + ,'FAILD' + ,'ERROR' + ,'LLM으로 콘텐츠 생성 성공' + ,SYSTIMESTAMP + ) + + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'AI_GENERATION' + ,'STEP_04' + ,'SUCCESS' + ,'INFO' + ,'블로그 발행 성공: 발행 URL 저장 완료' + ,SYSTIMESTAMP + ) + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'AI_GENERATION' + ,'STEP_04' + ,'FAILD' + ,'ERROR' + ,'블로그 발행 성공: 발행 URL 저장 완료' + ,SYSTIMESTAMP + ) + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + EXECUTION_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{executionId} + ,'END_WORKFLOW' + ,'STEP_99' + ,'FAILD' + ,'ERROR' + ,'워크플로우 종료' + ,SYSTIMESTAMP + ) + + + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + TASK_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{taskId} + ,'SCHEDULER' + ,'STEP_100' + ,'SUCCESS' + ,'INFO' + ,'스케줄 등록 성공' + ,SYSTIMESTAMP + ) + + + + INSERT INTO ADMIN.UNIFIED_LOGS ( + TASK_ID, + LOG_TYPE, + STEP_CODE, + STATUS_CODE, + LOG_LEVEL, + LOG_MESSAGE, + CREATED_AT + ) VALUES ( + #{taskId} + ,'SCHEDULER' + ,'STEP_100' + ,'FAILD' + ,'ERROR' + ,'스케줄 등록 실패' + ,SYSTIMESTAMP + ) + + + + \ No newline at end of file diff --git a/springboot/src/main/resources/mappers/domain/orchestration/PipelineMapper.xml b/springboot/src/main/resources/mappers/domain/orchestration/PipelineMapper.xml index 7e74483d..71015fc1 100644 --- a/springboot/src/main/resources/mappers/domain/orchestration/PipelineMapper.xml +++ b/springboot/src/main/resources/mappers/domain/orchestration/PipelineMapper.xml @@ -7,9 +7,15 @@