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
Expand Up @@ -4,8 +4,13 @@
import com.loopers.application.ranking.dto.FindRankingListResDto;
import com.loopers.domain.product.model.Product;
import com.loopers.domain.product.service.ProductService;
import com.loopers.domain.ranking.repository.RankingRepository;
import com.loopers.domain.ranking.model.RankingEntry;
import com.loopers.domain.ranking.model.RankingQuery;
import com.loopers.domain.ranking.model.RankingSlice;
import com.loopers.domain.ranking.model.RankingPeriod;
import com.loopers.domain.ranking.service.MonthlyRankingService;
import com.loopers.domain.ranking.service.RankingService;
import com.loopers.domain.ranking.service.WeeklyRankingService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -20,28 +25,36 @@
@Transactional(readOnly = true)
public class RankingFacade {

private final RankingService rankingService;
private final RankingService dailyRankingService;
private final WeeklyRankingService weeklyRankingService;
private final MonthlyRankingService monthlyRankingService;
private final ProductService productService;

public FindRankingListResDto getRankings(String date, int page, int size) {
List<RankingRepository.RankingEntry> entries = rankingService.getTopRankings(date, page, size);
long totalCount = rankingService.getTotalCount(date);

if (entries.isEmpty()) {
return new FindRankingListResDto(List.of(), totalCount, page, size);
public FindRankingListResDto getRankings(RankingQuery query) {
RankingSlice slice = switch (query.period()) {
case DAILY -> new RankingSlice(
dailyRankingService.getTopRankings(query.date(), query.page(), query.size()),
dailyRankingService.getTotalCount(query.date())
);
case WEEKLY -> weeklyRankingService.getRankings(query.date(), query.page(), query.size());
case MONTHLY -> monthlyRankingService.getRankings(query.date(), query.page(), query.size());
};

if (slice.entries().isEmpty()) {
return new FindRankingListResDto(List.of(), slice.totalCount(), query.page(), query.size());
}

List<Long> productIds = entries.stream()
.map(RankingRepository.RankingEntry::productId)
List<Long> productIds = slice.entries().stream()
.map(RankingEntry::productId)
.toList();
Map<Long, Product> productMap = productService.getProductsByIds(productIds).stream()
Map<Long, Product> productMap = productService.findAllByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));

List<FindRankingItemResDto> items = entries.stream()
List<FindRankingItemResDto> items = slice.entries().stream()
.filter(entry -> productMap.containsKey(entry.productId()))
.map(entry -> FindRankingItemResDto.from(entry, productMap.get(entry.productId())))
.toList();

return new FindRankingListResDto(items, totalCount, page, size);
return new FindRankingListResDto(items, slice.totalCount(), query.page(), query.size());
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.loopers.application.ranking.dto;

import com.loopers.domain.product.model.Product;
import com.loopers.domain.ranking.repository.RankingRepository;
import com.loopers.domain.ranking.model.RankingEntry;

public record FindRankingItemResDto(
long rank,
Expand All @@ -10,7 +10,7 @@ public record FindRankingItemResDto(
String productName,
int price
) {
public static FindRankingItemResDto from(RankingRepository.RankingEntry entry, Product product) {
public static FindRankingItemResDto from(RankingEntry entry, Product product) {
return new FindRankingItemResDto(
entry.rank(),
entry.score(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ public List<Product> getProductsByIds(List<Long> productIds) {
return products;
}

public List<Product> findAllByIds(List<Long> productIds) {
if (productIds == null || productIds.isEmpty()) {
return List.of();
}
return productRepository.findByIds(productIds);
}

private boolean isFirstPageLatest(Long brandId, SortFilter sortFilter, Pageable pageable) {
return brandId == null && sortFilter == SortFilter.LATEST && pageable.getPageNumber() == 0;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.loopers.domain.ranking.model;

public record RankingEntry(
Long productId,
double score,
long rank
) {
}
Comment on lines +3 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

도메인 모델 통일 목적의 record 도입에 이견이 없다.

다만 컨텍스트 스니펫에 따르면 RankingRepository 인터페이스는 여전히 내부 RankingRepository.RankingEntry를 선언하고 있고 RankingRepositoryImpl.getProductRanking은 이미 신규 도메인 RankingEntry를 반환하는 상태다. 또한 RankingService.getProductRanking / getProductRankRankingRepository.RankingEntry를 그대로 참조하고 있어 컴파일 레벨에서는 맞더라도 타입 일원화가 미완이다. 운영 관점에서 두 동명 타입이 공존하면 이후 리팩터링/IDE 자동완성 시 잘못된 타입 참조로 회귀 버그가 유입될 위험이 있다.

  • 수정안: RankingRepository 인터페이스의 내부 RankingEntry record를 제거하고 com.loopers.domain.ranking.model.RankingEntry로 교체, RankingService의 반환/변수 타입도 동일하게 교체한다.
  • 추가 테스트: RankingService.getProductRanking 단위 테스트에서 도메인 RankingEntry가 반환되는지 타입을 명시적으로 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingEntry.java`
around lines 3 - 8, Remove the duplicate inner record from the RankingRepository
interface and replace all references to RankingRepository.RankingEntry with the
new top-level com.loopers.domain.ranking.model.RankingEntry; update
RankingRepository method signatures and any implementing method like
RankingRepositoryImpl.getProductRanking to return the top-level RankingEntry,
and change RankingService methods (getProductRanking, getProductRank) and their
local variables/returns to use com.loopers.domain.ranking.model.RankingEntry as
well; finally add/adjust the unit test for RankingService.getProductRanking to
explicitly assert the returned element(s) are instances of the top-level
RankingEntry type.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.loopers.domain.ranking.model;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;

import java.util.Locale;

public enum RankingPeriod {
DAILY,
WEEKLY,
MONTHLY;

public static RankingPeriod from(String value) {
if (value == null || value.isBlank()) {
return DAILY;
}
try {
return RankingPeriod.valueOf(value.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 랭킹 기간입니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.loopers.domain.ranking.model;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;

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

public record RankingQuery(
RankingPeriod period,
LocalDate date,
int page,
int size
) {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;

public static RankingQuery of(String period, String date, int page, int size) {
validate(page, size);
try {
return new RankingQuery(RankingPeriod.from(period), LocalDate.parse(date, DATE_FORMAT), page, size);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)");
}
}
Comment on lines +18 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

datenull일 때 NullPointerException이 발생한다.

LocalDate.parse(date, DATE_FORMAT)datenull이면 DateTimeParseException이 아닌 NullPointerException을 던지므로, 현재 catch 블록으로 잡히지 않는다. 컨트롤러에서 @RequestParamrequired=false로 바인딩하거나 호출측에서 null을 넘기는 순간 ApiControllerAdvice를 거치지 않는 500 응답으로 이어질 수 있다. 운영 관점에서는 사용자 입력 검증 실패가 5xx로 기록되어 장애 알람/오탐을 유발한다.

date 역시 null/blank인 경우 "오늘 날짜" 등 기본값을 적용하거나 명시적으로 CoreException(BAD_REQUEST)를 던지도록 분기하고, 테스트에 null·빈 문자열·잘못된 포맷("2026-04-17", "20260431" 같은 존재하지 않는 날짜) 케이스를 추가해야 한다.

🛡️ 제안
     public static RankingQuery of(String period, String date, int page, int size) {
         validate(page, size);
+        if (date == null || date.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "날짜는 필수 입력입니다. (yyyyMMdd)");
+        }
         try {
             return new RankingQuery(RankingPeriod.from(period), LocalDate.parse(date, DATE_FORMAT), page, size);
         } catch (DateTimeParseException e) {
             throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)");
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static RankingQuery of(String period, String date, int page, int size) {
validate(page, size);
try {
return new RankingQuery(RankingPeriod.from(period), LocalDate.parse(date, DATE_FORMAT), page, size);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)");
}
}
public static RankingQuery of(String period, String date, int page, int size) {
validate(page, size);
if (date == null || date.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "날짜는 필수 입력입니다. (yyyyMMdd)");
}
try {
return new RankingQuery(RankingPeriod.from(period), LocalDate.parse(date, DATE_FORMAT), page, size);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)");
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/model/RankingQuery.java`
around lines 18 - 25, RankingQuery.of currently calls LocalDate.parse(date,
DATE_FORMAT) which throws NullPointerException when date is null and bypasses
the DateTimeParseException catch; update RankingQuery.of to first check if date
is null or blank and either apply a default (e.g., LocalDate.now()) or
immediately throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다.
(yyyyMMdd)") per your desired behavior, then keep the LocalDate.parse inside the
try/catch to handle format errors (DateTimeParseException) and rethrow
CoreException; also add unit tests for null, empty, invalid format (e.g.,
"2026-04-17", "20260431") and a valid case to verify the new branching in
RankingQuery.of.


private static void validate(int page, int size) {
if (page < 1) {
throw new CoreException(ErrorType.BAD_REQUEST, "페이지 번호는 1 이상이어야 합니다.");
}
if (size < 1 || size > 100) {
throw new CoreException(ErrorType.BAD_REQUEST, "페이지 크기는 1 이상 100 이하여야 합니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.loopers.domain.ranking.model;

import java.util.List;

public record RankingSlice(
List<RankingEntry> entries,
long totalCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.domain.ranking.repository;

import com.loopers.domain.ranking.model.RankingEntry;

import java.util.List;

public interface MonthlyRankingRepository {
List<RankingEntry> getTopRankings(String periodKey, int offset, int size);
long getTotalCount(String periodKey);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.domain.ranking.repository;

import com.loopers.domain.ranking.model.RankingEntry;

import java.util.List;

public interface WeeklyRankingRepository {
List<RankingEntry> getTopRankings(String periodKey, int offset, int size);
long getTotalCount(String periodKey);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.loopers.domain.ranking.service;

import com.loopers.domain.ranking.model.RankingSlice;
import com.loopers.domain.ranking.repository.MonthlyRankingRepository;
import com.loopers.support.util.RankingPeriodKeyFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDate;

@RequiredArgsConstructor
@Component
public class MonthlyRankingService {

private final MonthlyRankingRepository repository;

public RankingSlice getRankings(LocalDate date, int page, int size) {
int offset = (page - 1) * size;
String periodKey = RankingPeriodKeyFactory.toMonthlyKey(date);
return new RankingSlice(
repository.getTopRankings(periodKey, offset, size),
repository.getTotalCount(periodKey)
);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.loopers.domain.ranking.service;

import com.loopers.domain.ranking.model.RankingEntry;
import com.loopers.domain.ranking.repository.RankingRepository;
import com.loopers.support.util.RankingPeriodKeyFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

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

@RequiredArgsConstructor
Expand All @@ -12,13 +15,15 @@ public class RankingService {

private final RankingRepository rankingRepository;

public List<RankingRepository.RankingEntry> getTopRankings(String date, int page, int size) {
public List<RankingEntry> getTopRankings(LocalDate date, int page, int size) {
int offset = (page - 1) * size;
return rankingRepository.getTopRankings(date, offset, size);
return rankingRepository.getTopRankings(RankingPeriodKeyFactory.toDailyKey(date), offset, size).stream()
.map(entry -> new RankingEntry(entry.productId(), entry.score(), entry.rank()))
.toList();
}

public long getTotalCount(String date) {
return rankingRepository.getTotalCount(date);
public long getTotalCount(LocalDate date) {
return rankingRepository.getTotalCount(RankingPeriodKeyFactory.toDailyKey(date));
}

public RankingRepository.RankingEntry getProductRanking(String date, Long productId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.loopers.domain.ranking.service;

import com.loopers.domain.ranking.model.RankingSlice;
import com.loopers.domain.ranking.repository.WeeklyRankingRepository;
import com.loopers.support.util.RankingPeriodKeyFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDate;

@RequiredArgsConstructor
@Component
public class WeeklyRankingService {

private final WeeklyRankingRepository repository;

public RankingSlice getRankings(LocalDate date, int page, int size) {
int offset = (page - 1) * size;
String periodKey = RankingPeriodKeyFactory.toWeeklyKey(date);
return new RankingSlice(
repository.getTopRankings(periodKey, offset, size),
repository.getTotalCount(periodKey)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.loopers.infrastructure.ranking.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "mv_product_rank_monthly")
@IdClass(MonthlyRankingEntity.MonthlyRankingId.class)
public class MonthlyRankingEntity {

@Id
@Column(nullable = false, length = 16)
private String periodKey;

@Id
@Column(nullable = false)
private int rankNo;

@Column(nullable = false)
private Long productId;

@Column(nullable = false)
private double score;

@Column(nullable = false)
private long viewCount;

@Column(nullable = false)
private long likeCount;

@Column(nullable = false)
private long orderCount;

@Column(nullable = false)
private LocalDateTime updatedAt;

public record MonthlyRankingId(String periodKey, int rankNo) implements Serializable {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.loopers.infrastructure.ranking.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "mv_product_rank_weekly")
@IdClass(WeeklyRankingEntity.WeeklyRankingId.class)
public class WeeklyRankingEntity {

@Id
@Column(nullable = false, length = 16)
private String periodKey;

@Id
@Column(nullable = false)
private int rankNo;

@Column(nullable = false)
private Long productId;

@Column(nullable = false)
private double score;

@Column(nullable = false)
private long viewCount;

@Column(nullable = false)
private long likeCount;

@Column(nullable = false)
private long orderCount;

@Column(nullable = false)
private LocalDateTime updatedAt;

public record WeeklyRankingId(String periodKey, int rankNo) implements Serializable {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.loopers.infrastructure.ranking.repository;

import com.loopers.infrastructure.ranking.entity.MonthlyRankingEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MonthlyRankingJpaRepository extends JpaRepository<MonthlyRankingEntity, MonthlyRankingEntity.MonthlyRankingId> {
List<MonthlyRankingEntity> findByPeriodKeyOrderByRankNoAsc(String periodKey, Pageable pageable);
long countByPeriodKey(String periodKey);
}
Loading