Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,13 @@ private void applyPgResult(Payment payment, PgTransactionStatus status, String r
if (affected > 0) {
orderService.markOrderPaid(payment.getOrderId());
List<OrderItemInfo> orderItems = orderService.getOrderItems(payment.getOrderId());
List<Long> productIds = orderItems.stream()
.map(OrderItemInfo::productId)
.toList();
List<OrderedProduct> orderedProducts = orderItems.stream()
.map(item -> OrderedProduct.of(item.productId(), item.price(), item.quantity()))
.toList();

outboxEventPublisher.publish(
EventType.PAYMENT_COMPLETED,
PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null, productIds, orderedProducts),
PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), orderedProducts),
payment.getOrderId()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import com.loopers.application.product.ProductFacade;
import com.loopers.application.product.ProductInfo;
import com.loopers.domain.ranking.ProductRankSnapshot;
import com.loopers.domain.ranking.ProductRankSnapshotQueryRepository;
import com.loopers.domain.ranking.RankingEntry;
import com.loopers.domain.ranking.RankingInfo;
import com.loopers.event.ranking.RankingKeyGenerator;
import com.loopers.domain.ranking.RankingRepository;
import com.loopers.domain.ranking.RankingType;
import com.loopers.event.ranking.RankingKeyGenerator;
import com.loopers.support.error.CoreException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -22,22 +25,72 @@
@Service
public class RankingFacade {
private final RankingRepository rankingRepository;
private final ProductRankSnapshotQueryRepository snapshotQueryRepository;
private final ProductFacade productFacade;
private final Clock clock;

public RankingPageResult getRankings(LocalDate date, int page, int size) {
public RankingPageResult getRankings(LocalDate date, RankingType rankingType, int page, int size) {
if (rankingType == RankingType.DAILY) {
return getDailyRankings(date, page, size);
}

return getSnapshotRankings(rankingType, date, page, size);
}

public RankingInfo getProductRank(Long productId) {
LocalDate today = LocalDate.now(clock);
String key = RankingKeyGenerator.keyOf(today);
Long rank = rankingRepository.getRank(key, productId);
if (rank == null) {
return null;
}

Double score = rankingRepository.getScore(key, productId);
return new RankingInfo(rank + 1, score);
}

private RankingPageResult getDailyRankings(LocalDate date, int page, int size) {
String key = RankingKeyGenerator.keyOf(date);
int offset = (page - 1) * size;
List<RankingEntry> entries = rankingRepository.getTopRankings(key, offset, size);

List<RankingProductInfo> items = entries.stream()
.map(entry -> toRankingProductInfo(entry).orElse(null))
.filter(Objects::nonNull)
.toList();
.map(entry -> toRankingProductInfo(entry).orElse(null))
.filter(Objects::nonNull)
.toList();

return new RankingPageResult(items, page, size);
}

private RankingPageResult getSnapshotRankings(RankingType rankingType, LocalDate date, int page, int size) {
LocalDate rankDate = (date != null) ? date
: snapshotQueryRepository.findLatestRankDate(rankingType).orElse(null);

if (rankDate == null) {
return new RankingPageResult(List.of(), page, size);
}

int offset = (page - 1) * size;
List<ProductRankSnapshot> snapshots = snapshotQueryRepository.findRankings(rankingType, rankDate, offset, size);

List<RankingProductInfo> items = snapshots.stream()
.map(this::toRankingProductInfo)
.toList();

return new RankingPageResult(items, page, size);
}

private RankingProductInfo toRankingProductInfo(ProductRankSnapshot snapshot) {
return new RankingProductInfo(
snapshot.getProductId(),
snapshot.getProductName(),
snapshot.getPrice(),
snapshot.getBrandName(),
(long) snapshot.getRankPosition(),
snapshot.getScore()
);
}

private Optional<RankingProductInfo> toRankingProductInfo(RankingEntry entry) {
try {
ProductInfo product = productFacade.getActiveProduct(entry.productId());
Expand All @@ -47,15 +100,4 @@ private Optional<RankingProductInfo> toRankingProductInfo(RankingEntry entry) {
return Optional.empty();
}
}

public RankingInfo getProductRank(Long productId) {
LocalDate today = LocalDate.now(clock);
String key = RankingKeyGenerator.keyOf(today);
Long rank = rankingRepository.getRank(key, productId);
if (rank == null) {
return null;
}
Double score = rankingRepository.getScore(key, productId);
return new RankingInfo(rank + 1, score);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class ProductMetricsReadModel {
private Long viewCount;

@Column(nullable = false)
private Long orderCount;
private Long orderLineCount;

protected ProductMetricsReadModel() {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.domain.ranking;

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

public interface ProductRankSnapshotQueryRepository {
List<ProductRankSnapshot> findRankings(RankingType rankingType, LocalDate rankDate, int offset, int size);
Optional<LocalDate> findLatestRankDate(RankingType rankingType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.ProductRankSnapshot;
import com.loopers.domain.ranking.ProductRankSnapshotQueryRepository;
import com.loopers.domain.ranking.RankingType;
import com.loopers.infrastructure.ranking.ProductRankSnapshotJpaRepository;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

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

@RequiredArgsConstructor
@Repository
public class ProductRankSnapshotQueryRepositoryImpl implements ProductRankSnapshotQueryRepository {

private final EntityManager entityManager;

@Override
public List<ProductRankSnapshot> findRankings(RankingType rankingType, LocalDate rankDate, int offset, int size) {
return entityManager.createQuery(
"SELECT p FROM ProductRankSnapshot p " +
"WHERE p.rankingType = :rankingType AND p.rankDate = :rankDate " +
"ORDER BY p.rankPosition ASC", ProductRankSnapshot.class)
.setParameter("rankingType", rankingType)
.setParameter("rankDate", rankDate)
.setFirstResult(offset)
.setMaxResults(size)
.getResultList();
}

@Override
public Optional<LocalDate> findLatestRankDate(RankingType rankingType) {
List<LocalDate> result = entityManager.createQuery(
"SELECT MAX(p.rankDate) FROM ProductRankSnapshot p " +
"WHERE p.rankingType = :rankingType", LocalDate.class)
.setParameter("rankingType", rankingType)
.getResultList();
return result.isEmpty() ? Optional.empty() : Optional.ofNullable(result.get(0));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.loopers.application.ranking.RankingFacade;
import com.loopers.application.ranking.RankingPageResult;
import com.loopers.domain.ranking.RankingType;
import com.loopers.interfaces.api.ApiResponse;
import com.loopers.interfaces.api.ranking.dto.RankingV1Dto;
import lombok.RequiredArgsConstructor;
Expand All @@ -26,16 +27,27 @@ public class RankingV1Controller {
@GetMapping
public ApiResponse<List<RankingV1Dto.RankingResponse>> getRankings(
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date,
@RequestParam(defaultValue = "DAILY") RankingType rankingType,
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 | 🟡 Minor

rankingType 입력이 잘못된 경우 표준 에러 포맷을 벗어난다.

enum 바인딩 실패 시 Spring이 MethodArgumentTypeMismatchException을 던지고 ApiControllerAdvice에서 CoreException 기반의 통일된 응답 포맷 대신 기본 500/400 포맷이 반환될 수 있다. 운영 관점에서 클라이언트가 에러 코드를 일관되게 처리하지 못하면 장애 재현·디버깅이 어렵다.

수정안: String으로 받은 뒤 도메인 규칙으로 검증하여 CoreException(ErrorType.BAD_REQUEST, ...)로 변환하거나, @ControllerAdvice에 타입 미스매치 → CoreException 매핑을 추가한다. 추가 테스트: rankingType=INVALID 요청 시 400과 표준 에러 바디가 반환되는지 E2E로 검증한다.

As per coding guidelines, "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format".

🤖 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/interfaces/api/ranking/RankingV1Controller.java`
at line 30, The controller currently binds rankingType as the enum RankingType
which causes Spring to throw MethodArgumentTypeMismatchException (bypassing
ApiControllerAdvice) for invalid values; change the RankingV1Controller endpoint
to accept rankingType as String, validate/convert it to RankingType using domain
rules and throw new CoreException(ErrorType.BAD_REQUEST, ...) on invalid values,
or alternatively update ApiControllerAdvice to map
MethodArgumentTypeMismatchException -> CoreException so all enum binding
failures are normalized; add an E2E test that calls the endpoint with
rankingType=INVALID and asserts a 400 with the unified error body.

@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "1") int page
) {
LocalDate targetDate = (date != null) ? date : LocalDate.now(clock);
RankingPageResult result = rankingFacade.getRankings(targetDate, page, size);
LocalDate targetDate = resolveDate(date, rankingType);
RankingPageResult result = rankingFacade.getRankings(targetDate, rankingType, page, size);

List<RankingV1Dto.RankingResponse> responses = result.items().stream()
.map(RankingV1Dto.RankingResponse::from)
.toList();

return ApiResponse.success(responses);
}

private LocalDate resolveDate(LocalDate date, RankingType rankingType) {
if (date != null) {
return date;
}
if (rankingType == RankingType.DAILY) {
return LocalDate.now(clock);
}
return null; // WEEKLY/MONTHLY: null이면 Facade에서 최신 rank_date 조회
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ void publishesPaymentEventWithOrderedProducts_whenSuccess() {

PaymentCompletedEventPayload payload = captor.getValue();
assertAll(
() -> assertThat(payload.getProductIds()).containsExactly(1L, 2L),
() -> assertThat(payload.getOrderedProducts()).hasSize(2),
() -> assertThat(payload.getOrderedProducts())
.extracting("productId", "price", "quantity")
Expand Down
Loading