feat: 스팟별 '이 시간대 자주 오는 견종' 통계 API
Deploy (dev) / deploy (push) Failing after 12m57s
CI / build (push) Failing after 15m1s

체크인 이력(check_ins)에서 현재와 같은 요일·시간대 방문을 집계해 상위 견종 반환.
GET /api/spots/{id}/frequent-breeds. 집계는 Java(요일/시간대, KST) — H2/Postgres 공용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 19:15:41 +09:00
parent a29aadc5ff
commit ceba15df42
5 changed files with 75 additions and 0 deletions
@@ -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<ReviewResponse> reviews(@PathVariable Long id) {
@@ -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<String> 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());
@@ -56,6 +56,10 @@ public class CheckIn {
return dogId;
}
public OffsetDateTime getCheckedInAt() {
return checkedInAt;
}
public OffsetDateTime getExpiresAt() {
return expiresAt;
}
@@ -10,4 +10,7 @@ public interface CheckInRepository extends JpaRepository<CheckIn, Long> {
/** 스팟에 현재 유효한(만료 전) 체크인 목록. */
List<CheckIn> findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc(
Long spotId, OffsetDateTime now);
/** 통계용: 스팟의 특정 시점 이후 체크인 전체(요일/시간대 집계에 사용). */
List<CheckIn> findBySpotIdAndCheckedInAtAfter(Long spotId, OffsetDateTime since);
}
@@ -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<CheckIn> history = checkInRepository.findBySpotIdAndCheckedInAtAfter(spotId, since);
// 같은 요일 + 같은 시간대의 체크인만 (방문 1건 = dogId 1개, 중복 허용 → 빈도)
List<Long> 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<Long, String> breedByDog = dogRepository.findAllById(dogIds.stream().distinct().toList()).stream()
.filter(d -> d.getBreed() != null && !d.getBreed().isBlank())
.collect(Collectors.toMap(Dog::getId, Dog::getBreed));
Map<String, Long> counts = dogIds.stream()
.map(breedByDog::get)
.filter(Objects::nonNull)
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
List<String> top = counts.entrySet().stream()
.sorted(Map.Entry.<String, Long>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)) {