feat: 매칭 플로우 완성 (진행중·알림·수락 1:1채팅·거절·20분 자동만료)
CI / build (push) Failing after 16m2s

- 20분 미수락 자동 종료: MatchingRequest.expiresAt + EXPIRED 상태 + 1분 스케줄러
- 신청/수락/거절/만료 시 상대에게 STOMP 알림 (/topic/users/{userId})
- 수락 시 1:1 매칭 채팅방 생성 + 결과(양쪽 강아지 정보) 메시지, /topic/matches/{id}
- 보낸 대기중 조회 GET /api/matches/sent/{dogId} (진행중 표시용)
- 마이그레이션 V7: matching_requests.expires_at, EXPIRED, chat_rooms.match_id

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 14:49:39 +09:00
parent 29982e3572
commit f6882aef44
16 changed files with 301 additions and 15 deletions
@@ -24,4 +24,10 @@ public class ChatController {
public List<ChatMessageResponse> history(@PathVariable Long spotId) { public List<ChatMessageResponse> history(@PathVariable Long spotId) {
return chatService.history(spotId); return chatService.history(spotId);
} }
/** 매칭 수락으로 열린 1:1 채팅 히스토리. */
@GetMapping("/matches/{matchId}/messages")
public List<ChatMessageResponse> matchHistory(@PathVariable Long matchId) {
return chatService.matchHistory(matchId);
}
} }
@@ -53,4 +53,12 @@ public class MatchingController {
.map(MatchResponse::from) .map(MatchResponse::from)
.toList(); .toList();
} }
/** 특정 견이 보낸 대기중 신청 목록 (진행중 표시용). */
@GetMapping("/sent/{dogId}")
public List<MatchResponse> sent(@PathVariable Long dogId) {
return matchingService.findSentPending(dogId).stream()
.map(MatchResponse::from)
.toList();
}
} }
@@ -13,6 +13,7 @@ public final class ChatDtos {
public record ChatMessageResponse( public record ChatMessageResponse(
Long id, Long id,
Long spotId, Long spotId,
Long matchId,
Long senderId, Long senderId,
String senderNickname, String senderNickname,
String content, String content,
@@ -0,0 +1,8 @@
package com.dog.dognation.api.dto;
/**
* 매칭 실시간 알림 페이로드 (STOMP /topic/users/{userId}).
* type: NEW_REQUEST(신청 받음) | ACCEPTED(수락됨) | REJECTED(거절됨) | EXPIRED(자동종료)
*/
public record MatchNotification(String type, Long matchId, String title, String message) {
}
@@ -10,6 +10,7 @@ public record MatchResponse(
Long targetDogId, Long targetDogId,
String status, String status,
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime expiresAt,
OffsetDateTime respondedAt OffsetDateTime respondedAt
) { ) {
public static MatchResponse from(MatchingRequest r) { public static MatchResponse from(MatchingRequest r) {
@@ -19,6 +20,7 @@ public record MatchResponse(
r.getTargetDogId(), r.getTargetDogId(),
r.getStatus().name(), r.getStatus().name(),
r.getCreatedAt(), r.getCreatedAt(),
r.getExpiresAt(),
r.getRespondedAt() r.getRespondedAt()
); );
} }
@@ -39,4 +39,17 @@ public class ChatSocketController {
ChatMessageResponse res = chatService.post(spotId, user.userId(), req.content()); ChatMessageResponse res = chatService.post(spotId, user.userId(), req.content());
messagingTemplate.convertAndSend("/topic/spots/" + spotId, res); messagingTemplate.convertAndSend("/topic/spots/" + spotId, res);
} }
/** 매칭 수락으로 열린 1:1 채팅 전송 → 구독자에게 브로드캐스트. */
@MessageMapping("/matches/{matchId}/chat")
public void sendMatch(@DestinationVariable Long matchId,
@Valid @Payload ChatSendRequest req,
Principal principal) {
if (!(principal instanceof StompPrincipal user)) {
log.warn("[chat] 미인증 전송 시도 match={}", matchId);
return;
}
ChatMessageResponse res = chatService.postToMatch(matchId, user.userId(), req.content());
messagingTemplate.convertAndSend("/topic/matches/" + matchId, res);
}
} }
@@ -9,7 +9,10 @@ import jakarta.persistence.Table;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
/** 스팟 기반 오픈 채팅방. 스팟당 1개, 최초 메시지 시 생성. */ /**
* 채팅방. 스팟 기반 오픈채팅(spotId) 또는 매칭 수락 시 1:1 방(matchId).
* 둘 중 하나만 채워진다.
*/
@Entity @Entity
@Table(name = "chat_rooms") @Table(name = "chat_rooms")
public class ChatRoom { public class ChatRoom {
@@ -21,6 +24,10 @@ public class ChatRoom {
@Column(name = "spot_id") @Column(name = "spot_id")
private Long spotId; private Long spotId;
/** 매칭 수락으로 열린 1:1 방의 매칭 신청 id. */
@Column(name = "match_id")
private Long matchId;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now(); private OffsetDateTime createdAt = OffsetDateTime.now();
@@ -32,6 +39,14 @@ public class ChatRoom {
this.createdAt = OffsetDateTime.now(); this.createdAt = OffsetDateTime.now();
} }
/** 매칭 1:1 채팅방 생성. */
public static ChatRoom forMatch(Long matchId) {
ChatRoom room = new ChatRoom();
room.matchId = matchId;
room.createdAt = OffsetDateTime.now();
return room;
}
public Long getId() { public Long getId() {
return id; return id;
} }
@@ -39,4 +54,8 @@ public class ChatRoom {
public Long getSpotId() { public Long getSpotId() {
return spotId; return spotId;
} }
public Long getMatchId() {
return matchId;
}
} }
@@ -7,4 +7,6 @@ import java.util.Optional;
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> { public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
Optional<ChatRoom> findBySpotId(Long spotId); Optional<ChatRoom> findBySpotId(Long spotId);
Optional<ChatRoom> findByMatchId(Long matchId);
} }
@@ -56,11 +56,53 @@ public class ChatService {
ChatRoom room = roomForSpot(spotId); ChatRoom room = roomForSpot(spotId);
ChatMessage saved = messageRepository.save(new ChatMessage(room.getId(), senderId, content)); ChatMessage saved = messageRepository.save(new ChatMessage(room.getId(), senderId, content));
String nickname = userRepository.findById(senderId).map(User::getNickname).orElse("알 수 없음"); String nickname = userRepository.findById(senderId).map(User::getNickname).orElse("알 수 없음");
return new ChatMessageResponse(saved.getId(), spotId, senderId, nickname, return new ChatMessageResponse(saved.getId(), spotId, null, senderId, nickname,
saved.getContent(), saved.getCreatedAt());
}
// ==================== 매칭 1:1 채팅 ====================
/** 매칭 수락 시 1:1 채팅방 확보(없으면 생성). */
@Transactional
public ChatRoom roomForMatch(Long matchId) {
return roomRepository.findByMatchId(matchId)
.orElseGet(() -> roomRepository.save(ChatRoom.forMatch(matchId)));
}
/** 매칭 1:1 채팅 최근 메시지. 방이 없으면 빈 목록. */
@Transactional(readOnly = true)
public List<ChatMessageResponse> matchHistory(Long matchId) {
return roomRepository.findByMatchId(matchId)
.map(room -> toMatchResponses(matchId,
messageRepository.findTop100ByRoomIdOrderByCreatedAtAsc(room.getId())))
.orElseGet(List::of);
}
/** 매칭 1:1 메시지 저장 후 브로드캐스트용 응답 반환. */
@Transactional
public ChatMessageResponse postToMatch(Long matchId, Long senderId, String content) {
ChatRoom room = roomForMatch(matchId);
ChatMessage saved = messageRepository.save(new ChatMessage(room.getId(), senderId, content));
String nickname = userRepository.findById(senderId).map(User::getNickname).orElse("알 수 없음");
return new ChatMessageResponse(saved.getId(), null, matchId, senderId, nickname,
saved.getContent(), saved.getCreatedAt()); saved.getContent(), saved.getCreatedAt());
} }
private List<ChatMessageResponse> toResponses(Long spotId, List<ChatMessage> messages) { private List<ChatMessageResponse> toResponses(Long spotId, List<ChatMessage> messages) {
return mapMessages(messages, (m, nick) ->
new ChatMessageResponse(m.getId(), spotId, null, m.getSenderId(), nick,
m.getContent(), m.getCreatedAt()));
}
private List<ChatMessageResponse> toMatchResponses(Long matchId, List<ChatMessage> messages) {
return mapMessages(messages, (m, nick) ->
new ChatMessageResponse(m.getId(), null, matchId, m.getSenderId(), nick,
m.getContent(), m.getCreatedAt()));
}
private List<ChatMessageResponse> mapMessages(
List<ChatMessage> messages,
java.util.function.BiFunction<ChatMessage, String, ChatMessageResponse> mapper) {
if (messages.isEmpty()) { if (messages.isEmpty()) {
return List.of(); return List.of();
} }
@@ -68,9 +110,7 @@ public class ChatService {
Map<Long, String> nicknames = userRepository.findAllById(senderIds).stream() Map<Long, String> nicknames = userRepository.findAllById(senderIds).stream()
.collect(Collectors.toMap(User::getId, User::getNickname, (a, b) -> a)); .collect(Collectors.toMap(User::getId, User::getNickname, (a, b) -> a));
return messages.stream() return messages.stream()
.map(m -> new ChatMessageResponse(m.getId(), spotId, m.getSenderId(), .map(m -> mapper.apply(m, nicknames.getOrDefault(m.getSenderId(), "알 수 없음")))
nicknames.getOrDefault(m.getSenderId(), "알 수 없음"),
m.getContent(), m.getCreatedAt()))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
} }
@@ -0,0 +1,24 @@
package com.dog.dognation.domain.matching;
import com.dog.dognation.api.dto.MatchNotification;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
/**
* 매칭 관련 실시간 알림 발송 (STOMP).
* 사용자별 토픽 /topic/users/{userId} 로 전송하며, 프론트가 로그인 시 구독한다.
* 구독자가 없어도 안전하게 무시된다.
*/
@Component
public class MatchNotifier {
private final SimpMessagingTemplate messaging;
public MatchNotifier(SimpMessagingTemplate messaging) {
this.messaging = messaging;
}
public void notifyUser(Long userId, MatchNotification payload) {
messaging.convertAndSend("/topic/users/" + userId, payload);
}
}
@@ -32,9 +32,16 @@ public class MatchingRequest {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now(); private OffsetDateTime createdAt = OffsetDateTime.now();
/** 20분 내 미수락 시 자동 종료 기준 시각. */
@Column(name = "expires_at")
private OffsetDateTime expiresAt;
@Column(name = "responded_at") @Column(name = "responded_at")
private OffsetDateTime respondedAt; private OffsetDateTime respondedAt;
/** 신청 유효시간 — 이 시간 내 미수락 시 EXPIRED. */
public static final java.time.Duration TTL = java.time.Duration.ofMinutes(20);
protected MatchingRequest() { protected MatchingRequest() {
} }
@@ -43,6 +50,7 @@ public class MatchingRequest {
this.targetDogId = targetDogId; this.targetDogId = targetDogId;
this.status = MatchingStatus.PENDING; this.status = MatchingStatus.PENDING;
this.createdAt = OffsetDateTime.now(); this.createdAt = OffsetDateTime.now();
this.expiresAt = this.createdAt.plus(TTL);
} }
/** 대상 견주가 신청을 수락/거절 처리한다. */ /** 대상 견주가 신청을 수락/거절 처리한다. */
@@ -54,6 +62,20 @@ public class MatchingRequest {
this.respondedAt = OffsetDateTime.now(); this.respondedAt = OffsetDateTime.now();
} }
/** 20분 경과로 자동 종료. */
public void expire() {
this.status = MatchingStatus.EXPIRED;
this.respondedAt = OffsetDateTime.now();
}
public boolean isExpired(OffsetDateTime now) {
return expiresAt != null && now.isAfter(expiresAt);
}
public OffsetDateTime getExpiresAt() {
return expiresAt;
}
public Long getId() { public Long getId() {
return id; return id;
} }
@@ -2,6 +2,7 @@ package com.dog.dognation.domain.matching;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
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> {
@@ -12,4 +13,11 @@ public interface MatchingRequestRepository extends JpaRepository<MatchingRequest
/** 특정 견이 받은 신청 목록(대상 기준). */ /** 특정 견이 받은 신청 목록(대상 기준). */
List<MatchingRequest> findByTargetDogIdAndStatusOrderByCreatedAtDesc( List<MatchingRequest> findByTargetDogIdAndStatusOrderByCreatedAtDesc(
Long targetDogId, MatchingStatus status); Long targetDogId, MatchingStatus status);
/** 특정 견이 보낸 신청 목록(신청자 기준) — 진행중 표시용. */
List<MatchingRequest> findByRequesterDogIdAndStatusOrderByCreatedAtDesc(
Long requesterDogId, MatchingStatus status);
/** 만료 대상: 아직 대기중인데 만료시각이 지난 신청. */
List<MatchingRequest> findByStatusAndExpiresAtBefore(MatchingStatus status, OffsetDateTime now);
} }
@@ -1,5 +1,7 @@
package com.dog.dognation.domain.matching; package com.dog.dognation.domain.matching;
import com.dog.dognation.api.dto.MatchNotification;
import com.dog.dognation.domain.chat.ChatService;
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.user.User; import com.dog.dognation.domain.user.User;
@@ -8,14 +10,17 @@ 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.OffsetDateTime;
import java.util.List; import java.util.List;
/** /**
* 매칭 신청/응답 도메인 서비스. * 매칭 신청/응답 도메인 서비스.
* *
* 티어별 쿼터 가드: * 플로우:
* - PREMIUM : 무제한 (쿼터 차감 없음) * - 신청: 티어별 쿼터 차감(FREE/AD_FREE 하루 3회) → PENDING(20분 만료) → 상대에게 알림
* - FREE/AD_FREE : 하루 3회. consumeOne() 으로 1회 차감, 소진 시 QuotaExceededException. * - 수락: 1:1 채팅방 생성 + 결과 메시지 → 신청자에게 알림
* - 거절: 종료 → 신청자에게 불발 알림
* - 만료: 20분 내 미수락 시 스케줄러가 EXPIRED 처리 → 신청자에게 알림
*/ */
@Service @Service
public class MatchingService { public class MatchingService {
@@ -24,15 +29,21 @@ public class MatchingService {
private final DogRepository dogRepository; private final DogRepository dogRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final MatchQuotaService quotaService; private final MatchQuotaService quotaService;
private final ChatService chatService;
private final MatchNotifier notifier;
public MatchingService(MatchingRequestRepository matchingRequestRepository, public MatchingService(MatchingRequestRepository matchingRequestRepository,
DogRepository dogRepository, DogRepository dogRepository,
UserRepository userRepository, UserRepository userRepository,
MatchQuotaService quotaService) { MatchQuotaService quotaService,
ChatService chatService,
MatchNotifier notifier) {
this.matchingRequestRepository = matchingRequestRepository; this.matchingRequestRepository = matchingRequestRepository;
this.dogRepository = dogRepository; this.dogRepository = dogRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.quotaService = quotaService; this.quotaService = quotaService;
this.chatService = chatService;
this.notifier = notifier;
} }
@Transactional @Transactional
@@ -43,9 +54,8 @@ public class MatchingService {
Dog requesterDog = dogRepository.findById(requesterDogId) Dog requesterDog = dogRepository.findById(requesterDogId)
.orElseThrow(() -> new EntityNotFoundException("신청 견을 찾을 수 없습니다: " + requesterDogId)); .orElseThrow(() -> new EntityNotFoundException("신청 견을 찾을 수 없습니다: " + requesterDogId));
if (!dogRepository.existsById(targetDogId)) { Dog targetDog = dogRepository.findById(targetDogId)
throw new EntityNotFoundException("대상 견을 찾을 수 없습니다: " + targetDogId); .orElseThrow(() -> new EntityNotFoundException("대상 견을 찾을 수 없습니다: " + targetDogId));
}
// 같은 대상에게 이미 대기중인 신청이 있으면 중복 방지 (쿼터 차감 전에 검사) // 같은 대상에게 이미 대기중인 신청이 있으면 중복 방지 (쿼터 차감 전에 검사)
if (matchingRequestRepository.existsByRequesterDogIdAndTargetDogIdAndStatus( if (matchingRequestRepository.existsByRequesterDogIdAndTargetDogIdAndStatus(
@@ -63,7 +73,13 @@ public class MatchingService {
} }
} }
return matchingRequestRepository.save(new MatchingRequest(requesterDogId, targetDogId)); MatchingRequest saved = matchingRequestRepository.save(new MatchingRequest(requesterDogId, targetDogId));
// 상대(대상 견주)에게 신청 알림
notifier.notifyUser(targetDog.getUserId(), new MatchNotification(
"NEW_REQUEST", saved.getId(), "새 매칭 신청",
label(requesterDog) + "가 산책 메이트를 신청했어요."));
return saved;
} }
@Transactional @Transactional
@@ -73,7 +89,34 @@ public class MatchingService {
if (request.getStatus() != MatchingStatus.PENDING) { if (request.getStatus() != MatchingStatus.PENDING) {
throw new IllegalStateException("이미 처리된 매칭 신청입니다. (현재 상태: " + request.getStatus() + ")"); throw new IllegalStateException("이미 처리된 매칭 신청입니다. (현재 상태: " + request.getStatus() + ")");
} }
// 20분 경과분은 수락 불가 — 만료 처리
if (request.isExpired(OffsetDateTime.now())) {
request.expire();
notifyExpired(request);
throw new IllegalStateException("20분이 지나 만료된 매칭 신청입니다.");
}
Dog requesterDog = dogRepository.findById(request.getRequesterDogId()).orElse(null);
Dog targetDog = dogRepository.findById(request.getTargetDogId()).orElse(null);
request.respond(accept ? MatchingStatus.ACCEPTED : MatchingStatus.REJECTED); request.respond(accept ? MatchingStatus.ACCEPTED : MatchingStatus.REJECTED);
if (accept) {
// 1:1 채팅방 생성 + 결과(양쪽 강아지 정보) 메시지. 발신자는 수락자(대상 견주).
chatService.roomForMatch(request.getId());
if (requesterDog != null && targetDog != null) {
chatService.postToMatch(request.getId(), targetDog.getUserId(),
"🎉 매칭 성사! " + label(requesterDog) + "" + label(targetDog)
+ " 산책 메이트가 되었어요.");
notifier.notifyUser(requesterDog.getUserId(), new MatchNotification(
"ACCEPTED", request.getId(), "매칭 성사",
label(targetDog) + "가 매칭을 수락했어요! 채팅을 시작하세요."));
}
} else if (requesterDog != null && targetDog != null) {
notifier.notifyUser(requesterDog.getUserId(), new MatchNotification(
"REJECTED", request.getId(), "매칭 불발",
"아쉽지만 " + label(targetDog) + "와의 매칭이 성사되지 않았어요."));
}
return request; // 더티 체킹으로 flush return request; // 더티 체킹으로 flush
} }
@@ -82,4 +125,39 @@ public class MatchingService {
return matchingRequestRepository.findByTargetDogIdAndStatusOrderByCreatedAtDesc( return matchingRequestRepository.findByTargetDogIdAndStatusOrderByCreatedAtDesc(
targetDogId, MatchingStatus.PENDING); targetDogId, MatchingStatus.PENDING);
} }
}
/** 내가 보낸 대기중 신청 — 프론트 "진행중" 표시용. */
@Transactional(readOnly = true)
public List<MatchingRequest> findSentPending(Long requesterDogId) {
return matchingRequestRepository.findByRequesterDogIdAndStatusOrderByCreatedAtDesc(
requesterDogId, MatchingStatus.PENDING);
}
/** 20분 내 미수락 신청을 EXPIRED 처리하고 신청자에게 알림. 스케줄러가 호출. */
@Transactional
public int expireOverdue() {
List<MatchingRequest> overdue = matchingRequestRepository
.findByStatusAndExpiresAtBefore(MatchingStatus.PENDING, OffsetDateTime.now());
for (MatchingRequest req : overdue) {
req.expire();
notifyExpired(req);
}
return overdue.size();
}
private void notifyExpired(MatchingRequest req) {
Dog requesterDog = dogRepository.findById(req.getRequesterDogId()).orElse(null);
Dog targetDog = dogRepository.findById(req.getTargetDogId()).orElse(null);
if (requesterDog == null) {
return;
}
String who = targetDog != null ? label(targetDog) : "상대";
notifier.notifyUser(requesterDog.getUserId(), new MatchNotification(
"EXPIRED", req.getId(), "매칭 만료",
who + "가 20분 내 응답하지 않아 매칭이 종료됐어요."));
}
private String label(Dog dog) {
return dog.getBreed() == null ? dog.getName() : dog.getName() + "(" + dog.getBreed() + ")";
}
}
@@ -4,5 +4,6 @@ public enum MatchingStatus {
PENDING, // 신청 대기 PENDING, // 신청 대기
ACCEPTED, // 수락됨 ACCEPTED, // 수락됨
REJECTED, // 거절됨 REJECTED, // 거절됨
CANCELED // 신청 취소 CANCELED, // 신청 취소
EXPIRED // 20분 내 미수락으로 자동 종료
} }
@@ -0,0 +1,35 @@
package com.dog.dognation.scheduler;
import com.dog.dognation.domain.matching.MatchingService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 매칭 신청 20분 자동 종료 스케줄러.
* 1분마다 대기중(PENDING)인데 만료시각이 지난 신청을 EXPIRED 로 처리하고 신청자에게 알린다.
*/
@Component
public class MatchExpiryScheduler {
private static final Logger log = LoggerFactory.getLogger(MatchExpiryScheduler.class);
private final MatchingService matchingService;
public MatchExpiryScheduler(MatchingService matchingService) {
this.matchingService = matchingService;
}
@Scheduled(fixedRate = 60_000)
public void expireOverdueMatches() {
try {
int n = matchingService.expireOverdue();
if (n > 0) {
log.info("[매칭 만료] 20분 미수락 {}건 자동 종료", n);
}
} catch (Exception e) {
log.warn("[매칭 만료] 처리 실패: {}", e.toString());
}
}
}
@@ -0,0 +1,19 @@
-- 매칭 플로우 확장: 20분 자동 만료(EXPIRED) + 수락 시 1:1 매칭 채팅방
-- 1) 매칭 신청 20분 만료 시각 + EXPIRED 상태 허용
ALTER TABLE matching_requests ADD COLUMN expires_at TIMESTAMPTZ;
UPDATE matching_requests
SET expires_at = created_at + INTERVAL '20 minutes'
WHERE expires_at IS NULL;
ALTER TABLE matching_requests DROP CONSTRAINT IF EXISTS matching_requests_status_check;
ALTER TABLE matching_requests ADD CONSTRAINT matching_requests_status_check
CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELED', 'EXPIRED'));
CREATE INDEX IF NOT EXISTS idx_matching_requester ON matching_requests(requester_dog_id, status);
CREATE INDEX IF NOT EXISTS idx_matching_expiry ON matching_requests(status, expires_at);
-- 2) 매칭 수락 시 생성되는 1:1 채팅방 (chat_rooms 재사용, match_id 추가)
ALTER TABLE chat_rooms ADD COLUMN match_id BIGINT
REFERENCES matching_requests(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_rooms_match ON chat_rooms(match_id);