diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 29f095ea..096e6d65 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -44,8 +44,8 @@ dependencies { // MyBatis implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5' - // batch - implementation 'org.springframework.boot:spring-boot-starter-batch' + // Scheduler + implementation 'org.springframework.boot:spring-boot-starter-quartz' implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml' diff --git a/apps/user-service/src/main/java/site/icebang/UserServiceApplication.java b/apps/user-service/src/main/java/site/icebang/UserServiceApplication.java index 68da9f2a..29e975ba 100644 --- a/apps/user-service/src/main/java/site/icebang/UserServiceApplication.java +++ b/apps/user-service/src/main/java/site/icebang/UserServiceApplication.java @@ -1,13 +1,9 @@ package site.icebang; import org.mybatis.spring.annotation.MapperScan; -import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableScheduling; -@EnableScheduling -@EnableBatchProcessing @SpringBootApplication @MapperScan("site.icebang.**.mapper") public class UserServiceApplication { diff --git a/apps/user-service/src/main/java/site/icebang/batch/common/JobContextKeys.java b/apps/user-service/src/main/java/site/icebang/batch/common/JobContextKeys.java deleted file mode 100644 index d28b7bd0..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/common/JobContextKeys.java +++ /dev/null @@ -1,15 +0,0 @@ -package site.icebang.batch.common; - -/** - * Spring Batch의 JobExecutionContext에서 Step 간 데이터 공유를 위해 사용되는 Key들을 상수로 정의하는 인터페이스. 모든 Tasklet은 이 - * 인터페이스를 참조하여 데이터의 일관성을 유지합니다. - */ -public interface JobContextKeys { - - String EXTRACTED_KEYWORD = "extractedKeyword"; - String SEARCHED_PRODUCTS = "searchedProducts"; - String MATCHED_PRODUCTS = "matchedProducts"; - String SELECTED_PRODUCT = "selectedProduct"; - String CRAWLED_PRODUCT_DETAIL = "crawledProductDetail"; - String GENERATED_CONTENT = "generatedContent"; -} diff --git a/apps/user-service/src/main/java/site/icebang/batch/job/BlogAutomationJobConfig.java b/apps/user-service/src/main/java/site/icebang/batch/job/BlogAutomationJobConfig.java deleted file mode 100644 index d0c934b9..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/job/BlogAutomationJobConfig.java +++ /dev/null @@ -1,115 +0,0 @@ -package site.icebang.batch.job; // 패키지 경로 수정 - -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -import lombok.RequiredArgsConstructor; - -import site.icebang.batch.tasklet.*; - -/** [배치 시스템 구현] 트렌드 기반 블로그 자동화 워크플로우를 구성하는 Job들을 정의합니다. */ -@Configuration -@RequiredArgsConstructor -public class BlogAutomationJobConfig { - - // --- Tasklets --- - private final ExtractTrendKeywordTasklet extractTrendKeywordTask; - private final SearchProductsFromMallTasklet searchProductsFromMallTask; - private final MatchProductWithKeywordTasklet matchProductWithKeywordTask; - private final FindSimilarProductsTasklet findSimilarProductsTask; - private final CrawlSelectedProductTasklet crawlSelectedProductTask; - private final GenerateBlogContentTasklet generateBlogContentTask; - private final PublishBlogPostTasklet publishBlogPostTask; - - /** Job 1: 상품 선정 및 정보 수집 키워드 추출부터 최종 상품 정보 크롤링까지의 과정을 책임집니다. */ - @Bean - public Job productSelectionJob( - JobRepository jobRepository, - Step extractTrendKeywordStep, - Step searchProductsFromMallStep, - Step matchProductWithKeywordStep, - Step findSimilarProductsStep, - Step crawlSelectedProductStep) { - return new JobBuilder("productSelectionJob", jobRepository) - .start(extractTrendKeywordStep) - .next(searchProductsFromMallStep) - .next(matchProductWithKeywordStep) - .next(findSimilarProductsStep) - .next(crawlSelectedProductStep) - .build(); - } - - /** Job 2: 콘텐츠 생성 및 발행 수집된 상품 정보로 블로그 콘텐츠를 생성하고 발행합니다. */ - @Bean - public Job contentPublishingJob( - JobRepository jobRepository, Step generateBlogContentStep, Step publishBlogPostStep) { - return new JobBuilder("contentPublishingJob", jobRepository) - .start(generateBlogContentStep) - .next(publishBlogPostStep) - .build(); - } - - // --- Steps for productSelectionJob --- - @Bean - public Step extractTrendKeywordStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("extractTrendKeywordStep", jobRepository) - .tasklet(extractTrendKeywordTask, transactionManager) - .build(); - } - - @Bean - public Step searchProductsFromMallStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("searchProductsFromMallStep", jobRepository) - .tasklet(searchProductsFromMallTask, transactionManager) - .build(); - } - - @Bean - public Step matchProductWithKeywordStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("matchProductWithKeywordStep", jobRepository) - .tasklet(matchProductWithKeywordTask, transactionManager) - .build(); - } - - @Bean - public Step findSimilarProductsStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("findSimilarProductsStep", jobRepository) - .tasklet(findSimilarProductsTask, transactionManager) - .build(); - } - - @Bean - public Step crawlSelectedProductStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("crawlSelectedProductStep", jobRepository) - .tasklet(crawlSelectedProductTask, transactionManager) - .build(); - } - - // --- Steps for contentPublishingJob --- - @Bean - public Step generateBlogContentStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("generateBlogContentStep", jobRepository) - .tasklet(generateBlogContentTask, transactionManager) - .build(); - } - - @Bean - public Step publishBlogPostStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("publishBlogPostStep", jobRepository) - .tasklet(publishBlogPostTask, transactionManager) - .build(); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/batch/tasklet/CrawlSelectedProductTasklet.java b/apps/user-service/src/main/java/site/icebang/batch/tasklet/CrawlSelectedProductTasklet.java deleted file mode 100644 index 6a182c37..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/tasklet/CrawlSelectedProductTasklet.java +++ /dev/null @@ -1,60 +0,0 @@ -package site.icebang.batch.tasklet; - -import java.util.Map; - -import org.springframework.batch.core.StepContribution; -import org.springframework.batch.core.scope.context.ChunkContext; -import org.springframework.batch.core.step.tasklet.Tasklet; -import org.springframework.batch.item.ExecutionContext; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.batch.common.JobContextKeys; -import site.icebang.external.fastapi.adapter.FastApiAdapter; -import site.icebang.external.fastapi.dto.FastApiDto.RequestSsadaguCrawl; -import site.icebang.external.fastapi.dto.FastApiDto.ResponseSsadaguCrawl; - -@Slf4j -@Component -@RequiredArgsConstructor -public class CrawlSelectedProductTasklet implements Tasklet { - - private final FastApiAdapter fastApiAdapter; - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - // log.info(">>>> [Step 5] 최종 상품 크롤링 Tasklet 실행 시작"); - - ExecutionContext jobExecutionContext = getJobExecutionContext(chunkContext); - Map selectedProduct = - (Map) jobExecutionContext.get(JobContextKeys.SELECTED_PRODUCT); - - if (selectedProduct == null || !selectedProduct.containsKey("link")) { - throw new RuntimeException("크롤링할 상품 URL이 없습니다."); - } - String productUrl = (String) selectedProduct.get("link"); - - RequestSsadaguCrawl request = new RequestSsadaguCrawl(1, 1, null, "detail", productUrl); - ResponseSsadaguCrawl response = fastApiAdapter.requestProductCrawl(request); - - if (response == null || !"200".equals(response.status())) { - throw new RuntimeException("FastAPI 상품 크롤링에 실패했습니다."); - } - - Map productDetail = response.productDetail(); - log.info(">>>> FastAPI로부터 크롤링된 상품 상세 정보 획득"); - - jobExecutionContext.put(JobContextKeys.CRAWLED_PRODUCT_DETAIL, productDetail); - - // log.info(">>>> [Step 5] 최종 상품 크롤링 Tasklet 실행 완료"); - return RepeatStatus.FINISHED; - } - - private ExecutionContext getJobExecutionContext(ChunkContext chunkContext) { - return chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/batch/tasklet/ExtractTrendKeywordTasklet.java b/apps/user-service/src/main/java/site/icebang/batch/tasklet/ExtractTrendKeywordTasklet.java deleted file mode 100644 index a35bebf9..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/tasklet/ExtractTrendKeywordTasklet.java +++ /dev/null @@ -1,51 +0,0 @@ -package site.icebang.batch.tasklet; - -import org.springframework.batch.core.StepContribution; -import org.springframework.batch.core.scope.context.ChunkContext; -import org.springframework.batch.core.step.tasklet.Tasklet; -import org.springframework.batch.item.ExecutionContext; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.batch.common.JobContextKeys; -import site.icebang.external.fastapi.adapter.FastApiAdapter; -import site.icebang.external.fastapi.dto.FastApiDto.RequestNaverSearch; -import site.icebang.external.fastapi.dto.FastApiDto.ResponseNaverSearch; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ExtractTrendKeywordTasklet implements Tasklet { - - private final FastApiAdapter fastApiAdapter; - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - // log.info(">>>> [Step 1] 키워드 추출 Tasklet 실행 시작"); - - RequestNaverSearch request = - new RequestNaverSearch(1, 1, null, "naver", "50000000", null, null); - ResponseNaverSearch response = fastApiAdapter.requestNaverKeywordSearch(request); - - if (response == null || !"200".equals(response.status())) { - throw new RuntimeException("FastAPI로부터 키워드를 추출하는 데 실패했습니다."); - } - String extractedKeyword = response.keyword(); - log.info(">>>> FastAPI로부터 추출된 키워드: {}", extractedKeyword); - - ExecutionContext jobExecutionContext = getJobExecutionContext(chunkContext); - // 다른 클래스의 상수를 직접 참조하는 대신 공용 인터페이스의 키를 사용 - jobExecutionContext.put(JobContextKeys.EXTRACTED_KEYWORD, extractedKeyword); - - // log.info(">>>> [Step 1] 키워드 추출 Tasklet 실행 완료"); - return RepeatStatus.FINISHED; - } - - private ExecutionContext getJobExecutionContext(ChunkContext chunkContext) { - return chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/batch/tasklet/FindSimilarProductsTasklet.java b/apps/user-service/src/main/java/site/icebang/batch/tasklet/FindSimilarProductsTasklet.java deleted file mode 100644 index 316641e1..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/tasklet/FindSimilarProductsTasklet.java +++ /dev/null @@ -1,60 +0,0 @@ -package site.icebang.batch.tasklet; - -import java.util.List; -import java.util.Map; - -import org.springframework.batch.core.StepContribution; -import org.springframework.batch.core.scope.context.ChunkContext; -import org.springframework.batch.core.step.tasklet.Tasklet; -import org.springframework.batch.item.ExecutionContext; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.batch.common.JobContextKeys; -import site.icebang.external.fastapi.adapter.FastApiAdapter; -import site.icebang.external.fastapi.dto.FastApiDto.RequestSsadaguSimilarity; -import site.icebang.external.fastapi.dto.FastApiDto.ResponseSsadaguSimilarity; - -@Slf4j -@Component -@RequiredArgsConstructor -public class FindSimilarProductsTasklet implements Tasklet { - - private final FastApiAdapter fastApiAdapter; - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - // log.info(">>>> [Step 4] 상품 유사도 분석 Tasklet 실행 시작"); - - ExecutionContext jobExecutionContext = getJobExecutionContext(chunkContext); - String keyword = (String) jobExecutionContext.get(JobContextKeys.EXTRACTED_KEYWORD); - List> matchedProducts = - (List>) jobExecutionContext.get(JobContextKeys.MATCHED_PRODUCTS); - List> searchResults = - (List>) jobExecutionContext.get(JobContextKeys.SEARCHED_PRODUCTS); - - RequestSsadaguSimilarity request = - new RequestSsadaguSimilarity(1, 1, null, keyword, matchedProducts, searchResults); - ResponseSsadaguSimilarity response = fastApiAdapter.requestProductSimilarity(request); - - if (response == null || !"200".equals(response.status())) { - throw new RuntimeException("FastAPI 상품 유사도 분석에 실패했습니다."); - } - - Map selectedProduct = response.selectedProduct(); - log.info(">>>> FastAPI로부터 최종 선택된 상품: {}", selectedProduct.get("title")); - - jobExecutionContext.put(JobContextKeys.SELECTED_PRODUCT, selectedProduct); - - // log.info(">>>> [Step 4] 상품 유사도 분석 Tasklet 실행 완료"); - return RepeatStatus.FINISHED; - } - - private ExecutionContext getJobExecutionContext(ChunkContext chunkContext) { - return chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/batch/tasklet/GenerateBlogContentTasklet.java b/apps/user-service/src/main/java/site/icebang/batch/tasklet/GenerateBlogContentTasklet.java deleted file mode 100644 index ecf44cbb..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/tasklet/GenerateBlogContentTasklet.java +++ /dev/null @@ -1,62 +0,0 @@ -package site.icebang.batch.tasklet; - -import java.util.List; -import java.util.Map; - -import org.springframework.batch.core.StepContribution; -import org.springframework.batch.core.scope.context.ChunkContext; -import org.springframework.batch.core.step.tasklet.Tasklet; -import org.springframework.batch.item.ExecutionContext; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.batch.common.JobContextKeys; -import site.icebang.external.fastapi.adapter.FastApiAdapter; -import site.icebang.external.fastapi.dto.FastApiDto.RequestBlogCreate; -import site.icebang.external.fastapi.dto.FastApiDto.ResponseBlogCreate; - -@Slf4j -@Component -@RequiredArgsConstructor -public class GenerateBlogContentTasklet implements Tasklet { - - private final FastApiAdapter fastApiAdapter; - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - // log.info(">>>> [Step 6] 블로그 콘텐츠 생성 Tasklet 실행 시작"); - - ExecutionContext jobExecutionContext = getJobExecutionContext(chunkContext); - Map productDetail = - (Map) jobExecutionContext.get(JobContextKeys.CRAWLED_PRODUCT_DETAIL); - - // TODO: productDetail을 기반으로 LLM에 전달할 프롬프트 생성 - RequestBlogCreate request = new RequestBlogCreate(1, 1, null); - ResponseBlogCreate response = fastApiAdapter.requestBlogCreation(request); - - if (response == null || !"200".equals(response.status())) { - throw new RuntimeException("FastAPI 블로그 콘텐츠 생성에 실패했습니다."); - } - - // TODO: 실제 생성된 콘텐츠를 response로부터 받아와야 함 (현재는 더미 데이터) - Map generatedContent = - Map.of( - "title", "엄청난 상품을 소개합니다! " + productDetail.get("title"), - "content", "이 상품은 정말... 좋습니다. 상세 정보: " + productDetail.toString(), - "tags", List.of("상품리뷰", "최고")); - log.info(">>>> FastAPI로부터 블로그 콘텐츠 생성 완료"); - - jobExecutionContext.put(JobContextKeys.GENERATED_CONTENT, generatedContent); - - // log.info(">>>> [Step 6] 블로그 콘텐츠 생성 Tasklet 실행 완료"); - return RepeatStatus.FINISHED; - } - - private ExecutionContext getJobExecutionContext(ChunkContext chunkContext) { - return chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/batch/tasklet/MatchProductWithKeywordTasklet.java b/apps/user-service/src/main/java/site/icebang/batch/tasklet/MatchProductWithKeywordTasklet.java deleted file mode 100644 index bdb15200..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/tasklet/MatchProductWithKeywordTasklet.java +++ /dev/null @@ -1,57 +0,0 @@ -package site.icebang.batch.tasklet; - -import java.util.List; -import java.util.Map; - -import org.springframework.batch.core.StepContribution; -import org.springframework.batch.core.scope.context.ChunkContext; -import org.springframework.batch.core.step.tasklet.Tasklet; -import org.springframework.batch.item.ExecutionContext; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.batch.common.JobContextKeys; -import site.icebang.external.fastapi.adapter.FastApiAdapter; -import site.icebang.external.fastapi.dto.FastApiDto.RequestSsadaguMatch; -import site.icebang.external.fastapi.dto.FastApiDto.ResponseSsadaguMatch; - -@Slf4j -@Component -@RequiredArgsConstructor -public class MatchProductWithKeywordTasklet implements Tasklet { - - private final FastApiAdapter fastApiAdapter; - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - // log.info(">>>> [Step 3] 상품 매칭 Tasklet 실행 시작"); - - ExecutionContext jobExecutionContext = getJobExecutionContext(chunkContext); - String keyword = (String) jobExecutionContext.get(JobContextKeys.EXTRACTED_KEYWORD); - List> searchResults = - (List>) jobExecutionContext.get(JobContextKeys.SEARCHED_PRODUCTS); - - RequestSsadaguMatch request = new RequestSsadaguMatch(1, 1, null, keyword, searchResults); - ResponseSsadaguMatch response = fastApiAdapter.requestProductMatch(request); - - if (response == null || !"200".equals(response.status())) { - throw new RuntimeException("FastAPI 상품 매칭에 실패했습니다."); - } - - List> matchedProducts = response.matchedProducts(); - log.info(">>>> FastAPI로부터 매칭된 상품 {}개", matchedProducts.size()); - - jobExecutionContext.put(JobContextKeys.MATCHED_PRODUCTS, matchedProducts); - - log.info(">>>> [Step 3] 상품 매칭 Tasklet 실행 완료"); - return RepeatStatus.FINISHED; - } - - private ExecutionContext getJobExecutionContext(ChunkContext chunkContext) { - return chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/batch/tasklet/PublishBlogPostTasklet.java b/apps/user-service/src/main/java/site/icebang/batch/tasklet/PublishBlogPostTasklet.java deleted file mode 100644 index e1b75a18..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/tasklet/PublishBlogPostTasklet.java +++ /dev/null @@ -1,68 +0,0 @@ -package site.icebang.batch.tasklet; - -import java.util.List; -import java.util.Map; - -import org.springframework.batch.core.StepContribution; -import org.springframework.batch.core.scope.context.ChunkContext; -import org.springframework.batch.core.step.tasklet.Tasklet; -import org.springframework.batch.item.ExecutionContext; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.batch.common.JobContextKeys; -import site.icebang.external.fastapi.adapter.FastApiAdapter; -import site.icebang.external.fastapi.dto.FastApiDto.RequestBlogPublish; -import site.icebang.external.fastapi.dto.FastApiDto.ResponseBlogPublish; - -@Slf4j -@Component -@RequiredArgsConstructor -public class PublishBlogPostTasklet implements Tasklet { - - private final FastApiAdapter fastApiAdapter; - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - // log.info(">>>> [Step 7] 블로그 발행 Tasklet 실행 시작"); - - ExecutionContext jobExecutionContext = getJobExecutionContext(chunkContext); - Map content = - (Map) jobExecutionContext.get(JobContextKeys.GENERATED_CONTENT); - - // TODO: UserConfig 등에서 실제 블로그 정보(ID, PW)를 가져와야 함 - String blogId = "my_blog_id"; - String blogPw = "my_blog_password"; - - RequestBlogPublish request = - new RequestBlogPublish( - 1, - 1, - null, - "naver", - blogId, - blogPw, - (String) content.get("title"), - (String) content.get("content"), - (List) content.get("tags")); - - ResponseBlogPublish response = fastApiAdapter.requestBlogPost(request); - - if (response == null || !"200".equals(response.status())) { - throw new RuntimeException("FastAPI 블로그 발행에 실패했습니다."); - } - - log.info(">>>> FastAPI를 통해 블로그 발행 성공: {}", response.metadata()); - - // log.info(">>>> [Step 7] 블로그 발행 Tasklet 실행 완료"); - return RepeatStatus.FINISHED; - } - - private ExecutionContext getJobExecutionContext(ChunkContext chunkContext) { - return chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/batch/tasklet/SearchProductsFromMallTasklet.java b/apps/user-service/src/main/java/site/icebang/batch/tasklet/SearchProductsFromMallTasklet.java deleted file mode 100644 index 3480f391..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/tasklet/SearchProductsFromMallTasklet.java +++ /dev/null @@ -1,58 +0,0 @@ -package site.icebang.batch.tasklet; - -import java.util.List; -import java.util.Map; - -import org.springframework.batch.core.StepContribution; -import org.springframework.batch.core.scope.context.ChunkContext; -import org.springframework.batch.core.step.tasklet.Tasklet; -import org.springframework.batch.item.ExecutionContext; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.batch.common.JobContextKeys; -import site.icebang.external.fastapi.adapter.FastApiAdapter; -import site.icebang.external.fastapi.dto.FastApiDto.RequestSsadaguSearch; -import site.icebang.external.fastapi.dto.FastApiDto.ResponseSsadaguSearch; - -@Slf4j -@Component -@RequiredArgsConstructor -public class SearchProductsFromMallTasklet implements Tasklet { - - private final FastApiAdapter fastApiAdapter; - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - // log.info(">>>> [Step 2] 상품 검색 Tasklet 실행 시작"); - - ExecutionContext jobExecutionContext = getJobExecutionContext(chunkContext); - String keyword = (String) jobExecutionContext.get(JobContextKeys.EXTRACTED_KEYWORD); - - if (keyword == null) { - throw new RuntimeException("이전 Step에서 키워드를 전달받지 못했습니다."); - } - - RequestSsadaguSearch request = new RequestSsadaguSearch(1, 1, null, keyword); - ResponseSsadaguSearch response = fastApiAdapter.requestSsadaguProductSearch(request); - - if (response == null || !"200".equals(response.status())) { - throw new RuntimeException("FastAPI 상품 검색에 실패했습니다."); - } - List> searchResults = response.searchResults(); - log.info(">>>> FastAPI로부터 검색된 상품 {}개", searchResults.size()); - - jobExecutionContext.put(JobContextKeys.SEARCHED_PRODUCTS, searchResults); - - // log.info(">>>> [Step 2] 상품 검색 Tasklet 실행 완료"); - return RepeatStatus.FINISHED; - } - - private ExecutionContext getJobExecutionContext(ChunkContext chunkContext) { - return chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/domain/execution/mapper/JobRunMapper.java b/apps/user-service/src/main/java/site/icebang/domain/execution/mapper/JobRunMapper.java new file mode 100644 index 00000000..d5ce7e8f --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/execution/mapper/JobRunMapper.java @@ -0,0 +1,12 @@ +package site.icebang.domain.execution.mapper; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.domain.execution.model.JobRun; + +@Mapper +public interface JobRunMapper { + void insert(JobRun jobRun); + + void update(JobRun jobRun); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/execution/mapper/TaskRunMapper.java b/apps/user-service/src/main/java/site/icebang/domain/execution/mapper/TaskRunMapper.java new file mode 100644 index 00000000..646a7c91 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/execution/mapper/TaskRunMapper.java @@ -0,0 +1,12 @@ +package site.icebang.domain.execution.mapper; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.domain.execution.model.TaskRun; + +@Mapper +public interface TaskRunMapper { + void insert(TaskRun taskRun); + + void update(TaskRun taskRun); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/execution/mapper/WorkflowRunMapper.java b/apps/user-service/src/main/java/site/icebang/domain/execution/mapper/WorkflowRunMapper.java new file mode 100644 index 00000000..776ec4b0 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/execution/mapper/WorkflowRunMapper.java @@ -0,0 +1,12 @@ +package site.icebang.domain.execution.mapper; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.domain.execution.model.WorkflowRun; + +@Mapper +public interface WorkflowRunMapper { + void insert(WorkflowRun workflowRun); + + void update(WorkflowRun workflowRun); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/execution/model/JobRun.java b/apps/user-service/src/main/java/site/icebang/domain/execution/model/JobRun.java new file mode 100644 index 00000000..f5310f12 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/execution/model/JobRun.java @@ -0,0 +1,38 @@ +package site.icebang.domain.execution.model; + +import java.time.LocalDateTime; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class JobRun { + + private Long id; + private Long workflowRunId; + private Long jobId; + private String status; // PENDING, RUNNING, SUCCESS, FAILED + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private LocalDateTime createdAt; + + private JobRun(Long workflowRunId, Long jobId) { + this.workflowRunId = workflowRunId; + this.jobId = jobId; + this.status = "RUNNING"; + this.startedAt = LocalDateTime.now(); + this.createdAt = this.startedAt; + } + + /** Job 실행 시작을 위한 정적 팩토리 메소드 */ + public static JobRun start(Long workflowRunId, Long jobId) { + return new JobRun(workflowRunId, jobId); + } + + /** Job 실행 완료 처리 */ + public void finish(String status) { + this.status = status; + this.finishedAt = LocalDateTime.now(); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/execution/model/TaskRun.java b/apps/user-service/src/main/java/site/icebang/domain/execution/model/TaskRun.java new file mode 100644 index 00000000..f1ae2239 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/execution/model/TaskRun.java @@ -0,0 +1,43 @@ +package site.icebang.domain.execution.model; + +import java.time.LocalDateTime; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TaskRun { + + private Long id; + private Long jobRunId; + private Long taskId; + private String status; // PENDING, RUNNING, SUCCESS, FAILED + private String resultMessage; // 실행 결과 메시지 + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private LocalDateTime createdAt; + + // 생성자나 정적 팩토리 메서드를 통해 객체 생성 로직을 관리 + private TaskRun(Long jobRunId, Long taskId) { + this.jobRunId = jobRunId; + this.taskId = taskId; + this.status = "PENDING"; + this.createdAt = LocalDateTime.now(); + } + + /** Task 실행 시작을 위한 정적 팩토리 메서드 */ + public static TaskRun start(Long jobRunId, Long taskId) { + TaskRun taskRun = new TaskRun(jobRunId, taskId); + taskRun.status = "RUNNING"; + taskRun.startedAt = LocalDateTime.now(); + return taskRun; + } + + /** Task 실행 완료 처리 */ + public void finish(String status, String resultMessage) { + this.status = status; + this.resultMessage = resultMessage; + this.finishedAt = LocalDateTime.now(); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/execution/model/WorkflowRun.java b/apps/user-service/src/main/java/site/icebang/domain/execution/model/WorkflowRun.java new file mode 100644 index 00000000..6bd5dbc9 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/execution/model/WorkflowRun.java @@ -0,0 +1,39 @@ +package site.icebang.domain.execution.model; + +import java.time.LocalDateTime; +import java.util.UUID; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class WorkflowRun { + + private Long id; + private Long workflowId; + private String traceId; // 분산 추적을 위한 ID + private String status; // PENDING, RUNNING, SUCCESS, FAILED + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private LocalDateTime createdAt; + + private WorkflowRun(Long workflowId) { + this.workflowId = workflowId; + this.traceId = UUID.randomUUID().toString(); // 고유 추적 ID 생성 + this.status = "RUNNING"; + this.startedAt = LocalDateTime.now(); + this.createdAt = this.startedAt; + } + + /** 워크플로우 실행 시작을 위한 정적 팩토리 메소드 */ + public static WorkflowRun start(Long workflowId) { + return new WorkflowRun(workflowId); + } + + /** 워크플로우 실행 완료 처리 */ + public void finish(String status) { + this.status = status; + this.finishedAt = LocalDateTime.now(); + } +} 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 new file mode 100644 index 00000000..12567a60 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java @@ -0,0 +1,12 @@ +package site.icebang.domain.schedule.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.domain.schedule.model.Schedule; + +@Mapper +public interface ScheduleMapper { + List findAllActive(); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/model/Schedule.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/model/Schedule.java new file mode 100644 index 00000000..c2218bd0 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/model/Schedule.java @@ -0,0 +1,31 @@ +package site.icebang.domain.schedule.model; + +import java.time.LocalDateTime; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter // 서비스 레이어에서의 상태 변경 및 MyBatis 매핑을 위해 사용 +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Schedule { + + private Long id; + private Long workflowId; + private String cronExpression; + private String parameters; // JSON format + private boolean isActive; + private String lastRunStatus; + private LocalDateTime lastRunAt; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; + private String scheduleText; +} 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 new file mode 100644 index 00000000..3a5f1aef --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java @@ -0,0 +1,43 @@ +package site.icebang.domain.schedule.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.stereotype.Service; +import site.icebang.domain.schedule.model.Schedule; +import site.icebang.domain.workflow.scheduler.WorkflowTriggerJob; + +@Slf4j +@Service +@RequiredArgsConstructor +public class QuartzScheduleService { + + private final Scheduler scheduler; + + public void addOrUpdateSchedule(Schedule schedule) { + 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(); + + TriggerKey triggerKey = TriggerKey.triggerKey("trigger-for-workflow-" + schedule.getWorkflowId()); + Trigger trigger = TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity(triggerKey) + .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCronExpression())) + .build(); + try { + scheduler.scheduleJob(jobDetail, trigger); + log.info("Quartz 스케줄 등록/업데이트 완료: Workflow ID {}", schedule.getWorkflowId()); + } catch (SchedulerException e) { + log.error("Quartz 스케줄 등록 실패", e); + } + } + + public void deleteSchedule(Long workflowId) { + // ... (삭제 로직) + } +} \ No newline at end of file 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 39077eca..348058ee 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 @@ -1,9 +1,9 @@ package site.icebang.domain.workflow.controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import java.util.concurrent.CompletableFuture; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; import lombok.RequiredArgsConstructor; @@ -11,6 +11,7 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; import site.icebang.domain.workflow.dto.WorkflowCardDto; +import site.icebang.domain.workflow.service.WorkflowExecutionService; import site.icebang.domain.workflow.service.WorkflowService; @RestController @@ -18,6 +19,7 @@ @RequiredArgsConstructor public class WorkflowController { private final WorkflowService workflowService; + private final WorkflowExecutionService workflowExecutionService; @GetMapping("") public ApiResponse> getWorkflowList( @@ -25,4 +27,11 @@ public ApiResponse> getWorkflowList( PageResult result = workflowService.getPagedResult(pageParams); return ApiResponse.success(result); } + + @PostMapping("/{workflowId}/run") + public ResponseEntity runWorkflow(@PathVariable Long workflowId) { + // HTTP 요청/응답 스레드를 블로킹하지 않도록 비동기 실행 + CompletableFuture.runAsync(() -> workflowExecutionService.executeWorkflow(workflowId)); + return ResponseEntity.accepted().build(); + } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCardDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCardDto.java index b54a29c0..95a6b704 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCardDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCardDto.java @@ -1,6 +1,13 @@ package site.icebang.domain.workflow.dto; -import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; -@Data -public class WorkflowCardDto {} +@Getter +@NoArgsConstructor +public class WorkflowCardDto { + private Long id; + private String name; + private String description; + private boolean isEnabled; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/JobMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/JobMapper.java new file mode 100644 index 00000000..a82739f4 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/JobMapper.java @@ -0,0 +1,15 @@ +package site.icebang.domain.workflow.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.domain.workflow.model.Job; +import site.icebang.domain.workflow.model.Task; + +@Mapper +public interface JobMapper { + List findJobsByWorkflowId(Long workflowId); + + List findTasksByJobId(Long jobId); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskMapper.java new file mode 100644 index 00000000..0edb7812 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/TaskMapper.java @@ -0,0 +1,12 @@ +package site.icebang.domain.workflow.mapper; + +import java.util.Optional; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.domain.workflow.model.Task; + +@Mapper +public interface TaskMapper { + Optional findById(Long id); +} 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 new file mode 100644 index 00000000..4ddd94d3 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java @@ -0,0 +1,19 @@ +package site.icebang.domain.workflow.mapper; + +import java.util.List; +import java.util.Optional; + +import org.apache.ibatis.annotations.Mapper; + +import site.icebang.common.dto.PageParams; +import site.icebang.domain.workflow.dto.WorkflowCardDto; +import site.icebang.domain.workflow.model.Workflow; + +@Mapper +public interface WorkflowMapper { + Optional findById(Long id); + + List selectWorkflowList(PageParams pageParams); + + int selectWorkflowCount(PageParams pageParams); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java new file mode 100644 index 00000000..0a3604b5 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Job.java @@ -0,0 +1,22 @@ +package site.icebang.domain.workflow.model; + +import java.time.LocalDateTime; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Job { + private Long id; + private String name; + private String description; + private boolean isEnabled; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java new file mode 100644 index 00000000..09589cc1 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Task.java @@ -0,0 +1,20 @@ +package site.icebang.domain.workflow.model; + +import com.fasterxml.jackson.databind.JsonNode; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor // MyBatis가 객체를 생성하기 위해 필요 +public class Task { + + private Long id; + private String name; + + /** Task의 타입 (예: "HTTP", "SPRING_BATCH") 이 타입에 따라 TaskRunner가 선택됩니다. */ + private String type; + + /** Task 실행에 필요한 파라미터 (JSON) 예: {"url": "http://...", "method": "POST", "body": {...}} */ + private JsonNode parameters; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Workflow.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Workflow.java new file mode 100644 index 00000000..3ea80388 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/model/Workflow.java @@ -0,0 +1,28 @@ +package site.icebang.domain.workflow.model; + +import java.time.LocalDateTime; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Workflow { + + private Long id; + private String name; + private String description; + private boolean isEnabled; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; + + /** 워크플로우별 기본 설정값 (JSON) */ + private String defaultConfig; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java new file mode 100644 index 00000000..9f497b97 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/HttpTaskRunner.java @@ -0,0 +1,49 @@ +package site.icebang.domain.workflow.runner; + +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.JsonNode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.domain.execution.model.TaskRun; +import site.icebang.domain.workflow.model.Task; + +@Slf4j +@Component("httpTaskRunner") +@RequiredArgsConstructor +public class HttpTaskRunner implements TaskRunner { + private final RestTemplate restTemplate; + + @Override + public TaskExecutionResult execute(Task task, TaskRun taskRun) { + JsonNode params = task.getParameters(); + String url = params.get("url").asText(); + String method = params.get("method").asText(); + JsonNode body = params.get("body"); + + try { + HttpEntity requestEntity = + new HttpEntity<>( + body.toString(), + new HttpHeaders() { + { + setContentType(MediaType.APPLICATION_JSON); + } + }); + + ResponseEntity response = + restTemplate.exchange( + url, HttpMethod.valueOf(method.toUpperCase()), requestEntity, String.class); + + return TaskExecutionResult.success(response.getBody()); + } catch (RestClientException e) { + log.error("HTTP Task 실행 실패: TaskRunId={}, Error={}", taskRun.getId(), e.getMessage()); + return TaskExecutionResult.failure(e.getMessage()); + } + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java new file mode 100644 index 00000000..a2b820bb --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/runner/TaskRunner.java @@ -0,0 +1,22 @@ +package site.icebang.domain.workflow.runner; + +import site.icebang.domain.execution.model.TaskRun; +import site.icebang.domain.workflow.model.Task; + +public interface TaskRunner { + record TaskExecutionResult(String status, String message) { + public static TaskExecutionResult success(String message) { + return new TaskExecutionResult("SUCCESS", message); + } + + public static TaskExecutionResult failure(String message) { + return new TaskExecutionResult("FAILED", message); + } + + public boolean isFailure() { + return "FAILED".equals(status); + } + } + + TaskExecutionResult execute(Task task, TaskRun taskRun); +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java new file mode 100644 index 00000000..196c1fa0 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/scheduler/WorkflowTriggerJob.java @@ -0,0 +1,24 @@ +package site.icebang.domain.workflow.scheduler; + +import org.quartz.JobExecutionContext; +import org.springframework.scheduling.quartz.QuartzJobBean; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.domain.workflow.service.WorkflowExecutionService; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WorkflowTriggerJob extends QuartzJobBean { + private final WorkflowExecutionService workflowExecutionService; + + @Override + protected void executeInternal(JobExecutionContext context) { + Long workflowId = context.getJobDetail().getJobDataMap().getLong("workflowId"); + log.info("Quartz가 WorkflowTriggerJob을 실행합니다. WorkflowId={}", workflowId); + workflowExecutionService.executeWorkflow(workflowId); + } +} 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 new file mode 100644 index 00000000..086b00de --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowExecutionService.java @@ -0,0 +1,111 @@ +package site.icebang.domain.workflow.service; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.domain.execution.mapper.JobRunMapper; +import site.icebang.domain.execution.mapper.TaskRunMapper; +import site.icebang.domain.execution.mapper.WorkflowRunMapper; +import site.icebang.domain.execution.model.JobRun; +import site.icebang.domain.execution.model.TaskRun; +import site.icebang.domain.execution.model.WorkflowRun; +import site.icebang.domain.workflow.mapper.JobMapper; +import site.icebang.domain.workflow.model.Job; +import site.icebang.domain.workflow.model.Task; +import site.icebang.domain.workflow.runner.TaskRunner; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WorkflowExecutionService { + + private final JobMapper jobMapper; + private final WorkflowRunMapper workflowRunMapper; + private final JobRunMapper jobRunMapper; + private final TaskRunMapper taskRunMapper; + private final Map taskRunners; + + /** + * 워크플로우 실행의 시작점. 전체 과정은 하나의 트랜잭션으로 묶입니다. + * + * @param workflowId 실행할 워크플로우의 ID + */ + @Transactional + public void executeWorkflow(Long workflowId) { + log.info("========== 워크플로우 실행 시작: WorkflowId={} ==========", workflowId); + WorkflowRun workflowRun = WorkflowRun.start(workflowId); + workflowRunMapper.insert(workflowRun); + + List jobs = jobMapper.findJobsByWorkflowId(workflowId); + log.info("총 {}개의 Job을 순차적으로 실행합니다.", jobs.size()); + + for (Job job : jobs) { + JobRun jobRun = JobRun.start(workflowRun.getId(), job.getId()); + jobRunMapper.insert(jobRun); + log.info( + "---------- Job 실행 시작: JobId={}, JobRunId={} ----------", job.getId(), jobRun.getId()); + + boolean jobSucceeded = executeTasksForJob(jobRun); + + jobRun.finish(jobSucceeded ? "SUCCESS" : "FAILED"); + jobRunMapper.update(jobRun); + + if (!jobSucceeded) { + workflowRun.finish("FAILED"); + workflowRunMapper.update(workflowRun); + log.error("Job 실패로 인해 워크플로우 실행을 중단합니다: WorkflowRunId={}", workflowRun.getId()); + return; // Job이 실패하면 전체 워크플로우를 중단 + } + log.info("---------- Job 실행 성공: JobRunId={} ----------", jobRun.getId()); + } + + workflowRun.finish("SUCCESS"); + workflowRunMapper.update(workflowRun); + log.info("========== 워크플로우 실행 성공: WorkflowRunId={} ==========", workflowRun.getId()); + } + + /** + * 특정 Job에 속한 Task들을 순차적으로 실행합니다. + * + * @param jobRun 실행중인 Job의 기록 객체 + * @return 모든 Task가 성공하면 true, 하나라도 실패하면 false + */ + private boolean executeTasksForJob(JobRun jobRun) { + List tasks = jobMapper.findTasksByJobId(jobRun.getJobId()); + log.info("Job (JobRunId={}) 내 총 {}개의 Task를 실행합니다.", jobRun.getId(), tasks.size()); + + for (Task task : tasks) { + TaskRun taskRun = TaskRun.start(jobRun.getId(), task.getId()); + taskRunMapper.insert(taskRun); + log.info("Task 실행 시작: TaskId={}, TaskRunId={}", task.getId(), taskRun.getId()); + + String runnerBeanName = task.getType().toLowerCase() + "TaskRunner"; + TaskRunner runner = taskRunners.get(runnerBeanName); + + if (runner == null) { + taskRun.finish("FAILED", "지원하지 않는 Task 타입: " + task.getType()); + taskRunMapper.update(taskRun); + log.error("Task 실행 실패 (미지원 타입): TaskRunId={}, Type={}", taskRun.getId(), task.getType()); + return false; // 실행할 Runner가 없으므로 실패 + } + + TaskRunner.TaskExecutionResult result = runner.execute(task, taskRun); + taskRun.finish(result.status(), result.message()); + taskRunMapper.update(taskRun); + + if (result.isFailure()) { + log.error("Task 실행 실패: TaskRunId={}, Message={}", taskRun.getId(), result.message()); + return false; // Task가 실패하면 즉시 중단하고 실패 반환 + } + log.info("Task 실행 성공: TaskRunId={}", taskRun.getId()); + } + + return true; // 모든 Task가 성공적으로 완료됨 + } +} diff --git a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java deleted file mode 100644 index e4e81a73..00000000 --- a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java +++ /dev/null @@ -1,106 +0,0 @@ -package site.icebang.external.fastapi.adapter; - -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.external.fastapi.dto.FastApiDto.*; -import site.icebang.global.config.properties.FastApiProperties; - -/** FastAPI 서버와의 통신을 전담하는 어댑터 클래스. 모든 외부 API 호출은 이 클래스를 통해 이루어집니다. */ -@Slf4j -@Component -@RequiredArgsConstructor -public class FastApiAdapter { - - private final RestTemplate restTemplate; - private final FastApiProperties properties; - - /** TASK 1: 네이버 키워드 추출을 FastAPI에 요청합니다. */ - public ResponseNaverSearch requestNaverKeywordSearch(RequestNaverSearch request) { - String url = properties.getUrl() + "/keyword/search"; - log.info("Requesting to FastAPI [POST {}]", url); - try { - return restTemplate.postForObject(url, request, ResponseNaverSearch.class); - } catch (RestClientException e) { - log.error("Failed to call FastAPI keyword search API. Error: {}", e.getMessage()); - // TODO: 비즈니스 요구사항에 맞는 예외 처리 (재시도, 기본값 반환, 특정 예외 던지기 등) - return null; - } - } - - /** TASK 2: 싸다구몰 상품 검색을 FastAPI에 요청합니다. */ - public ResponseSsadaguSearch requestSsadaguProductSearch(RequestSsadaguSearch request) { - String url = properties.getUrl() + "/product/search"; - log.info("Requesting to FastAPI [POST {}]", url); - try { - return restTemplate.postForObject(url, request, ResponseSsadaguSearch.class); - } catch (RestClientException e) { - log.error("Failed to call FastAPI product search API. Error: {}", e.getMessage()); - return null; - } - } - - /** TASK 3: 상품 매칭을 FastAPI에 요청합니다. */ - public ResponseSsadaguMatch requestProductMatch(RequestSsadaguMatch request) { - String url = properties.getUrl() + "/product/match"; - log.info("Requesting to FastAPI [POST {}]", url); - try { - return restTemplate.postForObject(url, request, ResponseSsadaguMatch.class); - } catch (RestClientException e) { - log.error("Failed to call FastAPI product match API. Error: {}", e.getMessage()); - return null; - } - } - - /** TASK 4: 상품 유사도 분석을 FastAPI에 요청합니다. (메서드명 수정) */ - public ResponseSsadaguSimilarity requestProductSimilarity(RequestSsadaguSimilarity request) { - String url = properties.getUrl() + "/product/similarity"; - log.info("Requesting to FastAPI [POST {}]", url); - try { - return restTemplate.postForObject(url, request, ResponseSsadaguSimilarity.class); - } catch (RestClientException e) { - log.error("Failed to call FastAPI product similarity API. Error: {}", e.getMessage()); - return null; - } - } - - /** TASK 5: 상품 상세 정보 크롤링을 FastAPI에 요청합니다. */ - public ResponseSsadaguCrawl requestProductCrawl(RequestSsadaguCrawl request) { - String url = properties.getUrl() + "/product/crawl"; - log.info("Requesting to FastAPI [POST {}]", url); - try { - return restTemplate.postForObject(url, request, ResponseSsadaguCrawl.class); - } catch (RestClientException e) { - log.error("Failed to call FastAPI product crawl API. Error: {}", e.getMessage()); - return null; - } - } - - /** TASK 6: 블로그 콘텐츠 생성을 FastAPI에 요청합니다. */ - public ResponseBlogCreate requestBlogCreation(RequestBlogCreate request) { - String url = properties.getUrl() + "/blog/rag/create"; - log.info("Requesting to FastAPI [POST {}]", url); - try { - return restTemplate.postForObject(url, request, ResponseBlogCreate.class); - } catch (RestClientException e) { - log.error("Failed to call FastAPI blog creation API. Error: {}", e.getMessage()); - return null; - } - } - - /** TASK 7: 블로그 발행을 FastAPI에 요청합니다. */ - public ResponseBlogPublish requestBlogPost(RequestBlogPublish request) { - String url = properties.getUrl() + "/blog/publish"; - log.info("Requesting to FastAPI [POST {}]", url); - try { - return restTemplate.postForObject(url, request, ResponseBlogPublish.class); - } catch (RestClientException e) { - log.error("Failed to call FastAPI blog publish API. Error: {}", e.getMessage()); - return null; - } - } -} diff --git a/apps/user-service/src/main/java/site/icebang/external/fastapi/dto/FastApiDto.java b/apps/user-service/src/main/java/site/icebang/external/fastapi/dto/FastApiDto.java deleted file mode 100644 index 88ffe284..00000000 --- a/apps/user-service/src/main/java/site/icebang/external/fastapi/dto/FastApiDto.java +++ /dev/null @@ -1,103 +0,0 @@ -package site.icebang.external.fastapi.dto; - -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.annotation.JsonProperty; - -/** FastAPI 서버와 통신하기 위한 DTO 클래스 모음. Java의 record를 사용하여 불변 데이터 객체를 간결하게 정의합니다. */ -public final class FastApiDto { - - // --- 1. 네이버 키워드 추출 --- - public record RequestNaverSearch( - @JsonProperty("job_id") int jobId, - @JsonProperty("schedule_id") int scheduleId, - @JsonProperty("schedule_his_id") Integer scheduleHisId, - String tag, - String category, - @JsonProperty("start_date") String startDate, - @JsonProperty("end_date") String endDate) {} - - public record ResponseNaverSearch( - String status, - String category, - String keyword, - @JsonProperty("total_keyword") Map totalKeyword) {} - - // --- 2. 상품 검색 --- - public record RequestSsadaguSearch( - @JsonProperty("job_id") int jobId, - @JsonProperty("schedule_id") int scheduleId, - @JsonProperty("schedule_his_id") Integer scheduleHisId, - String keyword) {} - - public record ResponseSsadaguSearch( - String status, - String keyword, - @JsonProperty("search_results") List> searchResults) {} - - // --- 3. 상품 매칭 --- - public record RequestSsadaguMatch( - @JsonProperty("job_id") int jobId, - @JsonProperty("schedule_id") int scheduleId, - @JsonProperty("schedule_his_id") Integer scheduleHisId, - String keyword, - @JsonProperty("search_results") List> searchResults) {} - - public record ResponseSsadaguMatch( - String status, - String keyword, - @JsonProperty("matched_products") List> matchedProducts) {} - - // --- 4. 상품 유사도 --- - public record RequestSsadaguSimilarity( - @JsonProperty("job_id") int jobId, - @JsonProperty("schedule_id") int scheduleId, - @JsonProperty("schedule_his_id") Integer scheduleHisId, - String keyword, - @JsonProperty("matched_products") List> matchedProducts, - @JsonProperty("search_results") List> searchResults) {} - - public record ResponseSsadaguSimilarity( - String status, - String keyword, - @JsonProperty("selected_product") Map selectedProduct, - String reason) {} - - // --- 5. 상품 크롤링 --- - public record RequestSsadaguCrawl( - @JsonProperty("job_id") int jobId, - @JsonProperty("schedule_id") int scheduleId, - @JsonProperty("schedule_his_id") Integer scheduleHisId, - String tag, - @JsonProperty("product_url") String productUrl) {} - - public record ResponseSsadaguCrawl( - String status, - String tag, - @JsonProperty("product_url") String productUrl, - @JsonProperty("product_detail") Map productDetail, - @JsonProperty("crawled_at") String crawledAt) {} - - // --- 6. 블로그 콘텐츠 생성 --- - public record RequestBlogCreate( - @JsonProperty("job_id") int jobId, - @JsonProperty("schedule_id") int scheduleId, - @JsonProperty("schedule_his_id") Integer scheduleHisId) {} - - public record ResponseBlogCreate(String status) {} - - // --- 7. 블로그 발행 --- - public record RequestBlogPublish( - @JsonProperty("job_id") int jobId, - @JsonProperty("schedule_id") int scheduleId, - @JsonProperty("schedule_his_id") Integer scheduleHisId, - String tag, - @JsonProperty("blog_id") String blogId, - @JsonProperty("blog_pw") String blogPw, - @JsonProperty("post_title") String postTitle, - @JsonProperty("post_content") String postContent, - @JsonProperty("post_tags") List postTags) {} - - public record ResponseBlogPublish(String status, Map metadata) {} -} diff --git a/apps/user-service/src/main/java/site/icebang/global/aop/logging/LoggingAspect.java b/apps/user-service/src/main/java/site/icebang/global/aop/logging/LoggingAspect.java index b1806cff..126c7d35 100644 --- a/apps/user-service/src/main/java/site/icebang/global/aop/logging/LoggingAspect.java +++ b/apps/user-service/src/main/java/site/icebang/global/aop/logging/LoggingAspect.java @@ -22,9 +22,6 @@ public void serviceMethods() {} @Pointcut("execution(public * site.icebang..service..mapper..*(..))") public void repositoryMethods() {} - @Pointcut("execution(public * site.icebang.batch.tasklet..*(..))") - public void taskletMethods() {} - @Around("controllerMethods()") public Object logController(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); @@ -54,15 +51,4 @@ public Object logRepository(ProceedingJoinPoint joinPoint) throws Throwable { log.debug("[REPOSITORY] End: {} ({}ms)", joinPoint.getSignature(), duration); return result; } - - @Around("taskletMethods()") - public Object logTasklet(ProceedingJoinPoint joinPoint) throws Throwable { - long start = System.currentTimeMillis(); - // Tasklet 이름만으로도 구분이 되므로, 클래스명 + 메서드명으로 로그를 남깁니다. - log.info(">>>> [TASKLET] Start: {}", joinPoint.getSignature().toShortString()); - Object result = joinPoint.proceed(); // 실제 Tasklet의 execute() 메서드 실행 - long duration = System.currentTimeMillis() - start; - log.info("<<<< [TASKLET] End: {} ({}ms)", joinPoint.getSignature().toShortString(), duration); - return result; - } } diff --git a/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java b/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java new file mode 100644 index 00000000..233f5834 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/config/QuartzSchedulerInitializer.java @@ -0,0 +1,33 @@ +package site.icebang.global.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import site.icebang.domain.schedule.model.Schedule; +import site.icebang.domain.schedule.mapper.ScheduleMapper; +import site.icebang.domain.schedule.service.QuartzScheduleService; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class QuartzSchedulerInitializer implements CommandLineRunner { + + private final ScheduleMapper scheduleMapper; + private final QuartzScheduleService quartzScheduleService; + + @Override + public void run(String... args) { + log.info("Quartz 스케줄러 초기화 시작: DB 스케줄을 등록합니다."); + try { + List activeSchedules = scheduleMapper.findAllActive(); + for (Schedule schedule : activeSchedules) { + quartzScheduleService.addOrUpdateSchedule(schedule); + } + log.info("총 {}개의 활성 스케줄을 Quartz에 성공적으로 등록했습니다.", activeSchedules.size()); + } catch (Exception e) { + log.error("Quartz 스케줄 초기화 중 오류가 발생했습니다.", e); + } + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java b/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java new file mode 100644 index 00000000..4079c9f3 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/config/mybatis/typehandler/JsonNodeTypeHandler.java @@ -0,0 +1,56 @@ +package site.icebang.global.config.mybatis.typehandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedTypes; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@MappedTypes(JsonNode.class) +public class JsonNodeTypeHandler extends BaseTypeHandler { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void setNonNullParameter( + PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException { + try { + ps.setString(i, objectMapper.writeValueAsString(parameter)); + } catch (JsonProcessingException e) { + throw new SQLException("Error converting JsonNode to String", e); + } + } + + @Override + public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException { + return parseJson(rs.getString(columnName)); + } + + @Override + public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + return parseJson(rs.getString(columnIndex)); + } + + @Override + public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + return parseJson(cs.getString(columnIndex)); + } + + private JsonNode parseJson(String json) throws SQLException { + if (json == null) { + return null; + } + try { + return objectMapper.readTree(json); + } catch (JsonProcessingException e) { + throw new SQLException("Error parsing JSON", e); + } + } +} diff --git a/apps/user-service/src/main/java/site/icebang/global/config/scheduler/SchedulerConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/scheduler/SchedulerConfig.java deleted file mode 100644 index 79fc6436..00000000 --- a/apps/user-service/src/main/java/site/icebang/global/config/scheduler/SchedulerConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -package site.icebang.global.config.scheduler; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; - -/** 동적 스케줄링을 위한 TaskScheduler Bean을 설정하는 클래스 */ -@Configuration -public class SchedulerConfig { - - @Bean - public TaskScheduler taskScheduler() { - // ThreadPool 기반의 TaskScheduler를 생성합니다. - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - - // 스케줄러가 사용할 스레드 풀의 크기를 설정합니다. - // 동시에 실행될 수 있는 스케줄 작업의 최대 개수입니다. - scheduler.setPoolSize(10); - - // 스레드 이름의 접두사를 설정하여 로그 추적을 용이하게 합니다. - scheduler.setThreadNamePrefix("dynamic-scheduler-"); - - // 스케줄러를 초기화합니다. - scheduler.initialize(); - return scheduler; - } -} diff --git a/apps/user-service/src/main/java/site/icebang/schedule/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/site/icebang/schedule/mapper/ScheduleMapper.java deleted file mode 100644 index b1a92f1e..00000000 --- a/apps/user-service/src/main/java/site/icebang/schedule/mapper/ScheduleMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package site.icebang.schedule.mapper; - -import java.util.List; - -import org.apache.ibatis.annotations.Mapper; - -import site.icebang.schedule.model.Schedule; - -@Mapper -public interface ScheduleMapper { - List findAllByIsActive(boolean isActive); -} diff --git a/apps/user-service/src/main/java/site/icebang/schedule/model/Schedule.java b/apps/user-service/src/main/java/site/icebang/schedule/model/Schedule.java deleted file mode 100644 index ced2900c..00000000 --- a/apps/user-service/src/main/java/site/icebang/schedule/model/Schedule.java +++ /dev/null @@ -1,14 +0,0 @@ -package site.icebang.schedule.model; - -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class Schedule { - private Long scheduleId; - private Long workflowId; - private String cronExpression; - private boolean isActive; - // ... 기타 필요한 컬럼 -} diff --git a/apps/user-service/src/main/java/site/icebang/schedule/runner/SchedulerInitializer.java b/apps/user-service/src/main/java/site/icebang/schedule/runner/SchedulerInitializer.java deleted file mode 100644 index ee8580dd..00000000 --- a/apps/user-service/src/main/java/site/icebang/schedule/runner/SchedulerInitializer.java +++ /dev/null @@ -1,31 +0,0 @@ -package site.icebang.schedule.runner; - -import java.util.List; - -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.schedule.mapper.ScheduleMapper; -import site.icebang.schedule.model.Schedule; -import site.icebang.schedule.service.DynamicSchedulerService; - -@Slf4j -@Component -@RequiredArgsConstructor -public class SchedulerInitializer implements ApplicationRunner { - - private final ScheduleMapper scheduleMapper; - private final DynamicSchedulerService dynamicSchedulerService; - - @Override - public void run(ApplicationArguments args) { - log.info(">>>> Initializing schedules from database..."); - List activeSchedules = scheduleMapper.findAllByIsActive(true); - activeSchedules.forEach(dynamicSchedulerService::register); - log.info(">>>> {} active schedules have been registered.", activeSchedules.size()); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/schedule/service/DynamicSchedulerService.java b/apps/user-service/src/main/java/site/icebang/schedule/service/DynamicSchedulerService.java deleted file mode 100644 index b78c048e..00000000 --- a/apps/user-service/src/main/java/site/icebang/schedule/service/DynamicSchedulerService.java +++ /dev/null @@ -1,63 +0,0 @@ -package site.icebang.schedule.service; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ScheduledFuture; - -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.context.ApplicationContext; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import site.icebang.schedule.model.Schedule; - -@Slf4j -@Service -@RequiredArgsConstructor -public class DynamicSchedulerService { - - private final TaskScheduler taskScheduler; - private final JobLauncher jobLauncher; - private final ApplicationContext applicationContext; - private final Map> scheduledTasks = new ConcurrentHashMap<>(); - - public void register(Schedule schedule) { - // TODO: schedule.getWorkflowId()를 기반으로 실행할 Job의 이름을 DB에서 조회 - // String jobName = "blogContentJob"; // 예시 - // Job jobToRun = applicationContext.getBean(jobName, Job.class); - // - // Runnable runnable = - // () -> { - // try { - // JobParametersBuilder paramsBuilder = new JobParametersBuilder(); - // paramsBuilder.addString("runAt", LocalDateTime.now().toString()); - // paramsBuilder.addLong("scheduleId", schedule.getScheduleId()); - // jobLauncher.run(jobToRun, paramsBuilder.toJobParameters()); - // } catch (Exception e) { - // log.error( - // "Failed to run scheduled job for scheduleId: {}", schedule.getScheduleId(), - // e); - // } - // }; - // - // CronTrigger trigger = new CronTrigger(schedule.getCronExpression()); - // ScheduledFuture future = taskScheduler.schedule(runnable, trigger); - // scheduledTasks.put(schedule.getScheduleId(), future); - // log.info( - // ">>>> Schedule registered: id={}, cron={}", - // schedule.getScheduleId(), - // schedule.getCronExpression()); - } - - public void remove(Long scheduleId) { - ScheduledFuture future = scheduledTasks.get(scheduleId); - if (future != null) { - future.cancel(true); - scheduledTasks.remove(scheduleId); - log.info(">>>> Schedule removed: id={}", scheduleId); - } - } -} diff --git a/apps/user-service/src/main/resources/application-develop.yml b/apps/user-service/src/main/resources/application-develop.yml index 8c24f49d..336d62ae 100644 --- a/apps/user-service/src/main/resources/application-develop.yml +++ b/apps/user-service/src/main/resources/application-develop.yml @@ -36,6 +36,20 @@ spring: - classpath:sql/03-insert-workflow.sql encoding: UTF-8 +# # Spring Quartz 스케줄러 설정 +# quartz: +# job-store-type: jdbc +# auto-startup: true +# jdbc: +# initialize-schema: embedded # 운영 환경을 기준으로 기본값 설정 +# properties: +# org.quartz.scheduler.instanceId: AUTO +# org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX +# org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate +# org.quartz.jobStore.tablePrefix: QRTZ_ # Quartz 테이블 접두사 +# org.quartz.threadPool.class: org.quartz.simpl.SimpleThreadPool +# org.quartz.threadPool.threadCount: 5 # 개발 환경 스레드 수 + mybatis: mapper-locations: classpath:mybatis/mapper/**/*.xml type-aliases-package: site.icebang.dto diff --git a/apps/user-service/src/main/resources/application-production.yml b/apps/user-service/src/main/resources/application-production.yml index 6b048fbd..032954ad 100644 --- a/apps/user-service/src/main/resources/application-production.yml +++ b/apps/user-service/src/main/resources/application-production.yml @@ -17,6 +17,10 @@ spring: minimum-idle: 5 pool-name: HikariCP-MyBatis +# quartz: +# jdbc: +# initialize-schema: never + mybatis: mapper-locations: classpath:mybatis/mapper/**/*.xml type-aliases-package: site.icebang.dto diff --git a/apps/user-service/src/main/resources/application.yml b/apps/user-service/src/main/resources/application.yml index 7ede99ae..706eceea 100644 --- a/apps/user-service/src/main/resources/application.yml +++ b/apps/user-service/src/main/resources/application.yml @@ -7,6 +7,7 @@ spring: context: cache: maxSize: 1 + mybatis: # Mapper XML 파일 위치 mapper-locations: classpath:mapper/**/*.xml diff --git a/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml new file mode 100644 index 00000000..54e29ae4 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/JobMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/JobRunMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/JobRunMapper.xml new file mode 100644 index 00000000..4fd0ea3d --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/JobRunMapper.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + INSERT INTO job_run (workflow_run_id, job_id, status, started_at, created_at) + VALUES (#{workflowRunId}, #{jobId}, #{status}, #{startedAt}, #{createdAt}) + + + + UPDATE job_run + SET status = #{status}, + finished_at = #{finishedAt} + WHERE id = #{id} + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml index f9629b8a..2a5480e3 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml @@ -1,17 +1,11 @@ - + - + + + - + SELECT * FROM schedule WHERE is_active = true - \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/TaskMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/TaskMapper.xml new file mode 100644 index 00000000..7604cb94 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/TaskMapper.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/TaskRunMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/TaskRunMapper.xml new file mode 100644 index 00000000..582af278 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/TaskRunMapper.xml @@ -0,0 +1,17 @@ + + + + + + INSERT INTO task_run (job_run_id, task_id, status, started_at, created_at) + VALUES (#{jobRunId}, #{taskId}, #{status}, #{startedAt}, #{createdAt}) + + + + UPDATE task_run + SET status = #{status}, + finished_at = #{finishedAt}, + result_message = #{resultMessage} + WHERE id = #{id} + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml new file mode 100644 index 00000000..d10c487a --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowRunMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowRunMapper.xml new file mode 100644 index 00000000..224abd02 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowRunMapper.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + INSERT INTO workflow_run (workflow_id, trace_id, status, started_at, created_at) + VALUES (#{workflowId}, #{traceId}, #{status}, #{startedAt}, #{createdAt}) + + + + UPDATE workflow_run + SET status = #{status}, + finished_at = #{finishedAt} + WHERE id = #{id} + + + \ No newline at end of file