Skip to content

Commit dcab5ea

Browse files
authored
Merge pull request #18 from Kimjipang/round09
Round09
2 parents 73c0244 + ccc015e commit dcab5ea

16 files changed

Lines changed: 460 additions & 10 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import org.springframework.transaction.annotation.Transactional;
1818

1919
import java.math.BigDecimal;
20+
import java.time.LocalDate;
21+
import java.time.format.DateTimeFormatter;
2022
import java.util.List;
2123

2224
@Component
@@ -25,6 +27,7 @@ public class ProductFacade {
2527
private final ProductRepository productRepository;
2628
private final BrandRepository brandRepository;
2729
private final OutboxRepository outBoxRepository;
30+
private final RankingRedisReader rankingRedisReader;
2831

2932
@Transactional
3033
public ProductInfo registerProduct(ProductV1Dto.ProductRequest request) {
@@ -52,7 +55,7 @@ public List<ProductInfo> findAllProducts() {
5255

5356
@Transactional
5457
@Cacheable(value = "product", key = "#id")
55-
public ProductInfo findProductById(Long id) {
58+
public ProductRankingInfo findProductById(Long id) {
5659
Product product = productRepository.findById(id).orElseThrow(
5760
() -> new CoreException(ErrorType.NOT_FOUND, "찾고자 하는 상품이 존재하지 않습니다.")
5861
);
@@ -65,7 +68,14 @@ public ProductInfo findProductById(Long id) {
6568

6669
outBoxRepository.save(outBoxEvent);
6770

68-
return ProductInfo.from(product);
71+
RankingInfo ranking = null;
72+
73+
try {
74+
String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
75+
ranking = rankingRedisReader.getDailyRanking(date, product.getId());
76+
} catch (Exception ignored) {}
77+
78+
return ProductRankingInfo.from(product, ranking);
6979
}
7080

7181
@Transactional(readOnly = true)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.loopers.application.product;
2+
3+
import com.loopers.domain.product.Product;
4+
5+
import java.math.BigDecimal;
6+
7+
public record ProductRankingInfo(Long id, Long brandId, String name, BigDecimal price, int stock, int likeCount, int rank, double score) {
8+
public static ProductRankingInfo from(Product product, RankingInfo ranking) {
9+
return new ProductRankingInfo(
10+
product.getId(),
11+
product.getBrandId(),
12+
product.getName(),
13+
product.getPrice(),
14+
product.getStock(),
15+
product.getLikeCount(),
16+
ranking.rank(),
17+
ranking.score()
18+
);
19+
}
20+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.loopers.application.product;
2+
3+
public record RankingInfo(
4+
String date,
5+
double score,
6+
int rank,
7+
Long total
8+
) {
9+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.loopers.application.product;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.data.redis.core.RedisCallback;
5+
import org.springframework.data.redis.core.StringRedisTemplate;
6+
import org.springframework.data.redis.serializer.RedisSerializer;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.util.List;
10+
11+
@Component
12+
@RequiredArgsConstructor
13+
public class RankingRedisReader {
14+
private final StringRedisTemplate redisTemplate;
15+
16+
public RankingInfo getDailyRanking(String date, Long productId) {
17+
String key = "ranking:all:" + date;
18+
String member = String.valueOf(productId);
19+
20+
RedisSerializer<String> serializer = redisTemplate.getStringSerializer();
21+
byte[] keyBytes = serializer.serialize(key);
22+
byte[] memberBytes = serializer.serialize(member);
23+
24+
@SuppressWarnings("unchecked")
25+
List<Object> results = (List<Object>) redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
26+
connection.zScore(keyBytes, memberBytes); // -> Double (or null)
27+
connection.zRevRank(keyBytes, memberBytes); // -> Long (or null) 0-base
28+
connection.zCard(keyBytes); // -> Long
29+
return null;
30+
});
31+
32+
Double score = (Double) results.get(0);
33+
Long revRank0 = (Long) results.get(1);
34+
Long total = (Long) results.get(2);
35+
36+
Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1);
37+
38+
return new RankingInfo(date, score, rank, total);
39+
}
40+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public interface ProductV1ApiSpec {
1515
ApiResponse<List<ProductV1Dto.ProductResponse>> findAllProducts();
1616

1717
@Operation(summary = "상품 상세 조회")
18-
ApiResponse<ProductV1Dto.ProductResponse> findProductById(Long id);
18+
ApiResponse<ProductV1Dto.ProductRankingResponse> findProductById(Long id);
1919

2020
@Operation(summary = "상품 정렬 조회")
2121
ApiResponse<List<ProductV1Dto.ProductResponse>> findProductsBySortCondition(ProductV1Dto.SearchProductRequest request);

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.loopers.application.product.ProductFacade;
44
import com.loopers.application.product.ProductInfo;
5+
import com.loopers.application.product.ProductRankingInfo;
56
import com.loopers.interfaces.api.ApiResponse;
67
import lombok.RequiredArgsConstructor;
78
import org.springframework.web.bind.annotation.GetMapping;
@@ -42,9 +43,9 @@ public ApiResponse<List<ProductV1Dto.ProductResponse>> findAllProducts() {
4243

4344
@GetMapping("/{id}")
4445
@Override
45-
public ApiResponse<ProductV1Dto.ProductResponse> findProductById(@PathVariable Long id) {
46-
ProductInfo info = productFacade.findProductById(id);
47-
ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info);
46+
public ApiResponse<ProductV1Dto.ProductRankingResponse> findProductById(@PathVariable Long id) {
47+
ProductRankingInfo info = productFacade.findProductById(id);
48+
ProductV1Dto.ProductRankingResponse response = ProductV1Dto.ProductRankingResponse.from(info);
4849

4950
return ApiResponse.success(response);
5051
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
package com.loopers.interfaces.api.product;
22

33
import com.loopers.application.product.ProductInfo;
4+
import com.loopers.application.product.ProductRankingInfo;
45
import com.loopers.domain.product.Product;
56
import com.loopers.support.error.CoreException;
67
import com.loopers.support.error.ErrorType;
78

89
import java.math.BigDecimal;
910

1011
public class ProductV1Dto {
12+
public record ProductRankingResponse(Long id, Long brandId, String name, BigDecimal price, int stock, int rank, double score) {
13+
public static ProductRankingResponse from(ProductRankingInfo info) {
14+
return new ProductRankingResponse(
15+
info.id(),
16+
info.brandId(),
17+
info.name(),
18+
info.price(),
19+
info.stock(),
20+
info.rank(),
21+
info.score()
22+
);
23+
}
24+
}
1125
public record ProductResponse(Long id, Long brandId, String name, BigDecimal price, int stock, int likeCount) {
1226
public static ProductResponse from(ProductInfo info) {
1327
return new ProductResponse(
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.loopers.application;
2+
3+
import com.loopers.interfaces.api.ranking.RankingV1Dto;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.data.redis.core.StringRedisTemplate;
6+
import org.springframework.data.redis.core.ZSetOperations;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.time.LocalDate;
10+
import java.time.ZoneId;
11+
import java.time.format.DateTimeFormatter;
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
import java.util.Set;
15+
16+
@Component
17+
@RequiredArgsConstructor
18+
public class RankingFacade {
19+
private final StringRedisTemplate redisTemplate;
20+
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
21+
private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.BASIC_ISO_DATE;
22+
23+
public RankingV1Dto.ProductRankingPageResponse getDailyProductRanking(int page, int size) {
24+
if (page < 1) page = 1;
25+
if (size < 1) size = 20;
26+
27+
String date = LocalDate.now(KST).format(YYYYMMDD);
28+
29+
String key = "ranking:all:" + date;
30+
ZSetOperations<String, String> zset = redisTemplate.opsForZSet();
31+
32+
Long total = zset.size(key);
33+
long totalElements = (total == null) ? 0 : total;
34+
35+
if (totalElements == 0) {
36+
return new RankingV1Dto.ProductRankingPageResponse(date, page, size, 0, 0, List.of());
37+
}
38+
39+
long start = (long) (page - 1) * size;
40+
long end = start + size - 1;
41+
42+
if (start >= totalElements) {
43+
int totalPages = (int) Math.ceil((double) totalElements / size);
44+
return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, List.of());
45+
}
46+
47+
Set<ZSetOperations.TypedTuple<String>> tuples =
48+
zset.reverseRangeWithScores(key, start, end);
49+
50+
List<RankingV1Dto.ProductRankingResponse> items = new ArrayList<>();
51+
if (tuples != null) {
52+
long rank = start + 1;
53+
for (var t : tuples) {
54+
String member = t.getValue();
55+
Double score = t.getScore();
56+
if (member == null || score == null) continue;
57+
58+
items.add(new RankingV1Dto.ProductRankingResponse(
59+
rank++,
60+
Long.parseLong(member),
61+
score
62+
));
63+
}
64+
}
65+
66+
int totalPages = (int) Math.ceil((double) totalElements / size);
67+
return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, items);
68+
}
69+
70+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.loopers.interfaces.api;
2+
3+
import com.fasterxml.jackson.databind.JsonMappingException;
4+
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
5+
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
6+
import com.loopers.support.error.CoreException;
7+
import com.loopers.support.error.ErrorType;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.http.converter.HttpMessageNotReadableException;
11+
import org.springframework.web.bind.MissingServletRequestParameterException;
12+
import org.springframework.web.bind.annotation.ExceptionHandler;
13+
import org.springframework.web.bind.annotation.RestControllerAdvice;
14+
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
15+
import org.springframework.web.server.ServerWebInputException;
16+
import org.springframework.web.servlet.resource.NoResourceFoundException;
17+
18+
import java.util.Arrays;
19+
import java.util.regex.Matcher;
20+
import java.util.regex.Pattern;
21+
import java.util.stream.Collectors;
22+
23+
@RestControllerAdvice
24+
@Slf4j
25+
public class ApiControllerAdvice {
26+
@ExceptionHandler
27+
public ResponseEntity<ApiResponse<?>> handle(CoreException e) {
28+
log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e);
29+
return failureResponse(e.getErrorType(), e.getCustomMessage());
30+
}
31+
32+
@ExceptionHandler
33+
public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentTypeMismatchException e) {
34+
String name = e.getName();
35+
String type = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "unknown";
36+
String value = e.getValue() != null ? e.getValue().toString() : "null";
37+
String message = String.format("요청 파라미터 '%s' (타입: %s)의 값 '%s'이(가) 잘못되었습니다.", name, type, value);
38+
return failureResponse(ErrorType.BAD_REQUEST, message);
39+
}
40+
41+
@ExceptionHandler
42+
public ResponseEntity<ApiResponse<?>> handleBadRequest(MissingServletRequestParameterException e) {
43+
String name = e.getParameterName();
44+
String type = e.getParameterType();
45+
String message = String.format("필수 요청 파라미터 '%s' (타입: %s)가 누락되었습니다.", name, type);
46+
return failureResponse(ErrorType.BAD_REQUEST, message);
47+
}
48+
49+
@ExceptionHandler
50+
public ResponseEntity<ApiResponse<?>> handleBadRequest(HttpMessageNotReadableException e) {
51+
String errorMessage;
52+
Throwable rootCause = e.getRootCause();
53+
54+
if (rootCause instanceof InvalidFormatException invalidFormat) {
55+
String fieldName = invalidFormat.getPath().stream()
56+
.map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
57+
.collect(Collectors.joining("."));
58+
59+
String valueIndicationMessage = "";
60+
if (invalidFormat.getTargetType().isEnum()) {
61+
Class<?> enumClass = invalidFormat.getTargetType();
62+
String enumValues = Arrays.stream(enumClass.getEnumConstants())
63+
.map(Object::toString)
64+
.collect(Collectors.joining(", "));
65+
valueIndicationMessage = "사용 가능한 값 : [" + enumValues + "]";
66+
}
67+
68+
String expectedType = invalidFormat.getTargetType().getSimpleName();
69+
Object value = invalidFormat.getValue();
70+
71+
errorMessage = String.format("필드 '%s'의 값 '%s'이(가) 예상 타입(%s)과 일치하지 않습니다. %s",
72+
fieldName, value, expectedType, valueIndicationMessage);
73+
74+
} else if (rootCause instanceof MismatchedInputException mismatchedInput) {
75+
String fieldPath = mismatchedInput.getPath().stream()
76+
.map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
77+
.collect(Collectors.joining("."));
78+
errorMessage = String.format("필수 필드 '%s'이(가) 누락되었습니다.", fieldPath);
79+
80+
} else if (rootCause instanceof JsonMappingException jsonMapping) {
81+
String fieldPath = jsonMapping.getPath().stream()
82+
.map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
83+
.collect(Collectors.joining("."));
84+
errorMessage = String.format("필드 '%s'에서 JSON 매핑 오류가 발생했습니다: %s",
85+
fieldPath, jsonMapping.getOriginalMessage());
86+
87+
} else {
88+
errorMessage = "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요.";
89+
}
90+
91+
return failureResponse(ErrorType.BAD_REQUEST, errorMessage);
92+
}
93+
94+
@ExceptionHandler
95+
public ResponseEntity<ApiResponse<?>> handleBadRequest(ServerWebInputException e) {
96+
String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : "");
97+
if (!missingParams.isEmpty()) {
98+
String message = String.format("필수 요청 값 '%s'가 누락되었습니다.", missingParams);
99+
return failureResponse(ErrorType.BAD_REQUEST, message);
100+
} else {
101+
return failureResponse(ErrorType.BAD_REQUEST, null);
102+
}
103+
}
104+
105+
@ExceptionHandler
106+
public ResponseEntity<ApiResponse<?>> handleNotFound(NoResourceFoundException e) {
107+
return failureResponse(ErrorType.NOT_FOUND, null);
108+
}
109+
110+
@ExceptionHandler
111+
public ResponseEntity<ApiResponse<?>> handle(Throwable e) {
112+
log.error("Exception : {}", e.getMessage(), e);
113+
return failureResponse(ErrorType.INTERNAL_ERROR, null);
114+
}
115+
116+
private String extractMissingParameter(String message) {
117+
Pattern pattern = Pattern.compile("'(.+?)'");
118+
Matcher matcher = pattern.matcher(message);
119+
return matcher.find() ? matcher.group(1) : "";
120+
}
121+
122+
private ResponseEntity<ApiResponse<?>> failureResponse(ErrorType errorType, String errorMessage) {
123+
return ResponseEntity.status(errorType.getStatus())
124+
.body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage()));
125+
}
126+
}

0 commit comments

Comments
 (0)