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,22 @@
package com.loopers.application.ranking;

import com.loopers.domain.ranking.RankPeriod;
import com.loopers.domain.ranking.RankingService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

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

@RequiredArgsConstructor
@Component
public class RankingFacade {

private final RankingService rankingService;

public List<RankingInfo> getRanking(RankPeriod period, LocalDate date, int size) {
return rankingService.getRanking(period, date, size).stream()
.map(RankingInfo::from)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.application.ranking;

import com.loopers.domain.ranking.RankingRepository;

public record RankingInfo(Long productId, double score, int ranking) {

public static RankingInfo from(RankingRepository.RankingEntry entry) {
return new RankingInfo(entry.productId(), entry.score(), entry.ranking());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.loopers.domain.ranking;

public enum RankPeriod {
DAILY,
WEEKLY,
MONTHLY
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.loopers.domain.ranking;

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

public interface RankingRepository {

List<RankingEntry> findDailyRanking(LocalDate date, int size);

List<RankingEntry> findWeeklyRanking(LocalDate baseDate, int size);

List<RankingEntry> findMonthlyRanking(LocalDate baseDate, int size);

record RankingEntry(Long productId, double score, int ranking) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.loopers.domain.ranking;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

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

@RequiredArgsConstructor
@Component
public class RankingService {

private final RankingRepository rankingRepository;

@Transactional(readOnly = true)
public List<RankingRepository.RankingEntry> getRanking(RankPeriod period, LocalDate date, int size) {
return switch (period) {
case DAILY -> rankingRepository.findDailyRanking(date, size);
case WEEKLY -> rankingRepository.findWeeklyRanking(date, size);
case MONTHLY -> rankingRepository.findMonthlyRanking(date, size);
};
}
Comment on lines +16 to +23
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'RankingRepositoryImpl.java' | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2526


🏁 Script executed:

# Find ApplicationService or other service classes that use RankingService
rg -t java 'RankingService' --max-count=20 | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 458


🏁 Script executed:

# Check the RankingService class definition to understand its role
fd -t f 'RankingService.java' | xargs -I{} sh -c 'echo "=== {} ==="; head -40 "{}"'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 959


🏁 Script executed:

# Check RankingFacade to see if it has `@Transactional`
fd -t f 'RankingFacade.java' | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 802


@transactional을 Application 계층(RankingFacade)으로 이동해야 한다

PR#189 아키텍처 원칙에 따르면 @Transactional 선언은 Application 계층(ApplicationService)의 책임이다. 현재 RankingService는 Domain 계층에 위치하고 있으므로 @Transactional(readOnly=true)를 제거하고, Application 계층인 RankingFacade의 getRanking() 메서드에 @Transactional(readOnly=true)를 선언해야 한다. Infrastructure 계층인 RankingRepositoryImpl은 JdbcTemplate 기반이므로 올바르게 @Transactional이 없다.

🤖 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/RankingService.java`
around lines 16 - 23, Remove the `@Transactional`(readOnly = true) annotation from
RankingService.getRanking and add `@Transactional`(readOnly = true) to the
corresponding Application layer method RankingFacade.getRanking; ensure the
domain class RankingService.getRanking remains plain (no transactional) and do
not add transactions to the infrastructure implementation RankingRepositoryImpl
(JdbcTemplate) so transaction boundaries are defined at the Application service
layer.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.RankingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

@RequiredArgsConstructor
@Component
public class RankingRepositoryImpl implements RankingRepository {

private final JdbcTemplate jdbcTemplate;

private static final String DAILY_SQL = """
SELECT product_id, score
FROM product_metrics
WHERE metric_date = ?
ORDER BY score DESC
LIMIT ?
""";

private static final String WEEKLY_SQL = """
SELECT product_id, total_score, ranking
FROM mv_product_rank_weekly
WHERE base_date = ?
ORDER BY ranking ASC
LIMIT ?
""";

private static final String MONTHLY_SQL = """
SELECT product_id, total_score, ranking
FROM mv_product_rank_monthly
WHERE base_date = ?
ORDER BY ranking ASC
LIMIT ?
""";

@Override
public List<RankingEntry> findDailyRanking(LocalDate date, int size) {
AtomicInteger rank = new AtomicInteger(0);
return jdbcTemplate.query(DAILY_SQL, dailyRowMapper(rank), date, size);
}

@Override
public List<RankingEntry> findWeeklyRanking(LocalDate baseDate, int size) {
return jdbcTemplate.query(WEEKLY_SQL, mvRowMapper(), baseDate, size);
}

@Override
public List<RankingEntry> findMonthlyRanking(LocalDate baseDate, int size) {
return jdbcTemplate.query(MONTHLY_SQL, mvRowMapper(), baseDate, size);
}

private RowMapper<RankingEntry> dailyRowMapper(AtomicInteger rank) {
return (rs, rowNum) -> new RankingEntry(
rs.getLong("product_id"),
rs.getDouble("score"),
rank.incrementAndGet()
);
}

private RowMapper<RankingEntry> mvRowMapper() {
return (rs, rowNum) -> new RankingEntry(
rs.getLong("product_id"),
rs.getDouble("total_score"),
rs.getInt("ranking")
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.loopers.interfaces.api.ranking;

import com.loopers.interfaces.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;

import java.time.LocalDate;

@Tag(name = "Ranking V1 API", description = "상품 랭킹 조회 API")
public interface RankingV1ApiSpec {

@Operation(
summary = "랭킹 조회",
description = "기간별(DAILY/WEEKLY/MONTHLY) 상품 랭킹을 조회합니다."
)
ApiResponse<RankingV1Dto.RankingListResponse> getRankings(
@Parameter(description = "조회 기간 (DAILY, WEEKLY, MONTHLY)", required = true)
String period,
@Parameter(description = "기준 날짜 (yyyy-MM-dd)", required = true)
LocalDate date,
@Parameter(description = "조회 개수 (기본 100, 최대 100)")
int size
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.loopers.interfaces.api.ranking;

import com.loopers.application.ranking.RankingFacade;
import com.loopers.application.ranking.RankingInfo;
import com.loopers.domain.ranking.RankPeriod;
import com.loopers.interfaces.api.ApiResponse;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

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

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/rankings")
public class RankingV1Controller implements RankingV1ApiSpec {

private static final int DEFAULT_SIZE = 100;
private static final int MAX_SIZE = 100;

private final RankingFacade rankingFacade;

@GetMapping
@Override
public ApiResponse<RankingV1Dto.RankingListResponse> getRankings(
@RequestParam("period") String period,
@RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam(value = "size", defaultValue = "100") int size
) {
RankPeriod rankPeriod = parseRankPeriod(period);
int validSize = Math.min(Math.max(size, 1), MAX_SIZE);

List<RankingInfo> rankings = rankingFacade.getRanking(rankPeriod, date, validSize);
RankingV1Dto.RankingListResponse response = RankingV1Dto.RankingListResponse.from(rankings);

return ApiResponse.success(response);
}

private RankPeriod parseRankPeriod(String period) {
try {
return RankPeriod.valueOf(period.toUpperCase());
} catch (IllegalArgumentException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "잘못된 period 값입니다: " + period);
}
}
Comment on lines +45 to +51
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

toUpperCase() 로케일 의존성이 운영 환경에 따라 파싱 실패를 유발할 수 있다.

JVM 기본 로케일이 터키어(tr)로 설정된 환경에서는 "daily".toUpperCase()"DAİLY"가 되어 RankPeriod.valueOf가 항상 실패하고, 전체 랭킹 API가 400으로만 응답하게 된다. 컨테이너 이미지/운영 환경의 로케일 설정에 결과가 흔들리지 않도록 Locale.ROOT를 명시한다.

♻️ 제안 수정안
-    private RankPeriod parseRankPeriod(String period) {
-        try {
-            return RankPeriod.valueOf(period.toUpperCase());
-        } catch (IllegalArgumentException e) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "잘못된 period 값입니다: " + period);
-        }
-    }
+    private RankPeriod parseRankPeriod(String period) {
+        try {
+            return RankPeriod.valueOf(period.toUpperCase(java.util.Locale.ROOT));
+        } catch (IllegalArgumentException | NullPointerException e) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "잘못된 period 값입니다: " + period);
+        }
+    }

추가 테스트: Locale.setDefault(new Locale("tr")) 상태에서 period=daily 요청이 정상적으로 DAILY로 매핑되는지 검증하는 단위 테스트를 추가한다.

🤖 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`
around lines 45 - 51, parseRankPeriod currently uses period.toUpperCase() which
is locale-dependent and can fail (e.g., Turkish locale); change it to use
period.toUpperCase(Locale.ROOT) before calling RankPeriod.valueOf to ensure
deterministic ASCII uppercasing, keep the same IllegalArgumentException handling
and CoreException throw, and add a unit test that sets Locale.setDefault(new
Locale("tr")) and verifies parseRankPeriod (or the controller endpoint) maps
"daily" to RankPeriod.DAILY.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.loopers.interfaces.api.ranking;

import com.loopers.application.ranking.RankingInfo;

import java.util.List;

public class RankingV1Dto {

public record RankingResponse(int ranking, Long productId, double score) {
public static RankingResponse from(RankingInfo info) {
return new RankingResponse(info.ranking(), info.productId(), info.score());
}
}

public record RankingListResponse(List<RankingResponse> rankings) {
public static RankingListResponse from(List<RankingInfo> infos) {
List<RankingResponse> rankings = infos.stream()
.map(RankingResponse::from)
.toList();
return new RankingListResponse(rankings);
}
}
}
Loading