-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-10] 랭킹 시스템 구현 #392
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: najang
Are you sure you want to change the base?
Changes from all commits
4400dde
e7e727c
c61050f
b256757
331d5a5
48dfba6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.Table; | ||
| import lombok.Getter; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| /** | ||
| * 월간 랭킹 Materialized View (mv_product_rank_monthly). | ||
| * Spring Batch MonthlyRankingJob 이 집계한 결과를 조회 전용으로 사용한다. | ||
| */ | ||
| @Getter | ||
| @Entity | ||
| @Table(name = "mv_product_rank_monthly") | ||
| public class MvProductRankMonthly { | ||
|
|
||
| @Id | ||
| @Column(name = "product_id") | ||
| private Long productId; | ||
|
|
||
| @Column(name = "like_count", nullable = false) | ||
| private int likeCount; | ||
|
|
||
| @Column(name = "order_count", nullable = false) | ||
| private int orderCount; | ||
|
|
||
| @Column(name = "score", nullable = false) | ||
| private double score; | ||
|
|
||
| @Column(name = "ranking_period", nullable = false) | ||
| private String rankingPeriod; | ||
|
|
||
| @Column(name = "updated_at", nullable = false) | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| protected MvProductRankMonthly() { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface MvProductRankMonthlyRepository { | ||
| List<MvProductRankMonthly> findTop(int page, int size); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.Table; | ||
| import lombok.Getter; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| /** | ||
| * 주간 랭킹 Materialized View (mv_product_rank_weekly). | ||
| * Spring Batch WeeklyRankingJob 이 집계한 결과를 조회 전용으로 사용한다. | ||
| */ | ||
| @Getter | ||
| @Entity | ||
| @Table(name = "mv_product_rank_weekly") | ||
| public class MvProductRankWeekly { | ||
|
|
||
| @Id | ||
| @Column(name = "product_id") | ||
| private Long productId; | ||
|
|
||
| @Column(name = "like_count", nullable = false) | ||
| private int likeCount; | ||
|
|
||
| @Column(name = "order_count", nullable = false) | ||
| private int orderCount; | ||
|
|
||
| @Column(name = "score", nullable = false) | ||
| private double score; | ||
|
|
||
| @Column(name = "year_month_week", nullable = false) | ||
| private String yearMonthWeek; | ||
|
|
||
| @Column(name = "updated_at", nullable = false) | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| protected MvProductRankWeekly() { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface MvProductRankWeeklyRepository { | ||
| List<MvProductRankWeekly> findTop(int page, int size); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |||||||||||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Component; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import java.time.LocalDate; | ||||||||||||||||||||||||||||||||||||||
| import java.util.Optional; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -15,6 +16,6 @@ public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { | |||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||||||||
| public Optional<ProductMetrics> findByProductId(Long productId) { | ||||||||||||||||||||||||||||||||||||||
| return productMetricsJpaRepository.findByProductId(productId); | ||||||||||||||||||||||||||||||||||||||
| return productMetricsJpaRepository.findByProductIdAndDate(productId, LocalDate.now()); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
18
to
20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🐛 수정안: 명시적 타임존 사용+import java.time.ZoneId;
+
`@RequiredArgsConstructor`
`@Component`
public class ProductMetricsRepositoryImpl implements ProductMetricsRepository {
+ private static final ZoneId RANKING_ZONE = ZoneId.of("Asia/Seoul");
private final ProductMetricsJpaRepository productMetricsJpaRepository;
`@Override`
public Optional<ProductMetrics> findByProductId(Long productId) {
- return productMetricsJpaRepository.findByProductIdAndDate(productId, LocalDate.now());
+ return productMetricsJpaRepository.findByProductIdAndDate(productId, LocalDate.now(RANKING_ZONE));
}
}추가 테스트 권장: 타임존 경계 시간대(KST 00:00-01:00)에서의 동작을 검증하는 테스트 케이스를 추가한다. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.MvProductRankMonthly; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthly, Long> { | ||
| Page<MvProductRankMonthly> findAllByOrderByScoreDesc(Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.MvProductRankMonthly; | ||
| import com.loopers.domain.ranking.MvProductRankMonthlyRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class MvProductRankMonthlyRepositoryImpl implements MvProductRankMonthlyRepository { | ||
|
|
||
| private final MvProductRankMonthlyJpaRepository jpaRepository; | ||
|
|
||
| @Override | ||
| public List<MvProductRankMonthly> findTop(int page, int size) { | ||
| return jpaRepository.findAllByOrderByScoreDesc(PageRequest.of(page, size)).getContent(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.MvProductRankWeekly; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeekly, Long> { | ||
| Page<MvProductRankWeekly> findAllByOrderByScoreDesc(Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.MvProductRankWeekly; | ||
| import com.loopers.domain.ranking.MvProductRankWeeklyRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class MvProductRankWeeklyRepositoryImpl implements MvProductRankWeeklyRepository { | ||
|
|
||
| private final MvProductRankWeeklyJpaRepository jpaRepository; | ||
|
|
||
| @Override | ||
| public List<MvProductRankWeekly> findTop(int page, int size) { | ||
| return jpaRepository.findAllByOrderByScoreDesc(PageRequest.of(page, size)).getContent(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,26 +23,29 @@ public class RankingV1Controller { | |
| private final RankingFacade rankingFacade; | ||
|
|
||
| /** | ||
| * 날짜 기반 상품 랭킹 목록을 페이징으로 조회한다. | ||
| * - date 미입력 시 오늘 날짜 기준 | ||
| * 기간(period) 기반 상품 랭킹 목록을 페이징으로 조회한다. | ||
| * - period: "daily"(기본) | "weekly" | "monthly" | ||
| * - date: yyyyMMdd 형식. period=daily 일 때 사용. 미입력 시 오늘 날짜. | ||
| * - page는 0-based | ||
| * - 인증 불필요 (공개 API) | ||
| */ | ||
| @GetMapping | ||
| public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings( | ||
| @RequestParam(required = false) String period, | ||
| @RequestParam(required = false) String date, | ||
| @RequestParam(defaultValue = "0") int page, | ||
| @RequestParam(defaultValue = "20") int size | ||
| ) { | ||
| String targetDate = (date == null || date.isBlank()) | ||
| ? LocalDate.now().format(DATE_FORMATTER) | ||
| : date; | ||
| String resolvedPeriod = (period == null || period.isBlank()) ? "daily" : period; | ||
| String targetDate = "daily".equalsIgnoreCase(resolvedPeriod) | ||
| ? (date == null || date.isBlank() ? LocalDate.now().format(DATE_FORMATTER) : date) | ||
| : null; | ||
|
|
||
| List<RankingInfo> rankings = rankingFacade.findRankings(targetDate, page, size); | ||
| List<RankingInfo> rankings = rankingFacade.findRankings(resolvedPeriod, targetDate, page, size); | ||
| List<RankingV1Dto.RankingResponse> content = rankings.stream() | ||
| .map(RankingV1Dto.RankingResponse::from) | ||
| .toList(); | ||
|
|
||
| return ApiResponse.success(new RankingV1Dto.RankingPageResponse(content, targetDate, page, size)); | ||
| return ApiResponse.success(new RankingV1Dto.RankingPageResponse(content, resolvedPeriod, targetDate, page, size)); | ||
|
Comment on lines
+39
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 비정상
As per coding guidelines 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
생성자에서
productId/datenull 방어가 없어 장애 전파 지점이 늦다.운영 관점에서 잘못된 이벤트/호출이 들어오면 DB flush 시점에야 실패해 소비자 재시도 누적으로 지연이 커질 수 있다. Line 51-54에서 생성 즉시 null 검증으로 fail-fast 처리하는 것이 안전하다. 추가 테스트로
new ProductMetrics(null, today, 0)및new ProductMetrics(productId, null, 0)가 즉시 예외를 던지는지 검증해야 한다.수정 예시
As per coding guidelines
**/*.java: null 처리와 예외 흐름이 명확한지 점검한다.📝 Committable suggestion
🤖 Prompt for AI Agents