From 2898ac154c8c443ac3b5764ebe13395b6fa4d268 Mon Sep 17 00:00:00 2001 From: sb Date: Sat, 11 Jul 2026 23:54:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B0=B0=EB=96=A1=EC=A7=80=EC=88=98=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EA=B8=B0=EB=8A=A5=20(=EC=9A=B0?= =?UTF-8?q?=EB=A6=AC/=EB=8F=99=EB=84=A4=20=EA=B6=81=ED=95=A9=EB=8F=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 산책 후 피드백으로 강아지 궁합도(찰떡지수)를 누적한다. - 체크아웃/2시간 알림 → 피드백(잘 놀았어요/안 맞았어요) - 우리 찰떡지수(1:1): GOOD +1 / BAD -2, 매칭 이력 쌍만, 유료회원 노출 - 동네 찰떡지수(1:N): GOOD +0.5 / BAD -1, 전체 노출 (0 미만 불가) - 안 맞았어요 사유 수집 → AI 추천에 pastAffinity/neighborhood 반영 - 2시간 미피드백 시 피드백 요청 알림(FEEDBACK_REQUEST) 스케줄러 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dog/dognation/api/AffinityController.java | 47 ++++++ .../dog/dognation/api/dto/AffinityDtos.java | 30 ++++ .../domain/affinity/AffinityService.java | 153 ++++++++++++++++++ .../domain/affinity/DogAffinity.java | 52 ++++++ .../affinity/DogAffinityRepository.java | 11 ++ .../domain/affinity/DogNeighborhood.java | 43 +++++ .../affinity/DogNeighborhoodRepository.java | 6 + .../domain/affinity/FeedbackVerdict.java | 7 + .../domain/affinity/MatchFeedback.java | 63 ++++++++ .../affinity/MatchFeedbackRepository.java | 8 + .../domain/matching/MatchingRequest.java | 8 + .../matching/MatchingRequestRepository.java | 15 ++ .../domain/matching/MatchingService.java | 28 ++++ .../dognation/domain/spot/MateAiService.java | 32 +++- .../domain/user/SubscriptionTier.java | 5 + .../scheduler/MatchExpiryScheduler.java | 13 ++ .../resources/db/migration/V8__affinity.sql | 34 ++++ 17 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dog/dognation/api/AffinityController.java create mode 100644 src/main/java/com/dog/dognation/api/dto/AffinityDtos.java create mode 100644 src/main/java/com/dog/dognation/domain/affinity/AffinityService.java create mode 100644 src/main/java/com/dog/dognation/domain/affinity/DogAffinity.java create mode 100644 src/main/java/com/dog/dognation/domain/affinity/DogAffinityRepository.java create mode 100644 src/main/java/com/dog/dognation/domain/affinity/DogNeighborhood.java create mode 100644 src/main/java/com/dog/dognation/domain/affinity/DogNeighborhoodRepository.java create mode 100644 src/main/java/com/dog/dognation/domain/affinity/FeedbackVerdict.java create mode 100644 src/main/java/com/dog/dognation/domain/affinity/MatchFeedback.java create mode 100644 src/main/java/com/dog/dognation/domain/affinity/MatchFeedbackRepository.java create mode 100644 src/main/resources/db/migration/V8__affinity.sql diff --git a/src/main/java/com/dog/dognation/api/AffinityController.java b/src/main/java/com/dog/dognation/api/AffinityController.java new file mode 100644 index 0000000..dd8a789 --- /dev/null +++ b/src/main/java/com/dog/dognation/api/AffinityController.java @@ -0,0 +1,47 @@ +package com.dog.dognation.api; + +import com.dog.dognation.api.dto.AffinityDtos.FeedbackRequest; +import com.dog.dognation.api.dto.AffinityDtos.NeighborhoodScoreResponse; +import com.dog.dognation.api.dto.AffinityDtos.PairScoreResponse; +import com.dog.dognation.domain.affinity.AffinityService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** 찰떡지수/피드백 API. */ +@RestController +@RequestMapping("/api/affinity") +public class AffinityController { + + private final AffinityService affinityService; + + public AffinityController(AffinityService affinityService) { + this.affinityService = affinityService; + } + + /** 산책 후 피드백 제출 → 우리/동네 찰떡지수 반영. */ + @PostMapping("/matches/{matchId}/feedback") + public ResponseEntity feedback(@PathVariable Long matchId, + @Valid @RequestBody FeedbackRequest request) { + affinityService.submitFeedback(matchId, request.raterDogId(), request.verdict(), request.reasons()); + return ResponseEntity.noContent().build(); + } + + /** 동네 찰떡지수 (전체 노출). */ + @GetMapping("/neighborhood/{dogId}") + public NeighborhoodScoreResponse neighborhood(@PathVariable Long dogId) { + return affinityService.neighborhoodScore(dogId); + } + + /** 우리 찰떡지수 — viewerDogId(내 강아지) 기준, otherDogId 상대. 무료회원은 locked. */ + @GetMapping("/pair") + public PairScoreResponse pair(@RequestParam Long viewerDogId, @RequestParam Long otherDogId) { + return affinityService.pairScore(viewerDogId, otherDogId); + } +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/api/dto/AffinityDtos.java b/src/main/java/com/dog/dognation/api/dto/AffinityDtos.java new file mode 100644 index 0000000..effe623 --- /dev/null +++ b/src/main/java/com/dog/dognation/api/dto/AffinityDtos.java @@ -0,0 +1,30 @@ +package com.dog.dognation.api.dto; + +import com.dog.dognation.domain.affinity.FeedbackVerdict; +import jakarta.validation.constraints.NotNull; + +/** 찰떡지수/피드백 DTO 모음. */ +public final class AffinityDtos { + + private AffinityDtos() { + } + + /** 산책 후 피드백 요청. raterDogId=평가하는 내 강아지, verdict=GOOD/BAD, reasons=BAD 사유(쉼표). */ + public record FeedbackRequest( + @NotNull(message = "raterDogId 는 필수입니다.") Long raterDogId, + @NotNull(message = "verdict 는 필수입니다.") FeedbackVerdict verdict, + String reasons) { + } + + /** 동네 찰떡지수 (전체 노출). 예: 72% · 우리동네 인기 강아지 */ + public record NeighborhoodScoreResponse(int percent, String label) { + } + + /** + * 우리 찰떡지수 (1회 이상 매칭된 쌍만). + * - locked=true : 무료회원 → "유료회원 전용" 표기 + * - matched=false: 매칭 이력 없음 → 미노출 + */ + public record PairScoreResponse(boolean matched, boolean locked, int percent, String label) { + } +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/affinity/AffinityService.java b/src/main/java/com/dog/dognation/domain/affinity/AffinityService.java new file mode 100644 index 0000000..61b9c94 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/affinity/AffinityService.java @@ -0,0 +1,153 @@ +package com.dog.dognation.domain.affinity; + +import com.dog.dognation.api.dto.AffinityDtos.NeighborhoodScoreResponse; +import com.dog.dognation.api.dto.AffinityDtos.PairScoreResponse; +import com.dog.dognation.common.exception.ApiException; +import com.dog.dognation.domain.dog.Dog; +import com.dog.dognation.domain.dog.DogRepository; +import com.dog.dognation.domain.matching.MatchingRequest; +import com.dog.dognation.domain.matching.MatchingRequestRepository; +import com.dog.dognation.domain.matching.MatchingStatus; +import com.dog.dognation.domain.user.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 산책 후 피드백 → 찰떡지수(호감도) 반영. + * - 우리 찰떡지수(1:1, 쌍): GOOD +1 / BAD -2 (0 미만 불가) + * - 동네 찰떡지수(1:N, 평가받은 강아지): GOOD +0.5 / BAD -1 (0 미만 불가) + * 한 번의 피드백으로 두 지수를 함께 반영한다. + */ +@Service +public class AffinityService { + + private static final double PAIR_GOOD = 1.0; + private static final double PAIR_BAD = -2.0; + private static final double HOOD_GOOD = 0.5; + private static final double HOOD_BAD = -1.0; + + private final MatchingRequestRepository matchingRepo; + private final DogAffinityRepository affinityRepo; + private final DogNeighborhoodRepository hoodRepo; + private final MatchFeedbackRepository feedbackRepo; + private final DogRepository dogRepo; + private final UserRepository userRepo; + + public AffinityService(MatchingRequestRepository matchingRepo, + DogAffinityRepository affinityRepo, + DogNeighborhoodRepository hoodRepo, + MatchFeedbackRepository feedbackRepo, + DogRepository dogRepo, + UserRepository userRepo) { + this.matchingRepo = matchingRepo; + this.affinityRepo = affinityRepo; + this.hoodRepo = hoodRepo; + this.feedbackRepo = feedbackRepo; + this.dogRepo = dogRepo; + this.userRepo = userRepo; + } + + /** 매칭 산책 후 피드백 제출 → 우리/동네 찰떡지수 반영. */ + @Transactional + public void submitFeedback(Long matchId, Long raterDogId, FeedbackVerdict verdict, String reasons) { + MatchingRequest match = matchingRepo.findById(matchId) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "매칭을 찾을 수 없습니다.")); + if (match.getStatus() != MatchingStatus.ACCEPTED) { + throw new ApiException(HttpStatus.BAD_REQUEST, "성사된 매칭만 피드백할 수 있습니다."); + } + Long a = match.getRequesterDogId(); + Long b = match.getTargetDogId(); + if (!raterDogId.equals(a) && !raterDogId.equals(b)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "이 매칭에 속한 강아지가 아닙니다."); + } + if (feedbackRepo.existsByMatchIdAndRaterDogId(matchId, raterDogId)) { + throw new ApiException(HttpStatus.CONFLICT, "이미 피드백을 남겼어요."); + } + Long ratedDogId = raterDogId.equals(a) ? b : a; + + double pairDelta = verdict == FeedbackVerdict.GOOD ? PAIR_GOOD : PAIR_BAD; + double hoodDelta = verdict == FeedbackVerdict.GOOD ? HOOD_GOOD : HOOD_BAD; + + pair(a, b).apply(pairDelta); // 우리(쌍) + neighborhood(ratedDogId).apply(hoodDelta); // 동네(평가받은 강아지) + feedbackRepo.save(new MatchFeedback(matchId, raterDogId, ratedDogId, verdict, reasons)); + } + + /** 동네 찰떡지수 (전체 노출). */ + @Transactional(readOnly = true) + public NeighborhoodScoreResponse neighborhoodScore(Long dogId) { + double s = hoodRepo.findById(dogId).map(DogNeighborhood::getScore).orElse(0.0); + int pct = hoodPercent(s); + return new NeighborhoodScoreResponse(pct, hoodLabel(pct)); + } + + /** + * 우리 찰떡지수 (1회 이상 매칭된 쌍만 유효). + * viewerDogId = 조회 주체(내 강아지) — 견주가 무료회원이면 locked. + */ + @Transactional(readOnly = true) + public PairScoreResponse pairScore(Long viewerDogId, Long otherDogId) { + boolean matched = matchingRepo.hasAcceptedMatch(viewerDogId, otherDogId); + if (!matched) { + return new PairScoreResponse(false, false, 0, ""); + } + if (!viewerIsPaid(viewerDogId)) { + return new PairScoreResponse(true, true, 0, ""); + } + long lo = Math.min(viewerDogId, otherDogId); + long hi = Math.max(viewerDogId, otherDogId); + double s = affinityRepo.findByDogAIdAndDogBId(lo, hi).map(DogAffinity::getScore).orElse(0.0); + int pct = pairPercent(s); + return new PairScoreResponse(true, false, pct, pairLabel(pct)); + } + + private boolean viewerIsPaid(Long dogId) { + Dog dog = dogRepo.findById(dogId).orElse(null); + if (dog == null) { + return false; + } + return userRepo.findById(dog.getUserId()) + .map(u -> u.getSubscriptionTier().isPaid()) + .orElse(false); + } + + // ---- 내부 ---- + + private DogAffinity pair(long d1, long d2) { + long lo = Math.min(d1, d2); + long hi = Math.max(d1, d2); + return affinityRepo.findByDogAIdAndDogBId(lo, hi) + .orElseGet(() -> affinityRepo.save(new DogAffinity(lo, hi))); + } + + private DogNeighborhood neighborhood(long dogId) { + return hoodRepo.findById(dogId).orElseGet(() -> hoodRepo.save(new DogNeighborhood(dogId))); + } + + /** 우리: 호감도 5 → 100% (GOOD 5회). */ + private int pairPercent(double s) { + return (int) Math.min(100, Math.round(s * 20)); + } + + /** 동네: 호감도 10 → 100%. */ + private int hoodPercent(double s) { + return (int) Math.min(100, Math.round(s * 10)); + } + + private String pairLabel(int p) { + if (p >= 100) return "영혼의 파트너"; + if (p >= 80) return "단짝 친구"; + if (p >= 60) return "친한 사이"; + if (p >= 40) return "아는 사이"; + if (p >= 20) return "서먹한 사이"; + return "첫 만남"; + } + + private String hoodLabel(int p) { + if (p >= 80) return "우리동네 인기 강아지"; + if (p >= 50) return "사교왕"; + if (p >= 20) return "친화력 좋아요"; + return "새내기"; + } +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/affinity/DogAffinity.java b/src/main/java/com/dog/dognation/domain/affinity/DogAffinity.java new file mode 100644 index 0000000..da425f8 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/affinity/DogAffinity.java @@ -0,0 +1,52 @@ +package com.dog.dognation.domain.affinity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** 1:1 우리 찰떡지수 — 강아지 쌍의 호감도(0 이상). dogAId < dogBId 로 정규화. */ +@Entity +@Table(name = "dog_affinity") +public class DogAffinity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "dog_a_id", nullable = false) + private Long dogAId; + + @Column(name = "dog_b_id", nullable = false) + private Long dogBId; + + @Column(nullable = false) + private double score; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + protected DogAffinity() { + } + + public DogAffinity(long dogId1, long dogId2) { + this.dogAId = Math.min(dogId1, dogId2); + this.dogBId = Math.max(dogId1, dogId2); + this.score = 0; + this.updatedAt = OffsetDateTime.now(); + } + + /** 델타 반영 — 0 미만으로 내려가지 않음(호감도 0일 때 차감 없음). */ + public void apply(double delta) { + this.score = Math.max(0, this.score + delta); + this.updatedAt = OffsetDateTime.now(); + } + + public double getScore() { + return score; + } +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/affinity/DogAffinityRepository.java b/src/main/java/com/dog/dognation/domain/affinity/DogAffinityRepository.java new file mode 100644 index 0000000..00ee3c5 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/affinity/DogAffinityRepository.java @@ -0,0 +1,11 @@ +package com.dog.dognation.domain.affinity; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DogAffinityRepository extends JpaRepository { + + /** 정규화된 쌍(dogAId < dogBId)으로 조회. */ + Optional findByDogAIdAndDogBId(Long dogAId, Long dogBId); +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/affinity/DogNeighborhood.java b/src/main/java/com/dog/dognation/domain/affinity/DogNeighborhood.java new file mode 100644 index 0000000..6d93188 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/affinity/DogNeighborhood.java @@ -0,0 +1,43 @@ +package com.dog.dognation.domain.affinity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** 1:N 동네 찰떡지수 — 강아지별 누적 호감도(0 이상). */ +@Entity +@Table(name = "dog_neighborhood") +public class DogNeighborhood { + + @Id + @Column(name = "dog_id") + private Long dogId; + + @Column(nullable = false) + private double score; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + protected DogNeighborhood() { + } + + public DogNeighborhood(long dogId) { + this.dogId = dogId; + this.score = 0; + this.updatedAt = OffsetDateTime.now(); + } + + /** 델타 반영 — 0 미만으로 내려가지 않음. */ + public void apply(double delta) { + this.score = Math.max(0, this.score + delta); + this.updatedAt = OffsetDateTime.now(); + } + + public double getScore() { + return score; + } +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/affinity/DogNeighborhoodRepository.java b/src/main/java/com/dog/dognation/domain/affinity/DogNeighborhoodRepository.java new file mode 100644 index 0000000..2abdabf --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/affinity/DogNeighborhoodRepository.java @@ -0,0 +1,6 @@ +package com.dog.dognation.domain.affinity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DogNeighborhoodRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/affinity/FeedbackVerdict.java b/src/main/java/com/dog/dognation/domain/affinity/FeedbackVerdict.java new file mode 100644 index 0000000..611a8dd --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/affinity/FeedbackVerdict.java @@ -0,0 +1,7 @@ +package com.dog.dognation.domain.affinity; + +/** 산책 후 피드백 결과. */ +public enum FeedbackVerdict { + GOOD, // 잘 놀았어요 + BAD // 안 맞았어요 +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/affinity/MatchFeedback.java b/src/main/java/com/dog/dognation/domain/affinity/MatchFeedback.java new file mode 100644 index 0000000..464f4e7 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/affinity/MatchFeedback.java @@ -0,0 +1,63 @@ +package com.dog.dognation.domain.affinity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** 매칭 산책 후 피드백 (매칭당 평가자 1회). */ +@Entity +@Table(name = "match_feedback") +public class MatchFeedback { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "match_id", nullable = false) + private Long matchId; + + @Column(name = "rater_dog_id", nullable = false) + private Long raterDogId; + + @Column(name = "rated_dog_id", nullable = false) + private Long ratedDogId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private FeedbackVerdict verdict; + + /** BAD 사유(선택) — 쉼표 구분. */ + @Column(length = 500) + private String reasons; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + protected MatchFeedback() { + } + + public MatchFeedback(Long matchId, Long raterDogId, Long ratedDogId, + FeedbackVerdict verdict, String reasons) { + this.matchId = matchId; + this.raterDogId = raterDogId; + this.ratedDogId = ratedDogId; + this.verdict = verdict; + this.reasons = reasons; + this.createdAt = OffsetDateTime.now(); + } + + public Long getRatedDogId() { + return ratedDogId; + } + + public FeedbackVerdict getVerdict() { + return verdict; + } +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/affinity/MatchFeedbackRepository.java b/src/main/java/com/dog/dognation/domain/affinity/MatchFeedbackRepository.java new file mode 100644 index 0000000..99709a2 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/affinity/MatchFeedbackRepository.java @@ -0,0 +1,8 @@ +package com.dog.dognation.domain.affinity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MatchFeedbackRepository extends JpaRepository { + + boolean existsByMatchIdAndRaterDogId(Long matchId, Long raterDogId); +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/matching/MatchingRequest.java b/src/main/java/com/dog/dognation/domain/matching/MatchingRequest.java index 9c9a4b3..32a4fb3 100644 --- a/src/main/java/com/dog/dognation/domain/matching/MatchingRequest.java +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingRequest.java @@ -39,6 +39,10 @@ public class MatchingRequest { @Column(name = "responded_at") private OffsetDateTime respondedAt; + /** 수락 후 2시간 피드백 알림 발송 여부(중복 방지). */ + @Column(name = "feedback_notified", nullable = false) + private boolean feedbackNotified = false; + /** 신청 유효시간 — 이 시간 내 미수락 시 EXPIRED. */ public static final java.time.Duration TTL = java.time.Duration.ofMinutes(20); @@ -76,6 +80,10 @@ public class MatchingRequest { return expiresAt; } + public void markFeedbackNotified() { + this.feedbackNotified = true; + } + public Long getId() { return id; } diff --git a/src/main/java/com/dog/dognation/domain/matching/MatchingRequestRepository.java b/src/main/java/com/dog/dognation/domain/matching/MatchingRequestRepository.java index c487491..8ea207e 100644 --- a/src/main/java/com/dog/dognation/domain/matching/MatchingRequestRepository.java +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingRequestRepository.java @@ -1,12 +1,23 @@ package com.dog.dognation.domain.matching; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.OffsetDateTime; import java.util.List; public interface MatchingRequestRepository extends JpaRepository { + /** 두 강아지가 한 번이라도 매칭 성사(ACCEPTED)된 적 있는지 — 우리 찰떡지수 노출 조건. */ + @Query(""" + SELECT COUNT(m) > 0 FROM MatchingRequest m + WHERE m.status = com.dog.dognation.domain.matching.MatchingStatus.ACCEPTED + AND ((m.requesterDogId = :a AND m.targetDogId = :b) + OR (m.requesterDogId = :b AND m.targetDogId = :a)) + """) + boolean hasAcceptedMatch(@Param("a") Long dogA, @Param("b") Long dogB); + boolean existsByRequesterDogIdAndTargetDogIdAndStatus( Long requesterDogId, Long targetDogId, MatchingStatus status); @@ -20,4 +31,8 @@ public interface MatchingRequestRepository extends JpaRepository findByStatusAndExpiresAtBefore(MatchingStatus status, OffsetDateTime now); + + /** 피드백 알림 대상: 수락됐고 응답 후 2시간 지났으며 아직 알림 안 보낸 신청. */ + List findByStatusAndFeedbackNotifiedFalseAndRespondedAtBefore( + MatchingStatus status, OffsetDateTime cutoff); } diff --git a/src/main/java/com/dog/dognation/domain/matching/MatchingService.java b/src/main/java/com/dog/dognation/domain/matching/MatchingService.java index 17767b1..75627b6 100644 --- a/src/main/java/com/dog/dognation/domain/matching/MatchingService.java +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingService.java @@ -26,6 +26,9 @@ import java.util.List; @Service public class MatchingService { + /** 수락 후 이 시간이 지나면 피드백(찰떡지수) 요청 알림. */ + private static final java.time.Duration FEEDBACK_DELAY = java.time.Duration.ofHours(2); + private final MatchingRequestRepository matchingRequestRepository; private final DogRepository dogRepository; private final UserRepository userRepository; @@ -161,6 +164,31 @@ public class MatchingService { return overdue.size(); } + /** + * 수락 후 2시간 지난 매칭의 양쪽 견주에게 피드백 요청 알림 발송(중복 방지). + * 스케줄러가 호출. FEEDBACK_REQUEST 알림 → 프론트에서 "오늘 산책 어떠셨나요?" 팝업. + */ + @Transactional + public int notifyFeedbackDue() { + OffsetDateTime cutoff = OffsetDateTime.now().minus(FEEDBACK_DELAY); + List due = matchingRequestRepository + .findByStatusAndFeedbackNotifiedFalseAndRespondedAtBefore(MatchingStatus.ACCEPTED, cutoff); + for (MatchingRequest req : due) { + Dog requesterDog = dogRepository.findById(req.getRequesterDogId()).orElse(null); + Dog targetDog = dogRepository.findById(req.getTargetDogId()).orElse(null); + if (requesterDog != null && targetDog != null) { + notifier.notifyUser(requesterDog.getUserId(), new MatchNotification( + "FEEDBACK_REQUEST", req.getId(), "오늘 산책 어떠셨나요?", + label(targetDog) + "와의 산책은 어땠나요? 찰떡지수에 반영돼요.")); + notifier.notifyUser(targetDog.getUserId(), new MatchNotification( + "FEEDBACK_REQUEST", req.getId(), "오늘 산책 어떠셨나요?", + label(requesterDog) + "와의 산책은 어땠나요? 찰떡지수에 반영돼요.")); + } + req.markFeedbackNotified(); + } + return due.size(); + } + private void notifyExpired(MatchingRequest req) { Dog requesterDog = dogRepository.findById(req.getRequesterDogId()).orElse(null); Dog targetDog = dogRepository.findById(req.getTargetDogId()).orElse(null); diff --git a/src/main/java/com/dog/dognation/domain/spot/MateAiService.java b/src/main/java/com/dog/dognation/domain/spot/MateAiService.java index b1d228b..23ad8c3 100644 --- a/src/main/java/com/dog/dognation/domain/spot/MateAiService.java +++ b/src/main/java/com/dog/dognation/domain/spot/MateAiService.java @@ -3,6 +3,8 @@ package com.dog.dognation.domain.spot; import com.dog.dognation.ai.GeminiClient; import com.dog.dognation.api.dto.SpotDtos.AiMateResponse; import com.dog.dognation.common.exception.ApiException; +import com.dog.dognation.domain.affinity.DogAffinityRepository; +import com.dog.dognation.domain.affinity.DogNeighborhoodRepository; import com.dog.dognation.domain.dog.Dog; import com.dog.dognation.domain.dog.DogRepository; import com.dog.dognation.domain.dog.TraitTag; @@ -45,14 +47,20 @@ public class MateAiService { private final CheckInRedisStore checkInRedisStore; private final DogRepository dogRepository; + private final DogAffinityRepository affinityRepository; + private final DogNeighborhoodRepository neighborhoodRepository; private final GeminiClient gemini; private final ObjectMapper mapper = new ObjectMapper(); public MateAiService(CheckInRedisStore checkInRedisStore, DogRepository dogRepository, + DogAffinityRepository affinityRepository, + DogNeighborhoodRepository neighborhoodRepository, GeminiClient gemini) { this.checkInRedisStore = checkInRedisStore; this.dogRepository = dogRepository; + this.affinityRepository = affinityRepository; + this.neighborhoodRepository = neighborhoodRepository; this.gemini = gemini; } @@ -93,7 +101,7 @@ public class MateAiService { private String buildPrompt(Dog myDog, Iterable candidates) { List> cands = new ArrayList<>(); for (Dog d : candidates) { - cands.add(dogJson(d, true)); + cands.add(candidateJson(myDog.getId(), d)); } try { String myJson = mapper.writeValueAsString(dogJson(myDog, false)); @@ -104,6 +112,12 @@ public class MateAiService { 성향 충돌(예: '대형견 무서워함' vs 대형견, 저에너지 vs 과잉에너지)을 고려하고, 궁합이 낮은 강아지는 제외해라. + 참고 지표(있을 때만 반영): + - pastAffinity(0-100): 예전에 내 강아지와 함께 산책한 뒤 쌓인 우리끼리 호감도. + 높으면 이미 잘 맞았다는 신호이니 궁합점수를 올려라. 0이거나 없으면 첫 만남이다. + - neighborhood(0-100): 동네에서 이 강아지가 다른 강아지들과 얼마나 잘 어울리는지. + 높으면 사교성이 좋다는 신호다. 낮다고 무조건 배제하지는 마라. + 내 강아지: %s 공원의 강아지들: %s @@ -116,6 +130,22 @@ public class MateAiService { } } + /** 후보 강아지 JSON — 기본 정보 + 찰떡지수 지표(우리 호감도/동네 사교성). */ + private Map candidateJson(Long myDogId, Dog d) { + Map m = dogJson(d, true); + long lo = Math.min(myDogId, d.getId()); + long hi = Math.max(myDogId, d.getId()); + int pastAffinity = affinityRepository.findByDogAIdAndDogBId(lo, hi) + .map(a -> (int) Math.min(100, Math.round(a.getScore() * 20))) + .orElse(0); + int neighborhood = neighborhoodRepository.findById(d.getId()) + .map(n -> (int) Math.min(100, Math.round(n.getScore() * 10))) + .orElse(0); + m.put("pastAffinity", pastAffinity); + m.put("neighborhood", neighborhood); + return m; + } + private Map dogJson(Dog d, boolean includeId) { Map m = new LinkedHashMap<>(); if (includeId) { diff --git a/src/main/java/com/dog/dognation/domain/user/SubscriptionTier.java b/src/main/java/com/dog/dognation/domain/user/SubscriptionTier.java index ccbe8ac..aa07d8a 100644 --- a/src/main/java/com/dog/dognation/domain/user/SubscriptionTier.java +++ b/src/main/java/com/dog/dognation/domain/user/SubscriptionTier.java @@ -15,4 +15,9 @@ public enum SubscriptionTier { public boolean isQuotaLimited() { return this != PREMIUM; } + + /** 유료회원 여부 (FREE 만 무료) — 우리 찰떡지수 노출 조건. */ + public boolean isPaid() { + return this != FREE; + } } diff --git a/src/main/java/com/dog/dognation/scheduler/MatchExpiryScheduler.java b/src/main/java/com/dog/dognation/scheduler/MatchExpiryScheduler.java index 0eb1e06..d55130e 100644 --- a/src/main/java/com/dog/dognation/scheduler/MatchExpiryScheduler.java +++ b/src/main/java/com/dog/dognation/scheduler/MatchExpiryScheduler.java @@ -32,4 +32,17 @@ public class MatchExpiryScheduler { log.warn("[매칭 만료] 처리 실패: {}", e.toString()); } } + + /** 수락 후 2시간 지난 매칭에 피드백 요청 알림 — 5분마다 확인. */ + @Scheduled(fixedRate = 300_000) + public void notifyFeedbackDue() { + try { + int n = matchingService.notifyFeedbackDue(); + if (n > 0) { + log.info("[피드백 알림] 산책 후 피드백 요청 {}건 발송", n); + } + } catch (Exception e) { + log.warn("[피드백 알림] 처리 실패: {}", e.toString()); + } + } } \ No newline at end of file diff --git a/src/main/resources/db/migration/V8__affinity.sql b/src/main/resources/db/migration/V8__affinity.sql new file mode 100644 index 0000000..c5fe3de --- /dev/null +++ b/src/main/resources/db/migration/V8__affinity.sql @@ -0,0 +1,34 @@ +-- 산책 후 피드백 + 찰떡지수(호감도) + +-- 1:1 우리 찰떡지수 — 강아지 쌍(정렬: dog_a_id < dog_b_id 로 1행/쌍) +CREATE TABLE dog_affinity ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + dog_a_id BIGINT NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + dog_b_id BIGINT NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + score DOUBLE PRECISION NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_dog_affinity UNIQUE (dog_a_id, dog_b_id) +); + +-- 1:N 동네 찰떡지수 — 강아지별 누적 +CREATE TABLE dog_neighborhood ( + dog_id BIGINT PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE, + score DOUBLE PRECISION NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- 매칭 산책 후 피드백 (매칭당 평가자 1회) +CREATE TABLE match_feedback ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + match_id BIGINT NOT NULL REFERENCES matching_requests(id) ON DELETE CASCADE, + rater_dog_id BIGINT NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + rated_dog_id BIGINT NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + verdict VARCHAR(10) NOT NULL CHECK (verdict IN ('GOOD', 'BAD')), + reasons VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_match_feedback UNIQUE (match_id, rater_dog_id) +); +CREATE INDEX idx_feedback_rated ON match_feedback(rated_dog_id); + +-- 2시간 후 피드백 알림 1회 발송 표시 +ALTER TABLE matching_requests ADD COLUMN feedback_notified BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file