From afd8f31aeeae59345ece4c36b4176d3c57cfbf87 Mon Sep 17 00:00:00 2001 From: hubtwork Date: Wed, 31 Dec 2025 16:09:54 +0900 Subject: [PATCH 1/7] =?UTF-8?q?commerce-batch=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EB=A9=B0,=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=20Batch=20Job=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-batch/build.gradle.kts | 21 +++++ .../com/loopers/CommerceBatchApplication.java | 24 ++++++ .../loopers/batch/job/demo/DemoJobConfig.java | 48 ++++++++++++ .../batch/job/demo/step/DemoTasklet.java | 32 ++++++++ .../loopers/batch/listener/ChunkListener.java | 21 +++++ .../loopers/batch/listener/JobListener.java | 53 +++++++++++++ .../batch/listener/StepMonitorListener.java | 44 +++++++++++ .../src/main/resources/application.yml | 54 +++++++++++++ .../loopers/CommerceBatchApplicationTest.java | 10 +++ .../com/loopers/job/demo/DemoJobE2ETest.java | 76 +++++++++++++++++++ settings.gradle.kts | 1 + 11 files changed, 384 insertions(+) create mode 100644 apps/commerce-batch/build.gradle.kts create mode 100644 apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java create mode 100644 apps/commerce-batch/src/main/resources/application.yml create mode 100644 apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java 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/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", From 7281f0e3df470cc77dbce6a561577fd57880e990 Mon Sep 17 00:00:00 2001 From: hubtwork Date: Wed, 31 Dec 2025 16:16:04 +0900 Subject: [PATCH 2/7] =?UTF-8?q?commerce-batch=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=B4=20README=20=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) 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 From a994d1fb92f8546d4f6ba3cc025e99a006154acc Mon Sep 17 00:00:00 2001 From: green Date: Thu, 1 Jan 2026 22:09:06 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20ProductMetrics=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=B3=84=20=EC=A7=91=EA=B3=84=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProductMetrics 엔티티에 복합키(ref_product_id, metric_date)를 도입하여 날짜별로 조회수, 좋아요수, 판매수를 집계할 수 있는 구조로 변경 주요 변경: - ProductMetricsId 복합키 도입 - MetricDateConverter로 epoch 밀리초를 yyyyMMdd 정수로 변환 - Repository upsert 메서드에 metricDate 파라미터 추가 - 이벤트 전략에서 MetricDateConverter 사용 - 통합 테스트를 복합키 기반으로 수정 --- .../strategy/ProductLikedStrategy.java | 10 ++-- .../strategy/ProductSoldStrategy.java | 8 +++- .../strategy/ProductUnlikedStrategy.java | 10 ++-- .../strategy/ProductViewedStrategy.java | 10 ++-- .../domain/metrics/MetricDateConverter.java | 23 +++++++++ .../domain/metrics/ProductMetrics.java | 41 +++++++--------- .../domain/metrics/ProductMetricsId.java | 48 +++++++++++++++++++ .../metrics/ProductMetricsRepository.java | 6 +-- .../metrics/ProductMetricsJpaRepository.java | 24 ++++++---- .../metrics/ProductMetricsRepositoryImpl.java | 12 ++--- .../ProductMetricsIntegrationTest.java | 39 ++++++++++----- 11 files changed, 165 insertions(+), 66 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java b/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java index ddf8b69ca..150b61437 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.loopers.domain.event.EventType; +import com.loopers.domain.metrics.MetricDateConverter; import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.infrastructure.ranking.RankingRedisProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -13,6 +15,7 @@ public class ProductLikedStrategy implements CatalogEventStrategy { private final ProductMetricsRepository productMetricsRepository; + private final RankingRedisProperties rankingProperties; @Override public boolean supports(String eventType) { @@ -21,7 +24,8 @@ public boolean supports(String eventType) { @Override public void handle(Long productId, Long occurredAt, JsonNode payload) { - productMetricsRepository.upsertLikeCount(productId, 1, occurredAt); - log.debug("상품 {} 좋아요 수 증가", productId); + Integer metricDate = MetricDateConverter.toMetricDate(occurredAt, rankingProperties.getTimezone()); + productMetricsRepository.upsertLikeCount(productId, metricDate, 1, occurredAt); + log.debug("상품 {} 좋아요 수 증가 (날짜: {})", productId, metricDate); } -} \ No newline at end of file +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductSoldStrategy.java b/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductSoldStrategy.java index 69807199b..165f7f42a 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductSoldStrategy.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductSoldStrategy.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.loopers.domain.event.EventType; +import com.loopers.domain.metrics.MetricDateConverter; import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.infrastructure.ranking.RankingRedisProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -13,6 +15,7 @@ public class ProductSoldStrategy implements CatalogEventStrategy { private final ProductMetricsRepository productMetricsRepository; + private final RankingRedisProperties rankingProperties; @Override public boolean supports(String eventType) { @@ -22,8 +25,9 @@ public boolean supports(String eventType) { @Override public void handle(Long productId, Long occurredAt, JsonNode payload) { int quantity = extractQuantity(payload, productId); - productMetricsRepository.upsertSalesCount(productId, quantity, occurredAt); - log.debug("상품 {} 판매 수량 {} 증가", productId, quantity); + Integer metricDate = MetricDateConverter.toMetricDate(occurredAt, rankingProperties.getTimezone()); + productMetricsRepository.upsertSalesCount(productId, metricDate, quantity, occurredAt); + log.debug("상품 {} 판매 수량 {} 증가 (날짜: {})", productId, quantity, metricDate); } private int extractQuantity(JsonNode payload, Long productId) { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java b/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java index 54bb49ff3..f908d4703 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.loopers.domain.event.EventType; +import com.loopers.domain.metrics.MetricDateConverter; import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.infrastructure.ranking.RankingRedisProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -13,6 +15,7 @@ public class ProductUnlikedStrategy implements CatalogEventStrategy { private final ProductMetricsRepository productMetricsRepository; + private final RankingRedisProperties rankingProperties; @Override public boolean supports(String eventType) { @@ -21,7 +24,8 @@ public boolean supports(String eventType) { @Override public void handle(Long productId, Long occurredAt, JsonNode payload) { - productMetricsRepository.upsertLikeCount(productId, -1, occurredAt); - log.debug("상품 {} 좋아요 수 감소", productId); + Integer metricDate = MetricDateConverter.toMetricDate(occurredAt, rankingProperties.getTimezone()); + productMetricsRepository.upsertLikeCount(productId, metricDate, -1, occurredAt); + log.debug("상품 {} 좋아요 수 감소 (날짜: {})", productId, metricDate); } -} \ No newline at end of file +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductViewedStrategy.java b/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductViewedStrategy.java index 8ca78bedb..f2e9affe5 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductViewedStrategy.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductViewedStrategy.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.loopers.domain.event.EventType; +import com.loopers.domain.metrics.MetricDateConverter; import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.infrastructure.ranking.RankingRedisProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -13,6 +15,7 @@ public class ProductViewedStrategy implements CatalogEventStrategy { private final ProductMetricsRepository productMetricsRepository; + private final RankingRedisProperties rankingProperties; @Override public boolean supports(String eventType) { @@ -21,7 +24,8 @@ public boolean supports(String eventType) { @Override public void handle(Long productId, Long occurredAt, JsonNode payload) { - productMetricsRepository.upsertViewCount(productId, 1, occurredAt); - log.debug("상품 {} 조회 수 증가", productId); + Integer metricDate = MetricDateConverter.toMetricDate(occurredAt, rankingProperties.getTimezone()); + productMetricsRepository.upsertViewCount(productId, metricDate, 1, occurredAt); + log.debug("상품 {} 조회 수 증가 (날짜: {})", productId, metricDate); } -} \ No newline at end of file +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java new file mode 100644 index 000000000..7e672a9f5 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java @@ -0,0 +1,23 @@ +package com.loopers.domain.metrics; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +public final class MetricDateConverter { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private MetricDateConverter() {} + + public static Integer toMetricDate(Long epochMillis, ZoneId zoneId) { + if (epochMillis == null) { + throw new IllegalArgumentException("epochMillis는 null일 수 없습니다"); + } + String dateStr = Instant.ofEpochMilli(epochMillis) + .atZone(zoneId) + .toLocalDate() + .format(DATE_FORMATTER); + return Integer.parseInt(dateStr); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java index e81a0efa7..fa35776de 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -1,17 +1,16 @@ package com.loopers.domain.metrics; import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; -import jakarta.persistence.Id; import jakarta.persistence.Table; @Entity @Table(name = "product_metrics") public class ProductMetrics { - @Id - @Column(name = "ref_product_id", nullable = false) - private Long refProductId; + @EmbeddedId + private ProductMetricsId id; @Column(name = "like_count", nullable = false) private Long likeCount; @@ -28,21 +27,29 @@ public class ProductMetrics { protected ProductMetrics() {} private ProductMetrics( - Long refProductId, Long likeCount, Long salesCount, Long viewCount, Long updatedAt) { - this.refProductId = refProductId; + ProductMetricsId id, Long likeCount, Long salesCount, Long viewCount, Long updatedAt) { + this.id = id; this.likeCount = likeCount; this.salesCount = salesCount; this.viewCount = viewCount; this.updatedAt = updatedAt; } - public static ProductMetrics createWithLike(Long productId, int delta, Long occurredAt) { + public static ProductMetrics createWithLike(Long productId, Integer metricDate, int delta, Long occurredAt) { long initialLikeCount = Math.max(delta, 0); - return new ProductMetrics(productId, initialLikeCount, 0L, 0L, occurredAt); + return new ProductMetrics(ProductMetricsId.of(productId, metricDate), initialLikeCount, 0L, 0L, occurredAt); + } + + public ProductMetricsId getId() { + return id; } public Long getRefProductId() { - return refProductId; + return id.getRefProductId(); + } + + public Integer getMetricDate() { + return id.getMetricDate(); } public Long getLikeCount() { @@ -60,20 +67,4 @@ public Long getViewCount() { public Long getUpdatedAt() { return updatedAt; } - - public void incrementLikeCount(Long occurredAt) { - this.likeCount++; - updateTimestamp(occurredAt); - } - - public void decrementLikeCount(Long occurredAt) { - this.likeCount = Math.max(this.likeCount - 1, 0); - updateTimestamp(occurredAt); - } - - private void updateTimestamp(Long occurredAt) { - if (occurredAt != null && (this.updatedAt == null || occurredAt > this.updatedAt)) { - this.updatedAt = occurredAt; - } - } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java new file mode 100644 index 000000000..15f424e68 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java @@ -0,0 +1,48 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import java.util.Objects; + +@Embeddable +public class ProductMetricsId implements Serializable { + + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Column(name = "metric_date", nullable = false) + private Integer metricDate; + + protected ProductMetricsId() {} + + private ProductMetricsId(Long refProductId, Integer metricDate) { + this.refProductId = refProductId; + this.metricDate = metricDate; + } + + public static ProductMetricsId of(Long refProductId, Integer metricDate) { + return new ProductMetricsId(refProductId, metricDate); + } + + public Long getRefProductId() { + return refProductId; + } + + public Integer getMetricDate() { + return metricDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProductMetricsId that = (ProductMetricsId) o; + return Objects.equals(refProductId, that.refProductId) && Objects.equals(metricDate, that.metricDate); + } + + @Override + public int hashCode() { + return Objects.hash(refProductId, metricDate); + } +} \ No newline at end of file diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java index ec69392ce..32733b085 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -2,9 +2,9 @@ public interface ProductMetricsRepository { - void upsertLikeCount(Long productId, int delta, Long occurredAt); + void upsertLikeCount(Long productId, Integer metricDate, int delta, Long occurredAt); - void upsertSalesCount(Long productId, int quantity, Long occurredAt); + void upsertSalesCount(Long productId, Integer metricDate, int quantity, Long occurredAt); - void upsertViewCount(Long productId, int count, Long occurredAt); + void upsertViewCount(Long productId, Integer metricDate, int count, Long occurredAt); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java index 1d01c73aa..f25a6198a 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -1,19 +1,20 @@ package com.loopers.infrastructure.metrics; import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface ProductMetricsJpaRepository extends JpaRepository { +public interface ProductMetricsJpaRepository extends JpaRepository { - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ - INSERT INTO product_metrics (ref_product_id, like_count, sales_count, view_count, updated_at) - VALUES (:productId, GREATEST(:delta, 0), 0, 0, :occurredAt) + INSERT INTO product_metrics (ref_product_id, metric_date, like_count, sales_count, view_count, updated_at) + VALUES (:productId, :metricDate, GREATEST(:delta, 0), 0, 0, :occurredAt) ON DUPLICATE KEY UPDATE like_count = GREATEST(like_count + :delta, 0), updated_at = GREATEST(updated_at, :occurredAt) @@ -21,15 +22,16 @@ INSERT INTO product_metrics (ref_product_id, like_count, sales_count, view_count nativeQuery = true) void upsertLikeCount( @Param("productId") Long productId, + @Param("metricDate") Integer metricDate, @Param("delta") int delta, @Param("occurredAt") Long occurredAt); - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ - INSERT INTO product_metrics (ref_product_id, like_count, sales_count, view_count, updated_at) - VALUES (:productId, 0, :quantity, 0, :occurredAt) + INSERT INTO product_metrics (ref_product_id, metric_date, like_count, sales_count, view_count, updated_at) + VALUES (:productId, :metricDate, 0, :quantity, 0, :occurredAt) ON DUPLICATE KEY UPDATE sales_count = sales_count + :quantity, updated_at = GREATEST(updated_at, :occurredAt) @@ -37,15 +39,16 @@ INSERT INTO product_metrics (ref_product_id, like_count, sales_count, view_count nativeQuery = true) void upsertSalesCount( @Param("productId") Long productId, + @Param("metricDate") Integer metricDate, @Param("quantity") int quantity, @Param("occurredAt") Long occurredAt); - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ - INSERT INTO product_metrics (ref_product_id, like_count, sales_count, view_count, updated_at) - VALUES (:productId, 0, 0, :count, :occurredAt) + INSERT INTO product_metrics (ref_product_id, metric_date, like_count, sales_count, view_count, updated_at) + VALUES (:productId, :metricDate, 0, 0, :count, :occurredAt) ON DUPLICATE KEY UPDATE view_count = view_count + :count, updated_at = GREATEST(updated_at, :occurredAt) @@ -53,6 +56,7 @@ INSERT INTO product_metrics (ref_product_id, like_count, sales_count, view_count nativeQuery = true) void upsertViewCount( @Param("productId") Long productId, + @Param("metricDate") Integer metricDate, @Param("count") int count, @Param("occurredAt") Long occurredAt); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index a636a7363..cd40462e4 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -11,17 +11,17 @@ public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { private final ProductMetricsJpaRepository jpaRepository; @Override - public void upsertLikeCount(Long productId, int delta, Long occurredAt) { - jpaRepository.upsertLikeCount(productId, delta, occurredAt); + public void upsertLikeCount(Long productId, Integer metricDate, int delta, Long occurredAt) { + jpaRepository.upsertLikeCount(productId, metricDate, delta, occurredAt); } @Override - public void upsertSalesCount(Long productId, int quantity, Long occurredAt) { - jpaRepository.upsertSalesCount(productId, quantity, occurredAt); + public void upsertSalesCount(Long productId, Integer metricDate, int quantity, Long occurredAt) { + jpaRepository.upsertSalesCount(productId, metricDate, quantity, occurredAt); } @Override - public void upsertViewCount(Long productId, int count, Long occurredAt) { - jpaRepository.upsertViewCount(productId, count, occurredAt); + public void upsertViewCount(Long productId, Integer metricDate, int count, Long occurredAt) { + jpaRepository.upsertViewCount(productId, metricDate, count, occurredAt); } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java index 55a5d2c9e..fa346ea01 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java @@ -6,9 +6,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.application.CatalogEventHandler; +import com.loopers.domain.metrics.MetricDateConverter; import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsId; import com.loopers.infrastructure.metrics.ProductMetricsJpaRepository; import com.loopers.domain.event.EventType; +import com.loopers.infrastructure.ranking.RankingRedisProperties; import com.loopers.support.test.IntegrationTestSupport; import java.util.UUID; import org.junit.jupiter.api.DisplayName; @@ -28,6 +31,9 @@ class ProductMetricsIntegrationTest extends IntegrationTestSupport { @Autowired private ObjectMapper objectMapper; + @Autowired + private RankingRedisProperties rankingProperties; + @Nested @DisplayName("sales_count 집계") class SalesCountAggregation { @@ -38,12 +44,14 @@ void shouldIncreaseSalesCount_whenProductSoldEventReceived() throws Exception { Long productId = 100L; int quantity = 3; String eventId = UUID.randomUUID().toString(); + long occurredAt = System.currentTimeMillis(); JsonNode payload = objectMapper.readTree("{\"quantity\":" + quantity + ",\"orderId\":1}"); catalogEventHandler.handle( - eventId, EventType.PRODUCT_SOLD.getCode(), String.valueOf(productId), System.currentTimeMillis(), payload); + eventId, EventType.PRODUCT_SOLD.getCode(), String.valueOf(productId), occurredAt, payload); - ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow(); + Integer metricDate = MetricDateConverter.toMetricDate(occurredAt, rankingProperties.getTimezone()); + ProductMetrics metrics = productMetricsJpaRepository.findById(ProductMetricsId.of(productId, metricDate)).orElseThrow(); assertThat(metrics.getSalesCount()).isEqualTo(quantity); } @@ -67,7 +75,8 @@ void shouldAccumulateSalesCount_whenMultipleProductSoldEvents() throws Exception now + 1, objectMapper.readTree("{\"quantity\":5,\"orderId\":2}")); - ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow(); + Integer metricDate = MetricDateConverter.toMetricDate(now, rankingProperties.getTimezone()); + ProductMetrics metrics = productMetricsJpaRepository.findById(ProductMetricsId.of(productId, metricDate)).orElseThrow(); assertThat(metrics.getSalesCount()).isEqualTo(7); } @@ -100,16 +109,18 @@ class LikeCountAggregation { void shouldIncreaseLikeCount_whenProductLikedEventReceived() throws Exception { Long productId = 200L; String eventId = UUID.randomUUID().toString(); + long occurredAt = System.currentTimeMillis(); JsonNode emptyPayload = objectMapper.readTree("{}"); catalogEventHandler.handle( eventId, EventType.PRODUCT_LIKED.getCode(), String.valueOf(productId), - System.currentTimeMillis(), + occurredAt, emptyPayload); - ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow(); + Integer metricDate = MetricDateConverter.toMetricDate(occurredAt, rankingProperties.getTimezone()); + ProductMetrics metrics = productMetricsJpaRepository.findById(ProductMetricsId.of(productId, metricDate)).orElseThrow(); assertThat(metrics.getLikeCount()).isEqualTo(1); } @@ -142,7 +153,8 @@ void shouldDecreaseLikeCount_whenProductUnlikedEventReceived() throws Exception now + 2, emptyPayload); - ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow(); + Integer metricDate = MetricDateConverter.toMetricDate(now, rankingProperties.getTimezone()); + ProductMetrics metrics = productMetricsJpaRepository.findById(ProductMetricsId.of(productId, metricDate)).orElseThrow(); assertThat(metrics.getLikeCount()).isEqualTo(1); } @@ -150,6 +162,7 @@ void shouldDecreaseLikeCount_whenProductUnlikedEventReceived() throws Exception @DisplayName("like_count는 0 미만으로 내려가지 않는다") void shouldNotGoBelowZero_whenUnlikedMoreThanLiked() throws Exception { Long productId = 202L; + long occurredAt = System.currentTimeMillis(); JsonNode emptyPayload = objectMapper.readTree("{}"); // 좋아요 없이 취소 시도 @@ -157,10 +170,11 @@ void shouldNotGoBelowZero_whenUnlikedMoreThanLiked() throws Exception { UUID.randomUUID().toString(), EventType.PRODUCT_UNLIKED.getCode(), String.valueOf(productId), - System.currentTimeMillis(), + occurredAt, emptyPayload); - ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow(); + Integer metricDate = MetricDateConverter.toMetricDate(occurredAt, rankingProperties.getTimezone()); + ProductMetrics metrics = productMetricsJpaRepository.findById(ProductMetricsId.of(productId, metricDate)).orElseThrow(); assertThat(metrics.getLikeCount()).isZero(); } } @@ -174,16 +188,18 @@ class ViewCountAggregation { void shouldIncreaseViewCount_whenProductViewedEventReceived() throws Exception { Long productId = 300L; String eventId = UUID.randomUUID().toString(); + long occurredAt = System.currentTimeMillis(); JsonNode emptyPayload = objectMapper.readTree("{}"); catalogEventHandler.handle( eventId, EventType.PRODUCT_VIEWED.getCode(), String.valueOf(productId), - System.currentTimeMillis(), + occurredAt, emptyPayload); - ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow(); + Integer metricDate = MetricDateConverter.toMetricDate(occurredAt, rankingProperties.getTimezone()); + ProductMetrics metrics = productMetricsJpaRepository.findById(ProductMetricsId.of(productId, metricDate)).orElseThrow(); assertThat(metrics.getViewCount()).isEqualTo(1); } @@ -215,7 +231,8 @@ void shouldAccumulateViewCount_whenMultipleProductViewedEvents() throws Exceptio now + 2, emptyPayload); - ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow(); + Integer metricDate = MetricDateConverter.toMetricDate(now, rankingProperties.getTimezone()); + ProductMetrics metrics = productMetricsJpaRepository.findById(ProductMetricsId.of(productId, metricDate)).orElseThrow(); assertThat(metrics.getViewCount()).isEqualTo(3); } } From 6e90deb1d3f1a786ccc5a626e03978ee337725d6 Mon Sep 17 00:00:00 2001 From: green Date: Thu, 1 Jan 2026 22:10:58 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EC=A7=91=EA=B3=84=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20Job=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit product_metrics 데이터를 집계하여 주간/월간 랭킹을 계산하는 Spring Batch Job을 구현 배치 Job: - RankingAggregationJobConfig로 Reader-Processor-Writer 구성 - RankingItemProcessor에서 가중치 기반 점수 계산 (view:0.1, like:0.3, order:0.6) - period 파라미터로 WEEKLY/MONTHLY 구분 도메인: - ProductMetrics 읽기 전용 엔티티 추가 (배치 집계 소스) - WeeklyProductRank/MonthlyProductRank 랭킹 결과 엔티티 추가 인터페이스: - POST /api/v1/jobs/ranking 수동 실행 API 추가 테스트: - RankingAggregationJobE2ETest E2E 테스트 추가 --- apps/commerce-batch/build.gradle.kts | 1 + .../batch/application/BatchJobFacade.java | 51 ++++ .../loopers/batch/config/NoOpJobConfig.java | 38 +++ .../batch/config/RankingBatchProperties.java | 31 +++ .../batch/domain/metrics/ProductMetrics.java | 60 +++++ .../domain/metrics/ProductMetricsId.java | 48 ++++ .../domain/ranking/MonthlyProductRank.java | 70 +++++ .../domain/ranking/MonthlyProductRankId.java | 31 +++ .../domain/ranking/ProductRankEntity.java | 4 + .../batch/domain/ranking/RankingPeriod.java | 32 +++ .../domain/ranking/WeeklyProductRank.java | 70 +++++ .../domain/ranking/WeeklyProductRankId.java | 31 +++ .../batch/interfaces/BatchJobController.java | 30 +++ .../job/ranking/AggregatedProductScore.java | 13 + .../loopers/batch/job/ranking/DateRange.java | 9 + .../ranking/RankingAggregationJobConfig.java | 163 +++++++++++ .../job/ranking/RankingItemProcessor.java | 84 ++++++ .../MonthlyProductRankJpaRepository.java | 8 + .../WeeklyProductRankJpaRepository.java | 8 + .../src/main/resources/application.yml | 14 +- .../ranking/ProductMetricsTestRepository.java | 8 + .../ranking/RankingAggregationJobE2ETest.java | 252 ++++++++++++++++++ .../job/ranking/RankingJobTestConfig.java | 9 + .../src/test/resources/sql/cleanup.sql | 3 + 24 files changed, 1067 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/application/BatchJobFacade.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/config/NoOpJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/config/RankingBatchProperties.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/ProductRankEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriod.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/BatchJobController.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductMetricsTestRepository.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingJobTestConfig.java create mode 100644 apps/commerce-batch/src/test/resources/sql/cleanup.sql diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts index b22b6477c..ff453fc1c 100644 --- a/apps/commerce-batch/build.gradle.kts +++ b/apps/commerce-batch/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { // batch implementation("org.springframework.boot:spring-boot-starter-batch") + implementation("org.springframework.boot:spring-boot-starter-web") // Job 수동 실행 API용 testImplementation("org.springframework.batch:spring-batch-test") // querydsl diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/application/BatchJobFacade.java b/apps/commerce-batch/src/main/java/com/loopers/batch/application/BatchJobFacade.java new file mode 100644 index 000000000..492f5be13 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/application/BatchJobFacade.java @@ -0,0 +1,51 @@ +package com.loopers.batch.application; + +import com.loopers.batch.domain.ranking.RankingPeriod; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BatchJobFacade { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final JobLauncher jobLauncher; + private final Job rankingAggregationJob; + + public JobExecutionResult runRankingAggregation(RankingPeriod period, LocalDate date) { + String baseDate = date.format(DATE_FORMATTER); + log.info("랭킹 집계 Job 실행: period={}, baseDate={}", period, baseDate); + + try { + JobExecution execution = jobLauncher.run( + rankingAggregationJob, + new JobParametersBuilder() + .addString("period", period.name()) + .addString("baseDate", baseDate) + .addString("runId", LocalDateTime.now().toString()) + .toJobParameters() + ); + + return new JobExecutionResult( + execution.getJobId(), + execution.getStatus().toString(), + "Job 실행 완료" + ); + } catch (Exception e) { + log.error("랭킹 집계 Job 실행 실패", e); + return new JobExecutionResult(null, "FAILED", e.getMessage()); + } + } + + public record JobExecutionResult(Long jobId, String status, String message) {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/NoOpJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/NoOpJobConfig.java new file mode 100644 index 000000000..bf9274b15 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/NoOpJobConfig.java @@ -0,0 +1,38 @@ +package com.loopers.batch.config; + +import lombok.RequiredArgsConstructor; +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.batch.repeat.RepeatStatus; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * job.name이 지정되지 않았을 때 사용되는 빈 Job. + * 아무 작업도 수행하지 않고 즉시 완료됨. + */ +@Configuration +@RequiredArgsConstructor +public class NoOpJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + + @Bean("NONE") + public Job noOpJob() { + return new JobBuilder("NONE", jobRepository) + .start(noOpStep()) + .build(); + } + + @Bean + public Step noOpStep() { + return new StepBuilder("noOpStep", jobRepository) + .tasklet((contribution, chunkContext) -> RepeatStatus.FINISHED, transactionManager) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/RankingBatchProperties.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/RankingBatchProperties.java new file mode 100644 index 000000000..87539c162 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/RankingBatchProperties.java @@ -0,0 +1,31 @@ +package com.loopers.batch.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "ranking") +public class RankingBatchProperties { + + private Weight weight = new Weight(); + private Batch batch = new Batch(); + + @Getter + @Setter + public static class Weight { + private double view = 0.1; + private double like = 0.3; + private double order = 0.6; + } + + @Getter + @Setter + public static class Batch { + private int chunkSize = 100; + private int limit = 100; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..58c96c7a2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java @@ -0,0 +1,60 @@ +package com.loopers.batch.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product_metrics") +public class ProductMetrics { + + @EmbeddedId + private ProductMetricsId id; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "updated_at", nullable = false) + private Long updatedAt; + + protected ProductMetrics() {} + + private ProductMetrics(ProductMetricsId id, Long viewCount, Long likeCount, Long salesCount) { + this.id = id; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.updatedAt = System.currentTimeMillis(); + } + + public static ProductMetrics of(Long refProductId, Integer metricDate, Long viewCount, Long likeCount, Long salesCount) { + return new ProductMetrics(ProductMetricsId.of(refProductId, metricDate), viewCount, likeCount, salesCount); + } + + public Long getRefProductId() { + return id.getRefProductId(); + } + + public Integer getMetricDate() { + return id.getMetricDate(); + } + + public Long getLikeCount() { + return likeCount; + } + + public Long getSalesCount() { + return salesCount; + } + + public Long getViewCount() { + return viewCount; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java new file mode 100644 index 000000000..5e0eca0c3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java @@ -0,0 +1,48 @@ +package com.loopers.batch.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import java.util.Objects; + +@Embeddable +public class ProductMetricsId implements Serializable { + + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Column(name = "metric_date", nullable = false) + private Integer metricDate; + + protected ProductMetricsId() {} + + private ProductMetricsId(Long refProductId, Integer metricDate) { + this.refProductId = refProductId; + this.metricDate = metricDate; + } + + public static ProductMetricsId of(Long refProductId, Integer metricDate) { + return new ProductMetricsId(refProductId, metricDate); + } + + public Long getRefProductId() { + return refProductId; + } + + public Integer getMetricDate() { + return metricDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProductMetricsId that = (ProductMetricsId) o; + return Objects.equals(refProductId, that.refProductId) && Objects.equals(metricDate, that.metricDate); + } + + @Override + public int hashCode() { + return Objects.hash(refProductId, metricDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java new file mode 100644 index 000000000..9f4475cb9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java @@ -0,0 +1,70 @@ +package com.loopers.batch.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "mv_product_rank_monthly") +@IdClass(MonthlyProductRankId.class) +public class MonthlyProductRank implements ProductRankEntity { + + @Id + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Id + @Column(name = "ranking_year_month", nullable = false) + private String yearMonth; + + @Column(name = "score", nullable = false) + private Double score; + + @Column(name = "period_start", nullable = false) + private LocalDate periodStart; + + @Column(name = "period_end", nullable = false) + private LocalDate periodEnd; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected MonthlyProductRank() {} + + private MonthlyProductRank( + Long refProductId, + String yearMonth, + Double score, + LocalDate periodStart, + LocalDate periodEnd, + LocalDateTime updatedAt) { + this.refProductId = refProductId; + this.yearMonth = yearMonth; + this.score = score; + this.periodStart = periodStart; + this.periodEnd = periodEnd; + this.updatedAt = updatedAt; + } + + public static MonthlyProductRank of( + Long refProductId, + String yearMonth, + Double score, + LocalDate periodStart, + LocalDate periodEnd, + LocalDateTime updatedAt) { + return new MonthlyProductRank(refProductId, yearMonth, score, periodStart, periodEnd, updatedAt); + } + + public String getYearMonth() { + return yearMonth; + } + + public Double getScore() { + return score; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.java new file mode 100644 index 000000000..b1e2ee50f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.java @@ -0,0 +1,31 @@ +package com.loopers.batch.domain.ranking; + +import java.io.Serializable; +import java.util.Objects; + +// MonthlyProductRank 복합키 (refProductId + yearMonth) +public class MonthlyProductRankId implements Serializable { + + private Long refProductId; + private String yearMonth; + + public MonthlyProductRankId() {} + + public MonthlyProductRankId(Long refProductId, String yearMonth) { + this.refProductId = refProductId; + this.yearMonth = yearMonth; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MonthlyProductRankId that = (MonthlyProductRankId) o; + return Objects.equals(refProductId, that.refProductId) && Objects.equals(yearMonth, that.yearMonth); + } + + @Override + public int hashCode() { + return Objects.hash(refProductId, yearMonth); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/ProductRankEntity.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/ProductRankEntity.java new file mode 100644 index 000000000..6062a3f95 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/ProductRankEntity.java @@ -0,0 +1,4 @@ +package com.loopers.batch.domain.ranking; + +// Weekly/Monthly 랭킹 엔티티의 공통 마커 인터페이스 +public interface ProductRankEntity {} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriod.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriod.java new file mode 100644 index 000000000..3f0a2c259 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriod.java @@ -0,0 +1,32 @@ +package com.loopers.batch.domain.ranking; + +import java.util.Arrays; + +public enum RankingPeriod { + WEEKLY("weekly", "주간 랭킹"), + MONTHLY("monthly", "월간 랭킹"); + + private final String code; + private final String description; + + RankingPeriod(String code, String description) { + this.code = code; + this.description = description; + } + + public String getCode() { + return code; + } + + public String getDescription() { + return description; + } + + public static RankingPeriod fromCode(String code) { + return Arrays.stream(values()) + .filter(period -> period.code.equalsIgnoreCase(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "유효하지 않은 period: " + code + ". WEEKLY 또는 MONTHLY만 허용됩니다.")); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java new file mode 100644 index 000000000..692630b84 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java @@ -0,0 +1,70 @@ +package com.loopers.batch.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "mv_product_rank_weekly") +@IdClass(WeeklyProductRankId.class) +public class WeeklyProductRank implements ProductRankEntity { + + @Id + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Id + @Column(name = "ranking_year_week", nullable = false) + private String yearWeek; + + @Column(name = "score", nullable = false) + private Double score; + + @Column(name = "period_start", nullable = false) + private LocalDate periodStart; + + @Column(name = "period_end", nullable = false) + private LocalDate periodEnd; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected WeeklyProductRank() {} + + private WeeklyProductRank( + Long refProductId, + String yearWeek, + Double score, + LocalDate periodStart, + LocalDate periodEnd, + LocalDateTime updatedAt) { + this.refProductId = refProductId; + this.yearWeek = yearWeek; + this.score = score; + this.periodStart = periodStart; + this.periodEnd = periodEnd; + this.updatedAt = updatedAt; + } + + public static WeeklyProductRank of( + Long refProductId, + String yearWeek, + Double score, + LocalDate periodStart, + LocalDate periodEnd, + LocalDateTime updatedAt) { + return new WeeklyProductRank(refProductId, yearWeek, score, periodStart, periodEnd, updatedAt); + } + + public String getYearWeek() { + return yearWeek; + } + + public Double getScore() { + return score; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java new file mode 100644 index 000000000..382699859 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java @@ -0,0 +1,31 @@ +package com.loopers.batch.domain.ranking; + +import java.io.Serializable; +import java.util.Objects; + +// WeeklyProductRank 복합키 (refProductId + yearWeek) +public class WeeklyProductRankId implements Serializable { + + private Long refProductId; + private String yearWeek; + + public WeeklyProductRankId() {} + + public WeeklyProductRankId(Long refProductId, String yearWeek) { + this.refProductId = refProductId; + this.yearWeek = yearWeek; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WeeklyProductRankId that = (WeeklyProductRankId) o; + return Objects.equals(refProductId, that.refProductId) && Objects.equals(yearWeek, that.yearWeek); + } + + @Override + public int hashCode() { + return Objects.hash(refProductId, yearWeek); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/BatchJobController.java b/apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/BatchJobController.java new file mode 100644 index 000000000..d173343f8 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/BatchJobController.java @@ -0,0 +1,30 @@ +package com.loopers.batch.interfaces; + +import com.loopers.batch.application.BatchJobFacade; +import com.loopers.batch.application.BatchJobFacade.JobExecutionResult; +import com.loopers.batch.domain.ranking.RankingPeriod; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +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/jobs") +@RequiredArgsConstructor +public class BatchJobController { + + private final BatchJobFacade batchJobFacade; + + @PostMapping("/ranking") + public ResponseEntity runRankingAggregation( + @RequestParam RankingPeriod period, + @RequestParam @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date + ) { + return ResponseEntity.ok(batchJobFacade.runRankingAggregation(period, date)); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java new file mode 100644 index 000000000..ec5306fe9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java @@ -0,0 +1,13 @@ +package com.loopers.batch.job.ranking; + +public record AggregatedProductScore( + Long refProductId, + Long totalViewCount, + Long totalLikeCount, + Long totalSalesCount +) { + + public double calculateScore(double viewWeight, double likeWeight, double orderWeight) { + return (totalViewCount * viewWeight) + (totalLikeCount * likeWeight) + (totalSalesCount * orderWeight); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java new file mode 100644 index 000000000..26317024c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java @@ -0,0 +1,9 @@ +package com.loopers.batch.job.ranking; + +// 집계 기간 (시작일, 종료일 - YYYYMMDD 형식) +public record DateRange(int startDate, int endDate) { + + public static DateRange of(int startDate, int endDate) { + return new DateRange(startDate, endDate); + } +} \ No newline at end of file diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java new file mode 100644 index 000000000..2883975ac --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java @@ -0,0 +1,163 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.config.RankingBatchProperties; +import com.loopers.batch.domain.ranking.MonthlyProductRank; +import com.loopers.batch.domain.ranking.ProductRankEntity; +import com.loopers.batch.domain.ranking.RankingPeriod; +import com.loopers.batch.domain.ranking.WeeklyProductRank; +import com.loopers.batch.listener.JobListener; +import com.loopers.infrastructure.ranking.MonthlyProductRankJpaRepository; +import com.loopers.infrastructure.ranking.WeeklyProductRankJpaRepository; +import jakarta.persistence.EntityManagerFactory; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.configuration.annotation.StepScope; +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.batch.item.ItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class RankingAggregationJobConfig { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final String JOB_NAME = "rankingAggregationJob"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + private final RankingBatchProperties properties; + private final JobListener jobListener; + private final WeeklyProductRankJpaRepository weeklyRepository; + private final MonthlyProductRankJpaRepository monthlyRepository; + + @Bean(JOB_NAME) + public Job rankingAggregationJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .listener(jobListener) + .start(rankingAggregationStep(null, null)) + .build(); + } + + @Bean + @JobScope + public Step rankingAggregationStep( + @Value("#{jobParameters['period']}") String period, + @Value("#{jobParameters['baseDate']}") String baseDate) { + return new StepBuilder("rankingAggregationStep", jobRepository) + .chunk(properties.getBatch().getChunkSize(), transactionManager) + .reader(aggregatedScoreReader(period, baseDate)) + .processor(rankingProcessor()) + .writer(rankingWriter(period)) + .build(); + } + + @Bean + @StepScope + public JpaPagingItemReader aggregatedScoreReader( + @Value("#{jobParameters['period']}") String period, + @Value("#{jobParameters['baseDate']}") String baseDate) { + + LocalDate base = LocalDate.parse(baseDate, DATE_FORMATTER); + RankingPeriod rankingPeriod = RankingPeriod.fromCode(period); + DateRange dateRange = calculateDateRange(rankingPeriod, base); + + log.info("집계 기간: {} ~ {} (period={})", dateRange.startDate(), dateRange.endDate(), period); + + String jpql = """ + SELECT new com.loopers.batch.job.ranking.AggregatedProductScore( + m.id.refProductId, + SUM(m.viewCount), + SUM(m.likeCount), + SUM(m.salesCount) + ) + FROM ProductMetrics m + WHERE m.id.metricDate BETWEEN :startDate AND :endDate + GROUP BY m.id.refProductId + ORDER BY + (CAST(SUM(m.viewCount) AS double) * :viewWeight + + CAST(SUM(m.likeCount) AS double) * :likeWeight + + CAST(SUM(m.salesCount) AS double) * :orderWeight) DESC, + m.id.refProductId + """; + + Map params = new HashMap<>(); + params.put("startDate", dateRange.startDate()); + params.put("endDate", dateRange.endDate()); + params.put("viewWeight", properties.getWeight().getView()); + params.put("likeWeight", properties.getWeight().getLike()); + params.put("orderWeight", properties.getWeight().getOrder()); + + return new JpaPagingItemReaderBuilder() + .name("aggregatedScoreReader") + .entityManagerFactory(entityManagerFactory) + .queryString(jpql) + .parameterValues(params) + .pageSize(properties.getBatch().getChunkSize()) + .maxItemCount(properties.getBatch().getLimit()) + .build(); + } + + @Bean + @StepScope + public RankingItemProcessor rankingProcessor() { + return new RankingItemProcessor(properties); + } + + @Bean + @StepScope + public ItemWriter rankingWriter( + @Value("#{jobParameters['period']}") String period) { + RankingPeriod rankingPeriod = RankingPeriod.fromCode(period); + + if (rankingPeriod == RankingPeriod.WEEKLY) { + return items -> { + List ranks = items.getItems().stream() + .map(WeeklyProductRank.class::cast) + .toList(); + weeklyRepository.saveAll(ranks); + log.debug("주간 점수 {} 건 저장 완료", ranks.size()); + }; + } + return items -> { + List ranks = items.getItems().stream() + .map(MonthlyProductRank.class::cast) + .toList(); + monthlyRepository.saveAll(ranks); + log.debug("월간 점수 {} 건 저장 완료", ranks.size()); + }; + } + + private DateRange calculateDateRange(RankingPeriod period, LocalDate base) { + if (period == RankingPeriod.WEEKLY) { + LocalDate weekStart = base.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate weekEnd = base.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + return DateRange.of( + Integer.parseInt(weekStart.format(DATE_FORMATTER)), + Integer.parseInt(weekEnd.format(DATE_FORMATTER))); + } + LocalDate monthStart = base.withDayOfMonth(1); + LocalDate monthEnd = base.with(TemporalAdjusters.lastDayOfMonth()); + return DateRange.of( + Integer.parseInt(monthStart.format(DATE_FORMATTER)), + Integer.parseInt(monthEnd.format(DATE_FORMATTER))); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java new file mode 100644 index 000000000..09d01cc9b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java @@ -0,0 +1,84 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.config.RankingBatchProperties; +import com.loopers.batch.domain.ranking.MonthlyProductRank; +import com.loopers.batch.domain.ranking.ProductRankEntity; +import com.loopers.batch.domain.ranking.RankingPeriod; +import com.loopers.batch.domain.ranking.WeeklyProductRank; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.time.temporal.WeekFields; +import java.util.Locale; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.item.ItemProcessor; + +// 점수 계산 + 엔티티 변환 +@RequiredArgsConstructor +public class RankingItemProcessor implements ItemProcessor { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RankingBatchProperties properties; + + private RankingPeriod period; + private LocalDate baseDate; + + // 주간용 필드 + private String yearWeek; + private LocalDate weekStart; + private LocalDate weekEnd; + + // 월간용 필드 + private String yearMonth; + private LocalDate monthStart; + private LocalDate monthEnd; + + @BeforeStep + public void beforeStep(StepExecution stepExecution) { + String periodParam = stepExecution.getJobParameters().getString("period"); + String baseDateParam = stepExecution.getJobParameters().getString("baseDate"); + + this.period = RankingPeriod.fromCode(periodParam); + this.baseDate = LocalDate.parse(baseDateParam, DATE_FORMATTER); + + if (period == RankingPeriod.WEEKLY) { + initWeeklyFields(); + } else { + initMonthlyFields(); + } + } + + private void initWeeklyFields() { + WeekFields weekFields = WeekFields.of(Locale.KOREA); + int weekBasedYear = baseDate.get(weekFields.weekBasedYear()); + this.yearWeek = String.format("%d-W%02d", weekBasedYear, baseDate.get(weekFields.weekOfWeekBasedYear())); + this.weekStart = baseDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + this.weekEnd = baseDate.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + } + + private void initMonthlyFields() { + this.yearMonth = baseDate.format(DateTimeFormatter.ofPattern("yyyy-MM")); + this.monthStart = baseDate.withDayOfMonth(1); + this.monthEnd = baseDate.with(TemporalAdjusters.lastDayOfMonth()); + } + + @Override + public ProductRankEntity process(AggregatedProductScore item) { + double score = item.calculateScore( + properties.getWeight().getView(), + properties.getWeight().getLike(), + properties.getWeight().getOrder()); + + LocalDateTime now = LocalDateTime.now(); + + if (period == RankingPeriod.WEEKLY) { + return WeeklyProductRank.of(item.refProductId(), yearWeek, score, weekStart, weekEnd, now); + } + return MonthlyProductRank.of(item.refProductId(), yearMonth, score, monthStart, monthEnd, now); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.java new file mode 100644 index 000000000..de5bbc5ca --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.batch.domain.ranking.MonthlyProductRank; +import com.loopers.batch.domain.ranking.MonthlyProductRankId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MonthlyProductRankJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.java new file mode 100644 index 000000000..43b0fc482 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.batch.domain.ranking.WeeklyProductRank; +import com.loopers.batch.domain.ranking.WeeklyProductRankId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WeeklyProductRankJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index 9aa0d760a..e27f2fa27 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -1,6 +1,9 @@ +server: + port: 8082 + spring: main: - web-application-type: none + web-application-type: servlet # 수동 실행 API 제공을 위해 웹 서버 활성화 application: name: commerce-batch profiles: @@ -22,6 +25,15 @@ management: defaults: enabled: false +ranking: + weight: + view: 0.1 # 조회 가중치 + like: 0.3 # 좋아요 가중치 + order: 0.6 # 주문 가중치 + batch: + chunk-size: 100 # 청크 사이즈 + limit: 100 # TOP N 랭킹 개수 + --- spring: config: diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductMetricsTestRepository.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductMetricsTestRepository.java new file mode 100644 index 000000000..01e175554 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductMetricsTestRepository.java @@ -0,0 +1,8 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.domain.metrics.ProductMetrics; +import com.loopers.batch.domain.metrics.ProductMetricsId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductMetricsTestRepository extends JpaRepository { +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java new file mode 100644 index 000000000..f5af8737b --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java @@ -0,0 +1,252 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.batch.domain.metrics.ProductMetrics; +import com.loopers.batch.domain.ranking.WeeklyProductRank; +import com.loopers.batch.domain.ranking.MonthlyProductRank; +import com.loopers.infrastructure.ranking.WeeklyProductRankJpaRepository; +import com.loopers.infrastructure.ranking.MonthlyProductRankJpaRepository; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=rankingAggregationJob") +@Sql(scripts = "/sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Import(RankingJobTestConfig.class) +class RankingAggregationJobE2ETest { + + private static final AtomicLong RUN_ID_COUNTER = new AtomicLong(System.currentTimeMillis()); + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier("rankingAggregationJob") + private Job job; + + @Autowired + private ProductMetricsTestRepository productMetricsRepository; + + @Autowired + private WeeklyProductRankJpaRepository weeklyRepository; + + @Autowired + private MonthlyProductRankJpaRepository monthlyRepository; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + } + + @Nested + @DisplayName("파라미터 검증") + class ParameterValidation { + + @Test + @DisplayName("period 파라미터가 없으면 Job이 실패한다") + void shouldFail_whenPeriodMissing() throws Exception { + // given + var jobParameters = new JobParametersBuilder() + .addLong("run.id", RUN_ID_COUNTER.incrementAndGet()) + .addString("baseDate", "20251224") + .toJobParameters(); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @Test + @DisplayName("baseDate 파라미터가 없으면 Job이 실패한다") + void shouldFail_whenBaseDateMissing() throws Exception { + // given + var jobParameters = new JobParametersBuilder() + .addLong("run.id", RUN_ID_COUNTER.incrementAndGet()) + .addString("period", "weekly") + .toJobParameters(); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @Test + @DisplayName("유효하지 않은 period 값이면 Job이 실패한다") + void shouldFail_whenInvalidPeriod() throws Exception { + // given + var jobParameters = new JobParametersBuilder() + .addLong("run.id", RUN_ID_COUNTER.incrementAndGet()) + .addString("period", "daily") + .addString("baseDate", "20251224") + .toJobParameters(); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + } + + @Nested + @DisplayName("주간 랭킹 집계") + class WeeklyAggregation { + + @Test + @DisplayName("주간 메트릭을 집계하여 MV 테이블에 저장한다") + void shouldAggregateWeeklyMetrics() throws Exception { + // given: 2025-12-22 (월) ~ 2025-12-28 (일) 주간 데이터 + saveAllMetrics( + metrics(1L, 20251222, 100L, 10L, 5L), // 월 + metrics(1L, 20251223, 50L, 5L, 2L), // 화 + metrics(2L, 20251222, 200L, 20L, 10L) // 상품2가 더 높은 점수 + ); + + var jobParameters = new JobParametersBuilder() + .addLong("run.id", RUN_ID_COUNTER.incrementAndGet()) + .addString("period", "weekly") + .addString("baseDate", "20251224") // 수요일 기준 + .toJobParameters(); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> { + List ranks = weeklyRepository.findAll(); + assertThat(ranks).hasSize(2); + // 상품2가 더 높은 점수 (score = 200*0.1 + 20*0.3 + 10*0.6 = 32) + // 상품1 합계 (score = 150*0.1 + 15*0.3 + 7*0.6 = 23.7) + } + ); + } + + @Test + @DisplayName("연도 경계: 2024-12-30은 2025-W01에 속한다") + void shouldHandleYearBoundary_weekBelongsToNextYear() throws Exception { + // given: 2024-12-30 (월) ~ 2025-01-05 (일) = 2025-W01 + saveAllMetrics( + metrics(1L, 20241230, 100L, 10L, 5L), + metrics(1L, 20250101, 50L, 5L, 2L) + ); + + var jobParameters = new JobParametersBuilder() + .addLong("run.id", RUN_ID_COUNTER.incrementAndGet()) + .addString("period", "weekly") + .addString("baseDate", "20241231") // 2024년 마지막 날이지만 2025-W01 + .toJobParameters(); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> { + List ranks = weeklyRepository.findAll(); + assertThat(ranks).isNotEmpty(); + assertThat(ranks.get(0).getYearWeek()).isEqualTo("2025-W01"); + } + ); + } + } + + @Nested + @DisplayName("월간 랭킹 집계") + class MonthlyAggregation { + + @Test + @DisplayName("월간 메트릭을 집계하여 MV 테이블에 저장한다") + void shouldAggregateMonthlyMetrics() throws Exception { + // given: 2025년 12월 전체 데이터 + saveAllMetrics( + metrics(1L, 20251201, 100L, 10L, 5L), + metrics(1L, 20251215, 100L, 10L, 5L), + metrics(1L, 20251231, 100L, 10L, 5L) + ); + + var jobParameters = new JobParametersBuilder() + .addLong("run.id", RUN_ID_COUNTER.incrementAndGet()) + .addString("period", "monthly") + .addString("baseDate", "20251215") + .toJobParameters(); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> { + List ranks = monthlyRepository.findAll(); + assertThat(ranks).hasSize(1); + assertThat(ranks.get(0).getYearMonth()).isEqualTo("2025-12"); + } + ); + } + } + + @Nested + @DisplayName("점수 계산 검증") + class ScoreCalculation { + + @Test + @DisplayName("메트릭 가중치에 따라 점수가 계산된다") + void shouldCalculateScore_withConfiguredWeights() throws Exception { + // given: score = view*viewWeight + like*likeWeight + sales*orderWeight + saveAllMetrics(metrics(1L, 20251222, 100L, 10L, 5L)); + + var jobParameters = new JobParametersBuilder() + .addLong("run.id", RUN_ID_COUNTER.incrementAndGet()) + .addString("period", "weekly") + .addString("baseDate", "20251224") + .toJobParameters(); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + List ranks = weeklyRepository.findAll(); + assertThat(ranks).hasSize(1); + assertThat(ranks.get(0).getScore()).isEqualTo(16.0); + } + } + + private void saveAllMetrics(ProductMetrics... metrics) { + productMetricsRepository.saveAll(List.of(metrics)); + } + + private ProductMetrics metrics(Long productId, Integer metricDate, Long viewCount, Long likeCount, Long salesCount) { + return ProductMetrics.of(productId, metricDate, viewCount, likeCount, salesCount); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingJobTestConfig.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingJobTestConfig.java new file mode 100644 index 000000000..b361cd5fa --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingJobTestConfig.java @@ -0,0 +1,9 @@ +package com.loopers.job.ranking; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@TestConfiguration +@EnableJpaRepositories(basePackageClasses = ProductMetricsTestRepository.class) +public class RankingJobTestConfig { +} diff --git a/apps/commerce-batch/src/test/resources/sql/cleanup.sql b/apps/commerce-batch/src/test/resources/sql/cleanup.sql new file mode 100644 index 000000000..25f87e541 --- /dev/null +++ b/apps/commerce-batch/src/test/resources/sql/cleanup.sql @@ -0,0 +1,3 @@ +DELETE FROM mv_product_rank_weekly; +DELETE FROM mv_product_rank_monthly; +DELETE FROM product_metrics; From 5d13db05036d7156dd4497f9c041ded6a084385a Mon Sep 17 00:00:00 2001 From: green Date: Thu, 1 Jan 2026 22:11:45 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배치 Job이 생성한 랭킹 MV 테이블을 조회하는 API를 추가 API: - GET /api/v1/rankings/weekly 주간 랭킹 조회 - GET /api/v1/rankings/monthly 월간 랭킹 조회 도메인: - WeeklyProductRank/MonthlyProductRank MV 엔티티 추가 - RankingService에 주간/월간 조회 메서드 추가 애플리케이션: - RankingFacade에 주간/월간 조회 메서드 추가 --- .../application/ranking/RankingFacade.java | 16 ++++- .../domain/ranking/ProductRankView.java | 8 +++ .../domain/ranking/RankingService.java | 44 +++++++++++++- .../domain/ranking/mv/MonthlyProductRank.java | 51 ++++++++++++++++ .../ranking/mv/MonthlyProductRankId.java | 30 ++++++++++ .../mv/MonthlyProductRankRepository.java | 8 +++ .../domain/ranking/mv/WeeklyProductRank.java | 58 +++++++++++++++++++ .../ranking/mv/WeeklyProductRankId.java | 30 ++++++++++ .../mv/WeeklyProductRankRepository.java | 8 +++ .../mv/MonthlyProductRankJpaRepository.java | 12 ++++ .../mv/MonthlyProductRankRepositoryImpl.java | 20 +++++++ .../mv/WeeklyProductRankJpaRepository.java | 12 ++++ .../mv/WeeklyProductRankRepositoryImpl.java | 20 +++++++ .../api/ranking/RankingApiSpec.java | 18 +++++- .../api/ranking/RankingController.java | 26 +++++++++ 15 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankView.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java 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 index 28262a8d7..9eeb3b556 100644 --- 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 @@ -29,7 +29,22 @@ public class RankingFacade { @Transactional(readOnly = true) public RankingResult getDailyRanking(LocalDate date, int page, int size, Long userId) { List entries = rankingService.getTopN(date, page, size); + return buildResult(entries, date, page, size, userId); + } + + @Transactional(readOnly = true) + public RankingResult getWeeklyRanking(LocalDate date, int page, int size, Long userId) { + List entries = rankingService.getWeeklyTopN(date, page, size); + return buildResult(entries, date, page, size, userId); + } + @Transactional(readOnly = true) + public RankingResult getMonthlyRanking(LocalDate date, int page, int size, Long userId) { + List entries = rankingService.getMonthlyTopN(date, page, size); + return buildResult(entries, date, page, size, userId); + } + + private RankingResult buildResult(List entries, LocalDate date, int page, int size, Long userId) { if (entries.isEmpty()) { return RankingResult.empty(date, page, size); } @@ -72,5 +87,4 @@ public RankingResult getDailyRanking(LocalDate date, int page, int size, Long us return new RankingResult(items, page, size, date); } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankView.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankView.java new file mode 100644 index 000000000..c5df1cd32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankView.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking; + +public interface ProductRankView { + + Long getRefProductId(); + + Double getScore(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java index e50aacf30..448a42b32 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -1,13 +1,22 @@ package com.loopers.domain.ranking; -import java.time.LocalDate; -import java.util.List; +import com.loopers.domain.ranking.mv.MonthlyProductRank; +import com.loopers.domain.ranking.mv.MonthlyProductRankRepository; +import com.loopers.domain.ranking.mv.WeeklyProductRank; +import com.loopers.domain.ranking.mv.WeeklyProductRankRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.data.redis.RedisSystemException; import org.springframework.stereotype.Service; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + @Slf4j @Service @RequiredArgsConstructor @@ -15,12 +24,36 @@ public class RankingService { private final RankingRepository rankingRepository; private final RankingKeyPolicy rankingKeyPolicy; + private final WeeklyProductRankRepository weeklyProductRankRepository; + private final MonthlyProductRankRepository monthlyProductRankRepository; public List getTopN(LocalDate date, int page, int size) { String key = rankingKeyPolicy.buildKey(date); return rankingRepository.getTopN(key, page, size); } + public List getWeeklyTopN(LocalDate date, int page, int size) { + String yearWeek = toYearWeek(date); + List ranks = weeklyProductRankRepository.findByYearWeekOrderByScoreDesc(yearWeek, page, size); + return toRankingEntries(ranks, page, size); + } + + public List getMonthlyTopN(LocalDate date, int page, int size) { + String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyy-MM")); + List ranks = monthlyProductRankRepository.findByYearMonthOrderByScoreDesc(yearMonth, page, size); + return toRankingEntries(ranks, page, size); + } + + private List toRankingEntries(List ranks, int page, int size) { + int baseRank = page * size; + List result = new ArrayList<>(); + for (int i = 0; i < ranks.size(); i++) { + ProductRankView rank = ranks.get(i); + result.add(new RankingEntry(rank.getRefProductId(), rank.getScore(), baseRank + i + 1)); + } + return result; + } + public Integer getRankOrNull(LocalDate date, Long productId) { try { String key = rankingKeyPolicy.buildKey(date); @@ -30,4 +63,11 @@ public Integer getRankOrNull(LocalDate date, Long productId) { return null; } } + + private String toYearWeek(LocalDate date) { + WeekFields weekFields = WeekFields.of(Locale.KOREA); + int weekBasedYear = date.get(weekFields.weekBasedYear()); + int weekOfYear = date.get(weekFields.weekOfWeekBasedYear()); + return String.format("%d-W%02d", weekBasedYear, weekOfYear); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.java new file mode 100644 index 000000000..4248fab45 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.java @@ -0,0 +1,51 @@ +package com.loopers.domain.ranking.mv; + +import com.loopers.domain.ranking.ProductRankView; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "mv_product_rank_monthly") +@IdClass(MonthlyProductRankId.class) +public class MonthlyProductRank implements ProductRankView { + + @Id + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Id + @Column(name = "ranking_year_month", nullable = false) + private String yearMonth; + + @Column(name = "score", nullable = false) + private Double score; + + @Column(name = "period_start", nullable = false) + private LocalDate periodStart; + + @Column(name = "period_end", nullable = false) + private LocalDate periodEnd; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected MonthlyProductRank() {} + + public Long getRefProductId() { + return refProductId; + } + + public String getYearMonth() { + return yearMonth; + } + + public Double getScore() { + return score; + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.java new file mode 100644 index 000000000..f094d6c6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.java @@ -0,0 +1,30 @@ +package com.loopers.domain.ranking.mv; + +import java.io.Serializable; +import java.util.Objects; + +public class MonthlyProductRankId implements Serializable { + + private Long refProductId; + private String yearMonth; + + public MonthlyProductRankId() {} + + public MonthlyProductRankId(Long refProductId, String yearMonth) { + this.refProductId = refProductId; + this.yearMonth = yearMonth; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MonthlyProductRankId that = (MonthlyProductRankId) o; + return Objects.equals(refProductId, that.refProductId) && Objects.equals(yearMonth, that.yearMonth); + } + + @Override + public int hashCode() { + return Objects.hash(refProductId, yearMonth); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.java new file mode 100644 index 000000000..3c2ca3af2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking.mv; + +import java.util.List; + +public interface MonthlyProductRankRepository { + + List findByYearMonthOrderByScoreDesc(String yearMonth, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.java new file mode 100644 index 000000000..e8cd51049 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.java @@ -0,0 +1,58 @@ +package com.loopers.domain.ranking.mv; + +import com.loopers.domain.ranking.ProductRankView; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "mv_product_rank_weekly") +@IdClass(WeeklyProductRankId.class) +public class WeeklyProductRank implements ProductRankView { + + @Id + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Id + @Column(name = "ranking_year_week", nullable = false) + private String yearWeek; + + @Column(name = "score", nullable = false) + private Double score; + + @Column(name = "period_start", nullable = false) + private LocalDate periodStart; + + @Column(name = "period_end", nullable = false) + private LocalDate periodEnd; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected WeeklyProductRank() {} + + public Long getRefProductId() { + return refProductId; + } + + public String getYearWeek() { + return yearWeek; + } + + public Double getScore() { + return score; + } + + public LocalDate getPeriodStart() { + return periodStart; + } + + public LocalDate getPeriodEnd() { + return periodEnd; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.java new file mode 100644 index 000000000..90bb33e92 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.java @@ -0,0 +1,30 @@ +package com.loopers.domain.ranking.mv; + +import java.io.Serializable; +import java.util.Objects; + +public class WeeklyProductRankId implements Serializable { + + private Long refProductId; + private String yearWeek; + + public WeeklyProductRankId() {} + + public WeeklyProductRankId(Long refProductId, String yearWeek) { + this.refProductId = refProductId; + this.yearWeek = yearWeek; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WeeklyProductRankId that = (WeeklyProductRankId) o; + return Objects.equals(refProductId, that.refProductId) && Objects.equals(yearWeek, that.yearWeek); + } + + @Override + public int hashCode() { + return Objects.hash(refProductId, yearWeek); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.java new file mode 100644 index 000000000..d6a9da54d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking.mv; + +import java.util.List; + +public interface WeeklyProductRankRepository { + + List findByYearWeekOrderByScoreDesc(String yearWeek, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.java new file mode 100644 index 000000000..155060fa8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.MonthlyProductRank; +import com.loopers.domain.ranking.mv.MonthlyProductRankId; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MonthlyProductRankJpaRepository extends JpaRepository { + + List findByYearMonthOrderByScoreDesc(String yearMonth, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java new file mode 100644 index 000000000..7f5264fe3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.MonthlyProductRank; +import com.loopers.domain.ranking.mv.MonthlyProductRankRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MonthlyProductRankRepositoryImpl implements MonthlyProductRankRepository { + + private final MonthlyProductRankJpaRepository jpaRepository; + + @Override + public List findByYearMonthOrderByScoreDesc(String yearMonth, int page, int size) { + return jpaRepository.findByYearMonthOrderByScoreDesc(yearMonth, PageRequest.of(page, size)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.java new file mode 100644 index 000000000..2932976a2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.WeeklyProductRank; +import com.loopers.domain.ranking.mv.WeeklyProductRankId; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WeeklyProductRankJpaRepository extends JpaRepository { + + List findByYearWeekOrderByScoreDesc(String yearWeek, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java new file mode 100644 index 000000000..2c4d5828b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.WeeklyProductRank; +import com.loopers.domain.ranking.mv.WeeklyProductRankRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class WeeklyProductRankRepositoryImpl implements WeeklyProductRankRepository { + + private final WeeklyProductRankJpaRepository jpaRepository; + + @Override + public List findByYearWeekOrderByScoreDesc(String yearWeek, int page, int size) { + return jpaRepository.findByYearWeekOrderByScoreDesc(yearWeek, PageRequest.of(page, size)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApiSpec.java index cd90c5b55..cc687c06d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApiSpec.java @@ -10,11 +10,27 @@ @Tag(name = "Ranking", description = "랭킹 API") public interface RankingApiSpec { - @Operation(summary = "일간 랭킹 조회", description = "지정된 날짜의 상품 랭킹을 조회합니다.") + @Operation(summary = "일간 랭킹 조회", description = "지정된 날짜의 상품 일간 랭킹을 조회합니다.") ApiResponse getDailyRanking( @Parameter(description = "사용자 ID") Long userId, @Parameter(description = "조회 날짜 (yyyyMMdd)", required = true, example = "20251224") LocalDate date, @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") int page, @Parameter(description = "페이지 크기", example = "10") int size ); + + @Operation(summary = "주간 랭킹 조회", description = "지정된 날짜가 속한 주의 상품 랭킹을 조회합니다. TOP 100까지만 제공됩니다.") + ApiResponse getWeeklyRanking( + @Parameter(description = "사용자 ID") Long userId, + @Parameter(description = "조회 날짜 (yyyyMMdd)", required = true, example = "20251224") LocalDate date, + @Parameter(description = "페이지 번호 (0부터 시작, TOP 100 내에서 페이지네이션)", example = "0") int page, + @Parameter(description = "페이지 크기", example = "10") int size + ); + + @Operation(summary = "월간 랭킹 조회", description = "지정된 날짜가 속한 월의 상품 랭킹을 조회합니다. TOP 100까지만 제공됩니다.") + ApiResponse getMonthlyRanking( + @Parameter(description = "사용자 ID") Long userId, + @Parameter(description = "조회 날짜 (yyyyMMdd)", required = true, example = "20251224") LocalDate date, + @Parameter(description = "페이지 번호 (0부터 시작, TOP 100 내에서 페이지네이션)", example = "0") int page, + @Parameter(description = "페이지 크기", example = "10") int size + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java index 88de66d7a..25bdbce07 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java @@ -37,4 +37,30 @@ public ApiResponse getDailyRanking( RankingResponse response = RankingResponse.from(result); return ApiResponse.success(response); } + + @Override + @GetMapping("/weekly") + public ApiResponse getWeeklyRanking( + @RequestHeader(value = ApiHeaders.USER_ID, required = false) Long userId, + @RequestParam @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int size + ) { + RankingResult result = rankingFacade.getWeeklyRanking(date, page, size, userId); + RankingResponse response = RankingResponse.from(result); + return ApiResponse.success(response); + } + + @Override + @GetMapping("/monthly") + public ApiResponse getMonthlyRanking( + @RequestHeader(value = ApiHeaders.USER_ID, required = false) Long userId, + @RequestParam @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int size + ) { + RankingResult result = rankingFacade.getMonthlyRanking(date, page, size, userId); + RankingResponse response = RankingResponse.from(result); + return ApiResponse.success(response); + } } From 72431f7858ce2226aa1cb57729f381f242c5bdcf Mon Sep 17 00:00:00 2001 From: green Date: Thu, 1 Jan 2026 23:50:28 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20PR=20#230=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81=20-=20Produ?= =?UTF-8?q?ctMetrics.of()=EC=97=90=20updatedAt=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80=20-=20AggregatedProduct?= =?UTF-8?q?Score=20compact=20constructor=EC=97=90=EC=84=9C=20null=20->=200?= =?UTF-8?q?L=20=EC=A0=95=EA=B7=9C=ED=99=94=20-=20DateRange=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(startDate=20<=3D=20endDate,=20YYYYMMDD=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D)=20-=20RankingItemProcessor=20Job=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20null=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EB=B0=8F=20=EB=B0=B0=EC=B9=98=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=20=ED=86=B5=EC=9D=BC=20-=20WeeklyProductR?= =?UTF-8?q?ankId=20serialVersionUID=20=EC=B6=94=EA=B0=80=20-=20=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20null=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(batch/streamer)=20-=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EA=B2=BD=EA=B3=84=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20-=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B3=A0=EC=A0=95=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=B0=8F=20=EC=A0=90=EC=88=98=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20assertion=20=EA=B0=95=ED=99=94=20-=20cleanup.sql=20?= =?UTF-8?q?DELETE=20->=20TRUNCATE=20=EB=B3=80=EA=B2=BD=20-=20RankingServic?= =?UTF-8?q?e=20Locale.KOREA=20->=20Locale.getDefault()=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ranking/RankingService.java | 2 +- .../mv/MonthlyProductRankRepositoryImpl.java | 3 +++ .../mv/WeeklyProductRankRepositoryImpl.java | 3 +++ .../batch/domain/metrics/ProductMetrics.java | 19 ++++++++++++---- .../domain/ranking/WeeklyProductRankId.java | 2 ++ .../job/ranking/AggregatedProductScore.java | 6 +++++ .../loopers/batch/job/ranking/DateRange.java | 22 +++++++++++++++++++ .../job/ranking/RankingItemProcessor.java | 13 +++++++---- .../ranking/RankingAggregationJobE2ETest.java | 16 +++++++++----- .../src/test/resources/sql/cleanup.sql | 6 ++--- .../domain/metrics/ProductMetrics.java | 3 +++ .../metrics/ProductMetricsRepositoryImpl.java | 9 ++++++++ .../ProductMetricsIntegrationTest.java | 11 +++++----- 13 files changed, 93 insertions(+), 22 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java index 448a42b32..1e39b7db1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -65,7 +65,7 @@ public Integer getRankOrNull(LocalDate date, Long productId) { } private String toYearWeek(LocalDate date) { - WeekFields weekFields = WeekFields.of(Locale.KOREA); + WeekFields weekFields = WeekFields.of(Locale.getDefault()); int weekBasedYear = date.get(weekFields.weekBasedYear()); int weekOfYear = date.get(weekFields.weekOfWeekBasedYear()); return String.format("%d-W%02d", weekBasedYear, weekOfYear); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java index 7f5264fe3..cefc3099c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java @@ -15,6 +15,9 @@ public class MonthlyProductRankRepositoryImpl implements MonthlyProductRankRepos @Override public List findByYearMonthOrderByScoreDesc(String yearMonth, int page, int size) { + if (page < 0 || size < 1) { + throw new IllegalArgumentException("page는 0 이상, size는 1 이상이어야 합니다"); + } return jpaRepository.findByYearMonthOrderByScoreDesc(yearMonth, PageRequest.of(page, size)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java index 2c4d5828b..5fa699019 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java @@ -15,6 +15,9 @@ public class WeeklyProductRankRepositoryImpl implements WeeklyProductRankReposit @Override public List findByYearWeekOrderByScoreDesc(String yearWeek, int page, int size) { + if (page < 0 || size < 1) { + throw new IllegalArgumentException("page는 0 이상, size는 1 이상이어야 합니다"); + } return jpaRepository.findByYearWeekOrderByScoreDesc(yearWeek, PageRequest.of(page, size)); } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java index 58c96c7a2..305c8b873 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java @@ -26,16 +26,27 @@ public class ProductMetrics { protected ProductMetrics() {} - private ProductMetrics(ProductMetricsId id, Long viewCount, Long likeCount, Long salesCount) { + private ProductMetrics( + ProductMetricsId id, Long viewCount, Long likeCount, Long salesCount, Long updatedAt) { this.id = id; this.viewCount = viewCount; this.likeCount = likeCount; this.salesCount = salesCount; - this.updatedAt = System.currentTimeMillis(); + this.updatedAt = updatedAt; } - public static ProductMetrics of(Long refProductId, Integer metricDate, Long viewCount, Long likeCount, Long salesCount) { - return new ProductMetrics(ProductMetricsId.of(refProductId, metricDate), viewCount, likeCount, salesCount); + public static ProductMetrics of( + Long refProductId, + Integer metricDate, + Long viewCount, + Long likeCount, + Long salesCount, + Long updatedAt) { + if (refProductId == null || metricDate == null) { + throw new IllegalArgumentException("refProductId, metricDate는 필수입니다"); + } + return new ProductMetrics( + ProductMetricsId.of(refProductId, metricDate), viewCount, likeCount, salesCount, updatedAt); } public Long getRefProductId() { diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java index 382699859..ec0455192 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java @@ -6,6 +6,8 @@ // WeeklyProductRank 복합키 (refProductId + yearWeek) public class WeeklyProductRankId implements Serializable { + private static final long serialVersionUID = 1L; + private Long refProductId; private String yearWeek; diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java index ec5306fe9..094fe5f27 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java @@ -7,6 +7,12 @@ public record AggregatedProductScore( Long totalSalesCount ) { + public AggregatedProductScore { + totalViewCount = totalViewCount != null ? totalViewCount : 0L; + totalLikeCount = totalLikeCount != null ? totalLikeCount : 0L; + totalSalesCount = totalSalesCount != null ? totalSalesCount : 0L; + } + public double calculateScore(double viewWeight, double likeWeight, double orderWeight) { return (totalViewCount * viewWeight) + (totalLikeCount * likeWeight) + (totalSalesCount * orderWeight); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java index 26317024c..270fbd926 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java @@ -1,9 +1,31 @@ package com.loopers.batch.job.ranking; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + // 집계 기간 (시작일, 종료일 - YYYYMMDD 형식) public record DateRange(int startDate, int endDate) { + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + public DateRange { + validateDateFormat(startDate, "시작일"); + validateDateFormat(endDate, "종료일"); + if (startDate > endDate) { + throw new IllegalArgumentException("시작일은 종료일보다 클 수 없습니다"); + } + } + public static DateRange of(int startDate, int endDate) { return new DateRange(startDate, endDate); } + + private static void validateDateFormat(int date, String fieldName) { + try { + LocalDate.parse(String.valueOf(date), DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException(fieldName + "이 유효하지 않은 날짜 형식입니다: " + date); + } + } } \ No newline at end of file diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java index 09d01cc9b..fdd232baf 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java @@ -38,13 +38,20 @@ public class RankingItemProcessor implements ItemProcessor { List ranks = weeklyRepository.findAll(); - assertThat(ranks).hasSize(2); - // 상품2가 더 높은 점수 (score = 200*0.1 + 20*0.3 + 10*0.6 = 32) - // 상품1 합계 (score = 150*0.1 + 15*0.3 + 7*0.6 = 23.7) + // 상품1: (100+50)*0.1 + (10+5)*0.3 + (5+2)*0.6 = 23.7 + // 상품2: 200*0.1 + 20*0.3 + 10*0.6 = 32.0 + assertThat(ranks) + .hasSize(2) + .extracting(WeeklyProductRank::getScore) + .containsExactlyInAnyOrder(23.7, 32.0); } ); } @@ -246,7 +250,9 @@ private void saveAllMetrics(ProductMetrics... metrics) { productMetricsRepository.saveAll(List.of(metrics)); } - private ProductMetrics metrics(Long productId, Integer metricDate, Long viewCount, Long likeCount, Long salesCount) { - return ProductMetrics.of(productId, metricDate, viewCount, likeCount, salesCount); + private ProductMetrics metrics( + Long productId, Integer metricDate, Long viewCount, Long likeCount, Long salesCount) { + return ProductMetrics.of( + productId, metricDate, viewCount, likeCount, salesCount, FIXED_UPDATED_AT); } } diff --git a/apps/commerce-batch/src/test/resources/sql/cleanup.sql b/apps/commerce-batch/src/test/resources/sql/cleanup.sql index 25f87e541..8a893bc83 100644 --- a/apps/commerce-batch/src/test/resources/sql/cleanup.sql +++ b/apps/commerce-batch/src/test/resources/sql/cleanup.sql @@ -1,3 +1,3 @@ -DELETE FROM mv_product_rank_weekly; -DELETE FROM mv_product_rank_monthly; -DELETE FROM product_metrics; +TRUNCATE TABLE mv_product_rank_weekly; +TRUNCATE TABLE mv_product_rank_monthly; +TRUNCATE TABLE product_metrics; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java index fa35776de..7399d6890 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -36,6 +36,9 @@ private ProductMetrics( } public static ProductMetrics createWithLike(Long productId, Integer metricDate, int delta, Long occurredAt) { + if (productId == null || metricDate == null || occurredAt == null) { + throw new IllegalArgumentException("productId, metricDate, occurredAt은 필수입니다"); + } long initialLikeCount = Math.max(delta, 0); return new ProductMetrics(ProductMetricsId.of(productId, metricDate), initialLikeCount, 0L, 0L, occurredAt); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index cd40462e4..1747a15b4 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -12,16 +12,25 @@ public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { @Override public void upsertLikeCount(Long productId, Integer metricDate, int delta, Long occurredAt) { + validateParams(productId, metricDate, occurredAt); jpaRepository.upsertLikeCount(productId, metricDate, delta, occurredAt); } @Override public void upsertSalesCount(Long productId, Integer metricDate, int quantity, Long occurredAt) { + validateParams(productId, metricDate, occurredAt); jpaRepository.upsertSalesCount(productId, metricDate, quantity, occurredAt); } @Override public void upsertViewCount(Long productId, Integer metricDate, int count, Long occurredAt) { + validateParams(productId, metricDate, occurredAt); jpaRepository.upsertViewCount(productId, metricDate, count, occurredAt); } + + private void validateParams(Long productId, Integer metricDate, Long occurredAt) { + if (productId == null || metricDate == null || occurredAt == null) { + throw new IllegalArgumentException("productId, metricDate, occurredAt은 필수입니다"); + } + } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java index fa346ea01..48bd86541 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java @@ -22,6 +22,8 @@ @DisplayName("ProductMetrics 집계 통합 테스트") class ProductMetricsIntegrationTest extends IntegrationTestSupport { + private static final long FIXED_TIME = 1704067200000L; // 2024-01-01 00:00:00 UTC + @Autowired private CatalogEventHandler catalogEventHandler; @@ -44,7 +46,7 @@ void shouldIncreaseSalesCount_whenProductSoldEventReceived() throws Exception { Long productId = 100L; int quantity = 3; String eventId = UUID.randomUUID().toString(); - long occurredAt = System.currentTimeMillis(); + long occurredAt = FIXED_TIME; JsonNode payload = objectMapper.readTree("{\"quantity\":" + quantity + ",\"orderId\":1}"); catalogEventHandler.handle( @@ -59,23 +61,22 @@ void shouldIncreaseSalesCount_whenProductSoldEventReceived() throws Exception { @DisplayName("동일 상품에 여러 번 판매 이벤트 발생 시 sales_count가 누적된다") void shouldAccumulateSalesCount_whenMultipleProductSoldEvents() throws Exception { Long productId = 101L; - long now = System.currentTimeMillis(); catalogEventHandler.handle( UUID.randomUUID().toString(), EventType.PRODUCT_SOLD.getCode(), String.valueOf(productId), - now, + FIXED_TIME, objectMapper.readTree("{\"quantity\":2,\"orderId\":1}")); catalogEventHandler.handle( UUID.randomUUID().toString(), EventType.PRODUCT_SOLD.getCode(), String.valueOf(productId), - now + 1, + FIXED_TIME + 1, objectMapper.readTree("{\"quantity\":5,\"orderId\":2}")); - Integer metricDate = MetricDateConverter.toMetricDate(now, rankingProperties.getTimezone()); + Integer metricDate = MetricDateConverter.toMetricDate(FIXED_TIME, rankingProperties.getTimezone()); ProductMetrics metrics = productMetricsJpaRepository.findById(ProductMetricsId.of(productId, metricDate)).orElseThrow(); assertThat(metrics.getSalesCount()).isEqualTo(7); } From 52a77a91bf6869176ceec3c3ec1200f6280c1ef4 Mon Sep 17 00:00:00 2001 From: green Date: Fri, 2 Jan 2026 15:19:15 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20Job=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Reader=20=EC=97=AD=ED=95=A0=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingScheduler 추가: 주간(월 02:00), 월간(1일 03:00) 자동 실행 - @EnableScheduling 활성화 - Reader ORDER BY score → ORDER BY id 변경 (역할 분리) - Reader: 조회/집계만, Processor: 점수 계산 --- .../com/loopers/CommerceBatchApplication.java | 2 + .../batch/interfaces/RankingScheduler.java | 37 +++++++++++++++++++ .../ranking/RankingAggregationJobConfig.java | 9 +---- 3 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/RankingScheduler.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java index e5005c373..e94c8475d 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -4,9 +4,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; +@EnableScheduling @ConfigurationPropertiesScan @SpringBootApplication public class CommerceBatchApplication { diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/RankingScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/RankingScheduler.java new file mode 100644 index 000000000..b19ceee31 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/RankingScheduler.java @@ -0,0 +1,37 @@ +package com.loopers.batch.interfaces; + +import com.loopers.batch.application.BatchJobFacade; +import com.loopers.batch.domain.ranking.RankingPeriod; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingScheduler { + + private final BatchJobFacade batchJobFacade; + + /** + * 주간 랭킹 집계: 매주 월요일 02:00 실행 (전주 월~일 데이터 집계) + */ + @Scheduled(cron = "0 0 2 * * MON") + public void runWeeklyRankingAggregation() { + LocalDate lastWeek = LocalDate.now().minusWeeks(1); + log.info("주간 랭킹 집계 스케줄 실행: baseDate={}", lastWeek); + batchJobFacade.runRankingAggregation(RankingPeriod.WEEKLY, lastWeek); + } + + /** + * 월간 랭킹 집계: 매월 1일 03:00 실행 (전월 1일~말일 데이터 집계) + */ + @Scheduled(cron = "0 0 3 1 * *") + public void runMonthlyRankingAggregation() { + LocalDate lastMonth = LocalDate.now().minusMonths(1); + log.info("월간 랭킹 집계 스케줄 실행: baseDate={}", lastMonth); + batchJobFacade.runRankingAggregation(RankingPeriod.MONTHLY, lastMonth); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java index 2883975ac..17df37ac0 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java @@ -92,19 +92,12 @@ public JpaPagingItemReader aggregatedScoreReader( FROM ProductMetrics m WHERE m.id.metricDate BETWEEN :startDate AND :endDate GROUP BY m.id.refProductId - ORDER BY - (CAST(SUM(m.viewCount) AS double) * :viewWeight - + CAST(SUM(m.likeCount) AS double) * :likeWeight - + CAST(SUM(m.salesCount) AS double) * :orderWeight) DESC, - m.id.refProductId + ORDER BY m.id.refProductId """; Map params = new HashMap<>(); params.put("startDate", dateRange.startDate()); params.put("endDate", dateRange.endDate()); - params.put("viewWeight", properties.getWeight().getView()); - params.put("likeWeight", properties.getWeight().getLike()); - params.put("orderWeight", properties.getWeight().getOrder()); return new JpaPagingItemReaderBuilder() .name("aggregatedScoreReader")