diff --git a/springboot/build.gradle b/springboot/build.gradle index baa394c3..89a05b29 100644 --- a/springboot/build.gradle +++ b/springboot/build.gradle @@ -51,10 +51,10 @@ dependencies { // Oracle JDBC 드라이버 implementation 'com.oracle.database.jdbc:ojdbc8:21.1.0.0' - + // MyBatis implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' - + // dotenv-java for .env file support implementation 'io.github.cdimascio:dotenv-java:3.0.0' } diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/dto/request/ScheduleTasksRequestDTO.java b/springboot/src/main/java/com/softlabs/aicontents/common/dto/request/ScheduleTasksRequestDTO.java new file mode 100644 index 00000000..62e8ec8f --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/common/dto/request/ScheduleTasksRequestDTO.java @@ -0,0 +1,46 @@ +package com.softlabs.aicontents.common.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + + +// 공통 응답 DTO +// FE -> BE(DTO) 대시보드 중 "스케줄 관리" 카드 +// 단순 객체 전달용 +// 용도 및 의미만 맞추고 XML에는 alias 적용 + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "ScheduleTasksRequestDTO - 스케줄 작업 요청 객체") +public class ScheduleTasksRequestDTO { + + + @Schema(description = "스케줄러 명칭", example = "Untitled Schedule", requiredMode = Schema.RequiredMode.REQUIRED) + private String taskName; + + @Schema(description = "크론 표현식", example = "0 8 * * *") + private String cronExpression ; + + @Schema(description = "실행 주기", example="08:00") + private String executionCycle; // "매일 실행/ 주간 실행/ 월간 실행" + + @Schema(description ="실행 시간", example="08:00") + private String executionTime ; // "HH:MM" 자동 실행 시간 + + @Schema(description ="키워드 추출 개수" , example="50") + private int keywordCount ; // 추출 키워드 수량 + + @Schema(description ="블로그 발행 개수", example="1") + private int publishCount; // 블로그 발행 수량 + + @Schema(description ="AI 모델명", example="OpenAI GPT-4") + private String aiModel ; // AI 모델명 (예: "OpenAI GPT-4") + + +} + diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ApiResponseDTO.java b/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ApiResponseDTO.java index bb11d074..08ff7703 100644 --- a/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ApiResponseDTO.java +++ b/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ApiResponseDTO.java @@ -1,7 +1,12 @@ package com.softlabs.aicontents.common.dto.response; // 공통 응답 DTO -public record ApiResponseDTO(boolean success, T data, String message) { +public record ApiResponseDTO( + boolean success, + T data, + String message) + +{ // 성공 응답 생성 public static ApiResponseDTO success(T data) { return new ApiResponseDTO<>(true, data, null); diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ErrorResponseDTO.java b/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ErrorResponseDTO.java new file mode 100644 index 00000000..d13ddea5 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ErrorResponseDTO.java @@ -0,0 +1,39 @@ +package com.softlabs.aicontents.common.dto.response; + +import com.softlabs.aicontents.common.enums.ErrorCode; +import com.softlabs.aicontents.common.util.TraceIdUtil; +import java.time.LocalDateTime; + +public record ErrorResponseDTO( + LocalDateTime timestamp, String code, String message, String traceId, String path, int status) { + + public static ErrorResponseDTO of(ErrorCode errorCode, String path) { + return new ErrorResponseDTO( + LocalDateTime.now(), + errorCode.getCode(), + errorCode.getMessage(), + TraceIdUtil.getOrCreateTraceId(), + path, + errorCode.getStatus()); + } + + public static ErrorResponseDTO of(ErrorCode errorCode, String path, String customMessage) { + return new ErrorResponseDTO( + LocalDateTime.now(), + errorCode.getCode(), + customMessage, + TraceIdUtil.getOrCreateTraceId(), + path, + errorCode.getStatus()); + } + + public static ErrorResponseDTO ofWithTraceId(ErrorCode errorCode, String path, String traceId) { + return new ErrorResponseDTO( + LocalDateTime.now(), + errorCode.getCode(), + errorCode.getMessage(), + traceId, + path, + errorCode.getStatus()); + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ScheduleTasksResponseDTO.java b/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ScheduleTasksResponseDTO.java new file mode 100644 index 00000000..1596b5e4 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/common/dto/response/ScheduleTasksResponseDTO.java @@ -0,0 +1,10 @@ +package com.softlabs.aicontents.common.dto.response; + +import lombok.Data; + +@Data +public class ScheduleTasksResponseDTO { + + private int taskId; + +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/enums/ErrorCode.java b/springboot/src/main/java/com/softlabs/aicontents/common/enums/ErrorCode.java new file mode 100644 index 00000000..3e22d286 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/common/enums/ErrorCode.java @@ -0,0 +1,60 @@ +package com.softlabs.aicontents.common.enums; + +import org.springframework.http.HttpStatus; + +public enum ErrorCode { + + // 400번대 클라이언트 에러 + BAD_REQUEST(HttpStatus.BAD_REQUEST, "E001", "잘못된 요청입니다."), + INVALID_INPUT(HttpStatus.BAD_REQUEST, "E002", "입력값이 올바르지 않습니다."), + MISSING_REQUIRED_FIELD(HttpStatus.BAD_REQUEST, "E003", "필수 필드가 누락되었습니다."), + INVALID_FORMAT(HttpStatus.BAD_REQUEST, "E004", "올바르지 않은 형식입니다."), + + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E101", "인증이 필요합니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "E102", "유효하지 않은 토큰입니다."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "E103", "토큰이 만료되었습니다."), + + FORBIDDEN(HttpStatus.FORBIDDEN, "E201", "접근 권한이 없습니다."), + INSUFFICIENT_PERMISSIONS(HttpStatus.FORBIDDEN, "E202", "권한이 부족합니다."), + + NOT_FOUND(HttpStatus.NOT_FOUND, "E301", "요청한 리소스를 찾을 수 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "E302", "사용자를 찾을 수 없습니다."), + DATA_NOT_FOUND(HttpStatus.NOT_FOUND, "E303", "데이터를 찾을 수 없습니다."), + + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "E401", "허용되지 않은 HTTP 메서드입니다."), + + CONFLICT(HttpStatus.CONFLICT, "E501", "데이터 충돌이 발생했습니다."), + DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "E502", "이미 존재하는 리소스입니다."), + + // 500번대 서버 에러 + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E901", "내부 서버 오류가 발생했습니다."), + DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E902", "데이터베이스 오류가 발생했습니다."), + EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E903", "외부 API 호출 중 오류가 발생했습니다."), + SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "E904", "서비스를 사용할 수 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + ErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public int getStatus() { + return httpStatus.value(); + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/exception/BusinessException.java b/springboot/src/main/java/com/softlabs/aicontents/common/exception/BusinessException.java new file mode 100644 index 00000000..e3791864 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/common/exception/BusinessException.java @@ -0,0 +1,67 @@ +package com.softlabs.aicontents.common.exception; + +import com.softlabs.aicontents.common.enums.ErrorCode; + +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + private final String customMessage; + + // 기본 생성자 (ErrorCode만 사용) + // 사용 예시 throw new BusinessException(ErrorCode.USER_NOT_FOUND); + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.customMessage = null; + } + + // 커스텀 메시지 사용 가능 + // 사용 예시 throw new BusinessException(ErrorCode.INVALID_INPUT, "이메일 형식이 잘못되었습니다."); + public BusinessException(ErrorCode errorCode, String customMessage) { + super(customMessage); + this.errorCode = errorCode; + this.customMessage = customMessage; + } + + // 원인 예외(cause) 전달 가능 + // 사용 예시 + // try { + // externalApi.call(); + // } catch (IOException e) { + // throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, e); + // } + public BusinessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + this.customMessage = null; + } + + // 커스텀 메시지 + 원인 예외 둘 다 사용 + // 사용 예시 + // try { + // paymentGateway.call(); + // } catch (Exception e) { + // throw new BusinessException( + // ErrorCode.EXTERNAL_API_ERROR, + // "결제 서비스 호출 중 오류 발생", + // e + // ); + // } + public BusinessException(ErrorCode errorCode, String customMessage, Throwable cause) { + super(customMessage, cause); + this.errorCode = errorCode; + this.customMessage = customMessage; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public String getCustomMessage() { + return customMessage; + } + + public String getEffectiveMessage() { + return customMessage != null ? customMessage : errorCode.getMessage(); + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/common/exception/GlobalExceptionHandler.java b/springboot/src/main/java/com/softlabs/aicontents/common/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..c73833f0 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,161 @@ +package com.softlabs.aicontents.common.exception; + +import com.softlabs.aicontents.common.dto.response.ErrorResponseDTO; +import com.softlabs.aicontents.common.enums.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + // BusinessException에 정의된 예외 처리 + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException( + BusinessException ex, HttpServletRequest request) { + + log.warn("Business exception occurred: {}", ex.getMessage(), ex); + + ErrorCode errorCode = ex.getErrorCode(); + ErrorResponseDTO errorResponse = + ErrorResponseDTO.of(errorCode, request.getRequestURI(), ex.getEffectiveMessage()); + + return ResponseEntity.status(errorCode.getHttpStatus()).body(errorResponse); + } + + // @Valid나 @Validated 검증 실패 시 발생하는 예외 처리 ex)이메일 형식이 잘못되었을 때, 비밀번호 최소 몇 자 이상 등 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException( + MethodArgumentNotValidException ex, HttpServletRequest request) { + + log.warn("Validation exception occurred: {}", ex.getMessage()); + + String errorMessage = + ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + ErrorResponseDTO errorResponse = + ErrorResponseDTO.of( + ErrorCode.INVALID_INPUT, request.getRequestURI(), "입력값 검증 실패: " + errorMessage); + + return ResponseEntity.status(ErrorCode.INVALID_INPUT.getHttpStatus()).body(errorResponse); + } + + // 요청 데이터를 객체로 바인딩할 때 타입 불일치나 값 변환 실패가 발생하면 BindException이 발생. ex)검색어 누락 -> 검색어는 필수입니다. + @ExceptionHandler(BindException.class) + public ResponseEntity handleBindException( + BindException ex, HttpServletRequest request) { + + log.warn("Bind exception occurred: {}", ex.getMessage()); + + String errorMessage = + ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + ErrorResponseDTO errorResponse = + ErrorResponseDTO.of( + ErrorCode.INVALID_INPUT, request.getRequestURI(), "바인딩 오류: " + errorMessage); + + return ResponseEntity.status(ErrorCode.INVALID_INPUT.getHttpStatus()).body(errorResponse); + } + + // 필수 쿼리 파라미터가 요청에서 빠졌을 때 발생. ex) id 파라미터 누락 + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParameterException( + MissingServletRequestParameterException ex, HttpServletRequest request) { + + log.warn("Missing parameter exception occurred: {}", ex.getMessage()); + + ErrorResponseDTO errorResponse = + ErrorResponseDTO.of( + ErrorCode.MISSING_REQUIRED_FIELD, + request.getRequestURI(), + "필수 파라미터 누락: " + ex.getParameterName()); + + return ResponseEntity.status(ErrorCode.MISSING_REQUIRED_FIELD.getHttpStatus()) + .body(errorResponse); + } + + // 요청 파라미터 또는 경로 변수의 타입 변환 실패 시 발생. ex)id가 Long이어야 하는데 "abc" 전달 + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatchException( + MethodArgumentTypeMismatchException ex, HttpServletRequest request) { + + log.warn("Type mismatch exception occurred: {}", ex.getMessage()); + + ErrorResponseDTO errorResponse = + ErrorResponseDTO.of( + ErrorCode.INVALID_FORMAT, request.getRequestURI(), "잘못된 파라미터 타입: " + ex.getName()); + + return ResponseEntity.status(ErrorCode.INVALID_FORMAT.getHttpStatus()).body(errorResponse); + } + + // 요청 바디(JSON 등)를 Spring이 읽거나 파싱할 수 없을 때 발생. ex) JSON 문법 오류, 잘못된 타입, 비어있는 본문 등. + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex, HttpServletRequest request) { + + log.warn("Http message not readable exception occurred: {}", ex.getMessage()); + + ErrorResponseDTO errorResponse = + ErrorResponseDTO.of(ErrorCode.INVALID_FORMAT, request.getRequestURI(), "요청 본문을 읽을 수 없습니다."); + + return ResponseEntity.status(ErrorCode.INVALID_FORMAT.getHttpStatus()).body(errorResponse); + } + + // 지원하지 않는 HTTP 메서드를 호출할 때 발생. ex)GET 요청을 보냈지만 컨트롤러는 POST만 지원 + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupportedException( + HttpRequestMethodNotSupportedException ex, HttpServletRequest request) { + + log.warn("Method not supported exception occurred: {}", ex.getMessage()); + + ErrorResponseDTO errorResponse = + ErrorResponseDTO.of(ErrorCode.METHOD_NOT_ALLOWED, request.getRequestURI()); + + return ResponseEntity.status(ErrorCode.METHOD_NOT_ALLOWED.getHttpStatus()).body(errorResponse); + } + + // 매핑된 컨트롤러(핸들러)가 없는 잘못된 URL 호출 시 발생. + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNoHandlerFoundException( + NoHandlerFoundException ex, HttpServletRequest request) { + + log.warn("No handler found exception occurred: {}", ex.getMessage()); + + ErrorResponseDTO errorResponse = + ErrorResponseDTO.of(ErrorCode.NOT_FOUND, request.getRequestURI()); + + return ResponseEntity.status(ErrorCode.NOT_FOUND.getHttpStatus()).body(errorResponse); + } + + // 위에서 처리하지 못한 모든 예외를 처리하는 최후의 방어선. + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException( + Exception ex, HttpServletRequest request) { + + log.error("Unexpected exception occurred: {}", ex.getMessage(), ex); + + ErrorResponseDTO errorResponse = + ErrorResponseDTO.of(ErrorCode.INTERNAL_SERVER_ERROR, request.getRequestURI()); + + return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus()) + .body(errorResponse); + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/PipelineService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/PipelineService.java new file mode 100644 index 00000000..b2abeb33 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/PipelineService.java @@ -0,0 +1,78 @@ +package com.softlabs.aicontents.domain.orchestration; + +import com.softlabs.aicontents.domain.scheduler.dto.PipeResultDataDTO; +import com.softlabs.aicontents.domain.scheduler.dto.pipeLineDTO.StepExecutionResultDTO; +import com.softlabs.aicontents.domain.scheduler.service.executor.AIContentExecutor; +import com.softlabs.aicontents.domain.scheduler.service.executor.BlogPublishExecutor; +import com.softlabs.aicontents.domain.scheduler.service.executor.KeywordExecutor; +import com.softlabs.aicontents.domain.scheduler.service.executor.ProductCrawlingExecutor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@Component +public class PipelineService { + + // 🎯 실행 인터페이스들만 주입 + @Autowired private KeywordExecutor keywordExecutor; + + @Autowired private ProductCrawlingExecutor crawlingExecutor; + + @Autowired private AIContentExecutor aiExecutor; + + @Autowired private BlogPublishExecutor blogExecutor; + + public PipeResultDataDTO executionPipline() { + int executionId = createNewExecution(); + // todo : executionId = (동일한 파이프라인인지 구분하는 용도) + // DB 에서 PIPELINE_EXECUTIONS 테이블의 execution_id + + try { /// 파이프라인 전체 try-catch + // 각 단계를 순차적으로 실행 (실행과 검증이 포함되어 있음) + + // step01 - 키워드 추출 + StepExecutionResultDTO step01 = keywordExecutor.execute(executionId); + // todo : if 추출 실패 시 3회 재시도 및 예외처리 + // 예시 : + // if (!step1.isSuccess()) { + // throw new RuntimeException("1단계 실패: " + step1.getErrorMessage()); + + // step02 - 상품정보 & URL 추출 + StepExecutionResultDTO step02 = crawlingExecutor.execute(executionId); + // todo : if 추출 실패 시 3회 재시도 및 예외처리 + + // step03 - LLM 생성 + StepExecutionResultDTO step03 = aiExecutor.execute(executionId); + // todo : if 추출 실패 시 3회 재시도 및 예외처리 + + // step04 - 블로그 발행 + StepExecutionResultDTO step04 = blogExecutor.execute(executionId); + // todo : if 추출 실패 시 3회 재시도 및 예외처리 + + log.info("파이프라인 성공"); + + return new PipeResultDataDTO(); + + } catch (Exception e) { + log.error("파이프라인 실행 실패:{}", e.getMessage()); + updateExecutionStatus(executionId, "FAILED"); + } + return null; + } + + private int createNewExecution() { + + return 0; /// 일단은 Long타입의 기본값. + // todo: return 반환값으로, + // PIPELINE_EXECUTIONS 테이블에서 executionId를 새로 생성하고, + // 이것을 가져오는 메서드 구현 + // => 파이프라인이 새로 실행될 때마다 executionId를 생성 + } + + private void updateExecutionStatus(int executionId, String failed) { + // todo: PIPELINE_EXECUTIONS에 상태 업데이트하는 코드 구현(SUCCESS, FAILED,PENDING 등등등) + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/enums/PipelineStep.java b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/enums/PipelineStep.java new file mode 100644 index 00000000..6b91eb3b --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/orchestration/enums/PipelineStep.java @@ -0,0 +1,18 @@ +package com.softlabs.aicontents.domain.orchestration.enums; + +public enum PipelineStep { + STEP_01("STEP_01", "구글 트렌드 키워드 수집", 1), + STEP_02("STEP_02", "싸다구몰 상품 수집", 2), + STEP_03("STEP_03", "AI 콘텐츠 생성", 3), + STEP_04("STEP_04", "네이버 블로그 업로드", 4); + + private final String code; + private final String description; + private final int order; + + PipelineStep(String code, String description, int order) { + this.code = code; + this.description = description; + this.order = order; + } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/controller/ScheduleEngineController.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/controller/ScheduleEngineController.java new file mode 100644 index 00000000..071ff99e --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/controller/ScheduleEngineController.java @@ -0,0 +1,51 @@ +package com.softlabs.aicontents.domain.scheduler.controller; + +import com.softlabs.aicontents.common.dto.request.ScheduleTasksRequestDTO; +import com.softlabs.aicontents.common.dto.response.ApiResponseDTO; +import com.softlabs.aicontents.common.dto.response.ScheduleTasksResponseDTO; +import com.softlabs.aicontents.domain.orchestration.PipelineService; +import com.softlabs.aicontents.domain.scheduler.service.ScheduleEngineService; +import io.swagger.v3.oas.annotations.Operation; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.*; + +@Component +@Slf4j +@EnableScheduling +@RestController +@RequestMapping("/v1") +public class ScheduleEngineController { + + private int executionCount = 0; + private final int MAX_executionCount = 3; + private boolean isCompleted = false; + + @Autowired + private PipelineService pipelineService; //파이프라인(=오케스트레이션) + @Autowired + private ScheduleEngineService scheduleEngineService; //스케줄 엔진 + + + + /// 08. 스케줄 생성 + @Operation(summary = "스케줄 생성 API",description = "생성할 스케줄의 상세 정보입니다.") + @PostMapping("/schedule") + public ApiResponseDTO setSchedule(@RequestBody ScheduleTasksRequestDTO scheduleTasksRequestDTO) { + + // 확인 메세지 + System.out.println("scheduleTasksRequestDTO를 전달받음.=>" + scheduleTasksRequestDTO.toString()); + + try { + ScheduleTasksResponseDTO scheduleTasksResponseDTO = scheduleEngineService.scheduleEngine(scheduleTasksRequestDTO); + + return ApiResponseDTO.success(scheduleTasksResponseDTO,"새로운 스케줄 저장 완료"); + } catch (Exception e) { + + return ApiResponseDTO.error("스케줄 저장 실패"+e.getMessage()); + } + } +} + diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/PipeResultDataDTO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/PipeResultDataDTO.java new file mode 100644 index 00000000..19efea4f --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/PipeResultDataDTO.java @@ -0,0 +1,32 @@ +package com.softlabs.aicontents.domain.scheduler.dto; + +import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.ExecutionResults; +import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.Logs; +import com.softlabs.aicontents.domain.scheduler.dto.resultDTO.ProgressResult; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema +public class PipeResultDataDTO { + + // 실행정보 + int executionId; + String overallStatus; + String startedAt; + String completedAt; + String currentStage; + + // 각 단계별 진행 상황 + ProgressResult progressResult; + + //단계별 결과 데이터 + ExecutionResults results; + + // 로그 정보 + List logs; + + } + diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/pipeLineDTO/PublishingStatus.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/pipeLineDTO/PublishingStatus.java deleted file mode 100644 index c7f96286..00000000 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/pipeLineDTO/PublishingStatus.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.softlabs.aicontents.domain.scheduler.dto.pipeLineDTO; - -import lombok.Data; - -@Data -public class PublishingStatus { - String platform; - String status; - String url; -} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/pipeLineDTO/StepExecutionResultDTO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/pipeLineDTO/StepExecutionResultDTO.java new file mode 100644 index 00000000..a0ffde01 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/pipeLineDTO/StepExecutionResultDTO.java @@ -0,0 +1,28 @@ +package com.softlabs.aicontents.domain.scheduler.dto.pipeLineDTO; + +import lombok.Getter; + +@Getter +public class StepExecutionResultDTO { + + private boolean success; + private String resultData; + private String errorMessage; + + // // 생성자를 private으로 막아서 외부에서 new로 생성하는 것을 방지 + // private StepExecutionResultDTO(boolean success, String resultData, String errorMessage) { + // this.success = success; + // this.resultData = resultData; + // this.errorMessage = errorMessage; + // } + // + // // 성공 결과를 생성하는 정적 메서드 + // public static StepExecutionResultDTO success(String resultData) { + // return new StepExecutionResultDTO(true, resultData, null); + // } + // + // // 실패 결과를 생성하는 정적 메서드 + // public static StepExecutionResultDTO failure(String errorMessage) { + // return new StepExecutionResultDTO(false, null, errorMessage); + // } +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Content.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Content.java new file mode 100644 index 00000000..5bf02ac4 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Content.java @@ -0,0 +1,15 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; + +import java.util.List; +import lombok.Data; + + + +@Data +public class Content { + String title; + String content; + List tags; + + +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ContentGeneration.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ContentGeneration.java new file mode 100644 index 00000000..5b1a28e9 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ContentGeneration.java @@ -0,0 +1,10 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; +import lombok.Data; + + + +@Data +public class ContentGeneration { + String status; + int progress; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ContentPublishing.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ContentPublishing.java new file mode 100644 index 00000000..0fb2974a --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ContentPublishing.java @@ -0,0 +1,10 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; +import lombok.Data; + + + +@Data +public class ContentPublishing { + String status; + int progress; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ExecutionResults.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ExecutionResults.java new file mode 100644 index 00000000..4262009f --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ExecutionResults.java @@ -0,0 +1,18 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; + + +import java.util.List; + +import lombok.Data; + + + +@Data +public class ExecutionResults { + List keywords; + List products; + Content content; + PublishingStatus publishingStatus; + + +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Keyword.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Keyword.java new file mode 100644 index 00000000..e1018b49 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Keyword.java @@ -0,0 +1,13 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; +import lombok.Data; + + + +@Data +public class Keyword { + + String keyword; + boolean selected; + int relevanceScore; + +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/KeywordExtraction.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/KeywordExtraction.java new file mode 100644 index 00000000..aeb49bbd --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/KeywordExtraction.java @@ -0,0 +1,11 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; +import lombok.Data; + + + +@Data +public class KeywordExtraction { + + String status; + int progress; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Logs.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Logs.java new file mode 100644 index 00000000..9bc0b95d --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Logs.java @@ -0,0 +1,16 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; + +import lombok.Data; + + + +@Data +public class Logs { + + String timestamp; + String stage; + String level; + String message; + + +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Product.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Product.java new file mode 100644 index 00000000..3aedd450 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/Product.java @@ -0,0 +1,14 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; +import lombok.Data; + + + +@Data +public class Product { + + String productId; + String name; + int price; + String platform; + +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ProductCrawling.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ProductCrawling.java new file mode 100644 index 00000000..921bb02c --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ProductCrawling.java @@ -0,0 +1,10 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; +import lombok.Data; + + + +@Data +public class ProductCrawling { + String status; + int progress; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ProgressResult.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ProgressResult.java new file mode 100644 index 00000000..8cd7d988 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/ProgressResult.java @@ -0,0 +1,13 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; +import lombok.Data; + + + +@Data +public class ProgressResult { + + KeywordExtraction keywordExtraction; + ProductCrawling productCrawling; + ContentGeneration contentGeneration; + ContentPublishing contentPublishing; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/PublishingStatus.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/PublishingStatus.java new file mode 100644 index 00000000..8d19a305 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/dto/resultDTO/PublishingStatus.java @@ -0,0 +1,13 @@ +package com.softlabs.aicontents.domain.scheduler.dto.resultDTO; +import lombok.Data; + + + +@Data +public class PublishingStatus { + + String platform; + String status; + String url; + +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/interfacePipe/PipelineStepExecutor.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/interfacePipe/PipelineStepExecutor.java new file mode 100644 index 00000000..670891e2 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/interfacePipe/PipelineStepExecutor.java @@ -0,0 +1,11 @@ +package com.softlabs.aicontents.domain.scheduler.interfacePipe; + +import com.softlabs.aicontents.domain.scheduler.dto.pipeLineDTO.StepExecutionResultDTO; + +public interface PipelineStepExecutor { + // 파이프라인 실행관련 공통 인터페이스 + + StepExecutionResultDTO execute(int executionId); + + +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/mapper/ScheduleEngineMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/mapper/ScheduleEngineMapper.java new file mode 100644 index 00000000..310497c9 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/mapper/ScheduleEngineMapper.java @@ -0,0 +1,13 @@ +package com.softlabs.aicontents.domain.scheduler.mapper; + +import com.softlabs.aicontents.domain.scheduler.vo.request.SchedulerRequestVO; +import com.softlabs.aicontents.domain.scheduler.vo.response.ScheduleResponseVO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ScheduleEngineMapper { + + int insertSchedule( SchedulerRequestVO schedulerRequestVO); + + ScheduleResponseVO selectScheduleEngines(); +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/ScheduleEngineService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/ScheduleEngineService.java new file mode 100644 index 00000000..85c63717 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/ScheduleEngineService.java @@ -0,0 +1,77 @@ +package com.softlabs.aicontents.domain.scheduler.service; + +import com.softlabs.aicontents.common.dto.request.ScheduleTasksRequestDTO; +import com.softlabs.aicontents.common.dto.response.ScheduleTasksResponseDTO; +import com.softlabs.aicontents.domain.scheduler.mapper.ScheduleEngineMapper; +import com.softlabs.aicontents.domain.scheduler.vo.request.SchedulerRequestVO; +import com.softlabs.aicontents.domain.scheduler.vo.response.ScheduleResponseVO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@Slf4j +public class ScheduleEngineService { + + @Autowired + private ScheduleEngineMapper scheduleEngineMapper; + + @Transactional + public ScheduleTasksResponseDTO scheduleEngine(ScheduleTasksRequestDTO scheduleTasksRequestDTO) { + + try { + // 스케줄 생성 및 taskId 반환 + int insertResult = createSchedule(scheduleTasksRequestDTO); + int taskId = selectSchedule().getTaskId(); + ScheduleTasksResponseDTO resDTO = new ScheduleTasksResponseDTO(); + resDTO.setTaskId(taskId); + + return resDTO; + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + + // DTO -> VO로 변환 + private SchedulerRequestVO convertDTOtoVO(ScheduleTasksRequestDTO scheduleTasksRequestDTO) { + + SchedulerRequestVO schedulerRequestVO = new SchedulerRequestVO(); + +// schedulerRequestVO.setTaskName(scheduleTasksRequestDTO.getTaskName()); +// schedulerRequestVO.setCronExpression(scheduleTasksRequestDTO.getCronExpression()); + schedulerRequestVO.setExecutionCycle(scheduleTasksRequestDTO.getExecutionCycle()); + schedulerRequestVO.setExecutionTime(scheduleTasksRequestDTO.getExecutionTime()); + schedulerRequestVO.setKeywordCount(scheduleTasksRequestDTO.getKeywordCount()); + schedulerRequestVO.setPublishCount(scheduleTasksRequestDTO.getPublishCount()); + schedulerRequestVO.setAiModel(scheduleTasksRequestDTO.getAiModel()); + + return schedulerRequestVO; + } + + // 스케줄 생성 + public int createSchedule(ScheduleTasksRequestDTO scheduleTasksRequestDTO) { + + SchedulerRequestVO schedulerRequestVO = this.convertDTOtoVO(scheduleTasksRequestDTO); + + int resultInsert = scheduleEngineMapper.insertSchedule(schedulerRequestVO); + + log.info("DB 저장 메퍼 실행 완료"); + + + return resultInsert; + } + + + // 스케줄 조회 + public ScheduleResponseVO selectSchedule() { + + ScheduleResponseVO resultSelect = scheduleEngineMapper.selectScheduleEngines(); + + return resultSelect; + } + +} \ No newline at end of file diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/AIContentExecutor.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/AIContentExecutor.java new file mode 100644 index 00000000..d6a05512 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/AIContentExecutor.java @@ -0,0 +1,97 @@ +package com.softlabs.aicontents.domain.scheduler.service.executor; + +import com.softlabs.aicontents.domain.scheduler.dto.pipeLineDTO.StepExecutionResultDTO; +import com.softlabs.aicontents.domain.scheduler.interfacePipe.PipelineStepExecutor; +// import com.softlabs.aicontents.domain.testMapper.AIContentMapper; +import com.softlabs.aicontents.domain.testDomainService.AIContentService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +@Component +@Slf4j +@Service +public class AIContentExecutor implements PipelineStepExecutor { + + @Autowired private AIContentService aiContentService; + + // todo: 실제 LLM생성 클래스로 변경 + + // @Autowired + // private AIContentMapper aiContentMapper; + // // todo: 실제 LLM생성 매퍼 인터페이스로 변경 + + @Override + public StepExecutionResultDTO execute(int executionId) { + + /// test : 파이프라인 동작 테스트 + System.out.println("LLM생성 메서드 호출/ 실행"); + delayWithDots(3); + + /// todo : 테스트용 RDS 조회 쿼리 + System.out.println("LLM생성 결과 DB에서 쿼리 조회"); + delayWithDots(3); + System.out.println("LLM생성 결과 DB 완료 확인 로직 실행"); + delayWithDots(3); + System.out.println("LLM생성 수집 상태 판단 -> 완료(success)"); + System.out.println("LLM생성 수집 상태 판단 -> 실패(failure)-> 재시도/예외처리"); + delayWithDots(3); + System.out.println("[스케줄러]가 [LLM] -> [발행] (요청)객체 전달"); + delayWithDots(3); + return null; + /// todo : 반환 값으로 이전 기능이 요구하는 파라메터를 반환하기. + } + + /// 테스트용 딜레이 메서드 + private void delayWithDots(int seconds) { + try { + for (int i = 0; i < seconds; i++) { + Thread.sleep(200); // 1초마다 + System.out.print("."); + } + System.out.println(); // 줄바꿈 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} + +// try { +// //키워드 수집 서비스 실행 +// log.info("LLM생성 메서스 시작"); +// aiContentService.extractAiContent(executionId); +// // todo: 실제 키워드 수집 서비스 의 추출 메서드 +// +// //DB 조회로 결과 확인 (30초 대기 적용) +// String keyword = waitForResult(executionId, 30); +// +// if (keyword != null) { +// log.info("✅ 트렌드 키워드 추출 완료: {}", keyword); +// return StepExecutionResultDTO.success(keyword); +// +// } else { +// return StepExecutionResultDTO.failure("트렌드 키워드 추출 시간 초과"); +// } +// +// } catch (Exception e) { +// log.error("트렌드 키워드 추출 실패", e); +// return StepExecutionResultDTO.failure(e.getMessage()); +// } +// } +// private String waitForResult(Long executionId, int timeoutSeconds) { +// for (int i = 0; i < timeoutSeconds; i++) { +// String keyword = aiContentMapper.findAicontentByExecutionId(executionId); +// if (keyword != null) { +// return keyword; +// } +// try { +// Thread.sleep(1000); +// } catch (InterruptedException e) { +// Thread.currentThread().interrupt(); +// break; +// } +// } +// return null; +// } +// } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/BlogPublishExecutor.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/BlogPublishExecutor.java new file mode 100644 index 00000000..76a506bf --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/BlogPublishExecutor.java @@ -0,0 +1,80 @@ +package com.softlabs.aicontents.domain.scheduler.service.executor; + +import com.softlabs.aicontents.domain.scheduler.dto.pipeLineDTO.StepExecutionResultDTO; +import com.softlabs.aicontents.domain.scheduler.interfacePipe.PipelineStepExecutor; +// import com.softlabs.aicontents.domain.testMapper.BlogPublishMapper; +import com.softlabs.aicontents.domain.testDomainService.BlogPublishService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +@Component +@Slf4j +@Service +public class BlogPublishExecutor implements PipelineStepExecutor { + @Autowired + private BlogPublishService blogPublishService; + + // todo: 실제 발행 클래스로 변경 + + // @Autowired + // private BlogPublishMapper blogPublishMapper; + // // todo: 실제 발행 매퍼 인터페이스로 변경 + + @Override + public StepExecutionResultDTO execute(int executionId) { + + /// test : 파이프라인 동작 테스트 + System.out.println("발행 메서드 호출/ 실행"); + + /// todo : 테스트용 RDS 조회 쿼리 + System.out.println("발행 결과 DB에서 쿼리 조회"); + System.out.println("발행 결과 DB 완료 확인 로직 실행"); + System.out.println("발행 상태 판단 -> 완료(success)"); + System.out.println("발행 상태 판단 -> 실패(failure)-> 재시도/예외처리"); + System.out.println("[발행] 완료"); + return null; + /// todo : 반환 값으로 이전 기능이 요구하는 파라메터를 반환하기. + } + +} + +// try { +// //키워드 수집 서비스 실행 +// log.info("LLM생성 메서스 시작"); +// blogPublishService.extractBlogPublish(executionId); +// // todo: 실제 키워드 수집 서비스 의 추출 메서드 +// +// //DB 조회로 결과 확인 (30초 대기 적용) +// String keyword = waitForResult(executionId, 30); +// +// if (keyword != null) { +// log.info("✅ 트렌드 키워드 추출 완료: {}", keyword); +// return StepExecutionResultDTO.success(keyword); +// +// } else { +// return StepExecutionResultDTO.failure("트렌드 키워드 추출 시간 초과"); +// } +// +// } catch (Exception e) { +// log.error("트렌드 키워드 추출 실패", e); +// return StepExecutionResultDTO.failure(e.getMessage()); +// } +// } +// private String waitForResult(Long executionId, int timeoutSeconds) { +// for (int i = 0; i < timeoutSeconds; i++) { +// String keyword = blogPublishMapper.findBlogPublishByExecutionId(executionId); +// if (keyword != null) { +// return keyword; +// } +// try { +// Thread.sleep(1000); +// } catch (InterruptedException e) { +// Thread.currentThread().interrupt(); +// break; +// } +// } +// return null; +// } +// } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/KeywordExecutor.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/KeywordExecutor.java new file mode 100644 index 00000000..fc3f9db6 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/KeywordExecutor.java @@ -0,0 +1,96 @@ +package com.softlabs.aicontents.domain.scheduler.service.executor; + +import com.softlabs.aicontents.domain.scheduler.dto.pipeLineDTO.StepExecutionResultDTO; +import com.softlabs.aicontents.domain.scheduler.interfacePipe.PipelineStepExecutor; +// import com.softlabs.aicontents.domain.testMapper.KeywordMapper; +import com.softlabs.aicontents.domain.testDomainService.KeywordService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +@Component +@Slf4j +@Service +public class KeywordExecutor implements PipelineStepExecutor { + + @Autowired private KeywordService keywordService; + + /// todo : 실제 키워드 수집 기능 서비스 + + // @Autowired + // private KeywordMapper keywordMapper; // DB 조회용 + + @Override + public StepExecutionResultDTO execute(int executionId) { + + /// test : 파이프라인 동작 테스트 + System.out.println("키워드 수집 메서드 호출/ 실행"); + delayWithDots(3); + + /// todo : 테스트용 RDS 조회 쿼리 + System.out.println("키워드 수집 결과 DB에서 쿼리 조회"); + delayWithDots(3); + System.out.println("키워드 수집 결과 DB 완료 확인 로직 실행"); + delayWithDots(3); + System.out.println("키워드 수집 수집 상태 판단 -> 완료(success)"); + System.out.println("키워드 수집 수집 상태 판단 -> 실패(failure)-> 재시도/예외처리"); + delayWithDots(3); + System.out.println("[스케줄러]가 [키워드 수집] -> [싸다구 정보 수집] (요청)객체 전달"); + delayWithDots(3); + return null; + /// todo : 반환 값으로 이전 기능이 요구하는 파라메터를 반환하기. + } + + /// 테스트용 딜레이 메서드 + private void delayWithDots(int seconds) { + try { + for (int i = 0; i < seconds; i++) { + Thread.sleep(200); // 1초마다 + System.out.print("."); + } + System.out.println(); // 줄바꿈 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} + + /// Todo : 하기 기능 구현 및 구체화 +// try { +// // 🎬 1. 서비스 실행 +// log.info("🚀 트렌드 키워드 추출 실행 시작"); +// keywordService.extractTrendKeyword(executionId); +// +// // 🔍 2. DB 조회로 결과 확인 (최대 30초 대기) +// String keyword = waitForResult(executionId, 30); +// +// if (keyword != null) { +// log.info("✅ 트렌드 키워드 추출 완료: {}", keyword); +// return StepExecutionResultDTO.success(keyword); +// } else { +// return StepExecutionResultDTO.failure("트렌드 키워드 추출 시간 초과"); +// } +// +// } catch (Exception e) { +// log.error("❌ 트렌드 키워드 추출 실패", e); +// return StepExecutionResultDTO.failure(e.getMessage()); +// } +// } +// +// private String waitForResult(Long executionId, int timeoutSeconds) { +// for (int i = 0; i < timeoutSeconds; i++) { +// String keyword = keywordMapper.findKeywordByExecutionId(executionId); +// if (keyword != null) { +// return keyword; +// } +// try { +// Thread.sleep(1000); +// } catch (InterruptedException e) { +// Thread.currentThread().interrupt(); +// break; +// } +// } +// return null; +// } +// } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/ProductCrawlingExecutor.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/ProductCrawlingExecutor.java new file mode 100644 index 00000000..99f69b99 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/service/executor/ProductCrawlingExecutor.java @@ -0,0 +1,100 @@ +package com.softlabs.aicontents.domain.scheduler.service.executor; + +import com.softlabs.aicontents.domain.scheduler.dto.pipeLineDTO.StepExecutionResultDTO; +import com.softlabs.aicontents.domain.scheduler.interfacePipe.PipelineStepExecutor; +// import com.softlabs.aicontents.domain.testMapper.ProductCrawlingMapper; +import com.softlabs.aicontents.domain.testDomainService.ProductCrawlingService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +@Component +@Slf4j +@Service +public class ProductCrawlingExecutor implements PipelineStepExecutor { + + @Autowired private ProductCrawlingService productCrawlingService; + + // todo: 실제 싸다구 정보 수집 서비스 클래스로 변경 + + // @Autowired + // private ProductCrawlingMapper productCrawlingMapper; + // // todo: 실제 싸다구 정보 수집 매퍼 인터페이스로 변경 + + @Override + public StepExecutionResultDTO execute(int executionId) { + + /// test : 파이프라인 동작 테스트 + System.out.println("싸다구 정보 수집 메서드 호출/ 실행"); + delayWithDots(3); + + /// todo : 테스트용 RDS 조회 쿼리 + System.out.println("싸다구 정보 수집 결과 DB에서 쿼리 조회"); + delayWithDots(3); + System.out.println("싸다구 정보 수집 결과 DB 완료 확인 로직 실행"); + delayWithDots(3); + System.out.println("싸다구 정보 수집 상태 판단 -> 완료(success)"); + System.out.println("싸다구 정보 수집 상태 판단 -> 실패(failure)-> 재시도/예외처리"); + delayWithDots(3); + System.out.println("[스케줄러]가 [싸다구 정보 수집] -> [LLM] (요청)객체 전달"); + delayWithDots(3); + return null; + /// todo : 반환 값으로 이전 기능이 요구하는 파라메터를 반환하기. + } + + /// 테스트용 딜레이 메서드 + private void delayWithDots(int seconds) { + try { + for (int i = 0; i < seconds; i++) { + Thread.sleep(200); // 1초마다 + System.out.print("."); + } + System.out.println(); // 줄바꿈 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} + +// try { +// //키워드 수집 서비스 실행 +// log.info("트랜드 키워드 추출 메서스 시작"); +// productCrawlingService.extractproductCrawling(executionId); +// // todo: 실제 싸다구 정보 수집 서비스의 추출 메서드 +// +// //DB 조회로 결과 확인 (30초 대기 적용) +// String keyword = waitForResult(executionId, 30); +// +// if (keyword != null) { +// log.info("✅ 트렌드 키워드 추출 완료: {}", keyword); +// return StepExecutionResultDTO.success(keyword); +// +// } else { +// return StepExecutionResultDTO.failure("트렌드 키워드 추출 시간 초과"); +// } +// +// } catch (Exception e) { +// log.error("트렌드 키워드 추출 실패", e); +// return StepExecutionResultDTO.failure(e.getMessage()); +// } +// } + +// +// /// 이런 시간 제한으로 결과 확인은 비추 - 언제 끝나는 지 명확히 알아야 함 +// private String waitForResult(Long executionId, int timeoutSeconds) { +// for (int i = 0; i < timeoutSeconds; i++) { +// String keyword = productCrawlingMapper.findproductCrawlingByExecutionId(executionId); +// if (keyword != null) { +// return keyword; +// } +// try { +// Thread.sleep(1000); +// } catch (InterruptedException e) { +// Thread.currentThread().interrupt(); +// break; +// } +// } +// return null; +// } +// } diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/request/SchedulerRequestVO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/request/SchedulerRequestVO.java new file mode 100644 index 00000000..af2ffb31 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/request/SchedulerRequestVO.java @@ -0,0 +1,37 @@ +package com.softlabs.aicontents.domain.scheduler.vo.request; + +import lombok.Data; + +//서비스 -> DB로 보내는 객체 보관용 클래스 +// 하나의 VO가 모든 데이터를 관리 + +@Data +public class SchedulerRequestVO { + + /// 스케줄 관련 데이터 + private String executionCycle; // "매일(A)/ 주간 실행(B)/ 월간 실행(C)" + private String executionTime; // "HH:MM" 자동 실행 시간 + private int keywordCount; // 추출 키워드 수량 + private int publishCount; // 블로그 발행 수량 + private String aiModel; // AI 모델명 (예: "OpenAI GPT-4") + + + /// 파이프라인 관련 데이터 + + int taskId; + String taskName; + String taskDescription; + String cronExpression; + String taskType; + boolean isActive; + int maxRetryCount; + int timeoutMinutes; + String nextExecution; + String lastExecution; + String pipelineConfig; + String createdBy; + String updatedBy; + String createdAt; + String updatedAt; + +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/response/ScheduleResponseVO.java b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/response/ScheduleResponseVO.java new file mode 100644 index 00000000..d53f61f9 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/scheduler/vo/response/ScheduleResponseVO.java @@ -0,0 +1,11 @@ +package com.softlabs.aicontents.domain.scheduler.vo.response; + + +import lombok.Data; + +@Data +public class ScheduleResponseVO { + + // TASK 식별자 + public int taskId; +} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/AIContentService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/AIContentService.java new file mode 100644 index 00000000..87fc4e0c --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/AIContentService.java @@ -0,0 +1,11 @@ +package com.softlabs.aicontents.domain.testDomainService; + +import org.springframework.stereotype.Service; + +@Service +public class AIContentService { + + public void extractAiContent(Long executionId) {} +} + +/// todo : 실제 구현 클래스 적용 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/BlogPublishService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/BlogPublishService.java new file mode 100644 index 00000000..0968c6ba --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/BlogPublishService.java @@ -0,0 +1,10 @@ +package com.softlabs.aicontents.domain.testDomainService; + +import org.springframework.stereotype.Service; + +@Service +public class BlogPublishService { + public void extractBlogPublish(Long executionId) {} +} + +/// todo : 실제 구현 클래스 적용 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/KeywordService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/KeywordService.java new file mode 100644 index 00000000..e16fd60f --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/KeywordService.java @@ -0,0 +1,10 @@ +package com.softlabs.aicontents.domain.testDomainService; + +import org.springframework.stereotype.Service; + +@Service +public class KeywordService { + public void extractTrendKeyword(Long executionId) {} +} + +/// todo : 실제 구현 클래스 적용 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/ProductCrawlingService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/ProductCrawlingService.java new file mode 100644 index 00000000..aff37107 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testDomainService/ProductCrawlingService.java @@ -0,0 +1,10 @@ +package com.softlabs.aicontents.domain.testDomainService; + +import org.springframework.stereotype.Service; + +@Service +public class ProductCrawlingService { + public void extractproductCrawling(Long executionId) {} +} + +/// todo : 실제 구현 클래스 적용 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/AIContentMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/AIContentMapper.java new file mode 100644 index 00000000..5293fdd1 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/AIContentMapper.java @@ -0,0 +1,12 @@ +// package com.softlabs.aicontents.domain.testMapper; +// +// import org.apache.ibatis.annotations.Mapper; +// +// @Mapper +// public interface AIContentMapper { +// +// String findAicontentByExecutionId(Long executionId); +// +// } +// +///// todo : 실제 개발한 Mapper 적용 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/BlogPublishMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/BlogPublishMapper.java new file mode 100644 index 00000000..7d5323d7 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/BlogPublishMapper.java @@ -0,0 +1,12 @@ +// package com.softlabs.aicontents.domain.testMapper; +// +// +// import org.apache.ibatis.annotations.Mapper; +// +// @Mapper +// public interface BlogPublishMapper { +// String findBlogPublishByExecutionId(Long executionId); +// } +// +// +///// todo : 실제 개발한 Mapper 적용 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/KeywordMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/KeywordMapper.java new file mode 100644 index 00000000..26ba43b4 --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/KeywordMapper.java @@ -0,0 +1,11 @@ +// package com.softlabs.aicontents.domain.testMapper; +// +// import org.apache.ibatis.annotations.Mapper; +// +// @Mapper +// public interface KeywordMapper { +// String findKeywordByExecutionId(Long executionId); +// } +// +// +///// todo : 실제 개발한 Mapper 적용 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/ProductCrawlingMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/ProductCrawlingMapper.java new file mode 100644 index 00000000..03ca1b4d --- /dev/null +++ b/springboot/src/main/java/com/softlabs/aicontents/domain/testMapper/ProductCrawlingMapper.java @@ -0,0 +1,13 @@ +// package com.softlabs.aicontents.domain.testMapper; +// +// import org.apache.ibatis.annotations.Mapper; +// +// @Mapper +// public interface ProductCrawlingMapper { +// String findproductCrawlingByExecutionId(Long executionId); +// +// } +// +// +// +///// todo : 실제 개발한 Mapper 적용 diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/controller/TestExampleController.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/controller/TestExampleController.java deleted file mode 100644 index 8a126be7..00000000 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/controller/TestExampleController.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.softlabs.aicontents.domain.testexample.controller; - -import com.softlabs.aicontents.domain.testexample.service.TestExampleService; -import io.swagger.v3.oas.annotations.Operation; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/test-example") -public class TestExampleController { - - private final TestExampleService testExampleService; - - public TestExampleController(TestExampleService testExampleService) { - this.testExampleService = testExampleService; - } - - @PostMapping - @Operation(summary = "테스트 데이터 삽입", description = "RDS 테이블에 테스트 데이터를 삽입합니다.") - public ResponseEntity createTestExample(@RequestParam String testData) { - try { - testExampleService.createTestExample(testData); - return ResponseEntity.ok("테스트 데이터가 성공적으로 삽입되었습니다: " + testData); - } catch (Exception e) { - return ResponseEntity.status(500).body("데이터 삽입 실패: " + e.getMessage()); - } - } -} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/entity/TestExample.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/entity/TestExample.java deleted file mode 100644 index 7d6de007..00000000 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/entity/TestExample.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.softlabs.aicontents.domain.testexample.entity; - -public class TestExample { - private Long id; - private String testData; - - public TestExample() {} - - public TestExample(String testData) { - this.testData = testData; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getTestData() { - return testData; - } - - public void setTestData(String testData) { - this.testData = testData; - } -} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/mapper/TestExampleMapper.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/mapper/TestExampleMapper.java deleted file mode 100644 index 5ca7a055..00000000 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/mapper/TestExampleMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.softlabs.aicontents.domain.testexample.mapper; - -import com.softlabs.aicontents.domain.testexample.entity.TestExample; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface TestExampleMapper { - void insertTestExample(TestExample testExample); -} diff --git a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/service/TestExampleService.java b/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/service/TestExampleService.java deleted file mode 100644 index 521847f5..00000000 --- a/springboot/src/main/java/com/softlabs/aicontents/domain/testexample/service/TestExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.softlabs.aicontents.domain.testexample.service; - -import com.softlabs.aicontents.domain.testexample.entity.TestExample; -import com.softlabs.aicontents.domain.testexample.mapper.TestExampleMapper; -import org.springframework.stereotype.Service; - -@Service -public class TestExampleService { - - private final TestExampleMapper testExampleMapper; - - public TestExampleService(TestExampleMapper testExampleMapper) { - this.testExampleMapper = testExampleMapper; - } - - public void createTestExample(String testData) { - TestExample testExample = new TestExample(testData); - testExampleMapper.insertTestExample(testExample); - } -} diff --git a/springboot/src/main/java/com/softlabs/aicontents/orchestration/TestOrchestration.java b/springboot/src/main/java/com/softlabs/aicontents/orchestration/TestOrchestration.java deleted file mode 100644 index acdfe879..00000000 --- a/springboot/src/main/java/com/softlabs/aicontents/orchestration/TestOrchestration.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.softlabs.aicontents.orchestration; - -public class TestOrchestration {} diff --git a/springboot/src/main/java/com/softlabs/aicontents/scheduler/TestScheduler.java b/springboot/src/main/java/com/softlabs/aicontents/scheduler/TestScheduler.java deleted file mode 100644 index ff50365d..00000000 --- a/springboot/src/main/java/com/softlabs/aicontents/scheduler/TestScheduler.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.softlabs.aicontents.scheduler; - -public class TestScheduler {} diff --git a/springboot/src/main/resources/mappers/domain/scheduler/ScheduleEngineMapper.xml b/springboot/src/main/resources/mappers/domain/scheduler/ScheduleEngineMapper.xml new file mode 100644 index 00000000..5fe7bc75 --- /dev/null +++ b/springboot/src/main/resources/mappers/domain/scheduler/ScheduleEngineMapper.xml @@ -0,0 +1,33 @@ + + + + + + + + + +/* ScheduleEngineMapper.insertSchedule*/ + INSERT INTO SCHEDULED_TASKS ( TASK_ID, + SCHEDULE_TYPE, + EXECUTION_TIME, + KEYWORD_COUNT, + CONTENT_COUNT, + AI_MODEL + ) + VALUES ( SCHEDULE_TASK_SEQUENCES.NEXTVAL, + #{executionCycle}, + #{executionTime}, + #{keywordCount}, + #{publishCount}, + #{aiModel} + ) + + + diff --git a/springboot/src/main/resources/mappers/domain/testexample/TestExampleMapper.xml b/springboot/src/main/resources/mappers/domain/testexample/TestExampleMapper.xml index 31f07e09..06fe10b3 100644 --- a/springboot/src/main/resources/mappers/domain/testexample/TestExampleMapper.xml +++ b/springboot/src/main/resources/mappers/domain/testexample/TestExampleMapper.xml @@ -5,12 +5,12 @@ - - - SELECT TEST_EXAMPLE_SEQ.NEXTVAL FROM DUAL - - INSERT INTO TEST_EXAMPLE (ID, TEST_DATA) - VALUES (#{id}, #{testData}) - + + + + + + + \ No newline at end of file diff --git a/springboot/src/test/java/com/softlabs/aicontents/common/dto/response/ErrorResponseDTOTest.java b/springboot/src/test/java/com/softlabs/aicontents/common/dto/response/ErrorResponseDTOTest.java new file mode 100644 index 00000000..567b3d67 --- /dev/null +++ b/springboot/src/test/java/com/softlabs/aicontents/common/dto/response/ErrorResponseDTOTest.java @@ -0,0 +1,106 @@ +package com.softlabs.aicontents.common.dto.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.softlabs.aicontents.common.enums.ErrorCode; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ErrorResponseDTOTest { + + @Test + void testErrorResponseCreationWithPath() { + String path = "/api/test"; + ErrorCode errorCode = ErrorCode.NOT_FOUND; + + ErrorResponseDTO response = ErrorResponseDTO.of(errorCode, path); + + assertThat(response.code()).isEqualTo(errorCode.getCode()); + assertThat(response.message()).isEqualTo(errorCode.getMessage()); + assertThat(response.path()).isEqualTo(path); + assertThat(response.status()).isEqualTo(errorCode.getStatus()); + assertThat(response.timestamp()).isNotNull(); + assertThat(response.traceId()).isNotNull(); + } + + @Test + void testErrorResponseCreationWithCustomMessage() { + String path = "/api/test"; + String customMessage = "사용자 정의 오류 메시지"; + ErrorCode errorCode = ErrorCode.INVALID_INPUT; + + ErrorResponseDTO response = ErrorResponseDTO.of(errorCode, path, customMessage); + + assertThat(response.code()).isEqualTo(errorCode.getCode()); + assertThat(response.message()).isEqualTo(customMessage); + assertThat(response.path()).isEqualTo(path); + assertThat(response.status()).isEqualTo(errorCode.getStatus()); + assertThat(response.timestamp()).isNotNull(); + assertThat(response.traceId()).isNotNull(); + } + + @Test + void testErrorResponseCreationWithCustomTraceId() { + String path = "/api/test"; + String customTraceId = "custom-trace-123"; + ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR; + + ErrorResponseDTO response = ErrorResponseDTO.ofWithTraceId(errorCode, path, customTraceId); + + assertThat(response.code()).isEqualTo(errorCode.getCode()); + assertThat(response.message()).isEqualTo(errorCode.getMessage()); + assertThat(response.path()).isEqualTo(path); + assertThat(response.status()).isEqualTo(errorCode.getStatus()); + assertThat(response.timestamp()).isNotNull(); + assertThat(response.traceId()).isEqualTo(customTraceId); + } + + @Test + void testErrorResponseTimestamp() { + LocalDateTime beforeCreation = LocalDateTime.now().minusSeconds(1); + + ErrorResponseDTO response = ErrorResponseDTO.of(ErrorCode.BAD_REQUEST, "/api/test"); + + LocalDateTime afterCreation = LocalDateTime.now().plusSeconds(1); + + assertThat(response.timestamp()).isBetween(beforeCreation, afterCreation); + } + + @Test + void testErrorResponseWithDifferentErrorCodes() { + String path = "/api/test"; + + ErrorResponseDTO badRequestResponse = ErrorResponseDTO.of(ErrorCode.BAD_REQUEST, path); + assertThat(badRequestResponse.status()).isEqualTo(400); + assertThat(badRequestResponse.code()).isEqualTo("E001"); + + ErrorResponseDTO unauthorizedResponse = ErrorResponseDTO.of(ErrorCode.UNAUTHORIZED, path); + assertThat(unauthorizedResponse.status()).isEqualTo(401); + assertThat(unauthorizedResponse.code()).isEqualTo("E101"); + + ErrorResponseDTO notFoundResponse = ErrorResponseDTO.of(ErrorCode.NOT_FOUND, path); + assertThat(notFoundResponse.status()).isEqualTo(404); + assertThat(notFoundResponse.code()).isEqualTo("E301"); + + ErrorResponseDTO serverErrorResponse = + ErrorResponseDTO.of(ErrorCode.INTERNAL_SERVER_ERROR, path); + assertThat(serverErrorResponse.status()).isEqualTo(500); + assertThat(serverErrorResponse.code()).isEqualTo("E901"); + } + + @Test + void testErrorResponseImmutability() { + ErrorCode errorCode = ErrorCode.FORBIDDEN; + String path = "/api/forbidden"; + + ErrorResponseDTO response = ErrorResponseDTO.of(errorCode, path); + + assertThat(response.code()).isEqualTo(errorCode.getCode()); + assertThat(response.message()).isEqualTo(errorCode.getMessage()); + assertThat(response.path()).isEqualTo(path); + assertThat(response.status()).isEqualTo(errorCode.getStatus()); + } +} diff --git a/springboot/src/test/java/com/softlabs/aicontents/common/enums/ErrorCodeTest.java b/springboot/src/test/java/com/softlabs/aicontents/common/enums/ErrorCodeTest.java new file mode 100644 index 00000000..ef05466b --- /dev/null +++ b/springboot/src/test/java/com/softlabs/aicontents/common/enums/ErrorCodeTest.java @@ -0,0 +1,71 @@ +package com.softlabs.aicontents.common.enums; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class ErrorCodeTest { + + @Test + void testErrorCodeProperties() { + ErrorCode errorCode = ErrorCode.INVALID_INPUT; + + assertThat(errorCode.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorCode.getCode()).isEqualTo("E002"); + assertThat(errorCode.getMessage()).isEqualTo("입력값이 올바르지 않습니다."); + assertThat(errorCode.getStatus()).isEqualTo(400); + } + + @Test + void testAllErrorCodesHaveValidProperties() { + for (ErrorCode errorCode : ErrorCode.values()) { + assertThat(errorCode.getHttpStatus()).isNotNull(); + assertThat(errorCode.getCode()).isNotEmpty(); + assertThat(errorCode.getMessage()).isNotEmpty(); + assertThat(errorCode.getStatus()).isGreaterThan(0); + } + } + + @Test + void testClientErrorCodes() { + assertThat(ErrorCode.BAD_REQUEST.getStatus()).isEqualTo(400); + assertThat(ErrorCode.UNAUTHORIZED.getStatus()).isEqualTo(401); + assertThat(ErrorCode.FORBIDDEN.getStatus()).isEqualTo(403); + assertThat(ErrorCode.NOT_FOUND.getStatus()).isEqualTo(404); + assertThat(ErrorCode.METHOD_NOT_ALLOWED.getStatus()).isEqualTo(405); + assertThat(ErrorCode.CONFLICT.getStatus()).isEqualTo(409); + } + + @Test + void testServerErrorCodes() { + assertThat(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()).isEqualTo(500); + assertThat(ErrorCode.SERVICE_UNAVAILABLE.getStatus()).isEqualTo(503); + } + + @Test + void testErrorCodeUniqueness() { + ErrorCode[] errorCodes = ErrorCode.values(); + + for (int i = 0; i < errorCodes.length; i++) { + for (int j = i + 1; j < errorCodes.length; j++) { + assertThat(errorCodes[i].getCode()) + .as("Error codes should be unique: %s vs %s", errorCodes[i], errorCodes[j]) + .isNotEqualTo(errorCodes[j].getCode()); + } + } + } + + @Test + void testSpecificErrorCodes() { + ErrorCode notFound = ErrorCode.NOT_FOUND; + assertThat(notFound.getCode()).isEqualTo("E301"); + assertThat(notFound.getMessage()).isEqualTo("요청한 리소스를 찾을 수 없습니다."); + assertThat(notFound.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + + ErrorCode internalError = ErrorCode.INTERNAL_SERVER_ERROR; + assertThat(internalError.getCode()).isEqualTo("E901"); + assertThat(internalError.getMessage()).isEqualTo("내부 서버 오류가 발생했습니다."); + assertThat(internalError.getHttpStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/springboot/src/test/java/com/softlabs/aicontents/common/exception/BusinessExceptionTest.java b/springboot/src/test/java/com/softlabs/aicontents/common/exception/BusinessExceptionTest.java new file mode 100644 index 00000000..91101994 --- /dev/null +++ b/springboot/src/test/java/com/softlabs/aicontents/common/exception/BusinessExceptionTest.java @@ -0,0 +1,125 @@ +package com.softlabs.aicontents.common.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.softlabs.aicontents.common.enums.ErrorCode; +import org.junit.jupiter.api.Test; + +class BusinessExceptionTest { + + @Test + void testBusinessExceptionWithErrorCode() { + ErrorCode errorCode = ErrorCode.NOT_FOUND; + + BusinessException exception = new BusinessException(errorCode); + + assertThat(exception.getErrorCode()).isEqualTo(errorCode); + assertThat(exception.getMessage()).isEqualTo(errorCode.getMessage()); + assertThat(exception.getCustomMessage()).isNull(); + assertThat(exception.getEffectiveMessage()).isEqualTo(errorCode.getMessage()); + } + + @Test + void testBusinessExceptionWithCustomMessage() { + ErrorCode errorCode = ErrorCode.INVALID_INPUT; + String customMessage = "사용자 정의 오류 메시지"; + + BusinessException exception = new BusinessException(errorCode, customMessage); + + assertThat(exception.getErrorCode()).isEqualTo(errorCode); + assertThat(exception.getMessage()).isEqualTo(customMessage); + assertThat(exception.getCustomMessage()).isEqualTo(customMessage); + assertThat(exception.getEffectiveMessage()).isEqualTo(customMessage); + } + + @Test + void testBusinessExceptionWithCause() { + ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR; + Throwable cause = new RuntimeException("원인 예외"); + + BusinessException exception = new BusinessException(errorCode, cause); + + assertThat(exception.getErrorCode()).isEqualTo(errorCode); + assertThat(exception.getMessage()).isEqualTo(errorCode.getMessage()); + assertThat(exception.getCause()).isEqualTo(cause); + assertThat(exception.getCustomMessage()).isNull(); + assertThat(exception.getEffectiveMessage()).isEqualTo(errorCode.getMessage()); + } + + @Test + void testBusinessExceptionWithCustomMessageAndCause() { + ErrorCode errorCode = ErrorCode.DATABASE_ERROR; + String customMessage = "데이터베이스 연결 실패"; + Throwable cause = new RuntimeException("Connection timeout"); + + BusinessException exception = new BusinessException(errorCode, customMessage, cause); + + assertThat(exception.getErrorCode()).isEqualTo(errorCode); + assertThat(exception.getMessage()).isEqualTo(customMessage); + assertThat(exception.getCause()).isEqualTo(cause); + assertThat(exception.getCustomMessage()).isEqualTo(customMessage); + assertThat(exception.getEffectiveMessage()).isEqualTo(customMessage); + } + + @Test + void testEffectiveMessageWithoutCustomMessage() { + ErrorCode errorCode = ErrorCode.FORBIDDEN; + BusinessException exception = new BusinessException(errorCode); + + assertThat(exception.getEffectiveMessage()).isEqualTo(errorCode.getMessage()); + } + + @Test + void testEffectiveMessageWithCustomMessage() { + ErrorCode errorCode = ErrorCode.FORBIDDEN; + String customMessage = "특정 리소스에 대한 접근 권한이 없습니다"; + BusinessException exception = new BusinessException(errorCode, customMessage); + + assertThat(exception.getEffectiveMessage()).isEqualTo(customMessage); + } + + @Test + void testBusinessExceptionInheritance() { + ErrorCode errorCode = ErrorCode.BAD_REQUEST; + BusinessException exception = new BusinessException(errorCode); + + assertThat(exception).isInstanceOf(RuntimeException.class); + assertThat(exception).isInstanceOf(Exception.class); + assertThat(exception).isInstanceOf(Throwable.class); + } + + @Test + void testBusinessExceptionWithDifferentErrorCodes() { + BusinessException badRequestException = new BusinessException(ErrorCode.BAD_REQUEST); + assertThat(badRequestException.getErrorCode()).isEqualTo(ErrorCode.BAD_REQUEST); + + BusinessException unauthorizedException = new BusinessException(ErrorCode.UNAUTHORIZED); + assertThat(unauthorizedException.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); + + BusinessException notFoundException = new BusinessException(ErrorCode.NOT_FOUND); + assertThat(notFoundException.getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND); + + BusinessException serverErrorException = new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); + assertThat(serverErrorException.getErrorCode()).isEqualTo(ErrorCode.INTERNAL_SERVER_ERROR); + } + + @Test + void testBusinessExceptionMessageConsistency() { + ErrorCode errorCode = ErrorCode.CONFLICT; + BusinessException exception = new BusinessException(errorCode); + + assertThat(exception.getMessage()).isEqualTo(errorCode.getMessage()); + assertThat(exception.getEffectiveMessage()).isEqualTo(errorCode.getMessage()); + } + + @Test + void testCustomMessageOverridesDefaultMessage() { + ErrorCode errorCode = ErrorCode.SERVICE_UNAVAILABLE; + String customMessage = "현재 시스템 점검 중입니다"; + BusinessException exception = new BusinessException(errorCode, customMessage); + + assertThat(exception.getMessage()).isEqualTo(customMessage); + assertThat(exception.getEffectiveMessage()).isEqualTo(customMessage); + assertThat(exception.getMessage()).isNotEqualTo(errorCode.getMessage()); + } +}