Skip to content

Commit bcf0868

Browse files
committed
round9: 랭킹 시스템 도입
1 parent 4ec1700 commit bcf0868

File tree

17 files changed

+1101
-10
lines changed

17 files changed

+1101
-10
lines changed

apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.loopers.domain.supply.Supply;
1111
import com.loopers.domain.supply.SupplyService;
1212
import com.loopers.infrastructure.cache.product.ProductCacheService;
13+
import com.loopers.application.ranking.RankingFacade;
1314
import com.loopers.support.error.CoreException;
1415
import com.loopers.support.error.ErrorType;
1516
import lombok.RequiredArgsConstructor;
@@ -18,6 +19,8 @@
1819
import org.springframework.stereotype.Component;
1920
import org.springframework.transaction.annotation.Transactional;
2021

22+
import java.time.LocalDate;
23+
import java.time.format.DateTimeFormatter;
2124
import java.util.*;
2225
import java.util.stream.Collectors;
2326

@@ -30,6 +33,7 @@ public class ProductFacade {
3033
private final SupplyService supplyService;
3134
private final ProductCacheService productCacheService;
3235
private final ProductViewedEventPublisher productViewedEventPublisher;
36+
private final RankingFacade rankingFacade;
3337

3438
@Transactional
3539
public ProductInfo createProduct(ProductCreateRequest request) {
@@ -268,6 +272,32 @@ public ProductInfo getProductDetail(Long productId) {
268272
productCacheService.setProductDetail(productId, Optional.of(productInfo));
269273
return productInfo;
270274
}
275+
276+
/**
277+
* 상품 상세 조회 (랭킹 정보 포함)
278+
* @param productId 상품 ID
279+
* @return 상품 정보와 랭킹 정보를 포함한 응답
280+
*/
281+
@Transactional(readOnly = true)
282+
public ProductInfoWithRanking getProductDetailWithRanking(Long productId) {
283+
ProductInfo productInfo = getProductDetail(productId);
284+
285+
// 랭킹 정보 조회
286+
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
287+
com.loopers.application.ranking.RankingInfo.ProductRankingInfo rankingInfo =
288+
rankingFacade.getProductRanking(productId, today);
289+
290+
return new ProductInfoWithRanking(productInfo, rankingInfo);
291+
}
292+
293+
/**
294+
* 상품 정보와 랭킹 정보를 포함한 응답
295+
*/
296+
public record ProductInfoWithRanking(
297+
ProductInfo productInfo,
298+
com.loopers.application.ranking.RankingInfo.ProductRankingInfo rankingInfo
299+
) {
300+
}
271301

272302
private void invalidateBrandListCache(Long brandId) {
273303
try {
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.domain.product.Product;
4+
import com.loopers.domain.product.ProductService;
5+
import com.loopers.domain.ranking.RankingService;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.data.domain.Pageable;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.stream.Collectors;
16+
17+
/**
18+
* 랭킹 Facade
19+
* <p>
20+
* 랭킹 조회 로직을 담당하며, ZSET에서 랭킹 정보를 조회하고
21+
* 상품 정보를 Aggregation하여 반환합니다.
22+
*/
23+
@Slf4j
24+
@Component
25+
@RequiredArgsConstructor
26+
public class RankingFacade {
27+
28+
private final RankingService rankingService;
29+
private final ProductService productService;
30+
31+
/**
32+
* 랭킹 페이지 조회
33+
* 1. ZSET에서 Top-N 상품 ID 조회
34+
* 2. 상품 정보 조회 및 Aggregation
35+
* 3. 순위 정보 포함하여 반환
36+
*/
37+
@Transactional(readOnly = true)
38+
public RankingInfo.RankingsPageResponse getRankings(String date, Pageable pageable) {
39+
String rankingKey = rankingService.getRankingKey(date);
40+
41+
// 페이지네이션 계산
42+
long start = pageable.getPageNumber() * pageable.getPageSize();
43+
long end = start + pageable.getPageSize() - 1;
44+
45+
// ZSET에서 상품 ID와 점수 조회
46+
List<RankingService.RankingEntry> entries =
47+
rankingService.getTopNWithScores(rankingKey, start, end);
48+
49+
if (entries.isEmpty()) {
50+
return RankingInfo.RankingsPageResponse.empty(pageable);
51+
}
52+
53+
// 상품 ID 리스트 추출
54+
List<Long> productIds = entries.stream()
55+
.map(RankingService.RankingEntry::getProductId)
56+
.collect(Collectors.toList());
57+
58+
// 상품 정보 조회
59+
Map<Long, Product> productMap = productService.getProductMapByIds(productIds);
60+
61+
// 랭킹 정보와 상품 정보 결합
62+
List<RankingInfo.RankingItem> rankingItems = new ArrayList<>();
63+
long rank = start + 1; // 1부터 시작하는 순위
64+
65+
for (RankingService.RankingEntry entry : entries) {
66+
Product product = productMap.get(entry.getProductId());
67+
if (product != null) {
68+
rankingItems.add(RankingInfo.RankingItem.of(
69+
rank++,
70+
entry.getProductId(),
71+
entry.getScore(),
72+
product
73+
));
74+
}
75+
}
76+
77+
// 전체 랭킹 수 조회 (총 페이지 수 계산용)
78+
Long totalSize = rankingService.getRankingSize(rankingKey);
79+
80+
return RankingInfo.RankingsPageResponse.of(
81+
rankingItems,
82+
pageable.getPageNumber(),
83+
pageable.getPageSize(),
84+
totalSize != null ? totalSize.intValue() : 0
85+
);
86+
}
87+
88+
/**
89+
* 특정 상품의 랭킹 정보 조회
90+
*/
91+
@Transactional(readOnly = true)
92+
public RankingInfo.ProductRankingInfo getProductRanking(Long productId, String date) {
93+
String rankingKey = rankingService.getRankingKey(date);
94+
Long rank = rankingService.getRank(rankingKey, productId);
95+
Double score = rankingService.getScore(rankingKey, productId);
96+
97+
if (rank == null || score == null) {
98+
return null; // 랭킹에 없음
99+
}
100+
101+
return RankingInfo.ProductRankingInfo.of(
102+
rank + 1, // 1부터 시작하는 순위로 변환
103+
score
104+
);
105+
}
106+
}
107+
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.domain.product.Product;
4+
import org.springframework.data.domain.Pageable;
5+
6+
import java.util.Collections;
7+
import java.util.List;
8+
9+
/**
10+
* 랭킹 정보 DTO
11+
*/
12+
public class RankingInfo {
13+
14+
/**
15+
* 랭킹 페이지 응답
16+
*/
17+
public record RankingsPageResponse(
18+
List<RankingItem> items,
19+
int page,
20+
int size,
21+
int total
22+
) {
23+
public static RankingsPageResponse empty(Pageable pageable) {
24+
return new RankingsPageResponse(
25+
Collections.emptyList(),
26+
pageable.getPageNumber(),
27+
pageable.getPageSize(),
28+
0
29+
);
30+
}
31+
32+
public static RankingsPageResponse of(
33+
List<RankingItem> items,
34+
int page,
35+
int size,
36+
int total
37+
) {
38+
return new RankingsPageResponse(items, page, size, total);
39+
}
40+
}
41+
42+
/**
43+
* 랭킹 항목 (상품 정보 + 순위 + 점수)
44+
*/
45+
public record RankingItem(
46+
Long rank,
47+
Long productId,
48+
Double score,
49+
String productName,
50+
Long brandId,
51+
Integer price
52+
) {
53+
public static RankingItem of(Long rank, Long productId, Double score, Product product) {
54+
return new RankingItem(
55+
rank,
56+
productId,
57+
score,
58+
product.getName(),
59+
product.getBrandId(),
60+
product.getPrice().amount()
61+
);
62+
}
63+
}
64+
65+
/**
66+
* 상품 랭킹 정보 (상세 조회용)
67+
*/
68+
public record ProductRankingInfo(
69+
Long rank,
70+
Double score
71+
) {
72+
public static ProductRankingInfo of(Long rank, Double score) {
73+
return new ProductRankingInfo(rank, score);
74+
}
75+
}
76+
}
77+

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,11 @@ private Pageable normalizePageable(Pageable pageable) {
7979
@RequestMapping(method = RequestMethod.GET, path = "/{productId}")
8080
@Override
8181
public ApiResponse<ProductV1Dto.ProductResponse> getProductDetail(@PathVariable Long productId) {
82-
ProductInfo info = productFacade.getProductDetail(productId);
83-
ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info);
82+
ProductFacade.ProductInfoWithRanking infoWithRanking = productFacade.getProductDetailWithRanking(productId);
83+
ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(
84+
infoWithRanking.productInfo(),
85+
ProductV1Dto.RankingInfo.from(infoWithRanking.rankingInfo())
86+
);
8487
return ApiResponse.success(response);
8588
}
8689
}

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,39 @@ public record ProductResponse(
2020
String brand,
2121
int price,
2222
int likes,
23-
int stock
23+
int stock,
24+
RankingInfo rankingInfo
2425
) {
25-
public static ProductResponse from(ProductInfo info) {
26+
public static ProductResponse from(ProductInfo info, RankingInfo rankingInfo) {
2627
return new ProductResponse(
2728
info.id(),
2829
info.name(),
2930
info.brand(),
3031
info.price(),
3132
info.likes(),
32-
info.stock()
33+
info.stock(),
34+
rankingInfo
3335
);
3436
}
37+
38+
public static ProductResponse from(ProductInfo info) {
39+
return from(info, null);
40+
}
41+
}
42+
43+
/**
44+
* 상품 랭킹 정보
45+
*/
46+
public record RankingInfo(
47+
Long rank,
48+
Double score
49+
) {
50+
public static RankingInfo from(com.loopers.application.ranking.RankingInfo.ProductRankingInfo info) {
51+
if (info == null) {
52+
return null;
53+
}
54+
return new RankingInfo(info.rank(), info.score());
55+
}
3556
}
3657

3758
public record ProductsPageResponse(
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.loopers.interfaces.api.ranking;
2+
3+
import com.loopers.interfaces.api.ApiResponse;
4+
import io.swagger.v3.oas.annotations.Operation;
5+
import io.swagger.v3.oas.annotations.Parameter;
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
9+
@Tag(name = "Ranking V1 API", description = "랭킹 API 입니다.")
10+
public interface RankingV1ApiSpec {
11+
12+
@Operation(
13+
method = "GET",
14+
summary = "랭킹 페이지 조회",
15+
description = "일간 랭킹을 페이지네이션으로 조회합니다."
16+
)
17+
ApiResponse<RankingV1Dto.RankingsPageResponse> getRankings(
18+
@Parameter(description = "날짜 (yyyyMMdd 형식, 미지정 시 오늘 날짜)", example = "20241219")
19+
@Schema(description = "날짜 (yyyyMMdd 형식, 미지정 시 오늘 날짜)", example = "20241219")
20+
String date,
21+
@Parameter(description = "페이지 크기", example = "20")
22+
@Schema(description = "페이지 크기", example = "20")
23+
int size,
24+
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0")
25+
@Schema(description = "페이지 번호 (0부터 시작)", example = "0")
26+
int page
27+
);
28+
}
29+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.loopers.interfaces.api.ranking;
2+
3+
import com.loopers.application.ranking.RankingFacade;
4+
import com.loopers.application.ranking.RankingInfo;
5+
import com.loopers.interfaces.api.ApiResponse;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.data.domain.PageRequest;
8+
import org.springframework.data.domain.Pageable;
9+
import org.springframework.web.bind.annotation.GetMapping;
10+
import org.springframework.web.bind.annotation.RequestMapping;
11+
import org.springframework.web.bind.annotation.RequestParam;
12+
import org.springframework.web.bind.annotation.RestController;
13+
14+
import java.time.LocalDate;
15+
import java.time.format.DateTimeFormatter;
16+
17+
@RequiredArgsConstructor
18+
@RestController
19+
@RequestMapping("/api/v1/rankings")
20+
public class RankingV1Controller implements RankingV1ApiSpec {
21+
22+
private final RankingFacade rankingFacade;
23+
24+
@GetMapping
25+
@Override
26+
public ApiResponse<RankingV1Dto.RankingsPageResponse> getRankings(
27+
@RequestParam(required = false) String date,
28+
@RequestParam(defaultValue = "20") int size,
29+
@RequestParam(defaultValue = "0") int page
30+
) {
31+
String rankingDate = date != null ? date :
32+
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
33+
34+
Pageable pageable = PageRequest.of(page, size);
35+
RankingInfo.RankingsPageResponse response =
36+
rankingFacade.getRankings(rankingDate, pageable);
37+
38+
return ApiResponse.success(RankingV1Dto.RankingsPageResponse.from(response));
39+
}
40+
}
41+

0 commit comments

Comments
 (0)