Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.loopers.domain.supply.Supply;
import com.loopers.domain.supply.SupplyService;
import com.loopers.infrastructure.cache.product.ProductCacheService;
import com.loopers.application.ranking.RankingFacade;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
Expand All @@ -18,6 +19,8 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;

Expand All @@ -30,6 +33,7 @@ public class ProductFacade {
private final SupplyService supplyService;
private final ProductCacheService productCacheService;
private final ProductViewedEventPublisher productViewedEventPublisher;
private final RankingFacade rankingFacade;

@Transactional
public ProductInfo createProduct(ProductCreateRequest request) {
Expand Down Expand Up @@ -268,6 +272,32 @@ public ProductInfo getProductDetail(Long productId) {
productCacheService.setProductDetail(productId, Optional.of(productInfo));
return productInfo;
}

/**
* ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (๋žญํ‚น ์ •๋ณด ํฌํ•จ)
* @param productId ์ƒํ’ˆ ID
* @return ์ƒํ’ˆ ์ •๋ณด์™€ ๋žญํ‚น ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ์‘๋‹ต
*/
@Transactional(readOnly = true)
public ProductInfoWithRanking getProductDetailWithRanking(Long productId) {
ProductInfo productInfo = getProductDetail(productId);

// ๋žญํ‚น ์ •๋ณด ์กฐํšŒ
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
com.loopers.application.ranking.RankingInfo.ProductRankingInfo rankingInfo =
rankingFacade.getProductRanking(productId, today);

return new ProductInfoWithRanking(productInfo, rankingInfo);
}

/**
* ์ƒํ’ˆ ์ •๋ณด์™€ ๋žญํ‚น ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ์‘๋‹ต
*/
public record ProductInfoWithRanking(
ProductInfo productInfo,
com.loopers.application.ranking.RankingInfo.ProductRankingInfo rankingInfo
) {
}

private void invalidateBrandListCache(Long brandId) {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.loopers.application.ranking;

import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.ranking.RankingService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* ๋žญํ‚น Facade
* <p>
* ๋žญํ‚น ์กฐํšŒ ๋กœ์ง์„ ๋‹ด๋‹นํ•˜๋ฉฐ, ZSET์—์„œ ๋žญํ‚น ์ •๋ณด๋ฅผ ์กฐํšŒํ•˜๊ณ 
* ์ƒํ’ˆ ์ •๋ณด๋ฅผ Aggregationํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RankingFacade {

private final RankingService rankingService;
private final ProductService productService;

/**
* ๋žญํ‚น ํŽ˜์ด์ง€ ์กฐํšŒ
* 1. ZSET์—์„œ Top-N ์ƒํ’ˆ ID ์กฐํšŒ
* 2. ์ƒํ’ˆ ์ •๋ณด ์กฐํšŒ ๋ฐ Aggregation
* 3. ์ˆœ์œ„ ์ •๋ณด ํฌํ•จํ•˜์—ฌ ๋ฐ˜ํ™˜
*/
@Transactional(readOnly = true)
public RankingInfo.RankingsPageResponse getRankings(String date, Pageable pageable) {
String rankingKey = rankingService.getRankingKey(date);

// ํŽ˜์ด์ง€๋„ค์ด์…˜ ๊ณ„์‚ฐ
long start = pageable.getPageNumber() * pageable.getPageSize();
long end = start + pageable.getPageSize() - 1;

// ZSET์—์„œ ์ƒํ’ˆ ID์™€ ์ ์ˆ˜ ์กฐํšŒ
List<RankingService.RankingEntry> entries =
rankingService.getTopNWithScores(rankingKey, start, end);

if (entries.isEmpty()) {
return RankingInfo.RankingsPageResponse.empty(pageable);
}

// ์ƒํ’ˆ ID ๋ฆฌ์ŠคํŠธ ์ถ”์ถœ
List<Long> productIds = entries.stream()
.map(RankingService.RankingEntry::getProductId)
.collect(Collectors.toList());

// ์ƒํ’ˆ ์ •๋ณด ์กฐํšŒ
Map<Long, Product> productMap = productService.getProductMapByIds(productIds);

// ๋žญํ‚น ์ •๋ณด์™€ ์ƒํ’ˆ ์ •๋ณด ๊ฒฐํ•ฉ
List<RankingInfo.RankingItem> rankingItems = new ArrayList<>();
long rank = start + 1; // 1๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๋Š” ์ˆœ์œ„

for (RankingService.RankingEntry entry : entries) {
Product product = productMap.get(entry.getProductId());
if (product != null) {
rankingItems.add(RankingInfo.RankingItem.of(
rank++,
entry.getProductId(),
entry.getScore(),
product
));
}
}

// ์ „์ฒด ๋žญํ‚น ์ˆ˜ ์กฐํšŒ (์ด ํŽ˜์ด์ง€ ์ˆ˜ ๊ณ„์‚ฐ์šฉ)
Long totalSize = rankingService.getRankingSize(rankingKey);

return RankingInfo.RankingsPageResponse.of(
rankingItems,
pageable.getPageNumber(),
pageable.getPageSize(),
totalSize != null ? totalSize.intValue() : 0
);
}

/**
* ํŠน์ • ์ƒํ’ˆ์˜ ๋žญํ‚น ์ •๋ณด ์กฐํšŒ
*/
@Transactional(readOnly = true)
public RankingInfo.ProductRankingInfo getProductRanking(Long productId, String date) {
String rankingKey = rankingService.getRankingKey(date);
Long rank = rankingService.getRank(rankingKey, productId);
Double score = rankingService.getScore(rankingKey, productId);

if (rank == null || score == null) {
return null; // ๋žญํ‚น์— ์—†์Œ
}

return RankingInfo.ProductRankingInfo.of(
rank + 1, // 1๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๋Š” ์ˆœ์œ„๋กœ ๋ณ€ํ™˜
score
);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.loopers.application.ranking;

import com.loopers.domain.product.Product;
import org.springframework.data.domain.Pageable;

import java.util.Collections;
import java.util.List;

/**
* ๋žญํ‚น ์ •๋ณด DTO
*/
public class RankingInfo {

/**
* ๋žญํ‚น ํŽ˜์ด์ง€ ์‘๋‹ต
*/
public record RankingsPageResponse(
List<RankingItem> items,
int page,
int size,
int total
) {
public static RankingsPageResponse empty(Pageable pageable) {
return new RankingsPageResponse(
Collections.emptyList(),
pageable.getPageNumber(),
pageable.getPageSize(),
0
);
}

public static RankingsPageResponse of(
List<RankingItem> items,
int page,
int size,
int total
) {
return new RankingsPageResponse(items, page, size, total);
}
}

/**
* ๋žญํ‚น ํ•ญ๋ชฉ (์ƒํ’ˆ ์ •๋ณด + ์ˆœ์œ„ + ์ ์ˆ˜)
*/
public record RankingItem(
Long rank,
Long productId,
Double score,
String productName,
Long brandId,
Integer price
) {
public static RankingItem of(Long rank, Long productId, Double score, Product product) {
return new RankingItem(
rank,
productId,
score,
product.getName(),
product.getBrandId(),
product.getPrice().amount()
);
}
}

/**
* ์ƒํ’ˆ ๋žญํ‚น ์ •๋ณด (์ƒ์„ธ ์กฐํšŒ์šฉ)
*/
public record ProductRankingInfo(
Long rank,
Double score
) {
public static ProductRankingInfo of(Long rank, Double score) {
return new ProductRankingInfo(rank, score);
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ private Pageable normalizePageable(Pageable pageable) {
@RequestMapping(method = RequestMethod.GET, path = "/{productId}")
@Override
public ApiResponse<ProductV1Dto.ProductResponse> getProductDetail(@PathVariable Long productId) {
ProductInfo info = productFacade.getProductDetail(productId);
ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info);
ProductFacade.ProductInfoWithRanking infoWithRanking = productFacade.getProductDetailWithRanking(productId);
ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(
infoWithRanking.productInfo(),
ProductV1Dto.RankingInfo.from(infoWithRanking.rankingInfo())
);
return ApiResponse.success(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,39 @@ public record ProductResponse(
String brand,
int price,
int likes,
int stock
int stock,
RankingInfo rankingInfo
) {
public static ProductResponse from(ProductInfo info) {
public static ProductResponse from(ProductInfo info, RankingInfo rankingInfo) {
return new ProductResponse(
info.id(),
info.name(),
info.brand(),
info.price(),
info.likes(),
info.stock()
info.stock(),
rankingInfo
);
}

public static ProductResponse from(ProductInfo info) {
return from(info, null);
}
}

/**
* ์ƒํ’ˆ ๋žญํ‚น ์ •๋ณด
*/
public record RankingInfo(
Long rank,
Double score
) {
public static RankingInfo from(com.loopers.application.ranking.RankingInfo.ProductRankingInfo info) {
if (info == null) {
return null;
}
return new RankingInfo(info.rank(), info.score());
}
}

public record ProductsPageResponse(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.loopers.interfaces.api.ranking;

import com.loopers.interfaces.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;

@Tag(name = "Ranking V1 API", description = "๋žญํ‚น API ์ž…๋‹ˆ๋‹ค.")
public interface RankingV1ApiSpec {

@Operation(
method = "GET",
summary = "๋žญํ‚น ํŽ˜์ด์ง€ ์กฐํšŒ",
description = "์ผ๊ฐ„ ๋žญํ‚น์„ ํŽ˜์ด์ง€๋„ค์ด์…˜์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค."
)
ApiResponse<RankingV1Dto.RankingsPageResponse> getRankings(
@Parameter(description = "๋‚ ์งœ (yyyyMMdd ํ˜•์‹, ๋ฏธ์ง€์ • ์‹œ ์˜ค๋Š˜ ๋‚ ์งœ)", example = "20241219")
@Schema(description = "๋‚ ์งœ (yyyyMMdd ํ˜•์‹, ๋ฏธ์ง€์ • ์‹œ ์˜ค๋Š˜ ๋‚ ์งœ)", example = "20241219")
String date,
@Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ", example = "20")
@Schema(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ", example = "20")
int size,
@Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)", example = "0")
@Schema(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (0๋ถ€ํ„ฐ ์‹œ์ž‘)", example = "0")
int page
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.loopers.interfaces.api.ranking;

import com.loopers.application.ranking.RankingFacade;
import com.loopers.application.ranking.RankingInfo;
import com.loopers.interfaces.api.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/rankings")
public class RankingV1Controller implements RankingV1ApiSpec {

private final RankingFacade rankingFacade;

@GetMapping
@Override
public ApiResponse<RankingV1Dto.RankingsPageResponse> getRankings(
@RequestParam(required = false) String date,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "0") int page
) {
String rankingDate = date != null ? date :
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));

Pageable pageable = PageRequest.of(page, size);
RankingInfo.RankingsPageResponse response =
rankingFacade.getRankings(rankingDate, pageable);

return ApiResponse.success(RankingV1Dto.RankingsPageResponse.from(response));
}
Comment on lines +26 to +39
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸก Minor

์ž…๋ ฅ ํŒŒ๋ผ๋ฏธํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ํ•„์š”

size ํŒŒ๋ผ๋ฏธํ„ฐ์— ์ƒํ•œ์ด ์—†์–ด ๋งค์šฐ ํฐ ๊ฐ’(์˜ˆ: 1000000) ์š”์ฒญ ์‹œ Redis์™€ DB์— ๋ถ€ํ•˜๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ date ํŒŒ๋ผ๋ฏธํ„ฐ์˜ ํ˜•์‹(yyyyMMdd) ๊ฒ€์ฆ์ด ์—†์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+
 @GetMapping
 @Override
 public ApiResponse<RankingV1Dto.RankingsPageResponse> getRankings(
-        @RequestParam(required = false) String date,
-        @RequestParam(defaultValue = "20") int size,
-        @RequestParam(defaultValue = "0") int page
+        @RequestParam(required = false) 
+        @Pattern(regexp = "^\\d{8}$", message = "๋‚ ์งœ ํ˜•์‹์€ yyyyMMdd์ž…๋‹ˆ๋‹ค") String date,
+        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
+        @RequestParam(defaultValue = "0") @Min(0) int page
 ) {
๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
around lines 26 to 39, add validation for the incoming request parameters:
enforce a sensible bounds check on size (e.g., clamp or reject values below 1
and above a configured MAX_PAGE_SIZE such as 100 or 1000) and for page ensure
non-negative; validate the date parameter by attempting to parse it with
DateTimeFormatter.ofPattern("yyyyMMdd") and if parsing fails return a 400 Bad
Request (or fall back to LocalDate.now() only when date is null), and reject or
sanitize excessively large page requests before calling rankingFacade to prevent
DB/Redis load. Ensure error responses use ApiResponse.error or appropriate HTTP
status and include a clear message about which parameter failed validation.

}

Loading