-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현 - 김윤선 #393
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: kimyam1008
Are you sure you want to change the base?
Changes from all commits
ad91798
ed667fe
f196342
9e28c16
1c2b375
1fbbc59
66417c6
5ea70e8
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 |
|---|---|---|
|
|
@@ -7,6 +7,9 @@ | |
| import com.loopers.domain.product.ProductRepository; | ||
| import com.loopers.domain.ranking.RankingEntry; | ||
| import com.loopers.domain.ranking.RankingRepository; | ||
| import com.loopers.domain.ranking.mv.MvProductRankMonthly; | ||
| import com.loopers.domain.ranking.mv.MvProductRankWeekly; | ||
| import com.loopers.domain.ranking.mv.MvRankingRepository; | ||
| import com.loopers.infrastructure.ranking.RankingCacheStore; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
|
|
@@ -32,14 +35,19 @@ public class RankingFacade { | |
| private final ProductRepository productRepository; | ||
| private final BrandRepository brandRepository; | ||
| private final RankingCacheStore rankingCacheStore; | ||
| private final MvRankingRepository mvRankingRepository; | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public List<RankingDto.RankingItemInfo> getRankings(String date, int page, int size) { | ||
| return rankingCacheStore.getRankings(date, page, size) | ||
| public List<RankingDto.RankingItemInfo> getRankings(String period, String date, int page, int size) { | ||
| return rankingCacheStore.getRankings(period, date, page, size) | ||
| .orElseGet(() -> { | ||
| List<RankingDto.RankingItemInfo> result = loadRankings(date, page, size); | ||
| List<RankingDto.RankingItemInfo> result = switch (period) { | ||
| case "weekly" -> loadMvRankings(date, page, size, true); | ||
| case "monthly" -> loadMvRankings(date, page, size, false); | ||
| default -> loadRankings(date, page, size); | ||
| }; | ||
| if (!result.isEmpty()) { | ||
| rankingCacheStore.putRankings(date, page, size, result); | ||
| rankingCacheStore.putRankings(period, date, page, size, result); | ||
| } | ||
| return result; | ||
| }); | ||
|
|
@@ -86,6 +94,52 @@ private List<RankingDto.RankingItemInfo> loadRankings(String date, int page, int | |
| return result; | ||
| } | ||
|
|
||
| private List<RankingDto.RankingItemInfo> loadMvRankings(String date, int page, int size, boolean weekly) { | ||
| LocalDate aggregatedAt = LocalDate.parse(date, DATE_FORMAT); | ||
|
Comment on lines
+97
to
+98
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. 주간/월간 날짜 파싱 예외를 그대로 노출하면 응답 형식이 깨진다. 여기서 🤖 Prompt for AI Agents |
||
|
|
||
| List<Long> productIds; | ||
| List<Integer> ranks; | ||
| List<Double> scores; | ||
|
|
||
| if (weekly) { | ||
| var mvRanks = mvRankingRepository.findWeeklyRankings(aggregatedAt, page, size); | ||
| productIds = mvRanks.stream().map(MvProductRankWeekly::getProductId).toList(); | ||
| ranks = mvRanks.stream().map(MvProductRankWeekly::getRank).toList(); | ||
| scores = mvRanks.stream().map(MvProductRankWeekly::getScore).toList(); | ||
| } else { | ||
| var mvRanks = mvRankingRepository.findMonthlyRankings(aggregatedAt, page, size); | ||
| productIds = mvRanks.stream().map(MvProductRankMonthly::getProductId).toList(); | ||
| ranks = mvRanks.stream().map(MvProductRankMonthly::getRank).toList(); | ||
| scores = mvRanks.stream().map(MvProductRankMonthly::getScore).toList(); | ||
| } | ||
|
|
||
| if (productIds.isEmpty()) { | ||
| return List.of(); | ||
| } | ||
|
|
||
| Map<Long, Product> productMap = productRepository.findAllByIds(Set.copyOf(productIds)).stream() | ||
| .collect(Collectors.toMap(Product::getId, p -> p)); | ||
|
|
||
| Set<Long> brandIds = productMap.values().stream() | ||
| .map(Product::getBrandId) | ||
| .collect(Collectors.toSet()); | ||
|
|
||
| Map<Long, String> brandNameMap = brandRepository.findAllByIds(brandIds).stream() | ||
| .collect(Collectors.toMap(Brand::getId, Brand::getName)); | ||
|
|
||
| List<RankingDto.RankingItemInfo> result = new ArrayList<>(); | ||
| for (int i = 0; i < productIds.size(); i++) { | ||
| Product product = productMap.get(productIds.get(i)); | ||
| if (product == null) { | ||
| continue; | ||
| } | ||
| String brandName = brandNameMap.getOrDefault(product.getBrandId(), ""); | ||
| ProductDto.ProductInfo productInfo = ProductDto.ProductInfo.of(product, brandName); | ||
| result.add(RankingDto.RankingItemInfo.of(ranks.get(i), scores.get(i), productInfo)); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| public RankingDto.ProductRankInfo getProductRank(Long productId, String date) { | ||
| String key = RANKING_KEY_PREFIX + date; | ||
| Long rank = rankingRepository.getRank(key, productId); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package com.loopers.domain.ranking.mv; | ||
|
|
||
| import jakarta.persistence.*; | ||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| @Entity | ||
| @Table(name = "mv_product_rank_monthly") | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class MvProductRankMonthly { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(name = "product_id", nullable = false) | ||
| private Long productId; | ||
|
|
||
| @Column(name = "score", nullable = false) | ||
| private double score; | ||
|
|
||
| @Column(name = "ranking", nullable = false) | ||
| private int rank; | ||
|
|
||
| @Column(name = "view_count", nullable = false) | ||
| private long viewCount; | ||
|
|
||
| @Column(name = "like_count", nullable = false) | ||
| private long likeCount; | ||
|
|
||
| @Column(name = "sales_count", nullable = false) | ||
| private long salesCount; | ||
|
|
||
| @Column(name = "aggregated_at", nullable = false) | ||
| private LocalDate aggregatedAt; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package com.loopers.domain.ranking.mv; | ||
|
|
||
| import jakarta.persistence.*; | ||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| @Entity | ||
| @Table(name = "mv_product_rank_weekly") | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class MvProductRankWeekly { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(name = "product_id", nullable = false) | ||
| private Long productId; | ||
|
|
||
| @Column(name = "score", nullable = false) | ||
| private double score; | ||
|
|
||
| @Column(name = "ranking", nullable = false) | ||
| private int rank; | ||
|
|
||
| @Column(name = "view_count", nullable = false) | ||
| private long viewCount; | ||
|
|
||
| @Column(name = "like_count", nullable = false) | ||
| private long likeCount; | ||
|
|
||
| @Column(name = "sales_count", nullable = false) | ||
| private long salesCount; | ||
|
|
||
| @Column(name = "aggregated_at", nullable = false) | ||
| private LocalDate aggregatedAt; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.loopers.domain.ranking.mv; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| public interface MvRankingRepository { | ||
|
|
||
| List<MvProductRankWeekly> findWeeklyRankings(LocalDate aggregatedAt, int page, int size); | ||
|
|
||
| List<MvProductRankMonthly> findMonthlyRankings(LocalDate aggregatedAt, int page, int size); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.loopers.infrastructure.ranking.mv; | ||
|
|
||
| import com.loopers.domain.ranking.mv.MvProductRankMonthly; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthly, Long> { | ||
|
|
||
| List<MvProductRankMonthly> findByAggregatedAtOrderByRankAsc(LocalDate aggregatedAt, Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.loopers.infrastructure.ranking.mv; | ||
|
|
||
| import com.loopers.domain.ranking.mv.MvProductRankWeekly; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeekly, Long> { | ||
|
|
||
| List<MvProductRankWeekly> findByAggregatedAtOrderByRankAsc(LocalDate aggregatedAt, Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package com.loopers.infrastructure.ranking.mv; | ||
|
|
||
| import com.loopers.domain.ranking.mv.MvProductRankMonthly; | ||
| import com.loopers.domain.ranking.mv.MvProductRankWeekly; | ||
| import com.loopers.domain.ranking.mv.MvRankingRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class MvRankingRepositoryAdapter implements MvRankingRepository { | ||
|
|
||
| private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; | ||
| private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; | ||
|
|
||
| @Override | ||
| public List<MvProductRankWeekly> findWeeklyRankings(LocalDate aggregatedAt, int page, int size) { | ||
| return weeklyJpaRepository.findByAggregatedAtOrderByRankAsc(aggregatedAt, PageRequest.of(page, size)); | ||
| } | ||
|
|
||
| @Override | ||
| public List<MvProductRankMonthly> findMonthlyRankings(LocalDate aggregatedAt, int page, int size) { | ||
| return monthlyJpaRepository.findByAggregatedAtOrderByRankAsc(aggregatedAt, PageRequest.of(page, size)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,12 +23,13 @@ public class RankingV1Controller { | |
|
|
||
| @GetMapping("/api/v1/rankings") | ||
| public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings( | ||
| @RequestParam(defaultValue = "daily") String period, | ||
|
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.
수정안:
추가 테스트: "Weekly", "MONTHLY" 등 대소문자 변형 입력에 대한 E2E 테스트 추가 권장 🔧 정규화 적용 예시 `@GetMapping`("/api/v1/rankings")
public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings(
`@RequestParam`(defaultValue = "daily") String period,
`@RequestParam`(required = false) String date,
`@RequestParam`(defaultValue = "20") int size,
`@RequestParam`(defaultValue = "0") int page
) {
String resolvedDate = (date != null) ? date : todayDate();
- List<RankingDto.RankingItemInfo> rankings = rankingFacade.getRankings(period, resolvedDate, page, size);
+ List<RankingDto.RankingItemInfo> rankings = rankingFacade.getRankings(period.toLowerCase(), resolvedDate, page, size);
return ApiResponse.success(RankingV1Dto.RankingPageResponse.of(rankings, page, size));
}🤖 Prompt for AI Agents |
||
| @RequestParam(required = false) String date, | ||
| @RequestParam(defaultValue = "20") int size, | ||
| @RequestParam(defaultValue = "0") int page | ||
| ) { | ||
| String resolvedDate = (date != null) ? date : todayDate(); | ||
| List<RankingDto.RankingItemInfo> rankings = rankingFacade.getRankings(resolvedDate, page, size); | ||
| List<RankingDto.RankingItemInfo> rankings = rankingFacade.getRankings(period, resolvedDate, page, size); | ||
| return ApiResponse.success(RankingV1Dto.RankingPageResponse.of(rankings, page, size)); | ||
| } | ||
|
|
||
|
|
||
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.
허용되지 않은
period를daily로 묵살하지 말아야 한다.현재는
"weekly","monthly"외의 모든 값이default로 일간 랭킹으로 내려가고, 잘못된period가 그대로 캐시 키에도 반영된다. 운영에서는 잘못된 호출을 조용히 성공 처리해 계약 위반을 숨기고, 캐시 오염까지 남긴다. 캐시 조회 전에daily|weekly|monthly만 허용하도록 검증하고, 그 외 값은CoreException으로 명시적으로 거절해 달라.period=foo요청이 표준 오류 응답으로 실패하고 캐시/저장소가 호출되지 않는 테스트도 추가해 달라. Based on learnings: In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format.🤖 Prompt for AI Agents