diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..1f80db6bf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,13 @@ +name: PR Agent +on: + pull_request: + types: [opened, synchronize] +jobs: + pr_agent_job: + runs-on: ubuntu-latest + steps: + - name: PR Agent action step + uses: Codium-ai/pr-agent@main + env: + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + GITHUB_TOKEN: ${{ secrets.G_TOKEN }} diff --git a/README.md b/README.md index 04950f29d..f86e4dd8a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ docker-compose -f ./docker/monitoring-compose.yml up Root ├── apps ( spring-applications ) │ ├── 📦 commerce-api +│ ├── 📦 commerce-batch │ └── 📦 commerce-streamer ├── modules ( reusable-configurations ) │ ├── 📦 jpa diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 3ca1a15af..a7d22c673 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -17,6 +17,8 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.List; @Component @@ -25,6 +27,7 @@ public class ProductFacade { private final ProductRepository productRepository; private final BrandRepository brandRepository; private final OutboxRepository outBoxRepository; + private final RankingRedisReader rankingRedisReader; @Transactional public ProductInfo registerProduct(ProductV1Dto.ProductRequest request) { @@ -52,7 +55,7 @@ public List findAllProducts() { @Transactional @Cacheable(value = "product", key = "#id") - public ProductInfo findProductById(Long id) { + public ProductRankingInfo findProductById(Long id) { Product product = productRepository.findById(id).orElseThrow( () -> new CoreException(ErrorType.NOT_FOUND, "찾고자 하는 상품이 존재하지 않습니다.") ); @@ -65,7 +68,14 @@ public ProductInfo findProductById(Long id) { outBoxRepository.save(outBoxEvent); - return ProductInfo.from(product); + RankingInfo ranking = null; + + try { + String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + ranking = rankingRedisReader.getDailyRanking(date, product.getId()); + } catch (Exception ignored) {} + + return ProductRankingInfo.from(product, ranking); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRankingInfo.java new file mode 100644 index 000000000..8447e4ad7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductRankingInfo.java @@ -0,0 +1,20 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; + +import java.math.BigDecimal; + +public record ProductRankingInfo(Long id, Long brandId, String name, BigDecimal price, int stock, int likeCount, int rank, double score) { + public static ProductRankingInfo from(Product product, RankingInfo ranking) { + return new ProductRankingInfo( + product.getId(), + product.getBrandId(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getLikeCount(), + ranking.rank(), + ranking.score() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/RankingInfo.java new file mode 100644 index 000000000..e5dde6afb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/RankingInfo.java @@ -0,0 +1,9 @@ +package com.loopers.application.product; + +public record RankingInfo( + String date, + double score, + int rank, + Long total +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/RankingRedisReader.java b/apps/commerce-api/src/main/java/com/loopers/application/product/RankingRedisReader.java new file mode 100644 index 000000000..c700c71c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/RankingRedisReader.java @@ -0,0 +1,40 @@ +package com.loopers.application.product; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class RankingRedisReader { + private final StringRedisTemplate redisTemplate; + + public RankingInfo getDailyRanking(String date, Long productId) { + String key = "ranking:all:" + date; + String member = String.valueOf(productId); + + RedisSerializer serializer = redisTemplate.getStringSerializer(); + byte[] keyBytes = serializer.serialize(key); + byte[] memberBytes = serializer.serialize(member); + + @SuppressWarnings("unchecked") + List results = (List) redisTemplate.executePipelined((RedisCallback) connection -> { + connection.zScore(keyBytes, memberBytes); // -> Double (or null) + connection.zRevRank(keyBytes, memberBytes); // -> Long (or null) 0-base + connection.zCard(keyBytes); // -> Long + return null; + }); + + Double score = (Double) results.get(0); + Long revRank0 = (Long) results.get(1); + Long total = (Long) results.get(2); + + Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1); + + return new RankingInfo(date, score, rank, total); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java new file mode 100644 index 000000000..2781eceea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,70 @@ +package com.loopers.application.ranking; + +import com.loopers.interfaces.api.ranking.RankingV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class RankingFacade { + private final StringRedisTemplate redisTemplate; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.BASIC_ISO_DATE; + + public RankingV1Dto.ProductRankingPageResponse getDailyProductRanking(int page, int size) { + if (page < 1) page = 1; + if (size < 1) size = 20; + + String date = LocalDate.now(KST).format(YYYYMMDD); + + String key = "ranking:all:" + date; + ZSetOperations zset = redisTemplate.opsForZSet(); + + Long total = zset.size(key); + long totalElements = (total == null) ? 0 : total; + + if (totalElements == 0) { + return new RankingV1Dto.ProductRankingPageResponse(date, page, size, 0, 0, List.of()); + } + + long start = (long) (page - 1) * size; + long end = start + size - 1; + + if (start >= totalElements) { + int totalPages = (int) Math.ceil((double) totalElements / size); + return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, List.of()); + } + + Set> tuples = + zset.reverseRangeWithScores(key, start, end); + + List items = new ArrayList<>(); + if (tuples != null) { + long rank = start + 1; + for (var t : tuples) { + String member = t.getValue(); + Double score = t.getScore(); + if (member == null || score == null) continue; + + items.add(new RankingV1Dto.ProductRankingResponse( + rank++, + Long.parseLong(member), + score + )); + } + } + + int totalPages = (int) Math.ceil((double) totalElements / size); + return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, items); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java index fabd557cb..fd735a62c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -15,7 +15,7 @@ public interface ProductV1ApiSpec { ApiResponse> findAllProducts(); @Operation(summary = "상품 상세 조회") - ApiResponse findProductById(Long id); + ApiResponse findProductById(Long id); @Operation(summary = "상품 정렬 조회") ApiResponse> findProductsBySortCondition(ProductV1Dto.SearchProductRequest request); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index a0eb89d65..3cdf5dc61 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -2,6 +2,7 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductRankingInfo; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -42,9 +43,9 @@ public ApiResponse> findAllProducts() { @GetMapping("/{id}") @Override - public ApiResponse findProductById(@PathVariable Long id) { - ProductInfo info = productFacade.findProductById(id); - ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + public ApiResponse findProductById(@PathVariable Long id) { + ProductRankingInfo info = productFacade.findProductById(id); + ProductV1Dto.ProductRankingResponse response = ProductV1Dto.ProductRankingResponse.from(info); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index f581a3827..9c7cbd805 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductRankingInfo; import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -8,6 +9,19 @@ import java.math.BigDecimal; public class ProductV1Dto { + public record ProductRankingResponse(Long id, Long brandId, String name, BigDecimal price, int stock, int rank, double score) { + public static ProductRankingResponse from(ProductRankingInfo info) { + return new ProductRankingResponse( + info.id(), + info.brandId(), + info.name(), + info.price(), + info.stock(), + info.rank(), + info.score() + ); + } + } public record ProductResponse(Long id, Long brandId, String name, BigDecimal price, int stock, int likeCount) { public static ProductResponse from(ProductInfo info) { return new ProductResponse( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java new file mode 100644 index 000000000..7a4b6e4ba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -0,0 +1,9 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.interfaces.api.ApiResponse; + +public interface RankingV1ApiSpec { + + ApiResponse getDailyProductRanking(int size, int page); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java new file mode 100644 index 000000000..004f48972 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/rankings") +@RequiredArgsConstructor +public class RankingV1Controller implements RankingV1ApiSpec { + private final RankingFacade rankingFacade; + + @GetMapping + @Override + public ApiResponse getDailyProductRanking( + @RequestParam int size, + @RequestParam int page + ) { + RankingV1Dto.ProductRankingPageResponse response = rankingFacade.getDailyProductRanking(page, size); + + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 000000000..6d210553b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.ranking; + +import java.util.List; + +public class RankingV1Dto { + public record ProductRankingResponse( + Long rank, + Long productId, + double score + ) {} + + public record ProductRankingPageResponse( + String date, + int page, + int size, + long totalElements, + int totalPages, + List items + ) {} +} diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..b22b6477c --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..e5005c373 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,24 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..7c486483f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..800fe5a03 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet 작업 완료"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..10b09b8fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info( + "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..cb5c8bebd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..4f22f40b0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.info( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..9aa0d760a --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java new file mode 100644 index 000000000..c5e3bc7a3 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -0,0 +1,10 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class CommerceBatchApplicationTest { + @Test + void contextLoads() {} +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..dafe59a18 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,76 @@ +package com.loopers.job.demo; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. + // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob 배치가 정상적으로 실행된다.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetric.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetric.java index d4224a809..a0ebba5eb 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetric.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetric.java @@ -22,43 +22,47 @@ public class ProductMetric extends BaseEntity { @Column(name = "sales_volume", nullable = false) private Long salesVolume; + @Column(name = "metric_date", nullable = false) + private String metricDate; + @Enumerated(EnumType.STRING) @Column(name = "event_type", nullable = false) private ProductEventType eventType; - public ProductMetric(Long productId, Long viewCount, Long likeCount, Long salesVolume, ProductEventType eventType) { + public ProductMetric(Long productId, Long viewCount, Long likeCount, Long salesVolume, String metricDate, ProductEventType eventType) { this.productId = productId; this.viewCount = viewCount; this.likeCount = likeCount; this.salesVolume = salesVolume; + this.metricDate = metricDate; this.eventType = eventType; } /* - [ ] 리팩토링 예정 */ - public static ProductMetric of(Long productId, ProductEventType eventType) { + public static ProductMetric of(Long productId, String date, ProductEventType eventType) { if (eventType == null) { throw new IllegalArgumentException("정의되지 않은 event type입니다."); } return switch (eventType) { - case PRODUCT_VIEWED -> ofProductViewed(productId); - case PRODUCT_LIKED -> ofProductLiked(productId); - case PRODUCT_SALES -> ofProductSales(productId); + case PRODUCT_VIEWED -> ofProductViewed(productId, date); + case PRODUCT_LIKED -> ofProductLiked(productId, date); + case PRODUCT_SALES -> ofProductSales(productId, date); }; } - public static ProductMetric ofProductViewed(Long productId) { - return new ProductMetric(productId, 1L, 0L, 0L, ProductEventType.PRODUCT_VIEWED); + public static ProductMetric ofProductViewed(Long productId, String date) { + return new ProductMetric(productId, 1L, 0L, 0L, date, ProductEventType.PRODUCT_VIEWED); } - public static ProductMetric ofProductLiked(Long productId) { - return new ProductMetric(productId, 0L, 1L, 0L, ProductEventType.PRODUCT_LIKED); + public static ProductMetric ofProductLiked(Long productId, String date) { + return new ProductMetric(productId, 0L, 1L, 0L, date, ProductEventType.PRODUCT_LIKED); } - public static ProductMetric ofProductSales(Long productId) { - return new ProductMetric(productId, 0L, 0L, 1L, ProductEventType.PRODUCT_SALES); + public static ProductMetric ofProductSales(Long productId, String date) { + return new ProductMetric(productId, 0L, 0L, 1L, date, ProductEventType.PRODUCT_SALES); } public void increaseProductMetric(ProductEventType eventType) { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricRepository.java index ff451da2c..c38ac9886 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricRepository.java @@ -2,7 +2,7 @@ public interface ProductMetricRepository { - ProductMetric findByProductId(Long productId); + ProductMetric findByProductIdAndDate(Long productId, String date); ProductMetric save(ProductMetric of); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricJpaRepository.java index fec349610..8e7f2f34b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricJpaRepository.java @@ -6,4 +6,6 @@ public interface ProductMetricJpaRepository extends JpaRepository { ProductMetric findByProductId(Long productId); + + ProductMetric findByProductIdAndMetricDate(Long productId, String date); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricRepositoryImpl.java index 20aef6b90..d8d89c4d2 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricRepositoryImpl.java @@ -12,8 +12,8 @@ public class ProductMetricRepositoryImpl implements ProductMetricRepository { private final ProductMetricJpaRepository productMetricJpaRepository; @Override - public ProductMetric findByProductId(Long productId) { - return productMetricJpaRepository.findByProductId(productId); + public ProductMetric findByProductIdAndDate(Long productId, String date) { + return productMetricJpaRepository.findByProductIdAndMetricDate(productId, date); } @Override diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java index 086880a45..447acd9de 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java @@ -9,21 +9,33 @@ import com.loopers.domain.ProductMetricRepository; import lombok.RequiredArgsConstructor; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Component @RequiredArgsConstructor public class KafkaOutboxConsumer { private final ProductMetricRepository productMetricRepository; private final ObjectMapper objectMapper; + private final StringRedisTemplate redisTemplate; + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.BASIC_ISO_DATE; @KafkaListener( - topics = {"product-viewed", "product-liked"}, + topics = {"product-viewed", "product-liked", "product-sales"}, containerFactory = KafkaConfig.BATCH_LISTENER ) @Transactional @@ -31,23 +43,48 @@ public void productViewedListener( List> messages, Acknowledgment acknowledgment ) throws JsonProcessingException { + + Map scoreDelta = new HashMap<>(); + + String date = LocalDate.now(KST).format(YYYYMMDD); + for (var record : messages) { OutboxEvent value = objectMapper.readValue(record.value(), OutboxEvent.class); Long productId = value.aggregateId(); - String eventType = value.eventType(); + ProductEventType eventType = ProductEventType.valueOf(value.eventType()); + + scoreDelta.merge(productId, weight(eventType), Double::sum); - ProductMetric productMetric = productMetricRepository.findByProductId(productId); + ProductMetric productMetric = productMetricRepository.findByProductIdAndDate(productId, date); if (productMetric == null) { - ProductMetric newProductMetric = ProductMetric.of(productId, ProductEventType.valueOf(eventType)); + ProductMetric newProductMetric = ProductMetric.of(productId, date, eventType); productMetricRepository.save(newProductMetric); } else { - productMetric.increaseProductMetric(ProductEventType.valueOf(eventType)); + productMetric.increaseProductMetric(eventType); } + } + String key = "ranking:all:" + date; + ZSetOperations zset = redisTemplate.opsForZSet(); + + for (var e : scoreDelta.entrySet()) { + zset.incrementScore(key, String.valueOf(e.getKey()), e.getValue()); // ZINCRBY } + + redisTemplate.expire(key, Duration.ofDays(2)); + acknowledgment.acknowledge(); } + + double weight(ProductEventType eventType) { + return switch (eventType) { + case PRODUCT_VIEWED -> 0.1; + case PRODUCT_LIKED -> 0.3; + case PRODUCT_SALES -> 0.6; + }; + } } + diff --git a/settings.gradle.kts b/settings.gradle.kts index 906b49231..ec1672db8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ include( ":apps:commerce-api", ":apps:commerce-streamer", ":apps:pg-simulator", + ":apps:commerce-batch", ":modules:jpa", ":modules:redis", ":modules:kafka",