diff --git a/src/main/java/com/dog/dognation/api/SpotController.java b/src/main/java/com/dog/dognation/api/SpotController.java index 647bb6c..2f631f7 100644 --- a/src/main/java/com/dog/dognation/api/SpotController.java +++ b/src/main/java/com/dog/dognation/api/SpotController.java @@ -1,6 +1,7 @@ package com.dog.dognation.api; import com.dog.dognation.api.dto.SpotDtos.AiMateResponse; +import com.dog.dognation.api.dto.SpotDtos.FrequentBreedsResponse; import com.dog.dognation.api.dto.SpotDtos.CheckInRequest; import com.dog.dognation.api.dto.SpotDtos.CheckInResponse; import com.dog.dognation.api.dto.SpotDtos.MateResponse; @@ -62,6 +63,12 @@ public class SpotController { return mateAiService.compatibleMates(id, dogId); } + /** 현재 요일·시간대에 이 스팟에 자주 오는 견종(통계) */ + @GetMapping("/{id}/frequent-breeds") + public FrequentBreedsResponse frequentBreeds(@PathVariable Long id) { + return spotService.frequentBreeds(id); + } + /** 스팟 한줄평 목록 */ @GetMapping("/{id}/reviews") public List reviews(@PathVariable Long id) { diff --git a/src/main/java/com/dog/dognation/api/dto/SpotDtos.java b/src/main/java/com/dog/dognation/api/dto/SpotDtos.java index 40de55d..4dd998c 100644 --- a/src/main/java/com/dog/dognation/api/dto/SpotDtos.java +++ b/src/main/java/com/dog/dognation/api/dto/SpotDtos.java @@ -7,6 +7,7 @@ import com.dog.dognation.domain.spot.SpotReview; import jakarta.validation.constraints.NotNull; import java.time.OffsetDateTime; +import java.util.List; /** 스팟 관련 요청/응답 DTO 모음. */ public final class SpotDtos { @@ -30,6 +31,10 @@ public final class SpotDtos { public record AiMateResponse(Long dogId, String name, String breed, int score, String reason) { } + /** 현재 요일·시간대 기준, 이 스팟에 자주 오는 견종(빈도 내림차순). */ + public record FrequentBreedsResponse(List breeds) { + } + public record ReviewResponse(Long id, String tag, String content, OffsetDateTime createdAt) { public static ReviewResponse from(SpotReview r) { return new ReviewResponse(r.getId(), r.getTag(), r.getContent(), r.getCreatedAt()); diff --git a/src/main/java/com/dog/dognation/domain/spot/CheckIn.java b/src/main/java/com/dog/dognation/domain/spot/CheckIn.java index 0af27a7..28ee7a6 100644 --- a/src/main/java/com/dog/dognation/domain/spot/CheckIn.java +++ b/src/main/java/com/dog/dognation/domain/spot/CheckIn.java @@ -56,6 +56,10 @@ public class CheckIn { return dogId; } + public OffsetDateTime getCheckedInAt() { + return checkedInAt; + } + public OffsetDateTime getExpiresAt() { return expiresAt; } diff --git a/src/main/java/com/dog/dognation/domain/spot/CheckInRepository.java b/src/main/java/com/dog/dognation/domain/spot/CheckInRepository.java index 39af36e..d641987 100644 --- a/src/main/java/com/dog/dognation/domain/spot/CheckInRepository.java +++ b/src/main/java/com/dog/dognation/domain/spot/CheckInRepository.java @@ -10,4 +10,7 @@ public interface CheckInRepository extends JpaRepository { /** 스팟에 현재 유효한(만료 전) 체크인 목록. */ List findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc( Long spotId, OffsetDateTime now); + + /** 통계용: 스팟의 특정 시점 이후 체크인 전체(요일/시간대 집계에 사용). */ + List findBySpotIdAndCheckedInAtAfter(Long spotId, OffsetDateTime since); } diff --git a/src/main/java/com/dog/dognation/domain/spot/SpotService.java b/src/main/java/com/dog/dognation/domain/spot/SpotService.java index 12476bb..61e3d3c 100644 --- a/src/main/java/com/dog/dognation/domain/spot/SpotService.java +++ b/src/main/java/com/dog/dognation/domain/spot/SpotService.java @@ -1,12 +1,16 @@ package com.dog.dognation.domain.spot; +import com.dog.dognation.api.dto.SpotDtos.FrequentBreedsResponse; import com.dog.dognation.domain.dog.Dog; import com.dog.dognation.domain.dog.DogRepository; import jakarta.persistence.EntityNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.DayOfWeek; import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.List; import java.util.Map; import java.util.Objects; @@ -83,6 +87,58 @@ public class SpotService { return reviewRepository.findBySpotIdOrderByCreatedAtDesc(spotId); } + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + /** + * 통계용 체크인(PostgreSQL)을 기반으로, 지금과 같은 요일·시간대에 이 스팟에 자주 온 + * 견종 상위 목록을 반환한다. (방문 횟수 기준, 최근 120일) + */ + @Transactional(readOnly = true) + public FrequentBreedsResponse frequentBreeds(Long spotId) { + ZonedDateTime now = ZonedDateTime.now(KST); + DayOfWeek nowDow = now.getDayOfWeek(); + int nowBand = timeBand(now.getHour()); + + OffsetDateTime since = now.minusDays(120).toOffsetDateTime(); + List history = checkInRepository.findBySpotIdAndCheckedInAtAfter(spotId, since); + + // 같은 요일 + 같은 시간대의 체크인만 (방문 1건 = dogId 1개, 중복 허용 → 빈도) + List dogIds = history.stream() + .filter(c -> { + ZonedDateTime t = c.getCheckedInAt().atZoneSameInstant(KST); + return t.getDayOfWeek() == nowDow && timeBand(t.getHour()) == nowBand; + }) + .map(CheckIn::getDogId) + .toList(); + if (dogIds.isEmpty()) { + return new FrequentBreedsResponse(List.of()); + } + + Map breedByDog = dogRepository.findAllById(dogIds.stream().distinct().toList()).stream() + .filter(d -> d.getBreed() != null && !d.getBreed().isBlank()) + .collect(Collectors.toMap(Dog::getId, Dog::getBreed)); + + Map counts = dogIds.stream() + .map(breedByDog::get) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + + List top = counts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(3) + .map(Map.Entry::getKey) + .toList(); + return new FrequentBreedsResponse(top); + } + + /** 시간대 구간: 0=아침(5-11) 1=낮(11-17) 2=저녁(17-21) 3=밤(그 외). */ + private int timeBand(int hour) { + if (hour >= 5 && hour < 11) return 0; + if (hour >= 11 && hour < 17) return 1; + if (hour >= 17 && hour < 21) return 2; + return 3; + } + @Transactional public SpotReview addReview(Long spotId, Long userId, String tag, String content) { if (!spotRepository.existsById(spotId)) {