From f6882aef44318f0c0b3c5d338c962d82705f98bc Mon Sep 17 00:00:00 2001 From: sb Date: Sat, 11 Jul 2026 14:49:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=A4=EC=B9=AD=20=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EC=99=84=EC=84=B1=20(=EC=A7=84=ED=96=89=EC=A4=91?= =?UTF-8?q?=C2=B7=EC=95=8C=EB=A6=BC=C2=B7=EC=88=98=EB=9D=BD=201:1=EC=B1=84?= =?UTF-8?q?=ED=8C=85=C2=B7=EA=B1=B0=EC=A0=88=C2=B720=EB=B6=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EB=A7=8C=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../com/dog/dognation/api/ChatController.java | 6 ++ .../dog/dognation/api/MatchingController.java | 8 ++ .../com/dog/dognation/api/dto/ChatDtos.java | 1 + .../dognation/api/dto/MatchNotification.java | 8 ++ .../dog/dognation/api/dto/MatchResponse.java | 2 + .../dognation/chat/ChatSocketController.java | 13 +++ .../dog/dognation/domain/chat/ChatRoom.java | 21 +++- .../domain/chat/ChatRoomRepository.java | 2 + .../dognation/domain/chat/ChatService.java | 48 +++++++++- .../domain/matching/MatchNotifier.java | 24 +++++ .../domain/matching/MatchingRequest.java | 22 +++++ .../matching/MatchingRequestRepository.java | 8 ++ .../domain/matching/MatchingService.java | 96 +++++++++++++++++-- .../domain/matching/MatchingStatus.java | 3 +- .../scheduler/MatchExpiryScheduler.java | 35 +++++++ .../db/migration/V7__matching_flow.sql | 19 ++++ 16 files changed, 301 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/dog/dognation/api/dto/MatchNotification.java create mode 100644 src/main/java/com/dog/dognation/domain/matching/MatchNotifier.java create mode 100644 src/main/java/com/dog/dognation/scheduler/MatchExpiryScheduler.java create mode 100644 src/main/resources/db/migration/V7__matching_flow.sql diff --git a/src/main/java/com/dog/dognation/api/ChatController.java b/src/main/java/com/dog/dognation/api/ChatController.java index b43241f..9af68e8 100644 --- a/src/main/java/com/dog/dognation/api/ChatController.java +++ b/src/main/java/com/dog/dognation/api/ChatController.java @@ -24,4 +24,10 @@ public class ChatController { public List history(@PathVariable Long spotId) { return chatService.history(spotId); } + + /** 매칭 수락으로 열린 1:1 채팅 히스토리. */ + @GetMapping("/matches/{matchId}/messages") + public List matchHistory(@PathVariable Long matchId) { + return chatService.matchHistory(matchId); + } } diff --git a/src/main/java/com/dog/dognation/api/MatchingController.java b/src/main/java/com/dog/dognation/api/MatchingController.java index 8e2f6a5..dc0b570 100644 --- a/src/main/java/com/dog/dognation/api/MatchingController.java +++ b/src/main/java/com/dog/dognation/api/MatchingController.java @@ -53,4 +53,12 @@ public class MatchingController { .map(MatchResponse::from) .toList(); } + + /** 특정 견이 보낸 대기중 신청 목록 (진행중 표시용). */ + @GetMapping("/sent/{dogId}") + public List sent(@PathVariable Long dogId) { + return matchingService.findSentPending(dogId).stream() + .map(MatchResponse::from) + .toList(); + } } diff --git a/src/main/java/com/dog/dognation/api/dto/ChatDtos.java b/src/main/java/com/dog/dognation/api/dto/ChatDtos.java index 713703f..d037d9c 100644 --- a/src/main/java/com/dog/dognation/api/dto/ChatDtos.java +++ b/src/main/java/com/dog/dognation/api/dto/ChatDtos.java @@ -13,6 +13,7 @@ public final class ChatDtos { public record ChatMessageResponse( Long id, Long spotId, + Long matchId, Long senderId, String senderNickname, String content, diff --git a/src/main/java/com/dog/dognation/api/dto/MatchNotification.java b/src/main/java/com/dog/dognation/api/dto/MatchNotification.java new file mode 100644 index 0000000..59b899a --- /dev/null +++ b/src/main/java/com/dog/dognation/api/dto/MatchNotification.java @@ -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) { +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/api/dto/MatchResponse.java b/src/main/java/com/dog/dognation/api/dto/MatchResponse.java index beb7d8e..3680831 100644 --- a/src/main/java/com/dog/dognation/api/dto/MatchResponse.java +++ b/src/main/java/com/dog/dognation/api/dto/MatchResponse.java @@ -10,6 +10,7 @@ public record MatchResponse( Long targetDogId, String status, OffsetDateTime createdAt, + OffsetDateTime expiresAt, OffsetDateTime respondedAt ) { public static MatchResponse from(MatchingRequest r) { @@ -19,6 +20,7 @@ public record MatchResponse( r.getTargetDogId(), r.getStatus().name(), r.getCreatedAt(), + r.getExpiresAt(), r.getRespondedAt() ); } diff --git a/src/main/java/com/dog/dognation/chat/ChatSocketController.java b/src/main/java/com/dog/dognation/chat/ChatSocketController.java index cedfcc2..99995fe 100644 --- a/src/main/java/com/dog/dognation/chat/ChatSocketController.java +++ b/src/main/java/com/dog/dognation/chat/ChatSocketController.java @@ -39,4 +39,17 @@ public class ChatSocketController { ChatMessageResponse res = chatService.post(spotId, user.userId(), req.content()); 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); + } } diff --git a/src/main/java/com/dog/dognation/domain/chat/ChatRoom.java b/src/main/java/com/dog/dognation/domain/chat/ChatRoom.java index 6f7b73b..ecdc345 100644 --- a/src/main/java/com/dog/dognation/domain/chat/ChatRoom.java +++ b/src/main/java/com/dog/dognation/domain/chat/ChatRoom.java @@ -9,7 +9,10 @@ import jakarta.persistence.Table; import java.time.OffsetDateTime; -/** 스팟 기반 오픈 채팅방. 스팟당 1개, 최초 메시지 시 생성. */ +/** + * 채팅방. 스팟 기반 오픈채팅(spotId) 또는 매칭 수락 시 1:1 방(matchId). + * 둘 중 하나만 채워진다. + */ @Entity @Table(name = "chat_rooms") public class ChatRoom { @@ -21,6 +24,10 @@ public class ChatRoom { @Column(name = "spot_id") private Long spotId; + /** 매칭 수락으로 열린 1:1 방의 매칭 신청 id. */ + @Column(name = "match_id") + private Long matchId; + @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt = OffsetDateTime.now(); @@ -32,6 +39,14 @@ public class ChatRoom { 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() { return id; } @@ -39,4 +54,8 @@ public class ChatRoom { public Long getSpotId() { return spotId; } + + public Long getMatchId() { + return matchId; + } } diff --git a/src/main/java/com/dog/dognation/domain/chat/ChatRoomRepository.java b/src/main/java/com/dog/dognation/domain/chat/ChatRoomRepository.java index e8540b7..03102fb 100644 --- a/src/main/java/com/dog/dognation/domain/chat/ChatRoomRepository.java +++ b/src/main/java/com/dog/dognation/domain/chat/ChatRoomRepository.java @@ -7,4 +7,6 @@ import java.util.Optional; public interface ChatRoomRepository extends JpaRepository { Optional findBySpotId(Long spotId); + + Optional findByMatchId(Long matchId); } diff --git a/src/main/java/com/dog/dognation/domain/chat/ChatService.java b/src/main/java/com/dog/dognation/domain/chat/ChatService.java index 12f3044..17b2076 100644 --- a/src/main/java/com/dog/dognation/domain/chat/ChatService.java +++ b/src/main/java/com/dog/dognation/domain/chat/ChatService.java @@ -56,11 +56,53 @@ public class ChatService { ChatRoom room = roomForSpot(spotId); ChatMessage saved = messageRepository.save(new ChatMessage(room.getId(), senderId, content)); 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 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()); } private List toResponses(Long spotId, List messages) { + return mapMessages(messages, (m, nick) -> + new ChatMessageResponse(m.getId(), spotId, null, m.getSenderId(), nick, + m.getContent(), m.getCreatedAt())); + } + + private List toMatchResponses(Long matchId, List messages) { + return mapMessages(messages, (m, nick) -> + new ChatMessageResponse(m.getId(), null, matchId, m.getSenderId(), nick, + m.getContent(), m.getCreatedAt())); + } + + private List mapMessages( + List messages, + java.util.function.BiFunction mapper) { if (messages.isEmpty()) { return List.of(); } @@ -68,9 +110,7 @@ public class ChatService { Map nicknames = userRepository.findAllById(senderIds).stream() .collect(Collectors.toMap(User::getId, User::getNickname, (a, b) -> a)); return messages.stream() - .map(m -> new ChatMessageResponse(m.getId(), spotId, m.getSenderId(), - nicknames.getOrDefault(m.getSenderId(), "알 수 없음"), - m.getContent(), m.getCreatedAt())) + .map(m -> mapper.apply(m, nicknames.getOrDefault(m.getSenderId(), "알 수 없음"))) .collect(Collectors.toList()); } } diff --git a/src/main/java/com/dog/dognation/domain/matching/MatchNotifier.java b/src/main/java/com/dog/dognation/domain/matching/MatchNotifier.java new file mode 100644 index 0000000..cd1f0d9 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/matching/MatchNotifier.java @@ -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); + } +} \ 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 6607a84..9c9a4b3 100644 --- a/src/main/java/com/dog/dognation/domain/matching/MatchingRequest.java +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingRequest.java @@ -32,9 +32,16 @@ public class MatchingRequest { @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt = OffsetDateTime.now(); + /** 20분 내 미수락 시 자동 종료 기준 시각. */ + @Column(name = "expires_at") + private OffsetDateTime expiresAt; + @Column(name = "responded_at") private OffsetDateTime respondedAt; + /** 신청 유효시간 — 이 시간 내 미수락 시 EXPIRED. */ + public static final java.time.Duration TTL = java.time.Duration.ofMinutes(20); + protected MatchingRequest() { } @@ -43,6 +50,7 @@ public class MatchingRequest { this.targetDogId = targetDogId; this.status = MatchingStatus.PENDING; this.createdAt = OffsetDateTime.now(); + this.expiresAt = this.createdAt.plus(TTL); } /** 대상 견주가 신청을 수락/거절 처리한다. */ @@ -54,6 +62,20 @@ public class MatchingRequest { 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() { 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 3a08e17..c487491 100644 --- a/src/main/java/com/dog/dognation/domain/matching/MatchingRequestRepository.java +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingRequestRepository.java @@ -2,6 +2,7 @@ package com.dog.dognation.domain.matching; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.OffsetDateTime; import java.util.List; public interface MatchingRequestRepository extends JpaRepository { @@ -12,4 +13,11 @@ public interface MatchingRequestRepository extends JpaRepository findByTargetDogIdAndStatusOrderByCreatedAtDesc( Long targetDogId, MatchingStatus status); + + /** 특정 견이 보낸 신청 목록(신청자 기준) — 진행중 표시용. */ + List findByRequesterDogIdAndStatusOrderByCreatedAtDesc( + Long requesterDogId, MatchingStatus status); + + /** 만료 대상: 아직 대기중인데 만료시각이 지난 신청. */ + List findByStatusAndExpiresAtBefore(MatchingStatus status, OffsetDateTime now); } 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 e73fbc2..bdf64e7 100644 --- a/src/main/java/com/dog/dognation/domain/matching/MatchingService.java +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingService.java @@ -1,5 +1,7 @@ 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.DogRepository; import com.dog.dognation.domain.user.User; @@ -8,14 +10,17 @@ import jakarta.persistence.EntityNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.OffsetDateTime; import java.util.List; /** * 매칭 신청/응답 도메인 서비스. * - * 티어별 쿼터 가드: - * - PREMIUM : 무제한 (쿼터 차감 없음) - * - FREE/AD_FREE : 하루 3회. consumeOne() 으로 1회 차감, 소진 시 QuotaExceededException. + * 플로우: + * - 신청: 티어별 쿼터 차감(FREE/AD_FREE 하루 3회) → PENDING(20분 만료) → 상대에게 알림 + * - 수락: 1:1 채팅방 생성 + 결과 메시지 → 신청자에게 알림 + * - 거절: 종료 → 신청자에게 불발 알림 + * - 만료: 20분 내 미수락 시 스케줄러가 EXPIRED 처리 → 신청자에게 알림 */ @Service public class MatchingService { @@ -24,15 +29,21 @@ public class MatchingService { private final DogRepository dogRepository; private final UserRepository userRepository; private final MatchQuotaService quotaService; + private final ChatService chatService; + private final MatchNotifier notifier; public MatchingService(MatchingRequestRepository matchingRequestRepository, DogRepository dogRepository, UserRepository userRepository, - MatchQuotaService quotaService) { + MatchQuotaService quotaService, + ChatService chatService, + MatchNotifier notifier) { this.matchingRequestRepository = matchingRequestRepository; this.dogRepository = dogRepository; this.userRepository = userRepository; this.quotaService = quotaService; + this.chatService = chatService; + this.notifier = notifier; } @Transactional @@ -43,9 +54,8 @@ public class MatchingService { Dog requesterDog = dogRepository.findById(requesterDogId) .orElseThrow(() -> new EntityNotFoundException("신청 견을 찾을 수 없습니다: " + requesterDogId)); - if (!dogRepository.existsById(targetDogId)) { - throw new EntityNotFoundException("대상 견을 찾을 수 없습니다: " + targetDogId); - } + Dog targetDog = dogRepository.findById(targetDogId) + .orElseThrow(() -> new EntityNotFoundException("대상 견을 찾을 수 없습니다: " + targetDogId)); // 같은 대상에게 이미 대기중인 신청이 있으면 중복 방지 (쿼터 차감 전에 검사) 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 @@ -73,7 +89,34 @@ public class MatchingService { if (request.getStatus() != MatchingStatus.PENDING) { 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); + + 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 } @@ -82,4 +125,39 @@ public class MatchingService { return matchingRequestRepository.findByTargetDogIdAndStatusOrderByCreatedAtDesc( targetDogId, MatchingStatus.PENDING); } -} + + /** 내가 보낸 대기중 신청 — 프론트 "진행중" 표시용. */ + @Transactional(readOnly = true) + public List findSentPending(Long requesterDogId) { + return matchingRequestRepository.findByRequesterDogIdAndStatusOrderByCreatedAtDesc( + requesterDogId, MatchingStatus.PENDING); + } + + /** 20분 내 미수락 신청을 EXPIRED 처리하고 신청자에게 알림. 스케줄러가 호출. */ + @Transactional + public int expireOverdue() { + List 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() + ")"; + } +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/domain/matching/MatchingStatus.java b/src/main/java/com/dog/dognation/domain/matching/MatchingStatus.java index 0e359ee..ff92396 100644 --- a/src/main/java/com/dog/dognation/domain/matching/MatchingStatus.java +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingStatus.java @@ -4,5 +4,6 @@ public enum MatchingStatus { PENDING, // 신청 대기 ACCEPTED, // 수락됨 REJECTED, // 거절됨 - CANCELED // 신청 취소 + CANCELED, // 신청 취소 + EXPIRED // 20분 내 미수락으로 자동 종료 } diff --git a/src/main/java/com/dog/dognation/scheduler/MatchExpiryScheduler.java b/src/main/java/com/dog/dognation/scheduler/MatchExpiryScheduler.java new file mode 100644 index 0000000..0eb1e06 --- /dev/null +++ b/src/main/java/com/dog/dognation/scheduler/MatchExpiryScheduler.java @@ -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()); + } + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V7__matching_flow.sql b/src/main/resources/db/migration/V7__matching_flow.sql new file mode 100644 index 0000000..b76dc23 --- /dev/null +++ b/src/main/resources/db/migration/V7__matching_flow.sql @@ -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); \ No newline at end of file