feat: 찰떡지수 피드백 기능 (우리/동네 궁합도)
CI / build (push) Failing after 12m43s
Deploy (dev) / deploy (push) Failing after 15m28s

산책 후 피드백으로 강아지 궁합도(찰떡지수)를 누적한다.
- 체크아웃/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) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 23:54:39 +09:00
parent 20bfdd7576
commit 2898ac154c
17 changed files with 554 additions and 1 deletions
@@ -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<Void> 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);
}
}
@@ -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) {
}
}
@@ -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 "새내기";
}
}
@@ -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 &lt; 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;
}
}
@@ -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<DogAffinity, Long> {
/** 정규화된 쌍(dogAId < dogBId)으로 조회. */
Optional<DogAffinity> findByDogAIdAndDogBId(Long dogAId, Long dogBId);
}
@@ -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;
}
}
@@ -0,0 +1,6 @@
package com.dog.dognation.domain.affinity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DogNeighborhoodRepository extends JpaRepository<DogNeighborhood, Long> {
}
@@ -0,0 +1,7 @@
package com.dog.dognation.domain.affinity;
/** 산책 후 피드백 결과. */
public enum FeedbackVerdict {
GOOD, // 잘 놀았어요
BAD // 안 맞았어요
}
@@ -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;
}
}
@@ -0,0 +1,8 @@
package com.dog.dognation.domain.affinity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MatchFeedbackRepository extends JpaRepository<MatchFeedback, Long> {
boolean existsByMatchIdAndRaterDogId(Long matchId, Long raterDogId);
}
@@ -39,6 +39,10 @@ public class MatchingRequest {
@Column(name = "responded_at") @Column(name = "responded_at")
private OffsetDateTime respondedAt; private OffsetDateTime respondedAt;
/** 수락 후 2시간 피드백 알림 발송 여부(중복 방지). */
@Column(name = "feedback_notified", nullable = false)
private boolean feedbackNotified = false;
/** 신청 유효시간 — 이 시간 내 미수락 시 EXPIRED. */ /** 신청 유효시간 — 이 시간 내 미수락 시 EXPIRED. */
public static final java.time.Duration TTL = java.time.Duration.ofMinutes(20); public static final java.time.Duration TTL = java.time.Duration.ofMinutes(20);
@@ -76,6 +80,10 @@ public class MatchingRequest {
return expiresAt; return expiresAt;
} }
public void markFeedbackNotified() {
this.feedbackNotified = true;
}
public Long getId() { public Long getId() {
return id; return id;
} }
@@ -1,12 +1,23 @@
package com.dog.dognation.domain.matching; package com.dog.dognation.domain.matching;
import org.springframework.data.jpa.repository.JpaRepository; 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.time.OffsetDateTime;
import java.util.List; import java.util.List;
public interface MatchingRequestRepository extends JpaRepository<MatchingRequest, Long> { public interface MatchingRequestRepository extends JpaRepository<MatchingRequest, Long> {
/** 두 강아지가 한 번이라도 매칭 성사(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( boolean existsByRequesterDogIdAndTargetDogIdAndStatus(
Long requesterDogId, Long targetDogId, MatchingStatus status); Long requesterDogId, Long targetDogId, MatchingStatus status);
@@ -20,4 +31,8 @@ public interface MatchingRequestRepository extends JpaRepository<MatchingRequest
/** 만료 대상: 아직 대기중인데 만료시각이 지난 신청. */ /** 만료 대상: 아직 대기중인데 만료시각이 지난 신청. */
List<MatchingRequest> findByStatusAndExpiresAtBefore(MatchingStatus status, OffsetDateTime now); List<MatchingRequest> findByStatusAndExpiresAtBefore(MatchingStatus status, OffsetDateTime now);
/** 피드백 알림 대상: 수락됐고 응답 후 2시간 지났으며 아직 알림 안 보낸 신청. */
List<MatchingRequest> findByStatusAndFeedbackNotifiedFalseAndRespondedAtBefore(
MatchingStatus status, OffsetDateTime cutoff);
} }
@@ -26,6 +26,9 @@ import java.util.List;
@Service @Service
public class MatchingService { public class MatchingService {
/** 수락 후 이 시간이 지나면 피드백(찰떡지수) 요청 알림. */
private static final java.time.Duration FEEDBACK_DELAY = java.time.Duration.ofHours(2);
private final MatchingRequestRepository matchingRequestRepository; private final MatchingRequestRepository matchingRequestRepository;
private final DogRepository dogRepository; private final DogRepository dogRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
@@ -161,6 +164,31 @@ public class MatchingService {
return overdue.size(); return overdue.size();
} }
/**
* 수락 후 2시간 지난 매칭의 양쪽 견주에게 피드백 요청 알림 발송(중복 방지).
* 스케줄러가 호출. FEEDBACK_REQUEST 알림 → 프론트에서 "오늘 산책 어떠셨나요?" 팝업.
*/
@Transactional
public int notifyFeedbackDue() {
OffsetDateTime cutoff = OffsetDateTime.now().minus(FEEDBACK_DELAY);
List<MatchingRequest> 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) { private void notifyExpired(MatchingRequest req) {
Dog requesterDog = dogRepository.findById(req.getRequesterDogId()).orElse(null); Dog requesterDog = dogRepository.findById(req.getRequesterDogId()).orElse(null);
Dog targetDog = dogRepository.findById(req.getTargetDogId()).orElse(null); Dog targetDog = dogRepository.findById(req.getTargetDogId()).orElse(null);
@@ -3,6 +3,8 @@ package com.dog.dognation.domain.spot;
import com.dog.dognation.ai.GeminiClient; import com.dog.dognation.ai.GeminiClient;
import com.dog.dognation.api.dto.SpotDtos.AiMateResponse; import com.dog.dognation.api.dto.SpotDtos.AiMateResponse;
import com.dog.dognation.common.exception.ApiException; 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.Dog;
import com.dog.dognation.domain.dog.DogRepository; import com.dog.dognation.domain.dog.DogRepository;
import com.dog.dognation.domain.dog.TraitTag; import com.dog.dognation.domain.dog.TraitTag;
@@ -45,14 +47,20 @@ public class MateAiService {
private final CheckInRedisStore checkInRedisStore; private final CheckInRedisStore checkInRedisStore;
private final DogRepository dogRepository; private final DogRepository dogRepository;
private final DogAffinityRepository affinityRepository;
private final DogNeighborhoodRepository neighborhoodRepository;
private final GeminiClient gemini; private final GeminiClient gemini;
private final ObjectMapper mapper = new ObjectMapper(); private final ObjectMapper mapper = new ObjectMapper();
public MateAiService(CheckInRedisStore checkInRedisStore, public MateAiService(CheckInRedisStore checkInRedisStore,
DogRepository dogRepository, DogRepository dogRepository,
DogAffinityRepository affinityRepository,
DogNeighborhoodRepository neighborhoodRepository,
GeminiClient gemini) { GeminiClient gemini) {
this.checkInRedisStore = checkInRedisStore; this.checkInRedisStore = checkInRedisStore;
this.dogRepository = dogRepository; this.dogRepository = dogRepository;
this.affinityRepository = affinityRepository;
this.neighborhoodRepository = neighborhoodRepository;
this.gemini = gemini; this.gemini = gemini;
} }
@@ -93,7 +101,7 @@ public class MateAiService {
private String buildPrompt(Dog myDog, Iterable<Dog> candidates) { private String buildPrompt(Dog myDog, Iterable<Dog> candidates) {
List<Map<String, Object>> cands = new ArrayList<>(); List<Map<String, Object>> cands = new ArrayList<>();
for (Dog d : candidates) { for (Dog d : candidates) {
cands.add(dogJson(d, true)); cands.add(candidateJson(myDog.getId(), d));
} }
try { try {
String myJson = mapper.writeValueAsString(dogJson(myDog, false)); String myJson = mapper.writeValueAsString(dogJson(myDog, false));
@@ -104,6 +112,12 @@ public class MateAiService {
성향 충돌(예: '대형견 무서워함' vs 대형견, 저에너지 vs 과잉에너지)을 고려하고, 성향 충돌(예: '대형견 무서워함' vs 대형견, 저에너지 vs 과잉에너지)을 고려하고,
궁합이 낮은 강아지는 제외해라. 궁합이 낮은 강아지는 제외해라.
참고 지표(있을 때만 반영):
- pastAffinity(0-100): 예전에 내 강아지와 함께 산책한 뒤 쌓인 우리끼리 호감도.
높으면 이미 잘 맞았다는 신호이니 궁합점수를 올려라. 0이거나 없으면 첫 만남이다.
- neighborhood(0-100): 동네에서 이 강아지가 다른 강아지들과 얼마나 잘 어울리는지.
높으면 사교성이 좋다는 신호다. 낮다고 무조건 배제하지는 마라.
내 강아지: %s 내 강아지: %s
공원의 강아지들: %s 공원의 강아지들: %s
@@ -116,6 +130,22 @@ public class MateAiService {
} }
} }
/** 후보 강아지 JSON — 기본 정보 + 찰떡지수 지표(우리 호감도/동네 사교성). */
private Map<String, Object> candidateJson(Long myDogId, Dog d) {
Map<String, Object> 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<String, Object> dogJson(Dog d, boolean includeId) { private Map<String, Object> dogJson(Dog d, boolean includeId) {
Map<String, Object> m = new LinkedHashMap<>(); Map<String, Object> m = new LinkedHashMap<>();
if (includeId) { if (includeId) {
@@ -15,4 +15,9 @@ public enum SubscriptionTier {
public boolean isQuotaLimited() { public boolean isQuotaLimited() {
return this != PREMIUM; return this != PREMIUM;
} }
/** 유료회원 여부 (FREE 만 무료) — 우리 찰떡지수 노출 조건. */
public boolean isPaid() {
return this != FREE;
}
} }
@@ -32,4 +32,17 @@ public class MatchExpiryScheduler {
log.warn("[매칭 만료] 처리 실패: {}", e.toString()); 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());
}
}
} }
@@ -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;