Skip to content

Commit 78ead14

Browse files
authored
Merge pull request #47 from hyujikoh/feat/batch
배치 서비스 구현
2 parents 90c84c6 + e5128b7 commit 78ead14

File tree

76 files changed

+4017
-268
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+4017
-268
lines changed

.github/workflows/main.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: PR Agent
2+
on:
3+
pull_request:
4+
types: [opened, synchronize]
5+
jobs:
6+
pr_agent_job:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- name: PR Agent action step
10+
uses: Codium-ai/pr-agent@main
11+
env:
12+
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
13+
GITHUB_TOKEN: ${{ secrets.G_TOKEN }}

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ docker-compose -f ./docker/monitoring-compose.yml up
2727
Root
2828
├── apps ( spring-applications )
2929
│ ├── 📦 commerce-api
30+
│ ├── 📦 commerce-batch
3031
│ └── 📦 commerce-streamer
3132
├── modules ( reusable-configurations )
3233
│ ├── 📦 jpa

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Lon
6262
return of(product, brand, likeCount, isLiked, null);
6363
}
6464

65-
public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked, RankingItem ranking) {
65+
public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked,
66+
RankingItem ranking) {
6667
if (product == null) {
6768
throw new IllegalArgumentException("상품 정보는 필수입니다.");
6869
}

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

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import org.springframework.stereotype.Component;
1414
import org.springframework.transaction.annotation.Transactional;
1515

16+
import com.loopers.application.ranking.MonthlyRankingService;
17+
import com.loopers.application.ranking.WeeklyRankingService;
1618
import com.loopers.cache.CacheStrategy;
1719
import com.loopers.cache.RankingRedisService;
1820
import com.loopers.cache.dto.CachePayloads.RankingItem;
@@ -21,6 +23,9 @@
2123
import com.loopers.domain.like.LikeService;
2224
import com.loopers.domain.product.*;
2325
import com.loopers.domain.product.dto.ProductSearchFilter;
26+
import com.loopers.domain.ranking.MonthlyRankEntity;
27+
import com.loopers.domain.ranking.RankingPeriod;
28+
import com.loopers.domain.ranking.WeeklyRankEntity;
2429
import com.loopers.domain.tracking.UserBehaviorTracker;
2530
import com.loopers.domain.user.UserService;
2631

@@ -46,6 +51,8 @@ public class ProductFacade {
4651
private final BrandService brandService;
4752
private final UserBehaviorTracker behaviorTracker;
4853
private final RankingRedisService rankingRedisService;
54+
private final WeeklyRankingService weeklyRankingService;
55+
private final MonthlyRankingService monthlyRankingService;
4956

5057
/**
5158
* 도메인 서비스에서 MV 엔티티를 조회하고, Facade에서 DTO로 변환합니다.
@@ -70,7 +77,7 @@ public Page<ProductInfo> getProducts(ProductSearchFilter productSearchFilter) {
7077
* 도메인 서비스에서 엔티티를 조회하고, Facade에서 DTO로 변환합니다.
7178
*
7279
* @param productId 상품 ID
73-
* @param username 사용자 ID (nullable)
80+
* @param username 사용자 ID (nullable)
7481
* @return 상품 상세 정보
7582
*/
7683
@Transactional(readOnly = true)
@@ -129,25 +136,25 @@ public ProductDetailInfo getProductDetail(Long productId, String username) {
129136
@Transactional(readOnly = true)
130137
public Page<ProductInfo> getRankingProducts(Pageable pageable, LocalDate date) {
131138
LocalDate targetDate = date != null ? date : LocalDate.now();
132-
139+
133140
// 1. 랭킹 조회
134141
List<RankingItem> rankings = rankingRedisService.getRanking(
135142
targetDate,
136-
pageable.getPageNumber() + 1,
143+
pageable.getPageNumber() + 1,
137144
pageable.getPageSize()
138145
);
139146

140147
// 2. 콜드 스타트 Fallback: 오늘 랭킹이 비어있으면 어제 랭킹 조회
141148
if (rankings.isEmpty() && date == null) {
142149
LocalDate yesterday = targetDate.minusDays(1);
143150
log.info("콜드 스타트 Fallback: 오늘({}) 랭킹 없음, 어제({}) 랭킹 조회", targetDate, yesterday);
144-
151+
145152
rankings = rankingRedisService.getRanking(
146153
yesterday,
147154
pageable.getPageNumber() + 1,
148155
pageable.getPageSize()
149156
);
150-
157+
151158
if (!rankings.isEmpty()) {
152159
targetDate = yesterday; // totalCount 계산을 위해 날짜 변경
153160
}
@@ -181,6 +188,119 @@ public Page<ProductInfo> getRankingProducts(Pageable pageable, LocalDate date) {
181188
return new PageImpl<>(sortedProducts, pageable, totalCount);
182189
}
183190

191+
/**
192+
* 기간별 랭킹 상품 목록 조회
193+
*
194+
* @param period 랭킹 기간 (DAILY, WEEKLY, MONTHLY)
195+
* @param pageable 페이징 정보
196+
* @param date 조회 날짜 (DAILY용, null이면 오늘)
197+
* @param yearWeek 조회 주차 (WEEKLY용, 예: "2024-W52")
198+
* @param yearMonth 조회 월 (MONTHLY용, 예: "2024-12")
199+
* @return 랭킹 상품 목록
200+
*/
201+
@Transactional(readOnly = true)
202+
public Page<ProductInfo> getRankingProductsByPeriod(
203+
RankingPeriod period,
204+
Pageable pageable,
205+
LocalDate date,
206+
String yearWeek,
207+
String yearMonth) {
208+
209+
return switch (period) {
210+
case DAILY -> getRankingProducts(pageable, date);
211+
case WEEKLY -> getWeeklyRankingProducts(pageable, yearWeek);
212+
case MONTHLY -> getMonthlyRankingProducts(pageable, yearMonth);
213+
};
214+
}
215+
216+
/**
217+
* 주간 랭킹 상품 목록 조회
218+
*
219+
* @param pageable 페이징 정보
220+
* @param yearWeek 조회 주차 (예: "2024-W52")
221+
* @return 주간 랭킹 상품 목록
222+
*/
223+
@Transactional(readOnly = true)
224+
public Page<ProductInfo> getWeeklyRankingProducts(Pageable pageable, String yearWeek) {
225+
if (yearWeek == null || yearWeek.trim().isEmpty()) {
226+
log.warn("주간 랭킹 조회 시 yearWeek 파라미터가 필요합니다");
227+
return Page.empty(pageable);
228+
}
229+
230+
// 1. 주간 랭킹 조회
231+
Page<WeeklyRankEntity> weeklyRankings = weeklyRankingService.getWeeklyRanking(yearWeek, pageable);
232+
233+
if (weeklyRankings.isEmpty()) {
234+
log.debug("주간 랭킹 데이터 없음: yearWeek={}", yearWeek);
235+
return Page.empty(pageable);
236+
}
237+
238+
// 2. 상품 ID 목록 추출
239+
List<Long> productIds = weeklyRankings.getContent().stream()
240+
.map(WeeklyRankEntity::getProductId)
241+
.collect(Collectors.toList());
242+
243+
// 3. 상품 정보 조회 (MV 사용)
244+
List<ProductMaterializedViewEntity> products = mvService.getByIds(productIds);
245+
246+
// 4. 랭킹 순서대로 정렬
247+
List<ProductInfo> sortedProducts = productIds.stream()
248+
.map(productId -> products.stream()
249+
.filter(p -> p.getProductId().equals(productId))
250+
.findFirst()
251+
.map(ProductInfo::from)
252+
.orElse(null))
253+
.filter(Objects::nonNull)
254+
.collect(Collectors.toList());
255+
256+
// 5. Page 객체 생성
257+
return new PageImpl<>(sortedProducts, pageable, weeklyRankings.getTotalElements());
258+
}
259+
260+
/**
261+
* 월간 랭킹 상품 목록 조회
262+
*
263+
* @param pageable 페이징 정보
264+
* @param yearMonth 조회 월 (예: "2024-12")
265+
* @return 월간 랭킹 상품 목록
266+
*/
267+
@Transactional(readOnly = true)
268+
public Page<ProductInfo> getMonthlyRankingProducts(Pageable pageable, String yearMonth) {
269+
if (yearMonth == null || yearMonth.trim().isEmpty()) {
270+
log.warn("월간 랭킹 조회 시 yearMonth 파라미터가 필요합니다");
271+
return Page.empty(pageable);
272+
}
273+
274+
// 1. 월간 랭킹 조회
275+
Page<MonthlyRankEntity> monthlyRankings = monthlyRankingService.getMonthlyRanking(yearMonth, pageable);
276+
277+
if (monthlyRankings.isEmpty()) {
278+
log.debug("월간 랭킹 데이터 없음: yearMonth={}", yearMonth);
279+
return Page.empty(pageable);
280+
}
281+
282+
// 2. 상품 ID 목록 추출
283+
List<Long> productIds = monthlyRankings.getContent().stream()
284+
.map(MonthlyRankEntity::getProductId)
285+
.collect(Collectors.toList());
286+
287+
// 3. 상품 정보 조회 (MV 사용)
288+
List<ProductMaterializedViewEntity> products = mvService.getByIds(productIds);
289+
290+
// 4. 랭킹 순서대로 정렬
291+
List<ProductInfo> sortedProducts = productIds.stream()
292+
.map(productId -> products.stream()
293+
.filter(p -> p.getProductId().equals(productId))
294+
.findFirst()
295+
.map(ProductInfo::from)
296+
.orElse(null))
297+
.filter(Objects::nonNull)
298+
.collect(Collectors.toList());
299+
300+
// 5. Page 객체 생성
301+
return new PageImpl<>(sortedProducts, pageable, monthlyRankings.getTotalElements());
302+
}
303+
184304
/**
185305
* 상품을 삭제합니다.
186306
* <p>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.loopers.application.ranking;
2+
3+
import java.util.List;
4+
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.PageImpl;
7+
import org.springframework.data.domain.Pageable;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
import com.loopers.domain.ranking.MonthlyRankEntity;
12+
import com.loopers.domain.ranking.MonthlyRankRepository;
13+
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
17+
/**
18+
* 월간 랭킹 조회 서비스
19+
*/
20+
@Service
21+
@RequiredArgsConstructor
22+
@Slf4j
23+
@Transactional(readOnly = true)
24+
public class MonthlyRankingService {
25+
26+
private final MonthlyRankRepository monthlyRankRepository;
27+
28+
/**
29+
* 특정 월의 랭킹을 페이지네이션하여 조회합니다.
30+
*
31+
* @param yearMonth 조회할 월 (예: "2024-12")
32+
* @param pageable 페이징 정보
33+
* @return 월간 랭킹 페이지
34+
*/
35+
public Page<MonthlyRankEntity> getMonthlyRanking(String yearMonth, Pageable pageable) {
36+
log.debug("월간 랭킹 조회: yearMonth={}, page={}, size={}",
37+
yearMonth, pageable.getPageNumber(), pageable.getPageSize());
38+
39+
// 1. 전체 랭킹 조회 (순위 순으로 정렬됨)
40+
List<MonthlyRankEntity> allRankings = monthlyRankRepository.findByYearMonth(yearMonth);
41+
42+
if (allRankings.isEmpty()) {
43+
log.debug("월간 랭킹 데이터 없음: yearMonth={}", yearMonth);
44+
return Page.empty(pageable);
45+
}
46+
47+
// 2. 페이징 처리
48+
int start = (int) pageable.getOffset();
49+
int end = Math.min(start + pageable.getPageSize(), allRankings.size());
50+
51+
if (start >= allRankings.size()) {
52+
return Page.empty(pageable);
53+
}
54+
55+
List<MonthlyRankEntity> pagedRankings = allRankings.subList(start, end);
56+
57+
log.debug("월간 랭킹 조회 완료: yearMonth={}, 전체={}, 페이지={}",
58+
yearMonth, allRankings.size(), pagedRankings.size());
59+
60+
return new PageImpl<>(pagedRankings, pageable, allRankings.size());
61+
}
62+
63+
/**
64+
* 특정 월의 전체 랭킹 개수를 조회합니다.
65+
*
66+
* @param yearMonth 조회할 월
67+
* @return 랭킹 개수
68+
*/
69+
public long getMonthlyRankingCount(String yearMonth) {
70+
List<MonthlyRankEntity> rankings = monthlyRankRepository.findByYearMonth(yearMonth);
71+
return rankings.size();
72+
}
73+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.loopers.application.ranking;
2+
3+
import java.util.List;
4+
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.PageImpl;
7+
import org.springframework.data.domain.Pageable;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
import com.loopers.domain.ranking.WeeklyRankEntity;
12+
import com.loopers.domain.ranking.WeeklyRankRepository;
13+
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
17+
/**
18+
* 주간 랭킹 조회 서비스
19+
*/
20+
@Service
21+
@RequiredArgsConstructor
22+
@Slf4j
23+
@Transactional(readOnly = true)
24+
public class WeeklyRankingService {
25+
26+
private final WeeklyRankRepository weeklyRankRepository;
27+
28+
/**
29+
* 특정 주차의 랭킹을 페이지네이션하여 조회합니다.
30+
*
31+
* @param yearWeek 조회할 주차 (예: "2024-W52")
32+
* @param pageable 페이징 정보
33+
* @return 주간 랭킹 페이지
34+
*/
35+
public Page<WeeklyRankEntity> getWeeklyRanking(String yearWeek, Pageable pageable) {
36+
log.debug("주간 랭킹 조회: yearWeek={}, page={}, size={}",
37+
yearWeek, pageable.getPageNumber(), pageable.getPageSize());
38+
39+
// 1. 전체 랭킹 조회 (순위 순으로 정렬됨)
40+
List<WeeklyRankEntity> allRankings = weeklyRankRepository.findByYearWeek(yearWeek);
41+
42+
if (allRankings.isEmpty()) {
43+
log.debug("주간 랭킹 데이터 없음: yearWeek={}", yearWeek);
44+
return Page.empty(pageable);
45+
}
46+
47+
// 2. 페이징 처리
48+
int start = (int) pageable.getOffset();
49+
int end = Math.min(start + pageable.getPageSize(), allRankings.size());
50+
51+
if (start >= allRankings.size()) {
52+
return Page.empty(pageable);
53+
}
54+
55+
List<WeeklyRankEntity> pagedRankings = allRankings.subList(start, end);
56+
57+
log.debug("주간 랭킹 조회 완료: yearWeek={}, 전체={}, 페이지={}",
58+
yearWeek, allRankings.size(), pagedRankings.size());
59+
60+
return new PageImpl<>(pagedRankings, pageable, allRankings.size());
61+
}
62+
63+
/**
64+
* 특정 주차의 전체 랭킹 개수를 조회합니다.
65+
*
66+
* @param yearWeek 조회할 주차
67+
* @return 랭킹 개수
68+
*/
69+
public long getWeeklyRankingCount(String yearWeek) {
70+
List<WeeklyRankEntity> rankings = weeklyRankRepository.findByYearWeek(yearWeek);
71+
return rankings.size();
72+
}
73+
}

apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import java.util.Objects;
55

66
import com.loopers.domain.BaseEntity;
7-
import com.loopers.domain.product.ProductEntity;
87
import com.loopers.domain.user.UserEntity;
98

109
import lombok.AccessLevel;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.loopers.domain.ranking;
2+
3+
/**
4+
* 랭킹 조회 기간 타입
5+
*/
6+
public enum RankingPeriod {
7+
/**
8+
* 일간 랭킹 (Redis ZSET 기반)
9+
*/
10+
DAILY,
11+
12+
/**
13+
* 주간 랭킹 (mv_product_rank_weekly 기반)
14+
*/
15+
WEEKLY,
16+
17+
/**
18+
* 월간 랭킹 (mv_product_rank_monthly 기반)
19+
*/
20+
MONTHLY
21+
}

0 commit comments

Comments
 (0)