Skip to content
Open
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
@@ -0,0 +1,44 @@
package com.loopers.application.ranking;

import com.loopers.domain.ranking.MonthlyRankingRepository;
import com.loopers.domain.ranking.RankingEntry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.Clock;
import java.time.LocalDate;
import java.util.List;

/**
* 월간 랭킹 Use Case Facade.
*
* WeeklyRankingFacade 와 처리 흐름이 동일하며, 조회 대상 저장소만 다르다.
* MV 테이블 조회 후 상품 가시성 필터링과 RankingItemInfo 조립을 RankingAssembler 에 위임한다.
*
* date 가 null 이면 KST 기준 어제 날짜를 기본값으로 사용한다.
* batch 가 targetDate - 1일을 base_date 로 적재하므로 최신 월간 랭킹의 기본 기준일은 어제다.
*/
@Component
@RequiredArgsConstructor
public class MonthlyRankingFacade {

private final MonthlyRankingRepository monthlyRankingRepository;
private final RankingAssembler rankingAssembler;
private final Clock clock;

/**
* 월간 랭킹 목록을 조회하고 상품 정보를 합산하여 반환한다.
*
* @param date 조회 기준일. null 이면 KST 어제 날짜를 사용한다.
* @param pageOneBased 1-based 페이지 번호
* @param size 페이지 크기
* @return 가시성 필터링 후 RankingItemInfo 목록과 페이지 메타데이터.
*/
public RankingPageResult getMonthlyRanking(LocalDate date, int pageOneBased, int size) {
LocalDate baseDate = date != null ? date : LocalDate.now(clock.withZone(RankingAssembler.KST)).minusDays(1);

long total = monthlyRankingRepository.getTotal(baseDate);
List<RankingEntry> entries = monthlyRankingRepository.getTopN(baseDate, pageOneBased, size);
return rankingAssembler.assemble(baseDate, total, entries);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.loopers.application.ranking;

import com.loopers.application.product.ProductFacade;
import com.loopers.application.product.ProductInfo;
import com.loopers.domain.ranking.RankingEntry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
* 랭킹 항목 조립기.
*
* 저장소에서 가져온 RankingEntry 목록에 상품 가시성 필터링을 적용하고
* RankingPageResult 로 조립한다. 일간/주간/월간 Facade 가 공통으로 사용한다.
*/
@Component
@RequiredArgsConstructor
public class RankingAssembler {

public static final ZoneId KST = ZoneId.of("Asia/Seoul");

private final ProductFacade productFacade;

/**
* RankingEntry 목록을 상품 가시성 필터링 후 RankingPageResult 로 조립한다.
*
* 삭제/숨김 상태인 상품은 결과에서 제외되므로 반환 items 수가 entries 수보다 작을 수 있다.
*/
public RankingPageResult assemble(LocalDate effectiveDate, long total, List<RankingEntry> entries) {
if (entries.isEmpty()) return new RankingPageResult(effectiveDate, total, List.of());

List<Long> productIds = entries.stream().map(RankingEntry::productId).toList();
Map<Long, ProductInfo> products = productFacade.findVisibleByIds(productIds);

List<RankingItemInfo> items = entries.stream()
.map(entry -> {
ProductInfo info = products.get(entry.productId());
return info == null ? null : RankingItemInfo.of(entry, info);
})
.filter(Objects::nonNull)
.toList();

return new RankingPageResult(effectiveDate, total, items);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.loopers.application.ranking;

import com.loopers.application.product.ProductFacade;
import com.loopers.application.product.ProductInfo;
import com.loopers.domain.ranking.RankingEntry;
import com.loopers.domain.ranking.RankingKey;
import com.loopers.domain.ranking.RankingRepository;
Expand All @@ -10,67 +8,38 @@

import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
* 랭킹 Use Case Facade.
*
* - {@link #getDailyRanking(LocalDate, int, int)} : 랭킹 Page 조회 + 상품 정보 Aggregation
* - {@link #getDailyRank(Long)} : 상품 상세의 `dailyRank` 합성용
* - {@link #getDailyTotal(LocalDate)} : 페이지네이션 totalElements 제공
*/
@Component
@RequiredArgsConstructor
public class RankingFacade {

public static final ZoneId KST = ZoneId.of("Asia/Seoul");

private final RankingRepository rankingRepository;
private final ProductFacade productFacade;
private final RankingAssembler rankingAssembler;
private final Clock clock;

/**
* KST 기준 일간 랭킹 상위 항목을 조회한다.
* 일간 랭킹을 조회하고 상품 정보를 합산하여 반환한다.
*
* 삭제/숨김 상태인 상품은 결과에서 제외되므로, 반환 size 가 요청 size 보다 작을 수 있다.
* date 가 null 이면 오늘 날짜로 처리한다.
* date 가 null 이면 KST 오늘 날짜로 처리한다.
*
* @param pageOneBased 사용자 노출 기준 페이지 번호 (1-based)
*/
public List<RankingItemInfo> getDailyRanking(LocalDate date, int pageOneBased, int size) {
if (date == null) date = LocalDate.now(clock.withZone(KST));
String key = RankingKey.daily(date);
public RankingPageResult getDailyRanking(LocalDate date, int pageOneBased, int size) {
LocalDate effectiveDate = date != null ? date : LocalDate.now(clock.withZone(RankingAssembler.KST));
String key = RankingKey.daily(effectiveDate);

long total = rankingRepository.getTotal(key);
List<RankingEntry> entries = rankingRepository.getTopN(key, pageOneBased, size);
if (entries.isEmpty()) return Collections.emptyList();

List<Long> productIds = entries.stream().map(RankingEntry::productId).toList();
Map<Long, ProductInfo> products = productFacade.findVisibleByIds(productIds);

List<RankingItemInfo> result = new ArrayList<>(entries.size());
for (RankingEntry entry : entries) {
ProductInfo info = products.get(entry.productId());
if (info == null) continue; // 삭제/숨김 상품 — 응답에서 제외 (size 축소 허용)
result.add(RankingItemInfo.of(entry, info));
}
return result;
}

public long getDailyTotal(LocalDate date) {
if (date == null) date = LocalDate.now(clock.withZone(KST));
return rankingRepository.getTotal(RankingKey.daily(date));
}

/**
* KST 기준 "오늘" 을 반환한다 — controller / 응답 조립이 동일 clock 을 사용하도록.
*/
public LocalDate today() {
return LocalDate.now(clock.withZone(KST));
return rankingAssembler.assemble(effectiveDate, total, entries);
}

/**
Expand All @@ -79,7 +48,7 @@ public LocalDate today() {
*/
public Long getDailyRank(Long productId) {
if (productId == null) return null;
LocalDate today = LocalDate.now(clock.withZone(KST));
LocalDate today = LocalDate.now(clock.withZone(RankingAssembler.KST));
return rankingRepository.getRank(RankingKey.daily(today), productId);
}

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

import java.time.LocalDate;
import java.util.List;

/**
* 랭킹 페이지 조회 결과 VO.
*
* Facade 가 items, total, effectiveDate 를 하나로 묶어 반환함으로써
* Controller 가 Facade 의 내부 날짜 계산 메서드를 직접 호출하지 않아도 된다.
*
* @param effectiveDate 실제 조회에 사용된 기준일 (요청 date 가 null 이면 Facade 기본값이 적용된다)
* @param total 해당 기준일의 전체 랭킹 엔트리 수
* @param items 가시성 필터링 후 상품 정보가 합산된 랭킹 목록
*/
public record RankingPageResult(
LocalDate effectiveDate,
long total,
List<RankingItemInfo> items
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.loopers.application.ranking;

import com.loopers.domain.ranking.RankingEntry;
import com.loopers.domain.ranking.WeeklyRankingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.Clock;
import java.time.LocalDate;
import java.util.List;

/**
* 주간 랭킹 Use Case Facade.
*
* MV 테이블 조회 후 상품 가시성 필터링과 RankingItemInfo 조립을 RankingAssembler 에 위임한다.
*
* date 가 null 이면 KST 기준 어제 날짜를 기본값으로 사용한다.
* batch 가 targetDate - 1일을 base_date 로 적재하므로 최신 주간 랭킹의 기본 기준일은 어제다.
*/
@Component
@RequiredArgsConstructor
public class WeeklyRankingFacade {

private final WeeklyRankingRepository weeklyRankingRepository;
private final RankingAssembler rankingAssembler;
private final Clock clock;

/**
* 주간 랭킹 목록을 조회하고 상품 정보를 합산하여 반환한다.
*
* @param date 조회 기준일. null 이면 KST 어제 날짜를 사용한다.
* @param pageOneBased 1-based 페이지 번호
* @param size 페이지 크기
* @return 가시성 필터링 후 RankingItemInfo 목록과 페이지 메타데이터.
*/
public RankingPageResult getWeeklyRanking(LocalDate date, int pageOneBased, int size) {
LocalDate baseDate = date != null ? date : LocalDate.now(clock.withZone(RankingAssembler.KST)).minusDays(1);

long total = weeklyRankingRepository.getTotal(baseDate);
List<RankingEntry> entries = weeklyRankingRepository.getTopN(baseDate, pageOneBased, size);
return rankingAssembler.assemble(baseDate, total, entries);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.loopers.domain.ranking;

import java.time.LocalDate;
import java.util.List;

/**
* 월간 MV 랭킹 읽기 전용 저장소 인터페이스 (DIP).
*
* commerce-batch 가 mv_product_rank_monthly 에 적재한 데이터를 조회한다.
* base_date = batch 실행일 - 1일 (어제 기준 직전 30일 집계 결과).
*/
public interface MonthlyRankingRepository {

/**
* 지정 baseDate 의 월간 TOP-N 을 반환한다. rank 는 1-based.
*
* @param baseDate 집계 기준일 (MV 테이블의 base_date)
* @param pageOneBased 사용자 노출 기준 페이지 번호 (1-based)
* @param size 페이지 크기
* @return 해당 날짜의 월간 랭킹 엔트리. 데이터가 없으면 빈 리스트.
*/
List<RankingEntry> getTopN(LocalDate baseDate, int pageOneBased, int size);

/**
* 지정 baseDate 의 월간 랭킹 전체 엔트리 수.
*/
long getTotal(LocalDate baseDate);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.loopers.domain.ranking;

import java.time.LocalDate;
import java.util.List;

/**
* 주간 MV 랭킹 읽기 전용 저장소 인터페이스 (DIP).
*
* commerce-batch 가 mv_product_rank_weekly 에 적재한 데이터를 조회한다.
* base_date = batch 실행일 - 1일 (어제 기준 직전 7일 집계 결과).
*/
public interface WeeklyRankingRepository {

/**
* 지정 baseDate 의 주간 TOP-N 을 반환한다. rank 는 1-based.
*
* @param baseDate 집계 기준일 (MV 테이블의 base_date)
* @param pageOneBased 사용자 노출 기준 페이지 번호 (1-based)
* @param size 페이지 크기
* @return 해당 날짜의 주간 랭킹 엔트리. 데이터가 없으면 빈 리스트.
*/
List<RankingEntry> getTopN(LocalDate baseDate, int pageOneBased, int size);

/**
* 지정 baseDate 의 주간 랭킹 전체 엔트리 수.
*/
long getTotal(LocalDate baseDate);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MonthlyRankingRepository;
import com.loopers.domain.ranking.RankingEntry;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.Collections;
import java.util.List;

/**
* MonthlyRankingRepository JPA 구현체.
*
* WeeklyRankingRepositoryImpl 과 구조가 동일하며, 대상 JPA 레포지토리만 다르다.
* MvProductRankMonthlyJpaRepository 를 감싸서 JPA 엔티티를 RankingEntry 로 변환한다.
*/
@RequiredArgsConstructor
@Component
public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository {

private final MvProductRankMonthlyJpaRepository jpaRepository;

/**
* pageOneBased 를 JPA 의 0-based 페이지로 변환하여 조회한다.
* baseDate 가 null 이거나 size 가 0 이하이면 빈 리스트를 반환한다.
*/
@Override
public List<RankingEntry> getTopN(LocalDate baseDate, int pageOneBased, int size) {
if (baseDate == null || size <= 0) return Collections.emptyList();
// API 는 1-based 페이지를 사용하므로 JPA PageRequest 에는 (page-1)을 전달
int page = Math.max(pageOneBased, 1);
List<MvProductRankMonthly> entities = jpaRepository.findByBaseDateOrderByRankAsc(
baseDate, PageRequest.of(page - 1, size));
return entities.stream()
.map(e -> new RankingEntry(e.getProductId(), e.getRank(), e.getScore()))
.toList();
}

@Override
public long getTotal(LocalDate baseDate) {
if (baseDate == null) return 0L;
return jpaRepository.countByBaseDate(baseDate);
}
}
Loading