diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatRoomService.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatRoomService.java index 1b9ddd0..100491a 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatRoomService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatRoomService.java @@ -13,6 +13,7 @@ import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; import mannabom_server.manabom.domain.chat.enums.ChatMessageType; +import mannabom_server.manabom.domain.chat.enums.ChatStatus; import mannabom_server.manabom.domain.chat.repository.ChatMemberQueryRepository; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; @@ -135,15 +136,75 @@ public Long joinChatRoom(Meeting meeting, User user){ return room.getId(); } + @Transactional + public Long joinMatchingChatRoom(MeetingMatch match, User user) { + ChatRoom room = chatRoomRepository.findByMatch(match) + .orElseThrow(() -> new IllegalArgumentException( + "매칭 정보와 연결된 남녀 채팅방이 존재하지 않습니다." + )); + + if (chatMemberRepository.existsByRoomIdAndUser_UserIdAndStatus( + room.getId(), + user.getUserId(), + ChatMemberStatus.ACTIVATE + )) { + throw new IllegalStateException("이미 해당 매칭 채팅방에 참여한 사용자입니다."); + } + + chatMemberRepository.save(ChatMember.create(room, user)); + return room.getId(); + } + + @Transactional + public void disableMeetingGroupChatRooms(MeetingMatch match) { + disableMeetingGroupChatRoom(match.getMeeting1()); + disableMeetingGroupChatRoom(match.getMeeting2()); + } + + @Transactional + public void deactivateMeetingGroupMember(Meeting meeting, Long userId) { + ChatRoom room = chatRoomRepository.findByMeeting(meeting) + .orElseThrow(() -> new IllegalArgumentException( + "미팅과 연결된 동성 채팅방이 존재하지 않습니다." + )); + + chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus( + room.getId(), + userId, + ChatMemberStatus.ACTIVATE + ).ifPresent(ChatMember::deactivate); + } + + private void disableMeetingGroupChatRoom(Meeting meeting) { + ChatRoom room = chatRoomRepository.findByMeeting(meeting) + .orElseThrow(() -> new IllegalArgumentException( + "미팅과 연결된 동성 채팅방이 존재하지 않습니다." + )); + room.deactivate(); + } + public Long getChatRoomId(Meeting meeting){ ChatRoom room = chatRoomRepository.findByMeeting(meeting) .orElseThrow(()-> new IllegalArgumentException("미팅id와 연결된 채팅방: 존재하지 않은 채팅방입니다.")); return room.getId(); } + public Long getMatchingChatRoomId(MeetingMatch match) { + return chatRoomRepository.findByMatch(match) + .map(ChatRoom::getId) + .orElseThrow(() -> new IllegalArgumentException( + "매칭 정보와 연결된 남녀 채팅방이 존재하지 않습니다." + )); + } + public void leaveChatRoom(Long roomId, Long userId){ ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(()-> new IllegalArgumentException("채팅방 나가기: 존재하지 않는 채팅방아이디 입니다.")); + + if (room.getChatStatus() == ChatStatus.DISABLED) { + throw new IllegalStateException("비활성화된 채팅방에서는 나갈 수 없습니다."); + } + ChatMember chatMember = chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus(roomId, userId, ChatMemberStatus.ACTIVATE) .orElseThrow(()-> new IllegalArgumentException("방에 참여중인 유저가 아닙니다.")); diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java index 2924c2d..2160c79 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java @@ -10,6 +10,7 @@ import mannabom_server.manabom.domain.chat.entity.ChatMessage; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.enums.ChatStatus; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; @@ -54,6 +55,9 @@ public class ChatService { public void sendMessage(ChatSendRequest request, Long userId) { ChatRoom chatRoom = chatRoomRepository.findById(request.getRoomId()) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 채팅방입니다.")); + if (chatRoom.getChatStatus() == ChatStatus.DISABLED) { + throw new IllegalStateException("비활성화된 채팅방에는 메시지를 보낼 수 없습니다."); + } ChatMember sender = chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus(request.getRoomId(), userId, ChatMemberStatus.ACTIVATE) .orElseThrow(()-> new IllegalArgumentException("참여중인 채팅방이 아닙니다.")); User user = userRepository.findById(userId) @@ -118,7 +122,12 @@ private void sendNotificationWithSSEOrPush(ChatSendRequest request, List myRooms = chatMemberRepository.findAllByUser_UserIdAndStatus(userId, ChatMemberStatus.ACTIVATE); + List myRooms = chatMemberRepository + .findAllByUser_UserIdAndStatusAndRoom_ChatStatus( + userId, + ChatMemberStatus.ACTIVATE, + ChatStatus.ENABLED + ); boolean hasUnreadMessages = false; boolean hasNewRoom = false; @@ -144,7 +153,12 @@ public ChatInitialSyncResponse getInitialSync(Long userId) { */ @Transactional(readOnly = true) public List getChatRoomListSync(Long userId) { - List myRooms = chatMemberRepository.findAllByUser_UserIdAndStatus(userId, ChatMemberStatus.ACTIVATE); + List myRooms = chatMemberRepository + .findAllByUser_UserIdAndStatusAndRoom_ChatStatus( + userId, + ChatMemberStatus.ACTIVATE, + ChatStatus.ENABLED + ); return myRooms.stream().map(m -> { ChatRoom room = m.getRoom(); diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/request/MeetingCancellationVoteRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/request/MeetingCancellationVoteRequest.java new file mode 100644 index 0000000..3261a52 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/request/MeetingCancellationVoteRequest.java @@ -0,0 +1,14 @@ +package mannabom_server.manabom.application.meeting.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; + +@Getter +@NoArgsConstructor +public class MeetingCancellationVoteRequest { + + @NotNull(message = "동의 또는 비동의를 선택해야 합니다.") + private CancellationVoteDecision decision; +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationResponse.java new file mode 100644 index 0000000..2da5efa --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationResponse.java @@ -0,0 +1,55 @@ +package mannabom_server.manabom.application.meeting.dto.response; + +import lombok.Builder; +import lombok.Getter; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationVote; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; + +import java.time.Instant; +import java.util.List; + +@Getter +@Builder +public class MeetingCancellationResponse { + private Long requestId; + private Long matchId; + private Long initiatorUserId; + private MeetingCancellationStatus status; + private Instant requestedAt; + private Instant expiresAt; + private Instant completedAt; + private int totalMemberCount; + private int agreedMemberCount; + private int pendingMemberCount; + private List votes; + + public static MeetingCancellationResponse of( + MeetingCancellationRequest request, + List votes + ) { + int agreed = (int) votes.stream() + .filter(vote -> vote.getDecision() == CancellationVoteDecision.AGREE) + .count(); + int pending = (int) votes.stream() + .filter(vote -> vote.getDecision() == CancellationVoteDecision.PENDING) + .count(); + + return MeetingCancellationResponse.builder() + .requestId(request.getId()) + .matchId(request.getMeetingMatch().getId()) + .initiatorUserId(request.getInitiator().getUserId()) + .status(request.getStatus()) + .requestedAt(request.getRequestedAt()) + .expiresAt(request.getExpiresAt()) + .completedAt(request.getCompletedAt()) + .totalMemberCount(votes.size()) + .agreedMemberCount(agreed) + .pendingMemberCount(pending) + .votes(votes.stream() + .map(MeetingCancellationVoteResponse::from) + .toList()) + .build(); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationVoteResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationVoteResponse.java new file mode 100644 index 0000000..aa5b536 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationVoteResponse.java @@ -0,0 +1,24 @@ +package mannabom_server.manabom.application.meeting.dto.response; + +import lombok.Builder; +import lombok.Getter; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationVote; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; + +import java.time.Instant; + +@Getter +@Builder +public class MeetingCancellationVoteResponse { + private Long userId; + private CancellationVoteDecision decision; + private Instant decidedAt; + + public static MeetingCancellationVoteResponse from(MeetingCancellationVote vote) { + return MeetingCancellationVoteResponse.builder() + .userId(vote.getUser().getUserId()) + .decision(vote.getDecision()) + .decidedAt(vote.getDecidedAt()) + .build(); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingCancellationScheduler.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingCancellationScheduler.java new file mode 100644 index 0000000..3df3096 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingCancellationScheduler.java @@ -0,0 +1,23 @@ +package mannabom_server.manabom.application.meeting.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.meeting.service.MeetingCancellationService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class MeetingCancellationScheduler { + + private final MeetingCancellationService meetingCancellationService; + + @Scheduled(fixedDelayString = "${meeting.cancellation.expiration-check-delay:60000}") + public void expireCancellationRequests() { + int expiredCount = meetingCancellationService.expirePendingRequests(); + if (expiredCount > 0) { + log.info("만료된 미팅 전체 취소 요청 {}건을 처리했습니다.", expiredCount); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationService.java new file mode 100644 index 0000000..dd40be7 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationService.java @@ -0,0 +1,30 @@ +package mannabom_server.manabom.application.meeting.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationRequestRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Service +@RequiredArgsConstructor +public class MeetingCancellationExpirationService { + + private final MeetingCancellationRequestRepository requestRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean expire(Long requestId, Instant now) { + MeetingCancellationRequest request = requestRepository.findByIdForUpdate(requestId) + .orElse(null); + + if (request == null || !request.isExpiredAt(now)) { + return false; + } + + request.expire(now); + return true; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java new file mode 100644 index 0000000..37901c5 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java @@ -0,0 +1,286 @@ +package mannabom_server.manabom.application.meeting.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.meeting.dto.response.MeetingCancellationResponse; +import mannabom_server.manabom.domain.chat.entity.ChatMember; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationVote; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.entity.MeetingMember; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; +import mannabom_server.manabom.domain.meeting.enums.ChatUserStatus; +import mannabom_server.manabom.domain.meeting.enums.MatchingStatus; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationRequestRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationVoteRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingMatchRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingMemberRepository; +import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.domain.user.repository.UserRepository; +import mannabom_server.manabom.global.error.MeetingCancellationExpiredException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MeetingCancellationService { + + private static final Duration CANCELLATION_DEADLINE = Duration.ofHours(24); + + private final MeetingMatchRepository meetingMatchRepository; + private final MeetingMemberRepository meetingMemberRepository; + private final MeetingCancellationRequestRepository requestRepository; + private final MeetingCancellationVoteRepository voteRepository; + private final UserRepository userRepository; + private final ChatRoomRepository chatRoomRepository; + private final ChatMemberRepository chatMemberRepository; + private final MeetingCancellationExpirationService expirationService; + + @Transactional(readOnly = true) + public void validateNoPendingCancellation(Long meetingId) { + var match = meetingMatchRepository.findByMeetingIdAndStatus( + meetingId, + MatchingStatus.SUCCEEDED + ); + if (match.isEmpty()) { + return; + } + + if (requestRepository.existsByMeetingMatch_IdAndStatus( + match.get().getId(), + MeetingCancellationStatus.PENDING + )) { + throw new IllegalStateException( + "미팅 전체 취소 투표가 진행 중이므로 입장하거나 나갈 수 없습니다." + ); + } + } + + @Transactional + public MeetingCancellationResponse create(Long matchId, Long userId) { + MeetingMatch match = meetingMatchRepository.findByIdWithLockAndMeeting(matchId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅 매칭입니다.")); + + if (match.getMatchingStatus() != MatchingStatus.SUCCEEDED) { + throw new IllegalStateException("성사된 미팅만 전체 취소를 요청할 수 있습니다."); + } + validateActiveParticipant(match, userId); + + if (requestRepository.existsByMeetingMatch_IdAndStatus( + matchId, + MeetingCancellationStatus.PENDING + )) { + throw new IllegalStateException("이미 진행 중인 미팅 취소 요청이 있습니다."); + } + + List members = activeMembers(match); + if (members.size() < 2) { + throw new IllegalStateException("전체 취소 투표를 진행할 참여자가 부족합니다."); + } + + User initiator = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + Instant now = Instant.now(); + MeetingCancellationRequest request = requestRepository.save( + MeetingCancellationRequest.create( + match, + initiator, + now, + now.plus(CANCELLATION_DEADLINE) + ) + ); + + List votes = members.stream() + .map(member -> member.getUser().getUserId().equals(userId) + ? MeetingCancellationVote.agreedByInitiator(request, member.getUser(), now) + : MeetingCancellationVote.pending(request, member.getUser())) + .toList(); + voteRepository.saveAll(votes); + + return MeetingCancellationResponse.of(request, votes); + } + + @Transactional( + noRollbackFor = MeetingCancellationExpiredException.class + ) + public MeetingCancellationResponse vote( + Long requestId, + Long userId, + CancellationVoteDecision decision + ) { + if (decision == null || decision == CancellationVoteDecision.PENDING) { + throw new IllegalArgumentException("투표 결과는 AGREE 또는 REJECT여야 합니다."); + } + + MeetingCancellationRequest request = + requestRepository.findByIdForUpdate(requestId) + .orElseThrow(() -> + new IllegalArgumentException( + "존재하지 않는 미팅 취소 요청입니다." + ) + ); + + Instant now = Instant.now(); + + if (request.isExpiredAt(now)) { + request.expire(now); + throw new MeetingCancellationExpiredException( + "이미 만료된 미팅 취소 요청입니다." + ); + } + + if (request.getStatus() != MeetingCancellationStatus.PENDING) { + throw new IllegalStateException( + "이미 종료된 미팅 취소 요청입니다." + ); + } + + MeetingCancellationVote vote = voteRepository + .findByRequest_IdAndUser_UserId(requestId, userId) + .orElseThrow(() -> + new IllegalArgumentException( + "해당 미팅의 투표 대상자가 아닙니다." + ) + ); + + vote.decide(decision, now); + + if (decision == CancellationVoteDecision.REJECT) { + request.reject(now); + } else if (allMembersAgreed(requestId)) { + approveCancellation(request, now); + } + + return response(request); + } + + @Transactional + public MeetingCancellationResponse getCurrent(Long matchId, Long userId) { + MeetingMatch match = meetingMatchRepository.findByIdWithMeeting(matchId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅 매칭입니다.")); + validateActiveParticipant(match, userId); + MeetingCancellationRequest request = requestRepository + .findByMeetingMatch_IdAndStatus(matchId, MeetingCancellationStatus.PENDING) + .orElseThrow(() -> new IllegalArgumentException("진행 중인 미팅 취소 요청이 없습니다.")); + expireIfNecessary(request, Instant.now()); + return response(request); + } + + public int expirePendingRequests() { + Instant now = Instant.now(); + List expiredRequestIds = requestRepository + .findExpiredRequestIds( + MeetingCancellationStatus.PENDING, + now + ); + + int expiredCount = 0; + for (Long requestId : expiredRequestIds) { + try { + if (expirationService.expire(requestId, now)) { + expiredCount++; + } + } catch (RuntimeException e) { + log.warn("미팅 취소 요청 만료 처리 실패: requestId={}", requestId, e); + } + } + return expiredCount; + } + + private void approveCancellation( + MeetingCancellationRequest request, + Instant now + ) { + request.approve(now); + MeetingMatch match = request.getMeetingMatch(); + Meeting meeting1 = match.getMeeting1(); + Meeting meeting2 = match.getMeeting2(); + + chatRoomRepository.findByMatch(match) + .ifPresent(this::deactivateChatRoomAndMembers); + chatRoomRepository.findByMeeting(meeting1) + .ifPresent(this::deactivateChatRoomAndMembers); + chatRoomRepository.findByMeeting(meeting2) + .ifPresent(this::deactivateChatRoomAndMembers); + + activeMembers(match).forEach(MeetingMember::deactivate); + meeting1.cancelByAgreement(); + meeting2.cancelByAgreement(); + } + + private void expireIfNecessary( + MeetingCancellationRequest request, + Instant now + ) { + if (request.isExpiredAt(now)) { + request.expire(now); + } + } + + private boolean allMembersAgreed(Long requestId) { + return voteRepository.countByRequest_IdAndDecision( + requestId, + CancellationVoteDecision.PENDING + ) == 0 && voteRepository.countByRequest_IdAndDecision( + requestId, + CancellationVoteDecision.REJECT + ) == 0; + } + + private MeetingCancellationResponse response(MeetingCancellationRequest request) { + return MeetingCancellationResponse.of( + request, + voteRepository.findAllByRequest_IdOrderById(request.getId()) + ); + } + + private List activeMembers(MeetingMatch match) { + List members = new ArrayList<>(); + members.addAll(activeMembers(match.getMeeting1().getId())); + members.addAll(activeMembers(match.getMeeting2().getId())); + return members; + } + + private List activeMembers(Long meetingId) { + return meetingMemberRepository.findByMeetingIdAndStatus( + meetingId, + ChatUserStatus.ACTIVE + ); + } + + private void validateActiveParticipant(MeetingMatch match, Long userId) { + boolean belongsToMeeting1 = isActiveMember(match.getMeeting1().getId(), userId); + boolean belongsToMeeting2 = isActiveMember(match.getMeeting2().getId(), userId); + if (!belongsToMeeting1 && !belongsToMeeting2) { + throw new IllegalArgumentException("해당 미팅 매칭의 참여자가 아닙니다."); + } + } + + private boolean isActiveMember(Long meetingId, Long userId) { + return meetingMemberRepository.existsByMeeting_IdAndUser_UserIdAndStatus( + meetingId, + userId, + ChatUserStatus.ACTIVE + ); + } + + private void deactivateChatRoomAndMembers(ChatRoom room) { + room.deactivate(); + chatMemberRepository.findAllByRoomIdAndStatus( + room.getId(), + ChatMemberStatus.ACTIVATE + ).forEach(ChatMember::deactivate); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java index c85a0e8..7bae30f 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java @@ -337,6 +337,7 @@ else if(match.getMatchingStatus().equals(MatchingStatus.FAILED)) { private MatchedChatRoomInfo handleMatchSuccess(MeetingMatch match, boolean isByTimeout){ MatchedChatRoomInfo info = chatRoomService.createMatchingChatRoom(match); + chatRoomService.disableMeetingGroupChatRooms(match); eventPublisher.publishEvent(new MatchSuccessEvent(match.getId(),info.getRoomId(),isByTimeout)); return info; } @@ -365,6 +366,7 @@ private boolean isMetro(MeetingMatchingEvent event){ return METRO_CODES.contains(event.getSidoCode()); } + @Transactional public void handleMatchMemberLeave(Long matchId, Long userId){ MeetingMatch match = meetingMatchRepository.findById(matchId) .orElseThrow(()-> new IllegalArgumentException("존재하지 않는 미팅 매칭 아이디입니다.")); @@ -387,6 +389,7 @@ else if (match.getMeeting2().getId().equals(userMeetingId)){ } meetingService.handleMemberLeave(myMeeting.getId(), userId); + chatRoomService.deactivateMeetingGroupMember(myMeeting, userId); if((myMeeting.getCurrentMembers()+ opponent.getCurrentMembers())*2< myMeeting.getMaxMembers()+ opponent.getMaxMembers()){ // 다 환불 } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingService.java index 56e96e2..3b546a4 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingService.java @@ -13,6 +13,8 @@ import mannabom_server.manabom.domain.meeting.enums.MeetingBucket; import mannabom_server.manabom.domain.meeting.enums.MeetingRole; import mannabom_server.manabom.domain.meeting.repository.MeetingRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingMatchRepository; +import mannabom_server.manabom.domain.meeting.enums.MatchingStatus; import mannabom_server.manabom.domain.region.entity.Region; import mannabom_server.manabom.domain.user.entity.Profile; import mannabom_server.manabom.domain.user.entity.User; @@ -40,6 +42,7 @@ public class MeetingService { private static final int CODE_RETRY_LIMIT = 10; private final MeetingRepository meetingRepository; + private final MeetingMatchRepository meetingMatchRepository; private final UserRepository userRepository; private final ProfileRepository profileRepository; @@ -47,6 +50,7 @@ public class MeetingService { private final MeetingMemberReadService meetingMemberReadService; private final ChatRoomService chatRoomService; private final MeetingMemberService meetingMemberService; + private final MeetingCancellationService meetingCancellationService; private final RegionService regionService; private final CursorCodec cursorCodec; @@ -177,13 +181,23 @@ public MeetingRoomCreateDataDto enterRoomByCode(MeetingRoomJoinByCodeRequest req Meeting meeting = meetingRepository.findByCodeWithLock(request.getRoomCode()) .orElseThrow(() -> new IllegalArgumentException("방 코드로 미팅방 입장: 존재 하지 않는 초대 코드입니다.")); + meetingCancellationService.validateNoPendingCancellation(meeting.getId()); validateJoinCondition(meeting, user,profile); + boolean isFastMatchingEntry = meeting.getMeetingStatus() == MeetingStatus.FASTMATCHING; meeting.addMember(profile.computeAge()); meetingRepository.saveAndFlush(meeting); /*왜 addMember가 반영이 안되지*/ meetingMemberService.addMember(meeting, user); Long chatRoomId = chatRoomService.joinChatRoom(meeting, user); + Long matchingChatRoomId = joinMatchingChatRoomIfFastEntry( + meeting, + user, + isFastMatchingEntry + ); - return buildResponseForEnter(meeting, chatRoomId); + return buildResponseForEnter( + meeting, + matchingChatRoomId != null ? matchingChatRoomId : chatRoomId + ); } @@ -198,12 +212,41 @@ public MeetingRoomCreateDataDto enterRoomById(Long meetingId, Long userId) { .orElseThrow(() -> new IllegalArgumentException("미팅방 입장: 존재 하지 않는 id입니다. :" + meetingId)); + meetingCancellationService.validateNoPendingCancellation(meetingId); validateJoinCondition(meeting, user,profile); + boolean isFastMatchingEntry = meeting.getMeetingStatus() == MeetingStatus.FASTMATCHING; meeting.addMember(profile.computeAge()); meetingMemberService.addMember(meeting, user); Long chatRoomId = chatRoomService.joinChatRoom(meeting, user); + Long matchingChatRoomId = joinMatchingChatRoomIfFastEntry( + meeting, + user, + isFastMatchingEntry + ); + + return buildResponseForEnter( + meeting, + matchingChatRoomId != null ? matchingChatRoomId : chatRoomId + ); + } + + private Long joinMatchingChatRoomIfFastEntry( + Meeting meeting, + User user, + boolean isFastMatchingEntry + ) { + if (!isFastMatchingEntry) { + return null; + } - return buildResponseForEnter(meeting, chatRoomId); + var match = meetingMatchRepository.findByMeetingIdAndStatus( + meeting.getId(), + MatchingStatus.SUCCEEDED + ) + .orElseThrow(() -> new IllegalStateException( + "빠른 입장 미팅과 연결된 성사된 매칭을 찾을 수 없습니다." + )); + return chatRoomService.joinMatchingChatRoom(match, user); } @@ -213,8 +256,11 @@ private void validateJoinCondition(Meeting meeting, User user,Profile profile) { /*결제 조건 확인*/ /*매칭 상태*/ - if (meeting.getMeetingStatus() != MeetingStatus.RECRUITING) - throw new IllegalArgumentException("정원이 다 찬 미팅방입니다."); + MeetingStatus status = meeting.getMeetingStatus(); + if (status != MeetingStatus.RECRUITING + && status != MeetingStatus.FASTMATCHING) { + throw new IllegalArgumentException("현재 입장할 수 없는 미팅방입니다."); + } /*이미 참여중인지 체크*/ @@ -244,11 +290,27 @@ public MyMeetingStatusDataDto checkUserMeetingStatus(Long userId) { } else { MeetingMember meetingMember = mm.get(); Meeting meeting = meetingMember.getMeeting(); - Long chatRoomId = chatRoomService.getChatRoomId(meeting); + Long chatRoomId = resolveCurrentChatRoomId(meeting); return buildResponseDtoForCheck(meeting, chatRoomId, meetingMember.getMeetingRole() == MeetingRole.LEADER); } } + private Long resolveCurrentChatRoomId(Meeting meeting) { + if (meeting.getMeetingStatus() != MeetingStatus.MATCHED + && meeting.getMeetingStatus() != MeetingStatus.FASTMATCHING) { + return chatRoomService.getChatRoomId(meeting); + } + + var match = meetingMatchRepository.findByMeetingIdAndStatus( + meeting.getId(), + MatchingStatus.SUCCEEDED + ) + .orElseThrow(() -> new IllegalStateException( + "매칭된 미팅과 연결된 성사된 매칭을 찾을 수 없습니다." + )); + return chatRoomService.getMatchingChatRoomId(match); + } + /*팀원 프로필 상세 조회*/ public TeamMemberProfilesDto getTeamMemberProfilesDetail(Long meetingId) { @@ -358,14 +420,16 @@ public boolean cancelMatching(Long userId){ @Transactional public void handleMemberLeave(Long meetingId, Long userId){ - Meeting meeting = meetingRepository.findById(meetingId) + Meeting meeting = meetingRepository.findByIdWithLock(meetingId) .orElseThrow(()-> new IllegalArgumentException("존재하지 않는 미팅아이디입니다.")); User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); Profile profile = profileRepository.findByUser(user) .orElseThrow(() -> new IllegalArgumentException("프로필 정보를 찾을 수 없습니다.")); + meetingCancellationService.validateNoPendingCancellation(meetingId); validateMeetingStatus(meeting); + MeetingStatus previousStatus = meeting.getMeetingStatus(); boolean isLeader = meetingMemberService.isLeader(meetingId, userId); meeting.deleteMember(profile.computeAge()); @@ -376,6 +440,13 @@ public void handleMemberLeave(Long meetingId, Long userId){ meeting.delete(); return; } + + if (previousStatus == MeetingStatus.MATCHED) { + meeting.changeToFastMatchingAfterMemberLeave(); + } else if (previousStatus == MeetingStatus.FULL) { + meeting.changeToRecruitingAfterMemberLeave(); + } + if(isLeader){ meetingMemberService.appointNextLeader(meetingId); } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMemberRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMemberRepository.java index d0ed7cc..0a1404a 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMemberRepository.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMemberRepository.java @@ -2,6 +2,7 @@ import mannabom_server.manabom.domain.chat.entity.ChatMember; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.enums.ChatStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,10 +17,23 @@ public interface ChatMemberRepository extends JpaRepository { boolean existsByRoomIdAndUser_UserIdAndStatus(Long roomId, Long userId, ChatMemberStatus status); + boolean existsByRoomIdAndUser_UserIdAndStatusAndRoom_ChatStatus( + Long roomId, + Long userId, + ChatMemberStatus status, + ChatStatus chatStatus + ); + boolean existsByRoomIdAndUser_UserId(Long roomId, Long userId); List findAllByUser_UserIdAndStatus(Long userId, ChatMemberStatus status); + List findAllByUser_UserIdAndStatusAndRoom_ChatStatus( + Long userId, + ChatMemberStatus status, + ChatStatus chatStatus + ); + List findByRoomId(Long roomId); @Query("SELECT cm, u, p, pi FROM ChatMember cm " + diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatRoomRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatRoomRepository.java index 2f6adcc..d979c98 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatRoomRepository.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatRoomRepository.java @@ -3,6 +3,7 @@ import jakarta.persistence.LockModeType; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -14,6 +15,8 @@ public interface ChatRoomRepository extends JpaRepository { Optional findByMeeting(Meeting meeting); + Optional findByMatch(MeetingMatch match); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select cr from ChatRoom cr where cr.id = :chatRoomId") Optional findByIdForUpdate(@Param("chatRoomId") Long chatRoomId); diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/Meeting.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/Meeting.java index 23b9407..9d7e279 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/Meeting.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/Meeting.java @@ -1,7 +1,6 @@ package mannabom_server.manabom.domain.meeting.entity; import jakarta.persistence.*; -import java.time.LocalDateTime; import lombok.*; import mannabom_server.manabom.domain.common.BaseTimeEntity; import mannabom_server.manabom.domain.meeting.enums.MeetingStatus; @@ -103,11 +102,14 @@ public static Meeting create( public void addMember(int userAge){ + boolean wasFastMatching = meetingStatus == MeetingStatus.FASTMATCHING; double tmp = avgAge*currentMembers+userAge; currentMembers++; avgAge = tmp/currentMembers; if(currentMembers.equals(maxMembers)){ - meetingStatus = MeetingStatus.FULL; + meetingStatus = wasFastMatching + ? MeetingStatus.MATCHED + : MeetingStatus.FULL; } updateOccupancyScore(); } @@ -203,4 +205,46 @@ public void changeToMatchedStatus(){ public void delete(){ this.deletedAt = Instant.now(); } + + public void cancelByAgreement() { + if (meetingStatus == MeetingStatus.CANCELLED) { + throw new IllegalStateException("이미 취소된 미팅입니다."); + } + + this.meetingStatus = MeetingStatus.CANCELLED; + this.matchingStartAt = null; + delete(); + } + + public void changeToFastMatchingAfterMemberLeave() { + if (meetingStatus != MeetingStatus.MATCHED) { + throw new IllegalStateException( + "매칭 완료된 미팅방만 빠른 입장 상태로 변경할 수 있습니다." + ); + } + if (currentMembers <= 0 || currentMembers >= maxMembers) { + throw new IllegalStateException( + "빠른 입장 상태로 변경할 수 없는 인원입니다." + ); + } + + this.meetingStatus = MeetingStatus.FASTMATCHING; + this.matchingStartAt = null; + } + + public void changeToRecruitingAfterMemberLeave() { + if (meetingStatus != MeetingStatus.FULL) { + throw new IllegalStateException( + "인원이 가득 찬 미팅방만 모집 상태로 변경할 수 있습니다." + ); + } + if (currentMembers <= 0 || currentMembers >= maxMembers) { + throw new IllegalStateException( + "모집 상태로 변경할 수 없는 인원입니다." + ); + } + + this.meetingStatus = MeetingStatus.RECRUITING; + this.matchingStartAt = null; + } } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationRequest.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationRequest.java new file mode 100644 index 0000000..ee74c17 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationRequest.java @@ -0,0 +1,88 @@ +package mannabom_server.manabom.domain.meeting.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; +import mannabom_server.manabom.domain.user.entity.User; + +import java.time.Instant; + +@Entity +@Table(name = "meeting_cancellation_requests") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MeetingCancellationRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "meeting_match_id") + private MeetingMatch meetingMatch; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "initiator_user_id", nullable = false) + private User initiator; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MeetingCancellationStatus status; + + @Column(nullable = false) + private Instant requestedAt; + + @Column(nullable = false) + private Instant expiresAt; + + private Instant completedAt; + + @Version + private Long version; + + public static MeetingCancellationRequest create( + MeetingMatch meetingMatch, + User initiator, + Instant requestedAt, + Instant expiresAt + ) { + MeetingCancellationRequest request = new MeetingCancellationRequest(); + request.meetingMatch = meetingMatch; + request.initiator = initiator; + request.status = MeetingCancellationStatus.PENDING; + request.requestedAt = requestedAt; + request.expiresAt = expiresAt; + return request; + } + + public boolean isExpiredAt(Instant now) { + return status == MeetingCancellationStatus.PENDING + && !now.isBefore(expiresAt); + } + + public void approve(Instant completedAt) { + validatePending(); + this.status = MeetingCancellationStatus.APPROVED; + this.completedAt = completedAt; + } + + public void reject(Instant completedAt) { + validatePending(); + this.status = MeetingCancellationStatus.REJECTED; + this.completedAt = completedAt; + } + + public void expire(Instant completedAt) { + validatePending(); + this.status = MeetingCancellationStatus.EXPIRED; + this.completedAt = completedAt; + } + + private void validatePending() { + if (status != MeetingCancellationStatus.PENDING) { + throw new IllegalStateException("이미 종료된 미팅 취소 요청입니다."); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationVote.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationVote.java new file mode 100644 index 0000000..0da75f1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationVote.java @@ -0,0 +1,77 @@ +package mannabom_server.manabom.domain.meeting.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; +import mannabom_server.manabom.domain.user.entity.User; + +import java.time.Instant; + +@Entity +@Table( + name = "meeting_cancellation_votes", + uniqueConstraints = @UniqueConstraint( + columnNames = {"request_id", "user_id"} + ) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MeetingCancellationVote { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "request_id", nullable = false) + private MeetingCancellationRequest request; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CancellationVoteDecision decision; + + private Instant decidedAt; + + public static MeetingCancellationVote pending( + MeetingCancellationRequest request, + User user + ) { + MeetingCancellationVote vote = new MeetingCancellationVote(); + vote.request = request; + vote.user = user; + vote.decision = CancellationVoteDecision.PENDING; + return vote; + } + + public static MeetingCancellationVote agreedByInitiator( + MeetingCancellationRequest request, + User user, + Instant decidedAt + ) { + MeetingCancellationVote vote = pending(request, user); + vote.agree(decidedAt); + return vote; + } + + public void decide(CancellationVoteDecision decision, Instant decidedAt) { + if (decision == CancellationVoteDecision.PENDING) { + throw new IllegalArgumentException("투표 결과는 AGREE 또는 REJECT여야 합니다."); + } + if (this.decision != CancellationVoteDecision.PENDING) { + throw new IllegalStateException("이미 투표를 완료했습니다."); + } + this.decision = decision; + this.decidedAt = decidedAt; + } + + private void agree(Instant decidedAt) { + this.decision = CancellationVoteDecision.AGREE; + this.decidedAt = decidedAt; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/CancellationVoteDecision.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/CancellationVoteDecision.java new file mode 100644 index 0000000..4b40a49 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/CancellationVoteDecision.java @@ -0,0 +1,7 @@ +package mannabom_server.manabom.domain.meeting.enums; + +public enum CancellationVoteDecision { + PENDING, + AGREE, + REJECT +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingCancellationStatus.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingCancellationStatus.java new file mode 100644 index 0000000..68b2a28 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingCancellationStatus.java @@ -0,0 +1,8 @@ +package mannabom_server.manabom.domain.meeting.enums; + +public enum MeetingCancellationStatus { + PENDING, + APPROVED, + REJECTED, + EXPIRED +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingStatus.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingStatus.java index 33db805..7db9755 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingStatus.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingStatus.java @@ -11,7 +11,8 @@ public enum MeetingStatus { MATCHING_WAITING(2), //매칭 대기중 MATCHING_PENDING(3), // 매칭 완료 상대 수락/거절 대기중 MATCHED(4), //매칭 성공 - FASTMATCHING(5); // 빠른 매칭 + FASTMATCHING(5), + CANCELLED(6); // 빠른 매칭 private final int code; diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingCancellationRequestRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingCancellationRequestRepository.java new file mode 100644 index 0000000..80908f1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingCancellationRequestRepository.java @@ -0,0 +1,44 @@ +package mannabom_server.manabom.domain.meeting.repository; + +import jakarta.persistence.LockModeType; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public interface MeetingCancellationRequestRepository + extends JpaRepository { + + boolean existsByMeetingMatch_IdAndStatus( + Long meetingMatchId, + MeetingCancellationStatus status + ); + + Optional findByMeetingMatch_IdAndStatus( + Long meetingMatchId, + MeetingCancellationStatus status + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select r from MeetingCancellationRequest r where r.id = :requestId") + Optional findByIdForUpdate( + @Param("requestId") Long requestId + ); + + @Query(""" + select r.id + from MeetingCancellationRequest r + where r.status = :status + and r.expiresAt <= :now + """) + List findExpiredRequestIds( + @Param("status") MeetingCancellationStatus status, + @Param("now") Instant now + ); +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingCancellationVoteRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingCancellationVoteRepository.java new file mode 100644 index 0000000..3b749ff --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingCancellationVoteRepository.java @@ -0,0 +1,24 @@ +package mannabom_server.manabom.domain.meeting.repository; + +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationVote; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MeetingCancellationVoteRepository + extends JpaRepository { + + Optional findByRequest_IdAndUser_UserId( + Long requestId, + Long userId + ); + + List findAllByRequest_IdOrderById(Long requestId); + + long countByRequest_IdAndDecision( + Long requestId, + CancellationVoteDecision decision + ); +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingMatchRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingMatchRepository.java index 954658d..f82a9e2 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingMatchRepository.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingMatchRepository.java @@ -2,6 +2,7 @@ import jakarta.persistence.LockModeType; import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.MatchingStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -27,4 +28,16 @@ public interface MeetingMatchRepository extends JpaRepository "WHERE m.id = :matchId" ) Optional findByIdWithMeeting(@Param(value = "matchId") Long matchId); + + @Query( + "select m from MeetingMatch m " + + "join fetch m.meeting1 m1 " + + "join fetch m.meeting2 m2 " + + "where m.matchingStatus = :status " + + "and (m1.id = :meetingId or m2.id = :meetingId)" + ) + Optional findByMeetingIdAndStatus( + @Param("meetingId") Long meetingId, + @Param("status") MatchingStatus status + ); } diff --git a/manabom/src/main/java/mannabom_server/manabom/global/error/MeetingCancellationExpiredException.java b/manabom/src/main/java/mannabom_server/manabom/global/error/MeetingCancellationExpiredException.java new file mode 100644 index 0000000..45e948c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/global/error/MeetingCancellationExpiredException.java @@ -0,0 +1,7 @@ +package mannabom_server.manabom.global.error; + +public class MeetingCancellationExpiredException extends IllegalStateException{ + public MeetingCancellationExpiredException(String message) { + super(message); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/websocket/StompAuthChannelInterceptor.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/websocket/StompAuthChannelInterceptor.java index c43352b..3d74cf5 100644 --- a/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/websocket/StompAuthChannelInterceptor.java +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/websocket/StompAuthChannelInterceptor.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.enums.ChatStatus; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.infrastructure.security.jwt.JwtUtil; import org.springframework.data.redis.core.StringRedisTemplate; @@ -74,10 +75,21 @@ public class StompAuthChannelInterceptor implements ChannelInterceptor { String dest = acc.getDestination(); Long roomId = parseRoomId(dest); - if (!chatMemberRepository.existsByRoomIdAndUser_UserIdAndStatus( - roomId, userId, ChatMemberStatus.ACTIVATE)) { - log.warn("웹소켓 구독 거부: 유저 {} 는 채팅방 {} 의 참여자가 아닙니다.", userId, roomId); - throw new AccessDeniedException("채팅방 구독 권한이 없습니다."); + if (!chatMemberRepository + .existsByRoomIdAndUser_UserIdAndStatusAndRoom_ChatStatus( + roomId, + userId, + ChatMemberStatus.ACTIVATE, + ChatStatus.ENABLED + )) { + log.warn( + "웹소켓 구독 거부: 유저 {} 는 활성 채팅방 {} 의 참여자가 아닙니다.", + userId, + roomId + ); + throw new AccessDeniedException( + "활성화된 채팅방에 대한 구독 권한이 없습니다." + ); } redisTemplate.opsForValue().set(USER_LOCATION_PREFIX + userId, String.valueOf(roomId)); diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingController.java index 74a9d1e..c32d56a 100644 --- a/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingController.java +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingController.java @@ -10,6 +10,10 @@ import mannabom_server.manabom.application.meeting.dto.response.MeetingRoomCreateDataDto; import mannabom_server.manabom.application.meeting.dto.response.TeamMemberProfilesDto; import mannabom_server.manabom.application.meeting.dto.response.MyMeetingStatusDataDto; +import jakarta.validation.Valid; +import mannabom_server.manabom.application.meeting.dto.request.MeetingCancellationVoteRequest; +import mannabom_server.manabom.application.meeting.dto.response.MeetingCancellationResponse; +import mannabom_server.manabom.application.meeting.service.MeetingCancellationService; import mannabom_server.manabom.application.meeting.service.MeetingService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -21,6 +25,7 @@ @RequiredArgsConstructor public class MeetingController { private final MeetingService meetingService; + private final MeetingCancellationService meetingCancellationService; /*미팅방 생성 api*/ @PostMapping("/rooms/create") @@ -50,6 +55,44 @@ public ResponseEntity> getMyMeeting(@Authent return ResponseEntity.ok(ApiResponse.success(dto, "내 미팅 상태 조회 성공")); } + @PostMapping("/matches/{matchId}/cancellation-requests") + public ResponseEntity> createCancellationRequest( + @PathVariable Long matchId, + @AuthenticationPrincipal Long userId + ) { + MeetingCancellationResponse response = meetingCancellationService.create(matchId, userId); + return ResponseEntity.ok( + ApiResponse.success(response, "미팅 전체 취소 요청을 시작했습니다.") + ); + } + + @PostMapping("/cancellation-requests/{requestId}/votes") + public ResponseEntity> voteCancellation( + @PathVariable Long requestId, + @Valid @RequestBody MeetingCancellationVoteRequest request, + @AuthenticationPrincipal Long userId + ) { + MeetingCancellationResponse response = meetingCancellationService.vote( + requestId, + userId, + request.getDecision() + ); + return ResponseEntity.ok( + ApiResponse.success(response, "미팅 전체 취소 투표를 완료했습니다.") + ); + } + + @GetMapping("/matches/{matchId}/cancellation-requests/current") + public ResponseEntity> getCurrentCancellationRequest( + @PathVariable Long matchId, + @AuthenticationPrincipal Long userId + ) { + MeetingCancellationResponse response = meetingCancellationService.getCurrent(matchId, userId); + return ResponseEntity.ok( + ApiResponse.success(response, "진행 중인 미팅 전체 취소 요청을 조회했습니다.") + ); + } + /*팀원 프로필 상세 조회*/ @GetMapping("/member-profiles/{meetingId}") public ResponseEntity> getMeetingMemberProfiles(@PathVariable Long meetingId, @AuthenticationPrincipal Long userId){ diff --git a/manabom/src/main/resources/db/migration/V21__add_meeting_cancellation_tables.sql b/manabom/src/main/resources/db/migration/V21__add_meeting_cancellation_tables.sql new file mode 100644 index 0000000..37059fc --- /dev/null +++ b/manabom/src/main/resources/db/migration/V21__add_meeting_cancellation_tables.sql @@ -0,0 +1,85 @@ +CREATE TABLE meeting_cancellation_requests +( + id BIGSERIAL PRIMARY KEY, + meeting_id BIGINT NOT NULL, + initiator_user_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + requested_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ NULL, + version BIGINT NOT NULL DEFAULT 0, + + CONSTRAINT fk_cancellation_request_meeting + FOREIGN KEY (meeting_id) + REFERENCES meeting (id), + + CONSTRAINT fk_cancellation_request_initiator + FOREIGN KEY (initiator_user_id) + REFERENCES users (user_id), + + CONSTRAINT chk_cancellation_request_status + CHECK (status IN ( + 'PENDING', + 'APPROVED', + 'REJECTED', + 'EXPIRED', + 'WITHDRAWN' + )), + + CONSTRAINT chk_cancellation_request_expiration + CHECK (expires_at > requested_at) +); + +-- 한 미팅에서는 진행 중인 전체 취소 요청을 하나만 허용 +CREATE UNIQUE INDEX uk_meeting_cancellation_pending + ON meeting_cancellation_requests (meeting_id) + WHERE status = 'PENDING'; + +CREATE INDEX idx_cancellation_request_meeting_status + ON meeting_cancellation_requests (meeting_id, status); + +-- 만료된 요청을 스케줄러에서 조회할 때 사용 +CREATE INDEX idx_cancellation_request_status_expires + ON meeting_cancellation_requests (status, expires_at); + + +CREATE TABLE meeting_cancellation_votes +( + id BIGSERIAL PRIMARY KEY, + request_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + decision VARCHAR(20) NOT NULL DEFAULT 'PENDING', + decided_at TIMESTAMPTZ NULL, + + CONSTRAINT fk_cancellation_vote_request + FOREIGN KEY (request_id) + REFERENCES meeting_cancellation_requests (id) + ON DELETE CASCADE, + + CONSTRAINT fk_cancellation_vote_user + FOREIGN KEY (user_id) + REFERENCES users (user_id), + + CONSTRAINT uk_cancellation_vote_request_user + UNIQUE (request_id, user_id), + + CONSTRAINT chk_cancellation_vote_decision + CHECK (decision IN ( + 'PENDING', + 'AGREE', + 'REJECT' + )), + + CONSTRAINT chk_cancellation_vote_decided_at + CHECK ( + (decision = 'PENDING' AND decided_at IS NULL) + OR + (decision IN ('AGREE', 'REJECT') AND decided_at IS NOT NULL) + ) +); + +CREATE INDEX idx_cancellation_vote_request + ON meeting_cancellation_votes (request_id); + +CREATE INDEX idx_cancellation_vote_user + ON meeting_cancellation_votes (user_id); \ No newline at end of file diff --git a/manabom/src/main/resources/db/migration/V22__move_meeting_cancellation_to_match.sql b/manabom/src/main/resources/db/migration/V22__move_meeting_cancellation_to_match.sql new file mode 100644 index 0000000..0357654 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V22__move_meeting_cancellation_to_match.sql @@ -0,0 +1,25 @@ +ALTER TABLE meeting_cancellation_requests + ADD COLUMN meeting_match_id BIGINT; + +ALTER TABLE meeting_cancellation_requests + ALTER COLUMN meeting_id DROP NOT NULL; + +ALTER TABLE meeting_cancellation_requests + ADD CONSTRAINT fk_cancellation_request_meeting_match + FOREIGN KEY (meeting_match_id) + REFERENCES meeting_matches (id); + +ALTER TABLE meeting_cancellation_requests + ADD CONSTRAINT chk_cancellation_request_target + CHECK ( + (meeting_id IS NOT NULL AND meeting_match_id IS NULL) + OR + (meeting_id IS NULL AND meeting_match_id IS NOT NULL) + ); + +CREATE UNIQUE INDEX uk_meeting_match_cancellation_pending + ON meeting_cancellation_requests (meeting_match_id) + WHERE status = 'PENDING' AND meeting_match_id IS NOT NULL; + +CREATE INDEX idx_cancellation_request_match_status + ON meeting_cancellation_requests (meeting_match_id, status); diff --git a/manabom/src/main/resources/db/migration/V23__remove_withdrawn_cancellation_status.sql b/manabom/src/main/resources/db/migration/V23__remove_withdrawn_cancellation_status.sql new file mode 100644 index 0000000..13218b3 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V23__remove_withdrawn_cancellation_status.sql @@ -0,0 +1,11 @@ +ALTER TABLE meeting_cancellation_requests + DROP CONSTRAINT chk_cancellation_request_status; + +ALTER TABLE meeting_cancellation_requests + ADD CONSTRAINT chk_cancellation_request_status + CHECK (status IN ( + 'PENDING', + 'APPROVED', + 'REJECTED', + 'EXPIRED' + )); diff --git a/manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql b/manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql new file mode 100644 index 0000000..33aed3a --- /dev/null +++ b/manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql @@ -0,0 +1,28 @@ +-- 과거 DB 기본값 오타 보정 +UPDATE chat_members +SET status = 'ACTIVATE' +WHERE status = 'ACTIVE'; + +-- 변환 후 발생할 수 있는 활성 중복 정리 +WITH ranked_active_members AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY room_id, user_id + ORDER BY id + ) AS row_number + FROM chat_members + WHERE status = 'ACTIVATE' +) +UPDATE chat_members AS cm +SET status = 'DEACTIVATED' +FROM ranked_active_members AS ranked +WHERE cm.id = ranked.id + AND ranked.row_number > 1; + +-- 이후 기본값도 Java enum과 통일 +ALTER TABLE chat_members + ALTER COLUMN status SET DEFAULT 'ACTIVATE'; + +CREATE UNIQUE INDEX uk_chat_members_active_room_user + ON chat_members (room_id, user_id) + WHERE status = 'ACTIVATE'; diff --git a/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatRoomServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatRoomServiceTest.java new file mode 100644 index 0000000..704699c --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatRoomServiceTest.java @@ -0,0 +1,94 @@ +package mannabom_server.manabom.application.chat.service; + +import mannabom_server.manabom.domain.chat.entity.ChatMember; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.repository.ChatMemberQueryRepository; +import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; +import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; +import mannabom_server.manabom.application.meeting.service.MeetingMemberService; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.domain.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +class ChatRoomServiceTest { + + @Mock private ChatRoomRepository chatRoomRepository; + @Mock private ChatMemberRepository chatMemberRepository; + @Mock private ChatMemberQueryRepository chatMemberQueryRepository; + @Mock private UserRepository userRepository; + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private MeetingMemberService meetingMemberService; + @Mock private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private ChatRoomService chatRoomService; + + @Test + void allowsReentryWhenPreviousMembershipIsDeactivated() { + MeetingMatch match = mock(MeetingMatch.class); + User user = user(1L); + ChatRoom room = ChatRoom.builder().id(77L).match(match).build(); + when(chatRoomRepository.findByMatch(match)).thenReturn(Optional.of(room)); + when(chatMemberRepository.existsByRoomIdAndUser_UserIdAndStatus( + 77L, + 1L, + ChatMemberStatus.ACTIVATE + )).thenReturn(false); + + Long roomId = chatRoomService.joinMatchingChatRoom(match, user); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMember.class); + verify(chatMemberRepository).save(captor.capture()); + assertThat(roomId).isEqualTo(77L); + assertThat(captor.getValue().getRoom()).isSameAs(room); + assertThat(captor.getValue().getUser()).isSameAs(user); + assertThat(captor.getValue().getStatus()).isEqualTo(ChatMemberStatus.ACTIVATE); + } + + @Test + void rejectsEntryWhenActiveMembershipAlreadyExists() { + MeetingMatch match = mock(MeetingMatch.class); + User user = user(1L); + ChatRoom room = ChatRoom.builder().id(77L).match(match).build(); + when(chatRoomRepository.findByMatch(match)).thenReturn(Optional.of(room)); + when(chatMemberRepository.existsByRoomIdAndUser_UserIdAndStatus( + 77L, + 1L, + ChatMemberStatus.ACTIVATE + )).thenReturn(true); + + assertThatThrownBy(() -> chatRoomService.joinMatchingChatRoom(match, user)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("이미 해당 매칭 채팅방"); + + verify(chatMemberRepository, never()).save(any()); + } + + private User user(Long id) { + return User.builder() + .userId(id) + .kakaoId("mock_" + id) + .userName("user" + id) + .build(); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java new file mode 100644 index 0000000..854c3e3 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java @@ -0,0 +1,76 @@ +package mannabom_server.manabom.application.meeting.service; + +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationRequestRepository; +import mannabom_server.manabom.domain.user.entity.User; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MeetingCancellationExpirationServiceTest { + + @Mock + private MeetingCancellationRequestRepository requestRepository; + + @InjectMocks + private MeetingCancellationExpirationService expirationService; + + @Test + void expiresPendingRequestInItsOwnProcessingStep() { + Instant requestedAt = Instant.parse("2026-07-20T00:00:00Z"); + Instant now = requestedAt.plus(Duration.ofHours(25)); + MeetingCancellationRequest request = cancellationRequest(requestedAt); + when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + + boolean expired = expirationService.expire(30L, now); + + assertThat(expired).isTrue(); + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.EXPIRED); + assertThat(request.getCompletedAt()).isEqualTo(now); + } + + @Test + void skipsRequestThatWasCompletedBeforeLockAcquisition() { + Instant requestedAt = Instant.parse("2026-07-20T00:00:00Z"); + Instant now = requestedAt.plus(Duration.ofHours(25)); + MeetingCancellationRequest request = cancellationRequest(requestedAt); + request.reject(now.minusSeconds(1)); + when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + + boolean expired = expirationService.expire(30L, now); + + assertThat(expired).isFalse(); + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.REJECTED); + } + + private MeetingCancellationRequest cancellationRequest(Instant requestedAt) { + MeetingMatch match = MeetingMatch.builder() + .meeting1(Meeting.builder().id(10L).build()) + .meeting2(Meeting.builder().id(11L).build()) + .build(); + User initiator = User.builder() + .userId(1L) + .kakaoId("mock_1") + .userName("user1") + .build(); + return MeetingCancellationRequest.create( + match, + initiator, + requestedAt, + requestedAt.plus(Duration.ofHours(24)) + ); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java new file mode 100644 index 0000000..4daeb34 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java @@ -0,0 +1,232 @@ +package mannabom_server.manabom.application.meeting.service; + +import mannabom_server.manabom.application.meeting.dto.response.MeetingCancellationResponse; +import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationVote; +import mannabom_server.manabom.domain.meeting.entity.MeetingMember; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; +import mannabom_server.manabom.domain.meeting.enums.ChatUserStatus; +import mannabom_server.manabom.domain.meeting.enums.MatchingStatus; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationRequestRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationVoteRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingMemberRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingMatchRepository; +import mannabom_server.manabom.domain.user.repository.UserRepository; +import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.global.error.MeetingCancellationExpiredException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@ExtendWith(MockitoExtension.class) +class MeetingCancellationServiceTest { + + @Mock + private MeetingMatchRepository meetingMatchRepository; + @Mock + private MeetingMemberRepository meetingMemberRepository; + @Mock + private MeetingCancellationRequestRepository requestRepository; + @Mock + private MeetingCancellationVoteRepository voteRepository; + @Mock + private UserRepository userRepository; + @Mock + private ChatRoomRepository chatRoomRepository; + @Mock + private ChatMemberRepository chatMemberRepository; + @Mock + private MeetingCancellationExpirationService expirationService; + + @InjectMocks + private MeetingCancellationService meetingCancellationService; + + @Test + void blocksMembershipChangesWhileCancellationVoteIsPending() { + MeetingMatch match = mock(MeetingMatch.class); + when(match.getId()).thenReturn(20L); + when(meetingMatchRepository.findByMeetingIdAndStatus( + 10L, + MatchingStatus.SUCCEEDED + )).thenReturn(Optional.of(match)); + when(requestRepository.existsByMeetingMatch_IdAndStatus( + 20L, + MeetingCancellationStatus.PENDING + )).thenReturn(true); + + assertThatThrownBy(() -> + meetingCancellationService.validateNoPendingCancellation(10L) + ).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("취소 투표가 진행 중"); + } + + @Test + void allowsMembershipChangesWithoutPendingCancellationVote() { + when(meetingMatchRepository.findByMeetingIdAndStatus( + 10L, + MatchingStatus.SUCCEEDED + )).thenReturn(Optional.empty()); + + assertThatCode(() -> + meetingCancellationService.validateNoPendingCancellation(10L) + ).doesNotThrowAnyException(); + } + + @Test + void createsVotesForMembersOfBothMatchedTeams() { + Meeting meeting1 = mock(Meeting.class); + Meeting meeting2 = mock(Meeting.class); + MeetingMatch match = mock(MeetingMatch.class); + User initiator = user(1L); + + when(match.getId()).thenReturn(20L); + when(match.getMeeting1()).thenReturn(meeting1); + when(match.getMeeting2()).thenReturn(meeting2); + when(match.getMatchingStatus()).thenReturn(MatchingStatus.SUCCEEDED); + when(meeting1.getId()).thenReturn(10L); + when(meeting2.getId()).thenReturn(11L); + when(meetingMatchRepository.findByIdWithLockAndMeeting(20L)) + .thenReturn(Optional.of(match)); + when(meetingMemberRepository.existsByMeeting_IdAndUser_UserIdAndStatus( + 10L, 1L, ChatUserStatus.ACTIVE + )).thenReturn(true); + when(meetingMemberRepository.findByMeetingIdAndStatus(10L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of(member(meeting1, initiator), member(meeting1, user(2L)), member(meeting1, user(3L)))); + when(meetingMemberRepository.findByMeetingIdAndStatus(11L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of(member(meeting2, user(4L)), member(meeting2, user(5L)), member(meeting2, user(6L)))); + when(userRepository.findById(1L)).thenReturn(Optional.of(initiator)); + when(requestRepository.save(any(MeetingCancellationRequest.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var response = meetingCancellationService.create(20L, 1L); + + assertThatCode(() -> response.getVotes()).doesNotThrowAnyException(); + org.assertj.core.api.Assertions.assertThat(response.getMatchId()).isEqualTo(20L); + org.assertj.core.api.Assertions.assertThat(response.getTotalMemberCount()).isEqualTo(6); + org.assertj.core.api.Assertions.assertThat(response.getAgreedMemberCount()).isEqualTo(1); + org.assertj.core.api.Assertions.assertThat(response.getPendingMemberCount()).isEqualTo(5); + } + + @Test + void unanimousApprovalCancelsBothMatchedTeams() { + Meeting meeting1 = mock(Meeting.class); + Meeting meeting2 = mock(Meeting.class); + MeetingMatch match = mock(MeetingMatch.class); + User voter = user(2L); + Instant now = Instant.now(); + MeetingCancellationRequest request = MeetingCancellationRequest.create( + match, + user(1L), + now, + now.plusSeconds(3600) + ); + ReflectionTestUtils.setField(request, "id", 30L); + MeetingCancellationVote vote = MeetingCancellationVote.pending(request, voter); + + when(match.getId()).thenReturn(20L); + when(match.getMeeting1()).thenReturn(meeting1); + when(match.getMeeting2()).thenReturn(meeting2); + when(meeting1.getId()).thenReturn(10L); + when(meeting2.getId()).thenReturn(11L); + when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + when(voteRepository.findByRequest_IdAndUser_UserId(30L, 2L)).thenReturn(Optional.of(vote)); + when(voteRepository.countByRequest_IdAndDecision( + 30L, + CancellationVoteDecision.PENDING + )).thenReturn(0L); + when(voteRepository.countByRequest_IdAndDecision( + 30L, + CancellationVoteDecision.REJECT + )).thenReturn(0L); + when(voteRepository.findAllByRequest_IdOrderById(30L)).thenReturn(List.of(vote)); + when(chatRoomRepository.findByMatch(match)).thenReturn(Optional.empty()); + when(chatRoomRepository.findByMeeting(meeting1)).thenReturn(Optional.empty()); + when(chatRoomRepository.findByMeeting(meeting2)).thenReturn(Optional.empty()); + when(meetingMemberRepository.findByMeetingIdAndStatus(10L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of()); + when(meetingMemberRepository.findByMeetingIdAndStatus(11L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of()); + + meetingCancellationService.vote(30L, 2L, CancellationVoteDecision.AGREE); + + verify(meeting1).cancelByAgreement(); + verify(meeting2).cancelByAgreement(); + } + + @Test + void expiredRequestIsMarkedExpiredWhenVoting() { + Instant requestedAt = Instant.now().minus(Duration.ofHours(25)); + MeetingCancellationRequest request = MeetingCancellationRequest.create( + mock(MeetingMatch.class), + user(1L), + requestedAt, + requestedAt.plus(Duration.ofHours(24)) + ); + when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + + assertThatThrownBy(() -> meetingCancellationService.vote( + 30L, + 2L, + CancellationVoteDecision.AGREE + )) + .isInstanceOf(MeetingCancellationExpiredException.class) + .hasMessage("이미 만료된 미팅 취소 요청입니다."); + + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.EXPIRED); + assertThat(request.getCompletedAt()).isNotNull(); + verifyNoInteractions(voteRepository); + } + + @Test + void continuesExpiringOtherRequestsWhenOneRequestFails() { + when(requestRepository.findExpiredRequestIds( + any(MeetingCancellationStatus.class), + any(Instant.class) + )).thenReturn(List.of(30L, 31L)); + when(expirationService.expire(eq(30L), any(Instant.class))) + .thenThrow(new RuntimeException("optimistic lock conflict")); + when(expirationService.expire(eq(31L), any(Instant.class))) + .thenReturn(true); + + int expiredCount = meetingCancellationService.expirePendingRequests(); + + assertThat(expiredCount).isEqualTo(1); + verify(expirationService).expire(eq(30L), any(Instant.class)); + verify(expirationService).expire(eq(31L), any(Instant.class)); + } + + private MeetingMember member(Meeting meeting, User user) { + return MeetingMember.addMember(meeting, user); + } + + private User user(Long id) { + return User.builder() + .userId(id) + .kakaoId("mock_" + id) + .userName("user" + id) + .build(); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingCancellationDomainTest.java b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingCancellationDomainTest.java new file mode 100644 index 0000000..fb8e4d6 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingCancellationDomainTest.java @@ -0,0 +1,98 @@ +package mannabom_server.manabom.domain.meeting; + +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationVote; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; +import mannabom_server.manabom.domain.user.entity.User; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MeetingCancellationDomainTest { + + @Test + void initiatorAutomaticallyAgrees() { + Instant now = Instant.parse("2026-07-21T00:00:00Z"); + User initiator = user(1L); + MeetingCancellationRequest request = cancellationRequest(initiator, now); + + MeetingCancellationVote vote = MeetingCancellationVote.agreedByInitiator( + request, + initiator, + now + ); + + assertThat(vote.getDecision()).isEqualTo(CancellationVoteDecision.AGREE); + assertThat(vote.getDecidedAt()).isEqualTo(now); + } + + @Test + void pendingMemberCanVoteOnlyOnce() { + Instant now = Instant.parse("2026-07-21T00:00:00Z"); + User initiator = user(1L); + MeetingCancellationVote vote = MeetingCancellationVote.pending( + cancellationRequest(initiator, now), + user(2L) + ); + + vote.decide(CancellationVoteDecision.REJECT, now); + + assertThat(vote.getDecision()).isEqualTo(CancellationVoteDecision.REJECT); + assertThatThrownBy(() -> vote.decide(CancellationVoteDecision.AGREE, now)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void requestExpiresAtItsTwentyFourHourDeadline() { + Instant now = Instant.parse("2026-07-21T00:00:00Z"); + MeetingCancellationRequest request = cancellationRequest(user(1L), now); + Instant deadline = now.plus(Duration.ofHours(24)); + + assertThat(request.isExpiredAt(deadline.minusMillis(1))).isFalse(); + assertThat(request.isExpiredAt(deadline)).isTrue(); + + request.expire(deadline); + + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.EXPIRED); + assertThat(request.getCompletedAt()).isEqualTo(deadline); + } + + @Test + void approvedRequestCannotBeCompletedAgain() { + Instant now = Instant.parse("2026-07-21T00:00:00Z"); + MeetingCancellationRequest request = cancellationRequest(user(1L), now); + + request.approve(now); + + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.APPROVED); + assertThatThrownBy(() -> request.reject(now)) + .isInstanceOf(IllegalStateException.class); + } + + private MeetingCancellationRequest cancellationRequest(User initiator, Instant now) { + return MeetingCancellationRequest.create( + MeetingMatch.builder() + .meeting1(Meeting.builder().id(10L).build()) + .meeting2(Meeting.builder().id(11L).build()) + .build(), + initiator, + now, + now.plus(Duration.ofHours(24)) + ); + } + + private User user(Long id) { + return User.builder() + .userId(id) + .kakaoId("mock_" + id) + .userName("user" + id) + .build(); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMemberLeaveStatusTest.java b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMemberLeaveStatusTest.java new file mode 100644 index 0000000..13e3ba3 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMemberLeaveStatusTest.java @@ -0,0 +1,64 @@ +package mannabom_server.manabom.domain.meeting; + +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.enums.MeetingStatus; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MeetingMemberLeaveStatusTest { + + @Test + void matchedMeetingChangesToFastMatchingAfterMemberLeaves() { + Meeting meeting = meeting(MeetingStatus.MATCHED, 3, 3); + + meeting.deleteMember(25); + meeting.changeToFastMatchingAfterMemberLeave(); + + assertThat(meeting.getCurrentMembers()).isEqualTo(2); + assertThat(meeting.getMeetingStatus()).isEqualTo(MeetingStatus.FASTMATCHING); + } + + @Test + void fullMeetingChangesToRecruitingAfterMemberLeaves() { + Meeting meeting = meeting(MeetingStatus.FULL, 3, 3); + + meeting.deleteMember(25); + meeting.changeToRecruitingAfterMemberLeave(); + + assertThat(meeting.getCurrentMembers()).isEqualTo(2); + assertThat(meeting.getMeetingStatus()).isEqualTo(MeetingStatus.RECRUITING); + } + + @Test + void recruitingMeetingCannotBeChangedToFastMatching() { + Meeting meeting = meeting(MeetingStatus.RECRUITING, 2, 3); + + assertThatThrownBy(meeting::changeToFastMatchingAfterMemberLeave) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void fastMatchingMeetingReturnsToMatchedWhenVacancyIsFilled() { + Meeting meeting = meeting(MeetingStatus.FASTMATCHING, 2, 3); + + meeting.addMember(25); + + assertThat(meeting.getCurrentMembers()).isEqualTo(3); + assertThat(meeting.getMeetingStatus()).isEqualTo(MeetingStatus.MATCHED); + } + + private Meeting meeting( + MeetingStatus status, + int currentMembers, + int maxMembers + ) { + return Meeting.builder() + .meetingStatus(status) + .currentMembers(currentMembers) + .maxMembers(maxMembers) + .avgAge(25) + .build(); + } +}