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 new file mode 100644 index 00000000..d28b7bd0 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/batch/common/JobContextKeys.java @@ -0,0 +1,15 @@ +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 new file mode 100644 index 00000000..d0c934b9 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/batch/job/BlogAutomationJobConfig.java @@ -0,0 +1,115 @@ +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/job/BlogContentJobConfig.java b/apps/user-service/src/main/java/site/icebang/batch/job/BlogContentJobConfig.java deleted file mode 100644 index 5e85fe9f..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/job/BlogContentJobConfig.java +++ /dev/null @@ -1,51 +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.ContentGenerationTasklet; -import site.icebang.batch.tasklet.KeywordExtractionTasklet; - -@Configuration -@RequiredArgsConstructor -public class BlogContentJobConfig { - - // 변경점 1: Factory 대신 실제 Tasklet만 필드로 주입받습니다. - private final KeywordExtractionTasklet keywordExtractionTasklet; - private final ContentGenerationTasklet contentGenerationTasklet; - - @Bean - public Job blogContentJob( - JobRepository jobRepository, Step keywordExtractionStep, Step contentGenerationStep) { - return new JobBuilder("blogContentJob", jobRepository) // 변경점 2: JobBuilder를 직접 생성합니다. - .start(keywordExtractionStep) - .next(contentGenerationStep) - .build(); - } - - @Bean - public Step keywordExtractionStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("keywordExtractionStep", jobRepository) // 변경점 3: StepBuilder를 직접 생성합니다. - .tasklet( - keywordExtractionTasklet, - transactionManager) // 변경점 4: tasklet에 transactionManager를 함께 전달합니다. - .build(); - } - - @Bean - public Step contentGenerationStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("contentGenerationStep", jobRepository) - .tasklet(contentGenerationTasklet, transactionManager) - .build(); - } -} diff --git a/apps/user-service/src/main/java/site/icebang/batch/tasklet/ContentGenerationTasklet.java b/apps/user-service/src/main/java/site/icebang/batch/tasklet/ContentGenerationTasklet.java deleted file mode 100644 index a6ef4505..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/tasklet/ContentGenerationTasklet.java +++ /dev/null @@ -1,49 +0,0 @@ -package site.icebang.batch.tasklet; - -import java.util.List; - -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; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ContentGenerationTasklet implements Tasklet { - - // private final ContentService contentService; // 비즈니스 로직을 담은 서비스 - // private final FastApiClient fastApiClient; // FastAPI 통신을 위한 클라이언트 - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - log.info(">>>> [Step 2] ContentGenerationTasklet executed."); - - // --- 핵심: JobExecutionContext에서 이전 Step의 결과물 가져오기 --- - ExecutionContext jobExecutionContext = - chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - - // KeywordExtractionTasklet이 저장한 "extractedKeywordIds" Key로 데이터 조회 - List keywordIds = (List) jobExecutionContext.get("extractedKeywordIds"); - - if (keywordIds == null || keywordIds.isEmpty()) { - log.warn(">>>> No keyword IDs found from previous step. Skipping content generation."); - return RepeatStatus.FINISHED; - } - - log.info(">>>> Received Keyword IDs for content generation: {}", keywordIds); - - // TODO: 1. 전달받은 키워드 ID 목록으로 DB에서 상세 정보 조회 - // TODO: 2. 각 키워드/상품 정보에 대해 외부 AI 서비스(FastAPI/LangChain)를 호출하여 콘텐츠 생성을 요청 - // TODO: 3. 생성된 콘텐츠를 DB에 저장 - - log.info(">>>> [Step 2] ContentGenerationTasklet finished."); - return RepeatStatus.FINISHED; - } -} 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 new file mode 100644 index 00000000..6a182c37 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/batch/tasklet/CrawlSelectedProductTasklet.java @@ -0,0 +1,60 @@ +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 new file mode 100644 index 00000000..a35bebf9 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/batch/tasklet/ExtractTrendKeywordTasklet.java @@ -0,0 +1,51 @@ +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 new file mode 100644 index 00000000..316641e1 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/batch/tasklet/FindSimilarProductsTasklet.java @@ -0,0 +1,60 @@ +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 new file mode 100644 index 00000000..ecf44cbb --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/batch/tasklet/GenerateBlogContentTasklet.java @@ -0,0 +1,62 @@ +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/KeywordExtractionTasklet.java b/apps/user-service/src/main/java/site/icebang/batch/tasklet/KeywordExtractionTasklet.java deleted file mode 100644 index ebc27117..00000000 --- a/apps/user-service/src/main/java/site/icebang/batch/tasklet/KeywordExtractionTasklet.java +++ /dev/null @@ -1,47 +0,0 @@ -package site.icebang.batch.tasklet; - -import java.util.List; - -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; - -@Slf4j -@Component -@RequiredArgsConstructor -public class KeywordExtractionTasklet implements Tasklet { - - // private final TrendKeywordService trendKeywordService; // 비즈니스 로직을 담은 서비스 - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - log.info(">>>> [Step 1] KeywordExtractionTasklet executed."); - - // TODO: 1. DB에서 카테고리 정보 조회 - // TODO: 2. 외부 API 또는 내부 로직을 통해 트렌드 키워드 추출 - // TODO: 3. 추출된 키워드를 DB에 저장 - - // --- 핵심: 다음 Step에 전달할 데이터 생성 --- - // 예시: 새로 생성된 키워드 ID 목록을 가져왔다고 가정 - List extractedKeywordIds = List.of(1L, 2L, 3L); // 실제로는 DB 저장 후 반환된 ID 목록 - log.info(">>>> Extracted Keyword IDs: {}", extractedKeywordIds); - - // --- 핵심: JobExecutionContext에 결과물 저장 --- - // JobExecution 전체에서 공유되는 컨텍스트를 가져옵니다. - ExecutionContext jobExecutionContext = - chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - - // "extractedKeywordIds" 라는 Key로 데이터 저장 - jobExecutionContext.put("extractedKeywordIds", extractedKeywordIds); - - log.info(">>>> [Step 1] KeywordExtractionTasklet finished."); - return RepeatStatus.FINISHED; - } -} 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 new file mode 100644 index 00000000..bdb15200 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/batch/tasklet/MatchProductWithKeywordTasklet.java @@ -0,0 +1,57 @@ +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 new file mode 100644 index 00000000..e1b75a18 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/batch/tasklet/PublishBlogPostTasklet.java @@ -0,0 +1,68 @@ +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 new file mode 100644 index 00000000..3480f391 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/batch/tasklet/SearchProductsFromMallTasklet.java @@ -0,0 +1,58 @@ +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/external/fastapi/adapter/FastApiAdapter.java b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java new file mode 100644 index 00000000..e4e81a73 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java @@ -0,0 +1,106 @@ +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 new file mode 100644 index 00000000..88ffe284 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/external/fastapi/dto/FastApiDto.java @@ -0,0 +1,103 @@ +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 126c7d35..b1806cff 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,6 +22,9 @@ 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(); @@ -51,4 +54,15 @@ 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/domain/schedule/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/site/icebang/schedule/mapper/ScheduleMapper.java similarity index 63% rename from apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java rename to apps/user-service/src/main/java/site/icebang/schedule/mapper/ScheduleMapper.java index c757fc36..b1a92f1e 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java +++ b/apps/user-service/src/main/java/site/icebang/schedule/mapper/ScheduleMapper.java @@ -1,10 +1,10 @@ -package site.icebang.domain.schedule.mapper; +package site.icebang.schedule.mapper; import java.util.List; import org.apache.ibatis.annotations.Mapper; -import site.icebang.domain.schedule.model.Schedule; +import site.icebang.schedule.model.Schedule; @Mapper public interface ScheduleMapper { 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/schedule/model/Schedule.java similarity index 84% rename from apps/user-service/src/main/java/site/icebang/domain/schedule/model/Schedule.java rename to apps/user-service/src/main/java/site/icebang/schedule/model/Schedule.java index 65c48366..ced2900c 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/model/Schedule.java +++ b/apps/user-service/src/main/java/site/icebang/schedule/model/Schedule.java @@ -1,4 +1,4 @@ -package site.icebang.domain.schedule.model; +package site.icebang.schedule.model; import lombok.Getter; import lombok.Setter; diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/runner/SchedulerInitializer.java b/apps/user-service/src/main/java/site/icebang/schedule/runner/SchedulerInitializer.java similarity index 78% rename from apps/user-service/src/main/java/site/icebang/domain/schedule/runner/SchedulerInitializer.java rename to apps/user-service/src/main/java/site/icebang/schedule/runner/SchedulerInitializer.java index 0dfb8b33..ee8580dd 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/runner/SchedulerInitializer.java +++ b/apps/user-service/src/main/java/site/icebang/schedule/runner/SchedulerInitializer.java @@ -1,4 +1,4 @@ -package site.icebang.domain.schedule.runner; +package site.icebang.schedule.runner; import java.util.List; @@ -9,9 +9,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import site.icebang.domain.schedule.mapper.ScheduleMapper; -import site.icebang.domain.schedule.model.Schedule; -import site.icebang.domain.schedule.service.DynamicSchedulerService; +import site.icebang.schedule.mapper.ScheduleMapper; +import site.icebang.schedule.model.Schedule; +import site.icebang.schedule.service.DynamicSchedulerService; @Slf4j @Component diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/DynamicSchedulerService.java b/apps/user-service/src/main/java/site/icebang/schedule/service/DynamicSchedulerService.java similarity index 95% rename from apps/user-service/src/main/java/site/icebang/domain/schedule/service/DynamicSchedulerService.java rename to apps/user-service/src/main/java/site/icebang/schedule/service/DynamicSchedulerService.java index 372e0e1d..b81e30eb 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/DynamicSchedulerService.java +++ b/apps/user-service/src/main/java/site/icebang/schedule/service/DynamicSchedulerService.java @@ -1,4 +1,4 @@ -package site.icebang.domain.schedule.service; +package site.icebang.schedule.service; import java.time.LocalDateTime; import java.util.Map; @@ -16,7 +16,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import site.icebang.domain.schedule.model.Schedule; +import site.icebang.schedule.model.Schedule; @Slf4j @Service 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 3cdcc90e..f9629b8a 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml @@ -1,16 +1,16 @@ - + - SELECT id AS scheduleId, workflow_id AS workflowId, cron_expression AS cronExpression, is_active AS isActive - FROM + FROM schedule - WHERE + WHERE is_active = #{isActive}