diff --git a/README.md b/README.md index e69de29b..b3ebca51 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,188 @@ +# 블로그 콘텐츠 자동화 플랫폼 + +### AI 기반 워크플로우 오케스트레이터를 활용한 RAG 기반 블로그 콘텐츠 자동화 시스템 + +--- + +### 목차 + +1. [서비스 개요](#1-서비스-개요) +2. [시스템 개요](#2-시스템-개요) +3. [시스템 아키텍처](#3-시스템-아키텍처) +4. [유스케이스 다이어그램](#4-유스케이스-다이어그램) +5. [시퀀스 다이어그램](#5-시퀀스-다이어그램) +6. [기술 스택](#6-기술-스택) +7. [주요 구성 요소 및 역할](#7-주요-구성-요소-및-역할) +8. [프로젝트 디렉토리 구조](#8-프로젝트-디렉토리-구조) +9. [환경 변수 관리 전략](#9-환경-변수-관리-전략) +10. [시연 영상](#10-시연-영상) + +--- + +## 1. 서비스 개요 + +최근 커머스 업계의 AI 기반 콘텐츠 자동화 트렌드에도 불구하고, 여전히 트렌드 분석, 상품 조사, 콘텐츠 생성 및 발행 등 각 단계에서 시간 소모적인 수작업이 필요합니다. +본 프로젝트는 이러한 비효율을 해결하기 위해, **RAG(검색 증강 생성)** 기술을 기반으로 블로그 콘텐츠 생성의 전 과정을 자동화하는 워크플로우 플랫폼을 구축하는 것을 목표로 합니다. + +--- + +## 2. 시스템 개요 + +본 시스템은 `Spring Boot` 기반의 오케스트레이터와 `Python(FastAPI)` 기반의 AI 워커로 구성된 **이중 레이어 아키텍처**를 채택하여, 각 서비스의 역할을 명확히 분리했습니다. + +* **워크플로우 자동화**: 네이버 데이터 랩의 실시간 트렌드 키워드를 자동 수집하고, 싸다구몰(1688)에서 유사도 기반 검색을 통해 관련 상품을 매칭합니다. +* **AI 콘텐츠 생성**: 수집된 상품 정보와 이미지를 OCR 및 번역 기술로 분석한 후, RAG 기술을 통해 SEO에 최적화된 블로그 콘텐츠를 자동 생성합니다. +* **관리 및 모니터링**: 관리자 전용 대시보드에서 워크플로우 실행, 스케줄 제어, 실행 이력 및 결과를 실시간 모니터링합니다. Grafana로 서버 리소스와 API 상태를 시각적으로 확인할 수 있습니다. + +--- + +## 3. 시스템 아키텍처 + +역할과 책임을 명확히 분리하기 위해 `Spring Boot`가 **Orchestrator**, `FastAPI`가 **Worker** 역할을 수행하는 이중 레이어 아키텍처를 채택했습니다. + +* **Spring Boot (Orchestrator)**: `Workflow → Job → Task` 구조를 기반으로 전체 비즈니스 흐름을 제어합니다. 스케줄링(`Quartz`), 상태 관리, 데이터 영속성, 인증/인가 등 핵심 로직 담당. +* **FastAPI (Worker)**: 키워드 추출, 상품 검색, 웹 크롤링, AI 연동(RAG), OCR 등 Python 생태계 특화 작업을 담당. + +![System Architecture](assets/시스템_아키텍처_1.png) +![System Architecture](assets/시스템_아키텍처_2.png) + +--- + +## 4. 유스케이스 다이어그램 + +시스템의 주요 액터는 **관리자(Admin)** 와 **스케줄러(Scheduler)** 입니다. +관리자는 워크플로우와 스케줄을 관리하고 수동 실행이 가능하며, 스케줄러는 자동 실행을 담당합니다. + +![Usecase Diagram](assets/유스케이스_다이어그램.png) + +--- + +## 5. 시퀀스 다이어그램 + +### 5.1. 워크플로우 실행 흐름 (스케줄/수동) + +1. **트리거**: Quartz 스케줄러 또는 사용자의 `POST /v0/workflows/{id}/run` 요청으로 워크플로우 실행 시작 +2. **비동기 실행**: `WorkflowController`가 `WorkflowExecutionService`를 `@Async`로 호출하고 즉시 `202 Accepted` 응답 +3. **오케스트레이션**: `WorkflowExecutionService`가 Job, Task 순차 실행 및 `TaskExecutionService`에 재시도 위임 +4. **외부 API 호출**: `FastApiTaskRunner`와 `FastApiAdapter`를 통해 FastAPI와 통신 +5. **결과 기록**: 모든 실행 결과는 DB에 기록되며, 실패 시에도 다음 작업은 계속 진행 + +#### 수동 실행 + +![Sequence Diagram](assets/시퀀스 다이어그램(수동 실행).png) + +#### 스케줄 실행 + +![Sequence Diagram](assets/시퀀스 다이어그램(스케줄 실행).png) + +### 5.2. CI/CD 파이프라인 + +GitHub Actions 기반으로 빌드 → 테스트 → Docker 빌드 및 푸시 → EC2 배포까지 자동화되어 있습니다. + +![Sequence Diagram](assets/CICD_시퀀스_다이어그램_1.png) +![Sequence Diagram](assets/CICD_시퀀스_다이어그램_2.png) +![Sequence Diagram](assets/CICD_시퀀스_다이어그램_3.png) +![Sequence Diagram](assets/CICD_시퀀스_다이어그램_4.png) + +--- + +## 6. 기술 스택 + +### Backend (Orchestrator - `user-service`) + +* **Language & Framework**: Java 21, Spring Boot 3.5.4 +* **Data Access**: MyBatis 3.0.5, MariaDB Java Client 3.3.3 +* **Scheduling**: Spring Quartz +* **Resilience**: Spring Retry +* **Security**: Spring Security +* **Build Tool**: Gradle + +### Backend (Worker - `pre-processing-service`) + +* **Language & Framework**: Python 3.11, FastAPI 0.116.2 +* **AI & ML**: Transformers, Scikit-learn, OpenAI API +* **Web Scraping & OCR**: BeautifulSoup4, Selenium, Google Cloud Vision +* **DB & Translation**: SQLAlchemy, Deep-Translator +* **Package Manager**: Poetry + +### Database + +* **MariaDB 11.4** + +### DevOps & Monitoring + +* **Containerization**: Docker, Docker Compose +* **CI/CD**: GitHub Actions +* **Monitoring**: Prometheus, Grafana +* **Migration**: Flyway +* **Logging**: Log4j2 + +--- + +## 7. 주요 구성 요소 및 역할 + +* **WorkflowExecutionService**: 워크플로우 전체 실행 흐름 제어 +* **TaskExecutionService**: Task 실행 및 재시도 정책 관리 +* **TaskBodyBuilder (전략 패턴)**: 각 Task별 동적 Request Body 생성 +* **FastApiAdapter**: FastAPI 서버 통신 캡슐화 +* **QuartzSchedulerInitializer**: DB 스케줄 정보 Quartz 엔진 동기화 +* **ExecutionMdcManager**: 비동기 환경에서도 traceId 기반 분산 추적 로깅 + +--- + +## 8. 프로젝트 디렉토리 구조 + +Monorepo 형태로, `apps` 하위에 서비스별 디렉토리가 존재합니다. + +```bash +Final-4team-icebang/ +├── apps/ +│ ├── user-service/ # Java/Spring Boot 서비스 +│ └── pre-processing-service/# Python/FastAPI 서비스 +├── docker/ # Docker 설정 +├── .github/ # GitHub Actions (CI/CD) +└── logs/ # 로그 파일 +``` + +### user-service (Spring Boot) + +도메인 중심 아키텍처 적용. 각 도메인이 자체 구성요소를 포함. + +```bash +user-service/ +├── src/main/java/site/icebang/ +│ ├── domain/ # 도메인 로직 +│ ├── external/ # 외부 API 연동 +│ ├── global/ # 글로벌 설정 +│ └── common/ # 공용 유틸리티 +└── Dockerfile +``` + +### pre-processing-service (FastAPI) + +```bash +pre-processing-service/ +├── app/ +│ ├── api/ # 엔드포인트 +│ ├── service/ # 비즈니스 로직 +│ ├── core/ # 핵심 설정 +│ ├── db/ # DB 관련 +│ └── utils/ # 유틸리티 +└── pyproject.toml +``` + +--- + +## 9. 환경 변수 관리 전략 + +추후 작성 예정 + +* **FastAPI (`pre-processing-service`)**: +* **Spring Boot (`user-service`)**: + +--- + +## 10. 시연 영상 + +[https://www.youtube.com/watch?v=1vApNttVxVg](https://www.youtube.com/watch?v=1vApNttVxVg) +[![Video Label](http://img.youtube.com/vi/1vApNttVxVg/0.jpg)](https://www.youtube.com/watch?v=1vApNttVxVg) diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java b/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponseDto.java similarity index 81% rename from apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java rename to apps/user-service/src/main/java/site/icebang/common/dto/ApiResponseDto.java index 8a986b4d..5b9c91d9 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponse.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/ApiResponseDto.java @@ -30,7 +30,7 @@ * @see lombok.Data */ @Data -public class ApiResponse { +public class ApiResponseDto { /** * 요청 처리 성공 여부. * @@ -60,7 +60,7 @@ public class ApiResponse { private HttpStatus status; // HttpStatus로 변경 /** 기본 생성자입니다. 모든 필드가 기본값으로 초기화됩니다. */ - public ApiResponse() {} + public ApiResponseDto() {} /** * 모든 필드를 초기화하는 생성자. @@ -70,7 +70,7 @@ public ApiResponse() {} * @param message 응답 메시지 * @param status HTTP 상태 코드 */ - public ApiResponse(boolean success, T data, String message, HttpStatus status) { + public ApiResponseDto(boolean success, T data, String message, HttpStatus status) { this.success = success; this.data = data; this.message = message; @@ -84,8 +84,8 @@ public ApiResponse(boolean success, T data, String message, HttpStatus status) { * @param 데이터 타입 * @return 성공 응답 객체 */ - public static ApiResponse success(T data) { - return new ApiResponse<>(true, data, "OK", HttpStatus.OK); + public static ApiResponseDto success(T data) { + return new ApiResponseDto<>(true, data, "OK", HttpStatus.OK); } /** @@ -96,8 +96,8 @@ public static ApiResponse success(T data) { * @param 데이터 타입 * @return 성공 응답 객체 */ - public static ApiResponse success(T data, String message) { - return new ApiResponse<>(true, data, message, HttpStatus.OK); + public static ApiResponseDto success(T data, String message) { + return new ApiResponseDto<>(true, data, message, HttpStatus.OK); } /** @@ -109,8 +109,8 @@ public static ApiResponse success(T data, String message) { * @param 데이터 타입 * @return 성공 응답 객체 */ - public static ApiResponse success(T data, String message, HttpStatus status) { - return new ApiResponse<>(true, data, message, status); + public static ApiResponseDto success(T data, String message, HttpStatus status) { + return new ApiResponseDto<>(true, data, message, status); } /** @@ -121,7 +121,7 @@ public static ApiResponse success(T data, String message, HttpStatus stat * @param 데이터 타입 * @return 오류 응답 객체 */ - public static ApiResponse error(String message, HttpStatus status) { - return new ApiResponse<>(false, null, message, status); + public static ApiResponseDto error(String message, HttpStatus status) { + return new ApiResponseDto<>(false, null, message, status); } } diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java b/apps/user-service/src/main/java/site/icebang/common/dto/PageParamsDto.java similarity index 98% rename from apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java rename to apps/user-service/src/main/java/site/icebang/common/dto/PageParamsDto.java index 6083bc43..7a53edfc 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/PageParams.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/PageParamsDto.java @@ -25,7 +25,7 @@ * @see lombok.Data */ @Data -public class PageParams { +public class PageParamsDto { /** * 현재 페이지 번호 (1부터 시작). * diff --git a/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java b/apps/user-service/src/main/java/site/icebang/common/dto/PageResultDto.java similarity index 77% rename from apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java rename to apps/user-service/src/main/java/site/icebang/common/dto/PageResultDto.java index 0982be0a..c3714ede 100644 --- a/apps/user-service/src/main/java/site/icebang/common/dto/PageResult.java +++ b/apps/user-service/src/main/java/site/icebang/common/dto/PageResultDto.java @@ -36,7 +36,7 @@ */ @Data @NoArgsConstructor -public class PageResult { +public class PageResultDto { /** 현재 페이지에 포함된 데이터 목록. */ private List data; @@ -67,7 +67,7 @@ public class PageResult { * @param current 현재 페이지 번호 * @param pageSize 페이지 크기 */ - public PageResult(List data, int total, int current, int pageSize) { + public PageResultDto(List data, int total, int current, int pageSize) { this.data = data; this.total = total; this.current = current; @@ -96,8 +96,8 @@ private void calculatePagination() { * @param 데이터 타입 * @return PageResult 객체 */ - public static PageResult of(List data, int total, int current, int pageSize) { - return new PageResult<>(data, total, current, pageSize); + public static PageResultDto of(List data, int total, int current, int pageSize) { + return new PageResultDto<>(data, total, current, pageSize); } /** @@ -105,12 +105,13 @@ public static PageResult of(List data, int total, int current, int pag * * @param data 현재 페이지 데이터 * @param total 전체 데이터 개수 - * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param pageParamsDto 요청 파라미터 ({@link PageParamsDto}) * @param 데이터 타입 * @return PageResult 객체 */ - public static PageResult of(List data, int total, PageParams pageParams) { - return new PageResult<>(data, total, pageParams.getCurrent(), pageParams.getPageSize()); + public static PageResultDto of(List data, int total, PageParamsDto pageParamsDto) { + return new PageResultDto<>( + data, total, pageParamsDto.getCurrent(), pageParamsDto.getPageSize()); } /** @@ -118,28 +119,32 @@ public static PageResult of(List data, int total, PageParams pageParam * *

데이터 조회와 카운트 조회를 별도의 Supplier로 받아 트랜잭션 내에서 실행할 수 있습니다. * - * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param pageParamsDto 요청 파라미터 ({@link PageParamsDto}) * @param dataSupplier 데이터 조회 함수 * @param countSupplier 전체 개수 조회 함수 * @param 데이터 타입 * @return PageResult 객체 */ - public static PageResult from( - PageParams pageParams, Supplier> dataSupplier, Supplier countSupplier) { + public static PageResultDto from( + PageParamsDto pageParamsDto, + Supplier> dataSupplier, + Supplier countSupplier) { List data = dataSupplier.get(); int total = countSupplier.get(); - return new PageResult<>(data, total, pageParams.getCurrent(), pageParams.getPageSize()); + return new PageResultDto<>( + data, total, pageParamsDto.getCurrent(), pageParamsDto.getPageSize()); } /** * 비어 있는 페이지 결과를 생성합니다. * - * @param pageParams 요청 파라미터 ({@link PageParams}) + * @param pageParamsDto 요청 파라미터 ({@link PageParamsDto}) * @param 데이터 타입 * @return 빈 PageResult 객체 */ - public static PageResult empty(PageParams pageParams) { - return new PageResult<>(List.of(), 0, pageParams.getCurrent(), pageParams.getPageSize()); + public static PageResultDto empty(PageParamsDto pageParamsDto) { + return new PageResultDto<>( + List.of(), 0, pageParamsDto.getCurrent(), pageParamsDto.getPageSize()); } /** @@ -148,8 +153,8 @@ public static PageResult empty(PageParams pageParams) { * @param 데이터 타입 * @return 빈 PageResult 객체 */ - public static PageResult empty() { - return new PageResult<>(List.of(), 0, 1, 10); + public static PageResultDto empty() { + return new PageResultDto<>(List.of(), 0, 1, 10); } /** diff --git a/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java b/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java index da3ae215..7b83c524 100644 --- a/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java +++ b/apps/user-service/src/main/java/site/icebang/common/service/PageableService.java @@ -1,13 +1,13 @@ package site.icebang.common.service; -import site.icebang.common.dto.PageParams; -import site.icebang.common.dto.PageResult; +import site.icebang.common.dto.PageParamsDto; +import site.icebang.common.dto.PageResultDto; /** * 페이징 가능한 서비스 인터페이스. * - *

엔티티나 DTO 목록을 페이징 처리하여 반환해야 하는 서비스에서 구현합니다. 공통적으로 {@link PageParams} 요청 파라미터를 받아 {@link - * PageResult} 응답을 제공합니다. + *

엔티티나 DTO 목록을 페이징 처리하여 반환해야 하는 서비스에서 구현합니다. 공통적으로 {@link PageParamsDto} 요청 파라미터를 받아 {@link + * PageResultDto} 응답을 제공합니다. * *

사용 예시: * @@ -37,8 +37,8 @@ public interface PageableService { /** * 페이징 처리된 결과를 반환합니다. * - * @param pageParams 페이징 및 검색/정렬 요청 파라미터 + * @param pageParamsDto 페이징 및 검색/정렬 요청 파라미터 * @return 페이징 처리된 결과 객체 */ - PageResult getPagedResult(PageParams pageParams); + PageResultDto getPagedResult(PageParamsDto pageParamsDto); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java b/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java index 2303cf74..629cc226 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/auth/controller/AuthController.java @@ -14,7 +14,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import site.icebang.common.dto.ApiResponse; +import site.icebang.common.dto.ApiResponseDto; import site.icebang.domain.auth.dto.LoginRequestDto; import site.icebang.domain.auth.dto.RegisterDto; import site.icebang.domain.auth.model.AuthCredential; @@ -29,13 +29,13 @@ public class AuthController { @PostMapping("/register") @ResponseStatus(HttpStatus.CREATED) - public ApiResponse register(@Valid @RequestBody RegisterDto registerDto) { + public ApiResponseDto register(@Valid @RequestBody RegisterDto registerDto) { authService.registerUser(registerDto); - return ApiResponse.success(null); + return ApiResponseDto.success(null); } @PostMapping("/login") - public ApiResponse login( + public ApiResponseDto login( @RequestBody LoginRequestDto request, HttpServletRequest httpRequest) { UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()); @@ -49,21 +49,22 @@ public ApiResponse login( HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext()); - return ApiResponse.success(null); + return ApiResponseDto.success(null); } @GetMapping("/check-session") - public ApiResponse checkSession(@AuthenticationPrincipal AuthCredential user) { - return ApiResponse.success(user != null); + public ApiResponseDto checkSession(@AuthenticationPrincipal AuthCredential user) { + return ApiResponseDto.success(user != null); } @GetMapping("/permissions") - public ApiResponse getPermissions(@AuthenticationPrincipal AuthCredential user) { - return ApiResponse.success(user); + public ApiResponseDto getPermissions( + @AuthenticationPrincipal AuthCredential user) { + return ApiResponseDto.success(user); } @PostMapping("/logout") - public ApiResponse logout(HttpServletRequest request) { + public ApiResponseDto logout(HttpServletRequest request) { // SecurityContext 정리 SecurityContextHolder.clearContext(); @@ -73,6 +74,6 @@ public ApiResponse logout(HttpServletRequest request) { session.invalidate(); } - return ApiResponse.success(null); + return ApiResponseDto.success(null); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/auth/service/AuthService.java b/apps/user-service/src/main/java/site/icebang/domain/auth/service/AuthService.java index 25a5bd42..b1a7a7bd 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/auth/service/AuthService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/auth/service/AuthService.java @@ -10,7 +10,7 @@ import site.icebang.common.utils.RandomPasswordGenerator; import site.icebang.domain.auth.dto.RegisterDto; import site.icebang.domain.auth.mapper.AuthMapper; -import site.icebang.domain.email.dto.EmailRequest; +import site.icebang.domain.email.dto.EmailRequestDto; import site.icebang.domain.email.service.EmailService; @Service @@ -42,13 +42,13 @@ public void registerUser(RegisterDto registerDto) { authMapper.insertUserRoles(registerDto); } - EmailRequest emailRequest = - EmailRequest.builder() + EmailRequestDto emailRequestDto = + EmailRequestDto.builder() .to(registerDto.getEmail()) .subject("[ice-bang] 비밀번호") .body(randomPassword) .build(); - emailService.send(emailRequest); + emailService.send(emailRequestDto); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/department/dto/DepartmentCardDo.java b/apps/user-service/src/main/java/site/icebang/domain/department/dto/DepartmentCardDto.java similarity index 87% rename from apps/user-service/src/main/java/site/icebang/domain/department/dto/DepartmentCardDo.java rename to apps/user-service/src/main/java/site/icebang/domain/department/dto/DepartmentCardDto.java index 7644eb8e..f08c5d28 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/department/dto/DepartmentCardDo.java +++ b/apps/user-service/src/main/java/site/icebang/domain/department/dto/DepartmentCardDto.java @@ -9,7 +9,7 @@ @Data @Builder @AllArgsConstructor -public class DepartmentCardDo { +public class DepartmentCardDto { private BigInteger id; private String name; } 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 index 633e3ee7..ef38b607 100644 --- 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 @@ -6,7 +6,7 @@ import lombok.RequiredArgsConstructor; -import site.icebang.domain.email.dto.EmailRequest; +import site.icebang.domain.email.dto.EmailRequestDto; import site.icebang.domain.email.service.EmailService; @RestController @@ -19,15 +19,15 @@ public class EmailTestController { @PreAuthorize("permitAll()") public ResponseEntity sendTestEmail(@RequestParam String to) { try { - EmailRequest emailRequest = - EmailRequest.builder() + EmailRequestDto emailRequestDto = + EmailRequestDto.builder() .to(to) .subject("IceBang 실제 테스트 이메일") .body("안녕하세요!\n\nIceBang에서 보내는 실제 Gmail 테스트 이메일입니다.\n\n성공적으로 연동되었습니다!") .isHtml(false) .build(); - emailService.send(emailRequest); + emailService.send(emailRequestDto); return ResponseEntity.ok("실제 Gmail 테스트 이메일 전송 완료! 받은편지함을 확인하세요!"); } catch (Exception e) { diff --git a/apps/user-service/src/main/java/site/icebang/domain/email/dto/EmailRequest.java b/apps/user-service/src/main/java/site/icebang/domain/email/dto/EmailRequestDto.java similarity index 90% rename from apps/user-service/src/main/java/site/icebang/domain/email/dto/EmailRequest.java rename to apps/user-service/src/main/java/site/icebang/domain/email/dto/EmailRequestDto.java index 89898055..dc0afc98 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/email/dto/EmailRequest.java +++ b/apps/user-service/src/main/java/site/icebang/domain/email/dto/EmailRequestDto.java @@ -7,7 +7,7 @@ @Builder @Getter -public class EmailRequest { +public class EmailRequestDto { private String to; private String subject; private String body; diff --git a/apps/user-service/src/main/java/site/icebang/domain/email/service/EmailService.java b/apps/user-service/src/main/java/site/icebang/domain/email/service/EmailService.java index 51646cc3..e0977867 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/email/service/EmailService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/email/service/EmailService.java @@ -1,7 +1,7 @@ package site.icebang.domain.email.service; -import site.icebang.domain.email.dto.EmailRequest; +import site.icebang.domain.email.dto.EmailRequestDto; public interface EmailService { - void send(EmailRequest emailRequest); + void send(EmailRequestDto emailRequestDto); } 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 1bf7454b..5c573331 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 @@ -12,7 +12,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import site.icebang.domain.email.dto.EmailRequest; +import site.icebang.domain.email.dto.EmailRequestDto; @Slf4j @Service @@ -27,7 +27,7 @@ public class EmailServiceImpl implements EmailService { private String defaultSender; @Override - public void send(EmailRequest request) { + public void send(EmailRequestDto request) { try { if (request.isHtml()) { sendHtmlEmail(request); @@ -40,7 +40,7 @@ public void send(EmailRequest request) { } } - private void sendSimpleEmail(EmailRequest request) { + private void sendSimpleEmail(EmailRequestDto request) { try { SimpleMailMessage message = new SimpleMailMessage(); message.setTo(request.getTo()); @@ -67,7 +67,7 @@ private void sendSimpleEmail(EmailRequest request) { } } - private void sendHtmlEmail(EmailRequest request) throws MessagingException { + private void sendHtmlEmail(EmailRequestDto request) throws MessagingException { MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); 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 ee84e8ea..d40e8fa1 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 @@ -5,7 +5,7 @@ import lombok.extern.slf4j.Slf4j; -import site.icebang.domain.email.dto.EmailRequest; +import site.icebang.domain.email.dto.EmailRequestDto; @Service @Profile({"develop", "test-e2e", "test-integration", "test-unit"}) @@ -13,9 +13,9 @@ public class MockEmailService implements EmailService { @Override - public void send(EmailRequest emailRequest) { - log.info("Mock send mail to: {}", emailRequest.getTo()); - log.info("Subject: {}", emailRequest.getSubject()); - log.info("Body: {}", emailRequest.getBody()); + public void send(EmailRequestDto emailRequestDto) { + log.info("Mock send mail to: {}", emailRequestDto.getTo()); + log.info("Subject: {}", emailRequestDto.getSubject()); + log.info("Body: {}", emailRequestDto.getBody()); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java b/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java index 772c47e2..281ed526 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/log/mapper/ExecutionLogMapper.java @@ -5,9 +5,9 @@ import org.apache.ibatis.annotations.Mapper; import site.icebang.domain.workflow.dto.ExecutionLogDto; -import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteria; +import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteriaDto; @Mapper public interface ExecutionLogMapper { - List selectLogsByCriteria(WorkflowLogQueryCriteria criteria); + List selectLogsByCriteria(WorkflowLogQueryCriteriaDto criteria); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java b/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java index 7cd9a820..3fbf08bf 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/log/service/ExecutionLogService.java @@ -8,14 +8,14 @@ import site.icebang.domain.log.mapper.ExecutionLogMapper; import site.icebang.domain.workflow.dto.ExecutionLogDto; -import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteria; +import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteriaDto; @Service @RequiredArgsConstructor public class ExecutionLogService { private final ExecutionLogMapper executionLogMapper; - public List getRawLogs(WorkflowLogQueryCriteria criteria) { + public List getRawLogs(WorkflowLogQueryCriteriaDto criteria) { return executionLogMapper.selectLogsByCriteria(criteria); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/organization/controller/OrganizationController.java b/apps/user-service/src/main/java/site/icebang/domain/organization/controller/OrganizationController.java index 16ccbb65..dd8851ff 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/organization/controller/OrganizationController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/organization/controller/OrganizationController.java @@ -11,7 +11,7 @@ import lombok.RequiredArgsConstructor; -import site.icebang.common.dto.ApiResponse; +import site.icebang.common.dto.ApiResponseDto; import site.icebang.domain.organization.dto.OrganizationCardDto; import site.icebang.domain.organization.dto.OrganizationOptionDto; import site.icebang.domain.organization.service.OrganizationService; @@ -23,13 +23,14 @@ public class OrganizationController { private final OrganizationService organizationService; @GetMapping("") - public ResponseEntity>> getOrganizations() { - return ResponseEntity.ok(ApiResponse.success(organizationService.getAllOrganizationList())); + public ResponseEntity>> getOrganizations() { + return ResponseEntity.ok(ApiResponseDto.success(organizationService.getAllOrganizationList())); } @GetMapping("/{id}/options") - public ResponseEntity> getOrganizationDetails( + public ResponseEntity> getOrganizationDetails( @PathVariable BigInteger id) { - return ResponseEntity.ok(ApiResponse.success(organizationService.getOrganizationOptions(id))); + return ResponseEntity.ok( + ApiResponseDto.success(organizationService.getOrganizationOptions(id))); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/organization/dto/OrganizationOptionDto.java b/apps/user-service/src/main/java/site/icebang/domain/organization/dto/OrganizationOptionDto.java index d7e670eb..614a6c40 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/organization/dto/OrganizationOptionDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/organization/dto/OrganizationOptionDto.java @@ -6,7 +6,7 @@ import lombok.Builder; import lombok.Data; -import site.icebang.domain.department.dto.DepartmentCardDo; +import site.icebang.domain.department.dto.DepartmentCardDto; import site.icebang.domain.position.dto.PositionCardDto; import site.icebang.domain.roles.dto.RoleCardDto; @@ -14,7 +14,7 @@ @Data @AllArgsConstructor public class OrganizationOptionDto { - List departments; + List departments; List positions; List roles; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/organization/mapper/OrganizationMapper.java b/apps/user-service/src/main/java/site/icebang/domain/organization/mapper/OrganizationMapper.java index ed504cca..5d69ff07 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/organization/mapper/OrganizationMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/organization/mapper/OrganizationMapper.java @@ -6,7 +6,7 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; -import site.icebang.domain.department.dto.DepartmentCardDo; +import site.icebang.domain.department.dto.DepartmentCardDto; import site.icebang.domain.organization.dto.OrganizationCardDto; import site.icebang.domain.position.dto.PositionCardDto; import site.icebang.domain.roles.dto.RoleCardDto; @@ -15,7 +15,7 @@ public interface OrganizationMapper { List findAllOrganizations(); - List findDepartmentsByOrganizationId( + List findDepartmentsByOrganizationId( @Param("organizationId") BigInteger organizationId); List findPositionsByOrganizationId( diff --git a/apps/user-service/src/main/java/site/icebang/domain/organization/service/OrganizationService.java b/apps/user-service/src/main/java/site/icebang/domain/organization/service/OrganizationService.java index cc035935..f77e859e 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/organization/service/OrganizationService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/organization/service/OrganizationService.java @@ -8,7 +8,7 @@ import lombok.RequiredArgsConstructor; -import site.icebang.domain.department.dto.DepartmentCardDo; +import site.icebang.domain.department.dto.DepartmentCardDto; import site.icebang.domain.organization.dto.OrganizationCardDto; import site.icebang.domain.organization.dto.OrganizationOptionDto; import site.icebang.domain.organization.mapper.OrganizationMapper; @@ -26,7 +26,7 @@ public List getAllOrganizationList() { } public OrganizationOptionDto getOrganizationOptions(BigInteger id) { - List departments = organizationMapper.findDepartmentsByOrganizationId(id); + List departments = organizationMapper.findDepartmentsByOrganizationId(id); List positions = organizationMapper.findPositionsByOrganizationId(id); List roles = organizationMapper.findRolesByOrganizationId(id); diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/controller/ScheduleController.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/controller/ScheduleController.java new file mode 100644 index 00000000..bdcb3e12 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/controller/ScheduleController.java @@ -0,0 +1,148 @@ +package site.icebang.domain.schedule.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.common.dto.ApiResponseDto; +import site.icebang.domain.auth.model.AuthCredential; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; +import site.icebang.domain.schedule.dto.ScheduleUpdateDto; +import site.icebang.domain.schedule.model.Schedule; +import site.icebang.domain.schedule.service.ScheduleService; + +/** + * 스케줄 관리를 위한 REST API 컨트롤러입니다. + * + *

스케줄의 조회, 수정, 삭제, 활성화/비활성화 API를 제공합니다. + * + *

제공 API:

+ * + *
    + *
  • GET /v0/workflows/{workflowId}/schedules - 워크플로우의 스케줄 목록 조회 + *
  • GET /v0/schedules/{scheduleId} - 스케줄 단건 조회 + *
  • PUT /v0/schedules/{scheduleId} - 스케줄 수정 + *
  • PATCH /v0/schedules/{scheduleId}/active - 스케줄 활성화/비활성화 + *
  • DELETE /v0/schedules/{scheduleId} - 스케줄 삭제 + *
+ * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Slf4j +@RestController +@RequestMapping("/v0") +@RequiredArgsConstructor +public class ScheduleController { + + private final ScheduleService scheduleService; + + @PostMapping("/workflows/{workflowId}/schedules") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponseDto createSchedule( + @PathVariable Long workflowId, + @Valid @RequestBody ScheduleCreateDto dto, + @AuthenticationPrincipal AuthCredential authCredential) { + + Long userId = authCredential.getId().longValue(); + Schedule schedule = scheduleService.createSchedule(workflowId, dto, userId); + + return ApiResponseDto.success(schedule); + } + + /** + * 특정 워크플로우의 모든 스케줄을 조회합니다. + * + * @param workflowId 워크플로우 ID + * @return 스케줄 목록 + */ + @GetMapping("/workflows/{workflowId}/schedules") + public ApiResponseDto> getSchedulesByWorkflow(@PathVariable Long workflowId) { + log.info("워크플로우 스케줄 목록 조회 요청: Workflow ID {}", workflowId); + List schedules = scheduleService.getSchedulesByWorkflowId(workflowId); + return ApiResponseDto.success(schedules); + } + + /** + * 스케줄 ID로 단건 조회합니다. + * + * @param scheduleId 스케줄 ID + * @return 스케줄 정보 + */ + @GetMapping("/schedules/{scheduleId}") + public ApiResponseDto getSchedule(@PathVariable Long scheduleId) { + log.info("스케줄 조회 요청: Schedule ID {}", scheduleId); + Schedule schedule = scheduleService.getScheduleById(scheduleId); + return ApiResponseDto.success(schedule); + } + + /** + * 스케줄을 수정합니다. + * + *

크론 표현식, 스케줄 텍스트, 활성화 상태를 수정할 수 있으며, 변경사항은 즉시 Quartz에 반영됩니다. + * + * @param scheduleId 수정할 스케줄 ID + * @param dto 수정 정보 + * @param authCredential 인증 정보 (수정자) + * @return 성공 응답 + */ + @PutMapping("/schedules/{scheduleId}") + public ApiResponseDto updateSchedule( + @PathVariable Long scheduleId, + @Valid @RequestBody ScheduleUpdateDto dto, + @AuthenticationPrincipal AuthCredential authCredential) { + + log.info("스케줄 수정 요청: Schedule ID {} - {}", scheduleId, dto.getCronExpression()); + + // 인증 체크 + if (authCredential == null) { + throw new IllegalArgumentException("로그인이 필요합니다"); + } + + Long userId = authCredential.getId().longValue(); + scheduleService.updateSchedule(scheduleId, dto, userId); + + return ApiResponseDto.success(null); + } + + /** + * 스케줄 활성화 상태를 변경합니다. + * + *

활성화(true) 시 Quartz에 등록되어 실행되고, 비활성화(false) 시 Quartz에서 제거됩니다. + * + * @param scheduleId 스케줄 ID + * @param isActive 변경할 활성화 상태 + * @return 성공 응답 + */ + @PatchMapping("/schedules/{scheduleId}/active") + public ApiResponseDto toggleScheduleActive( + @PathVariable Long scheduleId, @RequestParam Boolean isActive) { + + log.info("스케줄 활성화 상태 변경 요청: Schedule ID {} - {}", scheduleId, isActive); + scheduleService.toggleScheduleActive(scheduleId, isActive); + + return ApiResponseDto.success(null); + } + + /** + * 스케줄을 삭제합니다 (논리 삭제). + * + *

DB에서 비활성화되고 Quartz에서도 제거됩니다. + * + * @param scheduleId 삭제할 스케줄 ID + * @return 성공 응답 + */ + @DeleteMapping("/schedules/{scheduleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponseDto deleteSchedule(@PathVariable Long scheduleId) { + log.info("스케줄 삭제 요청: Schedule ID {}", scheduleId); + scheduleService.deleteSchedule(scheduleId); + return ApiResponseDto.success(null); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java similarity index 88% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java rename to apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java index 87fdcb5a..8f5c7df5 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java @@ -1,4 +1,4 @@ -package site.icebang.domain.workflow.dto; +package site.icebang.domain.schedule.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -85,17 +85,18 @@ public class ScheduleCreateDto { * *

DTO의 정보를 DB 저장용 엔티티로 변환하며, 서비스 레이어에서 주입되는 workflowId와 userId를 함께 설정합니다. * + * @param dto 변환할 ScheduleCreateDto 객체 * @param workflowId 연결할 워크플로우 ID * @param userId 생성자 ID * @return DB 저장 가능한 Schedule 엔티티 */ - public Schedule toEntity(Long workflowId, Long userId) { + public static Schedule toEntity(ScheduleCreateDto dto, Long workflowId, Long userId) { return Schedule.builder() .workflowId(workflowId) - .cronExpression(this.cronExpression) - .scheduleText(this.scheduleText) - .isActive(this.isActive != null ? this.isActive : true) - .parameters(this.parameters) + .cronExpression(dto.cronExpression) + .scheduleText(dto.scheduleText) + .isActive(dto.isActive != null ? dto.isActive : true) + .parameters(dto.parameters) .createdBy(userId) .updatedBy(userId) .build(); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleDto.java similarity index 87% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java rename to apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleDto.java index 752bd619..ddd38730 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleDto.java @@ -1,4 +1,4 @@ -package site.icebang.domain.workflow.dto; +package site.icebang.domain.schedule.dto; import java.time.Instant; diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleUpdateDto.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleUpdateDto.java new file mode 100644 index 00000000..6cb65f8b --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleUpdateDto.java @@ -0,0 +1,54 @@ +package site.icebang.domain.schedule.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 스케줄 수정 요청을 위한 DTO 클래스입니다. + * + *

기존 스케줄의 크론 표현식, 스케줄 텍스트, 활성화 상태 등을 수정할 때 사용합니다. + * + *

검증 규칙:

+ * + *
    + *
  • cronExpression: 필수값, Quartz 크론식 형식 + *
  • scheduleText: 선택값, 사용자 친화적 스케줄 설명 (예: "매일 오전 8시") + *
  • isActive: 필수값, 스케줄 활성화 여부 + *
+ * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleUpdateDto { + + /** + * Quartz 크론 표현식 + * + *

예시: "0 0 8 * * ?" (매일 오전 8시) + */ + @NotBlank(message = "크론 표현식은 필수입니다") + private String cronExpression; + + /** + * 사용자 친화적 스케줄 설명 텍스트 + * + *

예시: "매일 오전 8시", "매주 월요일 오후 6시" + */ + private String scheduleText; + + /** + * 스케줄 활성화 여부 + * + *

true: 활성화 (실행됨), false: 비활성화 (실행 안 됨) + */ + @NotNull(message = "활성화 상태는 필수입니다") + private Boolean isActive; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java index 07ac19ea..939781cb 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java @@ -93,4 +93,12 @@ Schedule findByWorkflowIdAndCronExpression( * @return 영향받은 행 수 */ int deactivateAllByWorkflowId(@Param("workflowId") Long workflowId); + + /** + * 스케줄 ID로 단건 조회 + * + * @param id 스케줄 ID + * @return 스케줄 정보, 없으면 null + */ + Schedule findById(@Param("id") Long id); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java index 667637b1..4c8d6196 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java @@ -1,25 +1,34 @@ package site.icebang.domain.schedule.service; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.Set; + import org.quartz.*; +import org.quartz.impl.matchers.GroupMatcher; import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.workflow.scheduler.WorkflowTriggerJob; /** * Spring Quartz 스케줄러의 Job과 Trigger를 동적으로 관리하는 서비스 클래스입니다. * - *

이 서비스는 데이터베이스에 정의된 {@code Schedule} 정보를 바탕으로, - * Quartz 엔진에 실제 실행 가능한 작업을 등록, 수정, 삭제하는 역할을 담당합니다. + *

이 서비스는 데이터베이스에 정의된 {@code Schedule} 정보를 바탕으로, Quartz 엔진에 실제 실행 가능한 작업을 등록, 수정, 삭제하는 + * 역할을 담당합니다. * *

주요 기능:

+ * *
    - *
  • DB의 스케줄 정보를 바탕으로 Quartz Job 및 Trigger 생성 또는 업데이트
  • - *
  • 기존에 등록된 Quartz 스케줄 삭제
  • + *
  • DB의 스케줄 정보를 바탕으로 Quartz Job 및 Trigger 생성 또는 업데이트 + *
  • 기존에 등록된 Quartz 스케줄 삭제 + *
  • 워크플로우의 모든 스케줄 일괄 삭제 + *
  • Quartz 클러스터 환경에서 안전한 동작 보장 *
* - * @author jihu0210@naver.com + * @author bwnfo0702@gmail.com * @since v0.1.0 */ @Slf4j @@ -33,9 +42,8 @@ public class QuartzScheduleService { /** * DB에 정의된 Schedule 객체를 기반으로 Quartz에 스케줄을 등록하거나 업데이트합니다. * - *

지정된 워크플로우 ID에 해당하는 Job이 이미 존재할 경우, 기존 Job과 Trigger를 삭제하고 - * 새로운 정보로 다시 생성하여 스케줄을 업데이트합니다. {@code JobDataMap}을 통해 - * 실행될 Job에게 어떤 워크플로우를 실행해야 하는지 ID를 전달합니다. + *

지정된 워크플로우 ID에 해당하는 Job이 이미 존재할 경우, 기존 Job과 Trigger를 삭제하고 새로운 정보로 다시 생성하여 스케줄을 + * 업데이트합니다. {@code JobDataMap}을 통해 실행될 Job에게 어떤 워크플로우를 실행해야 하는지 ID를 전달합니다. * * @param schedule Quartz에 등록할 스케줄 정보를 담은 도메인 모델 객체 * @since v0.1.0 @@ -43,19 +51,21 @@ public class QuartzScheduleService { public void addOrUpdateSchedule(Schedule schedule) { try { JobKey jobKey = JobKey.jobKey("workflow-" + schedule.getWorkflowId()); - JobDetail jobDetail = JobBuilder.newJob(WorkflowTriggerJob.class) - .withIdentity(jobKey) - .withDescription("Workflow " + schedule.getWorkflowId() + " Trigger Job") - .usingJobData("workflowId", schedule.getWorkflowId()) - .storeDurably() - .build(); + JobDetail jobDetail = + JobBuilder.newJob(WorkflowTriggerJob.class) + .withIdentity(jobKey) + .withDescription("Workflow " + schedule.getWorkflowId() + " Trigger Job") + .usingJobData("workflowId", schedule.getWorkflowId()) + .storeDurably() + .build(); TriggerKey triggerKey = TriggerKey.triggerKey("trigger-for-workflow-" + schedule.getWorkflowId()); - Trigger trigger = TriggerBuilder.newTrigger() - .forJob(jobDetail) - .withIdentity(triggerKey) - .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCronExpression())) - .build(); + Trigger trigger = + TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity(triggerKey) + .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCronExpression())) + .build(); if (scheduler.checkExists(jobKey)) { scheduler.deleteJob(jobKey); // 기존 Job 삭제 후 재생성 (업데이트) @@ -64,6 +74,7 @@ public void addOrUpdateSchedule(Schedule schedule) { log.info("Quartz 스케줄 등록/업데이트 완료: Workflow ID {}", schedule.getWorkflowId()); } catch (SchedulerException e) { log.error("Quartz 스케줄 등록 실패: Workflow ID " + schedule.getWorkflowId(), e); + throw new RuntimeException("Quartz 스케줄 등록 중 오류가 발생했습니다", e); } } @@ -77,11 +88,85 @@ public void deleteSchedule(Long workflowId) { try { JobKey jobKey = JobKey.jobKey("workflow-" + workflowId); if (scheduler.checkExists(jobKey)) { - scheduler.deleteJob(jobKey); - log.info("Quartz 스케줄 삭제 완료: Workflow ID {}", workflowId); + boolean deleted = scheduler.deleteJob(jobKey); + if (deleted) { + log.info("Quartz 스케줄 삭제 완료: Workflow ID {}", workflowId); + } else { + log.warn("Quartz 스케줄 삭제 실패: Workflow ID {}", workflowId); + } + } else { + log.debug("삭제할 Quartz 스케줄이 존재하지 않음: Workflow ID {}", workflowId); } } catch (SchedulerException e) { log.error("Quartz 스케줄 삭제 실패: Workflow ID " + workflowId, e); + throw new RuntimeException("Quartz 스케줄 삭제 중 오류가 발생했습니다", e); + } + } + + /** + * 워크플로우와 연결된 모든 Quartz 스케줄을 일괄 삭제합니다. + * + *

하나의 워크플로우에 여러 스케줄이 있을 수 있으므로, 관련된 모든 Job을 제거합니다. + * + * @param workflowId 워크플로우 ID + * @return 삭제된 스케줄 개수 + */ + public int deleteAllSchedulesForWorkflow(Long workflowId) { + try { + int deletedCount = 0; + + // 워크플로우 관련 모든 Job 키 조회 + Set jobKeys = scheduler.getJobKeys(GroupMatcher.anyJobGroup()); + + for (JobKey jobKey : jobKeys) { + // "workflow-{workflowId}" 형식의 Job 찾기 + if (jobKey.getName().equals("workflow-" + workflowId)) { + boolean deleted = scheduler.deleteJob(jobKey); + if (deleted) { + deletedCount++; + log.debug("Quartz Job 삭제: {}", jobKey); + } + } + } + + log.info("Quartz 스케줄 일괄 삭제 완료: Workflow ID {} - {}개 삭제", workflowId, deletedCount); + return deletedCount; + + } catch (SchedulerException e) { + log.error("Quartz 스케줄 일괄 삭제 실패: Workflow ID " + workflowId, e); + throw new RuntimeException("Quartz 스케줄 일괄 삭제 중 오류가 발생했습니다", e); + } + } + + /** + * Quartz 스케줄러에 등록된 모든 Job 목록을 조회합니다. + * + *

디버깅 및 모니터링 용도로 사용됩니다. + * + * @return 등록된 Job 키 목록 + */ + public Set getAllScheduledJobs() { + try { + return scheduler.getJobKeys(GroupMatcher.anyJobGroup()); + } catch (SchedulerException e) { + log.error("Quartz Job 목록 조회 실패", e); + throw new RuntimeException("Quartz Job 목록 조회 중 오류가 발생했습니다", e); + } + } + + /** + * 특정 워크플로우의 Quartz 스케줄이 등록되어 있는지 확인합니다. + * + * @param workflowId 워크플로우 ID + * @return 등록되어 있으면 true + */ + public boolean isScheduleRegistered(Long workflowId) { + try { + JobKey jobKey = JobKey.jobKey("workflow-" + workflowId); + return scheduler.checkExists(jobKey); + } catch (SchedulerException e) { + log.error("Quartz 스케줄 존재 확인 실패: Workflow ID " + workflowId, e); + return false; } } } \ No newline at end of file diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java new file mode 100644 index 00000000..95670f82 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java @@ -0,0 +1,308 @@ +package site.icebang.domain.schedule.service; + +import java.util.*; + +import org.quartz.CronExpression; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.common.exception.DuplicateDataException; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; +import site.icebang.domain.schedule.dto.ScheduleUpdateDto; +import site.icebang.domain.schedule.mapper.ScheduleMapper; +import site.icebang.domain.schedule.model.Schedule; + +/** + * 스케줄 관리를 위한 비즈니스 로직을 처리하는 서비스 클래스입니다. + * + *

이 서비스는 스케줄의 CRUD 작업과 Quartz 스케줄러와의 동기화를 담당합니다. + * + *

주요 기능:

+ * + *
    + *
  • 스케줄 조회 (단건, 목록) + *
  • 스케줄 수정 (크론식, 활성화 상태) + *
  • 스케줄 삭제 (논리 삭제) + *
  • 스케줄 활성화/비활성화 토글 + *
  • DB 변경 시 Quartz 실시간 동기화 + *
+ * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ScheduleService { + + private final ScheduleMapper scheduleMapper; + private final QuartzScheduleService quartzScheduleService; + + @Transactional + public Schedule createSchedule(Long workflowId, ScheduleCreateDto dto, Long userId) { + // 1. Schedule 엔티티 생성 + Schedule schedule = ScheduleCreateDto.toEntity(dto, workflowId, userId); + + // 2. DB에 저장 + scheduleMapper.insertSchedule(schedule); + + // 3. 활성화 상태면 Quartz에 등록 + if (schedule.isActive()) { + quartzScheduleService.addOrUpdateSchedule(schedule); + } + + return schedule; + } + + /** + * 특정 워크플로우의 모든 활성 스케줄을 조회합니다. + * + * @param workflowId 워크플로우 ID + * @return 활성 스케줄 목록 + */ + @Transactional(readOnly = true) + public List getSchedulesByWorkflowId(Long workflowId) { + log.debug("워크플로우 스케줄 조회: Workflow ID {}", workflowId); + return scheduleMapper.findAllByWorkflowId(workflowId); + } + + /** + * 스케줄 ID로 단건 조회합니다. + * + * @param scheduleId 스케줄 ID + * @return 스케줄 정보 + * @throws IllegalArgumentException 스케줄이 존재하지 않을 경우 + */ + @Transactional(readOnly = true) + public Schedule getScheduleById(Long scheduleId) { + Schedule schedule = scheduleMapper.findById(scheduleId); + if (schedule == null) { + throw new IllegalArgumentException("스케줄을 찾을 수 없습니다: " + scheduleId); + } + return schedule; + } + + /** + * 스케줄을 수정하고 Quartz에 실시간 반영합니다. + * + *

수정 프로세스: + * + *

    + *
  1. 크론 표현식 유효성 검증 + *
  2. DB 업데이트 + *
  3. Quartz 스케줄러에 변경사항 반영 (재등록) + *
  4. 비활성화된 경우 Quartz에서 제거 + *
+ * + * @param scheduleId 수정할 스케줄 ID + * @param dto 수정 정보 + * @param updatedBy 수정자 ID + * @throws IllegalArgumentException 스케줄이 존재하지 않거나 크론식이 유효하지 않을 경우 + */ + @Transactional + public void updateSchedule(Long scheduleId, ScheduleUpdateDto dto, Long updatedBy) { + log.info("스케줄 수정 시작: Schedule ID {}", scheduleId); + + // 1. 기존 스케줄 조회 + Schedule schedule = getScheduleById(scheduleId); + + // 2. 크론 표현식 유효성 검증 + if (!isValidCronExpression(dto.getCronExpression())) { + throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + dto.getCronExpression()); + } + + // 3. 스케줄 정보 업데이트 + schedule.setCronExpression(dto.getCronExpression()); + schedule.setScheduleText(dto.getScheduleText()); + schedule.setActive(dto.getIsActive()); + schedule.setUpdatedBy(updatedBy); + + // 4. DB 업데이트 + int result = scheduleMapper.updateSchedule(schedule); + if (result != 1) { + throw new RuntimeException("스케줄 수정에 실패했습니다: Schedule ID " + scheduleId); + } + + // 5. Quartz 실시간 동기화 + syncScheduleToQuartz(schedule); + + log.info( + "스케줄 수정 완료: Schedule ID {} - {} (활성화: {})", + scheduleId, + dto.getCronExpression(), + dto.getIsActive()); + } + + /** + * 스케줄 활성화 상태를 토글합니다. + * + *

활성화 → 비활성화 또는 비활성화 → 활성화로 전환하고 Quartz에 반영합니다. + * + * @param scheduleId 스케줄 ID + * @param isActive 변경할 활성화 상태 + * @throws IllegalArgumentException 스케줄이 존재하지 않을 경우 + */ + @Transactional + public void toggleScheduleActive(Long scheduleId, Boolean isActive) { + log.info("스케줄 활성화 상태 변경: Schedule ID {} - {}", scheduleId, isActive); + + // 1. 기존 스케줄 조회 + Schedule schedule = getScheduleById(scheduleId); + + // 2. DB 업데이트 + int result = scheduleMapper.updateActiveStatus(scheduleId, isActive); + if (result != 1) { + throw new RuntimeException("스케줄 활성화 상태 변경 실패: Schedule ID " + scheduleId); + } + + // 3. 스케줄 객체 상태 업데이트 + schedule.setActive(isActive); + + // 4. Quartz 실시간 동기화 + syncScheduleToQuartz(schedule); + + log.info("스케줄 활성화 상태 변경 완료: Schedule ID {} - {}", scheduleId, isActive); + } + + /** + * 스케줄을 삭제합니다 (논리 삭제). + * + *

DB에서 is_active를 false로 설정하고 Quartz에서도 제거합니다. + * + * @param scheduleId 삭제할 스케줄 ID + * @throws IllegalArgumentException 스케줄이 존재하지 않을 경우 + */ + @Transactional + public void deleteSchedule(Long scheduleId) { + log.info("스케줄 삭제 시작: Schedule ID {}", scheduleId); + + // 1. 기존 스케줄 조회 + Schedule schedule = getScheduleById(scheduleId); + + // 2. DB에서 논리 삭제 + int result = scheduleMapper.deleteSchedule(scheduleId); + if (result != 1) { + throw new RuntimeException("스케줄 삭제에 실패했습니다: Schedule ID " + scheduleId); + } + + // 3. Quartz에서 제거 + quartzScheduleService.deleteSchedule(schedule.getWorkflowId()); + + log.info("스케줄 삭제 완료: Schedule ID {}", scheduleId); + } + + /** + * 스케줄 변경사항을 Quartz 스케줄러에 동기화합니다. + * + *

활성화된 스케줄: Quartz에 등록/업데이트 비활성화된 스케줄: Quartz에서 제거 + * + * @param schedule 동기화할 스케줄 + */ + private void syncScheduleToQuartz(Schedule schedule) { + if (schedule.isActive()) { + // 활성화: Quartz에 등록 또는 업데이트 + quartzScheduleService.addOrUpdateSchedule(schedule); + log.debug("Quartz 스케줄 등록/업데이트: Workflow ID {}", schedule.getWorkflowId()); + } else { + // 비활성화: Quartz에서 제거 + quartzScheduleService.deleteSchedule(schedule.getWorkflowId()); + log.debug("Quartz 스케줄 제거: Workflow ID {}", schedule.getWorkflowId()); + } + } + + /** + * Quartz 크론 표현식 유효성 검증 + * + * @param cronExpression 검증할 크론 표현식 + * @return 유효하면 true + */ + private boolean isValidCronExpression(String cronExpression) { + try { + new CronExpression(cronExpression); + return true; + } catch (Exception e) { + log.warn("유효하지 않은 크론 표현식: {}", cronExpression, e); + return false; + } + } + + /** + * 스케줄 목록을 검증하고 등록합니다. + * + * @param workflowId 워크플로우 ID + * @param scheduleDtos 등록할 스케줄 목록 + * @param userId 생성자 ID + * @throws IllegalArgumentException 유효하지 않은 크론식 + * @throws DuplicateDataException 중복 크론식 발견 + */ + @Transactional + public void validateAndRegisterSchedules( + Long workflowId, List scheduleDtos, Long userId) { + + // 1. 검증 + validateSchedules(scheduleDtos); + + // 2. 등록 + for (ScheduleCreateDto dto : scheduleDtos) { + createSchedule(workflowId, dto, userId); + } + } + + /** 스케줄 목록 검증 (크론 표현식 유효성 및 중복 검사) */ + public void validateSchedules(List schedules) { + if (schedules == null || schedules.isEmpty()) { + return; + } + + Set cronExpressions = new HashSet<>(); + + for (ScheduleCreateDto schedule : schedules) { + String cron = schedule.getCronExpression(); + + // 크론 표현식 유효성 검증 + if (!isValidCronExpression(cron)) { + throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + cron); + } + + // 중복 크론식 검사 + if (cronExpressions.contains(cron)) { + throw new DuplicateDataException("중복된 크론 표현식이 있습니다: " + cron); + } + cronExpressions.add(cron); + } + } + + /** 워크플로우의 모든 스케줄을 비활성화합니다. */ + @Transactional + public void deactivateAllByWorkflowId(Long workflowId) { + log.info("워크플로우 스케줄 일괄 비활성화: Workflow ID {}", workflowId); + + // DB 비활성화 + scheduleMapper.deactivateAllByWorkflowId(workflowId); + + // Quartz 제거 + quartzScheduleService.deleteSchedule(workflowId); + } + + /** 워크플로우의 활성 스케줄을 Quartz에 재등록합니다. */ + @Transactional + public int reactivateAllByWorkflowId(Long workflowId) { + log.info("워크플로우 스케줄 일괄 재활성화: Workflow ID {}", workflowId); + + List activeSchedules = scheduleMapper.findAllByWorkflowId(workflowId); + int count = 0; + + for (Schedule schedule : activeSchedules) { + if (schedule.isActive()) { + quartzScheduleService.addOrUpdateSchedule(schedule); + count++; + } + } + + log.info("Quartz 재등록 완료: {}개 스케줄", count); + return count; + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/user/controller/UserController.java b/apps/user-service/src/main/java/site/icebang/domain/user/controller/UserController.java index db9b3fcf..432b4f64 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/user/controller/UserController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/user/controller/UserController.java @@ -6,10 +6,10 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import site.icebang.common.dto.ApiResponse; +import site.icebang.common.dto.ApiResponseDto; import site.icebang.domain.auth.model.AuthCredential; -import site.icebang.domain.user.dto.CheckEmailRequest; -import site.icebang.domain.user.dto.CheckEmailResponse; +import site.icebang.domain.user.dto.CheckEmailRequestDto; +import site.icebang.domain.user.dto.CheckEmailResponseDto; import site.icebang.domain.user.dto.UserProfileResponseDto; import site.icebang.domain.user.service.UserService; @@ -20,17 +20,18 @@ public class UserController { private final UserService userService; @PostMapping("/check-email") - public ApiResponse checkEmailAvailable( - @Valid @RequestBody CheckEmailRequest request) { + public ApiResponseDto checkEmailAvailable( + @Valid @RequestBody CheckEmailRequestDto request) { Boolean available = !userService.isExistEmail(request); String message = available.equals(Boolean.TRUE) ? "사용 가능한 이메일입니다." : "이미 가입된 이메일입니다."; - return ApiResponse.success(CheckEmailResponse.builder().available(available).build(), message); + return ApiResponseDto.success( + CheckEmailResponseDto.builder().available(available).build(), message); } @GetMapping("/me") - public ApiResponse getUserProfile( + public ApiResponseDto getUserProfile( @AuthenticationPrincipal AuthCredential user) { - return ApiResponse.success(UserProfileResponseDto.from(user)); + return ApiResponseDto.success(UserProfileResponseDto.from(user)); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequest.java b/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequestDto.java similarity index 91% rename from apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequest.java rename to apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequestDto.java index fb4c9844..3a8bc245 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequest.java +++ b/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailRequestDto.java @@ -9,7 +9,7 @@ @Data @AllArgsConstructor @NoArgsConstructor -public class CheckEmailRequest { +public class CheckEmailRequestDto { @NotBlank(message = "이메일은 필수입니다") @Email(message = "올바른 이메일 형식이 아닙니다") private String email; diff --git a/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailResponse.java b/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailResponseDto.java similarity index 77% rename from apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailResponse.java rename to apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailResponseDto.java index adda35d4..16aabd42 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailResponse.java +++ b/apps/user-service/src/main/java/site/icebang/domain/user/dto/CheckEmailResponseDto.java @@ -5,6 +5,6 @@ @Data @Builder -public class CheckEmailResponse { +public class CheckEmailResponseDto { private Boolean available; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/user/service/UserService.java b/apps/user-service/src/main/java/site/icebang/domain/user/service/UserService.java index e3dce655..28225405 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/user/service/UserService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/user/service/UserService.java @@ -6,7 +6,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import site.icebang.domain.user.dto.CheckEmailRequest; +import site.icebang.domain.user.dto.CheckEmailRequestDto; import site.icebang.domain.user.mapper.UserMapper; @Service @@ -15,7 +15,7 @@ public class UserService { private final UserMapper userMapper; @Transactional(readOnly = true) - public Boolean isExistEmail(@Valid CheckEmailRequest request) { + public Boolean isExistEmail(@Valid CheckEmailRequestDto request) { return userMapper.existsByEmail(request.getEmail()); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/TaskController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/TaskController.java index c0ba5542..f5ee13ae 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/TaskController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/TaskController.java @@ -10,7 +10,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import site.icebang.common.dto.ApiResponse; +import site.icebang.common.dto.ApiResponseDto; import site.icebang.domain.workflow.dto.TaskDto; import site.icebang.domain.workflow.model.TaskIoData; import site.icebang.domain.workflow.service.WorkflowService; @@ -46,7 +46,7 @@ public ResponseEntity> getTask(@PathVariable Long id) { * @return Task IO 데이터 목록 (created_at 기준 내림차순 정렬) */ @GetMapping("/io-data") - public ResponseEntity>> getTaskIoData( + public ResponseEntity>> getTaskIoData( @RequestParam List taskRunIds, @RequestParam(required = false) String ioType, @RequestParam(required = false) Integer limit) { @@ -54,11 +54,11 @@ public ResponseEntity>> getTaskIoData( try { List ioData = workflowService.getTaskIoDataByTaskRunIds(taskRunIds, ioType, limit); - return ResponseEntity.ok(ApiResponse.success(ioData, "Task IO 데이터 조회 성공")); + return ResponseEntity.ok(ApiResponseDto.success(ioData, "Task IO 데이터 조회 성공")); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body( - ApiResponse.error( + ApiResponseDto.error( "Task IO 데이터 조회 실패: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR)); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java index 2bc388af..e5c4057b 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,11 +10,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import site.icebang.common.dto.ApiResponse; -import site.icebang.common.dto.PageParams; -import site.icebang.common.dto.PageResult; +import site.icebang.common.dto.ApiResponseDto; +import site.icebang.common.dto.PageParamsDto; +import site.icebang.common.dto.PageResultDto; import site.icebang.domain.auth.model.AuthCredential; -import site.icebang.domain.workflow.dto.RequestContext; +import site.icebang.domain.workflow.dto.RequestContextDto; import site.icebang.domain.workflow.dto.WorkflowCardDto; import site.icebang.domain.workflow.dto.WorkflowCreateDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; @@ -31,41 +31,98 @@ public class WorkflowController { private final RequestContextService requestContextService; @GetMapping("") - public ApiResponse> getWorkflowList( - @ModelAttribute PageParams pageParams) { - PageResult result = workflowService.getPagedResult(pageParams); - return ApiResponse.success(result); + public ApiResponseDto> getWorkflowList( + @ModelAttribute PageParamsDto pageParamsDto) { + PageResultDto result = workflowService.getPagedResult(pageParamsDto); + return ApiResponseDto.success(result); } @PostMapping("") @ResponseStatus(HttpStatus.CREATED) - public ApiResponse createWorkflow( + public ApiResponseDto createWorkflow( @Valid @RequestBody WorkflowCreateDto workflowCreateDto, @AuthenticationPrincipal AuthCredential authCredential) { - // 인증 체크 - if (authCredential == null) { - throw new IllegalArgumentException("로그인이 필요합니다"); - } // AuthCredential에서 userId 추출 BigInteger userId = authCredential.getId(); workflowService.createWorkflow(workflowCreateDto, userId); - return ApiResponse.success(null); + return ApiResponseDto.success(null); } @PostMapping("/{workflowId}/run") public ResponseEntity runWorkflow(@PathVariable Long workflowId) { - RequestContext context = requestContextService.extractRequestContext(); + RequestContextDto context = requestContextService.extractRequestContext(); // HTTP 요청/응답 스레드를 블로킹하지 않도록 비동기 실행 workflowExecutionService.executeWorkflow(workflowId, context); return ResponseEntity.accepted().build(); } @GetMapping("/{workflowId}/detail") - public ApiResponse getWorkflowDetail(@PathVariable BigInteger workflowId) { + public ApiResponseDto getWorkflowDetail( + @PathVariable BigInteger workflowId) { WorkflowDetailCardDto result = workflowService.getWorkflowDetail(workflowId); - return ApiResponse.success(result); + return ApiResponseDto.success(result); + } + + /** + * 워크플로우를 삭제합니다 (논리 삭제). + * + *

워크플로우를 비활성화하고 모든 스케줄을 중단합니다. + * + * @param workflowId 삭제할 워크플로우 ID + * @return 성공 응답 + */ + @DeleteMapping("/{workflowId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponseDto deleteWorkflow(@PathVariable BigInteger workflowId) { + workflowService.deleteWorkflow(workflowId); + return ApiResponseDto.success(null); + } + + /** + * 워크플로우를 비활성화합니다. + * + *

워크플로우를 중단하고 모든 스케줄을 Quartz에서 제거합니다. + * + * @param workflowId 비활성화할 워크플로우 ID + * @return 성공 응답 + */ + @PatchMapping("/{workflowId}/deactivate") + public ApiResponseDto deactivateWorkflow(@PathVariable BigInteger workflowId) { + workflowService.deactivateWorkflow(workflowId); + return ApiResponseDto.success(null); + } + + /** + * 워크플로우를 활성화합니다. + * + *

워크플로우를 재개하고 모든 활성 스케줄을 Quartz에 재등록합니다. + * + * @param workflowId 활성화할 워크플로우 ID + * @return 성공 응답 + */ + @PatchMapping("/{workflowId}/activate") + public ApiResponseDto activateWorkflow(@PathVariable BigInteger workflowId) { + workflowService.activateWorkflow(workflowId); + return ApiResponseDto.success(null); + } + + /** + * 워크플로우의 특정 스케줄을 삭제합니다. + * + *

스케줄을 비활성화하고 Quartz에서 제거합니다. + * + * @param workflowId 워크플로우 ID + * @param scheduleId 삭제할 스케줄 ID + * @return 성공 응답 + */ + @DeleteMapping("/{workflowId}/schedules/{scheduleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponseDto deleteWorkflowSchedule( + @PathVariable BigInteger workflowId, @PathVariable Long scheduleId) { + workflowService.deleteWorkflowSchedule(workflowId, scheduleId); + return ApiResponseDto.success(null); } } 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 index 0f8535cf..272f3ed4 100644 --- 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 @@ -7,14 +7,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import site.icebang.common.dto.ApiResponse; -import site.icebang.common.dto.PageParams; -import site.icebang.common.dto.PageResult; +import site.icebang.common.dto.ApiResponseDto; +import site.icebang.common.dto.PageParamsDto; +import site.icebang.common.dto.PageResultDto; import site.icebang.domain.log.service.ExecutionLogService; import site.icebang.domain.workflow.dto.WorkflowHistoryDTO; -import site.icebang.domain.workflow.dto.WorkflowRunDetailResponse; +import site.icebang.domain.workflow.dto.WorkflowRunDetailResponseDto; import site.icebang.domain.workflow.dto.log.ExecutionLogSimpleDto; -import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteria; +import site.icebang.domain.workflow.dto.log.WorkflowLogQueryCriteriaDto; import site.icebang.domain.workflow.service.WorkflowHistoryService; @RestController @@ -25,10 +25,11 @@ public class WorkflowHistoryController { private final ExecutionLogService executionLogService; @GetMapping("") - public ApiResponse> getWorkflowHistoryList( - @ModelAttribute PageParams pageParams) { - PageResult response = workflowHistoryService.getPagedResult(pageParams); - return ApiResponse.success(response); + public ApiResponseDto> getWorkflowHistoryList( + @ModelAttribute PageParamsDto pageParamsDto) { + PageResultDto response = + workflowHistoryService.getPagedResult(pageParamsDto); + return ApiResponseDto.success(response); } /** @@ -38,15 +39,16 @@ public ApiResponse> getWorkflowHistoryList( * @return WorkflowRunDetailResponse */ @GetMapping("/{runId}") - public ApiResponse getWorkflowRunDetail(@PathVariable Long runId) { - WorkflowRunDetailResponse response = workflowHistoryService.getWorkflowRunDetail(runId); - return ApiResponse.success(response); + public ApiResponseDto getWorkflowRunDetail( + @PathVariable Long runId) { + WorkflowRunDetailResponseDto response = workflowHistoryService.getWorkflowRunDetail(runId); + return ApiResponseDto.success(response); } @GetMapping("/logs") - public ApiResponse> getTaskExecutionLog( - @Valid @ModelAttribute WorkflowLogQueryCriteria requestDto) { - return ApiResponse.success( + public ApiResponseDto> getTaskExecutionLog( + @Valid @ModelAttribute WorkflowLogQueryCriteriaDto requestDto) { + return ApiResponseDto.success( ExecutionLogSimpleDto.from(executionLogService.getRawLogs(requestDto))); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContext.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContextDto.java similarity index 79% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContext.java rename to apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContextDto.java index 1812cd32..66ef57aa 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContext.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/RequestContextDto.java @@ -4,7 +4,7 @@ /** 요청 컨텍스트 정보를 담는 DTO 클래스 분산 추적, 클라이언트 정보 등을 포함하여 워크플로우 실행 시 필요한 컨텍스트를 관리합니다. */ @Data -public class RequestContext { +public class RequestContextDto { private final String traceId; private final String clientIp; @@ -16,7 +16,7 @@ public class RequestContext { * @param traceId 분산 추적 ID * @return 스케줄러용 RequestContext 객체 (clientIp와 userAgent는 기본값 설정) */ - public static RequestContext forScheduler(String traceId) { - return new RequestContext(traceId, "scheduler", "quartz-scheduler"); + public static RequestContextDto forScheduler(String traceId) { + return new RequestContextDto(traceId, "scheduler", "quartz-scheduler"); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java index f14b2aeb..26825dc4 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java @@ -3,6 +3,8 @@ import java.math.BigInteger; import java.util.List; +import org.springframework.util.CollectionUtils; + import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.Valid; @@ -12,6 +14,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; + /** * 워크플로우 생성 요청 DTO * @@ -79,7 +83,7 @@ public class WorkflowCreateDto { // JSON 변환용 필드 (MyBatis에서 사용) private String defaultConfigJson; - public String genertateDefaultConfigJson() { + public String generateDefaultConfigJson() { StringBuilder jsonBuilder = new StringBuilder(); jsonBuilder.append("{"); @@ -133,6 +137,6 @@ public boolean hasPostingConfig() { * @return 스케줄이 1개 이상 있으면 true */ public boolean hasSchedules() { - return schedules != null && !schedules.isEmpty(); + return !CollectionUtils.isEmpty(schedules); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java index 175db6ac..b71448d0 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java @@ -6,6 +6,8 @@ import lombok.Data; +import site.icebang.domain.schedule.dto.ScheduleDto; + @Data public class WorkflowDetailCardDto extends WorkflowCardDto { private String defaultConfig; 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/WorkflowRunDetailResponseDto.java similarity index 88% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDetailResponse.java rename to apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDetailResponseDto.java index 194e8583..f939fe03 100644 --- 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/WorkflowRunDetailResponseDto.java @@ -11,7 +11,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class WorkflowRunDetailResponse { +public class WorkflowRunDetailResponseDto { private String traceId; private WorkflowRunDto workflowRun; private List jobRuns; 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/WorkflowRunLogsResponseDto.java similarity index 87% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunLogsResponse.java rename to apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunLogsResponseDto.java index ff5304f5..62a8cfca 100644 --- 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/WorkflowRunLogsResponseDto.java @@ -11,7 +11,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class WorkflowRunLogsResponse { +public class WorkflowRunLogsResponseDto { private String traceId; private List logs; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteriaDto.java similarity index 90% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java rename to apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteriaDto.java index f2c2ed06..6c67f0bd 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteria.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/log/WorkflowLogQueryCriteriaDto.java @@ -8,7 +8,7 @@ @Data @Builder -public class WorkflowLogQueryCriteria { +public class WorkflowLogQueryCriteriaDto { private final String traceId; private final BigInteger sourceId; 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 index aec0bb36..a1ba5b09 100644 --- 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 @@ -4,7 +4,7 @@ import org.apache.ibatis.annotations.Mapper; -import site.icebang.common.dto.PageParams; +import site.icebang.common.dto.PageParamsDto; import site.icebang.domain.workflow.dto.JobRunDto; import site.icebang.domain.workflow.dto.TaskRunDto; import site.icebang.domain.workflow.dto.WorkflowHistoryDTO; @@ -55,16 +55,16 @@ public interface WorkflowHistoryMapper { /** * 페이지네이션을 적용한 워크플로우 히스토리 목록을 조회합니다. * - * @param pageParams 페이지 매개변수 + * @param pageParamsDto 페이지 매개변수 * @return 워크플로우 히스토리 정보 목록 */ - List selectWorkflowHistoryList(PageParams pageParams); + List selectWorkflowHistoryList(PageParamsDto pageParamsDto); /** * 워크플로우 런 인스턴스의 총 개수를 조회합니다. * - * @param pageParams 페이지 매개변수 + * @param pageParamsDto 페이지 매개변수 * @return 총 결과 개수 */ - int selectWorkflowHistoryCount(PageParams pageParams); + int selectWorkflowHistoryCount(PageParamsDto pageParamsDto); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java index 417dfd1d..0d61a781 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java @@ -3,15 +3,17 @@ import java.math.BigInteger; import java.util.*; -import site.icebang.common.dto.PageParams; -import site.icebang.domain.workflow.dto.ScheduleDto; +import org.apache.ibatis.annotations.Param; + +import site.icebang.common.dto.PageParamsDto; +import site.icebang.domain.schedule.dto.ScheduleDto; import site.icebang.domain.workflow.dto.WorkflowCardDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; public interface WorkflowMapper { - List selectWorkflowList(PageParams pageParams); + List selectWorkflowList(PageParamsDto pageParamsDto); - int selectWorkflowCount(PageParams pageParams); + int selectWorkflowCount(PageParamsDto pageParamsDto); int insertWorkflow(Map params); // insert workflow @@ -31,4 +33,9 @@ public interface WorkflowMapper { List selectSchedulesByWorkflowId(BigInteger workflowId); List> selectWorkflowWithJobsAndTasks(BigInteger workflowId); + + int updateWorkflowEnabled( + @Param("workflowId") BigInteger workflowId, @Param("isEnabled") Boolean isEnabled); + + int markAsDeleted(@Param("workflowId") BigInteger workflowId); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java index a4c501af..dd74a066 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/RequestContextService.java @@ -5,7 +5,7 @@ import org.slf4j.MDC; import org.springframework.stereotype.Service; -import site.icebang.domain.workflow.dto.RequestContext; +import site.icebang.domain.workflow.dto.RequestContextDto; /** 요청 컨텍스트 정보를 추출하고 관리하는 서비스 MDC(Mapped Diagnostic Context)를 사용하여 분산 추적 정보를 처리합니다. */ @Service @@ -16,12 +16,12 @@ public class RequestContextService { * * @return 추출된 요청 컨텍스트 */ - public RequestContext extractRequestContext() { + public RequestContextDto extractRequestContext() { String traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); String clientIp = MDC.get("clientIp"); String userAgent = MDC.get("userAgent"); - return new RequestContext(traceId, clientIp, userAgent); + return new RequestContextDto(traceId, clientIp, userAgent); } /** @@ -29,9 +29,9 @@ public RequestContext extractRequestContext() { * * @return 스케줄러용 요청 컨텍스트 */ - public RequestContext quartzContext() { + public RequestContextDto quartzContext() { String traceId = MDC.get("traceId") != null ? MDC.get("traceId") : UUID.randomUUID().toString(); - return RequestContext.forScheduler(traceId); + return RequestContextDto.forScheduler(traceId); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java index 073d81ce..b18b87fe 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java @@ -16,7 +16,7 @@ import lombok.RequiredArgsConstructor; import site.icebang.domain.workflow.dto.JobDto; -import site.icebang.domain.workflow.dto.RequestContext; +import site.icebang.domain.workflow.dto.RequestContextDto; import site.icebang.domain.workflow.dto.TaskDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; import site.icebang.domain.workflow.manager.ExecutionMdcManager; @@ -51,7 +51,7 @@ public class WorkflowExecutionService { private final WorkflowMapper workflowMapper; @Async("traceExecutor") - public void executeWorkflow(Long workflowId, RequestContext context) { + public void executeWorkflow(Long workflowId, RequestContextDto context) { WorkflowRun workflowRun = WorkflowRun.start(workflowId, context.getTraceId()); workflowRunMapper.insert(workflowRun); 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 index 17887630..578bba14 100644 --- 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 @@ -7,15 +7,15 @@ import lombok.RequiredArgsConstructor; -import site.icebang.common.dto.PageParams; -import site.icebang.common.dto.PageResult; +import site.icebang.common.dto.PageParamsDto; +import site.icebang.common.dto.PageResultDto; import site.icebang.common.service.PageableService; 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.WorkflowRunDetailResponse; +import site.icebang.domain.workflow.dto.WorkflowRunDetailResponseDto; import site.icebang.domain.workflow.dto.WorkflowRunDto; -import site.icebang.domain.workflow.dto.WorkflowRunLogsResponse; +import site.icebang.domain.workflow.dto.WorkflowRunLogsResponseDto; import site.icebang.domain.workflow.mapper.WorkflowHistoryMapper; /** @@ -44,19 +44,19 @@ public class WorkflowHistoryService implements PageableService이 메소드는 {@code PageableService} 인터페이스를 구현하며, {@code PageResult} 유틸리티를 사용하여 전체 카운트 쿼리와 목록 조회 * 쿼리를 실행하고 페이징 결과를 생성합니다. * - * @param pageParams 페이징 처리에 필요한 파라미터 (페이지 번호, 페이지 크기 등) + * @param pageParamsDto 페이징 처리에 필요한 파라미터 (페이지 번호, 페이지 크기 등) * @return 페이징 처리된 워크플로우 실행 이력 목록 - * @see PageResult + * @see PageResultDto * @since v0.1.0 */ @Override @Transactional(readOnly = true) - public PageResult getPagedResult(PageParams pageParams) { + public PageResultDto getPagedResult(PageParamsDto pageParamsDto) { - return PageResult.from( - pageParams, - () -> workflowHistoryMapper.selectWorkflowHistoryList(pageParams), - () -> workflowHistoryMapper.selectWorkflowHistoryCount(pageParams)); + return PageResultDto.from( + pageParamsDto, + () -> workflowHistoryMapper.selectWorkflowHistoryList(pageParamsDto), + () -> workflowHistoryMapper.selectWorkflowHistoryCount(pageParamsDto)); } /** @@ -70,7 +70,7 @@ public PageResult getPagedResult(PageParams pageParams) { * @since v0.1.0 */ @Transactional(readOnly = true) - public WorkflowRunDetailResponse getWorkflowRunDetail(Long runId) { + public WorkflowRunDetailResponseDto getWorkflowRunDetail(Long runId) { // 1. 워크플로우 실행 정보 조회 WorkflowRunDto workflowRunDto = workflowHistoryMapper.selectWorkflowRun(runId); @@ -90,7 +90,7 @@ public WorkflowRunDetailResponse getWorkflowRunDetail(Long runId) { // 4. TraceId 조회 String traceId = workflowHistoryMapper.selectTraceIdByRunId(runId); - return WorkflowRunDetailResponse.builder() + return WorkflowRunDetailResponseDto.builder() .workflowRun(workflowRunDto) .jobRuns(jobRunDtos) .traceId(traceId) @@ -104,7 +104,7 @@ public WorkflowRunDetailResponse getWorkflowRunDetail(Long runId) { * @return 워크플로우 실행 로그 응답 객체 * @since v0.1.0 */ - public WorkflowRunLogsResponse getWorkflowRunLogs(Long runId) { + public WorkflowRunLogsResponseDto getWorkflowRunLogs(Long runId) { // TODO: 구현 예정 return null; } @@ -116,7 +116,7 @@ public WorkflowRunLogsResponse getWorkflowRunLogs(Long runId) { * @return 워크플로우 실행 상세 응답 객체 * @since v0.1.0 */ - public WorkflowRunDetailResponse getWorkflowRunByTraceId(String traceId) { + public WorkflowRunDetailResponseDto getWorkflowRunByTraceId(String traceId) { // TODO: 구현 예정 return null; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index 6362d061..adebc611 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java @@ -3,25 +3,21 @@ import java.math.BigInteger; import java.time.Instant; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; -import org.quartz.CronExpression; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import site.icebang.common.dto.PageParams; -import site.icebang.common.dto.PageResult; +import site.icebang.common.dto.PageParamsDto; +import site.icebang.common.dto.PageResultDto; import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.service.PageableService; -import site.icebang.domain.schedule.mapper.ScheduleMapper; -import site.icebang.domain.schedule.model.Schedule; -import site.icebang.domain.schedule.service.QuartzScheduleService; +import site.icebang.domain.schedule.dto.ScheduleDto; +import site.icebang.domain.schedule.service.ScheduleService; import site.icebang.domain.workflow.dto.*; import site.icebang.domain.workflow.mapper.JobMapper; import site.icebang.domain.workflow.mapper.TaskIoDataMapper; @@ -50,8 +46,7 @@ public class WorkflowService implements PageableService { private final WorkflowMapper workflowMapper; - private final ScheduleMapper scheduleMapper; - private final QuartzScheduleService quartzScheduleService; + private final ScheduleService scheduleService; private final JobMapper jobMapper; private final TaskMapper taskMapper; private final TaskIoDataMapper taskIoDataMapper; @@ -62,18 +57,18 @@ public class WorkflowService implements PageableService { *

이 메소드는 {@code PageableService} 인터페이스를 구현하며, {@code PageResult} 유틸리티를 사용하여 전체 카운트 쿼리와 목록 조회 * 쿼리를 실행하고 페이징 결과를 생성합니다. * - * @param pageParams 페이징 처리에 필요한 파라미터 (페이지 번호, 페이지 크기 등) + * @param pageParamsDto 페이징 처리에 필요한 파라미터 (페이지 번호, 페이지 크기 등) * @return 페이징 처리된 워크플로우 카드 목록 - * @see PageResult + * @see PageResultDto * @since v0.1.0 */ @Override @Transactional(readOnly = true) - public PageResult getPagedResult(PageParams pageParams) { - return PageResult.from( - pageParams, - () -> workflowMapper.selectWorkflowList(pageParams), - () -> workflowMapper.selectWorkflowCount(pageParams)); + public PageResultDto getPagedResult(PageParamsDto pageParamsDto) { + return PageResultDto.from( + pageParamsDto, + () -> workflowMapper.selectWorkflowList(pageParamsDto), + () -> workflowMapper.selectWorkflowCount(pageParamsDto)); } /** @@ -123,24 +118,22 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 2. 비즈니스 검증 validateBusinessRules(dto); - // 3. 스케줄 검증 (있는 경우만) + // 3. 스케줄 검증 - ScheduleService로 위임 if (dto.hasSchedules()) { - validateSchedules(dto.getSchedules()); + scheduleService.validateSchedules(dto.getSchedules()); } // 4. 워크플로우 이름 중복 체크 if (workflowMapper.existsByName(dto.getName())) { - throw new IllegalArgumentException("이미 존재하는 워크플로우 이름입니다: " + dto.getName()); + throw new DuplicateDataException("이미 존재하는 워크플로우 이름입니다: " + dto.getName()); } // 5. 워크플로우 생성 Long workflowId = null; try { - // JSON 설정 생성 - String defaultConfigJson = dto.genertateDefaultConfigJson(); + String defaultConfigJson = dto.generateDefaultConfigJson(); dto.setDefaultConfigJson(defaultConfigJson); - // DB 삽입 파라미터 구성 Map params = new HashMap<>(); params.put("dto", dto); params.put("createdBy", createdBy); @@ -150,7 +143,6 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { throw new RuntimeException("워크플로우 생성에 실패했습니다"); } - // 생성된 workflow ID 추출 Object generatedId = params.get("id"); workflowId = (generatedId instanceof BigInteger) @@ -164,9 +156,10 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { throw new RuntimeException("워크플로우 생성 중 오류가 발생했습니다", e); } - // 6. 스케줄 등록 (있는 경우만) + // 6. 스케줄 등록 - ScheduleService로 위임 if (dto.hasSchedules() && workflowId != null) { - registerSchedules(workflowId, dto.getSchedules(), createdBy.longValue()); + scheduleService.validateAndRegisterSchedules( + workflowId, dto.getSchedules(), createdBy.longValue()); } } @@ -299,115 +292,105 @@ private void validateBusinessRules(WorkflowCreateDto dto) { } /** - * 스케줄 목록 검증 + * 워크플로우를 비활성화하고 모든 스케줄을 중단합니다. * - *

크론 표현식 유효성 및 중복 검사를 수행합니다. + *

워크플로우와 연결된 모든 스케줄을 비활성화하고, Quartz 스케줄러에서도 제거합니다. * - * @param schedules 검증할 스케줄 목록 - * @throws IllegalArgumentException 유효하지 않은 크론식 - * @throws DuplicateDataException 중복 크론식 발견 + * @param workflowId 비활성화할 워크플로우 ID + * @throws IllegalArgumentException 워크플로우가 존재하지 않을 경우 */ - private void validateSchedules(List schedules) { - if (schedules == null || schedules.isEmpty()) { - return; - } + @Transactional + public void deactivateWorkflow(BigInteger workflowId) { + log.info("워크플로우 비활성화 시작: Workflow ID {}", workflowId); - // 중복 크론식 검사 (같은 요청 내에서) - Set cronExpressions = new HashSet<>(); + // 1. 워크플로우 존재 확인 + WorkflowDetailCardDto workflow = workflowMapper.selectWorkflowDetailById(workflowId); + if (workflow == null) { + throw new IllegalArgumentException("워크플로우를 찾을 수 없습니다: " + workflowId); + } - for (ScheduleCreateDto schedule : schedules) { - String cron = schedule.getCronExpression(); + // 2. 워크플로우 비활성화 + int result = workflowMapper.updateWorkflowEnabled(workflowId, false); + if (result != 1) { + throw new RuntimeException("워크플로우 비활성화에 실패했습니다: " + workflowId); + } - // 1. 크론 표현식 유효성 검증 (Quartz 기준) - if (!isValidCronExpression(cron)) { - throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + cron); - } + // 3. 스케줄 비활성화 - ScheduleService로 위임 + scheduleService.deactivateAllByWorkflowId(workflowId.longValue()); - // 2. 중복 크론식 검사 - if (cronExpressions.contains(cron)) { - throw new DuplicateDataException("중복된 크론 표현식이 있습니다: " + cron); - } - cronExpressions.add(cron); - } + log.info("워크플로우 비활성화 완료: Workflow ID {}", workflowId); } /** - * Quartz 크론 표현식 유효성 검증 + * 워크플로우를 활성화하고 모든 스케줄을 재등록합니다. + * + *

워크플로우를 활성화하고, 연결된 활성 스케줄들을 Quartz에 재등록합니다. * - * @param cronExpression 검증할 크론 표현식 - * @return 유효하면 true + * @param workflowId 활성화할 워크플로우 ID + * @throws IllegalArgumentException 워크플로우가 존재하지 않을 경우 */ - private boolean isValidCronExpression(String cronExpression) { - try { - new CronExpression(cronExpression); - return true; - } catch (Exception e) { - log.warn("유효하지 않은 크론 표현식: {}", cronExpression, e); - return false; + @Transactional + public void activateWorkflow(BigInteger workflowId) { + log.info("워크플로우 활성화 시작: Workflow ID {}", workflowId); + + // 1. 워크플로우 존재 확인 + WorkflowDetailCardDto workflow = workflowMapper.selectWorkflowDetailById(workflowId); + if (workflow == null) { + throw new IllegalArgumentException("워크플로우를 찾을 수 없습니다: " + workflowId); } + + // 2. 워크플로우 활성화 + int result = workflowMapper.updateWorkflowEnabled(workflowId, true); + if (result != 1) { + throw new RuntimeException("워크플로우 활성화에 실패했습니다: " + workflowId); + } + + // 3. 스케줄 재활성화 - ScheduleService로 위임 + int reactivatedCount = scheduleService.reactivateAllByWorkflowId(workflowId.longValue()); + + log.info("워크플로우 활성화 완료: Workflow ID {} - {}개 스케줄 재등록", workflowId, reactivatedCount); } /** - * 스케줄 목록 등록 (DB 저장 + Quartz 등록) + * 워크플로우를 삭제합니다 (논리 삭제). * - *

트랜잭션 내에서 DB 저장을 수행하고, Quartz 등록은 실패해도 워크플로우는 유지되도록 예외를 로그로만 처리합니다. + *

워크플로우를 비활성화하고, 모든 스케줄을 중단하며, Quartz에서 제거합니다. 실제 DB에서 삭제하지 않고 비활성화 처리합니다. * - * @param workflowId 워크플로우 ID - * @param scheduleCreateDtos 등록할 스케줄 목록 - * @param userId 생성자 ID + * @param workflowId 삭제할 워크플로우 ID + * @throws IllegalArgumentException 워크플로우가 존재하지 않을 경우 */ - private void registerSchedules( - Long workflowId, List scheduleCreateDtos, Long userId) { - if (scheduleCreateDtos == null || scheduleCreateDtos.isEmpty()) { - return; - } - - log.info("스케줄 등록 시작: Workflow ID {} - {}개", workflowId, scheduleCreateDtos.size()); + @Transactional + public void deleteWorkflow(BigInteger workflowId) { + log.info("워크플로우 삭제 시작: Workflow ID {}", workflowId); - int successCount = 0; - int failCount = 0; + // 1. 워크플로우 존재 확인 + WorkflowDetailCardDto workflow = workflowMapper.selectWorkflowDetailById(workflowId); + if (workflow == null) { + throw new IllegalArgumentException("워크플로우를 찾을 수 없습니다: " + workflowId); + } - for (ScheduleCreateDto dto : scheduleCreateDtos) { - try { - // 1. DTO → Model 변환 - Schedule schedule = dto.toEntity(workflowId, userId); + // 2. 워크플로우 비활성화 (논리 삭제) + deactivateWorkflow(workflowId); - // 2. DB 중복 체크 (같은 워크플로우 + 같은 크론식) - if (scheduleMapper.existsByWorkflowIdAndCronExpression( - workflowId, schedule.getCronExpression())) { - throw new DuplicateDataException( - "이미 동일한 크론식의 스케줄이 존재합니다: " + schedule.getCronExpression()); - } + log.info("워크플로우 삭제 완료: Workflow ID {}", workflowId); + } - // 3. DB 저장 - int insertResult = scheduleMapper.insertSchedule(schedule); - if (insertResult != 1) { - log.error("스케줄 DB 저장 실패: Workflow ID {} - {}", workflowId, schedule.getCronExpression()); - failCount++; - continue; - } + /** + * 워크플로우의 특정 스케줄만 삭제합니다. + * + *

스케줄을 DB에서 비활성화하고 Quartz에서 제거합니다. + * + * @param workflowId 워크플로우 ID + * @param scheduleId 삭제할 스케줄 ID + * @throws IllegalArgumentException 스케줄이 존재하지 않거나 워크플로우에 속하지 않을 경우 + */ + @Transactional + public void deleteWorkflowSchedule(BigInteger workflowId, Long scheduleId) { + log.info("워크플로우 스케줄 삭제 시작: Workflow ID {}, Schedule ID {}", workflowId, scheduleId); - // 4. Quartz 등록 (실시간 반영) - quartzScheduleService.addOrUpdateSchedule(schedule); - - log.info( - "스케줄 등록 완료: Workflow ID {} - {} ({})", - workflowId, - schedule.getCronExpression(), - schedule.getScheduleText()); - successCount++; - - } catch (DuplicateDataException e) { - log.warn("스케줄 중복으로 등록 건너뜀: Workflow ID {} - {}", workflowId, dto.getCronExpression()); - failCount++; - // 중복은 경고만 하고 계속 진행 - } catch (Exception e) { - log.error("스케줄 등록 실패: Workflow ID {} - {}", workflowId, dto.getCronExpression(), e); - failCount++; - // 스케줄 등록 실패해도 워크플로우는 유지 - } - } + // ScheduleService로 위임하여 검증 + 삭제 처리 + scheduleService.deleteSchedule(scheduleId); - log.info("스케줄 등록 완료: Workflow ID {} - 성공 {}개, 실패 {}개", workflowId, successCount, failCount); + log.info("워크플로우 스케줄 삭제 완료: Workflow ID {}, Schedule ID {}", workflowId, scheduleId); } } diff --git a/apps/user-service/src/main/java/site/icebang/global/config/QuartzConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/QuartzConfig.java new file mode 100644 index 00000000..665b7995 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/config/QuartzConfig.java @@ -0,0 +1,96 @@ +package site.icebang.global.config; + +import java.util.Properties; + +import javax.sql.DataSource; + +import org.quartz.spi.JobFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.quartz.QuartzDataSource; +import org.springframework.boot.autoconfigure.quartz.QuartzProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.springframework.scheduling.quartz.SpringBeanJobFactory; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Quartz Scheduler의 핵심 설정을 담당하는 Configuration 클래스입니다. + * + *

이 클래스는 Quartz Scheduler가 클러스터 환경에서 안전하게 동작하고, Spring Bean을 Job 내에서 주입받을 수 있도록 + * 스케줄러 인스턴스를 구성합니다. + * + *

주요 기능:

+ * + *
    + *
  • Quartz 전용 DataSource 분리 설정 + *
  • 클러스터링 활성화를 위한 Quartz Properties 구성 + *
  • Spring Bean 주입이 가능한 JobFactory 등록 + *
+ * + *

클러스터링 동작 원리:

+ * + *

여러 애플리케이션 인스턴스(Pod)가 동일한 DB를 공유하며, Quartz 테이블(QRTZ_*)을 통해 Job 실행 상태를 동기화합니다. + * 각 인스턴스는 주기적으로(기본 20초) 체크인하며, 특정 시점에 하나의 인스턴스만 Job을 실행하도록 보장합니다. + * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class QuartzConfig { + + private final ApplicationContext applicationContext; + private final QuartzProperties quartzProperties; + + /** + * Spring Bean을 Quartz Job에서 사용할 수 있도록 하는 JobFactory를 생성합니다. + * + *

기본 Quartz JobFactory는 Spring의 ApplicationContext를 인식하지 못하므로, Spring Bean 주입이 + * 불가능합니다. 이 Bean을 통해 Job 클래스 내에서 {@code @Autowired}를 사용할 수 있게 됩니다. + * + * @return Spring Bean 주입이 가능한 JobFactory + */ + @Bean + public JobFactory jobFactory() { + SpringBeanJobFactory jobFactory = new SpringBeanJobFactory(); + jobFactory.setApplicationContext(applicationContext); + log.info("Spring Bean 주입 가능한 JobFactory 생성 완료"); + return jobFactory; + } + + @Bean + public SchedulerFactoryBean schedulerFactoryBean( + DataSource dataSource, + JobFactory jobFactory) { + + SchedulerFactoryBean factory = new SchedulerFactoryBean(); + + // 1. 메인 DataSource 사용 (Quartz 전용 DataSource 제거) + factory.setDataSource(dataSource); + + // 2. Spring Bean 주입 가능한 JobFactory 설정 + factory.setJobFactory(jobFactory); + + // 3. Quartz Properties 설정 (클러스터링 포함) + Properties properties = new Properties(); + properties.putAll(quartzProperties.getProperties()); + properties.setProperty("org.quartz.threadPool.threadCount", "10"); + + factory.setQuartzProperties(properties); + factory.setApplicationContextSchedulerContextKey("applicationContext"); + factory.setAutoStartup(true); + factory.setOverwriteExistingJobs(false); + + log.info("Quartz SchedulerFactoryBean 설정 완료 (Clustering: {})", + properties.getProperty("org.quartz.jobStore.isClustered")); + + return factory; + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java index 1c064368..6a17766c 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/GlobalExceptionHandler.java @@ -14,14 +14,14 @@ import lombok.extern.slf4j.Slf4j; -import site.icebang.common.dto.ApiResponse; +import site.icebang.common.dto.ApiResponseDto; import site.icebang.common.exception.DuplicateDataException; /** * 전역 예외 처리기 (Global Exception Handler). * - *

이 클래스는 애플리케이션 전역에서 발생하는 예외를 {@link ApiResponse} 형태로 변환하여 클라이언트에게 반환합니다. 예외 유형에 따라 적절한 {@link - * HttpStatus} 코드를 설정하며, 공통적인 예외 처리 로직을 중앙화합니다. + *

이 클래스는 애플리케이션 전역에서 발생하는 예외를 {@link ApiResponseDto} 형태로 변환하여 클라이언트에게 반환합니다. 예외 유형에 따라 적절한 + * {@link HttpStatus} 코드를 설정하며, 공통적인 예외 처리 로직을 중앙화합니다. * *

처리되는 주요 예외는 다음과 같습니다: * @@ -44,91 +44,92 @@ public class GlobalExceptionHandler { * 요청 데이터 유효성 검증 실패 시 발생하는 예외를 처리합니다. * * @param ex 발생한 {@link MethodArgumentNotValidException} - * @return {@link ApiResponse} - 검증 실패 메시지와 {@link HttpStatus#BAD_REQUEST} + * @return {@link ApiResponseDto} - 검증 실패 메시지와 {@link HttpStatus#BAD_REQUEST} */ @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) - public ApiResponse handleValidation(MethodArgumentNotValidException ex) { + public ApiResponseDto handleValidation(MethodArgumentNotValidException ex) { String errorMessage = ex.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining(", ")); - return ApiResponse.error("입력 값 검증 실패: " + errorMessage, HttpStatus.BAD_REQUEST); + return ApiResponseDto.error("입력 값 검증 실패: " + errorMessage, HttpStatus.BAD_REQUEST); } /** * 처리되지 않은 모든 일반 예외를 처리합니다. 서버 내부 오류로 간주되며, 에러 로그를 남깁니다. * * @param ex 발생한 {@link Exception} - * @return {@link ApiResponse} - 내부 오류 메시지와 {@link HttpStatus#INTERNAL_SERVER_ERROR} + * @return {@link ApiResponseDto} - 내부 오류 메시지와 {@link HttpStatus#INTERNAL_SERVER_ERROR} */ @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public ApiResponse handleGeneric(Exception ex) { + public ApiResponseDto handleGeneric(Exception ex) { log.error(ex.getMessage(), ex); - return ApiResponse.error("Internal error: ", HttpStatus.INTERNAL_SERVER_ERROR); + return ApiResponseDto.error("Internal error: ", HttpStatus.INTERNAL_SERVER_ERROR); } /** * 존재하지 않는 리소스 접근 시 발생하는 예외를 처리합니다. * * @param ex 발생한 {@link NoResourceFoundException} - * @return {@link ApiResponse} - 리소스 없음 메시지와 {@link HttpStatus#NOT_FOUND} + * @return {@link ApiResponseDto} - 리소스 없음 메시지와 {@link HttpStatus#NOT_FOUND} */ @ExceptionHandler(NoResourceFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) - public ApiResponse handleNotFound(NoResourceFoundException ex) { - return ApiResponse.error("Notfound: " + ex.getMessage(), HttpStatus.NOT_FOUND); + public ApiResponseDto handleNotFound(NoResourceFoundException ex) { + return ApiResponseDto.error("Notfound: " + ex.getMessage(), HttpStatus.NOT_FOUND); } /** * 인증 실패 시 발생하는 예외를 처리합니다. * * @param ex 발생한 {@link AuthenticationException} - * @return {@link ApiResponse} - 인증 실패 메시지와 {@link HttpStatus#UNAUTHORIZED} + * @return {@link ApiResponseDto} - 인증 실패 메시지와 {@link HttpStatus#UNAUTHORIZED} */ @ExceptionHandler(AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) - public ApiResponse handleAuthentication(AuthenticationException ex) { - return ApiResponse.error("Authentication failed: " + ex.getMessage(), HttpStatus.UNAUTHORIZED); + public ApiResponseDto handleAuthentication(AuthenticationException ex) { + return ApiResponseDto.error( + "Authentication failed: " + ex.getMessage(), HttpStatus.UNAUTHORIZED); } /** * 인가(권한) 실패 시 발생하는 예외를 처리합니다. * * @param ex 발생한 {@link AccessDeniedException} - * @return {@link ApiResponse} - 접근 거부 메시지와 {@link HttpStatus#FORBIDDEN} + * @return {@link ApiResponseDto} - 접근 거부 메시지와 {@link HttpStatus#FORBIDDEN} */ @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) - public ApiResponse handleAccessDenied(AccessDeniedException ex) { - return ApiResponse.error("Access denied: " + ex.getMessage(), HttpStatus.FORBIDDEN); + public ApiResponseDto handleAccessDenied(AccessDeniedException ex) { + return ApiResponseDto.error("Access denied: " + ex.getMessage(), HttpStatus.FORBIDDEN); } /** * 중복 데이터 발생 시 발생하는 예외를 처리합니다. * * @param ex 발생한 {@link DuplicateDataException} - * @return {@link ApiResponse} - 중복 데이터 메시지와 {@link HttpStatus#CONFLICT} + * @return {@link ApiResponseDto} - 중복 데이터 메시지와 {@link HttpStatus#CONFLICT} */ @ExceptionHandler(DuplicateDataException.class) @ResponseStatus(HttpStatus.CONFLICT) - public ApiResponse handleDuplicateData(DuplicateDataException ex) { + public ApiResponseDto handleDuplicateData(DuplicateDataException ex) { log.warn(ex.getMessage(), ex); - return ApiResponse.error("Duplicate: " + ex.getMessage(), HttpStatus.CONFLICT); + return ApiResponseDto.error("Duplicate: " + ex.getMessage(), HttpStatus.CONFLICT); } /** * IllegalArgumentException을 400 Bad Request로 처리합니다. WorkflowService에서 던지는 검증 오류를 처리하기 위해 추가되었습니다. * * @param ex 발생한 {@link IllegalArgumentException} - * @return {@link ApiResponse} - 검증 실패 메시지와 {@link HttpStatus#BAD_REQUEST} + * @return {@link ApiResponseDto} - 검증 실패 메시지와 {@link HttpStatus#BAD_REQUEST} */ @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) - public ApiResponse handleIllegalArgument(IllegalArgumentException ex) { + public ApiResponseDto handleIllegalArgument(IllegalArgumentException ex) { log.warn("Validation failed: {}", ex.getMessage()); - return ApiResponse.error("입력값 검증 실패: " + ex.getMessage(), HttpStatus.BAD_REQUEST); + return ApiResponseDto.error("입력값 검증 실패: " + ex.getMessage(), HttpStatus.BAD_REQUEST); } } diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java index 9e6672f3..0b4e7950 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAccessDeniedHandler.java @@ -13,7 +13,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import site.icebang.common.dto.ApiResponse; +import site.icebang.common.dto.ApiResponseDto; /** * 접근 거부 처리기 (REST 전용 AccessDeniedHandler). @@ -25,7 +25,7 @@ * *

    *
  • HTTP 상태 코드: {@link HttpStatus#FORBIDDEN} (403) - *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지 + *
  • 응답 본문: {@link ApiResponseDto} 형식의 에러 메시지 *
  • 응답 Content-Type: {@code application/json;charset=UTF-8} *
* @@ -50,7 +50,7 @@ public class RestAccessDeniedHandler implements AccessDeniedHandler { public void handle( HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) throws IOException { - ApiResponse body = ApiResponse.error("Access denied", HttpStatus.FORBIDDEN); + ApiResponseDto body = ApiResponseDto.error("Access denied", HttpStatus.FORBIDDEN); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); diff --git a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java index 9d3ec7b5..8f9255c4 100644 --- a/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java +++ b/apps/user-service/src/main/java/site/icebang/global/handler/exception/RestAuthenticationEntryPoint.java @@ -13,7 +13,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import site.icebang.common.dto.ApiResponse; +import site.icebang.common.dto.ApiResponseDto; /** * 인증 진입점 처리기 (REST 전용 AuthenticationEntryPoint). @@ -25,7 +25,7 @@ * *
    *
  • HTTP 상태 코드: {@link HttpStatus#UNAUTHORIZED} (401) - *
  • 응답 본문: {@link ApiResponse} 형식의 에러 메시지 + *
  • 응답 본문: {@link ApiResponseDto} 형식의 에러 메시지 *
  • 응답 Content-Type: {@code application/json;charset=UTF-8} *
* @@ -50,8 +50,8 @@ public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { public void commence( HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) throws IOException { - ApiResponse body = - ApiResponse.error("Authentication required", HttpStatus.UNAUTHORIZED); + ApiResponseDto body = + ApiResponseDto.error("Authentication required", HttpStatus.UNAUTHORIZED); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); diff --git a/apps/user-service/src/main/resources/application-develop.yml b/apps/user-service/src/main/resources/application-develop.yml index 64e1a0be..49b275e0 100644 --- a/apps/user-service/src/main/resources/application-develop.yml +++ b/apps/user-service/src/main/resources/application-develop.yml @@ -39,6 +39,12 @@ spring: jdbc: initialize-schema: never platform: mysql # MariaDB는 mysql 스크립트와 호환 + # 📌 Quartz의 Clustering설정 + properties: + org.quartz.scheduler.instanceName: IcebangScheduler + org.quartz.scheduler.instanceId: AUTO # 자동 ID 생성 + org.quartz.jobStore.isClustered: true # 클러스터링 활성화 + org.quartz.jobStore.clusterCheckinInterval: 20000 # 20초마다 체크인 sql: init: diff --git a/apps/user-service/src/main/resources/mybatis/mapper/OrganizationMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/OrganizationMapper.xml index 40abe4d5..d992be29 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/OrganizationMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/OrganizationMapper.xml @@ -14,7 +14,7 @@ + SELECT * + FROM schedule + WHERE id = #{id} + LIMIT 1 + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml index 290f53e4..4a87c364 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowHistoryMapper.xml @@ -68,7 +68,7 @@ WHERE id = #{runId} - - SELECT w.id, @@ -19,7 +19,7 @@ LIMIT #{pageSize} OFFSET #{offset} -