diff --git a/src/main/java/com/matzip/place/repository/PlaceRepository.java b/src/main/java/com/matzip/place/repository/PlaceRepository.java index e80345a..49e882e 100644 --- a/src/main/java/com/matzip/place/repository/PlaceRepository.java +++ b/src/main/java/com/matzip/place/repository/PlaceRepository.java @@ -72,6 +72,9 @@ List findWithinBoundsAndSortByDistance( @Query("SELECT p FROM Place p WHERE p.campus = :campus AND p.status = 'APPROVED' ORDER BY p.likeCount DESC") List findTopByCampusOrderByLikeCount(@Param("campus") Campus campus, Pageable pageable); + @Query("SELECT p FROM Place p WHERE p.campus = :campus AND p.status = 'APPROVED' ORDER BY p.viewCount DESC") + List findTopByCampusOrderByViewCount(@Param("campus") Campus campus, Pageable pageable); + @Query("SELECT p FROM Place p WHERE p.campus = :campus AND p.status = 'APPROVED' ORDER BY p.createdAt DESC") List findTopByCampusOrderByCreatedAtDesc(@Param("campus") Campus campus, Pageable pageable); diff --git a/src/main/java/com/matzip/place/service/PlaceReadService.java b/src/main/java/com/matzip/place/service/PlaceReadService.java index a545c7e..78dd7cb 100644 --- a/src/main/java/com/matzip/place/service/PlaceReadService.java +++ b/src/main/java/com/matzip/place/service/PlaceReadService.java @@ -130,46 +130,48 @@ public List getRanking(Campus campus, SortType sortType) return getRankingByLikes(campus); } + /** + * 오늘의 맛집(조회수 랭킹). 오늘 조회 데이터가 부족한 새벽/저트래픽 구간에 빈 값이 반환되지 않도록 + * 오늘(3곳 이상) → 어제(3곳 이상) → 전체 누적 조회수 순으로 폴백한다. + */ private List getDailyRankingByViews(Campus campus) { - LocalDate today = LocalDate.now(); Pageable topN = PageRequest.of(0, RANKING_SIZE); + LocalDate today = LocalDate.now(); - List dailyRankings = dailyViewCountRepository.findDailyRankingByCampus(campus, today, topN); - List places = dailyRankings.stream() - .map(DailyViewCount::getPlace) - .collect(Collectors.toList()); + List todayRanking = findDailyRankingPlaces(campus, today, topN); + if (todayRanking.size() >= RANKING_SIZE) { + return buildRankingResponse(todayRanking); + } - Map relatedDataMap = getPlaceRelatedDataInBatch(places); + List yesterdayRanking = findDailyRankingPlaces(campus, today.minusDays(1), topN); + if (yesterdayRanking.size() >= RANKING_SIZE) { + return buildRankingResponse(yesterdayRanking); + } - return dailyRankings.stream() - .map(dailyViewCount -> { - Place place = dailyViewCount.getPlace(); + List viewCountRanking = placeRepository.findTopByCampusOrderByViewCount(campus, topN); + return buildRankingResponse(viewCountRanking); + } - PlaceRelatedData relatedData = relatedDataMap.get(place.getId()); - return PlaceCommonResponseDto.from(place, relatedData.categories(), relatedData.tags()); - }) + private List findDailyRankingPlaces(Campus campus, LocalDate date, Pageable pageable) { + return dailyViewCountRepository.findDailyRankingByCampus(campus, date, pageable).stream() + .map(DailyViewCount::getPlace) .collect(Collectors.toList()); } private List getRankingByLikes(Campus campus) { Pageable topN = PageRequest.of(0, RANKING_SIZE); - - List places = placeRepository.findTopByCampusOrderByLikeCount(campus, topN); - - Map relatedDataMap = getPlaceRelatedDataInBatch(places); - - return places.stream() - .map(place -> { - PlaceRelatedData relatedData = relatedDataMap.get(place.getId()); - return PlaceCommonResponseDto.from(place, relatedData.categories(), relatedData.tags()); - }) - .collect(Collectors.toList()); + return buildRankingResponse(placeRepository.findTopByCampusOrderByLikeCount(campus, topN)); } private List getRankingByLatest(Campus campus) { Pageable topN = PageRequest.of(0, LATEST_PLACES_SIZE); - List places = placeRepository.findTopByCampusOrderByCreatedAtDesc(campus, topN); + return buildRankingResponse(placeRepository.findTopByCampusOrderByCreatedAtDesc(campus, topN)); + } + /** + * Place 목록을 연관 데이터(카테고리/태그)와 함께 응답 DTO로 변환하는 공통 로직. + */ + private List buildRankingResponse(List places) { Map relatedDataMap = getPlaceRelatedDataInBatch(places); return places.stream() diff --git a/src/test/java/com/matzip/place/application/PlaceReadServiceTest.java b/src/test/java/com/matzip/place/application/PlaceReadServiceTest.java index 099eb06..424abd3 100644 --- a/src/test/java/com/matzip/place/application/PlaceReadServiceTest.java +++ b/src/test/java/com/matzip/place/application/PlaceReadServiceTest.java @@ -2,12 +2,21 @@ import com.matzip.place.service.PlaceReadService; import com.matzip.place.domain.Campus; +import com.matzip.place.domain.DailyViewCount; import com.matzip.place.domain.PlaceStatus; +import com.matzip.place.domain.SortType; import com.matzip.place.domain.entity.Place; +import com.matzip.place.dto.response.PlaceCommonResponseDto; import com.matzip.place.repository.DailyViewCountRepository; import com.matzip.place.repository.PlaceRepository; import com.matzip.user.domain.User; import com.matzip.user.repository.UserRepository; + +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -39,6 +48,9 @@ public class PlaceReadServiceTest { @Autowired private DailyViewCountRepository dailyViewCountRepository; + @Autowired + private PlatformTransactionManager transactionManager; + private Place testPlace; private User testUser; @@ -114,4 +126,91 @@ void getPlaceDetail_concurrentAccess() throws InterruptedException { assertThat(finalPlace.getViewCount()).isEqualTo(numberOfThreads); }); } + + @Test + @DisplayName("오늘의 맛집: 오늘 조회수 랭킹이 3곳 이상이면 오늘 랭킹을 반환한다") + void dailyRanking_returnsToday_whenTodayHasEnough() { + // given + LocalDate today = LocalDate.now(); + Place p1 = createApprovedPlace("t1"); + Place p2 = createApprovedPlace("t2"); + Place p3 = createApprovedPlace("t3"); + saveDailyCount(p1, today, 10); + saveDailyCount(p2, today, 30); + saveDailyCount(p3, today, 20); + + // when + List result = placeReadService.getRanking(Campus.CHEONAN, SortType.VIEWS); + + // then: 오늘 count DESC 순 + assertThat(result).extracting(PlaceCommonResponseDto::getPlaceId) + .containsExactly(p2.getId(), p3.getId(), p1.getId()); + } + + @Test + @DisplayName("오늘의 맛집: 오늘이 3곳 미만이면 어제 랭킹(3곳 이상)으로 폴백한다") + void dailyRanking_fallsBackToYesterday_whenTodayInsufficient() { + // given + LocalDate today = LocalDate.now(); + LocalDate yesterday = today.minusDays(1); + Place p1 = createApprovedPlace("y1"); + Place p2 = createApprovedPlace("y2"); + Place p3 = createApprovedPlace("y3"); + saveDailyCount(p1, today, 5); // 오늘은 1곳뿐 (3 미만) + saveDailyCount(p1, yesterday, 10); + saveDailyCount(p2, yesterday, 30); + saveDailyCount(p3, yesterday, 20); + + // when + List result = placeReadService.getRanking(Campus.CHEONAN, SortType.VIEWS); + + // then: 빈 값이 아니라 어제 랭킹으로 폴백 + assertThat(result).extracting(PlaceCommonResponseDto::getPlaceId) + .containsExactly(p2.getId(), p3.getId(), p1.getId()); + } + + @Test + @DisplayName("오늘의 맛집: 오늘·어제 모두 3곳 미만이면 전체 누적 조회수로 폴백한다") + void dailyRanking_fallsBackToTotalViewCount_whenNoRecentData() { + // given: 오늘/어제 daily 데이터 없음, 누적 조회수만 존재 + Place p1 = createApprovedPlace("v1"); + Place p2 = createApprovedPlace("v2"); + Place p3 = createApprovedPlace("v3"); + bumpViewCount(p1, 10); + bumpViewCount(p2, 30); + bumpViewCount(p3, 20); + + // when + List result = placeReadService.getRanking(Campus.CHEONAN, SortType.VIEWS); + + // then: 빈 값이 아니라 누적 조회수 DESC로 폴백 + assertThat(result).isNotEmpty(); + assertThat(result).extracting(PlaceCommonResponseDto::getPlaceId) + .containsExactly(p2.getId(), p3.getId(), p1.getId()); + } + + private Place createApprovedPlace(String suffix) { + return placeRepository.save(Place.builder() + .name("맛집_" + suffix) + .address("주소_" + suffix) + .kakaoPlaceId("kakao_" + suffix) + .campus(Campus.CHEONAN) + .latitude(36.1) + .longitude(127.1) + .status(PlaceStatus.APPROVED) + .build()); + } + + private void saveDailyCount(Place place, LocalDate date, int count) { + dailyViewCountRepository.save(new DailyViewCount(place, date, count)); + } + + private void bumpViewCount(Place place, int times) { + // incrementViewCount는 @Modifying이라 트랜잭션이 필요하다 (운영에선 서비스 @Transactional 안에서 호출됨) + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + for (int i = 0; i < times; i++) { + placeRepository.incrementViewCount(place.getId()); + } + }); + } }