Skip to content
Merged
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 @@ -72,6 +72,9 @@ List<Place> findWithinBoundsAndSortByDistance(
@Query("SELECT p FROM Place p WHERE p.campus = :campus AND p.status = 'APPROVED' ORDER BY p.likeCount DESC")
List<Place> 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<Place> 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<Place> findTopByCampusOrderByCreatedAtDesc(@Param("campus") Campus campus, Pageable pageable);

Expand Down
50 changes: 26 additions & 24 deletions src/main/java/com/matzip/place/service/PlaceReadService.java
Original file line number Diff line number Diff line change
Expand Up @@ -130,46 +130,48 @@ public List<PlaceCommonResponseDto> getRanking(Campus campus, SortType sortType)
return getRankingByLikes(campus);
}

/**
* 오늘의 맛집(조회수 랭킹). 오늘 조회 데이터가 부족한 새벽/저트래픽 구간에 빈 값이 반환되지 않도록
* 오늘(3곳 이상) → 어제(3곳 이상) → 전체 누적 조회수 순으로 폴백한다.
*/
private List<PlaceCommonResponseDto> getDailyRankingByViews(Campus campus) {
LocalDate today = LocalDate.now();
Pageable topN = PageRequest.of(0, RANKING_SIZE);
LocalDate today = LocalDate.now();

List<DailyViewCount> dailyRankings = dailyViewCountRepository.findDailyRankingByCampus(campus, today, topN);
List<Place> places = dailyRankings.stream()
.map(DailyViewCount::getPlace)
.collect(Collectors.toList());
List<Place> todayRanking = findDailyRankingPlaces(campus, today, topN);
if (todayRanking.size() >= RANKING_SIZE) {
return buildRankingResponse(todayRanking);
}

Map<Long, PlaceRelatedData> relatedDataMap = getPlaceRelatedDataInBatch(places);
List<Place> 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<Place> viewCountRanking = placeRepository.findTopByCampusOrderByViewCount(campus, topN);
return buildRankingResponse(viewCountRanking);
}

PlaceRelatedData relatedData = relatedDataMap.get(place.getId());
return PlaceCommonResponseDto.from(place, relatedData.categories(), relatedData.tags());
})
private List<Place> findDailyRankingPlaces(Campus campus, LocalDate date, Pageable pageable) {
return dailyViewCountRepository.findDailyRankingByCampus(campus, date, pageable).stream()
.map(DailyViewCount::getPlace)
.collect(Collectors.toList());
}

private List<PlaceCommonResponseDto> getRankingByLikes(Campus campus) {
Pageable topN = PageRequest.of(0, RANKING_SIZE);

List<Place> places = placeRepository.findTopByCampusOrderByLikeCount(campus, topN);

Map<Long, PlaceRelatedData> 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<PlaceCommonResponseDto> getRankingByLatest(Campus campus) {
Pageable topN = PageRequest.of(0, LATEST_PLACES_SIZE);
List<Place> places = placeRepository.findTopByCampusOrderByCreatedAtDesc(campus, topN);
return buildRankingResponse(placeRepository.findTopByCampusOrderByCreatedAtDesc(campus, topN));
}

/**
* Place 목록을 연관 데이터(카테고리/태그)와 함께 응답 DTO로 변환하는 공통 로직.
*/
private List<PlaceCommonResponseDto> buildRankingResponse(List<Place> places) {
Map<Long, PlaceRelatedData> relatedDataMap = getPlaceRelatedDataInBatch(places);

return places.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,6 +48,9 @@ public class PlaceReadServiceTest {
@Autowired
private DailyViewCountRepository dailyViewCountRepository;

@Autowired
private PlatformTransactionManager transactionManager;

private Place testPlace;
private User testUser;

Expand Down Expand Up @@ -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<PlaceCommonResponseDto> 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<PlaceCommonResponseDto> 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<PlaceCommonResponseDto> 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());
}
});
}
}
Loading