- 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:
@@ -24,4 +24,10 @@ public class ChatController {
|
||||
public List<ChatMessageResponse> history(@PathVariable Long 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)
|
||||
.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(
|
||||
Long id,
|
||||
Long spotId,
|
||||
Long matchId,
|
||||
Long senderId,
|
||||
String senderNickname,
|
||||
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,
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ import java.util.Optional;
|
||||
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
|
||||
|
||||
Optional<ChatRoom> findBySpotId(Long spotId);
|
||||
|
||||
Optional<ChatRoom> findByMatchId(Long matchId);
|
||||
}
|
||||
|
||||
@@ -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<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());
|
||||
}
|
||||
|
||||
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()) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -68,9 +110,7 @@ public class ChatService {
|
||||
Map<Long, String> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<MatchingRequest, Long> {
|
||||
@@ -12,4 +13,11 @@ public interface MatchingRequestRepository extends JpaRepository<MatchingRequest
|
||||
/** 특정 견이 받은 신청 목록(대상 기준). */
|
||||
List<MatchingRequest> findByTargetDogIdAndStatusOrderByCreatedAtDesc(
|
||||
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;
|
||||
|
||||
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<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, // 신청 대기
|
||||
ACCEPTED, // 수락됨
|
||||
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);
|
||||
Reference in New Issue
Block a user