diff --git a/.github/workflows/deploy-java.yml b/.github/workflows/deploy-java.yml index 8d472520..bb4483dd 100644 --- a/.github/workflows/deploy-java.yml +++ b/.github/workflows/deploy-java.yml @@ -34,6 +34,8 @@ jobs: echo "GRAFANA_CLOUD_PROMETHEUS_URL=${{ secrets.GRAFANA_CLOUD_PROMETHEUS_URL }}" >> .env.prod echo "GRAFANA_CLOUD_PROMETHEUS_USER=${{ secrets.GRAFANA_CLOUD_PROMETHEUS_USER }}" >> .env.prod echo "GRAFANA_CLOUD_API_KEY=${{ secrets.GRAFANA_CLOUD_API_KEY }}" >> .env.prod + echo "MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}" >> .env.prod + echo "MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" >> .env.prod - name: Set repo lowercase run: echo "REPO_LC=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV diff --git a/apps/pre-processing-service/app/model/schemas.py b/apps/pre-processing-service/app/model/schemas.py index 549ba7b5..dd49cf44 100644 --- a/apps/pre-processing-service/app/model/schemas.py +++ b/apps/pre-processing-service/app/model/schemas.py @@ -227,6 +227,12 @@ class S3UploadData(BaseModel): uploaded_at: str = Field( ..., title="업로드 완료 시간", description="S3 업로드 완료 시간" ) + # 🆕 임시: 콘텐츠 생성용 단일 상품만 추가 (나중에 삭제 예정) + selected_product_for_content: Optional[Dict] = Field( + None, + title="콘텐츠 생성용 선택 상품", + description="임시: 블로그 콘텐츠 생성을 위해 선택된 단일 상품 정보", + ) # 최종 응답 모델 diff --git a/apps/pre-processing-service/app/service/blog/blog_publish_service.py b/apps/pre-processing-service/app/service/blog/blog_publish_service.py index 0848f123..59014222 100644 --- a/apps/pre-processing-service/app/service/blog/blog_publish_service.py +++ b/apps/pre-processing-service/app/service/blog/blog_publish_service.py @@ -1,4 +1,5 @@ -from typing import Dict, Optional +# app/service/blog/blog_publish_service.py +from typing import Dict from app.errors.CustomException import CustomException from app.model.schemas import RequestBlogPublish from app.service.blog.blog_service_factory import BlogServiceFactory @@ -10,20 +11,15 @@ class BlogPublishService: def __init__(self): self.factory = BlogServiceFactory() - def publish_content( - self, - request: RequestBlogPublish, - ) -> Dict: + def publish_content(self, request: RequestBlogPublish) -> Dict: """ 생성된 블로그 콘텐츠를 배포합니다. Args: - request: 블로그 발행 요청 데이터 - blog_id: 블로그 아이디 - blog_password: 블로그 비밀번호 + request: RequestBlogPublish 객체 """ try: - # 팩토리를 통해 적절한 서비스 생성 + # 블로그 서비스 생성 (네이버, 티스토리, 블로거 등) blog_service = self.factory.create_service( request.tag, blog_id=request.blog_id, @@ -31,7 +27,7 @@ def publish_content( blog_name=request.blog_name, ) - # 공통 인터페이스로 포스팅 실행 + # 콘텐츠 포스팅 response_data = blog_service.post_content( title=request.post_title, content=request.post_content, @@ -40,16 +36,20 @@ def publish_content( if not response_data: raise CustomException( - 500, f"{request.tag} 블로그 포스팅에 실패했습니다.", "POSTING_FAIL" + detail=f"{request.tag} 블로그 포스팅에 실패했습니다.", + status_code=500, + code="POSTING_FAIL", ) return response_data except CustomException: - # 이미 처리된 예외는 그대로 전달 + # 이미 CustomException이면 그대로 전달 raise except Exception as e: # 예상치 못한 예외 처리 raise CustomException( - 500, f"블로그 포스팅 중 오류가 발생했습니다: {str(e)}", "ERROR" + detail=f"블로그 포스팅 중 오류가 발생했습니다: {str(e)}", + status_code=500, + code="ERROR", ) diff --git a/apps/pre-processing-service/app/service/blog/blog_service_factory.py b/apps/pre-processing-service/app/service/blog/blog_service_factory.py index 4759b5ab..27eef797 100644 --- a/apps/pre-processing-service/app/service/blog/blog_service_factory.py +++ b/apps/pre-processing-service/app/service/blog/blog_service_factory.py @@ -45,7 +45,7 @@ def create_service( if platform.lower() == "tistory_blog": if not blog_name: raise CustomException( - 200, + 400, "티스토리 블로그가 존재하지않습니다.", "NOT_FOUND_BLOG", ) diff --git a/apps/pre-processing-service/app/service/s3_upload_service.py b/apps/pre-processing-service/app/service/s3_upload_service.py index 1c024a63..48c84d35 100644 --- a/apps/pre-processing-service/app/service/s3_upload_service.py +++ b/apps/pre-processing-service/app/service/s3_upload_service.py @@ -102,11 +102,16 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: if product_index < len(crawled_products): await asyncio.sleep(1) + # 🆕 임시: 콘텐츠 생성용 단일 상품 선택 로직 + selected_product_for_content = self._select_single_product_for_content( + crawled_products, upload_results + ) + logger.success( f"S3 업로드 서비스 완료: 총 성공 이미지 {total_success_images}개, 총 실패 이미지 {total_fail_images}개" ) - # 간소화된 응답 데이터 구성 + # 기존 응답 데이터 구성 data = { "upload_results": upload_results, "summary": { @@ -115,6 +120,8 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: "total_fail_images": total_fail_images, }, "uploaded_at": time.strftime("%Y-%m-%d %H:%M:%S"), + # 🆕 임시: 콘텐츠 생성용 단일 상품만 추가 (나중에 삭제 예정) + "selected_product_for_content": selected_product_for_content, } message = f"S3 업로드 완료: {total_success_images}개 이미지 업로드 성공, 상품 데이터 JSON 파일 포함" @@ -123,3 +130,89 @@ async def upload_crawled_products_to_s3(self, request: RequestS3Upload) -> dict: except Exception as e: logger.error(f"S3 업로드 서비스 전체 오류: {e}") raise InvalidItemDataException() + + def _select_single_product_for_content( + self, crawled_products: List[Dict], upload_results: List[Dict] + ) -> Dict: + """ + 🆕 임시: 콘텐츠 생성을 위한 단일 상품 선택 로직 + 우선순위: 1) S3 업로드 성공한 상품 중 이미지 개수가 많은 것 + 2) 없다면 크롤링 성공한 첫 번째 상품 + """ + try: + # 1순위: S3 업로드 성공하고 이미지가 있는 상품들 + successful_uploads = [ + result + for result in upload_results + if result.get("status") == "completed" + and result.get("success_count", 0) > 0 + ] + + if successful_uploads: + # 이미지 개수가 가장 많은 상품 선택 + best_upload = max( + successful_uploads, key=lambda x: x.get("success_count", 0) + ) + selected_index = best_upload["product_index"] + + # 원본 크롤링 데이터에서 해당 상품 찾기 + for product_info in crawled_products: + if product_info.get("index") == selected_index: + logger.info( + f"콘텐츠 생성용 상품 선택: index={selected_index}, " + f"title='{product_info.get('product_detail', {}).get('title', 'Unknown')[:30]}', " + f"images={best_upload.get('success_count', 0)}개" + ) + return { + "selection_reason": "s3_upload_success_with_most_images", + "product_info": product_info, + "s3_upload_info": best_upload, + } + + # 2순위: 크롤링 성공한 첫 번째 상품 (S3 업로드 실패해도) + for product_info in crawled_products: + if product_info.get("status") == "success" and product_info.get( + "product_detail" + ): + + # 해당 상품의 S3 업로드 정보 찾기 + upload_info = None + for result in upload_results: + if result.get("product_index") == product_info.get("index"): + upload_info = result + break + + logger.info( + f"콘텐츠 생성용 상품 선택 (fallback): index={product_info.get('index')}, " + f"title='{product_info.get('product_detail', {}).get('title', 'Unknown')[:30]}'" + ) + return { + "selection_reason": "first_crawl_success", + "product_info": product_info, + "s3_upload_info": upload_info, + } + + # 3순위: 아무거나 (모든 상품이 실패한 경우) + if crawled_products: + logger.warning("모든 상품이 크롤링 실패 - 첫 번째 상품으로 fallback") + return { + "selection_reason": "fallback_first_product", + "product_info": crawled_products[0], + "s3_upload_info": upload_results[0] if upload_results else None, + } + + logger.error("선택할 상품이 없습니다") + return { + "selection_reason": "no_products_available", + "product_info": None, + "s3_upload_info": None, + } + + except Exception as e: + logger.error(f"단일 상품 선택 오류: {e}") + return { + "selection_reason": "selection_error", + "product_info": crawled_products[0] if crawled_products else None, + "s3_upload_info": upload_results[0] if upload_results else None, + "error": str(e), + } diff --git a/apps/user-service/src/main/java/site/icebang/domain/email/controller/EmailTestController.java b/apps/user-service/src/main/java/site/icebang/domain/email/controller/EmailTestController.java new file mode 100644 index 00000000..633e3ee7 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/email/controller/EmailTestController.java @@ -0,0 +1,37 @@ +package site.icebang.domain.email.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; + +import site.icebang.domain.email.dto.EmailRequest; +import site.icebang.domain.email.service.EmailService; + +@RestController +@RequestMapping("/api/v1/email") +@RequiredArgsConstructor +public class EmailTestController { + private final EmailService emailService; + + @GetMapping("/test") + @PreAuthorize("permitAll()") + public ResponseEntity sendTestEmail(@RequestParam String to) { + try { + EmailRequest emailRequest = + EmailRequest.builder() + .to(to) + .subject("IceBang 실제 테스트 이메일") + .body("안녕하세요!\n\nIceBang에서 보내는 실제 Gmail 테스트 이메일입니다.\n\n성공적으로 연동되었습니다!") + .isHtml(false) + .build(); + + emailService.send(emailRequest); + return ResponseEntity.ok("실제 Gmail 테스트 이메일 전송 완료! 받은편지함을 확인하세요!"); + + } catch (Exception e) { + return ResponseEntity.badRequest().body("이메일 전송 실패: " + e.getMessage()); + } + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/email/service/EmailServiceImpl.java b/apps/user-service/src/main/java/site/icebang/domain/email/service/EmailServiceImpl.java index cd8ba706..1bf7454b 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/email/service/EmailServiceImpl.java +++ b/apps/user-service/src/main/java/site/icebang/domain/email/service/EmailServiceImpl.java @@ -1,16 +1,92 @@ -// package site.icebang.domain.email.service; -// -// import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -// import org.springframework.stereotype.Service; -// -// import lombok.RequiredArgsConstructor; -// -// import site.icebang.domain.email.dto.EmailRequest; -// -// @Service -// @RequiredArgsConstructor +package site.icebang.domain.email.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.domain.email.dto.EmailRequest; + +@Slf4j +@Service +@Profile({"production"}) +@RequiredArgsConstructor // @ConditionalOnMissingBean(EmailService.class) -// public class EmailServiceImpl implements EmailService { -// @Override -// public void send(EmailRequest emailRequest) {} -// } +public class EmailServiceImpl implements EmailService { + + private final JavaMailSender mailSender; + + @Value("${spring.mail.username}") + private String defaultSender; + + @Override + public void send(EmailRequest request) { + try { + if (request.isHtml()) { + sendHtmlEmail(request); + } else { + sendSimpleEmail(request); + } + } catch (Exception e) { + log.error("❌ 이메일 전송 실패 - To: {}", request.getTo(), e); + throw new RuntimeException("이메일 전송 실패: " + e.getMessage(), e); + } + } + + private void sendSimpleEmail(EmailRequest request) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(request.getTo()); + message.setSubject(request.getSubject()); + message.setText(request.getBody()); + message.setFrom(defaultSender); + + // CC 설정 + if (request.getCc() != null && !request.getCc().isEmpty()) { + message.setCc(request.getCc().toArray(new String[0])); + } + + // BCC 설정 + if (request.getBcc() != null && !request.getBcc().isEmpty()) { + message.setBcc(request.getBcc().toArray(new String[0])); + } + + mailSender.send(message); + log.info("✅ 실제 Gmail 전송 성공! To: {}, Subject: {}", request.getTo(), request.getSubject()); + + } catch (Exception e) { + log.error("❌ Gmail 텍스트 전송 실패", e); + throw e; + } + } + + private void sendHtmlEmail(EmailRequest request) throws MessagingException { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setTo(request.getTo()); + helper.setSubject(request.getSubject()); + helper.setText(request.getBody(), true); // HTML 모드 + helper.setFrom(defaultSender); + + // CC 설정 + if (request.getCc() != null && !request.getCc().isEmpty()) { + helper.setCc(request.getCc().toArray(new String[0])); + } + + // BCC 설정 + if (request.getBcc() != null && !request.getBcc().isEmpty()) { + helper.setBcc(request.getBcc().toArray(new String[0])); + } + + mailSender.send(message); + log.info("✅ 실제 Gmail HTML 전송 성공! To: {}, Subject: {}", request.getTo(), request.getSubject()); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/email/service/MockEmailService.java b/apps/user-service/src/main/java/site/icebang/domain/email/service/MockEmailService.java index 7530667d..ee84e8ea 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/email/service/MockEmailService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/email/service/MockEmailService.java @@ -1,5 +1,6 @@ package site.icebang.domain.email.service; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; @@ -7,7 +8,7 @@ import site.icebang.domain.email.dto.EmailRequest; @Service -// @Profile({"test-unit", "test-e2e", "test-integration", "local", "develop", "production"}) +@Profile({"develop", "test-e2e", "test-integration", "test-unit"}) @Slf4j public class MockEmailService implements EmailService { 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 9cd5933b..650a2bcf 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 @@ -10,6 +10,7 @@ import site.icebang.common.dto.PageResult; import site.icebang.domain.workflow.dto.WorkflowCardDto; import site.icebang.domain.workflow.service.WorkflowExecutionService; +import site.icebang.domain.workflow.service.WorkflowHistoryService; import site.icebang.domain.workflow.service.WorkflowService; @RestController @@ -18,6 +19,7 @@ public class WorkflowController { private final WorkflowService workflowService; private final WorkflowExecutionService workflowExecutionService; + private final WorkflowHistoryService workflowHistoryService; @GetMapping("") public ApiResponse> getWorkflowList( diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java new file mode 100644 index 00000000..07d4f20e --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowHistoryController.java @@ -0,0 +1,38 @@ +package site.icebang.domain.workflow.controller; + +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; + +import site.icebang.common.dto.ApiResponse; +import site.icebang.common.dto.PageParams; +import site.icebang.common.dto.PageResult; +import site.icebang.domain.workflow.dto.WorkflowHistoryDTO; +import site.icebang.domain.workflow.dto.WorkflowRunDetailResponse; +import site.icebang.domain.workflow.service.WorkflowHistoryService; + +@RestController +@RequestMapping("/v0/workflow-runs") +@RequiredArgsConstructor +public class WorkflowHistoryController { + private final WorkflowHistoryService workflowHistoryService; + + @GetMapping("") + public ApiResponse> getWorkflowHistoryList( + @ModelAttribute PageParams pageParams) { + PageResult response = workflowHistoryService.getPagedResult(pageParams); + return ApiResponse.success(response); + } + + /** + * 워크플로우 실행 상세 조회 + * + * @param runId workflow_run.id + * @return WorkflowRunDetailResponse + */ + @GetMapping("/{runId}") + public ApiResponse getWorkflowRunDetail(@PathVariable Long runId) { + WorkflowRunDetailResponse response = workflowHistoryService.getWorkflowRunDetail(runId); + return ApiResponse.success(response); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java new file mode 100644 index 00000000..5dbb5711 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java @@ -0,0 +1,22 @@ +package site.icebang.domain.workflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExecutionLogDto { + private Long id; // execution_log.id + private String executionType; // workflow, job, task + private Long sourceId; // 모든 데이터에 대한 ID + private Long runId; // 실행 ID (workflow_run, job_run, task_run) + private String logLevel; // info, success, warning, error + private String status; // running, success, failed, etc + private String logMessage; + private String executedAt; + private Integer durationMs; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java new file mode 100644 index 00000000..618a6214 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java @@ -0,0 +1,26 @@ +package site.icebang.domain.workflow.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobRunDto { + private Long id; // job_run.id (Job 실행 ID) + private Long workflowRunId; // workflow_run.id (관계) + private Long jobId; // job.id (Job 설계 ID) + private String jobName; + private String jobDescription; + private String status; + private Integer executionOrder; + private String startedAt; + private String finishedAt; + private Integer durationMs; + private List taskRuns; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java new file mode 100644 index 00000000..9005c45a --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java @@ -0,0 +1,24 @@ +package site.icebang.domain.workflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TaskRunDto { + private Long id; // task_run.id (Task 실행 ID) + private Long jobRunId; // job_run.id (관계) + private Long taskId; // task.id (Task 설계 ID) + private String taskName; + private String taskDescription; + private String taskType; + private String status; + private Integer executionOrder; + private String startedAt; + private String finishedAt; + private Integer durationMs; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowHistoryDTO.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowHistoryDTO.java new file mode 100644 index 00000000..18a25b7e --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowHistoryDTO.java @@ -0,0 +1,20 @@ +package site.icebang.domain.workflow.dto; + +import java.math.BigInteger; +import java.time.LocalDateTime; + +import lombok.Data; + +@Data +public class WorkflowHistoryDTO { + + private BigInteger id; + private BigInteger workflowId; + private String traceId; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private BigInteger createdBy; + private String triggerType; + private String runNumber; + private String status; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDetailResponse.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDetailResponse.java new file mode 100644 index 00000000..194e8583 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDetailResponse.java @@ -0,0 +1,18 @@ +package site.icebang.domain.workflow.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkflowRunDetailResponse { + private String traceId; + private WorkflowRunDto workflowRun; + private List jobRuns; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java new file mode 100644 index 00000000..20b8ecd2 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java @@ -0,0 +1,25 @@ +package site.icebang.domain.workflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkflowRunDto { + private Long id; // workflow_run.id (실행 ID) + private Long workflowId; // workflow.id (설계 ID) + private String workflowName; + private String workflowDescription; + private String runNumber; + private String status; + private String triggerType; + private String startedAt; + private String finishedAt; + private Integer durationMs; + private Long createdBy; + private String createdAt; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunLogsResponse.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunLogsResponse.java new file mode 100644 index 00000000..ff5304f5 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunLogsResponse.java @@ -0,0 +1,17 @@ +package site.icebang.domain.workflow.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkflowRunLogsResponse { + private String traceId; + private List logs; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java new file mode 100644 index 00000000..d22e2a68 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowHistoryMapper.java @@ -0,0 +1,62 @@ +package site.icebang.domain.workflow.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.common.dto.PageParams; +import site.icebang.domain.workflow.dto.JobRunDto; +import site.icebang.domain.workflow.dto.TaskRunDto; +import site.icebang.domain.workflow.dto.WorkflowHistoryDTO; +import site.icebang.domain.workflow.dto.WorkflowRunDto; + +@Mapper +public interface WorkflowHistoryMapper { + /** + * 워크플로우 실행 정보 조회 + * + * @param runId workflow_run.id + * @return WorkflowRunDto + */ + WorkflowRunDto selectWorkflowRun(Long runId); + + /** + * 워크플로우 실행의 Job 목록 조회 + * + * @param workflowRunId workflow_run.id + * @return List + */ + List selectJobRunsByWorkflowRunId(Long workflowRunId); + + /** + * Job 실행의 Task 목록 조회 + * + * @param jobRunId job_run.id + * @return List + */ + List selectTaskRunsByJobRunId(Long jobRunId); + + /** + * 워크플로우 실행 TraceId 조회 + * + * @param runId workflow_run.id + * @return String traceId + */ + String selectTraceIdByRunId(Long runId); + + /** + * 워크플로우 런 페이지네이션 + * + * @param pageParams pageParams + * @return List + */ + List selectWorkflowHistoryList(PageParams pageParams); + + /** + * 워크플로우 런 인스턴스 개수 조회 + * + * @param pageParams pageParams + * @return 결과 개수 + */ + int selectWorkflowHistoryCount(PageParams pageParams); +} 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 94613f64..1bf9561f 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 @@ -50,9 +50,10 @@ public ObjectNode build(Task task, Map workflowContext) { .ifPresent(tagsNode -> body.set("post_tags", tagsNode)); }); - body.put("tag", "tistory"); + body.put("tag", "TISTORY_BLOG"); + body.put("blog_name", "hoons2641"); body.put("blog_id", "fair_05@nate.com"); - body.put("blog_pw", "kdyn2641*"); + body.put("blog_pw", "kdyn26*"); 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 ad22a58d..419a23a4 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 @@ -20,7 +20,7 @@ public class BlogRagBodyBuilder implements TaskBodyBuilder { private final ObjectMapper objectMapper; private static final String TASK_NAME = "블로그 RAG 생성 태스크"; private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; - private static final String CRAWL_SOURCE_TASK = "상품 정보 크롤링 태스크"; + private static final String S3_UPLOAD_SOURCE_TASK = "S3 업로드 태스크"; // 변경: 크롤링 → S3 업로드 @Override public boolean supports(String taskName) { @@ -36,9 +36,14 @@ public ObjectNode build(Task task, Map workflowContext) { .map(node -> node.path("data").path("keyword")) .ifPresent(keywordNode -> body.set("keyword", keywordNode)); - // 크롤링된 상품 정보 가져오기 - Optional.ofNullable(workflowContext.get(CRAWL_SOURCE_TASK)) - .map(node -> node.path("data").path("product_detail")) + // S3 업로드에서 선택된 상품 정보 가져오기 (변경된 부분) + Optional.ofNullable(workflowContext.get(S3_UPLOAD_SOURCE_TASK)) + .map( + node -> + node.path("data") + .path("selected_product_for_content") + .path("product_info") + .path("product_detail")) .ifPresent(productNode -> body.set("product_info", productNode)); // 기본 콘텐츠 설정 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 138e95d0..4c90e31a 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 @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; @@ -30,11 +31,27 @@ public boolean supports(String taskName) { public ObjectNode build(Task task, Map workflowContext) { ObjectNode body = objectMapper.createObjectNode(); - // 유사도 분석에서 선택된 상품의 URL 가져오기 + // ArrayNode 준비 (product_urls 배열로 변경) + ArrayNode productUrls = objectMapper.createArrayNode(); + + // 유사도 분석에서 선택된 상품들의 URL 가져오기 (복수로 변경) Optional.ofNullable(workflowContext.get(SIMILARITY_SOURCE_TASK)) - .map(node -> node.path("data").path("selected_product").path("url")) - .filter(urlNode -> !urlNode.isMissingNode() && !urlNode.asText().isEmpty()) - .ifPresent(urlNode -> body.set("product_url", urlNode)); + .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()); + } + }); + } + }); + + body.set("product_urls", productUrls); return body; } 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 new file mode 100644 index 00000000..bd0f823e --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/fastapi/body/S3UploadBodyBuilder.java @@ -0,0 +1,49 @@ +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.Task; + +@Component +@RequiredArgsConstructor +public class S3UploadBodyBuilder implements TaskBodyBuilder { + + private final ObjectMapper objectMapper; + private static final String TASK_NAME = "S3 업로드 태스크"; + private static final String KEYWORD_SOURCE_TASK = "키워드 검색 태스크"; + private static final String CRAWL_SOURCE_TASK = "상품 정보 크롤링 태스크"; + + @Override + public boolean supports(String taskName) { + return TASK_NAME.equals(taskName); + } + + @Override + public ObjectNode build(Task task, Map workflowContext) { + ObjectNode body = objectMapper.createObjectNode(); + + // 키워드 정보 가져오기 + Optional.ofNullable(workflowContext.get(KEYWORD_SOURCE_TASK)) + .map(node -> node.path("data").path("keyword")) + .ifPresent(keywordNode -> body.set("keyword", keywordNode)); + + // 크롤링된 상품 데이터 가져오기 + Optional.ofNullable(workflowContext.get(CRAWL_SOURCE_TASK)) + .map(node -> node.path("data").path("crawled_products")) + .ifPresent(crawledProductsNode -> body.set("crawled_products", crawledProductsNode)); + + // 기본 폴더 설정 + body.put("base_folder", "product"); + + return body; + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java new file mode 100644 index 00000000..d04e238f --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowHistoryService.java @@ -0,0 +1,92 @@ +package site.icebang.domain.workflow.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import site.icebang.common.dto.PageParams; +import site.icebang.common.dto.PageResult; +import site.icebang.common.service.PageableService; +import site.icebang.domain.workflow.dto.*; +import site.icebang.domain.workflow.mapper.WorkflowHistoryMapper; + +@Service +@RequiredArgsConstructor +public class WorkflowHistoryService implements PageableService { + private final WorkflowHistoryMapper workflowHistoryMapper; + + /** + * 워크플로우 런 조회 + * + * @param pageParams pageParams + * @return PageResult + */ + @Override + @Transactional(readOnly = true) + public PageResult getPagedResult(PageParams pageParams) { + + return PageResult.from( + pageParams, + () -> workflowHistoryMapper.selectWorkflowHistoryList(pageParams), + () -> workflowHistoryMapper.selectWorkflowHistoryCount(pageParams)); + } + + /** + * 워크플로우 실행 상세 조회 + * + * @param runId workflow_run.id + * @return WorkflowRunDetailResponse + */ + @Transactional(readOnly = true) + public WorkflowRunDetailResponse getWorkflowRunDetail(Long runId) { + // 1. 워크플로우 실행 정보 조회 + WorkflowRunDto workflowRunDto = workflowHistoryMapper.selectWorkflowRun(runId); + + // 2. Job 실행 목록 조회 + List jobRunDtos = workflowHistoryMapper.selectJobRunsByWorkflowRunId(runId); + + // 3. 각 Job의 Task 실행 목록 조회 + if (jobRunDtos != null) { + jobRunDtos.forEach( + jobRun -> { + List taskRuns = + workflowHistoryMapper.selectTaskRunsByJobRunId(jobRun.getId()); + jobRun.setTaskRuns(taskRuns); + }); + } + + // 4. TraceId 조회 + String traceId = workflowHistoryMapper.selectTraceIdByRunId(runId); + + return WorkflowRunDetailResponse.builder() + .workflowRun(workflowRunDto) + .jobRuns(jobRunDtos) + .traceId(traceId) + .build(); + } + + /** + * 워크플로우 실행 로그 조회 + * + * @param runId workflow_run.id + * @return WorkflowRunLogsResponse + */ + public WorkflowRunLogsResponse getWorkflowRunLogs(Long runId) { + // TODO: 구현 예정 + return null; + } + + /** + * TraceId로 워크플로우 실행 조회 + * + * @param traceId workflow_run.trace_id + * @return WorkflowRunDetailResponse + */ + public WorkflowRunDetailResponse getWorkflowRunByTraceId(String traceId) { + // TODO: 구현 예정 + return null; + } +} diff --git a/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java index 8b5dd63c..3543a8dd 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/security/SecurityConfig.java @@ -78,6 +78,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/v0/workflows/**") .permitAll() + .requestMatchers("/api/v1/email/**") // 이메일 API 경로 허용 + .permitAll() .requestMatchers("/v0/auth/check-session") .authenticated() .requestMatchers(SecurityEndpoints.DATA_ADMIN.getMatchers()) diff --git a/apps/user-service/src/main/resources/application-production.yml b/apps/user-service/src/main/resources/application-production.yml index 032954ad..c3645e13 100644 --- a/apps/user-service/src/main/resources/application-production.yml +++ b/apps/user-service/src/main/resources/application-production.yml @@ -17,6 +17,22 @@ spring: minimum-idle: 5 pool-name: HikariCP-MyBatis + # Gmail 연동 설정 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + debug: true # quartz: # jdbc: # initialize-schema: never diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml new file mode 100644 index 00000000..290f53e4 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/03-insert-workflow.sql b/apps/user-service/src/main/resources/sql/03-insert-workflow.sql index 9b6db4c0..fc9f39c2 100644 --- a/apps/user-service/src/main/resources/sql/03-insert-workflow.sql +++ b/apps/user-service/src/main/resources/sql/03-insert-workflow.sql @@ -51,11 +51,20 @@ INSERT INTO `task` (`id`, `name`, `type`, `parameters`) VALUES )), (5, '상품 정보 크롤링 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/products/crawl', 'method', 'POST', - 'body', JSON_OBJECT('product_url', 'String') -- { "product_url": str } + 'body', JSON_OBJECT('product_urls', 'List') -- { "product_urls": List[str] } 수정됨 )), + -- 🆕 S3 업로드 태스크 추가 + (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' + ) + )), -- RAG관련 request body는 추후에 결정될 예정 - (6, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT('endpoint', '/blogs/rag/create', 'method', 'POST')), - (7, '블로그 발행 태스크', 'FastAPI', JSON_OBJECT( + (7, '블로그 RAG 생성 태스크', 'FastAPI', JSON_OBJECT('endpoint', '/blogs/rag/create', 'method', 'POST')), + (8, '블로그 발행 태스크', 'FastAPI', JSON_OBJECT( 'endpoint', '/blogs/publish', 'method', 'POST', 'body', JSON_OBJECT( -- { tag: str, blog_id: str, ... } 'tag', 'String', @@ -80,8 +89,9 @@ INSERT INTO `workflow_job` (`workflow_id`, `job_id`, `execution_order`) VALUES -- Job-Task 연결 INSERT INTO `job_task` (`job_id`, `task_id`, `execution_order`) VALUES - (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), - (2, 6, 1), (2, 7, 2) + -- Job 1: 상품 분석 (키워드검색 → 상품검색 → 매칭 → 유사도 → 크롤링 → S3업로드) + (1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5), (1, 6, 6), + (2, 7, 1), (2, 8, 2) ON DUPLICATE KEY UPDATE execution_order = VALUES(execution_order); -- 스케줄 설정 (매일 오전 8시) diff --git a/apps/user-service/src/main/resources/sql/04-insert-workflow-history.sql b/apps/user-service/src/main/resources/sql/04-insert-workflow-history.sql new file mode 100644 index 00000000..814c3b5b --- /dev/null +++ b/apps/user-service/src/main/resources/sql/04-insert-workflow-history.sql @@ -0,0 +1,76 @@ +-- =================================================================== +-- 워크플로우 히스토리 테스트용 데이터 삽입 +-- =================================================================== + +-- 기존 실행 데이터 삭제 (참조 순서 고려) +DELETE FROM `task_run` WHERE id = 1; +DELETE FROM `job_run` WHERE id = 1; +DELETE FROM `workflow_run` WHERE id = 1; + +-- AUTO_INCREMENT 초기화 +ALTER TABLE `task_run` AUTO_INCREMENT = 1; +ALTER TABLE `job_run` AUTO_INCREMENT = 1; +ALTER TABLE `workflow_run` AUTO_INCREMENT = 1; + +-- 워크플로우 실행 데이터 삽입 (workflow_run) +INSERT INTO `workflow_run` ( + `workflow_id`, + `trace_id`, + `run_number`, + `status`, + `trigger_type`, + `started_at`, + `finished_at`, + `created_by` +) VALUES ( + 1, + '3e3c832d-b51f-48ea-95f9-98f0ae6d3413', + NULL, + 'FAILED', + NULL, + '2025-09-22 18:18:43', + '2025-09-22 18:18:44', + NULL + ); + +-- Job 실행 데이터 삽입 (job_run) +INSERT INTO `job_run` ( + `id`, + `workflow_run_id`, + `job_id`, + `status`, + `execution_order`, + `started_at`, + `finished_at`, + `created_at` +) VALUES ( + 1, + 1, + 1, + 'FAILED', + NULL, + '2025-09-22 18:18:44', + '2025-09-22 18:18:44', + NOW() + ); + +-- Task 실행 데이터 삽입 (task_run) +INSERT INTO `task_run` ( + `id`, + `job_run_id`, + `task_id`, + `status`, + `execution_order`, + `started_at`, + `finished_at`, + `created_at` +) VALUES ( + 1, + 1, + 1, + 'FAILED', + NULL, + '2025-09-22 18:18:44', + '2025-09-22 18:18:44', + NOW() + ); \ No newline at end of file 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 new file mode 100644 index 00000000..4703e9f6 --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java @@ -0,0 +1,228 @@ +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.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.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/01-insert-internal-users.sql", + "classpath:sql/03-insert-workflow.sql", + "classpath:sql/04-insert-workflow-history.sql" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Transactional +public class WorkflowHistoryApiIntegrationTest extends IntegrationTestSupport { + @Test + @DisplayName("워크플로우 실행 상세 조회 성공") + @WithUserDetails("admin@icebang.site") + void getWorkflowRunDetail_success() throws Exception { + // given + Long runId = 1L; + + // when & then + 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)) + .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-22 18:18:43")) + .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.workflowRun.durationMs").value(1000)) + .andExpect(jsonPath("$.data.workflowRun.createdBy").isEmpty()) + .andExpect(jsonPath("$.data.workflowRun.createdAt").exists()) + // 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-22 18:18:44")) + .andExpect(jsonPath("$.data.jobRuns[0].finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.jobRuns[0].durationMs").value(0)) + // 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-22 18:18:44")) + .andExpect( + jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].durationMs").value(0)) + .andDo( + document( + "workflow-run-detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Workflow History") + .summary("워크플로우 실행 상세 조회") + .description("워크플로우 실행 ID로 상세 정보를 조회합니다") + .responseFields( + fieldWithPath("success") + .type(JsonFieldType.BOOLEAN) + .description("요청 성공 여부"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.traceId") + .type(JsonFieldType.STRING) + .description("워크플로우 실행 추적 ID"), + fieldWithPath("data.workflowRun") + .type(JsonFieldType.OBJECT) + .description("워크플로우 실행 정보"), + fieldWithPath("data.workflowRun.id") + .type(JsonFieldType.NUMBER) + .description("워크플로우 실행 ID"), + fieldWithPath("data.workflowRun.workflowId") + .type(JsonFieldType.NUMBER) + .description("워크플로우 설계 ID"), + fieldWithPath("data.workflowRun.workflowName") + .type(JsonFieldType.STRING) + .description("워크플로우 이름"), + fieldWithPath("data.workflowRun.workflowDescription") + .type(JsonFieldType.STRING) + .description("워크플로우 설명"), + fieldWithPath("data.workflowRun.runNumber") + .type(JsonFieldType.NULL) + .description("실행 번호"), + fieldWithPath("data.workflowRun.status") + .type(JsonFieldType.STRING) + .description("실행 상태"), + fieldWithPath("data.workflowRun.triggerType") + .type(JsonFieldType.NULL) + .description("트리거 유형"), + fieldWithPath("data.workflowRun.startedAt") + .type(JsonFieldType.STRING) + .description("시작 시간"), + fieldWithPath("data.workflowRun.finishedAt") + .type(JsonFieldType.STRING) + .description("완료 시간"), + fieldWithPath("data.workflowRun.durationMs") + .type(JsonFieldType.NUMBER) + .description("실행 시간(ms)"), + fieldWithPath("data.workflowRun.createdBy") + .type(JsonFieldType.NULL) + .description("생성자 ID"), + fieldWithPath("data.workflowRun.createdAt") + .type(JsonFieldType.STRING) + .description("생성 시간"), + fieldWithPath("data.jobRuns") + .type(JsonFieldType.ARRAY) + .description("Job 실행 목록"), + fieldWithPath("data.jobRuns[].id") + .type(JsonFieldType.NUMBER) + .description("Job 실행 ID"), + fieldWithPath("data.jobRuns[].workflowRunId") + .type(JsonFieldType.NUMBER) + .description("워크플로우 실행 ID"), + fieldWithPath("data.jobRuns[].jobId") + .type(JsonFieldType.NUMBER) + .description("Job 설계 ID"), + fieldWithPath("data.jobRuns[].jobName") + .type(JsonFieldType.STRING) + .description("Job 이름"), + fieldWithPath("data.jobRuns[].jobDescription") + .type(JsonFieldType.STRING) + .description("Job 설명"), + fieldWithPath("data.jobRuns[].status") + .type(JsonFieldType.STRING) + .description("Job 실행 상태"), + fieldWithPath("data.jobRuns[].executionOrder") + .type(JsonFieldType.NULL) + .description("실행 순서"), + fieldWithPath("data.jobRuns[].startedAt") + .type(JsonFieldType.STRING) + .description("Job 시작 시간"), + fieldWithPath("data.jobRuns[].finishedAt") + .type(JsonFieldType.STRING) + .description("Job 완료 시간"), + fieldWithPath("data.jobRuns[].durationMs") + .type(JsonFieldType.NUMBER) + .description("Job 실행 시간(ms)"), + fieldWithPath("data.jobRuns[].taskRuns") + .type(JsonFieldType.ARRAY) + .description("Task 실행 목록"), + fieldWithPath("data.jobRuns[].taskRuns[].id") + .type(JsonFieldType.NUMBER) + .description("Task 실행 ID"), + fieldWithPath("data.jobRuns[].taskRuns[].jobRunId") + .type(JsonFieldType.NUMBER) + .description("Job 실행 ID"), + fieldWithPath("data.jobRuns[].taskRuns[].taskId") + .type(JsonFieldType.NUMBER) + .description("Task 설계 ID"), + fieldWithPath("data.jobRuns[].taskRuns[].taskName") + .type(JsonFieldType.STRING) + .description("Task 이름"), + fieldWithPath("data.jobRuns[].taskRuns[].taskDescription") + .type(JsonFieldType.NULL) + .description("Task 설명"), + fieldWithPath("data.jobRuns[].taskRuns[].taskType") + .type(JsonFieldType.STRING) + .description("Task 유형"), + fieldWithPath("data.jobRuns[].taskRuns[].status") + .type(JsonFieldType.STRING) + .description("Task 실행 상태"), + fieldWithPath("data.jobRuns[].taskRuns[].executionOrder") + .type(JsonFieldType.NULL) + .description("Task 실행 순서"), + fieldWithPath("data.jobRuns[].taskRuns[].startedAt") + .type(JsonFieldType.STRING) + .description("Task 시작 시간"), + fieldWithPath("data.jobRuns[].taskRuns[].finishedAt") + .type(JsonFieldType.STRING) + .description("Task 완료 시간"), + fieldWithPath("data.jobRuns[].taskRuns[].durationMs") + .type(JsonFieldType.NUMBER) + .description("Task 실행 시간(ms)"), + fieldWithPath("message") + .type(JsonFieldType.STRING) + .description("응답 메시지"), + fieldWithPath("status") + .type(JsonFieldType.STRING) + .description("HTTP 상태")) + .build()))); + } +}