diff --git a/.github/workflows/deploy-fastapi.yml b/.github/workflows/deploy-fastapi.yml index a05caa39..4f064350 100644 --- a/.github/workflows/deploy-fastapi.yml +++ b/.github/workflows/deploy-fastapi.yml @@ -43,7 +43,8 @@ jobs: echo "IMAGE_DOWNLOAD_TIMEOUT=${{ secrets.IMAGE_DOWNLOAD_TIMEOUT }}" >> .env.prod echo "MAX_IMAGE_SIZE_MB=${{ secrets.MAX_IMAGE_SIZE_MB }}" >> .env.prod echo "MECAB_PATH=${{ secrets.MECAB_PATH }}" >> .env.prod - echo "MECABRC=${{ secrets.MECABRC }}" >> .env.prod + echo "MECABRC=${{ secrets.MECABRC }}" >> .env.prod + echo "GOOGLE_APPLICATION_CREDENTIALS=${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}" >> .env.prod - name: Set repo lowercase run: echo "REPO_LC=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV diff --git a/.github/workflows/deploy-java.yml b/.github/workflows/deploy-java.yml index 4ed5518f..b8e40a7d 100644 --- a/.github/workflows/deploy-java.yml +++ b/.github/workflows/deploy-java.yml @@ -1,9 +1,6 @@ name: Deploy on: - push: - branches: - - fix/** workflow_run: workflows: ["CI (Java)"] types: [completed] diff --git a/apps/pre-processing-service/.gitignore b/apps/pre-processing-service/.gitignore new file mode 100644 index 00000000..26d2fb2d --- /dev/null +++ b/apps/pre-processing-service/.gitignore @@ -0,0 +1,2 @@ +/blogger +/key \ No newline at end of file diff --git a/apps/pre-processing-service/app/api/endpoints/product.py b/apps/pre-processing-service/app/api/endpoints/product.py index f5a91272..8a1d8feb 100644 --- a/apps/pre-processing-service/app/api/endpoints/product.py +++ b/apps/pre-processing-service/app/api/endpoints/product.py @@ -1,5 +1,4 @@ from fastapi import APIRouter, Request, HTTPException -from app.decorators.logging import log_api_call from ...errors.CustomException import ( InvalidItemDataException, ItemNotFoundException, diff --git a/apps/pre-processing-service/app/api/endpoints/sample.py b/apps/pre-processing-service/app/api/endpoints/sample.py deleted file mode 100644 index f6d586fb..00000000 --- a/apps/pre-processing-service/app/api/endpoints/sample.py +++ /dev/null @@ -1,45 +0,0 @@ -from fastapi import APIRouter -from ...model.schemas import * -from app.utils.response import Response - -router = APIRouter() - - -@router.get("/") -async def root(): - return {"message": "sample API"} - - -@router.post("/keywords/search", summary="네이버 키워드 검색") -async def search(request: RequestNaverSearch): - return Response.ok({"test": "hello world"}) - - -@router.post("/blogs/rag/create", summary="RAG 기반 블로그 콘텐츠 생성") -async def rag_create(request: RequestBlogCreate): - return Response.ok({"test": "hello world"}) - - -@router.post("/blogs/publish", summary="블로그 콘텐츠 배포") -async def publish(request: RequestBlogPublish): - return Response.ok({"test": "hello world"}) - - -@router.post("/products/search", summary="상품 검색") -async def product_search(request: RequestSadaguSearch): - return Response.ok({"test": "hello world"}) - - -@router.post("/products/match", summary="상품 매칭") -async def product_match(request: RequestSadaguMatch): - return Response.ok({"test": "hello world"}) - - -@router.post("/products/similarity", summary="상품 유사도 분석") -async def product_similarity(request: RequestSadaguSimilarity): - return Response.ok({"test": "hello world"}) - - -@router.post("/products/crawl", summary="상품 상세 정보 크롤링") -async def product_crawl(request: RequestSadaguCrawl): - return Response.ok({"test": "hello world"}) diff --git a/apps/pre-processing-service/app/api/endpoints/test.py b/apps/pre-processing-service/app/api/endpoints/test.py index ca1a43b5..9dfc5d40 100644 --- a/apps/pre-processing-service/app/api/endpoints/test.py +++ b/apps/pre-processing-service/app/api/endpoints/test.py @@ -5,14 +5,12 @@ from fastapi import APIRouter from sqlalchemy import text -from app.decorators.logging import log_api_call from ...errors.CustomException import * from fastapi import APIRouter from typing import Mapping, Any, Dict from ...model.schemas import * from ...service.blog.blog_create_service import BlogContentService from ...service.blog.naver_blog_post_service import NaverBlogPostService -from ...service.blog.tistory_blog_post_service import TistoryBlogPostService from ...service.crawl_service import CrawlService from ...service.keyword_service import keyword_search from ...service.match_service import MatchService @@ -27,7 +25,6 @@ @router.get("/hello/{name}", tags=["hello"]) -# @log_api_call async def say_hello(name: str): return {"message": f"Hello {name}"} @@ -122,8 +119,6 @@ async def processing_tester(): # tistory_service = TistoryBlogPostService() naverblogPostService = NaverBlogPostService() result = naverblogPostService.post_content( - # blog_id="wtecho331", - # blog_pw="wt505033@#", title=data.get("title"), content=data.get("content"), tags=data.get("tags"), diff --git a/apps/pre-processing-service/app/api/router.py b/apps/pre-processing-service/app/api/router.py index c1a2fcb4..d21eb2f3 100644 --- a/apps/pre-processing-service/app/api/router.py +++ b/apps/pre-processing-service/app/api/router.py @@ -1,6 +1,6 @@ # app/api/router.py from fastapi import APIRouter -from .endpoints import keywords, blog, product, test, sample +from .endpoints import keywords, blog, product, test from ..core.config import settings api_router = APIRouter() @@ -15,19 +15,9 @@ api_router.include_router(product.router, prefix="/products", tags=["product"]) # 모듈 테스터를 위한 endpoint -> 추후 삭제 예정 -api_router.include_router(test.router, prefix="/tests", tags=["Test"]) - -api_router.include_router(sample.router, prefix="/v0", tags=["Sample"]) +# api_router.include_router(test.router, prefix="/tests", tags=["Test"]) @api_router.get("/ping") async def root(): return {"message": "서버 실행중입니다."} - - -@api_router.get("/db") -def get_settings(): - """ - 환경 변수가 올바르게 로드되었는지 확인하는 엔드포인트 - """ - return {"환경": settings.env_name, "데이터베이스 URL": settings.db_url} diff --git a/apps/pre-processing-service/app/decorators/__init__.py b/apps/pre-processing-service/app/decorators/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/pre-processing-service/app/decorators/logging.py b/apps/pre-processing-service/app/decorators/logging.py deleted file mode 100644 index 23604a73..00000000 --- a/apps/pre-processing-service/app/decorators/logging.py +++ /dev/null @@ -1,85 +0,0 @@ -# app/decorators/logging.py - -from fastapi import Request -from loguru import logger -import functools -import time - - -def log_api_call(func): - """ - FastAPI API 호출에 대한 상세 정보를 로깅하는 데코레이터입니다. - IP 주소, User-Agent, URL, 메서드, 실행 시간 등을 기록합니다. - """ - - @functools.wraps(func) - async def wrapper(*args, **kwargs): - # 1. request 객체를 안전하게 가져옵니다. - # kwargs에서 'request'를 찾고, 없으면 args가 비어있지 않은 경우에만 args[0]을 시도합니다. - request: Request | None = kwargs.get("request") - if request is None and args and isinstance(args[0], Request): - request = args[0] - - # 2. 로깅에 사용할 추가 정보를 추출합니다. - client_ip: str | None = None - user_agent: str | None = None - if request: - client_ip = request.client.host - user_agent = request.headers.get("user-agent", "N/A") - - # 3. 요청 정보를 로그로 기록합니다. - log_context = {"func": func.__name__, "ip": client_ip, "user_agent": user_agent} - if request: - log_context.update( - { - "url": str(request.url), - "method": request.method, - } - ) - logger.info( - "API 호출 시작: URL='{url}' 메서드='{method}' 함수='{func}' IP='{ip}' User-Agent='{user_agent}'", - **log_context, - ) - else: - logger.info("API 호출 시작: 함수='{func}'", **log_context) - - start_time = time.time() - result = None - - try: - # 4. 원본 함수를 실행합니다. - result = await func(*args, **kwargs) - return result - except Exception as e: - # 5. 예외 발생 시 에러 로그를 기록합니다. - elapsed_time = time.time() - start_time - log_context["exception"] = e - log_context["elapsed"] = f"{elapsed_time:.4f}s" - - if request: - logger.error( - "API 호출 실패: URL='{url}' 메서드='{method}' IP='{ip}' 예외='{exception}' ({elapsed})", - **log_context, - ) - else: - logger.error( - "API 호출 실패: 함수='{func}' 예외='{exception}' ({elapsed})", - **log_context, - ) - raise # 예외를 다시 발생시켜 FastAPI가 처리하도록 합니다. - finally: - # 6. 성공적으로 완료되면 성공 로그를 기록합니다. - if result is not None: - elapsed_time = time.time() - start_time - log_context["elapsed"] = f"{elapsed_time:.4f}s" - if request: - logger.success( - "API 호출 성공: URL='{url}' 메서드='{method}' IP='{ip}' ({elapsed})", - **log_context, - ) - else: - logger.success( - "API 호출 성공: 함수='{func}' ({elapsed})", **log_context - ) - - return wrapper diff --git a/apps/pre-processing-service/app/errors/handlers.py b/apps/pre-processing-service/app/errors/handlers.py index 882a6078..c7b7339f 100644 --- a/apps/pre-processing-service/app/errors/handlers.py +++ b/apps/pre-processing-service/app/errors/handlers.py @@ -57,8 +57,8 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE """ # 변경점: ErrorBaseModel을 기본 구조로 사용하고, 추가 정보를 더함 base_error = ErrorBaseModel( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=ERROR_MESSAGES[status.HTTP_422_UNPROCESSABLE_ENTITY], + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=ERROR_MESSAGES[status.HTTP_422_UNPROCESSABLE_CONTENT], code="VALIDATION_ERROR", ) @@ -67,7 +67,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE response_content["details"] = exc.errors() return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content=response_content, ) diff --git a/apps/pre-processing-service/app/errors/messages.py b/apps/pre-processing-service/app/errors/messages.py index 80139492..ea82ec11 100644 --- a/apps/pre-processing-service/app/errors/messages.py +++ b/apps/pre-processing-service/app/errors/messages.py @@ -6,7 +6,7 @@ status.HTTP_401_UNAUTHORIZED: "인증이 필요합니다.", status.HTTP_403_FORBIDDEN: "접근 권한이 없습니다.", status.HTTP_404_NOT_FOUND: "요청하신 리소스를 찾을 수 없습니다.", - status.HTTP_422_UNPROCESSABLE_ENTITY: "입력 데이터가 유효하지 않습니다.", + status.HTTP_422_UNPROCESSABLE_CONTENT: "입력 데이터가 유효하지 않습니다.", status.HTTP_500_INTERNAL_SERVER_ERROR: "서버 내부 오류가 발생했습니다.", } diff --git a/apps/pre-processing-service/app/middleware/BackServiceLoggerDependency.py b/apps/pre-processing-service/app/middleware/BackServiceLoggerDependency.py deleted file mode 100644 index d18630f6..00000000 --- a/apps/pre-processing-service/app/middleware/BackServiceLoggerDependency.py +++ /dev/null @@ -1,124 +0,0 @@ -# import time -# from typing import Dict, Any, List, Optional -# from fastapi import Request -# from loguru import logger -# from contextvars import ContextVar -# -# trace_id_context: ContextVar[str] = ContextVar('trace_id', default="NO_TRACE_ID") -# -# -# class ServiceLoggingDependency: -# """ -# 서비스 로깅을 위한 의존성 클래스 -# :param service_type: 서비스 유형 (예: "CHUNKING", "PARSING", "EMBEDDING") -# :param track_params: 추적할 매개변수 이름 목록 -# :param response_trackers: 응답에서 추적할 필드 이름 목록 (딕셔너리) -# """ -# -# def __init__(self, service_type: str, -# track_params: List[str] = None, -# response_trackers: List[str] = None): -# self.service_type = service_type -# self.track_params = track_params or [] -# self.response_trackers = response_trackers or [] -# -# async def __call__(self, request: Request): -# """ -# 의존성 주입 시 호출되는 메서드 -# :param request: FastAPI Request 객체 -# :return: 서비스 유형과 추출된 매개변수 딕셔너리 -# """ -# trace_id = trace_id_context.get("NO_TRACE_ID") -# start_time = time.time() -# -# # 파라미터 추출 -# params = await self._extract_params(request) -# param_str = "" -# if params: -# param_strs = [f"{k}={v}" for k, v in params.items()] -# param_str = " " + " ".join(param_strs) -# -# logger.info(f"[{self.service_type}_START] trace_id={trace_id}{param_str}") -# -# # 응답 시 사용할 정보를 request.state에 저장 -# request.state.service_type = self.service_type -# request.state.start_time = start_time -# request.state.param_str = param_str -# request.state.response_trackers = self.response_trackers -# -# return {"service_type": self.service_type, "params": params} -# -# async def _extract_params(self, request: Request) -> Dict[str, Any]: -# """ -# 요청에서 추적 파라미터 추출 -# :param request: FastAPI Request 객체 -# :return: 추출된 매개변수 딕셔너리 -# """ -# params = {} -# -# try: -# # Query Parameters 추출 -# for key, value in request.query_params.items(): -# if key in self.track_params: -# params[key] = value -# -# # JSON Body 추출 -# try: -# json_body = await request.json() -# if json_body: -# for key, value in json_body.items(): -# if key in self.track_params: -# if isinstance(value, str) and len(value) > 50: -# params[f"{key}_length"] = len(value) -# elif isinstance(value, list): -# params[f"{key}_count"] = len(value) -# else: -# params[key] = value -# except: -# pass -# except: -# pass -# -# return params -# -# # 서비스 응답 시 성공 로그 함수 -# async def log_service_response_with_data(request: Request, response_data: Optional[Dict] = None): -# """ -# 서비스 응답 시 성공 로그 기록 -# :param request: FastAPI Request 객체 -# :param response_data: 응답 데이터 -# """ -# if hasattr(request.state, 'service_type'): -# trace_id = trace_id_context.get("NO_TRACE_ID") -# duration = time.time() - request.state.start_time -# -# # 기본 로그 문자열 -# log_parts = [f"[{request.state.service_type}_SUCCESS]", -# f"trace_id={trace_id}", -# f"execution_time={duration:.4f}s{request.state.param_str}"] -# -# # 응답 데이터에서 추적할 필드 추출 -# if response_data and hasattr(request.state, 'response_trackers'): -# response_params = [] -# for tracker in request.state.response_trackers: -# if tracker in response_data: -# value = response_data[tracker] -# if isinstance(value, dict): -# response_params.append(f"{tracker}_keys={list(value.keys())}") -# response_params.append(f"{tracker}_count={len(value)}") -# elif isinstance(value, list): -# response_params.append(f"{tracker}_count={len(value)}") -# else: -# response_params.append(f"{tracker}={value}") -# -# if response_params: -# log_parts.append(" ".join(response_params)) -# -# logger.info(" ".join(log_parts)) -# return None -# -# naver_search_dependency = ServiceLoggingDependency( -# "NAVER_CRAWLING", -# track_params=["job_id", "schedule_id", "tag", "category", "startDate", "endDate"], -# response_trackers=["keyword", "total_keyword"] -# ) diff --git a/apps/pre-processing-service/app/middleware/ServiceLoggerMiddleware.py b/apps/pre-processing-service/app/middleware/ServiceLoggerMiddleware.py index 30d3475b..e4e23185 100644 --- a/apps/pre-processing-service/app/middleware/ServiceLoggerMiddleware.py +++ b/apps/pre-processing-service/app/middleware/ServiceLoggerMiddleware.py @@ -54,10 +54,11 @@ def __init__( def _default_mappings(self) -> Dict[str, Dict]: """기본 서비스 매핑 설정""" + service_type = "TASK" return { # 네이버 키워드 검색 "/keywords/search": { - "service_type": "NAVER_CRAWLING", + "service_type": service_type, "track_params": [ "tag", "keyword", @@ -71,7 +72,7 @@ def _default_mappings(self) -> Dict[str, Dict]: }, # 블로그 RAG 콘텐츠 생성 "/blogs/rag/create": { - "service_type": "BLOG_RAG_CREATE", + "service_type": service_type, "track_params": [ "keyword", "product_info", @@ -91,7 +92,7 @@ def _default_mappings(self) -> Dict[str, Dict]: }, # 블로그 배포 "/blogs/publish": { - "service_type": "BLOG_PUBLISH", + "service_type": service_type, "track_params": [ "tag", "blog_id", @@ -115,7 +116,7 @@ def _default_mappings(self) -> Dict[str, Dict]: }, # 상품 검색 "/products/search": { - "service_type": "PRODUCT_SEARCH", + "service_type": service_type, "track_params": [ "keyword", "job_id", @@ -131,7 +132,7 @@ def _default_mappings(self) -> Dict[str, Dict]: }, # 상품 매칭 "/products/match": { - "service_type": "PRODUCT_MATCH", + "service_type": service_type, "track_params": [ "keyword", "search_results", @@ -148,7 +149,7 @@ def _default_mappings(self) -> Dict[str, Dict]: }, # 상품 유사도 분석 "/products/similarity": { - "service_type": "PRODUCT_SIMILARITY", + "service_type": service_type, "track_params": [ "keyword", "matched_products", @@ -167,7 +168,7 @@ def _default_mappings(self) -> Dict[str, Dict]: }, # 상품 크롤링 "/products/crawl": { - "service_type": "PRODUCT_CRAWL", + "service_type": service_type, "track_params": [ "tag", "product_url", @@ -184,6 +185,18 @@ def _default_mappings(self) -> Dict[str, Dict]: "status", ], }, + # 상품 이미지 번역 + "blogs/ocr/extract": { + "service_type": service_type, + "track_params": [], + "response_trackers": [], + }, + # 상품 이미지 S3업로드 + "products/s3-upload": { + "service_type": service_type, + "track_params": [], + "response_trackers": [], + }, } async def dispatch(self, request: Request, call_next): diff --git a/apps/pre-processing-service/app/service/blog/blogger_blog_post_adapter.py b/apps/pre-processing-service/app/service/blog/blogger_blog_post_adapter.py index 1a2b66f5..5d6ef5e0 100644 --- a/apps/pre-processing-service/app/service/blog/blogger_blog_post_adapter.py +++ b/apps/pre-processing-service/app/service/blog/blogger_blog_post_adapter.py @@ -50,6 +50,7 @@ def _write_content(self, title: str, content: str, tags: List[str] = None) -> No result = self.api_service.create_post_via_api(title, content, labels=tags) # 결과 로깅 print(f"포스트 생성 완료: {result.get('published_url', 'URL 없음')}") + return result.get("published_url") except Exception as e: raise BlogPostPublishException("Blogger", f"포스트 작성 실패: {str(e)}") diff --git a/apps/pre-processing-service/app/test/test_sadagu_crawl.py b/apps/pre-processing-service/app/test/test_sadagu_crawl.py index 72e4f0df..cd879a7d 100644 --- a/apps/pre-processing-service/app/test/test_sadagu_crawl.py +++ b/apps/pre-processing-service/app/test/test_sadagu_crawl.py @@ -7,10 +7,9 @@ def test_crawl_success(): body = { - "tag": "detail", - "product_url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=886788894790", - "use_selenium": False, - "include_images": False, + "product_urls": [ + "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=886788894790", + ], } response = client.post("/products/crawl", json=body) @@ -20,8 +19,7 @@ def test_crawl_success(): data = response.json() assert data["success"] == True assert data["status"] == "OK" - assert data["data"]["product_url"] == body["product_url"] - assert "product_detail" in data["data"] + assert isinstance(data["data"]["crawled_products"], list) # def test_crawl_invalid_url(): diff --git a/apps/pre-processing-service/app/test/test_similarity_service.py b/apps/pre-processing-service/app/test/test_similarity_service.py index 6efbcdc1..250fd55b 100644 --- a/apps/pre-processing-service/app/test/test_similarity_service.py +++ b/apps/pre-processing-service/app/test/test_similarity_service.py @@ -9,124 +9,132 @@ def test_similarity_with_matched_products(): """매칭된 상품들 중에서 유사도 분석""" matched_products = [ { - "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", - "title": "925 실버 반지 여성용", - "match_info": { - "match_type": "exact", - "match_score": 1.0, - "match_reason": "완전 매칭", - }, + "product_id": 201, + "title": "15인치 노트북 백팩", + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=22334455", }, { - "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=456", - "title": "반지 세트 커플링", - "match_info": { - "match_type": "morphological", - "match_score": 0.8, - "match_reason": "형태소 매칭", - }, + "product_id": 202, + "title": "노트북 파우치 13인치", + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=66778899", }, - ] - - body = { - "keyword": "반지", - "matched_products": matched_products, - } - - response = client.post("/products/similarity", json=body) - print(f"Similarity Response: {response.json()}") - - assert response.status_code == 200 - data = response.json() - assert data["success"] == True - assert data["status"] == "OK" - assert data["data"]["keyword"] == body["keyword"] - - if data["data"]["selected_product"]: - assert "similarity_info" in data["data"]["selected_product"] - assert "similarity_score" in data["data"]["selected_product"]["similarity_info"] - assert data["data"]["reason"] is not None - - -def test_similarity_fallback_to_search_results(): - """매칭 실패시 전체 검색 결과에서 유사도 분석""" - search_results = [ { - "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", - "title": "실버 링 악세서리", + "product_id": 101, + "title": "Magsafe 자기 휴대폰 케이스 아이폰15", + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=886788894790", }, { - "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=456", - "title": "골드 반지 여성", + "product_id": 102, + "title": "휴대 전화 보호 케이스 갤럭시 S24", + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=1234567890", }, ] body = { "keyword": "반지", - "matched_products": [], # 매칭된 상품 없음 - "search_results": search_results, # 폴백용 - } - - response = client.post("/products/similarity", json=body) - print(f"Fallback Response: {response.json()}") - - assert response.status_code == 200 - data = response.json() - assert data["success"] == True - assert data["status"] == "OK" - - # 폴백 모드에서는 임계값을 통과한 경우에만 상품이 선택됨 - if data["data"]["selected_product"]: - assert "similarity_info" in data["data"]["selected_product"] - assert ( - data["data"]["selected_product"]["similarity_info"]["analysis_mode"] - == "fallback_similarity_only" - ) - - -def test_similarity_single_candidate(): - """후보가 1개만 있는 경우""" - single_product = [ - { - "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", - "title": "925 실버 반지 여성용", - "match_info": {"match_type": "exact", "match_score": 1.0}, - } - ] - - body = { - "keyword": "반지", - "matched_products": single_product, + "matched_products": matched_products, } response = client.post("/products/similarity", json=body) - print(f"Single candidate response: {response.json()}") + print(f"Similarity Response: {response.json()}") assert response.status_code == 200 data = response.json() assert data["success"] == True assert data["status"] == "OK" - assert data["data"]["selected_product"] is not None - assert ( - data["data"]["selected_product"]["similarity_info"]["analysis_type"] - == "single_candidate" - ) - - -def test_similarity_no_candidates(): - """후보가 없는 경우""" - body = { - "keyword": "반지", - "matched_products": [], - "search_results": [], - } + assert data["data"]["keyword"] == body["keyword"] + products = data["data"]["top_products"] + if products: + for product in products: + assert "product_id" in product + assert "title" in product + assert "url" in product + assert "rank" in product + assert "similarity_score" in product["similarity_info"] + # assert "analysis_type" in product["similarity_info"] + # assert "analysis_mode" in product["similarity_info"] + assert data["data"]["reason"] is not None - response = client.post("/products/similarity", json=body) - print(f"No candidates response: {response.json()}") - assert response.status_code == 200 - data = response.json() - assert data["success"] == True - assert data["status"] == "OK" - assert data["data"]["selected_product"] is None - assert "검색 결과가 모두 없음" in data["data"]["reason"] +# def test_similarity_fallback_to_search_results(): +# """매칭 실패시 전체 검색 결과에서 유사도 분석""" +# search_results = [ +# { +# "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", +# "title": "실버 링 악세서리", +# }, +# { +# "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=456", +# "title": "골드 반지 여성", +# }, +# ] +# +# body = { +# "keyword": "반지", +# "matched_products": [], # 매칭된 상품 없음 +# "search_results": search_results, # 폴백용 +# } +# +# response = client.post("/products/similarity", json=body) +# print(f"Fallback Response: {response.json()}") +# +# assert response.status_code == 200 +# data = response.json() +# assert data["success"] == True +# assert data["status"] == "OK" +# +# # 폴백 모드에서는 임계값을 통과한 경우에만 상품이 선택됨 +# if data["data"]["top_products"]: +# assert "similarity_info" in data["data"]["top_products"] +# assert ( +# data["data"]["top_products"]["similarity_info"]["analysis_mode"] +# == "fallback_similarity_only" +# ) +# +# +# def test_similarity_single_candidate(): +# """후보가 1개만 있는 경우""" +# single_product = [ +# { +# "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", +# "title": "925 실버 반지 여성용", +# "match_info": {"match_type": "exact", "match_score": 1.0}, +# } +# ] +# +# body = { +# "keyword": "반지", +# "matched_products": single_product, +# } +# +# response = client.post("/products/similarity", json=body) +# print(f"Single candidate response: {response.json()}") +# +# assert response.status_code == 200 +# data = response.json() +# assert data["success"] == True +# assert data["status"] == "OK" +# assert data["data"]["top_products"] is not None +# assert ( +# data["data"]["top_products"]["similarity_info"]["analysis_type"] +# == "single_candidate" +# ) +# +# +# def test_similarity_no_candidates(): +# """후보가 없는 경우""" +# body = { +# "keyword": "반지", +# "matched_products": [], +# "search_results": [], +# } +# +# response = client.post("/products/similarity", json=body) +# print(f"No candidates response: {response.json()}") +# +# assert response.status_code == 200 +# data = response.json() +# assert data["success"] == True +# assert data["status"] == "OK" +# assert data["data"]["top_products"] is None +# assert "검색 결과가 모두 없음" in data["data"]["reason"] diff --git a/apps/pre-processing-service/poetry.lock b/apps/pre-processing-service/poetry.lock index 16e99d6b..ebfc906b 100644 --- a/apps/pre-processing-service/poetry.lock +++ b/apps/pre-processing-service/poetry.lock @@ -323,18 +323,18 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "boto3" -version = "1.40.39" +version = "1.40.40" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "boto3-1.40.39-py3-none-any.whl", hash = "sha256:e2cab5606269fe9f428981892aa592b7e0c087a038774475fa4cd6c8b5fe0a99"}, - {file = "boto3-1.40.39.tar.gz", hash = "sha256:27ca06d4d6f838b056b4935c9eceb92c8d125dbe0e895c5583bcf7130627dcd2"}, + {file = "boto3-1.40.40-py3-none-any.whl", hash = "sha256:385904de68623e1c341bdc095d94a30006843032c912adeb1e0752a343632ec6"}, + {file = "boto3-1.40.40.tar.gz", hash = "sha256:f384d3a0410d0f1a4d4ae7aa69c41d0549c6ca5a76667dc25fc97d50ad6db740"}, ] [package.dependencies] -botocore = ">=1.40.39,<1.41.0" +botocore = ">=1.40.40,<1.41.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.14.0,<0.15.0" @@ -343,14 +343,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.40.39" +version = "1.40.40" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "botocore-1.40.39-py3-none-any.whl", hash = "sha256:144e0e887a9fc198c6772f660fc006028bd1a9ce5eea3caddd848db3e421bc79"}, - {file = "botocore-1.40.39.tar.gz", hash = "sha256:c6efc55cac341811ba90c693d20097db6e2ce903451d94496bccd3f672b1709d"}, + {file = "botocore-1.40.40-py3-none-any.whl", hash = "sha256:68506142b3cde93145ef3ee0268f2444f2b68ada225a151f714092bbd3d6516a"}, + {file = "botocore-1.40.40.tar.gz", hash = "sha256:78eb121a16a6481ed0f6e1aebe53a4f23aa121f34466846c13a5ca48fa980e31"}, ] [package.dependencies] diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 3660ab02..cf0b538b 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -165,7 +165,7 @@ bootJar { exclude 'application-test-*.yml' exclude 'log4j2-test-*.yml' exclude 'application-develop.yml' - exclude 'log4j2-develop.yml' + exclude 'log4j2-*.yml' exclude 'sql/**' } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/JobController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/JobController.java new file mode 100644 index 00000000..e8f12f01 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/JobController.java @@ -0,0 +1,36 @@ +package site.icebang.domain.workflow.controller; + +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +import site.icebang.domain.workflow.dto.JobDto; +import site.icebang.domain.workflow.service.WorkflowService; + +@RestController +@RequestMapping("/v0/jobs") +@RequiredArgsConstructor +public class JobController { + + private final WorkflowService workflowService; + + @PostMapping + public ResponseEntity> createJob(@Valid @RequestBody JobDto dto) { + JobDto created = workflowService.createJob(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("success", true, "data", created)); + } + + @GetMapping("/{id}") + public ResponseEntity> getJob(@PathVariable Long id) { + JobDto job = workflowService.findJobById(id); + if (job == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false)); + } + return ResponseEntity.ok(Map.of("success", true, "data", job)); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/TaskController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/TaskController.java new file mode 100644 index 00000000..c0ba5542 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/TaskController.java @@ -0,0 +1,65 @@ +package site.icebang.domain.workflow.controller; + +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +import site.icebang.common.dto.ApiResponse; +import site.icebang.domain.workflow.dto.TaskDto; +import site.icebang.domain.workflow.model.TaskIoData; +import site.icebang.domain.workflow.service.WorkflowService; + +@RestController +@RequestMapping("/v0/tasks") +@RequiredArgsConstructor +public class TaskController { + + private final WorkflowService workflowService; + + @PostMapping + public ResponseEntity> createTask(@Valid @RequestBody TaskDto dto) { + TaskDto created = workflowService.createTask(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("success", true, "data", created)); + } + + @GetMapping("/{id}") + public ResponseEntity> getTask(@PathVariable Long id) { + TaskDto task = workflowService.findTaskById(id); + if (task == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false)); + } + return ResponseEntity.ok(Map.of("success", true, "data", task)); + } + + /** + * Task Run ID 목록으로 Task IO 데이터 조회 + * + * @param taskRunIds Task Run ID 목록 (쉼표로 구분) + * @param ioType IO 타입 필터 ("INPUT", "OUTPUT", 미지정시 모두 조회) + * @param limit 조회 제한 수 (선택사항) + * @return Task IO 데이터 목록 (created_at 기준 내림차순 정렬) + */ + @GetMapping("/io-data") + public ResponseEntity>> getTaskIoData( + @RequestParam List taskRunIds, + @RequestParam(required = false) String ioType, + @RequestParam(required = false) Integer limit) { + + try { + List ioData = + workflowService.getTaskIoDataByTaskRunIds(taskRunIds, ioType, limit); + return ResponseEntity.ok(ApiResponse.success(ioData, "Task IO 데이터 조회 성공")); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + ApiResponse.error( + "Task IO 데이터 조회 실패: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR)); + } + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java index c98ece1f..2bc388af 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java @@ -14,9 +14,11 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; import site.icebang.domain.auth.model.AuthCredential; +import site.icebang.domain.workflow.dto.RequestContext; import site.icebang.domain.workflow.dto.WorkflowCardDto; import site.icebang.domain.workflow.dto.WorkflowCreateDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; +import site.icebang.domain.workflow.service.RequestContextService; import site.icebang.domain.workflow.service.WorkflowExecutionService; import site.icebang.domain.workflow.service.WorkflowService; @@ -26,6 +28,7 @@ public class WorkflowController { private final WorkflowService workflowService; private final WorkflowExecutionService workflowExecutionService; + private final RequestContextService requestContextService; @GetMapping("") public ApiResponse> getWorkflowList( @@ -53,8 +56,10 @@ public ApiResponse createWorkflow( @PostMapping("/{workflowId}/run") public ResponseEntity runWorkflow(@PathVariable Long workflowId) { + + RequestContext context = requestContextService.extractRequestContext(); // HTTP 요청/응답 스레드를 블로킹하지 않도록 비동기 실행 - workflowExecutionService.executeWorkflow(workflowId); + workflowExecutionService.executeWorkflow(workflowId, context); return ResponseEntity.accepted().build(); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobDto.java index 035d6d17..8911a4e7 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobDto.java @@ -2,12 +2,16 @@ import java.time.Instant; +import jakarta.validation.constraints.NotBlank; import lombok.Data; @Data public class JobDto { private Long id; + + @NotBlank(message = "Job 이름은 필수입니다") private String name; + private String description; private Boolean isEnabled; private Instant createdAt; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContext.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContext.java new file mode 100644 index 00000000..1812cd32 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContext.java @@ -0,0 +1,22 @@ +package site.icebang.domain.workflow.dto; + +import lombok.Data; + +/** 요청 컨텍스트 정보를 담는 DTO 클래스 분산 추적, 클라이언트 정보 등을 포함하여 워크플로우 실행 시 필요한 컨텍스트를 관리합니다. */ +@Data +public class RequestContext { + + private final String traceId; + private final String clientIp; + private final String userAgent; + + /** + * 스케줄러 실행용 컨텍스트를 생성하는 정적 팩토리 메서드 HTTP 요청이 아닌 스케줄된 작업에서 사용됩니다. + * + * @param traceId 분산 추적 ID + * @return 스케줄러용 RequestContext 객체 (clientIp와 userAgent는 기본값 설정) + */ + public static RequestContext forScheduler(String traceId) { + return new RequestContext(traceId, "scheduler", "quartz-scheduler"); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java index 1047d141..8d323f3b 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskDto.java @@ -4,12 +4,16 @@ import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotBlank; import lombok.Data; @Data public class TaskDto { private Long id; + + @NotBlank(message = "Task 이름은 필수입니다") private String name; + private String type; private Integer executionOrder; private JsonNode settings; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java index 38c1ae38..8dea91f4 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/manager/ExecutionMdcManager.java @@ -8,11 +8,21 @@ public class ExecutionMdcManager { private static final String SOURCE_ID = "sourceId"; private static final String EXECUTION_TYPE = "executionType"; private static final String TRACE_ID = "traceID"; + private static final String CLIENT_IP = "clientIp"; + private static final String USER_AGENT = "userAgent"; - public void setWorkflowContext(Long workflowId, String traceId) { + public void setWorkflowContext( + Long workflowId, String traceId, String clientIp, String userAgent) { MDC.put(SOURCE_ID, workflowId.toString()); MDC.put(EXECUTION_TYPE, "WORKFLOW"); MDC.put(TRACE_ID, traceId); + + if (clientIp != null) { + MDC.put(CLIENT_IP, clientIp); + } + if (userAgent != null) { + MDC.put(USER_AGENT, userAgent); + } } public void setWorkflowContext(Long workflowId) { diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/JobMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/JobMapper.java index e03ac06d..0ae89235 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/JobMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/JobMapper.java @@ -12,4 +12,8 @@ public interface JobMapper { List findJobsByWorkflowId(Long workflowId); List findTasksByJobId(Long jobId); + + JobDto findJobById(Long id); + + void insertJob(JobDto job); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskIoDataMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskIoDataMapper.java new file mode 100644 index 00000000..dffeeb8e --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskIoDataMapper.java @@ -0,0 +1,21 @@ +package site.icebang.domain.workflow.mapper; + +import java.util.List; +import java.util.Optional; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import site.icebang.domain.workflow.model.TaskIoData; + +@Mapper +public interface TaskIoDataMapper { + void insert(TaskIoData taskIoData); + + Optional findOutputByTaskRunId(Long taskRunId); + + List findByTaskRunIds( + @Param("taskRunIds") List taskRunIds, + @Param("ioType") String ioType, + @Param("limit") Integer limit); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskMapper.java index 0edb7812..c15d14ad 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskMapper.java @@ -4,9 +4,14 @@ import org.apache.ibatis.annotations.Mapper; +import site.icebang.domain.workflow.dto.TaskDto; import site.icebang.domain.workflow.model.Task; @Mapper public interface TaskMapper { Optional findById(Long id); + + void insertTask(TaskDto task); + + TaskDto findTaskById(Long id); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskRunMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskRunMapper.java index e177dee6..267e931a 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskRunMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskRunMapper.java @@ -1,5 +1,7 @@ package site.icebang.domain.workflow.mapper; +import java.util.Optional; + import org.apache.ibatis.annotations.Mapper; import site.icebang.domain.workflow.model.TaskRun; @@ -9,4 +11,6 @@ public interface TaskRunMapper { void insert(TaskRun taskRun); void update(TaskRun taskRun); + + Optional findLatestSuccessRunInJob(Long jobRunId, String taskName); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java index c363f8de..41cd5fa8 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java @@ -26,7 +26,7 @@ public Job(JobDto dto) { this.id = dto.getId(); this.name = dto.getName(); this.description = dto.getDescription(); - this.isEnabled = dto.getIsEnabled(); + this.isEnabled = Boolean.TRUE.equals(dto.getIsEnabled()); this.createdAt = dto.getCreatedAt(); this.updatedAt = dto.getUpdatedAt(); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java index 04d577c1..bfa1b96f 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java @@ -36,5 +36,7 @@ public Task(TaskDto taskDto) { this.type = taskDto.getType(); this.settings = taskDto.getSettings(); this.parameters = taskDto.getParameters(); + this.createdAt = taskDto.getCreatedAt(); + this.updatedAt = taskDto.getUpdatedAt(); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/TaskIoData.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/TaskIoData.java new file mode 100644 index 00000000..16bbd7c2 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/TaskIoData.java @@ -0,0 +1,35 @@ +package site.icebang.domain.workflow.model; + +import java.time.Instant; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TaskIoData { + private Long id; + private Long taskRunId; + private String ioType; + private String name; + private String dataType; + private String dataValue; // JSON을 문자열로 저장 + private Long dataSize; + private Instant createdAt; + + public TaskIoData( + Long taskRunId, + String ioType, + String name, + String dataType, + String dataValue, + Long dataSize) { + this.taskRunId = taskRunId; + this.ioType = ioType; + this.name = name; + this.dataType = dataType; + this.dataValue = dataValue; + this.dataSize = dataSize; + this.createdAt = Instant.now(); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java index 1c3a0796..111b2e89 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/WorkflowRun.java @@ -1,9 +1,6 @@ package site.icebang.domain.workflow.model; import java.time.Instant; -import java.util.UUID; - -import org.slf4j.MDC; import lombok.Getter; import lombok.NoArgsConstructor; @@ -20,18 +17,18 @@ public class WorkflowRun { private Instant finishedAt; private Instant createdAt; - private WorkflowRun(Long workflowId) { + private WorkflowRun(Long workflowId, String traceId) { this.workflowId = workflowId; // MDC에서 현재 요청의 traceId를 가져오거나, 없으면 새로 생성 - this.traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); + this.traceId = traceId; this.status = "RUNNING"; this.startedAt = Instant.now(); this.createdAt = this.startedAt; } /** 워크플로우 실행 시작을 위한 정적 팩토리 메소드 */ - public static WorkflowRun start(Long workflowId) { - return new WorkflowRun(workflowId); + public static WorkflowRun start(Long workflowId, String traceId) { + return new WorkflowRun(workflowId, traceId); } /** 워크플로우 실행 완료 처리 */ diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogPublishBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogPublishBodyBuilder.java index ed148061..39cb9378 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogPublishBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogPublishBodyBuilder.java @@ -1,6 +1,5 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Component; @@ -11,13 +10,16 @@ import lombok.RequiredArgsConstructor; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.service.WorkflowContextService; @Component @RequiredArgsConstructor public class BlogPublishBodyBuilder implements TaskBodyBuilder { private final ObjectMapper objectMapper; + private final WorkflowContextService contextService; private static final String TASK_NAME = "블로그 발행 태스크"; private static final String RAG_SOURCE_TASK = "블로그 RAG 생성 태스크"; @@ -27,33 +29,40 @@ public boolean supports(String taskName) { } @Override - public ObjectNode build(Task task, Map workflowContext) { + public ObjectNode build(Task task, JobRun jobRun) { ObjectNode body = objectMapper.createObjectNode(); - // RAG에서 생성된 블로그 콘텐츠 가져오기 - Optional.ofNullable(workflowContext.get(RAG_SOURCE_TASK)) - .ifPresent( - ragResult -> { - JsonNode data = ragResult.path("data"); - - // 제목, 내용, 태그 설정 - Optional.ofNullable(data.path("title")) - .filter(node -> !node.isMissingNode()) - .ifPresent(titleNode -> body.set("post_title", titleNode)); - - Optional.ofNullable(data.path("content")) - .filter(node -> !node.isMissingNode()) - .ifPresent(contentNode -> body.set("post_content", contentNode)); - - Optional.ofNullable(data.path("tags")) - .filter(node -> !node.isMissingNode()) - .ifPresent(tagsNode -> body.set("post_tags", tagsNode)); - }); - String blog_name = task.getSettings().path("blog_name").asText(""); - body.put("tag", task.getSettings().get("tag").asText()); - body.put("blog_name", blog_name); - body.put("blog_id", task.getSettings().get("blog_id").asText()); - body.put("blog_pw", task.getSettings().get("blog_pw").asText()); + Optional ragResultOpt = contextService.getPreviousTaskOutput(jobRun, RAG_SOURCE_TASK); + ragResultOpt.ifPresent( + ragResult -> { + JsonNode data = ragResult.path("data"); + + // 📌 1. .path()로 노드를 가져옵니다. + JsonNode titleNode = data.path("title"); + // 📌 2. .isMissingNode()로 노드가 존재하는지 확인합니다. + if (!titleNode.isMissingNode()) { + body.set("post_title", titleNode); + } + + JsonNode contentNode = data.path("content"); + if (!contentNode.isMissingNode()) { + body.set("post_content", contentNode); + } + + JsonNode tagsNode = data.path("tags"); + if (!tagsNode.isMissingNode()) { + body.set("post_tags", tagsNode); + } + }); + + Optional settingsOpt = Optional.ofNullable(task.getSettings()); + settingsOpt.ifPresent( + settings -> { + body.put("tag", settings.path("tag").asText()); + body.put("blog_name", settings.path("blog_name").asText()); + body.put("blog_id", settings.path("blog_id").asText()); + body.put("blog_pw", settings.path("blog_pw").asText()); + }); return body; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java index 8a8008ed..33b3ca55 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/BlogRagBodyBuilder.java @@ -1,6 +1,5 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Component; @@ -11,39 +10,60 @@ import lombok.RequiredArgsConstructor; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.service.WorkflowContextService; @Component @RequiredArgsConstructor public class BlogRagBodyBuilder implements TaskBodyBuilder { private final ObjectMapper objectMapper; + private final WorkflowContextService contextService; // 📌 컨텍스트 서비스 주입 private static final String TASK_NAME = "블로그 RAG 생성 태스크"; + + // 📌 데이터 소스가 되는 이전 Task들의 이름 private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; - private static final String PRODUCT_SELECT_SOURCE_TASK = "상품 선택 태스크"; // 변경: S3 업로드 → 상품 선택 + private static final String PRODUCT_SELECT_SOURCE_TASK = "상품 선택 태스크"; + private static final String OCR_SOURCE_TASK = "이미지 OCR 태스크"; @Override public boolean supports(String taskName) { return TASK_NAME.equals(taskName); } + /** + * 여러 이전 Task들의 결과를 DB에서 조회하고 조합하여 '블로그 RAG 생성'을 위한 Request Body를 생성합니다. + * + * @param task 실행할 Task의 도메인 모델 + * @param jobRun 현재 실행 중인 Job의 기록 객체 (이전 Task 결과를 조회하는 키로 사용) + * @return 생성된 JSON Body + */ @Override - public ObjectNode build(Task task, Map workflowContext) { + public ObjectNode build(Task task, JobRun jobRun) { ObjectNode body = objectMapper.createObjectNode(); - // 키워드 정보 가져오기 - Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) + // 1. '키워드 검색 태스크' 결과에서 키워드 정보 가져오기 + Optional keywordResult = + contextService.getPreviousTaskOutput(jobRun, KEYWORD_SOURCE_TASK); + keywordResult .map(node -> node.path("data").path("keyword")) .ifPresent(keywordNode -> body.set("keyword", keywordNode)); - Optional.ofNullable(workflowContext.get(PRODUCT_SELECT_SOURCE_TASK)) + // 2. '이미지 OCR 태스크' 결과에서 번역 언어 정보 가져오기 + Optional ocrResult = contextService.getPreviousTaskOutput(jobRun, OCR_SOURCE_TASK); + ocrResult + .map(node -> node.path("data").path("translation_language")) + .filter(node -> !node.isMissingNode() && !node.asText().trim().isEmpty()) + .ifPresent(translationNode -> body.set("translation_language", translationNode)); + + // 3. '상품 선택 태스크' 결과에서 선택된 상품 정보 가져오기 + Optional productSelectResult = + contextService.getPreviousTaskOutput(jobRun, PRODUCT_SELECT_SOURCE_TASK); + productSelectResult .map(node -> node.path("data").path("selected_product")) .ifPresent(productNode -> body.set("product_info", productNode)); - // 기본 콘텐츠 설정 - body.put("content_type", "review_blog"); - body.put("target_length", 1000); - return body; } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ImageOcrBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ImageOcrBodyBuilder.java new file mode 100644 index 00000000..5d819dc6 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ImageOcrBodyBuilder.java @@ -0,0 +1,53 @@ +package site.icebang.domain.workflow.runner.fastapi.body; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; + +import site.icebang.domain.workflow.model.JobRun; +import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.service.WorkflowContextService; + +@Component +@RequiredArgsConstructor +public class ImageOcrBodyBuilder implements TaskBodyBuilder { + + private final ObjectMapper objectMapper; + private final WorkflowContextService contextService; // 📌 컨텍스트 서비스 주입 + private static final String TASK_NAME = "이미지 OCR 태스크"; + private static final String SOURCE_TASK_NAME = "키워드 검색 태스크"; + + @Override + public boolean supports(String taskName) { + return TASK_NAME.equals(taskName); + } + + /** + * 이전 Task 결과(키워드)를 DB에서 조회하여 OCR Task의 Request Body를 생성합니다. + * + * @param task 실행할 Task의 도메인 모델 + * @param jobRun 현재 실행 중인 Job의 기록 객체 + * @return 생성된 JSON Body + */ + @Override + public ObjectNode build(Task task, JobRun jobRun) { + ObjectNode body = objectMapper.createObjectNode(); + + // 📌 컨텍스트 서비스를 통해 DB에서 '키워드 검색 태스크'의 결과를 조회합니다. + Optional sourceResult = + contextService.getPreviousTaskOutput(jobRun, SOURCE_TASK_NAME); + + sourceResult + .map(result -> result.path("data").path("keyword")) + .filter(node -> !node.isMissingNode() && !node.asText().trim().isEmpty()) + .ifPresent(keywordNode -> body.set("keyword", keywordNode)); + + return body; + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/KeywordSearchBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/KeywordSearchBodyBuilder.java index 597ab0b7..00c9551e 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/KeywordSearchBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/KeywordSearchBodyBuilder.java @@ -1,15 +1,15 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; +import java.util.Optional; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; @Component @@ -24,10 +24,22 @@ public boolean supports(String taskName) { return TASK_NAME.equals(taskName); } + /** + * Task에 주입된 사용자 정의 설정(settings)을 기반으로 Request Body를 생성합니다. + * + * @param task 실행할 Task의 도메인 모델 (settings 포함) + * @param jobRun 현재 실행 중인 Job의 기록 객체 (이 빌더에서는 사용되지 않음) + * @return 생성된 JSON Body (예: {"tag": "google"}) + */ @Override - public ObjectNode build(Task task, Map workflowContext) { - // 이 Task는 항상 정적인 Body를 가집니다. - String tag = task.getSettings().get("tag").asText(); + public ObjectNode build(Task task, JobRun jobRun) { + // 📌 Task에 동적으로 주입된 settings에서 'tag' 값을 가져옵니다. + // settings가 없거나 'tag' 필드가 없으면 기본값으로 "naver"를 사용합니다. + String tag = + Optional.ofNullable(task.getSettings()) + .map(settings -> settings.path("tag").asText("naver")) + .orElse("naver"); + return objectMapper.createObjectNode().put("tag", tag); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductCrawlBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductCrawlBodyBuilder.java index 4c90e31a..7cc9c005 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductCrawlBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductCrawlBodyBuilder.java @@ -1,6 +1,5 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Component; @@ -12,13 +11,16 @@ import lombok.RequiredArgsConstructor; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.service.WorkflowContextService; @Component @RequiredArgsConstructor public class ProductCrawlBodyBuilder implements TaskBodyBuilder { private final ObjectMapper objectMapper; + private final WorkflowContextService contextService; // 📌 컨텍스트 서비스 주입 private static final String TASK_NAME = "상품 정보 크롤링 태스크"; private static final String SIMILARITY_SOURCE_TASK = "상품 유사도 분석 태스크"; @@ -27,32 +29,39 @@ public boolean supports(String taskName) { return TASK_NAME.equals(taskName); } + /** + * 이전 Task 결과(유사도 분석 결과)를 DB에서 조회하여 크롤링할 상품 URL 목록으로 구성된 Request Body를 생성합니다. + * + * @param task 실행할 Task의 도메인 모델 + * @param jobRun 현재 실행 중인 Job의 기록 객체 + * @return 생성된 JSON Body (예: {"product_urls": ["url1", "url2", ...]}) + */ @Override - public ObjectNode build(Task task, Map workflowContext) { + public ObjectNode build(Task task, JobRun jobRun) { ObjectNode body = objectMapper.createObjectNode(); - - // ArrayNode 준비 (product_urls 배열로 변경) ArrayNode productUrls = objectMapper.createArrayNode(); - // 유사도 분석에서 선택된 상품들의 URL 가져오기 (복수로 변경) - Optional.ofNullable(workflowContext.get(SIMILARITY_SOURCE_TASK)) - .ifPresent( - node -> { - JsonNode topProducts = node.path("data").path("top_products"); - if (topProducts.isArray()) { - // top_products 배열에서 각 상품의 URL 추출 - topProducts.forEach( - product -> { - JsonNode urlNode = product.path("url"); - if (!urlNode.isMissingNode() && !urlNode.asText().isEmpty()) { - productUrls.add(urlNode.asText()); - } - }); - } - }); + // 📌 컨텍스트 서비스를 통해 DB에서 '상품 유사도 분석 태스크'의 결과를 조회합니다. + Optional sourceResult = + contextService.getPreviousTaskOutput(jobRun, SIMILARITY_SOURCE_TASK); - body.set("product_urls", productUrls); + sourceResult.ifPresent( + node -> { + JsonNode topProducts = node.path("data").path("top_products"); + if (topProducts.isArray()) { + topProducts.forEach( + product -> { + JsonNode urlNode = product.path("url"); + if (!urlNode.isMissingNode() + && urlNode.isTextual() + && !urlNode.asText().isEmpty()) { + productUrls.add(urlNode.asText()); + } + }); + } + }); + body.set("product_urls", productUrls); return body; } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductMatchBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductMatchBodyBuilder.java index 65e693f3..a1b55970 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductMatchBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductMatchBodyBuilder.java @@ -1,6 +1,5 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Component; @@ -11,14 +10,19 @@ import lombok.RequiredArgsConstructor; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.service.WorkflowContextService; @Component @RequiredArgsConstructor public class ProductMatchBodyBuilder implements TaskBodyBuilder { private final ObjectMapper objectMapper; + private final WorkflowContextService contextService; // 📌 컨텍스트 서비스 주입 private static final String TASK_NAME = "상품 매칭 태스크"; + + // 📌 데이터 소스가 되는 이전 Task들의 이름 private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; private static final String SEARCH_SOURCE_TASK = "상품 검색 태스크"; @@ -27,17 +31,28 @@ public boolean supports(String taskName) { return TASK_NAME.equals(taskName); } + /** + * 여러 이전 Task들의 결과를 DB에서 조회하고 조합하여 '상품 매칭'을 위한 Request Body를 생성합니다. + * + * @param task 실행할 Task의 도메인 모델 + * @param jobRun 현재 실행 중인 Job의 기록 객체 (이전 Task 결과를 조회하는 키로 사용) + * @return 생성된 JSON Body + */ @Override - public ObjectNode build(Task task, Map workflowContext) { + public ObjectNode build(Task task, JobRun jobRun) { ObjectNode body = objectMapper.createObjectNode(); - // 키워드 정보 가져오기 - Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) + // 📌 1. 컨텍스트 서비스를 통해 DB에서 '키워드 검색 태스크'의 결과를 조회 + Optional keywordResult = + contextService.getPreviousTaskOutput(jobRun, KEYWORD_SOURCE_TASK); + keywordResult .map(node -> node.path("data").path("keyword")) .ifPresent(keywordNode -> body.set("keyword", keywordNode)); - // 상품 검색 결과 정보 가져오기 - Optional.ofNullable(workflowContext.get(SEARCH_SOURCE_TASK)) + // 📌 2. 컨텍스트 서비스를 통해 DB에서 '상품 검색 태스크'의 결과를 조회 + Optional searchResult = + contextService.getPreviousTaskOutput(jobRun, SEARCH_SOURCE_TASK); + searchResult .map(node -> node.path("data").path("search_results")) .ifPresent(resultsNode -> body.set("search_results", resultsNode)); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSearchBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSearchBodyBuilder.java index d594e4e2..9056b1fd 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSearchBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSearchBodyBuilder.java @@ -1,6 +1,6 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; +import java.util.Optional; import org.springframework.stereotype.Component; @@ -10,13 +10,17 @@ import lombok.RequiredArgsConstructor; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.service.WorkflowContextService; @Component @RequiredArgsConstructor public class ProductSearchBodyBuilder implements TaskBodyBuilder { private final ObjectMapper objectMapper; + private final WorkflowContextService contextService; + private static final String TASK_NAME = "상품 검색 태스크"; private static final String SOURCE_TASK_NAME = "키워드 검색 태스크"; @@ -26,10 +30,13 @@ public boolean supports(String taskName) { } @Override - public ObjectNode build(Task task, Map workflowContext) { - JsonNode sourceResult = workflowContext.get(SOURCE_TASK_NAME); + public ObjectNode build(Task task, JobRun jobRun) { + Optional sourceResult = + contextService.getPreviousTaskOutput(jobRun, SOURCE_TASK_NAME); + String keyword = - sourceResult != null ? sourceResult.path("data").path("keyword").asText("") : ""; + sourceResult.map(result -> result.path("data").path("keyword").asText("")).orElse(""); + return objectMapper.createObjectNode().put("keyword", keyword); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java index a8a885ed..b25cd966 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSelectBodyBuilder.java @@ -1,15 +1,13 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; - import org.springframework.stereotype.Component; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; @Component @@ -24,10 +22,18 @@ public boolean supports(String taskName) { return TASK_NAME.equals(taskName); } + /** + * '상품 선택' Task를 위한 정적인 Request Body를 생성합니다. + * + * @param task 실행할 Task의 도메인 모델 (이 빌더에서는 사용되지 않음) + * @param jobRun 현재 실행 중인 Job의 기록 객체 (이 빌더에서는 사용되지 않음) + * @return 생성된 JSON Body (예: {"selection_criteria": "image_count_priority"}) + */ @Override - public ObjectNode build(Task task, Map workflowContext) { + public ObjectNode build(Task task, JobRun jobRun) { ObjectNode body = objectMapper.createObjectNode(); + // 이 Task는 항상 고정된 선택 기준을 Body에 담아 보냅니다. body.put("selection_criteria", "image_count_priority"); return body; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSimilarityBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSimilarityBodyBuilder.java index 45f19ad8..d4857602 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSimilarityBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/ProductSimilarityBodyBuilder.java @@ -1,6 +1,5 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Component; @@ -11,14 +10,19 @@ import lombok.RequiredArgsConstructor; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.service.WorkflowContextService; @Component @RequiredArgsConstructor public class ProductSimilarityBodyBuilder implements TaskBodyBuilder { private final ObjectMapper objectMapper; + private final WorkflowContextService contextService; // 📌 컨텍스트 서비스 주입 private static final String TASK_NAME = "상품 유사도 분석 태스크"; + + // 📌 데이터 소스가 되는 이전 Task들의 이름 private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; private static final String MATCH_SOURCE_TASK = "상품 매칭 태스크"; private static final String SEARCH_SOURCE_TASK = "상품 검색 태스크"; @@ -28,22 +32,35 @@ public boolean supports(String taskName) { return TASK_NAME.equals(taskName); } + /** + * 여러 이전 Task들의 결과를 DB에서 조회하고 조합하여 '상품 유사도 분석'을 위한 Request Body를 생성합니다. + * + * @param task 실행할 Task의 도메인 모델 + * @param jobRun 현재 실행 중인 Job의 기록 객체 (이전 Task 결과를 조회하는 키로 사용) + * @return 생성된 JSON Body + */ @Override - public ObjectNode build(Task task, Map workflowContext) { + public ObjectNode build(Task task, JobRun jobRun) { ObjectNode body = objectMapper.createObjectNode(); - // 키워드 정보 가져오기 - Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) + // 1. 컨텍스트 서비스를 통해 DB에서 '키워드 검색 태스크'의 결과를 조회 + Optional keywordResult = + contextService.getPreviousTaskOutput(jobRun, KEYWORD_SOURCE_TASK); + keywordResult .map(node -> node.path("data").path("keyword")) .ifPresent(keywordNode -> body.set("keyword", keywordNode)); - // 매칭된 상품 정보 가져오기 - Optional.ofNullable(workflowContext.get(MATCH_SOURCE_TASK)) + // 2. 컨텍스트 서비스를 통해 DB에서 '상품 매칭 태스크'의 결과를 조회 + Optional matchResult = + contextService.getPreviousTaskOutput(jobRun, MATCH_SOURCE_TASK); + matchResult .map(node -> node.path("data").path("matched_products")) .ifPresent(matchedNode -> body.set("matched_products", matchedNode)); - // 상품 검색 결과 정보 가져오기 - Optional.ofNullable(workflowContext.get(SEARCH_SOURCE_TASK)) + // 3. 컨텍스트 서비스를 통해 DB에서 '상품 검색 태스크'의 결과를 조회 + Optional searchResult = + contextService.getPreviousTaskOutput(jobRun, SEARCH_SOURCE_TASK); + searchResult .map(node -> node.path("data").path("search_results")) .ifPresent(resultsNode -> body.set("search_results", resultsNode)); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java index 7548452a..ddd3c296 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java @@ -1,6 +1,5 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Component; @@ -11,14 +10,19 @@ import lombok.RequiredArgsConstructor; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.service.WorkflowContextService; @Component @RequiredArgsConstructor public class S3UploadBodyBuilder implements TaskBodyBuilder { private final ObjectMapper objectMapper; + private final WorkflowContextService contextService; // 📌 컨텍스트 서비스 주입 private static final String TASK_NAME = "S3 업로드 태스크"; + + // 📌 데이터 소스가 되는 이전 Task들의 이름 private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; private static final String CRAWL_SOURCE_TASK = "상품 정보 크롤링 태스크"; @@ -27,23 +31,34 @@ public boolean supports(String taskName) { return TASK_NAME.equals(taskName); } + /** + * 여러 이전 Task들의 결과를 DB에서 조회하고 조합하여 'S3 업로드'를 위한 Request Body를 생성합니다. + * + * @param task 실행할 Task의 도메인 모델 + * @param jobRun 현재 실행 중인 Job의 기록 객체 (이전 Task 결과를 조회하는 키로 사용) + * @return 생성된 JSON Body + */ @Override - public ObjectNode build(Task task, Map workflowContext) { + public ObjectNode build(Task task, JobRun jobRun) { ObjectNode body = objectMapper.createObjectNode(); - // 키워드 정보 가져오기 (폴더명 생성용 - 스키마 주석 참조) - Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) + // 1. 컨텍스트 서비스를 통해 DB에서 '키워드 검색 태스크'의 결과를 조회 + Optional keywordResult = + contextService.getPreviousTaskOutput(jobRun, KEYWORD_SOURCE_TASK); + keywordResult .map(node -> node.path("data").path("keyword")) .filter(node -> !node.isMissingNode() && !node.asText().trim().isEmpty()) .ifPresent(keywordNode -> body.set("keyword", keywordNode)); - // 크롤링된 상품 데이터 가져오기 - Optional.ofNullable(workflowContext.get(CRAWL_SOURCE_TASK)) + // 2. 컨텍스트 서비스를 통해 DB에서 '상품 정보 크롤링 태스크'의 결과를 조회 + Optional crawlResult = + contextService.getPreviousTaskOutput(jobRun, CRAWL_SOURCE_TASK); + crawlResult .map(node -> node.path("data").path("crawled_products")) .filter(node -> !node.isMissingNode()) .ifPresent(crawledProductsNode -> body.set("crawled_products", crawledProductsNode)); - // 기본 폴더 설정 (스키마의 기본값과 일치) + // 3. 정적 데이터 설정 body.put("base_folder", "product"); return body; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/TaskBodyBuilder.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/TaskBodyBuilder.java index 04dacef4..ffd76457 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/TaskBodyBuilder.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/TaskBodyBuilder.java @@ -1,10 +1,8 @@ package site.icebang.domain.workflow.runner.fastapi.body; -import java.util.Map; - -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; public interface TaskBodyBuilder { @@ -17,12 +15,6 @@ public interface TaskBodyBuilder { */ boolean supports(String taskName); - /** - * 실제 API 요청에 사용될 Body를 생성합니다. - * - * @param task DB에 저장된 Task의 원본 정의 - * @param workflowContext 이전 Task들의 결과가 담긴 컨텍스트 - * @return 생성된 JSON Body - */ - ObjectNode build(Task task, Map workflowContext); + // 📌 workflowContext(Map) 대신 JobRun 객체를 받도록 변경 + ObjectNode build(Task task, JobRun jobRun); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java index a3076d1f..d0bc46f1 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import site.icebang.domain.workflow.service.RequestContextService; import site.icebang.domain.workflow.service.WorkflowExecutionService; /** @@ -30,6 +31,7 @@ @RequiredArgsConstructor public class WorkflowTriggerJob extends QuartzJobBean { private final WorkflowExecutionService workflowExecutionService; + private final RequestContextService requestContextService; /** * Quartz 스케줄러에 의해 트리거가 발동될 때 호출되는 메인 실행 메소드입니다. @@ -45,6 +47,6 @@ public class WorkflowTriggerJob extends QuartzJobBean { protected void executeInternal(JobExecutionContext context) { Long workflowId = context.getJobDetail().getJobDataMap().getLong("workflowId"); log.info("Quartz가 WorkflowTriggerJob을 실행합니다. WorkflowId={}", workflowId); - workflowExecutionService.executeWorkflow(workflowId); + workflowExecutionService.executeWorkflow(workflowId, requestContextService.quartzContext()); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java new file mode 100644 index 00000000..a4c501af --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java @@ -0,0 +1,37 @@ +package site.icebang.domain.workflow.service; + +import java.util.UUID; + +import org.slf4j.MDC; +import org.springframework.stereotype.Service; + +import site.icebang.domain.workflow.dto.RequestContext; + +/** 요청 컨텍스트 정보를 추출하고 관리하는 서비스 MDC(Mapped Diagnostic Context)를 사용하여 분산 추적 정보를 처리합니다. */ +@Service +public class RequestContextService { + + /** + * HTTP 요청으로부터 컨텍스트 정보를 추출합니다. + * + * @return 추출된 요청 컨텍스트 + */ + public RequestContext extractRequestContext() { + String traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); + String clientIp = MDC.get("clientIp"); + String userAgent = MDC.get("userAgent"); + + return new RequestContext(traceId, clientIp, userAgent); + } + + /** + * Quartz 스케줄러용 컨텍스트를 생성합니다. 스케줄된 작업에서는 HTTP 요청 정보가 없으므로 traceId만 포함됩니다. + * + * @return 스케줄러용 요청 컨텍스트 + */ + public RequestContext quartzContext() { + String traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); + + return RequestContext.forScheduler(traceId); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowContextService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowContextService.java new file mode 100644 index 00000000..bbdff181 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowContextService.java @@ -0,0 +1,53 @@ +package site.icebang.domain.workflow.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.domain.workflow.mapper.TaskIoDataMapper; +import site.icebang.domain.workflow.mapper.TaskRunMapper; +import site.icebang.domain.workflow.model.JobRun; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WorkflowContextService { + + private final TaskRunMapper taskRunMapper; + private final TaskIoDataMapper taskIoDataMapper; + private final ObjectMapper objectMapper; + + /** + * 특정 Job 실행 내에서, 이전에 성공한 Task의 이름으로 결과(Output)를 조회합니다. + * + * @param jobRun 현재 실행중인 JobRun + * @param sourceTaskName 결과를 조회할 이전 Task의 이름 + * @return 조회된 결과 데이터 (JsonNode) + */ + public Optional getPreviousTaskOutput(JobRun jobRun, String sourceTaskName) { + try { + return taskRunMapper + .findLatestSuccessRunInJob(jobRun.getId(), sourceTaskName) + .flatMap(taskRun -> taskIoDataMapper.findOutputByTaskRunId(taskRun.getId())) + .map( + ioData -> { + try { + return objectMapper.readTree(ioData.getDataValue()); + } catch (Exception e) { + log.error("TaskIoData JSON 파싱 실패: TaskIoDataId={}", ioData.getId(), e); + return null; + } + }); + } catch (Exception e) { + log.error( + "이전 Task 결과 조회 중 오류 발생: JobRunId={}, TaskName={}", jobRun.getId(), sourceTaskName, e); + return Optional.empty(); + } + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java index 4d781cee..073d81ce 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java @@ -2,17 +2,13 @@ import java.math.BigInteger; import java.util.Comparator; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -20,13 +16,20 @@ import lombok.RequiredArgsConstructor; import site.icebang.domain.workflow.dto.JobDto; +import site.icebang.domain.workflow.dto.RequestContext; import site.icebang.domain.workflow.dto.TaskDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.manager.ExecutionMdcManager; -import site.icebang.domain.workflow.mapper.*; +import site.icebang.domain.workflow.mapper.JobMapper; +import site.icebang.domain.workflow.mapper.JobRunMapper; +import site.icebang.domain.workflow.mapper.TaskIoDataMapper; +import site.icebang.domain.workflow.mapper.TaskRunMapper; +import site.icebang.domain.workflow.mapper.WorkflowMapper; +import site.icebang.domain.workflow.mapper.WorkflowRunMapper; import site.icebang.domain.workflow.model.Job; import site.icebang.domain.workflow.model.JobRun; import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.model.TaskIoData; import site.icebang.domain.workflow.model.TaskRun; import site.icebang.domain.workflow.model.WorkflowRun; import site.icebang.domain.workflow.runner.TaskRunner; @@ -40,6 +43,7 @@ public class WorkflowExecutionService { private final WorkflowRunMapper workflowRunMapper; private final JobRunMapper jobRunMapper; private final TaskRunMapper taskRunMapper; + private final TaskIoDataMapper taskIoDataMapper; private final ObjectMapper objectMapper; private final List bodyBuilders; private final ExecutionMdcManager mdcManager; @@ -47,60 +51,54 @@ public class WorkflowExecutionService { private final WorkflowMapper workflowMapper; @Async("traceExecutor") - public void executeWorkflow(Long workflowId) { - WorkflowRun workflowRun = WorkflowRun.start(workflowId); + public void executeWorkflow(Long workflowId, RequestContext context) { + WorkflowRun workflowRun = WorkflowRun.start(workflowId, context.getTraceId()); workflowRunMapper.insert(workflowRun); - mdcManager.setWorkflowContext(workflowId, workflowRun.getTraceId()); + mdcManager.setWorkflowContext( + workflowId, context.getTraceId(), context.getClientIp(), context.getUserAgent()); try { workflowLogger.info("========== 워크플로우 실행 시작: WorkflowId={} ==========", workflowId); - Map workflowContext = new HashMap<>(); - WorkflowDetailCardDto settings = + // 📌 1. selectWorkflowDetailById를 호출하여 워크플로우의 모든 상세 정보를 가져옵니다. + WorkflowDetailCardDto settingsDto = workflowMapper.selectWorkflowDetailById(BigInteger.valueOf(workflowId)); - workflowLogger.info("Workflow 정보 로드 성공"); + if (settingsDto == null) { + throw new IllegalStateException("실행할 워크플로우를 찾을 수 없습니다: ID " + workflowId); + } - workflowLogger.info("Default config 로드 시도"); - JsonNode setting = objectMapper.readTree(settings.getDefaultConfig()); - workflowLogger.info("Default config 로드 성공"); + // 📌 2. 가져온 DTO 객체에서 getDefaultConfig() 메소드를 호출하여 값을 얻습니다. + String defaultConfigJson = settingsDto.getDefaultConfig(); + JsonNode setting = + (defaultConfigJson != null && !defaultConfigJson.isEmpty()) + ? objectMapper.readTree(defaultConfigJson) + : objectMapper.createObjectNode(); - workflowLogger.info("Job 목록 로드 시도"); List jobDtos = jobMapper.findJobsByWorkflowId(workflowId); - workflowLogger.info("Job 목록 로드 성공"); - - workflowLogger.info("execution_order 기준으로 정렬 시도"); jobDtos.sort( Comparator.comparing( JobDto::getExecutionOrder, Comparator.nullsLast(Comparator.naturalOrder())) .thenComparing(JobDto::getId)); - workflowLogger.info("execution_order 기준으로 성공"); - - workflowLogger.info("총 {}개의 Job을 순차적으로 실행합니다.", jobDtos.size()); boolean hasAnyJobFailed = false; - // 📌 정렬된 JobDto 리스트를 순회합니다. for (JobDto jobDto : jobDtos) { - // 📌 DTO로부터 Job 모델을 생성합니다. Job job = new Job(jobDto); - mdcManager.setJobContext(job.getId()); JobRun jobRun = JobRun.start(workflowRun.getId(), job.getId()); jobRunMapper.insert(jobRun); workflowLogger.info( "---------- Job 실행 시작: JobId={}, JobRunId={} ----------", job.getId(), jobRun.getId()); - boolean jobSucceeded = executeTasksForJob(jobRun, workflowContext, setting); + boolean jobSucceeded = executeTasksForJob(jobRun, setting); jobRun.finish(jobSucceeded ? "SUCCESS" : "FAILED"); jobRunMapper.update(jobRun); if (!jobSucceeded) { - workflowLogger.error("Job 실행 실패: JobRunId={}", jobRun.getId()); hasAnyJobFailed = true; - } else { - workflowLogger.info("---------- Job 실행 성공: JobRunId={} ----------", jobRun.getId()); } - mdcManager.setWorkflowContext(workflowId); + mdcManager.setWorkflowContext( + workflowId, context.getTraceId(), context.getClientIp(), context.getUserAgent()); } workflowRun.finish(hasAnyJobFailed ? "FAILED" : "SUCCESS"); workflowRunMapper.update(workflowRun); @@ -108,52 +106,50 @@ public void executeWorkflow(Long workflowId) { "========== 워크플로우 실행 {} : WorkflowRunId={} ==========", hasAnyJobFailed ? "실패" : "성공", workflowRun.getId()); - } catch (JsonMappingException e) { - throw new RuntimeException(e); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); + } catch (Exception e) { + workflowLogger.error("워크플로우 실행 중 심각한 오류 발생: WorkflowId={}", workflowId, e); + if (workflowRun != null) { + workflowRun.finish("FAILED"); + workflowRunMapper.update(workflowRun); + } } finally { mdcManager.clearExecutionContext(); } } - private boolean executeTasksForJob( - JobRun jobRun, Map workflowContext, JsonNode setting) { + private boolean executeTasksForJob(JobRun jobRun, JsonNode setting) { List taskDtos = jobMapper.findTasksByJobId(jobRun.getJobId()); - for (TaskDto taskDto : taskDtos) { - String taskId = taskDto.getId().toString(); - JsonNode settingForTask = setting.get(taskId); - if (settingForTask != null) { - taskDto.setSettings(settingForTask); - } - } + taskDtos.forEach( + dto -> { + JsonNode s = setting.get(String.valueOf(dto.getId())); + if (s != null) dto.setSettings(s); + }); taskDtos.sort( Comparator.comparing( TaskDto::getExecutionOrder, Comparator.nullsLast(Comparator.naturalOrder())) .thenComparing(TaskDto::getId)); - workflowLogger.info( - "Job (JobRunId={}) 내 총 {}개의 Task를 순차 실행합니다.", jobRun.getId(), taskDtos.size()); boolean hasAnyTaskFailed = false; Long s3UploadTaskRunId = null; // S3 업로드 태스크의 task_run_id 저장용 for (TaskDto taskDto : taskDtos) { + TaskRun taskRun = null; try { - TaskRun taskRun = - TaskRun.start(jobRun.getId(), taskDto.getId(), taskDto.getExecutionOrder()); + taskRun = TaskRun.start(jobRun.getId(), taskDto.getId(), taskDto.getExecutionOrder()); taskRunMapper.insert(taskRun); mdcManager.setTaskContext(taskRun.getId()); - workflowLogger.info("Task 실행 시작: TaskId={}, Name={}", taskDto.getId(), taskDto.getName()); Task task = new Task(taskDto); + workflowLogger.info("Task 실행 시작: TaskId={}, Name={}", task.getId(), task.getName()); ObjectNode requestBody = bodyBuilders.stream() .filter(builder -> builder.supports(task.getName())) .findFirst() - .map(builder -> builder.build(task, workflowContext)) + .map(builder -> builder.build(task, jobRun)) .orElse(objectMapper.createObjectNode()); + // TODO: 아래 로직 다른 곳으로 분리시키기 if ("S3 업로드 태스크".equals(task.getName())) { requestBody.put("task_run_id", taskRun.getId()); s3UploadTaskRunId = taskRun.getId(); // S3 업로드의 task_run_id 저장 @@ -167,28 +163,48 @@ private boolean executeTasksForJob( } } + saveIoData(taskRun.getId(), "INPUT", "request_body", requestBody); TaskRunner.TaskExecutionResult result = taskExecutionService.executeWithRetry(task, taskRun, requestBody); taskRun.finish(result.status(), result.message()); - taskRunMapper.update(taskRun); if (result.isFailure()) { - workflowLogger.error( - "Task 최종 실패: TaskRunId={}, Message={}", taskRun.getId(), result.message()); hasAnyTaskFailed = true; + saveIoData( + taskRun.getId(), + "OUTPUT", + "error_message", + objectMapper.valueToTree(result.message())); } else { JsonNode resultJson = objectMapper.readTree(result.message()); - workflowContext.put(task.getName(), resultJson); - workflowLogger.info("Task 실행 성공: TaskRunId={}", taskRun.getId()); + saveIoData(taskRun.getId(), "OUTPUT", "response_body", resultJson); } } catch (Exception e) { workflowLogger.error( "Task 처리 중 심각한 오류 발생: JobRunId={}, TaskName={}", jobRun.getId(), taskDto.getName(), e); hasAnyTaskFailed = true; + if (taskRun != null) { + taskRun.finish("FAILED", e.getMessage()); + saveIoData( + taskRun.getId(), "OUTPUT", "error_message", objectMapper.valueToTree(e.getMessage())); + } } finally { + if (taskRun != null) taskRunMapper.update(taskRun); mdcManager.setJobContext(jobRun.getId()); } } return !hasAnyTaskFailed; } + + private void saveIoData(Long taskRunId, String ioType, String name, JsonNode data) { + try { + String dataValue = data.toString(); + TaskIoData ioData = + new TaskIoData( + taskRunId, ioType, name, "JSON", dataValue, (long) dataValue.getBytes().length); + taskIoDataMapper.insert(ioData); + } catch (Exception e) { + workflowLogger.error("Task IO 데이터 저장 실패: TaskRunId={}, Type={}", taskRunId, ioType, e); + } + } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index 30a00c55..6362d061 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java @@ -1,6 +1,7 @@ package site.icebang.domain.workflow.service; import java.math.BigInteger; +import java.time.Instant; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -21,12 +22,12 @@ import site.icebang.domain.schedule.mapper.ScheduleMapper; import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.schedule.service.QuartzScheduleService; -import site.icebang.domain.workflow.dto.ScheduleCreateDto; -import site.icebang.domain.workflow.dto.ScheduleDto; -import site.icebang.domain.workflow.dto.WorkflowCardDto; -import site.icebang.domain.workflow.dto.WorkflowCreateDto; -import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; +import site.icebang.domain.workflow.dto.*; +import site.icebang.domain.workflow.mapper.JobMapper; +import site.icebang.domain.workflow.mapper.TaskIoDataMapper; +import site.icebang.domain.workflow.mapper.TaskMapper; import site.icebang.domain.workflow.mapper.WorkflowMapper; +import site.icebang.domain.workflow.model.TaskIoData; /** * 워크플로우의 '정의'와 관련된 비즈니스 로직을 처리하는 서비스 클래스입니다. @@ -51,6 +52,9 @@ public class WorkflowService implements PageableService { private final WorkflowMapper workflowMapper; private final ScheduleMapper scheduleMapper; private final QuartzScheduleService quartzScheduleService; + private final JobMapper jobMapper; + private final TaskMapper taskMapper; + private final TaskIoDataMapper taskIoDataMapper; /** * 워크플로우 목록을 페이징 처리하여 조회합니다. @@ -166,6 +170,104 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { } } + /** + * Job 생성 + * + * @param dto Job 생성 정보 + * @return 생성된 Job 정보 + * @throws IllegalArgumentException Job 이름이 필수인데 없거나 빈 값일 경우 + */ + @Transactional + public JobDto createJob(JobDto dto) { + // 1. 유효성 검증 + if (dto.getName() == null || dto.getName().isBlank()) { + throw new IllegalArgumentException("job name is required"); + } + + // 2. 시간 정보 설정 + Instant now = Instant.now(); + dto.setCreatedAt(now); + dto.setUpdatedAt(now); + + // 3. 생성자 정보 설정 (현재 사용자 정보가 없으므로 기본값 또는 추후 개선) + // dto.setCreatedBy(getCurrentUserId()); + // dto.setUpdatedBy(getCurrentUserId()); + + // 4. DB 저장 + jobMapper.insertJob(dto); + + // 5. 저장된 Job 반환 + return jobMapper.findJobById(dto.getId()); + } + + /** + * Job ID로 Job 조회 + * + * @param id Job ID + * @return Job 정보, 없으면 null + */ + @Transactional(readOnly = true) + public JobDto findJobById(Long id) { + return jobMapper.findJobById(id); + } + + /** + * Task 생성 + * + * @param dto Task 생성 정보 + * @return 생성된 Task 정보 + * @throws IllegalArgumentException Task 이름이 필수인데 없거나 빈 값일 경우 + */ + @Transactional + public TaskDto createTask(TaskDto dto) { + // 1. 유효성 검증 + if (dto.getName() == null || dto.getName().isBlank()) { + throw new IllegalArgumentException("task name is required"); + } + // 2. 시간 정보 설정 + Instant now = Instant.now(); + dto.setCreatedAt(now); + dto.setUpdatedAt(now); + + // 3. 생성자 정보 설정 (현재 사용자 정보가 없으므로 기본값 또는 추후 개선) + // dto.setCreatedBy(getCurrentUserId()); + // dto.setUpdatedBy(getCurrentUserId()); + + // 4. DB 저장 + taskMapper.insertTask(dto); + + // 5. 저장된 Task 반환 + return taskMapper.findTaskById(dto.getId()); + } + + /** + * Task ID로 Task 조회 + * + * @param id Task ID + * @return Task 정보, 없으면 null + */ + @Transactional(readOnly = true) + public TaskDto findTaskById(Long id) { + return taskMapper.findTaskById(id); + } + + /** + * Task Run ID 목록으로 Task IO 데이터 조회 + * + * @param taskRunIds Task Run ID 목록 + * @param ioType IO 타입 필터 ("INPUT", "OUTPUT", null이면 모두 조회) + * @param limit 조회 제한 수 (null이면 모두 조회) + * @return Task IO 데이터 목록 (created_at 기준 내림차순 정렬) + */ + @Transactional(readOnly = true) + public List getTaskIoDataByTaskRunIds( + List taskRunIds, String ioType, Integer limit) { + if (taskRunIds == null || taskRunIds.isEmpty()) { + return List.of(); + } + return taskIoDataMapper.findByTaskRunIds(taskRunIds, ioType, limit); + } + /** 기본 입력값 검증 */ private void validateBasicInput(WorkflowCreateDto dto, BigInteger createdBy) { if (dto == null) { diff --git a/apps/user-service/src/main/java/site/icebang/global/filter/logging/ClientLoggingFilter.java b/apps/user-service/src/main/java/site/icebang/global/filter/logging/ClientLoggingFilter.java new file mode 100644 index 00000000..97c87b36 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/filter/logging/ClientLoggingFilter.java @@ -0,0 +1,71 @@ +package site.icebang.global.filter.logging; + +import java.io.IOException; + +import org.slf4j.MDC; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; + +/** 클라이언트 요청 정보를 MDC에 설정하는 필터 모든 HTTP 요청에 대해 클라이언트 IP와 User-Agent 정보를 추출하여 로깅 컨텍스트에 저장합니다. */ +@Component +@Order(1) // 필터 체인에서 첫 번째로 실행되도록 우선순위 설정 +public class ClientLoggingFilter implements Filter { + + /** + * HTTP 요청을 필터링하여 클라이언트 정보를 MDC에 설정합니다. + * + * @param request 서블릿 요청 객체 + * @param response 서블릿 응답 객체 + * @param chain 필터 체인 + * @throws IOException 입출력 예외 + * @throws ServletException 서블릿 예외 + */ + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + String ip = getClientIp(httpRequest); + String userAgent = httpRequest.getHeader("User-Agent"); + + try { + MDC.put("clientIp", ip); + MDC.put("userAgent", userAgent); + + chain.doFilter(request, response); + } finally { + MDC.remove("clientIp"); + MDC.remove("userAgent"); + } + } + + /** + * 프록시 환경을 고려하여 클라이언트의 실제 IP 주소를 추출합니다. 로드 밸런서나 프록시 서버를 통해 들어오는 요청의 원본 IP를 찾습니다. + * + * @param request HTTP 요청 객체 + * @return 클라이언트의 실제 IP 주소 + */ + private String getClientIp(HttpServletRequest request) { + + String[] headers = { + "X-Forwarded-For", // 표준 프록시 헤더 + "Proxy-Client-IP", // Apache 프록시 + "WL-Proxy-Client-IP", // WebLogic 프록시 + "HTTP_X_FORWARDED_FOR", // HTTP 프록시 + "HTTP_CLIENT_IP", // HTTP 클라이언트 IP + "REMOTE_ADDR" // 원격 주소 + }; + + for (String header : headers) { + String ip = request.getHeader(header); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.split(",")[0].trim(); + } + } + + return request.getRemoteAddr(); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java index 485e7e1e..1c064368 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java @@ -118,4 +118,17 @@ public ApiResponse handleDuplicateData(DuplicateDataException ex) { log.warn(ex.getMessage(), ex); return ApiResponse.error("Duplicate: " + ex.getMessage(), HttpStatus.CONFLICT); } + + /** + * IllegalArgumentException을 400 Bad Request로 처리합니다. WorkflowService에서 던지는 검증 오류를 처리하기 위해 추가되었습니다. + * + * @param ex 발생한 {@link IllegalArgumentException} + * @return {@link ApiResponse} - 검증 실패 메시지와 {@link HttpStatus#BAD_REQUEST} + */ + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleIllegalArgument(IllegalArgumentException ex) { + log.warn("Validation failed: {}", ex.getMessage()); + return ApiResponse.error("입력값 검증 실패: " + ex.getMessage(), HttpStatus.BAD_REQUEST); + } } diff --git a/apps/user-service/src/main/resources/application-production.yml b/apps/user-service/src/main/resources/application-production.yml index c53e00bb..9463232d 100644 --- a/apps/user-service/src/main/resources/application-production.yml +++ b/apps/user-service/src/main/resources/application-production.yml @@ -44,4 +44,4 @@ mybatis: map-underscore-to-camel-case: true logging: - config: classpath:log4j2-production.yml + config: file:config/log4j2-production.yml diff --git a/apps/user-service/src/main/resources/log4j2-develop.yml b/apps/user-service/src/main/resources/log4j2-develop.yml index 21790eea..f869d73b 100644 --- a/apps/user-service/src/main/resources/log4j2-develop.yml +++ b/apps/user-service/src/main/resources/log4j2-develop.yml @@ -12,10 +12,10 @@ Configuration: value: "UTF-8" # DEBUG 환경용 콘솔 패턴 - 더 간단하고 가독성 좋게 - name: "console-layout-pattern" - value: "%highlight{[%-5level]} [%X{traceId}] [%X{spanId}] %d{HH:mm:ss} [%t] %n %logger{20} - %msg%n%n " + value: "%highlight{[%-5level]} [%X{traceId}] [%X{spanId}] [%X{clientIp}] [%X{userAgent}] %d{HH:mm:ss} [%t] %n %logger{20} - %msg%n%n " # 파일용 패턴 - Promtail이 파싱하기 쉽게 구조화 (UTC 시간 사용) - name: "file-layout-pattern" - value: "[%X{traceId}] [%X{spanId}] %d{yyyy-MM-dd HH:mm:ss.SSS}{UTC} [%t] %-5level %logger{36} - %msg%n" + value: "[%X{traceId}] [%X{spanId}] [%X{clientIp}] [%X{userAgent}] %d{yyyy-MM-dd HH:mm:ss.SSS}{UTC} [%t] %-5level %logger{36} - %msg%n" # 개발 환경용 로그 파일들 - 절대경로나 상대경로 설정 - name: "dev-log" value: ${log-path}/develop/app.log diff --git a/apps/user-service/src/main/resources/log4j2-production.yml b/apps/user-service/src/main/resources/log4j2-production.yml index 79d920fc..efeb7fa1 100644 --- a/apps/user-service/src/main/resources/log4j2-production.yml +++ b/apps/user-service/src/main/resources/log4j2-production.yml @@ -13,10 +13,10 @@ Configuration: value: "UTF-8" # 프로덕션 환경용 콘솔 패턴 - 구조화된 로그 - name: "console-layout-pattern" - value: "%highlight{[%-5level]} [%X{traceId}] [%X{spanId}] %d{HH:mm:ss}{UTC} [%t] %logger{20} - %msg%n" + value: "%highlight{[%-5level]} [%X{traceId}] [%X{spanId}] [%X{clientIp}] [%X{userAgent}] %d{HH:mm:ss}{UTC} [%t] %logger{20} - %msg%n" # 파일용 패턴 - Promtail이 파싱하기 쉽게 구조화 (UTC 시간 사용) - name: "file-layout-pattern" - value: "[%X{traceId}] [%X{spanId}] %d{yyyy-MM-dd HH:mm:ss.SSS}{UTC} [%t] %-5level %logger{36} - %msg%n" + value: "[%X{traceId}] [%X{spanId}] [%X{clientIp}] [%X{userAgent}] %d{yyyy-MM-dd HH:mm:ss.SSS}{UTC} [%t] %-5level %logger{36} - %msg%n" # 프로덕션 환경용 로그 파일들 - name: "prod-log" value: ${log-path}/production/app.log diff --git a/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml index 5b959db3..f5cd2ed0 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml @@ -23,6 +23,33 @@ + + INSERT INTO job ( + name, + description, + is_enabled, + created_at, + created_by, + updated_at, + updated_by + ) VALUES ( + #{name}, + #{description}, + #{isEnabled}, + #{createdAt}, + #{createdBy}, + #{updatedAt}, + #{updatedBy} + ) + + + + + SELECT * FROM task_io_data + WHERE task_run_id = #{taskRunId} AND io_type = 'OUTPUT' + ORDER BY id DESC + LIMIT 1 + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/TaskMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/TaskMapper.xml index 80c6c044..6568aa94 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/TaskMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/TaskMapper.xml @@ -12,6 +12,40 @@ + + + + + + + + + + + + + INSERT INTO task ( + name, + type, + parameters, + created_at, + updated_at + ) VALUES ( + #{name}, + #{type}, + #{parameters, javaType=com.fasterxml.jackson.databind.JsonNode, jdbcType=VARCHAR, typeHandler=site.icebang.global.config.mybatis.typehandler.JsonNodeTypeHandler}, + #{createdAt}, + #{updatedAt} + ) + + + + + SELECT tr.* + FROM task_run tr + JOIN task t ON tr.task_id = t.id + where t.name = #{taskName} + AND tr.status = 'SUCCESS' + ORDER BY tr.id DESC + LIMIT 1 + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/data/03-insert-workflow-h2.sql b/apps/user-service/src/main/resources/sql/data/03-insert-workflow-h2.sql index a4d4129b..c2dda2b3 100644 --- a/apps/user-service/src/main/resources/sql/data/03-insert-workflow-h2.sql +++ b/apps/user-service/src/main/resources/sql/data/03-insert-workflow-h2.sql @@ -16,75 +16,86 @@ DELETE FROM `workflow`; -- 워크플로우 생성 (ID: 1) - H2에서는 NOW() 사용 INSERT INTO `workflow` (`id`, `name`, `description`, `created_by`, `default_config`) VALUES (1, '상품 분석 및 블로그 자동 발행', '키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스', 1, - JSON_OBJECT('1',json_object('tag','naver'),'9',json_object('tag','blogger','blog_id', '', 'blog_pw', ''))) -ON DUPLICATE KEY UPDATE - name = VALUES(name), - description = VALUES(description), - updated_at = NOW(); + JSON_OBJECT('1',json_object('tag','naver'),'10',json_object('tag','blogger','blog_id', '', 'blog_pw', ''))) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + description = VALUES(description), + updated_at = NOW(); + -- Job 생성 (ID: 1, 2) - H2에서는 NOW() 사용 -INSERT INTO `job` (`id`, `name`, `description`, `created_by`) VALUES - (1, '상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업', 1), - (2, '블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업', 1) +INSERT INTO `job` (`id`, `name`, `description`, `created_by`, `created_at`, `updated_at`) VALUES + (1, '상품 분석', '키워드 검색, 상품 크롤링, S3 업로드, OCR 처리 및 상품 선택 작업', 1, NOW(), NOW()), + (2, '블로그 콘텐츠 생성', 'OCR 데이터 기반 RAG 콘텐츠 생성 및 발행 작업', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), updated_at = NOW(); --- Task 생성 (ID: 1 ~ 9) - H2에서는 NOW() 사용 -INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES - (1, '키워드 검색 태스크', 'FastAPI', JSON_OBJECT( - 'endpoint', '/keywords/search', 'method', 'POST', - 'body', JSON_OBJECT('tag', 'String') -- { "tag": str } - )), - (2, '상품 검색 태스크', 'FastAPI', JSON_OBJECT( - 'endpoint', '/products/search', 'method', 'POST', - 'body', JSON_OBJECT('keyword', 'String') -- { "keyword": str } - )), - (3, '상품 매칭 태스크', 'FastAPI', JSON_OBJECT( - 'endpoint', '/products/match', 'method', 'POST', - 'body', JSON_OBJECT( -- { keyword: str, search_results: List } - 'keyword', 'String', - 'search_results', 'List' - ) - )), - (4, '상품 유사도 분석 태스크', 'FastAPI', JSON_OBJECT( - 'endpoint', '/products/similarity', 'method', 'POST', - 'body', JSON_OBJECT( -- { keyword: str, matched_products: List, search_results: List } - 'keyword', 'String', - 'matched_products', 'List', - 'search_results', 'List' - ) - )), - (5, '상품 정보 크롤링 태스크', 'FastAPI', JSON_OBJECT( - 'endpoint', '/products/crawl', 'method', 'POST', - 'body', JSON_OBJECT('product_urls', 'List') -- { "product_urls": List[str] } 수정됨 - )), - (6, 'S3 업로드 태스크', 'FastAPI', JSON_OBJECT( - 'endpoint', '/products/s3-upload', 'method', 'POST', - 'body', JSON_OBJECT( -- { keyword: str, crawled_products: List, base_folder: str } - 'keyword', 'String', - 'crawled_products', 'List', - 'base_folder', 'String' - ) - )), - (7, '상품 선택 태스크', 'FastAPI', JSON_OBJECT( - 'endpoint', '/products/select', 'method', 'POST', - 'body', JSON_OBJECT( -- { task_run_id: int, selection_criteria: str } - 'task_run_id', 'Integer', - 'selection_criteria', 'String' - ) - )), - -- RAG관련 request body는 추후에 결정될 예정 - (8, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT('endpoint', '/blogs/rag/create', 'method', 'POST')), - (9, '블로그 발행 태스크', 'FastAPI', JSON_OBJECT( - 'endpoint', '/blogs/publish', 'method', 'POST', - 'body', JSON_OBJECT( -- { tag: str, blog_id: str, ... } - 'tag', 'String', - 'blog_id', 'String', - 'blog_pw', 'String', - 'blog_name', 'String', - 'post_title', 'String', - 'post_content', 'String', - 'post_tags', 'List' - ) - )) +-- Task 생성 (ID: 1 ~ 10) - H2에서는 NOW() 사용, created_at/updated_at 추가 +INSERT INTO `task` (`id`, `name`, `type`, `parameters`, `created_at`, `updated_at`) VALUES + (1, '키워드 검색 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/keywords/search', 'method', 'POST', + 'body', JSON_OBJECT('tag', 'String') + ), NOW(), NOW()), + (2, '상품 검색 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/search', 'method', 'POST', + 'body', JSON_OBJECT('keyword', 'String') + ), NOW(), NOW()), + (3, '상품 매칭 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/match', 'method', 'POST', + 'body', JSON_OBJECT( + 'keyword', 'String', + 'search_results', 'List' + ) + ), NOW(), NOW()), + (4, '상품 유사도 분석 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/similarity', 'method', 'POST', + 'body', JSON_OBJECT( + 'keyword', 'String', + 'matched_products', 'List', + 'search_results', 'List' + ) + ), NOW(), NOW()), + (5, '상품 정보 크롤링 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/crawl', 'method', 'POST', + 'body', JSON_OBJECT('product_urls', 'List') + ), NOW(), NOW()), + (6, 'S3 업로드 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/s3-upload', 'method', 'POST', + 'body', JSON_OBJECT( + 'keyword', 'String', + 'crawled_products', 'List', + 'base_folder', 'String' + ) + ), NOW(), NOW()), + (7, '상품 선택 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/products/select', 'method', 'POST', + 'body', JSON_OBJECT( + 'task_run_id', 'Integer', + 'selection_criteria', 'String' + ) + ), NOW(), NOW()), + (8, '이미지 OCR 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/blogs/ocr/extract', 'method', 'POST', + 'body', JSON_OBJECT('keyword', 'String') + ), NOW(), NOW()), + (9, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/blogs/rag/create', 'method', 'POST', + 'body', JSON_OBJECT( + 'keyword', 'String', + 'translation_language', 'String', + 'product_info', 'Object' + ) + ), NOW(), NOW()), + (10, '블로그 발행 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/blogs/publish', 'method', 'POST', + 'body', JSON_OBJECT( + 'tag', 'String', + 'blog_id', 'String', + 'blog_pw', 'String', + 'blog_name', 'String', + 'post_title', 'String', + 'post_content', 'String', + 'post_tags', 'List' + ) + ), NOW(), NOW()) ON DUPLICATE KEY UPDATE name = VALUES(name), type = VALUES(type), parameters = VALUES(parameters), updated_at = NOW(); -- =================================================================== @@ -98,10 +109,10 @@ INSERT INTO `workflow_job` (`workflow_id`, `job_id`, `execution_order`) VALUES -- Job-Task 연결 INSERT INTO `job_task` (`job_id`, `task_id`, `execution_order`) VALUES - -- Job 1: 상품 분석 (키워드검색 → 상품검색 → 매칭 → 유사도 → 크롤링 → S3업로드 → 상품선택) - (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), (1, 6, 6), (1, 7, 7), + -- Job 1: 상품 분석 (키워드검색 → 상품검색 → 매칭 → 유사도 → 크롤링 → S3업로드 → 상품선택 → OCR) + (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), (1, 6, 6), (1, 7, 7), (1, 8, 8), -- Job 2: 블로그 콘텐츠 생성 (RAG생성 → 발행) - (2, 8, 1), (2, 9, 2) + (2, 9, 1), (2, 10, 2) ON DUPLICATE KEY UPDATE execution_order = VALUES(execution_order); -- 스케줄 설정 (매일 오전 8시) - H2에서는 NOW() 사용 diff --git a/apps/user-service/src/main/resources/sql/data/03-insert-workflow.sql b/apps/user-service/src/main/resources/sql/data/03-insert-workflow.sql index e7e28042..9b95977b 100644 --- a/apps/user-service/src/main/resources/sql/data/03-insert-workflow.sql +++ b/apps/user-service/src/main/resources/sql/data/03-insert-workflow.sql @@ -16,37 +16,37 @@ DELETE FROM `workflow`; -- 워크플로우 생성 (ID: 1) INSERT INTO `workflow` (`id`, `name`, `description`, `created_by`, `default_config`) VALUES (1, '상품 분석 및 블로그 자동 발행', '키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스', 1, - JSON_OBJECT('1',json_object('tag','naver'),'9',json_object('tag','blogger','blog_id', '', 'blog_pw', ''))) + JSON_OBJECT('1',json_object('tag','naver'),'10',json_object('tag','blogger','blog_id', '', 'blog_pw', ''))) ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), updated_at = UTC_TIMESTAMP(); -- Job 생성 (ID: 1, 2) INSERT INTO `job` (`id`, `name`, `description`, `created_by`) VALUES - (1, '상품 분석', '키워드 검색, 상품 크롤링 및 유사도 분석 작업', 1), - (2, '블로그 콘텐츠 생성', '분석 데이터를 기반으로 RAG 콘텐츠 생성 및 발행 작업', 1) + (1, '상품 분석', '키워드 검색, 상품 크롤링, S3 업로드, OCR 처리 및 상품 선택 작업', 1), + (2, '블로그 콘텐츠 생성', 'OCR 데이터 기반 RAG 콘텐츠 생성 및 발행 작업', 1) ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), updated_at = UTC_TIMESTAMP(); --- Task 생성 (ID: 1 ~ 9) +-- Task 생성 (ID: 1 ~ 10) INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES (1, '키워드 검색 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/keywords/search', 'method', 'POST', - 'body', JSON_OBJECT('tag', 'String') -- { "tag": str } + 'body', JSON_OBJECT('tag', 'String') )), (2, '상품 검색 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/products/search', 'method', 'POST', - 'body', JSON_OBJECT('keyword', 'String') -- { "keyword": str } + 'body', JSON_OBJECT('keyword', 'String') )), (3, '상품 매칭 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/products/match', 'method', 'POST', - 'body', JSON_OBJECT( -- { keyword: str, search_results: List } + 'body', JSON_OBJECT( 'keyword', 'String', 'search_results', 'List' ) )), (4, '상품 유사도 분석 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/products/similarity', 'method', 'POST', - 'body', JSON_OBJECT( -- { keyword: str, matched_products: List, search_results: List } + 'body', JSON_OBJECT( 'keyword', 'String', 'matched_products', 'List', 'search_results', 'List' @@ -54,11 +54,11 @@ INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES )), (5, '상품 정보 크롤링 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/products/crawl', 'method', 'POST', - 'body', JSON_OBJECT('product_urls', 'List') -- { "product_urls": List[str] } 수정됨 + 'body', JSON_OBJECT('product_urls', 'List') )), (6, 'S3 업로드 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/products/s3-upload', 'method', 'POST', - 'body', JSON_OBJECT( -- { keyword: str, crawled_products: List, base_folder: str } + 'body', JSON_OBJECT( 'keyword', 'String', 'crawled_products', 'List', 'base_folder', 'String' @@ -66,16 +66,26 @@ INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES )), (7, '상품 선택 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/products/select', 'method', 'POST', - 'body', JSON_OBJECT( -- { task_run_id: int, selection_criteria: str } + 'body', JSON_OBJECT( 'task_run_id', 'Integer', 'selection_criteria', 'String' ) )), - -- RAG관련 request body는 추후에 결정될 예정 - (8, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT('endpoint', '/blogs/rag/create', 'method', 'POST')), - (9, '블로그 발행 태스크', 'FastAPI', JSON_OBJECT( + (8, '이미지 OCR 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/blogs/ocr/extract', 'method', 'POST', + 'body', JSON_OBJECT('keyword', 'String') + )), + (9, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT( + 'endpoint', '/blogs/rag/create', 'method', 'POST', + 'body', JSON_OBJECT( + 'keyword', 'String', + 'translation_language', 'String', + 'product_info', 'Object' + ) + )), + (10, '블로그 발행 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/blogs/publish', 'method', 'POST', - 'body', JSON_OBJECT( -- { tag: str, blog_id: str, ... } + 'body', JSON_OBJECT( 'tag', 'String', 'blog_id', 'String', 'blog_pw', 'String', @@ -84,7 +94,7 @@ INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES 'post_content', 'String', 'post_tags', 'List' ) - )) + )) ON DUPLICATE KEY UPDATE name = VALUES(name), type = VALUES(type), parameters = VALUES(parameters), updated_at = UTC_TIMESTAMP(); -- =================================================================== @@ -98,10 +108,10 @@ INSERT INTO `workflow_job` (`workflow_id`, `job_id`, `execution_order`) VALUES -- Job-Task 연결 INSERT INTO `job_task` (`job_id`, `task_id`, `execution_order`) VALUES - -- Job 1: 상품 분석 (키워드검색 → 상품검색 → 매칭 → 유사도 → 크롤링 → S3업로드 → 상품선택) - (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), (1, 6, 6), (1, 7, 7), + -- Job 1: 상품 분석 (키워드검색 → 상품검색 → 매칭 → 유사도 → 크롤링 → S3업로드 → 상품선택 → OCR) + (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), (1, 6, 6), (1, 7, 7), (1, 8, 8), -- Job 2: 블로그 콘텐츠 생성 (RAG생성 → 발행) - (2, 8, 1), (2, 9, 2) + (2, 9, 1), (2, 10, 2) ON DUPLICATE KEY UPDATE execution_order = VALUES(execution_order); -- 스케줄 설정 (매일 오전 8시) diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/JobApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/JobApiIntegrationTest.java new file mode 100644 index 00000000..efd471cc --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/JobApiIntegrationTest.java @@ -0,0 +1,292 @@ +package site.icebang.integration.tests.workflow; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; + +import site.icebang.integration.setup.support.IntegrationTestSupport; + +@Sql( + value = { + "classpath:sql/data/00-truncate.sql", + "classpath:sql/data/01-insert-internal-users.sql", + "classpath:sql/data/03-insert-workflow-h2.sql" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Transactional +@DisplayName("Job API 통합 테스트") +public class JobApiIntegrationTest extends IntegrationTestSupport { + + @Test + @DisplayName("Job 생성 성공") + @WithUserDetails("admin@icebang.site") + void createJob_success() throws Exception { + // given + String jobRequestJson = + """ + { + "name": "통합테스트 Job", + "description": "Integration Test용 Job", + "isEnabled": true + } + """; + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/jobs")) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .contentType(MediaType.APPLICATION_JSON) + .content(jobRequestJson)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").exists()) + .andExpect(jsonPath("$.data.name").value("통합테스트 Job")) + .andExpect(jsonPath("$.data.description").value("Integration Test용 Job")) + .andExpect(jsonPath("$.data.isEnabled").value(true)) + .andExpect(jsonPath("$.data.createdAt").exists()) + .andExpect(jsonPath("$.data.updatedAt").exists()) + .andDo( + document( + "job-create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Job") + .summary("Job 생성") + .description("새로운 Job을 생성합니다") + .requestFields( + fieldWithPath("name") + .type(JsonFieldType.STRING) + .description("Job 이름 (필수)"), + fieldWithPath("description") + .type(JsonFieldType.STRING) + .description("Job 설명 (선택)"), + fieldWithPath("isEnabled") + .type(JsonFieldType.BOOLEAN) + .description("활성화 여부 (선택, 기본값: true)")) + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data") + .type(JsonFieldType.OBJECT) + .description("생성된 Job 정보"), + fieldWithPath("data.id") + .type(JsonFieldType.NUMBER) + .description("Job ID"), + fieldWithPath("data.name") + .type(JsonFieldType.STRING) + .description("Job 이름"), + fieldWithPath("data.description") + .type(JsonFieldType.STRING) + .description("Job 설명") + .optional(), + fieldWithPath("data.isEnabled") + .type(JsonFieldType.BOOLEAN) + .description("활성화 여부") + .optional(), + fieldWithPath("data.createdAt") + .type(JsonFieldType.STRING) + .description("생성 시간"), + fieldWithPath("data.createdBy") + .type(JsonFieldType.NUMBER) + .description("생성자 ID") + .optional(), + fieldWithPath("data.updatedAt") + .type(JsonFieldType.STRING) + .description("수정 시간"), + fieldWithPath("data.updatedBy") + .type(JsonFieldType.NUMBER) + .description("수정자 ID") + .optional(), + fieldWithPath("data.executionOrder") + .type(JsonFieldType.NUMBER) + .description("실행 순서") + .optional()) + .build()))); + } + + @Test + @DisplayName("Job 조회 성공") + @WithUserDetails("admin@icebang.site") + void getJob_success() throws Exception { + // given - 03-insert-workflow-h2.sql에서 생성된 Job ID 1 사용 + Long jobId = 1L; + + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/jobs/{id}"), jobId) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(jobId.intValue())) + .andExpect(jsonPath("$.data.name").value("상품 분석")) + .andExpect( + jsonPath("$.data.description").value("키워드 검색, 상품 크롤링, S3 업로드, OCR 처리 및 상품 선택 작업")) + .andExpect(jsonPath("$.data.createdAt").exists()) + .andExpect(jsonPath("$.data.updatedAt").exists()) + .andDo( + document( + "job-get", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Job") + .summary("Job 조회") + .description("Job ID로 Job 정보를 조회합니다") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("Job 정보"), + fieldWithPath("data.id") + .type(JsonFieldType.NUMBER) + .description("Job ID"), + fieldWithPath("data.name") + .type(JsonFieldType.STRING) + .description("Job 이름"), + fieldWithPath("data.description") + .type(JsonFieldType.STRING) + .description("Job 설명") + .optional(), + fieldWithPath("data.isEnabled") + .type(JsonFieldType.BOOLEAN) + .description("활성화 여부") + .optional(), + fieldWithPath("data.createdAt") + .type(JsonFieldType.STRING) + .description("생성 시간"), + fieldWithPath("data.createdBy") + .type(JsonFieldType.NUMBER) + .description("생성자 ID") + .optional(), + fieldWithPath("data.updatedAt") + .type(JsonFieldType.STRING) + .description("수정 시간"), + fieldWithPath("data.updatedBy") + .type(JsonFieldType.NUMBER) + .description("수정자 ID") + .optional(), + fieldWithPath("data.executionOrder") + .type(JsonFieldType.NUMBER) + .description("실행 순서") + .optional()) + .build()))); + } + + @Test + @DisplayName("Job 이름 없이 생성 시 실패") + @WithUserDetails("admin@icebang.site") + void createJob_withoutName_shouldFail() throws Exception { + // given + String invalidJobRequestJson = + """ + { + "description": "이름이 없는 Job", + "isEnabled": true + } + """; + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/jobs")) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJobRequestJson)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("빈 문자열 이름으로 Job 생성 시 실패") + @WithUserDetails("admin@icebang.site") + void createJob_withBlankName_shouldFail() throws Exception { + // given + String blankNameJobRequestJson = + """ + { + "name": " ", + "description": "빈 이름 Job", + "isEnabled": true + } + """; + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/jobs")) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .contentType(MediaType.APPLICATION_JSON) + .content(blankNameJobRequestJson)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("존재하지 않는 Job 조회 시 404 반환") + @WithUserDetails("admin@icebang.site") + void getJob_withNonExistentId_shouldReturn404() throws Exception { + // given + Long nonExistentId = 99999L; + + // when & then + mockMvc + .perform( + get(getApiUrlForDocs("/v0/jobs/{id}"), nonExistentId) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + @DisplayName("Job 생성 시 UTC 시간으로 저장되는지 검증") + @WithUserDetails("admin@icebang.site") + void createJob_utc_time_validation() throws Exception { + // given + String jobRequestJson = + """ + { + "name": "UTC 시간 검증 Job", + "description": "시간대 보정 테스트용 Job" + } + """; + + // when & then + mockMvc + .perform( + post(getApiUrlForDocs("/v0/jobs")) + .header("Origin", "https://admin.icebang.site") + .header("Referer", "https://admin.icebang.site/") + .contentType(MediaType.APPLICATION_JSON) + .content(jobRequestJson)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.createdAt").exists()) + .andExpect(jsonPath("$.data.updatedAt").exists()); + + // 시간 형식이 올바른지는 실제 DB에서 확인하거나 + // 응답 시간이 현재 UTC 시간과 비슷한 범위에 있는지 검증할 수 있음 + } +} diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskApiIntegrationTest.java new file mode 100644 index 00000000..65feaf15 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/TaskApiIntegrationTest.java @@ -0,0 +1,93 @@ +package site.icebang.integration.tests.workflow; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import site.icebang.domain.workflow.dto.TaskDto; +import site.icebang.domain.workflow.service.WorkflowService; +import site.icebang.integration.setup.support.IntegrationTestSupport; + +public class TaskApiIntegrationTest extends IntegrationTestSupport { + + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private WorkflowService workflowService; + + @Test + @DisplayName("Task 생성 API - 성공") + @WithMockUser(roles = "SUPER_ADMIN") // 📌 DB 조회 없이 'SUPER_ADMIN' 권한을 가진 가상 유저로 인증 + void createTask_success() throws Exception { + // given + TaskDto requestDto = new TaskDto(); + requestDto.setName("테스트 태스크"); + requestDto.setType("FastAPI"); + + TaskDto createdDto = new TaskDto(); + createdDto.setId(1L); + createdDto.setName("테스트 태스크"); + createdDto.setType("FastAPI"); + + when(workflowService.createTask(any(TaskDto.class))).thenReturn(createdDto); + + // when & then + mockMvc + .perform( + post("/v0/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(1L)) + .andExpect(jsonPath("$.data.name").value("테스트 태스크")); + } + + @Test + @DisplayName("Task 조회 API - 성공") + @WithMockUser(roles = "SUPER_ADMIN") // 📌 가상 유저로 인증 + void getTask_success() throws Exception { + // given + Long taskId = 1L; + TaskDto foundDto = new TaskDto(); + foundDto.setId(taskId); + foundDto.setName("조회된 태스크"); + foundDto.setType("FastAPI"); + + when(workflowService.findTaskById(taskId)).thenReturn(foundDto); + + // when & then + mockMvc + .perform(get("/v0/tasks/{id}", taskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(taskId)) + .andExpect(jsonPath("$.data.name").value("조회된 태스크")); + } + + @Test + @DisplayName("Task 조회 API - 실패 (존재하지 않는 ID)") + @WithMockUser(roles = "SUPER_ADMIN") // 📌 가상 유저로 인증 + void getTask_notFound() throws Exception { + // given + Long nonExistentTaskId = 999L; + when(workflowService.findTaskById(nonExistentTaskId)).thenReturn(null); + + // when & then + mockMvc + .perform(get("/v0/tasks/{id}", nonExistentTaskId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)); + } +} diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java index f83e0142..2fc6be1b 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java @@ -1,12 +1,12 @@ package site.icebang.integration.tests.workflow; import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; -import static com.epages.restdocs.apispec.ResourceDocumentation.*; -import static org.hamcrest.Matchers.matchesPattern; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -28,8 +28,9 @@ executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Transactional public class WorkflowHistoryApiIntegrationTest extends IntegrationTestSupport { + @Test - @DisplayName("워크플로우 실행 상세 조회 성공") + @DisplayName("워크플로우 실행 상세 조회 API - 성공") @WithUserDetails("admin@icebang.site") void getWorkflowRunDetail_success() throws Exception { // given @@ -43,83 +44,11 @@ void getWorkflowRunDetail_success() throws Exception { .header("Referer", "https://admin.icebang.site/")) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value("OK")) - .andExpect(jsonPath("$.message").value("OK")) - // traceId 확인 .andExpect(jsonPath("$.data.traceId").value("3e3c832d-b51f-48ea-95f9-98f0ae6d3413")) - // workflowRun 필드 확인 .andExpect(jsonPath("$.data.workflowRun.id").value(1)) - .andExpect(jsonPath("$.data.workflowRun.workflowId").value(1)) - .andExpect(jsonPath("$.data.workflowRun.workflowName").value("상품 분석 및 블로그 자동 발행")) - .andExpect( - jsonPath("$.data.workflowRun.workflowDescription") - .value("키워드 검색부터 상품 분석 후 블로그 발행까지의 자동화 프로세스")) - .andExpect(jsonPath("$.data.workflowRun.runNumber").isEmpty()) .andExpect(jsonPath("$.data.workflowRun.status").value("FAILED")) - .andExpect(jsonPath("$.data.workflowRun.triggerType").isEmpty()) .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22T18:18:43Z")) .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22T18:18:44Z")) - .andExpect(jsonPath("$.data.workflowRun.durationMs").value(1000)) - .andExpect(jsonPath("$.data.workflowRun.createdBy").isEmpty()) - .andExpect(jsonPath("$.data.workflowRun.createdAt").exists()) - // UTC 시간 형식 검증 (시간대 보장) - 마이크로초 포함 가능 - .andExpect( - jsonPath("$.data.workflowRun.startedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - .andExpect( - jsonPath("$.data.workflowRun.finishedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - .andExpect( - jsonPath("$.data.workflowRun.createdAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - // jobRuns 배열 확인 - .andExpect(jsonPath("$.data.jobRuns").isArray()) - .andExpect(jsonPath("$.data.jobRuns.length()").value(1)) - // jobRuns[0] 필드 확인 - .andExpect(jsonPath("$.data.jobRuns[0].id").value(1)) - .andExpect(jsonPath("$.data.jobRuns[0].workflowRunId").value(1)) - .andExpect(jsonPath("$.data.jobRuns[0].jobId").value(1)) - .andExpect(jsonPath("$.data.jobRuns[0].jobName").value("상품 분석")) - .andExpect(jsonPath("$.data.jobRuns[0].jobDescription").value("키워드 검색, 상품 크롤링 및 유사도 분석 작업")) - .andExpect(jsonPath("$.data.jobRuns[0].status").value("FAILED")) - .andExpect(jsonPath("$.data.jobRuns[0].executionOrder").isEmpty()) - .andExpect(jsonPath("$.data.jobRuns[0].startedAt").value("2025-09-22T18:18:44Z")) - .andExpect(jsonPath("$.data.jobRuns[0].finishedAt").value("2025-09-22T18:18:44Z")) - .andExpect(jsonPath("$.data.jobRuns[0].durationMs").value(0)) - // JobRun UTC 시간 형식 검증 - 마이크로초 포함 가능 - .andExpect( - jsonPath( - "$.data.jobRuns[0].startedAt", - matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - // finishedAt 도 동일하게 - .andExpect( - jsonPath( - "$.data.jobRuns[0].finishedAt", - matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - // taskRuns 배열 확인 - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns").isArray()) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns.length()").value(1)) - // taskRuns[0] 필드 확인 - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].id").value(1)) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].jobRunId").value(1)) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskId").value(1)) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskName").value("키워드 검색 태스크")) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskDescription").isEmpty()) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskType").value("FastAPI")) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].status").value("FAILED")) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].executionOrder").isEmpty()) - .andExpect( - jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt").value("2025-09-22T18:18:44Z")) - .andExpect( - jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt").value("2025-09-22T18:18:44Z")) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].durationMs").value(0)) - // TaskRun UTC 시간 형식 검증 - 마이크로초 포함 가능 - .andExpect( - jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - .andExpect( - jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andDo( document( "workflow-run-detail", @@ -129,8 +58,9 @@ void getWorkflowRunDetail_success() throws Exception { ResourceSnippetParameters.builder() .tag("Workflow History") .summary("워크플로우 실행 상세 조회") - .description("워크플로우 실행 ID로 상세 정보를 조회합니다") + .description("워크플로우 실행 ID로 상세 정보를 조회합니다.") .responseFields( + // 📌 responseFields에 모든 필드 경로를 추가합니다. fieldWithPath("success") .type(JsonFieldType.BOOLEAN) .description("요청 성공 여부"), @@ -155,28 +85,31 @@ void getWorkflowRunDetail_success() throws Exception { .description("워크플로우 설명"), fieldWithPath("data.workflowRun.runNumber") .type(JsonFieldType.NULL) - .description("실행 번호"), + .description("실행 번호") + .optional(), fieldWithPath("data.workflowRun.status") .type(JsonFieldType.STRING) .description("실행 상태"), fieldWithPath("data.workflowRun.triggerType") .type(JsonFieldType.NULL) - .description("트리거 유형"), + .description("트리거 유형") + .optional(), fieldWithPath("data.workflowRun.startedAt") .type(JsonFieldType.STRING) - .description("시작 시간"), + .description("시작 시간 (UTC)"), fieldWithPath("data.workflowRun.finishedAt") .type(JsonFieldType.STRING) - .description("완료 시간"), + .description("완료 시간 (UTC)"), fieldWithPath("data.workflowRun.durationMs") .type(JsonFieldType.NUMBER) .description("실행 시간(ms)"), fieldWithPath("data.workflowRun.createdBy") .type(JsonFieldType.NULL) - .description("생성자 ID"), + .description("생성자 ID") + .optional(), fieldWithPath("data.workflowRun.createdAt") .type(JsonFieldType.STRING) - .description("생성 시간"), + .description("생성 시간 (UTC)"), fieldWithPath("data.jobRuns") .type(JsonFieldType.ARRAY) .description("Job 실행 목록"), @@ -194,19 +127,21 @@ void getWorkflowRunDetail_success() throws Exception { .description("Job 이름"), fieldWithPath("data.jobRuns[].jobDescription") .type(JsonFieldType.STRING) - .description("Job 설명"), + .description("Job 설명") + .optional(), fieldWithPath("data.jobRuns[].status") .type(JsonFieldType.STRING) .description("Job 실행 상태"), fieldWithPath("data.jobRuns[].executionOrder") .type(JsonFieldType.NULL) - .description("실행 순서"), + .description("실행 순서") + .optional(), fieldWithPath("data.jobRuns[].startedAt") .type(JsonFieldType.STRING) - .description("Job 시작 시간"), + .description("Job 시작 시간 (UTC)"), fieldWithPath("data.jobRuns[].finishedAt") .type(JsonFieldType.STRING) - .description("Job 완료 시간"), + .description("Job 완료 시간 (UTC)"), fieldWithPath("data.jobRuns[].durationMs") .type(JsonFieldType.NUMBER) .description("Job 실행 시간(ms)"), @@ -227,7 +162,8 @@ void getWorkflowRunDetail_success() throws Exception { .description("Task 이름"), fieldWithPath("data.jobRuns[].taskRuns[].taskDescription") .type(JsonFieldType.NULL) - .description("Task 설명"), + .description("Task 설명") + .optional(), fieldWithPath("data.jobRuns[].taskRuns[].taskType") .type(JsonFieldType.STRING) .description("Task 유형"), @@ -236,13 +172,14 @@ void getWorkflowRunDetail_success() throws Exception { .description("Task 실행 상태"), fieldWithPath("data.jobRuns[].taskRuns[].executionOrder") .type(JsonFieldType.NULL) - .description("Task 실행 순서"), + .description("Task 실행 순서") + .optional(), fieldWithPath("data.jobRuns[].taskRuns[].startedAt") .type(JsonFieldType.STRING) - .description("Task 시작 시간"), + .description("Task 시작 시간 (UTC)"), fieldWithPath("data.jobRuns[].taskRuns[].finishedAt") .type(JsonFieldType.STRING) - .description("Task 완료 시간"), + .description("Task 완료 시간 (UTC)"), fieldWithPath("data.jobRuns[].taskRuns[].durationMs") .type(JsonFieldType.NUMBER) .description("Task 실행 시간(ms)"), @@ -254,48 +191,4 @@ void getWorkflowRunDetail_success() throws Exception { .description("HTTP 상태")) .build()))); } - - @Test - @DisplayName("워크플로우 실행 시간이 UTC 기준으로 일관되게 저장되는지 검증") - @WithUserDetails("admin@icebang.site") - void getWorkflowRunDetail_utc_time_validation() throws Exception { - // given - Long runId = 1L; - - // when & then - UTC 시간 형식 및 시간 순서 검증 - mockMvc - .perform( - get(getApiUrlForDocs("/v0/workflow-runs/{runId}"), runId) - .header("Origin", "https://admin.icebang.site") - .header("Referer", "https://admin.icebang.site/")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - // WorkflowRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 - .andExpect( - jsonPath("$.data.workflowRun.startedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - .andExpect( - jsonPath("$.data.workflowRun.finishedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - .andExpect( - jsonPath("$.data.workflowRun.createdAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - // JobRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 - .andExpect( - jsonPath("$.data.jobRuns[0].startedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - .andExpect( - jsonPath("$.data.jobRuns[0].finishedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - // TaskRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 - .andExpect( - jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - .andExpect( - jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt") - .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) - // 시간 순서 논리적 검증 (startedAt <= finishedAt) - .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22T18:18:43Z")) - .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22T18:18:44Z")); - } } diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java index 23c4eaa4..5cbcce77 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowRunApiIntegrationTest.java @@ -2,6 +2,8 @@ import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; @@ -18,6 +20,7 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; +import site.icebang.domain.workflow.dto.RequestContext; import site.icebang.domain.workflow.service.WorkflowExecutionService; import site.icebang.integration.setup.support.IntegrationTestSupport; @@ -62,6 +65,7 @@ void runWorkflow_success() throws Exception { .build()))); // 📌 2. 비동기 호출된 executeWorkflow 메소드가 1초 이내에 1번 실행되었는지 검증 - verify(mockWorkflowExecutionService, timeout(1000).times(1)).executeWorkflow(workflowId); + verify(mockWorkflowExecutionService, timeout(1000).times(1)) + .executeWorkflow(eq(workflowId), any(RequestContext.class)); } }