From da1cac096d91d00d26e579d720a84ada013c9f67 Mon Sep 17 00:00:00 2001 From: imsejin Date: Tue, 16 Sep 2025 00:57:45 +0900 Subject: [PATCH 1/9] =?UTF-8?q?test:=20ConcurrentAssertion=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/test/assertion/ConcurrentAssertion.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java b/apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java index b9295e3..40fdeb1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java +++ b/apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java @@ -70,6 +70,10 @@ private ConcurrentAssertion execute(RunnableCallableAdapter adapter) { V executed = adapter.execute(n); this.successCount.incrementAndGet(); return executed; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + this.errors.add(e); + return null; } catch (Throwable t) { this.errors.add(t); return null; From 430ca218d359b1d517035a15c72bb9492dfd44a7 Mon Sep 17 00:00:00 2001 From: imsejin Date: Tue, 16 Sep 2025 00:58:05 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20batch=20app=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + apps/commerce-batch/build.gradle.kts | 19 +++++++ .../com/loopers/CommerceBatchApplication.java | 28 +++++++++ .../src/main/resources/application.yml | 57 +++++++++++++++++++ 4 files changed, 105 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/resources/application.yml diff --git a/README.md b/README.md index 2cc1f62..59dff3d 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 │ └── 📦 pg-simulator ├── modules ( reusable-configurations ) diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 0000000..aee7ef4 --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,19 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + + // 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 0000000..2b3d938 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,28 @@ +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 org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.TimeZone; + +@EnableAsync +@EnableScheduling +@SpringBootApplication +@ConfigurationPropertiesScan +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + SpringApplication.run(CommerceBatchApplication.class, args); + } + +} 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 0000000..e25f246 --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,57 @@ +server: + shutdown: graceful + port: 8086 + +spring: + mvc.format.date: iso + threads.virtual.enabled: true + application.name: commerce-batch + profiles: + active: local + config: + import: + - application-jpa.yml + - application-redis.yml + - application-logging.yml + + task: + execution: + pool: + core-size: 8 + max-size: 16 + queue-capacity: 8 + batch: + jdbc: + initialize-schema: never + job: + enabled: false + +--- + +spring.config.activate.on-profile: test + +spring: + batch: + jdbc: + initialize-schema: always + +--- + +spring.config.activate.on-profile: local + +spring: + batch: + job: + enabled: true + +--- + +spring.config.activate.on-profile: dev + +--- + +spring.config.activate.on-profile: qa + +--- + +spring.config.activate.on-profile: prd From 7e68a5e51827207b8b73cbedc83e80a13935a038 Mon Sep 17 00:00:00 2001 From: imsejin Date: Wed, 17 Sep 2025 01:48:29 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20batch=20=EC=95=B1=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-batch/build.gradle.kts | 2 + .../ProductRankingWeeklyJobConfig.java | 52 +++++++++++++ .../loopers/config/BatchJobProperties.java | 33 ++++++++ .../domain/ranking/ProductRankingMonthly.java | 73 +++++++++++++++++ .../domain/ranking/ProductRankingWeekly.java | 78 +++++++++++++++++++ .../loopers/support/CommonJobListener.java | 34 ++++++++ .../src/main/resources/application.yml | 7 ++ build.gradle.kts | 3 + 8 files changed, 282 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductRankingWeeklyJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/support/CommonJobListener.java diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts index aee7ef4..fb373e8 100644 --- a/apps/commerce-batch/build.gradle.kts +++ b/apps/commerce-batch/build.gradle.kts @@ -8,6 +8,8 @@ dependencies { // batch implementation("org.springframework.boot:spring-boot-starter-batch") + implementation("org.threeten:threeten-extra") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductRankingWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductRankingWeeklyJobConfig.java new file mode 100644 index 0000000..40d0333 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductRankingWeeklyJobConfig.java @@ -0,0 +1,52 @@ +package com.loopers.application.ranking; + +import com.loopers.config.BatchJobProperties; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.support.CommonJobListener; +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.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class ProductRankingWeeklyJobConfig { + + private static final String JOB_NAME = "product_ranking_weekly_job"; + private static final String STEP_NAME = "aggregate_step"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final CommonJobListener commonJobListener; + private final BatchJobProperties batchJobProperties; + + @Bean(name = JOB_NAME) + public Job productRankingWeeklyJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(commonJobListener) + .start(aggregateStep()) + .build(); + } + + @Bean(name = STEP_NAME) + public Step aggregateStep() { + int checkSize = batchJobProperties.getJobs().get(JOB_NAME) + .getSteps().get(STEP_NAME) + .getCheckSize(); + + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(checkSize, transactionManager) +// .reader(reader) +// .processor(processor) +// .writer(writer) + .build(); + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java b/apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java new file mode 100644 index 0000000..d1c8661 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java @@ -0,0 +1,33 @@ +package com.loopers.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +import java.util.Map; + +@Getter +@ConfigurationProperties(prefix = "batch") +public class BatchJobProperties { + + private final Map jobs; + + @ConstructorBinding + public BatchJobProperties(Map jobs) { + this.jobs = jobs; + } + + @Getter + @RequiredArgsConstructor + public static class Job { + private final Map steps; + } + + @Getter + @RequiredArgsConstructor + public static class Step { + private final int checkSize; + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java new file mode 100644 index 0000000..66a5b85 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java @@ -0,0 +1,73 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.YearMonth; + +@Getter +@Entity +@Table( + name = "product_rankings_monthly", + uniqueConstraints = @UniqueConstraint(columnNames = {"year_month", "ref_product_id"}), + indexes = @Index(columnList = "year_month, ref_product_id, rank") +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductRankingMonthly extends BaseEntity { + + /** + * 아이디 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "product_ranking_weekly_id", nullable = false, updatable = false) + private Long id; + + /** + * 한 해의 주차 + */ + @Column(name = "year_month", nullable = false, updatable = false) + private YearMonth yearMonth; + + /** + * 순위 + */ + @Column(name = "rank", nullable = false, updatable = false) + private Integer rank; + + // ------------------------------------------------------------------------------------------------- + + /** + * 상품 아이디 + */ + @Column(name = "ref_product_id", nullable = false, updatable = false) + private Long productId; + + // ------------------------------------------------------------------------------------------------- + + @Builder + private ProductRankingMonthly( + YearMonth yearMonth, + Integer rank, + Long productId + ) { + if (yearMonth == null) { + throw new IllegalArgumentException("기준 연월이 올바르지 않습니다."); + } + if (rank == null || rank <= 0) { + throw new IllegalArgumentException("랭크가 올바르지 않습니다."); + } + if (productId == null) { + throw new IllegalArgumentException("상품 아이디가 올바르지 않습니다."); + } + + this.yearMonth = yearMonth; + this.rank = rank; + this.productId = productId; + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java new file mode 100644 index 0000000..0f63f67 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java @@ -0,0 +1,78 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.threeten.extra.YearWeek; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; + +@Getter +@Entity +@Table( + name = "product_rankings_weekly", + uniqueConstraints = @UniqueConstraint(columnNames = {"year_week", "ref_product_id"}), + indexes = @Index(columnList = "year_week, ref_product_id, rank") +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductRankingWeekly extends BaseEntity { + + /** + * 아이디 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "product_ranking_weekly_id", nullable = false, updatable = false) + private Long id; + + /** + * 한 해의 주차 + */ + @Column(name = "year_week", nullable = false, updatable = false) + private YearWeek yearWeek; + + /** + * 순위 + */ + @Column(name = "rank", nullable = false, updatable = false) + private Integer rank; + + // ------------------------------------------------------------------------------------------------- + + /** + * 상품 아이디 + */ + @Column(name = "ref_product_id", nullable = false, updatable = false) + private Long productId; + + // ------------------------------------------------------------------------------------------------- + + @Builder + private ProductRankingWeekly( + LocalDate standardDate, + Integer rank, + Long productId + ) { + if (standardDate == null) { + throw new IllegalArgumentException("기준 일자가 올바르지 않습니다."); + } + if (rank == null || rank <= 0) { + throw new IllegalArgumentException("랭크가 올바르지 않습니다."); + } + if (productId == null) { + throw new IllegalArgumentException("상품 아이디가 올바르지 않습니다."); + } + + LocalDate dateAtMonday = standardDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); +// this.yearWeek = YearWeek.of(dateAtMonday.getYear(), dateAtMonday.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)); + this.yearWeek = YearWeek.from(dateAtMonday); + this.rank = rank; + this.productId = productId; + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/support/CommonJobListener.java b/apps/commerce-batch/src/main/java/com/loopers/support/CommonJobListener.java new file mode 100644 index 0000000..5db7e04 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/support/CommonJobListener.java @@ -0,0 +1,34 @@ +package com.loopers.support; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class CommonJobListener implements JobExecutionListener { + + @Override + public void beforeJob(JobExecution jobExecution) { + String jobName = jobExecution.getJobInstance().getJobName(); + Long jobId = jobExecution.getJobId(); + + log.info("Job 시작={}, Job ID={}", jobName, jobId); + } + + @Override + public void afterJob(JobExecution jobExecution) { + String jobName = jobExecution.getJobInstance().getJobName(); + String status = jobExecution.getStatus().toString(); + long jobId = jobExecution.getJobId(); + + log.info("Job 완료={}, Job ID={}, Status={}", jobName, jobId, status); + + if (jobExecution.getStatus().isUnsuccessful()) { + String exitCode = jobExecution.getExitStatus().getExitCode(); + log.error("Job 실패={}, Job ID={}, Exit Code={}", jobName, jobId, exitCode); + } + } + +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index e25f246..9bbd0a1 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -26,6 +26,13 @@ spring: job: enabled: false +batch: + jobs: + product_ranking_weekly_job: + steps: + aggregate_step: + check-size: 1000 + --- spring.config.activate.on-profile: test diff --git a/build.gradle.kts b/build.gradle.kts index 440036f..73c6d65 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -53,6 +53,9 @@ subprojects { // UUID v7 dependency("com.fasterxml.uuid:java-uuid-generator:5.1.0") + + // Additional date-time classes that complement those in Java SE 8. + dependency("org.threeten:threeten-extra:1.8.0") } } From 676d04de6540fcaaf63ba18564879c80d2022d00 Mon Sep 17 00:00:00 2001 From: imsejin Date: Fri, 19 Sep 2025 00:01:33 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ranking/ProductRankingWeeklyJobConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename apps/commerce-batch/src/main/java/com/loopers/{application => job}/ranking/ProductRankingWeeklyJobConfig.java (97%) diff --git a/apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductRankingWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingWeeklyJobConfig.java similarity index 97% rename from apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductRankingWeeklyJobConfig.java rename to apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingWeeklyJobConfig.java index 40d0333..e27d719 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/application/ranking/ProductRankingWeeklyJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingWeeklyJobConfig.java @@ -1,4 +1,4 @@ -package com.loopers.application.ranking; +package com.loopers.job.ranking; import com.loopers.config.BatchJobProperties; import com.loopers.domain.ranking.ProductRankingWeekly; From eb905e63b09338287074739e1d0b2accf712cd60 Mon Sep 17 00:00:00 2001 From: imsejin Date: Fri, 19 Sep 2025 14:58:50 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=A7=81=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=9E=A1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-batch/build.gradle.kts | 1 + .../com/loopers/CommerceBatchApplication.java | 2 + .../loopers/config/BatchJobProperties.java | 33 ---- .../domain/ranking/ProductRankingDaily.java | 84 ++++++++++ .../domain/ranking/ProductRankingMonthly.java | 9 +- .../domain/ranking/ProductRankingWeekly.java | 1 - .../domain/ranking/RankingCommand.java | 39 +++++ .../domain/ranking/RankingRepository.java | 16 ++ .../loopers/domain/ranking/RankingResult.java | 33 ++++ .../domain/ranking/RankingService.java | 79 ++++++++++ .../ProductRankingDailyJpaRepository.java | 33 ++++ .../ProductRankingMonthlyJpaRepository.java | 32 ++++ .../ProductRankingWeeklyJpaRepository.java | 30 ++++ .../ranking/RankingRepositoryImpl.java | 62 ++++++++ .../DailyRankingAggregationTasklet.java | 64 ++++++++ .../MonthlyRankingAggregationTasklet.java | 73 +++++++++ .../job/ranking/ProductRankingJobConfig.java | 146 ++++++++++++++++++ .../ProductRankingWeeklyJobConfig.java | 52 ------- .../WeeklyRankingAggregationTasklet.java | 74 +++++++++ .../src/main/resources/application.yml | 8 +- 20 files changed, 774 insertions(+), 97 deletions(-) delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingDaily.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingCommand.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingResult.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingService.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingDailyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingMonthlyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingWeeklyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/job/ranking/DailyRankingAggregationTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/job/ranking/MonthlyRankingAggregationTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingJobConfig.java delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingWeeklyJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/job/ranking/WeeklyRankingAggregationTasklet.java diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts index fb373e8..d33d75f 100644 --- a/apps/commerce-batch/build.gradle.kts +++ b/apps/commerce-batch/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { // batch implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") implementation("org.threeten:threeten-extra") 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 2b3d938..845753b 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -1,6 +1,7 @@ package com.loopers; import jakarta.annotation.PostConstruct; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @@ -11,6 +12,7 @@ @EnableAsync @EnableScheduling +@EnableBatchProcessing(dataSourceRef = "mysqlMainDataSource") @SpringBootApplication @ConfigurationPropertiesScan public class CommerceBatchApplication { diff --git a/apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java b/apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java deleted file mode 100644 index d1c8661..0000000 --- a/apps/commerce-batch/src/main/java/com/loopers/config/BatchJobProperties.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.config; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.bind.ConstructorBinding; - -import java.util.Map; - -@Getter -@ConfigurationProperties(prefix = "batch") -public class BatchJobProperties { - - private final Map jobs; - - @ConstructorBinding - public BatchJobProperties(Map jobs) { - this.jobs = jobs; - } - - @Getter - @RequiredArgsConstructor - public static class Job { - private final Map steps; - } - - @Getter - @RequiredArgsConstructor - public static class Step { - private final int checkSize; - } - -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingDaily.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingDaily.java new file mode 100644 index 0000000..3a40606 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingDaily.java @@ -0,0 +1,84 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Entity +@Table( + name = "product_rankings_daily", + uniqueConstraints = @UniqueConstraint(columnNames = {"date", "ref_product_id"}), + indexes = @Index(columnList = "date, ref_product_id, rank") +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductRankingDaily extends BaseEntity { + + /** + * 아이디 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "product_ranking_daily_id", nullable = false, updatable = false) + private Long id; + + /** + * 기준일자 + */ + @Column(name = "date", nullable = false, updatable = false) + private LocalDate date; + + /** + * 순위 + */ + @Column(name = "rank", nullable = false, updatable = false) + private Integer rank; + + /** + * 점수 + */ + @Column(name = "score", nullable = false, updatable = false) + private Double score; + + // ------------------------------------------------------------------------------------------------- + + /** + * 상품 아이디 + */ + @Column(name = "ref_product_id", nullable = false, updatable = false) + private Long productId; + + // ------------------------------------------------------------------------------------------------- + + @Builder + private ProductRankingDaily( + LocalDate date, + Integer rank, + Double score, + Long productId + ) { + if (date == null) { + throw new IllegalArgumentException("기준 일자가 올바르지 않습니다."); + } + if (rank == null || rank <= 0) { + throw new IllegalArgumentException("랭크가 올바르지 않습니다."); + } + if (score == null || Double.isNaN(score) || Double.isInfinite(score) || score <= 0.0) { + throw new IllegalArgumentException("점수가 올바르지 않습니다."); + } + if (productId == null) { + throw new IllegalArgumentException("상품 아이디가 올바르지 않습니다."); + } + + this.date = date; + this.rank = rank; + this.score = score; + this.productId = productId; + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java index 66a5b85..4ffdc1f 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java @@ -7,6 +7,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDate; import java.time.YearMonth; @Getter @@ -51,12 +52,12 @@ public class ProductRankingMonthly extends BaseEntity { @Builder private ProductRankingMonthly( - YearMonth yearMonth, + LocalDate standardDate, Integer rank, Long productId ) { - if (yearMonth == null) { - throw new IllegalArgumentException("기준 연월이 올바르지 않습니다."); + if (standardDate == null) { + throw new IllegalArgumentException("기준 일자가 올바르지 않습니다."); } if (rank == null || rank <= 0) { throw new IllegalArgumentException("랭크가 올바르지 않습니다."); @@ -65,7 +66,7 @@ private ProductRankingMonthly( throw new IllegalArgumentException("상품 아이디가 올바르지 않습니다."); } - this.yearMonth = yearMonth; + this.yearMonth = YearMonth.from(standardDate); this.rank = rank; this.productId = productId; } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java index 0f63f67..62ff965 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java @@ -69,7 +69,6 @@ private ProductRankingWeekly( } LocalDate dateAtMonday = standardDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); -// this.yearWeek = YearWeek.of(dateAtMonday.getYear(), dateAtMonday.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)); this.yearWeek = YearWeek.from(dateAtMonday); this.rank = rank; this.productId = productId; diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingCommand.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingCommand.java new file mode 100644 index 0000000..db9ffb5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingCommand.java @@ -0,0 +1,39 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public record RankingCommand() { + + public record GetDaily( + LocalDate startDate, + LocalDate endDate + ) { + } + + // ------------------------------------------------------------------------------------------------- + + public record AggregateDaily( + LocalDate date, + List> entries + ) { + } + + // ------------------------------------------------------------------------------------------------- + + public record AggregateWeekly( + LocalDate date, + List productIds + ) { + } + + // ------------------------------------------------------------------------------------------------- + + public record AggregateMonthly( + LocalDate date, + List productIds + ) { + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingRepository.java new file mode 100644 index 0000000..548fd85 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface RankingRepository { + + List findDailyRanking(LocalDate startDate, LocalDate endDate); + + boolean merge(ProductRankingDaily ranking); + + boolean merge(ProductRankingWeekly ranking); + + boolean merge(ProductRankingMonthly ranking); + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingResult.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingResult.java new file mode 100644 index 0000000..b506c54 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingResult.java @@ -0,0 +1,33 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public record RankingResult() { + + public record GetDaily( + List items + ) { + public static GetDaily from(List rankings) { + List items = rankings.stream() + .map(ranking -> new Item( + ranking.getDate(), + ranking.getProductId(), + ranking.getRank(), + ranking.getScore() + )) + .toList(); + + return new GetDaily(items); + } + + public record Item( + LocalDate date, + Long productId, + Integer rank, + Double score + ) { + } + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 0000000..2ec5593 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,79 @@ +package com.loopers.domain.ranking; + +import com.loopers.annotation.ReadOnlyTransactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class RankingService { + + private final RankingRepository rankingRepository; + + @ReadOnlyTransactional + public RankingResult.GetDaily getDaily(RankingCommand.GetDaily command) { + List rankings = rankingRepository.findDailyRanking(command.startDate(), command.endDate()); + return RankingResult.GetDaily.from(rankings); + } + + @Transactional + public void aggregateDaily(RankingCommand.AggregateDaily command) { + List> entries = command.entries(); + if (CollectionUtils.isEmpty(entries)) { + return; + } + + for (int i = 0; i < entries.size(); i++) { + ProductRankingDaily ranking = ProductRankingDaily.builder() + .date(command.date()) + .productId(entries.get(i).getKey()) + .rank(i + 1) + .score(entries.get(i).getValue()) + .build(); + + rankingRepository.merge(ranking); + } + } + + @Transactional + public void aggregateWeekly(RankingCommand.AggregateWeekly command) { + List productIds = command.productIds(); + if (CollectionUtils.isEmpty(productIds)) { + return; + } + + for (int i = 0; i < productIds.size(); i++) { + ProductRankingWeekly ranking = ProductRankingWeekly.builder() + .standardDate(command.date()) + .productId(productIds.get(i)) + .rank(i + 1) + .build(); + + rankingRepository.merge(ranking); + } + } + + @Transactional + public void aggregateMonthly(RankingCommand.AggregateMonthly command) { + List productIds = command.productIds(); + if (CollectionUtils.isEmpty(productIds)) { + return; + } + + for (int i = 0; i < productIds.size(); i++) { + ProductRankingMonthly ranking = ProductRankingMonthly.builder() + .standardDate(command.date()) + .productId(productIds.get(i)) + .rank(i + 1) + .build(); + + rankingRepository.merge(ranking); + } + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingDailyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingDailyJpaRepository.java new file mode 100644 index 0000000..27739b7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingDailyJpaRepository.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankingDaily; +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; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; + +public interface ProductRankingDailyJpaRepository extends JpaRepository { + + List findByDateBetween(LocalDate start, LocalDate end); + + @Modifying + @Query(""" + insert into ProductRankingDaily (date, productId, rank, createdAt, updatedAt) + values (:date, :productId, :rank, :createdAt, :updatedAt) + on conflict (date, productId) do update set + rank = :rank, + updatedAt = :updatedAt + """) + int merge( + @Param("date") LocalDate date, + @Param("productId") Long productId, + @Param("rank") Integer rank, + @Param("createdAt") ZonedDateTime createdAt, + @Param("updatedAt") ZonedDateTime updatedAt + ); + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingMonthlyJpaRepository.java new file mode 100644 index 0000000..063837d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingMonthlyJpaRepository.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingWeekly; +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; +import org.threeten.extra.YearWeek; + +import java.time.YearMonth; +import java.time.ZonedDateTime; + +public interface ProductRankingMonthlyJpaRepository extends JpaRepository { + + @Modifying + @Query(""" + insert into ProductRankingMonthly (yearMonth, productId, rank, createdAt, updatedAt) + values (:yearMonth, :productId, :rank, :createdAt, :updatedAt) + on conflict (yearMonth, productId) do update set + rank = :rank, + updatedAt = :updatedAt + """) + int merge( + @Param("yearMonth") YearMonth yearMonth, + @Param("productId") Long productId, + @Param("rank") Integer rank, + @Param("createdAt") ZonedDateTime createdAt, + @Param("updatedAt") ZonedDateTime updatedAt + ); + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingWeeklyJpaRepository.java new file mode 100644 index 0000000..2ace36d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankingWeeklyJpaRepository.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankingWeekly; +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; +import org.threeten.extra.YearWeek; + +import java.time.ZonedDateTime; + +public interface ProductRankingWeeklyJpaRepository extends JpaRepository { + + @Modifying + @Query(""" + insert into ProductRankingWeekly (yearWeek, productId, rank, createdAt, updatedAt) + values (:yearWeek, :productId, :rank, :createdAt, :updatedAt) + on conflict (yearWeek, productId) do update set + rank = :rank, + updatedAt = :updatedAt + """) + int merge( + @Param("yearWeek") YearWeek yearWeek, + @Param("productId") Long productId, + @Param("rank") Integer rank, + @Param("createdAt") ZonedDateTime createdAt, + @Param("updatedAt") ZonedDateTime updatedAt + ); + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java new file mode 100644 index 0000000..28a639a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java @@ -0,0 +1,62 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankingDaily; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.RankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class RankingRepositoryImpl implements RankingRepository { + + private final ProductRankingDailyJpaRepository productRankingDailyJpaRepository; + private final ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository; + private final ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository; + + @Override + public List findDailyRanking(LocalDate startDate, LocalDate endDate) { + return productRankingDailyJpaRepository.findByDateBetween(startDate, endDate); + } + + @Override + public boolean merge(ProductRankingDaily ranking) { + ranking.prePersist(); + return productRankingDailyJpaRepository.merge( + ranking.getDate(), + ranking.getProductId(), + ranking.getRank(), + ranking.getCreatedAt(), + ranking.getUpdatedAt() + ) == 1; + } + + @Override + public boolean merge(ProductRankingWeekly ranking) { + ranking.prePersist(); + return productRankingWeeklyJpaRepository.merge( + ranking.getYearWeek(), + ranking.getProductId(), + ranking.getRank(), + ranking.getCreatedAt(), + ranking.getUpdatedAt() + ) == 1; + } + + @Override + public boolean merge(ProductRankingMonthly ranking) { + ranking.prePersist(); + return productRankingMonthlyJpaRepository.merge( + ranking.getYearMonth(), + ranking.getProductId(), + ranking.getRank(), + ranking.getCreatedAt(), + ranking.getUpdatedAt() + ) == 1; + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/job/ranking/DailyRankingAggregationTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/DailyRankingAggregationTasklet.java new file mode 100644 index 0000000..c9b459b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/DailyRankingAggregationTasklet.java @@ -0,0 +1,64 @@ +package com.loopers.job.ranking; + +import com.loopers.domain.ranking.RankingCommand; +import com.loopers.domain.ranking.RankingService; +import com.loopers.support.StringUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class DailyRankingAggregationTasklet implements Tasklet { + + private final StringRedisTemplate stringRedisTemplate; + private final RankingService rankingService; + + @Value("#{jobParameters['date'] ?: null}") + private LocalDate date; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String day = date.format(DateTimeFormatter.BASIC_ISO_DATE); + String all = "metric.product.all:" + day; + + // Top 100 + ZSetOperations zSet = stringRedisTemplate.opsForZSet(); + Set> tuples = zSet.reverseRangeWithScores(all, 0, 99); + + if (CollectionUtils.isEmpty(tuples)) { + log.info("No daily ranking data found"); + return RepeatStatus.FINISHED; + } + + List> entries = tuples.stream() + .map(tuple -> Map.entry( + Long.parseLong(StringUtils.invert9sComplement(tuple.getValue())), + tuple.getScore() + )) + .toList(); + rankingService.aggregateDaily(new RankingCommand.AggregateDaily(date, entries)); + + log.info("Daily ranking aggregation completed: {}", entries.size()); + + return RepeatStatus.FINISHED; + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/job/ranking/MonthlyRankingAggregationTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/MonthlyRankingAggregationTasklet.java new file mode 100644 index 0000000..d72a276 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/MonthlyRankingAggregationTasklet.java @@ -0,0 +1,73 @@ +package com.loopers.job.ranking; + +import com.loopers.domain.ranking.RankingCommand; +import com.loopers.domain.ranking.RankingResult; +import com.loopers.domain.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.summingDouble; + +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class MonthlyRankingAggregationTasklet implements Tasklet { + + private final RankingService rankingService; + + @Value("#{jobParameters['date'] ?: null}") + private LocalDate date; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + // 지난 달 1일 ~ 말일 + LocalDate startDate = date.with(TemporalAdjusters.firstDayOfMonth()).minusMonths(1); + LocalDate endDate = date.minusDays(1); + + RankingResult.GetDaily ranking = rankingService.getDaily(new RankingCommand.GetDaily(startDate, endDate)); + + if (CollectionUtils.isEmpty(ranking.items())) { + log.info("No monthly ranking data found"); + return RepeatStatus.FINISHED; + } + + Map productScores = ranking.items() + .stream() + .collect(groupingBy( + RankingResult.GetDaily.Item::productId, + summingDouble(RankingResult.GetDaily.Item::score) + )); + + List productIds = productScores.entrySet() + .stream() + .sorted(Comparator + .>comparingDouble(Map.Entry::getValue).reversed() + .thenComparing(Map.Entry::getKey)) + .map(Map.Entry::getKey) + .toList(); + + // Top 100 + rankingService.aggregateMonthly(new RankingCommand.AggregateMonthly(date, productIds)); + + log.info("Monthly ranking aggregation completed: {}", productIds.size()); + + return RepeatStatus.FINISHED; + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingJobConfig.java new file mode 100644 index 0000000..0a4ac68 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingJobConfig.java @@ -0,0 +1,146 @@ +package com.loopers.job.ranking; + +import com.loopers.support.CommonJobListener; +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.Step; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.job.builder.FlowBuilder; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.flow.Flow; +import org.springframework.batch.core.job.flow.FlowExecutionStatus; +import org.springframework.batch.core.job.flow.JobExecutionDecider; +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.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.DayOfWeek; +import java.time.LocalDate; + +@Configuration +@RequiredArgsConstructor +public class ProductRankingJobConfig { + + public static final String JOB_NAME = "product_ranking_job"; + private static final String DAILY_RANKING_STEP_NAME = "daily_ranking_step"; + private static final String WEEKLY_RANKING_STEP_NAME = "weekly_ranking_step"; + private static final String MONTHLY_RANKING_STEP_NAME = "monthly_ranking_step"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final CommonJobListener commonJobListener; + @Qualifier(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) + private final TaskExecutor taskExecutor; + + private final DailyRankingAggregationTasklet dailyRankingAggregationTasklet; + private final WeeklyRankingAggregationTasklet weeklyRankingAggregationTasklet; + private final MonthlyRankingAggregationTasklet monthlyRankingAggregationTasklet; + + @Bean(name = JOB_NAME) + public Job productRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(commonJobListener) + .flow(dailyRankingStep()) + // daily 후 weekly/monthly를 병렬로 실행 + .next(new FlowBuilder("parallelAfterDailyRanking") + .split(taskExecutor) + .add(weeklyRankingFlow(), monthlyRankingFlow()) + .build() + ) + .end() + .build(); + } + + @Bean(name = DAILY_RANKING_STEP_NAME) + public Step dailyRankingStep() { + return new StepBuilder(DAILY_RANKING_STEP_NAME, jobRepository) + .tasklet(dailyRankingAggregationTasklet, transactionManager) + .build(); + } + + @Bean(name = WEEKLY_RANKING_STEP_NAME) + public Step weeklyRankingStep() { + return new StepBuilder(WEEKLY_RANKING_STEP_NAME, jobRepository) + .tasklet(weeklyRankingAggregationTasklet, transactionManager) + .build(); + } + + @Bean(name = MONTHLY_RANKING_STEP_NAME) + public Step monthlyRankingStep() { + return new StepBuilder(MONTHLY_RANKING_STEP_NAME, jobRepository) + .tasklet(monthlyRankingAggregationTasklet, transactionManager) + .build(); + } + + @Bean + public Flow weeklyRankingFlow() { + return new FlowBuilder("weeklyRankingFlow") + .start(weeklyDecider()) + .on("RUN").to(weeklyRankingStep()) + .from(weeklyDecider()) + .on("SKIP").end() + .build(); + } + + @Bean + public Flow monthlyRankingFlow() { + return new FlowBuilder("monthlyRankingFlow") + .start(monthlyDecider()) + .on("RUN").to(monthlyRankingStep()) + .from(monthlyDecider()) + .on("SKIP").end() + .build(); + } + + @Bean + WeeklyDecider weeklyDecider() { + return new WeeklyDecider(); + } + + @Bean + MonthlyDecider monthlyDecider() { + return new MonthlyDecider(); + } + + // ------------------------------------------------------------------------------------------------- + + @Slf4j + public static class WeeklyDecider implements JobExecutionDecider { + @Override + public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) { + LocalDate date = jobExecution.getJobParameters().getLocalDate("date"); + String status = date.getDayOfWeek() == DayOfWeek.MONDAY ? "RUN" : "SKIP"; + + if (status.equals("SKIP")) { + log.debug("Skip weekly ranking aggregation"); + } + + return new FlowExecutionStatus(status); + } + } + + @Slf4j + public static class MonthlyDecider implements JobExecutionDecider { + @Override + public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) { + LocalDate date = jobExecution.getJobParameters().getLocalDate("date"); + String status = date.getDayOfMonth() == 1 ? "RUN" : "SKIP"; + + if (status.equals("SKIP")) { + log.debug("Skip monthly ranking aggregation"); + } + + return new FlowExecutionStatus(status); + } + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingWeeklyJobConfig.java deleted file mode 100644 index e27d719..0000000 --- a/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingWeeklyJobConfig.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.loopers.job.ranking; - -import com.loopers.config.BatchJobProperties; -import com.loopers.domain.ranking.ProductRankingWeekly; -import com.loopers.support.CommonJobListener; -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.launch.support.RunIdIncrementer; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -@Configuration -@RequiredArgsConstructor -public class ProductRankingWeeklyJobConfig { - - private static final String JOB_NAME = "product_ranking_weekly_job"; - private static final String STEP_NAME = "aggregate_step"; - - private final JobRepository jobRepository; - private final PlatformTransactionManager transactionManager; - private final CommonJobListener commonJobListener; - private final BatchJobProperties batchJobProperties; - - @Bean(name = JOB_NAME) - public Job productRankingWeeklyJob() { - return new JobBuilder(JOB_NAME, jobRepository) - .incrementer(new RunIdIncrementer()) - .listener(commonJobListener) - .start(aggregateStep()) - .build(); - } - - @Bean(name = STEP_NAME) - public Step aggregateStep() { - int checkSize = batchJobProperties.getJobs().get(JOB_NAME) - .getSteps().get(STEP_NAME) - .getCheckSize(); - - return new StepBuilder(STEP_NAME, jobRepository) - .chunk(checkSize, transactionManager) -// .reader(reader) -// .processor(processor) -// .writer(writer) - .build(); - } - -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/job/ranking/WeeklyRankingAggregationTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/WeeklyRankingAggregationTasklet.java new file mode 100644 index 0000000..7d16dc8 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/WeeklyRankingAggregationTasklet.java @@ -0,0 +1,74 @@ +package com.loopers.job.ranking; + +import com.loopers.domain.ranking.RankingCommand; +import com.loopers.domain.ranking.RankingResult; +import com.loopers.domain.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.summingDouble; + +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class WeeklyRankingAggregationTasklet implements Tasklet { + + private final RankingService rankingService; + + @Value("#{jobParameters['date'] ?: null}") + private LocalDate date; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + // 지난 주 월요일 ~ 일요일 + LocalDate startDate = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate endDate = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)); + + RankingResult.GetDaily ranking = rankingService.getDaily(new RankingCommand.GetDaily(startDate, endDate)); + + if (CollectionUtils.isEmpty(ranking.items())) { + log.info("No weekly ranking data found"); + return RepeatStatus.FINISHED; + } + + Map productScores = ranking.items() + .stream() + .collect(groupingBy( + RankingResult.GetDaily.Item::productId, + summingDouble(RankingResult.GetDaily.Item::score) + )); + + List productIds = productScores.entrySet() + .stream() + .sorted(Comparator + .>comparingDouble(Map.Entry::getValue).reversed() + .thenComparing(Map.Entry::getKey)) + .map(Map.Entry::getKey) + .toList(); + + // Top 100 + rankingService.aggregateWeekly(new RankingCommand.AggregateWeekly(date, productIds)); + + log.info("Weekly ranking aggregation completed: {}", productIds.size()); + + return RepeatStatus.FINISHED; + } + +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index 9bbd0a1..1d99f9e 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -25,13 +25,7 @@ spring: initialize-schema: never job: enabled: false - -batch: - jobs: - product_ranking_weekly_job: - steps: - aggregate_step: - check-size: 1000 + name: ${job.name:NONE} --- From 5ec01a98edcf25f105e1348f40d77d9c3bef6298 Mon Sep 17 00:00:00 2001 From: imsejin Date: Fri, 19 Sep 2025 15:00:18 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9D=BC=2000:00:05?= =?UTF-8?q?=EC=97=90=20=EC=88=98=ED=96=89=ED=95=98=EB=8A=94=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A5=B4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/ranking/RankingScheduler.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/interfaces/scheduler/ranking/RankingScheduler.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/interfaces/scheduler/ranking/RankingScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/interfaces/scheduler/ranking/RankingScheduler.java new file mode 100644 index 0000000..9304e28 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/interfaces/scheduler/ranking/RankingScheduler.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.scheduler.ranking; + +import com.loopers.job.ranking.ProductRankingJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +public class RankingScheduler { + + private final JobLauncher jobLauncher; + + @Qualifier(ProductRankingJobConfig.JOB_NAME) + private final Job job; + + @Scheduled(cron = "5 0 0 * * *", zone = "Asia/Seoul") + public void launchRankingJob() throws Exception { + LocalDate yesterday = LocalDate.now().minusDays(1); + JobParameters jobParameters = new JobParametersBuilder() + .addLocalDate("date", yesterday) + .toJobParameters(); + jobLauncher.run(job, jobParameters); + } + +} From 81fdc507c8950e6145389ee2437aad334d79d9ff Mon Sep 17 00:00:00 2001 From: imsejin Date: Fri, 19 Sep 2025 16:19:08 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20YearWeek=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=EA=B0=80=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 2 ++ .../com/loopers/config/web/WebConfig.java | 16 +++++++++++++++ .../web/converter/YearWeekFormatter.java | 20 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/web/WebConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/web/converter/YearWeekFormatter.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 6174f9b..43385dd 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -17,6 +17,8 @@ dependencies { // UUIDv7 implementation("com.fasterxml.uuid:java-uuid-generator") + implementation("org.threeten:threeten-extra") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/config/web/WebConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/web/WebConfig.java new file mode 100644 index 0000000..5f6ef9d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/web/WebConfig.java @@ -0,0 +1,16 @@ +package com.loopers.config.web; + +import com.loopers.config.web.converter.YearWeekFormatter; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addFormatter(new YearWeekFormatter()); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/web/converter/YearWeekFormatter.java b/apps/commerce-api/src/main/java/com/loopers/config/web/converter/YearWeekFormatter.java new file mode 100644 index 0000000..8fe9998 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/web/converter/YearWeekFormatter.java @@ -0,0 +1,20 @@ +package com.loopers.config.web.converter; + +import org.springframework.format.Formatter; +import org.threeten.extra.YearWeek; + +import java.util.Locale; + +public class YearWeekFormatter implements Formatter { + + @Override + public YearWeek parse(String text, Locale locale) { + return YearWeek.parse(text); + } + + @Override + public String print(YearWeek object, Locale locale) { + return object.toString(); + } + +} From 678969eb3d583ecacf8b48d7f60181a3af5aa0df Mon Sep 17 00:00:00 2001 From: imsejin Date: Fri, 19 Sep 2025 16:19:37 +0900 Subject: [PATCH 8/9] =?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=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 55 ++++++++++- .../application/ranking/RankingInput.java | 23 ++++- .../application/ranking/RankingOutput.java | 97 ++++++++++++++----- .../domain/ranking/ProductRankingMonthly.java | 49 ++++++++++ .../domain/ranking/ProductRankingWeekly.java | 48 +++++++++ .../domain/ranking/RankingCommand.java | 23 ++++- .../domain/ranking/RankingRepository.java | 6 ++ .../loopers/domain/ranking/RankingResult.java | 59 +++++++++-- .../domain/ranking/RankingService.java | 20 +++- .../ProductRankingMonthlyJpaRepository.java | 16 +++ .../ProductRankingWeeklyJpaRepository.java | 15 +++ .../ranking/RankingRepositoryImpl.java | 36 +++++++ .../api/ranking/RankingRequest.java | 36 ++++++- .../api/ranking/RankingResponse.java | 97 ++++++++++++++----- .../api/ranking/RankingV1ApiSpec.java | 26 ++++- .../api/ranking/RankingV1Controller.java | 44 +++++++-- .../job/ranking/ProductRankingJobConfig.java | 12 +-- 17 files changed, 582 insertions(+), 80 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankingMonthlyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankingWeeklyJpaRepository.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 d41c521..bc1fcba 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 @@ -20,12 +20,15 @@ public class RankingFacade { private final RankingService rankingService; private final ProductService productService; - public RankingOutput.SearchRankings searchRankings(RankingInput.SearchRankings input) { - RankingCommand.SearchRanks rankCommand = new RankingCommand.SearchRanks(input.date(), input.page(), input.size()); - RankingResult.SearchRanks ranks = rankingService.searchRanks(rankCommand); + public RankingOutput.SearchDaily searchDaily(RankingInput.SearchDaily input) { + RankingResult.SearchDaily ranks = rankingService.searchDaily(new RankingCommand.SearchDaily( + input.date(), + input.page(), + input.size() + )); if (CollectionUtils.isEmpty(ranks.items())) { - return RankingOutput.SearchRankings.empty(ranks); + return RankingOutput.SearchDaily.empty(ranks); } List details = ranks.items() @@ -35,7 +38,49 @@ public RankingOutput.SearchRankings searchRankings(RankingInput.SearchRankings i ) .toList(); - return RankingOutput.SearchRankings.from(ranks, details); + return RankingOutput.SearchDaily.from(ranks, details); + } + + public RankingOutput.SearchWeekly searchWeekly(RankingInput.SearchWeekly input) { + RankingResult.SearchWeekly ranks = rankingService.searchWeekly(new RankingCommand.SearchWeekly( + input.yearWeek(), + input.page(), + input.size() + )); + + if (CollectionUtils.isEmpty(ranks.items())) { + return RankingOutput.SearchWeekly.empty(ranks); + } + + List details = ranks.items() + .stream() + .map(rank -> productService.getProductDetail(rank.productId()) + .orElseThrow(() -> new BusinessException(CommonErrorType.NOT_FOUND)) + ) + .toList(); + + return RankingOutput.SearchWeekly.from(ranks, details); + } + + public RankingOutput.SearchMonthly searchMonthly(RankingInput.SearchMonthly input) { + RankingResult.SearchMonthly ranks = rankingService.searchMonthly(new RankingCommand.SearchMonthly( + input.yearMonth(), + input.page(), + input.size() + )); + + if (CollectionUtils.isEmpty(ranks.items())) { + return RankingOutput.SearchMonthly.empty(ranks); + } + + List details = ranks.items() + .stream() + .map(rank -> productService.getProductDetail(rank.productId()) + .orElseThrow(() -> new BusinessException(CommonErrorType.NOT_FOUND)) + ) + .toList(); + + return RankingOutput.SearchMonthly.from(ranks, details); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInput.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInput.java index a2be596..149c364 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInput.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInput.java @@ -1,14 +1,35 @@ package com.loopers.application.ranking; +import org.threeten.extra.YearWeek; + import java.time.LocalDate; +import java.time.YearMonth; public record RankingInput() { - public record SearchRankings( + public record SearchDaily( LocalDate date, Integer page, Integer size ) { } + // ------------------------------------------------------------------------------------------------- + + public record SearchWeekly( + YearWeek yearWeek, + Integer page, + Integer size + ) { + } + + // ------------------------------------------------------------------------------------------------- + + public record SearchMonthly( + YearMonth yearMonth, + Integer page, + Integer size + ) { + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java index 96138da..e162b86 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java @@ -7,44 +7,95 @@ public record RankingOutput() { - public record SearchRankings( + public record SearchDaily( Integer totalPages, Long totalItems, Integer page, Integer size, List items ) { - public static SearchRankings empty(RankingResult.SearchRanks result) { - return new SearchRankings(result.totalPages(), result.totalItems(), result.page(), result.size(), List.of()); + public static SearchDaily empty(RankingResult.SearchDaily result) { + return new SearchDaily(result.totalPages(), result.totalItems(), result.page(), result.size(), List.of()); } - public static SearchRankings from(RankingResult.SearchRanks ranks, List details) { - return new SearchRankings( + public static SearchDaily from(RankingResult.SearchDaily ranks, List details) { + return new SearchDaily( ranks.totalPages(), ranks.totalItems(), ranks.page(), ranks.size(), - details.stream() - .map(detail -> new Item( - detail.productId(), - detail.productName(), - detail.basePrice(), - detail.likeCount(), - detail.brandId(), - detail.brandName() - )) - .toList() + details.stream().map(Item::from).toList() ); } + } + + // ------------------------------------------------------------------------------------------------- + + public record SearchWeekly( + Integer totalPages, + Long totalItems, + Integer page, + Integer size, + List items + ) { + public static SearchWeekly empty(RankingResult.SearchWeekly result) { + return new SearchWeekly(result.totalPages(), result.totalItems(), result.page(), result.size(), List.of()); + } + + public static SearchWeekly from(RankingResult.SearchWeekly ranks, List details) { + return new SearchWeekly( + ranks.totalPages(), + ranks.totalItems(), + ranks.page(), + ranks.size(), + details.stream().map(Item::from).toList() + ); + } + } + + // ------------------------------------------------------------------------------------------------- - public record Item( - Long productId, - String productName, - Integer basePrice, - Long likeCount, - Long brandId, - String brandName - ) { + public record SearchMonthly( + Integer totalPages, + Long totalItems, + Integer page, + Integer size, + List items + ) { + public static SearchMonthly empty(RankingResult.SearchMonthly result) { + return new SearchMonthly(result.totalPages(), result.totalItems(), result.page(), result.size(), List.of()); + } + + public static SearchMonthly from(RankingResult.SearchMonthly ranks, List details) { + return new SearchMonthly( + ranks.totalPages(), + ranks.totalItems(), + ranks.page(), + ranks.size(), + details.stream().map(Item::from).toList() + ); + } + } + + // ------------------------------------------------------------------------------------------------- + + public record Item( + Long productId, + String productName, + Integer basePrice, + Long likeCount, + Long brandId, + String brandName + ) { + public static Item from(ProductResult.GetProductDetail detail) { + return new Item( + detail.productId(), + detail.productName(), + detail.basePrice(), + detail.likeCount(), + detail.brandId(), + detail.brandName() + ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java new file mode 100644 index 0000000..60d50cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java @@ -0,0 +1,49 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.YearMonth; + +@Getter +@Entity +@Table( + name = "product_rankings_monthly", + uniqueConstraints = @UniqueConstraint(columnNames = {"year_month", "ref_product_id"}), + indexes = @Index(columnList = "year_month, ref_product_id, rank") +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductRankingMonthly extends BaseEntity { + + /** + * 아이디 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "product_ranking_weekly_id", nullable = false, updatable = false) + private Long id; + + /** + * 한 해의 주차 + */ + @Column(name = "year_month", nullable = false, updatable = false) + private YearMonth yearMonth; + + /** + * 순위 + */ + @Column(name = "rank", nullable = false, updatable = false) + private Integer rank; + + // ------------------------------------------------------------------------------------------------- + + /** + * 상품 아이디 + */ + @Column(name = "ref_product_id", nullable = false, updatable = false) + private Long productId; + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java new file mode 100644 index 0000000..83416e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java @@ -0,0 +1,48 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.threeten.extra.YearWeek; + +@Getter +@Entity +@Table( + name = "product_rankings_weekly", + uniqueConstraints = @UniqueConstraint(columnNames = {"year_week", "ref_product_id"}), + indexes = @Index(columnList = "year_week, ref_product_id, rank") +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductRankingWeekly extends BaseEntity { + + /** + * 아이디 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "product_ranking_weekly_id", nullable = false, updatable = false) + private Long id; + + /** + * 한 해의 주차 + */ + @Column(name = "year_week", nullable = false, updatable = false) + private YearWeek yearWeek; + + /** + * 순위 + */ + @Column(name = "rank", nullable = false, updatable = false) + private Integer rank; + + // ------------------------------------------------------------------------------------------------- + + /** + * 상품 아이디 + */ + @Column(name = "ref_product_id", nullable = false, updatable = false) + private Long productId; + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingCommand.java index bdabe05..6d787da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingCommand.java @@ -1,6 +1,9 @@ package com.loopers.domain.ranking; +import org.threeten.extra.YearWeek; + import java.time.LocalDate; +import java.time.YearMonth; public record RankingCommand() { @@ -12,11 +15,29 @@ public record FindRank( // ------------------------------------------------------------------------------------------------- - public record SearchRanks( + public record SearchDaily( LocalDate date, Integer page, Integer size ) { } + // ------------------------------------------------------------------------------------------------- + + public record SearchWeekly( + YearWeek yearWeek, + Integer page, + Integer size + ) { + } + + // ------------------------------------------------------------------------------------------------- + + public record SearchMonthly( + YearMonth yearMonth, + Integer page, + Integer size + ) { + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java index 567f291..4c51351 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java @@ -2,8 +2,10 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.threeten.extra.YearWeek; import java.time.LocalDate; +import java.time.YearMonth; import java.util.Optional; public interface RankingRepository { @@ -12,4 +14,8 @@ public interface RankingRepository { Page searchRanks(LocalDate date, Pageable pageable); + Page searchRanks(YearWeek yearWeek, Pageable pageable); + + Page searchRanks(YearMonth yearMonth, Pageable pageable); + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingResult.java index 33827d5..34d1eb2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingResult.java @@ -1,22 +1,21 @@ package com.loopers.domain.ranking; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import java.util.List; public record RankingResult() { - public record SearchRanks( + public record SearchDaily( Integer totalPages, Long totalItems, Integer page, Integer size, List items ) { - public static SearchRanks from(Page page, Pageable pageable) { - return new SearchRanks( + public static SearchDaily from(Page page, Pageable pageable) { + return new SearchDaily( page.getTotalPages(), page.getTotalElements(), pageable.getPageNumber(), @@ -25,8 +24,56 @@ public static SearchRanks from(Page page, Pageab ); } - public Pageable pageable() { - return PageRequest.of(page, size); + public record Item( + Long productId, + Long rank + ) { + } + } + + // ------------------------------------------------------------------------------------------------- + + public record SearchWeekly( + Integer totalPages, + Long totalItems, + Integer page, + Integer size, + List items + ) { + public static SearchWeekly from(Page page, Pageable pageable) { + return new SearchWeekly( + page.getTotalPages(), + page.getTotalElements(), + pageable.getPageNumber(), + pageable.getPageSize(), + page.map(result -> new Item(result.productId(), result.rank())).toList() + ); + } + + public record Item( + Long productId, + Long rank + ) { + } + } + + // ------------------------------------------------------------------------------------------------- + + public record SearchMonthly( + Integer totalPages, + Long totalItems, + Integer page, + Integer size, + List items + ) { + public static SearchMonthly from(Page page, Pageable pageable) { + return new SearchMonthly( + page.getTotalPages(), + page.getTotalElements(), + pageable.getPageNumber(), + pageable.getPageSize(), + page.map(result -> new Item(result.productId(), result.rank())).toList() + ); } public record Item( 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 acb94b6..787d03c 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 @@ -23,11 +23,27 @@ public Optional findRank(RankingCommand.FindRank command) { } @ReadOnlyTransactional - public RankingResult.SearchRanks searchRanks(RankingCommand.SearchRanks command) { + public RankingResult.SearchDaily searchDaily(RankingCommand.SearchDaily command) { Pageable pageable = PageRequest.of(command.page(), command.size()); Page page = rankingRepository.searchRanks(command.date(), pageable); - return RankingResult.SearchRanks.from(page, pageable); + return RankingResult.SearchDaily.from(page, pageable); + } + + @ReadOnlyTransactional + public RankingResult.SearchWeekly searchWeekly(RankingCommand.SearchWeekly command) { + Pageable pageable = PageRequest.of(command.page(), command.size()); + Page page = rankingRepository.searchRanks(command.yearWeek(), pageable); + + return RankingResult.SearchWeekly.from(page, pageable); + } + + @ReadOnlyTransactional + public RankingResult.SearchMonthly searchMonthly(RankingCommand.SearchMonthly command) { + Pageable pageable = PageRequest.of(command.page(), command.size()); + Page page = rankingRepository.searchRanks(command.yearMonth(), pageable); + + return RankingResult.SearchMonthly.from(page, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankingMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankingMonthlyJpaRepository.java new file mode 100644 index 0000000..58a8f46 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankingMonthlyJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankingMonthly; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.YearMonth; + +public interface ProductRankingMonthlyJpaRepository extends JpaRepository { + + @Query("select p from ProductRankingMonthly p where p.yearMonth = ?1 order by p.rank, p.id") + Page findByYearMonth(YearMonth yearMonth, Pageable pageable); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankingWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankingWeeklyJpaRepository.java new file mode 100644 index 0000000..37ca218 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankingWeeklyJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankingWeekly; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.threeten.extra.YearWeek; + +public interface ProductRankingWeeklyJpaRepository extends JpaRepository { + + @Query("select p from ProductRankingWeekly p where p.yearWeek = ?1 order by p.rank, p.id") + Page findByYearWeek(YearWeek yearWeek, Pageable pageable); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java index 1fda6b3..ef29d24 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java @@ -1,5 +1,7 @@ package com.loopers.infrastructure.ranking; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingWeekly; import com.loopers.domain.ranking.RankingQueryResult; import com.loopers.domain.ranking.RankingRepository; import com.loopers.support.StringUtils; @@ -11,8 +13,10 @@ import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; import org.springframework.util.CollectionUtils; +import org.threeten.extra.YearWeek; import java.time.LocalDate; +import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.util.*; @@ -20,6 +24,8 @@ @RequiredArgsConstructor public class RankingRepositoryImpl implements RankingRepository { + private final ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository; + private final ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository; private final StringRedisTemplate stringRedisTemplate; @Override @@ -65,4 +71,34 @@ public Page searchRanks(LocalDate date, Pageable () -> Objects.requireNonNullElse(zSet.zCard(key), 0L)); } + @Override + public Page searchRanks(YearWeek yearWeek, Pageable pageable) { + Page page = productRankingWeeklyJpaRepository.findByYearWeek(yearWeek, pageable); + + List content = page.getContent() + .stream() + .map(item -> new RankingQueryResult.SearchRanks( + item.getProductId(), + Long.valueOf(item.getRank()) + )) + .toList(); + + return PageableExecutionUtils.getPage(content, pageable, page::getTotalElements); + } + + @Override + public Page searchRanks(YearMonth yearMonth, Pageable pageable) { + Page page = productRankingMonthlyJpaRepository.findByYearMonth(yearMonth, pageable); + + List content = page.getContent() + .stream() + .map(item -> new RankingQueryResult.SearchRanks( + item.getProductId(), + Long.valueOf(item.getRank()) + )) + .toList(); + + return PageableExecutionUtils.getPage(content, pageable, page::getTotalElements); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingRequest.java index f070b97..56d9f27 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingRequest.java @@ -1,11 +1,14 @@ package com.loopers.interfaces.api.ranking; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Past; import jakarta.validation.constraints.PastOrPresent; import jakarta.validation.constraints.Positive; import lombok.*; +import org.threeten.extra.YearWeek; import java.time.LocalDate; +import java.time.YearMonth; @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class RankingRequest { @@ -13,10 +16,41 @@ public final class RankingRequest { @Getter @Builder @RequiredArgsConstructor(access = AccessLevel.PRIVATE) - public static class SearchRankings { + public static class SearchDaily { @PastOrPresent private final LocalDate date; + @NotNull + @Positive + private final Integer page; + @NotNull + @Positive + private final Integer size; + } + + // ------------------------------------------------------------------------------------------------- + + @Getter + @Builder + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class SearchWeekly { + @NotNull + private final YearWeek yearWeek; + @NotNull + @Positive + private final Integer page; + @NotNull + @Positive + private final Integer size; + } + + // ------------------------------------------------------------------------------------------------- + @Getter + @Builder + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class SearchMonthly { + @Past + private final YearMonth yearMonth; @NotNull @Positive private final Integer page; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java index 6458f4a..b91eb93 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java @@ -11,45 +11,92 @@ public final class RankingResponse { @Getter @Builder @RequiredArgsConstructor(access = AccessLevel.PRIVATE) - public static class SearchRankings { + public static class SearchDaily { private final Integer totalPages; private final Long totalItems; private final Integer page; private final Integer size; private final List items; - public static SearchRankings from(RankingOutput.SearchRankings output) { + public static SearchDaily from(RankingOutput.SearchDaily output) { return builder() .totalPages(output.totalPages()) .totalItems(output.totalItems()) .page(output.page()) .size(output.size()) - .items(output.items() - .stream() - .map(content -> Item.builder() - .productId(content.productId()) - .productName(content.productName()) - .basePrice(content.basePrice()) - .likeCount(content.likeCount()) - .brandId(content.brandId()) - .brandName(content.brandName()) - .build() - ) - .toList() - ) + .items(output.items().stream().map(Item::from).toList()) .build(); } + } + + // ------------------------------------------------------------------------------------------------- + + @Getter + @Builder + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class SearchWeekly { + private final Integer totalPages; + private final Long totalItems; + private final Integer page; + private final Integer size; + private final List items; + + public static SearchWeekly from(RankingOutput.SearchWeekly output) { + return builder() + .totalPages(output.totalPages()) + .totalItems(output.totalItems()) + .page(output.page()) + .size(output.size()) + .items(output.items().stream().map(Item::from).toList()) + .build(); + } + } + + // ------------------------------------------------------------------------------------------------- + + @Getter + @Builder + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class SearchMonthly { + private final Integer totalPages; + private final Long totalItems; + private final Integer page; + private final Integer size; + private final List items; - @Getter - @Builder - @RequiredArgsConstructor(access = AccessLevel.PRIVATE) - public static class Item { - private final Long productId; - private final String productName; - private final Integer basePrice; - private final Long likeCount; - private final Long brandId; - private final String brandName; + public static SearchMonthly from(RankingOutput.SearchMonthly output) { + return builder() + .totalPages(output.totalPages()) + .totalItems(output.totalItems()) + .page(output.page()) + .size(output.size()) + .items(output.items().stream().map(Item::from).toList()) + .build(); + } + } + + // ------------------------------------------------------------------------------------------------- + + @Getter + @Builder + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class Item { + private final Long productId; + private final String productName; + private final Integer basePrice; + private final Long likeCount; + private final Long brandId; + private final String brandName; + + public static Item from(RankingOutput.Item output) { + return builder() + .productId(output.productId()) + .productName(output.productName()) + .basePrice(output.basePrice()) + .likeCount(output.likeCount()) + .brandId(output.brandId()) + .brandName(output.brandName()) + .build(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java index f3e2c89..9054f38 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -9,12 +9,30 @@ public interface RankingV1ApiSpec { @Operation( - summary = "상품 랭킹 조회", - description = "상품 랭킹을 조회합니다." + summary = "(실시간) 일간 상품 랭킹 조회", + description = "(실시간) 일간 상품 랭킹을 조회합니다." ) - ApiResponse searchRankings( + ApiResponse searchDailyRankings( @Valid - RankingRequest.SearchRankings request + RankingRequest.SearchDaily request + ); + + @Operation( + summary = "주간 상품 랭킹 조회", + description = "주간 상품 랭킹을 조회합니다." + ) + ApiResponse searchWeeklyRankings( + @Valid + RankingRequest.SearchWeekly request + ); + + @Operation( + summary = "월간 상품 랭킹 조회", + description = "월간 상품 랭킹을 조회합니다." + ) + ApiResponse searchMonthlyRankings( + @Valid + RankingRequest.SearchMonthly request ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index ad28c98..5056389 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -16,18 +16,50 @@ public class RankingV1Controller implements RankingV1ApiSpec { private final RankingFacade rankingFacade; - @GetMapping + @GetMapping("/daily") @Override - public ApiResponse searchRankings( - RankingRequest.SearchRankings request + public ApiResponse searchDailyRankings( + RankingRequest.SearchDaily request ) { - RankingInput.SearchRankings input = new RankingInput.SearchRankings( + RankingInput.SearchDaily input = new RankingInput.SearchDaily( request.getDate(), request.getPage(), request.getSize() ); - RankingOutput.SearchRankings rankings = rankingFacade.searchRankings(input); - RankingResponse.SearchRankings response = RankingResponse.SearchRankings.from(rankings); + RankingOutput.SearchDaily rankings = rankingFacade.searchDaily(input); + RankingResponse.SearchDaily response = RankingResponse.SearchDaily.from(rankings); + + return ApiResponse.success(response); + } + + @GetMapping("/weekly") + @Override + public ApiResponse searchWeeklyRankings( + RankingRequest.SearchWeekly request + ) { + RankingInput.SearchWeekly input = new RankingInput.SearchWeekly( + request.getYearWeek(), + request.getPage(), + request.getSize() + ); + RankingOutput.SearchWeekly rankings = rankingFacade.searchWeekly(input); + RankingResponse.SearchWeekly response = RankingResponse.SearchWeekly.from(rankings); + + return ApiResponse.success(response); + } + + @GetMapping("/monthly") + @Override + public ApiResponse searchMonthlyRankings( + RankingRequest.SearchMonthly request + ) { + RankingInput.SearchMonthly input = new RankingInput.SearchMonthly( + request.getYearMonth(), + request.getPage(), + request.getSize() + ); + RankingOutput.SearchMonthly rankings = rankingFacade.searchMonthly(input); + RankingResponse.SearchMonthly response = RankingResponse.SearchMonthly.from(rankings); return ApiResponse.success(response); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingJobConfig.java index 0a4ac68..0752df8 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/job/ranking/ProductRankingJobConfig.java @@ -45,7 +45,7 @@ public class ProductRankingJobConfig { private final MonthlyRankingAggregationTasklet monthlyRankingAggregationTasklet; @Bean(name = JOB_NAME) - public Job productRankingJob() { + Job productRankingJob() { return new JobBuilder(JOB_NAME, jobRepository) .incrementer(new RunIdIncrementer()) .listener(commonJobListener) @@ -61,28 +61,28 @@ public Job productRankingJob() { } @Bean(name = DAILY_RANKING_STEP_NAME) - public Step dailyRankingStep() { + Step dailyRankingStep() { return new StepBuilder(DAILY_RANKING_STEP_NAME, jobRepository) .tasklet(dailyRankingAggregationTasklet, transactionManager) .build(); } @Bean(name = WEEKLY_RANKING_STEP_NAME) - public Step weeklyRankingStep() { + Step weeklyRankingStep() { return new StepBuilder(WEEKLY_RANKING_STEP_NAME, jobRepository) .tasklet(weeklyRankingAggregationTasklet, transactionManager) .build(); } @Bean(name = MONTHLY_RANKING_STEP_NAME) - public Step monthlyRankingStep() { + Step monthlyRankingStep() { return new StepBuilder(MONTHLY_RANKING_STEP_NAME, jobRepository) .tasklet(monthlyRankingAggregationTasklet, transactionManager) .build(); } @Bean - public Flow weeklyRankingFlow() { + Flow weeklyRankingFlow() { return new FlowBuilder("weeklyRankingFlow") .start(weeklyDecider()) .on("RUN").to(weeklyRankingStep()) @@ -92,7 +92,7 @@ public Flow weeklyRankingFlow() { } @Bean - public Flow monthlyRankingFlow() { + Flow monthlyRankingFlow() { return new FlowBuilder("monthlyRankingFlow") .start(monthlyDecider()) .on("RUN").to(monthlyRankingStep()) From 833ee77abbe4b04062ff359e4ba8005c821a8620 Mon Sep 17 00:00:00 2001 From: imsejin Date: Fri, 19 Sep 2025 16:19:57 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=EB=9E=AD=ED=81=AC=20API=20HTTP=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/commerce-api/rank.http | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/http/commerce-api/rank.http b/http/commerce-api/rank.http index 7aed0a1..f3dbbe2 100644 --- a/http/commerce-api/rank.http +++ b/http/commerce-api/rank.http @@ -1,4 +1,14 @@ -### 상품 랭킹 조회 -GET {{commerce-api}}/api/v1/rankings?date=2025-09-10&page=1&size=10 +### (실시간) 일간 상품 랭킹 조회 +GET {{commerce-api}}/api/v1/rankings/daily?date=2025-09-10&page=1&size=10 +X-USER-ID: user000001 +Content-Type: application/json + +### 주간 상품 랭킹 조회 +GET {{commerce-api}}/api/v1/rankings/weekly?yearWeek=2025-W36&page=1&size=10 +X-USER-ID: user000001 +Content-Type: application/json + +### 월간 상품 랭킹 조회 +GET {{commerce-api}}/api/v1/rankings/monthly?yearMonth=2025-08&page=1&size=10 X-USER-ID: user000001 Content-Type: application/json