feat: 스팟별 '이 시간대 자주 오는 견종' 통계 API
체크인 이력(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:
@@ -1,6 +1,7 @@
|
|||||||
package com.dog.dognation.api;
|
package com.dog.dognation.api;
|
||||||
|
|
||||||
import com.dog.dognation.api.dto.SpotDtos.AiMateResponse;
|
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.CheckInRequest;
|
||||||
import com.dog.dognation.api.dto.SpotDtos.CheckInResponse;
|
import com.dog.dognation.api.dto.SpotDtos.CheckInResponse;
|
||||||
import com.dog.dognation.api.dto.SpotDtos.MateResponse;
|
import com.dog.dognation.api.dto.SpotDtos.MateResponse;
|
||||||
@@ -62,6 +63,12 @@ public class SpotController {
|
|||||||
return mateAiService.compatibleMates(id, dogId);
|
return mateAiService.compatibleMates(id, dogId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 현재 요일·시간대에 이 스팟에 자주 오는 견종(통계) */
|
||||||
|
@GetMapping("/{id}/frequent-breeds")
|
||||||
|
public FrequentBreedsResponse frequentBreeds(@PathVariable Long id) {
|
||||||
|
return spotService.frequentBreeds(id);
|
||||||
|
}
|
||||||
|
|
||||||
/** 스팟 한줄평 목록 */
|
/** 스팟 한줄평 목록 */
|
||||||
@GetMapping("/{id}/reviews")
|
@GetMapping("/{id}/reviews")
|
||||||
public List<ReviewResponse> reviews(@PathVariable Long id) {
|
public List<ReviewResponse> reviews(@PathVariable Long id) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.dog.dognation.domain.spot.SpotReview;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** 스팟 관련 요청/응답 DTO 모음. */
|
/** 스팟 관련 요청/응답 DTO 모음. */
|
||||||
public final class SpotDtos {
|
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 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 record ReviewResponse(Long id, String tag, String content, OffsetDateTime createdAt) {
|
||||||
public static ReviewResponse from(SpotReview r) {
|
public static ReviewResponse from(SpotReview r) {
|
||||||
return new ReviewResponse(r.getId(), r.getTag(), r.getContent(), r.getCreatedAt());
|
return new ReviewResponse(r.getId(), r.getTag(), r.getContent(), r.getCreatedAt());
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ public class CheckIn {
|
|||||||
return dogId;
|
return dogId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getCheckedInAt() {
|
||||||
|
return checkedInAt;
|
||||||
|
}
|
||||||
|
|
||||||
public OffsetDateTime getExpiresAt() {
|
public OffsetDateTime getExpiresAt() {
|
||||||
return expiresAt;
|
return expiresAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ public interface CheckInRepository extends JpaRepository<CheckIn, Long> {
|
|||||||
/** 스팟에 현재 유효한(만료 전) 체크인 목록. */
|
/** 스팟에 현재 유효한(만료 전) 체크인 목록. */
|
||||||
List<CheckIn> findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc(
|
List<CheckIn> findBySpotIdAndExpiresAtAfterOrderByCheckedInAtDesc(
|
||||||
Long spotId, OffsetDateTime now);
|
Long spotId, OffsetDateTime now);
|
||||||
|
|
||||||
|
/** 통계용: 스팟의 특정 시점 이후 체크인 전체(요일/시간대 집계에 사용). */
|
||||||
|
List<CheckIn> findBySpotIdAndCheckedInAtAfter(Long spotId, OffsetDateTime since);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.dog.dognation.domain.spot;
|
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.Dog;
|
||||||
import com.dog.dognation.domain.dog.DogRepository;
|
import com.dog.dognation.domain.dog.DogRepository;
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -83,6 +87,58 @@ public class SpotService {
|
|||||||
return reviewRepository.findBySpotIdOrderByCreatedAtDesc(spotId);
|
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
|
@Transactional
|
||||||
public SpotReview addReview(Long spotId, Long userId, String tag, String content) {
|
public SpotReview addReview(Long spotId, Long userId, String tag, String content) {
|
||||||
if (!spotRepository.existsById(spotId)) {
|
if (!spotRepository.existsById(spotId)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user