Skip to content

Commit 3cd37f4

Browse files
Merge pull request #211 from kilian-develop/main
[volume-9] Product Ranking with Redis
2 parents a3b1903 + 58de68d commit 3cd37f4

59 files changed

Lines changed: 1307 additions & 26 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.loopers.application.api.product;
2+
3+
import com.loopers.core.service.productlike.ProductLikeEventPublishService;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.scheduling.annotation.Scheduled;
6+
import org.springframework.stereotype.Component;
7+
8+
@Component
9+
@RequiredArgsConstructor
10+
public class ProductLikeEventScheduler {
11+
12+
private final ProductLikeEventPublishService service;
13+
14+
@Scheduled(fixedDelay = 1000)
15+
public void publish() {
16+
service.publish();
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.loopers.application.api.product;
2+
3+
import com.loopers.core.service.product.ProductRankingCarryOverService;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.scheduling.annotation.Scheduled;
6+
import org.springframework.stereotype.Component;
7+
8+
@Component
9+
@RequiredArgsConstructor
10+
public class ProductRankingCarryOverScheduler {
11+
12+
private final ProductRankingCarryOverService service;
13+
14+
@Scheduled(cron = "0 50 23 * * *")
15+
public void carryOver() {
16+
service.carryOver();
17+
}
18+
}

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,30 @@
44
import com.loopers.core.domain.product.Product;
55
import com.loopers.core.domain.product.ProductDetail;
66
import com.loopers.core.domain.product.ProductListView;
7+
import com.loopers.core.domain.product.ProductRankingList;
8+
import com.loopers.core.service.product.GetProductRankingService;
79
import com.loopers.core.service.product.ProductQueryService;
810
import com.loopers.core.service.product.query.GetProductDetailQuery;
911
import com.loopers.core.service.product.query.GetProductListQuery;
1012
import com.loopers.core.service.product.query.GetProductQuery;
13+
import com.loopers.core.service.product.query.GetProductRankingQuery;
1114
import lombok.RequiredArgsConstructor;
15+
import org.springframework.format.annotation.DateTimeFormat;
1216
import org.springframework.web.bind.annotation.*;
1317

18+
import java.time.LocalDate;
19+
1420
import static com.loopers.application.api.product.ProductV1Dto.*;
21+
import static com.loopers.application.api.product.ProductV1Dto.GetProductDetailResponse.GetProductRankingsResponse;
22+
import static com.loopers.application.api.product.ProductV1Dto.GetProductDetailResponse.from;
1523

1624
@RestController
1725
@RequiredArgsConstructor
1826
@RequestMapping("/api/v1/products")
1927
public class ProductV1Api implements ProductV1ApiSpec {
2028

2129
private final ProductQueryService queryService;
30+
private final GetProductRankingService getProductRankingService;
2231

2332
@Override
2433
@GetMapping("/{productId}")
@@ -50,6 +59,18 @@ public ApiResponse<GetProductDetailResponse> getProductDetail(
5059
) {
5160
ProductDetail productDetail = queryService.getProductDetail(new GetProductDetailQuery(productId));
5261

53-
return ApiResponse.success(GetProductDetailResponse.from(productDetail));
62+
return ApiResponse.success(from(productDetail));
63+
}
64+
65+
@Override
66+
@GetMapping("/rankings")
67+
public ApiResponse<GetProductRankingsResponse> getProductRankings(
68+
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date,
69+
@RequestParam(required = false, defaultValue = "0") int pageNo,
70+
@RequestParam(required = false, defaultValue = "10") int pageSize
71+
) {
72+
ProductRankingList ranking = getProductRankingService.getRanking(new GetProductRankingQuery(date, pageNo, pageSize));
73+
74+
return ApiResponse.success(GetProductRankingsResponse.from(ranking));
5475
}
5576
}

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,25 @@
44
import io.swagger.v3.oas.annotations.Operation;
55
import io.swagger.v3.oas.annotations.tags.Tag;
66

7+
import java.time.LocalDate;
8+
9+
import static com.loopers.application.api.product.ProductV1Dto.*;
10+
import static com.loopers.application.api.product.ProductV1Dto.GetProductDetailResponse.GetProductRankingsResponse;
11+
712
@Tag(name = "Product V1 API", description = "상품 API 입니다.")
813
public interface ProductV1ApiSpec {
914

1015
@Operation(
1116
summary = "상품 정보 조회",
1217
description = "상품 정보를 조회합니다."
1318
)
14-
ApiResponse<ProductV1Dto.GetProductResponse> getProduct(String productId);
19+
ApiResponse<GetProductResponse> getProduct(String productId);
1520

1621
@Operation(
1722
summary = "상품 목록 조회",
1823
description = "상품 목록을 조회합니다."
1924
)
20-
ApiResponse<ProductV1Dto.GetProductListResponse> getProductList(
25+
ApiResponse<GetProductListResponse> getProductList(
2126
String brandId,
2227
String createdAtSort,
2328
String priceSort,
@@ -30,5 +35,11 @@ ApiResponse<ProductV1Dto.GetProductListResponse> getProductList(
3035
summary = "상품 상세 조회",
3136
description = "상품 상세 정보를 조회합니다."
3237
)
33-
ApiResponse<ProductV1Dto.GetProductDetailResponse> getProductDetail(String productId);
38+
ApiResponse<GetProductDetailResponse> getProductDetail(String productId);
39+
40+
@Operation(
41+
summary = "상품 랭킹 조회",
42+
description = "상품 랭킹을 조회합니다."
43+
)
44+
ApiResponse<GetProductRankingsResponse> getProductRankings(LocalDate date, int pageNo, int pageSize);
3445
}

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

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package com.loopers.application.api.product;
22

33
import com.loopers.core.domain.brand.Brand;
4-
import com.loopers.core.domain.product.Product;
5-
import com.loopers.core.domain.product.ProductDetail;
6-
import com.loopers.core.domain.product.ProductListItem;
7-
import com.loopers.core.domain.product.ProductListView;
4+
import com.loopers.core.domain.product.*;
5+
import com.loopers.core.domain.product.ProductRankingList.ProductRankingItem;
6+
import com.loopers.core.domain.product.vo.ProductRanking;
87

98
import java.math.BigDecimal;
109
import java.util.List;
10+
import java.util.Optional;
1111

1212
public class ProductV1Dto {
1313

@@ -77,7 +77,8 @@ public record GetProductDetailResponse(
7777
String name,
7878
BigDecimal price,
7979
Long stock,
80-
Long likeCount
80+
Long likeCount,
81+
GetProductDetailRanking ranking
8182
) {
8283

8384
public static GetProductDetailResponse from(ProductDetail detail) {
@@ -87,10 +88,22 @@ public static GetProductDetailResponse from(ProductDetail detail) {
8788
detail.getProduct().getName().value(),
8889
detail.getProduct().getPrice().value(),
8990
detail.getProduct().getStock().value(),
90-
detail.getProduct().getLikeCount().value()
91+
detail.getProduct().getLikeCount().value(),
92+
Optional.ofNullable(detail.getRanking())
93+
.map(GetProductDetailRanking::from)
94+
.orElse(null)
9195
);
9296
}
9397

98+
public record GetProductDetailRanking(
99+
Long ranking,
100+
Double score
101+
) {
102+
public static GetProductDetailRanking from(ProductRanking ranking) {
103+
return new GetProductDetailRanking(ranking.ranking(), ranking.score());
104+
}
105+
}
106+
94107
public record GetProductDetailBrand(
95108
String id,
96109
String name,
@@ -105,5 +118,48 @@ public static GetProductDetailBrand from(Brand brand) {
105118
);
106119
}
107120
}
121+
122+
public record GetProductRankingsResponse(
123+
List<ProductRankingResponse> products,
124+
long totalElements,
125+
int totalPages,
126+
boolean hasNext,
127+
boolean hasPrevious
128+
) {
129+
130+
public static GetProductRankingsResponse from(ProductRankingList list) {
131+
return new GetProductRankingsResponse(
132+
list.products().stream()
133+
.map(ProductRankingResponse::from)
134+
.toList(),
135+
list.totalElements(),
136+
list.totalPages(),
137+
list.hasNext(),
138+
list.hasPrevious()
139+
);
140+
}
141+
142+
public record ProductRankingResponse(
143+
String id,
144+
Long ranking,
145+
String brandName,
146+
String name,
147+
BigDecimal price,
148+
Long likeCount,
149+
Double score
150+
) {
151+
public static ProductRankingResponse from(ProductRankingItem item) {
152+
return new ProductRankingResponse(
153+
item.id().value(),
154+
item.ranking(),
155+
item.brandName().value(),
156+
item.name().value(),
157+
item.price().value(),
158+
item.likeCount().value(),
159+
item.score()
160+
);
161+
}
162+
}
163+
}
108164
}
109165
}

apps/commerce-api/src/main/resources/application.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ spring:
3030
product-detail-viewed: product-detail-viewed.v1
3131
product-out-of-stock: product-out-of-stock.v1
3232
payment-completed: payment-completed.v1
33+
product-like: product-like.v1
34+
35+
product:
36+
ranking:
37+
score:
38+
weight:
39+
like: 0.2
40+
view: 0.1
41+
pay: 0.7
42+
carryOver: 0.1
3343

3444
springdoc:
3545
use-fqn: true

apps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.loopers.core.domain.brand.repository.BrandRepository;
88
import com.loopers.core.domain.product.Product;
99
import com.loopers.core.domain.product.ProductFixture;
10+
import com.loopers.core.domain.product.repository.ProductRankingCacheRepository;
1011
import com.loopers.core.domain.product.repository.ProductRepository;
1112
import org.junit.jupiter.api.BeforeEach;
1213
import org.junit.jupiter.api.DisplayName;
@@ -19,6 +20,9 @@
1920
import org.springframework.http.HttpStatus;
2021
import org.springframework.http.ResponseEntity;
2122

23+
import java.time.LocalDate;
24+
25+
import static com.loopers.application.api.product.ProductV1Dto.GetProductDetailResponse.GetProductRankingsResponse;
2226
import static com.loopers.application.api.product.ProductV1Dto.GetProductListResponse;
2327
import static com.loopers.application.api.product.ProductV1Dto.GetProductResponse;
2428
import static org.assertj.core.api.Assertions.assertThat;
@@ -31,6 +35,9 @@ class ProductV1ApiIntegrationTest extends ApiIntegrationTest {
3135
@Autowired
3236
private ProductRepository productRepository;
3337

38+
@Autowired
39+
private ProductRankingCacheRepository productRankingCacheRepository;
40+
3441
@Nested
3542
@DisplayName("상품 목록 조회")
3643
class 상품_목록_조회 {
@@ -136,4 +143,74 @@ void status404() {
136143
}
137144
}
138145
}
146+
147+
@Nested
148+
@DisplayName("상품 랭킹 조회")
149+
class 상품_랭킹_조회 {
150+
151+
@Nested
152+
@DisplayName("정상 요청인 경우")
153+
class 정상_요청인_경우 {
154+
155+
@BeforeEach
156+
void setUp() {
157+
Brand brand = brandRepository.save(
158+
BrandFixture.create()
159+
);
160+
161+
Product product1 = productRepository.save(
162+
ProductFixture.createWith(brand.getId())
163+
);
164+
productRankingCacheRepository.increaseDaily(product1.getId(), LocalDate.now(), 0.7);
165+
166+
Product product2 = productRepository.save(
167+
ProductFixture.createWith(brand.getId())
168+
);
169+
productRankingCacheRepository.increaseDaily(product2.getId(), LocalDate.now(), 0.9);
170+
}
171+
172+
@Test
173+
@DisplayName("Status 200")
174+
void status200() {
175+
// When
176+
LocalDate today = LocalDate.now();
177+
String endPoint = "/api/v1/products/rankings?date=" + today + "&pageNo=0&pageSize=10";
178+
179+
ParameterizedTypeReference<ApiResponse<GetProductRankingsResponse>> responseType =
180+
new ParameterizedTypeReference<>() {
181+
};
182+
183+
ResponseEntity<ApiResponse<GetProductRankingsResponse>> response =
184+
testRestTemplate.exchange(endPoint, HttpMethod.GET, HttpEntity.EMPTY, responseType);
185+
186+
// Then
187+
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
188+
assertThat(response.getBody()).isNotNull();
189+
assertThat(response.getBody().data()).isNotNull();
190+
assertThat(response.getBody().data().products()).isNotEmpty();
191+
}
192+
193+
@Test
194+
@DisplayName("페이지네이션 정보가 포함되어 있다")
195+
void pagination_information_included() {
196+
// When
197+
LocalDate today = LocalDate.now();
198+
String endPoint = "/api/v1/products/rankings?date=" + today + "&pageNo=0&pageSize=10";
199+
200+
ParameterizedTypeReference<ApiResponse<GetProductRankingsResponse>> responseType =
201+
new ParameterizedTypeReference<>() {
202+
};
203+
204+
ResponseEntity<ApiResponse<GetProductRankingsResponse>> response =
205+
testRestTemplate.exchange(endPoint, HttpMethod.GET, HttpEntity.EMPTY, responseType);
206+
207+
// Then
208+
GetProductRankingsResponse rankingResponse = response.getBody().data();
209+
assertThat(rankingResponse.totalElements()).isGreaterThanOrEqualTo(0);
210+
assertThat(rankingResponse.totalPages()).isGreaterThanOrEqualTo(0);
211+
assertThat(rankingResponse.hasNext()).isFalse();
212+
assertThat(rankingResponse.hasPrevious()).isFalse();
213+
}
214+
}
215+
}
139216
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.loopers.applications.streamer.consumer.product;
2+
3+
import com.loopers.JacksonUtil;
4+
import com.loopers.applications.streamer.consumer.product.dto.IncreaseProductLikeRankingScoreEvent;
5+
import com.loopers.core.infra.event.kafka.config.KafkaConfig;
6+
import com.loopers.core.service.productlike.IncreaseProductLikeRankingScoreService;
7+
import lombok.RequiredArgsConstructor;
8+
import org.apache.kafka.clients.consumer.ConsumerRecord;
9+
import org.springframework.kafka.annotation.KafkaListener;
10+
import org.springframework.kafka.support.Acknowledgment;
11+
import org.springframework.stereotype.Component;
12+
13+
import java.util.List;
14+
15+
@Component
16+
@RequiredArgsConstructor
17+
public class IncreaseProductLikeRankingScoreKafkaConsumer {
18+
19+
private final IncreaseProductLikeRankingScoreService service;
20+
21+
@KafkaListener(
22+
topics = {"${spring.kafka.topic.product-like}"},
23+
containerFactory = KafkaConfig.BATCH_LISTENER,
24+
groupId = "increase-product-like-ranking-score"
25+
)
26+
public void listen(
27+
List<ConsumerRecord<Object, String>> records,
28+
Acknowledgment acknowledgment
29+
) {
30+
records.stream()
31+
.map(event -> JacksonUtil.convertToObject(event.value(), IncreaseProductLikeRankingScoreEvent.class))
32+
.map(IncreaseProductLikeRankingScoreEvent::toCommand)
33+
.forEach(service::increase);
34+
acknowledgment.acknowledge();
35+
}
36+
}

0 commit comments

Comments
 (0)