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 @@