diff --git a/manabom/build.gradle b/manabom/build.gradle index a131fba..0f43cae 100644 --- a/manabom/build.gradle +++ b/manabom/build.gradle @@ -75,6 +75,9 @@ dependencies { implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-database-postgresql' + //api 문서 + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17' + } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/event/ChatSystemMessageEvent.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/event/ChatSystemMessageEvent.java new file mode 100644 index 0000000..f722a94 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/event/ChatSystemMessageEvent.java @@ -0,0 +1,40 @@ +package mannabom_server.manabom.application.chat.dto.event; + +import mannabom_server.manabom.application.chat.message.SystemMessageType; + +import java.util.List; +import java.util.Map; + +public record ChatSystemMessageEvent( + Long roomId, + SystemMessageType type, + Long actorUserId, + List recipientUserIds, + Map data, + boolean pushEnabled +) { + public ChatSystemMessageEvent { + recipientUserIds = recipientUserIds == null ? null : List.copyOf(recipientUserIds); + data = data == null ? Map.of() : Map.copyOf(data); + } + + public static ChatSystemMessageEvent of( + Long roomId, + SystemMessageType type, + Long actorUserId, + List recipientUserIds, + Map data + ) { + return new ChatSystemMessageEvent(roomId, type, actorUserId, recipientUserIds, data, true); + } + + public static ChatSystemMessageEvent withoutPush( + Long roomId, + SystemMessageType type, + Long actorUserId, + List recipientUserIds, + Map data + ) { + return new ChatSystemMessageEvent(roomId, type, actorUserId, recipientUserIds, data, false); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageEvent.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageEvent.java index 59e659a..b53d29a 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageEvent.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageEvent.java @@ -4,6 +4,8 @@ import lombok.Getter; import java.time.Instant; +import java.util.List; +import java.util.Map; //메세지 보내진 사실을 모든 참여자들에게 보내는 이벤트 서버->클 @Getter @@ -12,8 +14,14 @@ public class ChatMessageEvent { private Long roomId; private Long senderUserId; + private Long actorUserId; + private List recipientUserIds; private String messageType; + private String systemEventType; + private String systemTitle; + private String actorNickname; private String content; + private Map data; private Long messageId; private String clientMessageId; diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponse.java index 9f3f3f9..0ac60f5 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponse.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponse.java @@ -5,6 +5,7 @@ import mannabom_server.manabom.domain.chat.entity.ChatMessage; import java.time.Instant; +import java.util.Map; @Getter @Builder @@ -12,6 +13,11 @@ public class ChatMessageResponse { private Long messageId; private String content; private String messageType; + private String systemEventType; + private String systemTitle; + private Long actorUserId; + private String actorNickname; + private Map data; private Instant createdAt; private Long senderId; @@ -21,8 +27,13 @@ public static ChatMessageResponse of(ChatMessage msg){ .messageId(msg.getId()) .createdAt(msg.getCreatedAt()) .messageType(msg.getType().name()) - .senderId(msg.getUser().getUserId()) + .senderId(msg.getUser() != null ? msg.getUser().getUserId() : null) .content(msg.getContent()) + .systemEventType(msg.getSystemEventType()) + .systemTitle(msg.getSystemTitle()) + .actorUserId(msg.getActorUserId()) + .actorNickname(msg.getActorNickname()) + .data(msg.getSystemData()) .build(); } } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatRoomListResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatRoomListResponse.java index ca470e6..c8e3028 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatRoomListResponse.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatRoomListResponse.java @@ -4,7 +4,7 @@ import lombok.Getter; import mannabom_server.manabom.domain.chat.entity.ChatMessage; import mannabom_server.manabom.domain.chat.entity.ChatRoom; -import mannabom_server.manabom.domain.chat.enums.ChatRoomType; +import mannabom_server.manabom.domain.chat.enums.ChatMessageType; import java.time.Instant; @@ -21,10 +21,17 @@ public static ChatRoomListResponse of(ChatRoom room,String roomName, ChatMessage return ChatRoomListResponse.builder() .roomId(room.getId()) .roomName(roomName) - .lastMessage(lastMsg!=null ? lastMsg.getType().getDisplayMessage(lastMsg.getContent()): null ) + .lastMessage(lastMsg != null ? lastMessagePreview(lastMsg) : null) .lastmessageAt(lastMsg != null ? lastMsg.getCreatedAt() : null) .hasUnreadMessages(hasUnreadMessages) .build(); } + private static String lastMessagePreview(ChatMessage message) { + if (message.getType() == ChatMessageType.SYSTEM && message.getSystemTitle() != null) { + return message.getSystemTitle(); + } + return message.getType().getDisplayMessage(message.getContent()); + } + } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandler.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandler.java new file mode 100644 index 0000000..fa7faa8 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandler.java @@ -0,0 +1,67 @@ +package mannabom_server.manabom.application.chat.handler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.service.SystemMessageService; +import mannabom_server.manabom.application.notification.dto.MatchFailureEvent; +import mannabom_server.manabom.application.notification.dto.MatchFoundEvent; +import mannabom_server.manabom.application.notification.dto.MatchSuccessEvent; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatSystemMessageEventHandler { + + private final SystemMessageService systemMessageService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMatchFound(MatchFoundEvent event) { + try { + systemMessageService.dispatch( + systemMessageService.recordMatchFound(event.matchId(), event.decisionDeadline()) + ); + } catch (RuntimeException e) { + log.error("매칭 발견 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMatchFailure(MatchFailureEvent event) { + try { + systemMessageService.dispatch(systemMessageService.recordMatchFailure( + event.matchId(), + event.triggerUserId(), + Boolean.TRUE.equals(event.isByTimeout()) + )); + } catch (RuntimeException e) { + log.error("매칭 실패 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMatchSuccess(MatchSuccessEvent event) { + try { + systemMessageService.dispatch(systemMessageService.recordMatchSuccess( + event.matchId(), + event.triggerUserId(), + event.chatRoomId() + )); + } catch (RuntimeException e) { + log.error("매칭 성사 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleRoomSystemMessage(ChatSystemMessageEvent event) { + try { + systemMessageService.dispatch(systemMessageService.recordForRoom(event)); + } catch (RuntimeException e) { + log.error("채팅방 시스템 메시지 처리 실패: roomId={}, type={}", + event.roomId(), event.type(), e); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageType.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageType.java new file mode 100644 index 0000000..95d04d2 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageType.java @@ -0,0 +1,175 @@ +package mannabom_server.manabom.application.chat.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; + +import java.util.Map; + +@Getter +@RequiredArgsConstructor +public enum SystemMessageType { + MATCHING_STARTED( + "매칭을 시작했어요", + "팀장님이 매칭을 시작하셨어요." + ), + MATCHING_STOPPED( + "매칭을 중단했어요", + "팀장님이 매칭을 중단하셨어요." + ), + MATCH_FOUND( + "미팅할 상대 팀을 찾았어요‼️✨", + "수락 가능 시간 안에 팀원들과 상의해 주세요.\n" + + "팀장은 상단 헤더에서 수락을 확정해 주세요.\n" + + "수락 가능 시간이 지나면 자동 거절돼요." + ), + MATCH_REJECTED_BY_LEADER( + "아쉽네요", + "팀장님이 매칭을 거절했어요.\n새로운 상대 팀을 찾고 있어요." + ), + MATCH_REJECTED_BY_OPPONENT( + "아쉽네요", + "이번에는 상대 팀과 연결되지 않았어요.\n다시 매칭 신청을 눌러주세요!" + ), + MATCH_TIMED_OUT( + "응답 시간이 지났어요", + "수락 가능 시간이 지나 매칭이 자동으로 거절됐어요." + ), + OPPONENT_MATCH_TIMED_OUT( + "아쉽네요", + "상대 팀의 응답 시간이 지나 매칭이 취소됐어요.\n다시 매칭 신청을 눌러주세요!" + ), + MATCH_COMPLETED( + "미팅이 성사되었어요 🎉🎉", + "이 채팅방은 더 이상 이용하실 수 없습니다.\n" + + "새로운 채팅방으로 이동하여 상대 팀과 이야기를 나눠보세요!" + ), + CHAT_ROOM_CREATED( + "매칭이 성사되었어요 🎉", + "서로 인사를 나누고 즐거운 대화를 시작해 보세요." + ), + MEETING_VERIFICATION_STARTED( + "만남인증 시작", + "만남인증이 시작되었어요." + ), + MEETING_VERIFICATION_SUCCEEDED( + "만남인증 성공", + "만남인증이 완료되었어요." + ), + MEETING_VERIFICATION_FAILED( + "만남인증 실패", + "만남인증이 완료되지 않았어요." + ), + MEETING_CANCELLATION_VOTE_STARTED( + "미팅 취소 투표", + "미팅 취소 투표가 시작되었어요.\n" + + "모든 인원이 취소에 동의하면 채팅방이 사라지고 채팅방에서 사용된 팅은 위약금으로 제외하고 환불해드려요." + ), + MEETING_CANCELLATION_APPROVED( + "미팅 취소 투표 결과", + "모두가 동의하여 미팅과 채팅방이 취소되었어요." + ), + MEETING_CANCELLATION_REJECTED( + "미팅 취소 투표 결과", + "모두가 동의하지 않아 취소되지 않았어요." + ), + MEETING_CANCELLATION_EXPIRED( + "미팅 취소 투표 결과", + "투표 시간이 지나 미팅이 취소되지 않았어요." + ), + PHOTO_REQUESTED( + "프로필 공개 요청", + "수락할 시 서로의 프로필이 공개됩니다.\n헤더를 내려 수락하기 버튼을 눌러주세요." + ), + PHOTO_REQUEST_ACCEPTED( + "서로의 프로필이 공개되었습니다! ✨", + "서로의 프로필을 눌러 더 다양한 대화를 나눠보세요!" + ), + PHOTO_REQUEST_REJECTED( + "프로필 공개가 거절되었어요", + "아직 상대가 조심스러운가 봐요.\n더 대화를 나눠보세요!" + ); + + private final String defaultTitle; + private final String defaultBody; + + public RenderedSystemMessage render(String actorNickname, Map data) { + String nickname = StringUtils.hasText(actorNickname) ? actorNickname : "사용자"; + int participantCount = number(data, "participantCount"); + int verifiedParticipantCount = data != null && data.containsKey("verifiedParticipantCount") + ? number(data, "verifiedParticipantCount") + : participantCount; + + return switch (this) { + case MATCHING_STARTED -> message( + defaultTitle, + "팀장 " + nickname + "님이 매칭을 시작하셨어요." + ); + case MATCHING_STOPPED -> message( + defaultTitle, + "팀장 " + nickname + "님이 매칭을 중단하셨어요." + ); + case MATCH_REJECTED_BY_LEADER -> message( + defaultTitle, + "팀장 " + nickname + "님이 매칭을 거절했어요.\n새로운 상대 팀을 찾고 있어요." + ); + case MEETING_VERIFICATION_STARTED -> message( + defaultTitle, + nickname + "님이 만남인증을 시작했어요." + ); + case MEETING_VERIFICATION_SUCCEEDED -> message( + defaultTitle, + nickname + "님의 참여로 만남인증이 완료되었어요.\n" + + "참여인원 : " + verifiedParticipantCount + "명" + ); + case MEETING_VERIFICATION_FAILED -> message( + defaultTitle, + nickname + "님이 시작한 만남인증이 완료되지 않았어요.\n" + + "참여인원 : " + participantCount + "명" + ); + case MEETING_CANCELLATION_VOTE_STARTED -> message( + defaultTitle, + nickname + "님이 미팅 취소 투표를 시작하셨어요!\n" + + "모든 인원이 취소에 동의하면 채팅방이 사라지고 채팅방에서 사용된 팅은 위약금으로 제외하고 환불해드려요." + ); + case PHOTO_REQUESTED -> message( + nickname + "님이 프로필 공개를 요청했어요", + defaultBody + ); + case PHOTO_REQUEST_ACCEPTED -> message( + defaultTitle, + nickname + "님이 프로필 공개 요청을 수락했어요.\n" + defaultBody + ); + case PHOTO_REQUEST_REJECTED -> message( + defaultTitle, + nickname + "님이 아직은 조심스러운가 봐요.\n더 대화를 나눠보세요!" + ); + default -> message(defaultTitle, defaultBody); + }; + } + + private static int number(Map data, String key) { + if (data == null) { + return 0; + } + Object value = data.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String stringValue) { + try { + return Integer.parseInt(stringValue); + } catch (NumberFormatException ignored) { + return 0; + } + } + return 0; + } + + private static RenderedSystemMessage message(String title, String body) { + return new RenderedSystemMessage(title, body); + } + + public record RenderedSystemMessage(String title, String body) { + } +} 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..74c6790 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 @@ -4,18 +4,18 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import mannabom_server.manabom.application.chat.dto.event.ChatRoomLeaveEvent; +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; import mannabom_server.manabom.application.meeting.dto.response.MatchedChatRoomInfo; import mannabom_server.manabom.application.meeting.service.MeetingMatchingService; import mannabom_server.manabom.application.meeting.service.MeetingMemberService; import mannabom_server.manabom.application.meeting.service.MeetingService; import mannabom_server.manabom.domain.chat.entity.ChatMember; -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.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; import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; import mannabom_server.manabom.domain.matching.entity.LoveViewRecommendHistory; import mannabom_server.manabom.domain.matching.entity.ProfileRecommendHistory; @@ -29,6 +29,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -39,9 +40,6 @@ public class ChatRoomService { private final ChatMemberRepository chatMemberRepository; private final ChatMemberQueryRepository chatMemberQueryRepository; private final UserRepository userRepository; - private final ChatMessageRepository chatMessageRepository; - - private final MeetingMemberService meetingMemberService; private final ApplicationEventPublisher eventPublisher; @@ -57,6 +55,11 @@ public Long createMeetingChatRoom(Meeting meeting,User user){ @Transactional public MatchedChatRoomInfo createMatchingChatRoom(MeetingMatch match){ + return createMatchingChatRoom(match, null); + } + + @Transactional + public MatchedChatRoomInfo createMatchingChatRoom(MeetingMatch match, Long actorUserId){ ChatRoom chatRoom = ChatRoom.createMatchingChatRoom(match); chatRoomRepository.save(chatRoom); @@ -71,7 +74,7 @@ public MatchedChatRoomInfo createMatchingChatRoom(MeetingMatch match){ newChatMembers.add(ChatMember.create(chatRoom,m.getUser())); chatMemberRepository.saveAll(newChatMembers); - sendSystemWelcomeMessage(chatRoom); + publishRoomCreated(chatRoom, actorUserId); List participants = chatMemberQueryRepository.findParticipantsByRoomId(chatRoom.getId()); return MatchedChatRoomInfo.of(chatRoom.getId(),participants); @@ -82,11 +85,16 @@ public MatchedChatRoomInfo createMatchingChatRoom(MeetingMatch match){ * */ @Transactional public Long createProfileChatRoom(ProfileRecommendHistory profileHistory){ + return createProfileChatRoom(profileHistory, null); + } + + @Transactional + public Long createProfileChatRoom(ProfileRecommendHistory profileHistory, Long actorUserId){ ChatRoom chatRoom = ChatRoom.createProfileChatRoom(profileHistory); chatRoomRepository.save(chatRoom); setOneToOneChatMember(chatRoom, profileHistory.getRequesterUserId(),profileHistory.getTargetUserId()); - sendSystemWelcomeMessage(chatRoom); + publishRoomCreated(chatRoom, actorUserId); return chatRoom.getId(); } @@ -95,22 +103,27 @@ public Long createProfileChatRoom(ProfileRecommendHistory profileHistory){ * */ @Transactional public Long createLoveViewChatRoom(LoveViewRecommendHistory loveViewHistory){ + return createLoveViewChatRoom(loveViewHistory, null); + } + + @Transactional + public Long createLoveViewChatRoom(LoveViewRecommendHistory loveViewHistory, Long actorUserId){ ChatRoom chatRoom = ChatRoom.createLoveviewChatRoom(loveViewHistory); chatRoomRepository.save(chatRoom); setOneToOneChatMember(chatRoom, loveViewHistory.getRequesterUserId(),loveViewHistory.getTargetUserId()); - sendSystemWelcomeMessage(chatRoom); + publishRoomCreated(chatRoom, actorUserId); return chatRoom.getId(); } - private void sendSystemWelcomeMessage(ChatRoom room ){ - ChatMessage welcomeMessage = ChatMessage.builder() - .room(room) - .type(ChatMessageType.SYSTEM) - .content("🎉 매칭이 성사되었습니다! 서로 인사를 나눠보세요.") - .user(null) - .build(); - chatMessageRepository.save(welcomeMessage); + private void publishRoomCreated(ChatRoom room, Long actorUserId){ + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + room.getId(), + SystemMessageType.CHAT_ROOM_CREATED, + actorUserId, + null, + Map.of("roomType", room.getType().name()) + )); } private void setOneToOneChatMember(ChatRoom chatRoom, Long user1Id, Long user2Id){ @@ -135,15 +148,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..4aa4047 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,11 +10,13 @@ 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.ChatMessageType; +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; import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; -import mannabom_server.manabom.domain.meeting.enums.SseEventName; +import mannabom_server.manabom.domain.notification.enums.NotificationType; import mannabom_server.manabom.domain.user.entity.Profile; import mannabom_server.manabom.domain.user.entity.ProfileImage; import mannabom_server.manabom.domain.user.entity.User; @@ -52,8 +54,15 @@ public class ChatService { //채팅 보내기 @Transactional public void sendMessage(ChatSendRequest request, Long userId) { + if (request.getMessageType() == ChatMessageType.SYSTEM) { + throw new IllegalArgumentException("시스템 메시지는 클라이언트가 전송할 수 없습니다."); + } + 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) @@ -73,18 +82,31 @@ public void sendMessage(ChatSendRequest request, Long userId) { sender.updateLastReadMessageId(message.getId()); + List recipientUserIds = members.stream() + .map(member -> member.getUser().getUserId()) + .filter(memberUserId -> !memberUserId.equals(userId)) + .toList(); + ChatMessageEvent event = ChatMessageEvent.builder() .roomId(request.getRoomId()) .sendAt(message.getCreatedAt()) .senderUserId(userId) + .actorUserId(userId) + .recipientUserIds(recipientUserIds) .content(request.getContent()) .messageId(message.getId()) .clientMessageId(request.getClientMessageId()) .messageType(request.getMessageType().name()) .build(); - simpMessagingTemplate.convertAndSend("/topic/rooms/" + request.getRoomId(), event); - - sendNotificationWithSSEOrPush(request, members, userId, profile.getNickName()); + boolean broadcasted = broadcast(event); + + sendPushToMembersOutsideRoom( + request, + members, + userId, + profile.getNickName(), + !broadcasted + ); log.debug("채팅 전송 완료: room={}, sender={}, msgId={}", chatRoom.getId(), userId, message.getId()); @@ -92,7 +114,24 @@ public void sendMessage(ChatSendRequest request, Long userId) { - private void sendNotificationWithSSEOrPush(ChatSendRequest request, List members, Long senderId, String senderNickname) { + private boolean broadcast(ChatMessageEvent event) { + try { + simpMessagingTemplate.convertAndSend("/topic/rooms/" + event.getRoomId(), event); + return true; + } catch (RuntimeException e) { + log.error("채팅 WebSocket 전송 실패: roomId={}, messageId={}", + event.getRoomId(), event.getMessageId(), e); + return false; + } + } + + private void sendPushToMembersOutsideRoom( + ChatSendRequest request, + List members, + Long senderId, + String senderNickname, + boolean forcePush + ) { String displayContent = request.getMessageType().getDisplayMessage(request.getContent()); Map notifyData = Map.of( @@ -105,9 +144,15 @@ 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 +194,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/chat/service/SystemMessageService.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/SystemMessageService.java new file mode 100644 index 0000000..b1947ce --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/SystemMessageService.java @@ -0,0 +1,307 @@ +package mannabom_server.manabom.application.chat.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.dto.response.ChatMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; +import mannabom_server.manabom.application.notification.service.NotificationService; +import mannabom_server.manabom.domain.chat.entity.ChatMember; +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.repository.ChatMemberRepository; +import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.MeetingDecision; +import mannabom_server.manabom.domain.meeting.repository.MeetingMatchRepository; +import mannabom_server.manabom.domain.notification.enums.NotificationType; +import mannabom_server.manabom.domain.user.repository.ProfileRepository; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SystemMessageService { + + private static final String USER_LOCATION_PREFIX = "user:location:"; + + private final ChatRoomRepository chatRoomRepository; + private final ChatMemberRepository chatMemberRepository; + private final ChatMessageRepository chatMessageRepository; + private final MeetingMatchRepository meetingMatchRepository; + private final ProfileRepository profileRepository; + private final SimpMessagingTemplate messagingTemplate; + private final StringRedisTemplate stringRedisTemplate; + private final NotificationService notificationService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public RecordedSystemMessage recordForRoom(ChatSystemMessageEvent event) { + ChatRoom room = chatRoomRepository.findById(event.roomId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 채팅방입니다.")); + return save(room, event); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List recordMatchFound(Long matchId, Instant deadline) { + MeetingMatch match = findMatch(matchId); + Map data = Map.of( + "matchId", matchId, + "decisionDeadline", deadline.toString() + ); + return List.of( + saveForMeeting(match.getMeeting1(), ChatSystemMessageEvent.of( + roomIdForMeeting(match.getMeeting1()), + SystemMessageType.MATCH_FOUND, + null, + null, + data + )), + saveForMeeting(match.getMeeting2(), ChatSystemMessageEvent.of( + roomIdForMeeting(match.getMeeting2()), + SystemMessageType.MATCH_FOUND, + null, + null, + data + )) + ); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List recordMatchFailure( + Long matchId, + Long actorUserId, + boolean isByTimeout + ) { + MeetingMatch match = findMatch(matchId); + Meeting failedMeeting = findFailedMeeting(match, isByTimeout); + Meeting opponentMeeting = failedMeeting.getId().equals(match.getMeeting1().getId()) + ? match.getMeeting2() + : match.getMeeting1(); + + SystemMessageType failedTeamType = isByTimeout + ? SystemMessageType.MATCH_TIMED_OUT + : SystemMessageType.MATCH_REJECTED_BY_LEADER; + boolean bothTeamsTimedOut = isByTimeout + && match.getMeeting1Decision() == MeetingDecision.AUTO_REJECTED + && match.getMeeting2Decision() == MeetingDecision.AUTO_REJECTED; + SystemMessageType opponentType = isByTimeout + ? bothTeamsTimedOut + ? SystemMessageType.MATCH_TIMED_OUT + : SystemMessageType.OPPONENT_MATCH_TIMED_OUT + : SystemMessageType.MATCH_REJECTED_BY_OPPONENT; + Map data = Map.of("matchId", matchId, "timeout", isByTimeout); + + return List.of( + saveForMeeting(failedMeeting, ChatSystemMessageEvent.of( + roomIdForMeeting(failedMeeting), + failedTeamType, + actorUserId, + null, + data + )), + saveForMeeting(opponentMeeting, ChatSystemMessageEvent.of( + roomIdForMeeting(opponentMeeting), + opponentType, + actorUserId, + null, + data + )) + ); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List recordMatchSuccess(Long matchId, Long actorUserId, Long chatRoomId) { + MeetingMatch match = findMatch(matchId); + Map data = Map.of( + "matchId", matchId, + "chatRoomId", chatRoomId + ); + + return List.of( + saveForMeeting(match.getMeeting1(), ChatSystemMessageEvent.withoutPush( + roomIdForMeeting(match.getMeeting1()), + SystemMessageType.MATCH_COMPLETED, + actorUserId, + null, + data + )), + saveForMeeting(match.getMeeting2(), ChatSystemMessageEvent.withoutPush( + roomIdForMeeting(match.getMeeting2()), + SystemMessageType.MATCH_COMPLETED, + actorUserId, + null, + data + )) + ); + } + + public void dispatch(RecordedSystemMessage recorded) { + boolean broadcasted = broadcast(recorded.message()); + if (recorded.pushEnabled()) { + sendPushFallback(recorded, !broadcasted); + } + } + + public void dispatch(List messages) { + messages.forEach(this::dispatch); + } + + private boolean broadcast(ChatMessageEvent event) { + try { + messagingTemplate.convertAndSend("/topic/rooms/" + event.getRoomId(), event); + return true; + } catch (RuntimeException e) { + log.error("시스템 메시지 WebSocket 전송 실패: roomId={}, messageId={}", + event.getRoomId(), event.getMessageId(), e); + return false; + } + } + + private void sendPushFallback(RecordedSystemMessage recorded, boolean forcePush) { + ChatMessageEvent message = recorded.message(); + for (Long recipientUserId : message.getRecipientUserIds()) { + if (!forcePush && isViewingRoom(recipientUserId, message.getRoomId())) { + continue; + } + + Map data = new HashMap<>(message.getData()); + data.put("roomId", message.getRoomId()); + data.put("messageId", message.getMessageId()); + data.put("systemEventType", recorded.type().name()); + if (message.getActorUserId() != null) { + data.put("actorUserId", message.getActorUserId()); + } + if (message.getActorNickname() != null) { + data.put("actorNickname", message.getActorNickname()); + } + + try { + notificationService.sendNotification( + recipientUserId, + NotificationType.SYSTEM_MESSAGE, + message.getSystemTitle(), + null, + data + ); + } catch (RuntimeException e) { + log.error("시스템 메시지 Push 알림 처리 실패: userId={}, roomId={}, type={}", + recipientUserId, message.getRoomId(), recorded.type(), e); + } + } + } + + private boolean isViewingRoom(Long userId, Long roomId) { + String location = stringRedisTemplate.opsForValue().get(USER_LOCATION_PREFIX + userId); + return String.valueOf(roomId).equals(location); + } + + private RecordedSystemMessage saveForMeeting(Meeting meeting, ChatSystemMessageEvent event) { + ChatRoom room = chatRoomRepository.findByMeeting_Id(meeting.getId()) + .orElseThrow(() -> new IllegalArgumentException("미팅 채팅방이 존재하지 않습니다.")); + return save(room, event); + } + + private RecordedSystemMessage save(ChatRoom room, ChatSystemMessageEvent event) { + String actorNickname = resolveActorNickname(event.actorUserId()); + SystemMessageType.RenderedSystemMessage rendered = event.type().render(actorNickname, event.data()); + ChatMessage message = chatMessageRepository.saveAndFlush( + ChatMessage.system( + room, + rendered.body(), + event.type().name(), + rendered.title(), + event.actorUserId(), + actorNickname, + event.data() + ) + ); + List recipients = resolveRecipients(room.getId(), event.actorUserId(), event.recipientUserIds()); + + ChatMessageEvent response = ChatMessageEvent.builder() + .roomId(room.getId()) + .senderUserId(null) + .actorUserId(event.actorUserId()) + .recipientUserIds(recipients) + .messageType(message.getType().name()) + .systemEventType(message.getSystemEventType()) + .systemTitle(message.getSystemTitle()) + .actorNickname(message.getActorNickname()) + .content(message.getContent()) + .data(message.getSystemData()) + .messageId(message.getId()) + .clientMessageId(null) + .sendAt(message.getCreatedAt()) + .build(); + return new RecordedSystemMessage(response, event.type(), event.pushEnabled()); + } + + private String resolveActorNickname(Long actorUserId) { + if (actorUserId == null) { + return null; + } + return profileRepository.findByUser_UserId(actorUserId) + .map(profile -> profile.getNickName()) + .filter(org.springframework.util.StringUtils::hasText) + .orElse(null); + } + + private List resolveRecipients(Long roomId, Long actorUserId, List explicitRecipients) { + List recipients = explicitRecipients == null + ? chatMemberRepository.findAllByRoomIdAndStatus(roomId, ChatMemberStatus.ACTIVATE) + .stream() + .map(ChatMember::getUser) + .map(user -> user.getUserId()) + .toList() + : new ArrayList<>(explicitRecipients); + + return recipients.stream() + .filter(userId -> actorUserId == null || !actorUserId.equals(userId)) + .distinct() + .toList(); + } + + private Long roomIdForMeeting(Meeting meeting) { + return chatRoomRepository.findByMeeting_Id(meeting.getId()) + .map(ChatRoom::getId) + .orElseThrow(() -> new IllegalArgumentException("미팅 채팅방이 존재하지 않습니다.")); + } + + private MeetingMatch findMatch(Long matchId) { + return meetingMatchRepository.findByIdWithMeeting(matchId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅 매칭입니다.")); + } + + private Meeting findFailedMeeting(MeetingMatch match, boolean isByTimeout) { + MeetingDecision failedDecision = isByTimeout + ? MeetingDecision.AUTO_REJECTED + : MeetingDecision.REJECTED; + + if (match.getMeeting1Decision() == failedDecision) { + return match.getMeeting1(); + } + if (match.getMeeting2Decision() == failedDecision) { + return match.getMeeting2(); + } + throw new IllegalStateException("거절한 미팅 팀을 확인할 수 없습니다."); + } + + public record RecordedSystemMessage( + ChatMessageEvent message, + SystemMessageType type, + boolean pushEnabled + ) { + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/like/service/LikeService.java b/manabom/src/main/java/mannabom_server/manabom/application/like/service/LikeService.java index fc35569..b897eef 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/like/service/LikeService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/like/service/LikeService.java @@ -131,15 +131,22 @@ public RespondSignalResponseDto respondLike(Long responderUserId, Long likeReque Long chatRoomId = null; if (accepted) { likeRequest.accept(); - chatRoomId = createChatRoom(likeRequest.getFromUserId(), likeRequest.getToUserId(), likeRequest.getSource()); + chatRoomId = createChatRoom( + likeRequest.getFromUserId(), + likeRequest.getToUserId(), + likeRequest.getSource(), + responderUserId + ); } else { likeRequest.reject(rejectReason); - } - - try { - pushService.sendToUser(likeRequest.getFromUserId(), PushMessages.likeResponded(accepted, likeRequest.getToUserId())); - } catch (Exception e) { - log.warn("좋아요 응답 푸시 전송 실패 likeRequestId={} accepted={}", likeRequestId, accepted, e); + try { + pushService.sendToUser( + likeRequest.getFromUserId(), + PushMessages.likeResponded(false, likeRequest.getToUserId()) + ); + } catch (Exception e) { + log.warn("좋아요 응답 푸시 전송 실패 likeRequestId={} accepted=false", likeRequestId, e); + } } return RespondSignalResponseDto.builder() @@ -149,17 +156,22 @@ public RespondSignalResponseDto respondLike(Long responderUserId, Long likeReque .build(); } - private Long createChatRoom(Long requesterUserId, Long targetUserId, LikeSource source) { + private Long createChatRoom( + Long requesterUserId, + Long targetUserId, + LikeSource source, + Long actorUserId + ) { if (source == LikeSource.PROFILE_MATCH) { ProfileRecommendHistory history = profileRecommendHistoryRepository .findTopByRequesterUserIdAndTargetUserIdOrderByRecommendedAtDesc(requesterUserId, targetUserId) .orElseThrow(() -> new IllegalStateException("프로필 추천 이력이 없어 채팅방을 생성할 수 없습니다.")); - return chatRoomService.createProfileChatRoom(history); + return chatRoomService.createProfileChatRoom(history, actorUserId); } LoveViewRecommendHistory history = loveViewRecommendHistoryRepository .findTopByRequesterUserIdAndTargetUserIdOrderByRecommendedAtDesc(requesterUserId, targetUserId) .orElseThrow(() -> new IllegalStateException("연애관 추천 이력이 없어 채팅방을 생성할 수 없습니다.")); - return chatRoomService.createLoveViewChatRoom(history); + return chatRoomService.createLoveViewChatRoom(history, actorUserId); } } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/matching/service/PhotoRequestService.java b/manabom/src/main/java/mannabom_server/manabom/application/matching/service/PhotoRequestService.java index 1216ac7..405fff2 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/matching/service/PhotoRequestService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/matching/service/PhotoRequestService.java @@ -2,7 +2,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import mannabom_server.manabom.application.notification.service.NotificationService; +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; import mannabom_server.manabom.domain.chat.entity.ChatMember; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; @@ -15,15 +16,14 @@ import mannabom_server.manabom.domain.matching.enums.LoveViewPhotoStatus; import mannabom_server.manabom.domain.matching.enums.PhotoRequestStatus; import mannabom_server.manabom.domain.matching.repository.LoveViewPhotoRequestRepository; -import mannabom_server.manabom.domain.meeting.enums.SseEventName; import mannabom_server.manabom.domain.user.entity.User; import mannabom_server.manabom.domain.user.repository.UserRepository; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Map; +import java.util.List; import java.util.Optional; @Service @@ -36,9 +36,7 @@ public class PhotoRequestService { private final UserRepository userRepository; private final ChatMemberRepository chatMemberRepository; - private final SimpMessagingTemplate simpMessagingTemplate; - private final NotificationService notificationService; - private final StringRedisTemplate stringRedisTemplate; + private final ApplicationEventPublisher eventPublisher; //조회- 채팅방 입장 public LoveViewPhotoStatus getPhotoRequestStatus(Long roomId, Long userId) { @@ -58,13 +56,17 @@ public LoveViewPhotoStatus getPhotoRequestStatus(Long roomId, Long userId) { return request.getSender().getUserId().equals(userId) ? LoveViewPhotoStatus.PENDING : LoveViewPhotoStatus.RECEIVED; if (request.getStatus() == PhotoRequestStatus.REJECTED) { - int messageCount = chatMessageRepository.countChatMessagesByRoom_IdAndCreatedAtAfter(room.getId(), request.getUpdatedAt()); + int messageCount = chatMessageRepository + .countChatMessagesByRoom_IdAndCreatedAtAfterAndUserIsNotNull( + room.getId(), + request.getUpdatedAt() + ); return messageCount >= 10 ? LoveViewPhotoStatus.READY : LoveViewPhotoStatus.REJECTED; } } - int messageCount = chatMessageRepository.countChatMessagesByRoom_Id(room.getId()); + int messageCount = chatMessageRepository.countChatMessagesByRoom_IdAndUserIsNotNull(room.getId()); return messageCount >= 10 ? LoveViewPhotoStatus.READY : LoveViewPhotoStatus.WAITING; } @@ -100,8 +102,16 @@ public void createPhotoRequest(Long roomId, Long userId) { .build(); photoRequestRepository.save(request); - sendNotification(roomId, receiver.getUserId(), "프로필 공개 요청", - "상대방이 프로필을 몹시 궁금해하고 있어요!", SseEventName.PHOTO_REQUEST_RECEIVED, LoveViewPhotoStatus.RECEIVED); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + roomId, + SystemMessageType.PHOTO_REQUESTED, + userId, + List.of(receiver.getUserId()), + Map.of( + "actorStatus", LoveViewPhotoStatus.PENDING.name(), + "recipientStatus", LoveViewPhotoStatus.RECEIVED.name() + ) + )); } @@ -114,11 +124,18 @@ public void acceptPhotoRequest(Long roomId, Long userId) { LoveViewPhotoRequest request = findPendingRequest(room.getLoveView().getId(), userId); request.accept(); - User opponent = getOpponent(roomId, userId); - sendNotification(roomId, opponent.getUserId(), - "프로필 열람 성공!", "상대방이 요청을 수락했어요! 지금 바로 확인해 보세요.", - SseEventName.PHOTO_REQUEST_ACCEPTED, LoveViewPhotoStatus.ACCEPTED); } + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + roomId, + SystemMessageType.PHOTO_REQUEST_ACCEPTED, + userId, + List.of(opponent.getUserId()), + Map.of( + "actorStatus", LoveViewPhotoStatus.ACCEPTED.name(), + "recipientStatus", LoveViewPhotoStatus.ACCEPTED.name() + ) + )); + } //거절 @Transactional @@ -130,11 +147,17 @@ public void rejectPhotoRequest(Long roomId, Long userId) { LoveViewPhotoRequest request = findPendingRequest(room.getLoveView().getId(), userId); request.reject(); - User opponent = getOpponent(roomId, userId); - sendNotification(roomId, opponent.getUserId(), - "다음에 다시 시도해 봐요!", "상대방이 아직은 사진 공개가 조금 부끄러운가 봐요.", - SseEventName.PHOTO_REQUEST_REJECTED, LoveViewPhotoStatus.REJECTED); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + roomId, + SystemMessageType.PHOTO_REQUEST_REJECTED, + userId, + List.of(opponent.getUserId()), + Map.of( + "actorStatus", LoveViewPhotoStatus.REJECTED.name(), + "recipientStatus", LoveViewPhotoStatus.REJECTED.name() + ) + )); } private LoveViewPhotoRequest findPendingRequest(Long historyId, Long userId){ LoveViewPhotoRequest request = photoRequestRepository.findTopByHistoryIdOrderByIdDesc(historyId) @@ -173,15 +196,6 @@ private User getOpponent(Long chatRoomId, Long userId){ .findFirst() .orElseThrow(()-> new IllegalStateException("상대방이 채팅방을 나갔습니다.")); } - private void sendNotification(Long roomId, Long targetUserId, String title, String msg, SseEventName sseEventName, LoveViewPhotoStatus status){ - simpMessagingTemplate.convertAndSend("/topic/rooms/"+roomId, Map.of("status", status, "roomId", roomId)); - - String location = stringRedisTemplate.opsForValue().get("user:location:"+targetUserId); - if(!String.valueOf(roomId).equals(location)){ - - notificationService.sendNotification(targetUserId,sseEventName,title, msg,Map.of("roomId",roomId)); - } - } private void validateActiveMember(Long roomId, Long userId) { chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus( roomId, userId, ChatMemberStatus.ACTIVATE 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/MatchingResultDataDto.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MatchingResultDataDto.java index 0148f9c..0fbb95e 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MatchingResultDataDto.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MatchingResultDataDto.java @@ -1,30 +1,46 @@ package mannabom_server.manabom.application.meeting.dto.response; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonUnwrapped; import lombok.Builder; import lombok.Getter; import mannabom_server.manabom.application.meeting.dto.common.RegionDto; import mannabom_server.manabom.domain.meeting.entity.Meeting; import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.MatchingStatus; import java.time.Instant; import java.util.List; @Getter @Builder +@JsonInclude(JsonInclude.Include.NON_NULL) public class MatchingResultDataDto { Long matchId; + MatchingStatus matchingStatus; + boolean requeued; Instant decisionDeadline; OpponentTeamInfo opponentTeamInfo; + public static MatchingResultDataDto failed(MeetingMatch match) { + return MatchingResultDataDto.builder() + .matchId(match.getId()) + .matchingStatus(MatchingStatus.FAILED) + .requeued(true) + .build(); + } - public static MatchingResultDataDto of(MeetingMatch match, Meeting opponent,List members ){ + public static MatchingResultDataDto withOpponent( + MeetingMatch match, + Meeting opponent, + List members + ) { return MatchingResultDataDto.builder() .matchId(match.getId()) + .matchingStatus(match.getMatchingStatus()) + .requeued(false) .decisionDeadline(match.getDecisionDeadLine()) - .opponentTeamInfo( - OpponentTeamInfo.of(opponent,members) - ) + .opponentTeamInfo(OpponentTeamInfo.of(opponent, members)) .build(); } 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..924b837 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationResponse.java @@ -0,0 +1,60 @@ +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 int rejectedMemberCount; + 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(); + int rejected = (int) votes.stream() + .filter(vote -> vote.getDecision() == CancellationVoteDecision.REJECT) + .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) + .rejectedMemberCount(rejected) + .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/scheduler/MeetingVerificationScheduler.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingVerificationScheduler.java new file mode 100644 index 0000000..59806cb --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingVerificationScheduler.java @@ -0,0 +1,31 @@ +package mannabom_server.manabom.application.meeting.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.meeting.service.MeetingVerificationExpirationService; +import mannabom_server.manabom.domain.meeting.repository.MeetingVerificationRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MeetingVerificationScheduler { + + private final MeetingVerificationRepository verificationRepository; + private final MeetingVerificationExpirationService expirationService; + + @Scheduled(fixedDelayString = "${meeting.verification.expiration-check-delay:60000}") + public void notifyExpiredVerifications() { + Instant now = Instant.now(); + for (Long verificationId : verificationRepository.findFailureNotificationTargetIds(now)) { + try { + expirationService.notifyFailure(verificationId, now); + } catch (RuntimeException e) { + log.warn("만남 인증 실패 알림 처리 실패: verificationId={}", verificationId, e); + } + } + } +} 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..323a74f --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationService.java @@ -0,0 +1,85 @@ +package mannabom_server.manabom.application.meeting.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.entity.MeetingMember; +import mannabom_server.manabom.domain.meeting.enums.ChatUserStatus; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationRequestRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingMemberRepository; +import org.springframework.stereotype.Service; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class MeetingCancellationExpirationService { + + private final MeetingCancellationRequestRepository requestRepository; + private final ChatRoomRepository chatRoomRepository; + private final MeetingMemberRepository meetingMemberRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean expire(Long requestId, Instant now) { + MeetingCancellationRequest request = requestRepository.findByIdForUpdate(requestId) + .orElse(null); + + return request != null && expire(request, now); + } + + @Transactional + public boolean expire(MeetingCancellationRequest request, Instant now) { + if (!request.isExpiredAt(now)) { + return false; + } + + request.expire(now); + ChatRoom room = chatRoomRepository.findByMatch(request.getMeetingMatch()) + .orElseThrow(() -> new IllegalStateException("매칭 채팅방이 존재하지 않습니다.")); + List recipients = activeMemberUserIds(request.getMeetingMatch()); + Map data = new HashMap<>(); + if (request.getId() != null) { + data.put("requestId", request.getId()); + } + data.put("status", request.getStatus().name()); + data.put("expiresAt", request.getExpiresAt().toString()); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + room.getId(), + SystemMessageType.MEETING_CANCELLATION_EXPIRED, + null, + recipients, + data + )); + return true; + } + + private List activeMemberUserIds(MeetingMatch match) { + List members = new ArrayList<>(); + members.addAll(meetingMemberRepository.findByMeetingIdAndStatus( + match.getMeeting1().getId(), + ChatUserStatus.ACTIVE + )); + members.addAll(meetingMemberRepository.findByMeetingIdAndStatus( + match.getMeeting2().getId(), + ChatUserStatus.ACTIVE + )); + + return members.stream() + .map(MeetingMember::getUser) + .map(user -> user.getUserId()) + .distinct() + .toList(); + } +} 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..068d70b --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java @@ -0,0 +1,320 @@ +package mannabom_server.manabom.application.meeting.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; +import mannabom_server.manabom.application.meeting.dto.response.MeetingCancellationResponse; +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.context.ApplicationEventPublisher; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@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; + private final ApplicationEventPublisher eventPublisher; + + @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); + publishCancellationEvent( + request, + SystemMessageType.MEETING_CANCELLATION_VOTE_STARTED, + userId, + memberUserIds(members) + ); + + 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 (expirationService.expire(request, now)) { + throw new MeetingCancellationExpiredException( + "이미 만료된 미팅 취소 요청입니다." + ); + } + + if (request.getStatus() != MeetingCancellationStatus.PENDING) { + throw new IllegalStateException("이미 종료된 미팅 취소 요청입니다."); + } + + MeetingCancellationVote vote = voteRepository + .findByRequest_IdAndUser_UserId(requestId, userId) + .orElseThrow(() -> new IllegalArgumentException("해당 미팅의 투표 대상자가 아닙니다.")); + List recipientUserIds = memberUserIds(activeMembers(request.getMeetingMatch())); + vote.decide(decision, now); + + if (decision == CancellationVoteDecision.REJECT) { + request.reject(now); + publishCancellationEvent( + request, + SystemMessageType.MEETING_CANCELLATION_REJECTED, + userId, + recipientUserIds + ); + } else if (allMembersAgreed(requestId)) { + + publishCancellationEvent( + request, + SystemMessageType.MEETING_CANCELLATION_APPROVED, + userId, + recipientUserIds + ); + + 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("진행 중인 미팅 취소 요청이 없습니다.")); + expirationService.expire(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 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 List memberUserIds(List members) { + return members.stream() + .map(member -> member.getUser().getUserId()) + .distinct() + .toList(); + } + + private void publishCancellationEvent( + MeetingCancellationRequest request, + SystemMessageType type, + Long actorUserId, + List recipientUserIds + ) { + Long roomId = chatRoomRepository.findByMatch(request.getMeetingMatch()) + .map(ChatRoom::getId) + .orElseThrow(() -> new IllegalStateException("매칭 채팅방이 존재하지 않습니다.")); + Map data = new HashMap<>(); + if (request.getId() != null) { + data.put("requestId", request.getId()); + } + data.put("status", request.getStatus().name()); + data.put("expiresAt", request.getExpiresAt().toString()); + + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + roomId, + type, + actorUserId, + recipientUserIds, + data + )); + } + + 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 -> 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..d65c0bd 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 @@ -10,7 +10,6 @@ import mannabom_server.manabom.application.notification.dto.MatchFailureEvent; import mannabom_server.manabom.application.notification.dto.MatchFoundEvent; import mannabom_server.manabom.application.notification.dto.MatchSuccessEvent; -import mannabom_server.manabom.application.notification.service.NotificationService; import mannabom_server.manabom.domain.meeting.entity.Meeting; import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; import mannabom_server.manabom.domain.meeting.entity.MeetingMember; @@ -217,7 +216,7 @@ public void processMatchSuccess(MeetingMatchingEvent me, MeetingMatchingEvent op checkRejectCount(meeting2, meetingMatch); if(meetingMatch.getMatchingStatus() == MatchingStatus.SUCCEEDED){ - handleMatchSuccess(meetingMatch,false); + handleMatchSuccess(meetingMatch,false, null); } else eventPublisher.publishEvent(new MatchFoundEvent(meetingMatch.getId(), meetingMatch.getDecisionDeadLine())); } @@ -256,7 +255,7 @@ public AcceptMatchDataDto acceptMatch(Long matchId,Long userId){ match.accept(myMeeting.getId()); if(match.getMatchingStatus()== MatchingStatus.SUCCEEDED){ log.info("매칭 성사! 채팅방 생성을 위한 추가 정보 로딩... matchId={}", matchId); - MatchedChatRoomInfo info = handleMatchSuccess(match,false); + MatchedChatRoomInfo info = handleMatchSuccess(match,false, userId); //상대방 알림 return AcceptMatchDataDto.ofMatched(info); } @@ -282,7 +281,7 @@ public RejectMatchDataDto rejectMatch(Long matchId, Long userId){ //둘 다 수락 - 정상 사용자라면 이럴 일 없지만 if(match.getMatchingStatus() == MatchingStatus.SUCCEEDED){ - handleMatchSuccess(match,false); + handleMatchSuccess(match,false, userId); } if(match.getMatchingStatus()==MatchingStatus.FAILED){ handleMatchFailure(match ,userId,false); @@ -298,22 +297,36 @@ private void reQueue(Meeting meeting) { addToQueue(event); } - public MatchingResultDataDto getMatchingResult(Long matchId, Long userId){ + public MatchingResultDataDto getMatchingResult(Long matchId, Long userId) { MeetingMatch match = meetingMatchRepository.findByIdWithMeeting(matchId) - .orElseThrow(()-> new IllegalArgumentException("매칭 결과 조회: 존재하지 않는 매칭 아이디 입니다."+ matchId)); - Meeting opponent; + .orElseThrow(() -> new IllegalArgumentException( + "매칭 결과 조회: 존재하지 않는 매칭 아이디입니다. " + matchId + )); + Meeting opponent = findOpponentMeeting(match, userId); + + if (match.getMatchingStatus() == MatchingStatus.FAILED) { + return MatchingResultDataDto.failed(match); + } + + List members = + meetingMemberReadService.getActiveTeammMemberDetails(opponent.getId()); + return MatchingResultDataDto.withOpponent(match, opponent, members); + } + + private Meeting findOpponentMeeting(MeetingMatch match, Long userId) { Meeting meeting1 = match.getMeeting1(); Meeting meeting2 = match.getMeeting2(); - if(meetingMemberService.isMember(meeting1.getId(),userId)) - opponent= meeting2; - else if(meetingMemberService.isMember(meeting2.getId(),userId)) - opponent= meeting1; - else throw new IllegalArgumentException("매칭 결과:해당 매칭에 속하지 않은 유저 아이디 입니다."+ userId); - - List members = meetingMemberReadService.getActiveTeammMemberDetails(opponent.getId()); - return MatchingResultDataDto.of(match,opponent,members); + if (meetingMemberService.isMember(meeting1.getId(), userId)) { + return meeting2; + } + if (meetingMemberService.isMember(meeting2.getId(), userId)) { + return meeting1; + } + throw new IllegalArgumentException( + "매칭 결과: 해당 매칭에 속하지 않은 사용자입니다. userId=" + userId + ); } @Transactional @@ -326,7 +339,7 @@ public void processAutoDecision(Long matchId){ } match.processExpiration(); if(match.getMatchingStatus().equals(MatchingStatus.SUCCEEDED)){ - handleMatchSuccess(match,true); + handleMatchSuccess(match,true, null); } else if(match.getMatchingStatus().equals(MatchingStatus.FAILED)) { @@ -335,9 +348,19 @@ else if(match.getMatchingStatus().equals(MatchingStatus.FAILED)) { } - private MatchedChatRoomInfo handleMatchSuccess(MeetingMatch match, boolean isByTimeout){ - MatchedChatRoomInfo info = chatRoomService.createMatchingChatRoom(match); - eventPublisher.publishEvent(new MatchSuccessEvent(match.getId(),info.getRoomId(),isByTimeout)); + private MatchedChatRoomInfo handleMatchSuccess( + MeetingMatch match, + boolean isByTimeout, + Long triggerUserId + ){ + MatchedChatRoomInfo info = chatRoomService.createMatchingChatRoom(match, triggerUserId); + chatRoomService.disableMeetingGroupChatRooms(match); + eventPublisher.publishEvent(new MatchSuccessEvent( + match.getId(), + info.getRoomId(), + isByTimeout, + triggerUserId + )); return info; } @@ -365,6 +388,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 +411,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..1ca94a4 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 @@ -2,6 +2,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; import mannabom_server.manabom.application.chat.service.ChatRoomService; import mannabom_server.manabom.application.meeting.dto.common.*; import mannabom_server.manabom.application.meeting.dto.request.*; @@ -13,6 +15,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; @@ -27,6 +31,7 @@ import java.security.SecureRandom; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; @Service @@ -40,6 +45,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 +53,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 +184,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 +215,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 +259,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 +293,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) { @@ -339,6 +404,13 @@ public MatchingStartDataDto startMatching( Long userId){ //비동기 이벤트 발행 MeetingMatchingEvent event = MeetingMatchingEvent.from(meeting); eventPublisher.publishEvent(event); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + chatRoomService.getChatRoomId(meeting), + SystemMessageType.MATCHING_STARTED, + userId, + null, + Map.of("meetingId", meeting.getId()) + )); return MatchingStartDataDto.builder() @@ -353,19 +425,28 @@ public boolean cancelMatching(Long userId){ .orElseThrow(()-> new IllegalArgumentException("매칭 취소: 리더만이 매칭을 취소할 수 있습니다. "+ userId)); meeting.cancelMatching(); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + chatRoomService.getChatRoomId(meeting), + SystemMessageType.MATCHING_STOPPED, + userId, + null, + Map.of("meetingId", meeting.getId()) + )); return true; } @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 +457,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/application/meeting/service/MeetingVerificationExpirationService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationExpirationService.java new file mode 100644 index 0000000..35bca3a --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationExpirationService.java @@ -0,0 +1,44 @@ +package mannabom_server.manabom.application.meeting.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; +import mannabom_server.manabom.domain.meeting.entity.MeetingVerification; +import mannabom_server.manabom.domain.meeting.repository.MeetingVerificationRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class MeetingVerificationExpirationService { + + private final MeetingVerificationRepository verificationRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean notifyFailure(Long verificationId, Instant now) { + MeetingVerification verification = verificationRepository.findByIdForUpdate(verificationId) + .orElse(null); + if (verification == null || !verification.needsFailureNotification(now)) { + return false; + } + + verification.markFailureNotified(now); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + verification.getRoom().getId(), + SystemMessageType.MEETING_VERIFICATION_FAILED, + verification.getStartedBy() == null ? null : verification.getStartedBy().getUserId(), + null, + Map.of( + "expiredAt", verification.getExpiresAt().toString(), + "participantCount", verification.getParticipantCount() + ) + )); + return true; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationService.java index c658a98..c5b81f7 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationService.java @@ -2,6 +2,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; import mannabom_server.manabom.domain.chat.entity.ChatMember; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; @@ -17,6 +19,7 @@ import mannabom_server.manabom.global.util.LocationUtils; import org.redisson.api.RedissonClient; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; @@ -24,6 +27,7 @@ import java.time.*; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import org.springframework.transaction.annotation.Transactional; @@ -41,6 +45,7 @@ public class MeetingVerificationService { private final StringRedisTemplate stringRedisTemplate; private final RedissonClient redissonClient; private final SimpMessagingTemplate simpMessagingTemplate; + private final ApplicationEventPublisher eventPublisher; private static final String POS_KEY = "meeting:pos:%d:%d"; private static final String GATHER_KEY = "meeting:gather:%d"; @@ -52,17 +57,27 @@ public class MeetingVerificationService { public String verifyMeeting(Long chatRoomId, Long userId, double latitude, double longitude){ ChatRoom room = chatRoomRepository.findByIdForUpdate(chatRoomId) .orElseThrow(()-> new IllegalArgumentException("존재 하지 않는 채팅방 입니다.")); - getActiveChatMember(chatRoomId, userId); + ChatMember actorMember = getActiveChatMember(chatRoomId, userId); MeetingVerification verification = getOrCreateVerification(room); Instant now = Instant.now(); - verification.startIfNeeded(now, VERIFICATION_TTL); + boolean startedNow = verification.getStartedAt() == null; + verification.startIfNeeded(now, VERIFICATION_TTL, actorMember.getUser()); + if (startedNow) { + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + chatRoomId, + SystemMessageType.MEETING_VERIFICATION_STARTED, + userId, + null, + Map.of("expiresAt", verification.getExpiresAt().toString()) + )); + } if(verification.isExpired(now)){ throw new IllegalStateException("만남인증 가능 시간이 지났습니다."); } if(verification.isVerified()){ - processLatecomer(chatRoomId, userId, latitude, longitude); + processLatecomer(chatRoomId, userId, latitude, longitude, verification); return "합류 성공! 보상이 지급됩니다."; } return processGeneral(chatRoomId, userId, latitude, longitude, verification); @@ -97,11 +112,15 @@ public MeetingVerificationStatusData getStatus(Long chatRoomId, Long userId){ MeetingVerification verification = verificationOpt.get(); boolean mySubmitted = stringRedisTemplate.hasKey(String.format(POS_KEY, chatRoomId, userId)); FinalLocation myLocation = getUserLocation(chatRoomId, userId).orElse(null); - BestCluster bestCluster = findBestCluster(chatRoomId); + BestCluster bestCluster = verification.isVerified() + ? BestCluster.verifiedSnapshot(verification) + : findBestCluster(chatRoomId); Instant now = Instant.now(); long remainingSeconds = Math.max(0, verification.remainingTime(now).toSeconds()); - FinalLocation finalLocation = getFinalLocation(chatRoomId).orElse(null); + FinalLocation finalLocation = verification.hasFinalLocation() + ? new FinalLocation(verification.getFinalLatitude(), verification.getFinalLongitude()) + : getFinalLocation(chatRoomId).orElse(null); if(verification.isVerified()){ return MeetingVerificationStatusData.verified( @@ -161,10 +180,17 @@ private ChatMember getActiveChatMember(Long chatRoomId, Long userId){ } - private void processLatecomer(Long chatRoomId, Long userId, double latitude, double longitude ){ + private void processLatecomer( + Long chatRoomId, + Long userId, + double latitude, + double longitude, + MeetingVerification verification + ){ if(isAlreadyVerifiedParticipant(chatRoomId, userId)){ throw new IllegalStateException("이미 만남인증에 참여 완료되었습니다."); } + boolean firstSubmission = !stringRedisTemplate.hasKey(String.format(POS_KEY, chatRoomId, userId)); String finalPos = stringRedisTemplate.opsForValue().get(String.format(FINAL_LOC_KEY,chatRoomId)); if(finalPos == null) throw new IllegalStateException("인증 유효 기간이 지났습니다."); @@ -181,6 +207,7 @@ private void processLatecomer(Long chatRoomId, Long userId, double latitude, dou //보상 reward(chatRoomId, List.of(userId)); + verification.recordVerifiedLatecomer(firstSubmission); simpMessagingTemplate.convertAndSend("/topic/chat/"+ chatRoomId, "LATECOMER_OK:"+userId); } @@ -194,6 +221,8 @@ private String processGeneral(Long chatRoomId, Long userId, double latitude, dou saveUserLocation(chatRoomId, userId, latitude, longitude, positionTtl); stringRedisTemplate.opsForSet().add(gatherKey,userId.toString()); redissonClient.getSet(gatherKey).expire(positionTtl); + Long submittedCount = stringRedisTemplate.opsForSet().size(gatherKey); + verification.updateParticipantCount(submittedCount == null ? 0 : submittedCount.intValue()); int totalMembers = chatMemberRepository.countChatMemberByRoomIdAndStatus(chatRoomId, ChatMemberStatus.ACTIVATE); int requiredCount = requiredCount(totalMembers); @@ -202,7 +231,13 @@ private String processGeneral(Long chatRoomId, Long userId, double latitude, dou if(nearbyCount >= requiredCount){ if(bestCluster.hasMale() && bestCluster.hasFemale()){ - verification.verify(); + verification.verify( + bestCluster.count(), + bestCluster.latitude(), + bestCluster.longitude(), + bestCluster.hasMale(), + bestCluster.hasFemale() + ); reward(chatRoomId, bestCluster.userIds()); Duration finalLocationTtl = verification.remainingTime(Instant.now()); if(finalLocationTtl.isZero() || finalLocationTtl.isNegative()){ @@ -216,7 +251,19 @@ private String processGeneral(Long chatRoomId, Long userId, double latitude, dou redissonClient.getBucket(String.format(FINAL_LOC_KEY, chatRoomId)).expire(finalLocationTtl); stringRedisTemplate.delete(gatherKey); - simpMessagingTemplate.convertAndSend("/topic/chat/" + chatRoomId, "VERIFIED_SUCCESS"); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + chatRoomId, + SystemMessageType.MEETING_VERIFICATION_SUCCEEDED, + userId, + null, + Map.of( + "verifiedAt", verification.getVerifiedAt().toString(), + "expiresAt", verification.getExpiresAt().toString(), + "participantCount", bestCluster.count(), + "submittedCount", verification.getParticipantCount(), + "verifiedParticipantCount", bestCluster.count() + ) + )); return "모든 조건 충족! 만남 인증이 완료되었습니다. ✨"; } else { @@ -303,7 +350,7 @@ private BestCluster toBestCluster(Long chatRoomId, List subm .average() .orElse(0.0); - return new BestCluster(userIds, latitude, longitude, hasMale, hasFemale); + return new BestCluster(userIds, userIds.size(), latitude, longitude, hasMale, hasFemale); } private List getLocationSubmissions(Long chatRoomId){ @@ -377,7 +424,9 @@ public record MeetingVerificationStatusData( boolean verified, FinalLocation myLocation, FinalLocation finalLocation, - FinalLocation bestClusterLocation + FinalLocation bestClusterLocation, + int submittedCount, + int verifiedParticipantCount ) { static MeetingVerificationStatusData notStarted(int totalMembers, int requiredCount, BestCluster bestCluster, boolean mySubmitted){ return new MeetingVerificationStatusData( @@ -394,7 +443,9 @@ static MeetingVerificationStatusData notStarted(int totalMembers, int requiredCo false, null, null, - bestCluster.locationOrNull() + bestCluster.locationOrNull(), + 0, + 0 ); } @@ -421,7 +472,9 @@ static MeetingVerificationStatusData inProgress( false, myLocation, null, - bestCluster.locationOrNull() + bestCluster.locationOrNull(), + verification.getParticipantCount(), + 0 ); } @@ -449,7 +502,9 @@ static MeetingVerificationStatusData verified( true, myLocation, finalLocation, - bestCluster.locationOrNull() + bestCluster.locationOrNull(), + verification.getParticipantCount(), + verification.getVerifiedParticipantCount() ); } @@ -475,7 +530,9 @@ static MeetingVerificationStatusData expired( false, myLocation, null, - bestCluster.locationOrNull() + bestCluster.locationOrNull(), + verification.getParticipantCount(), + 0 ); } } @@ -486,17 +543,31 @@ public record FinalLocation(double latitude, double longitude) { private record LocationSubmission(Long userId, double latitude, double longitude) { } - private record BestCluster(List userIds, double latitude, double longitude, boolean hasMale, boolean hasFemale) { + private record BestCluster( + List userIds, + int count, + double latitude, + double longitude, + boolean hasMale, + boolean hasFemale + ) { static BestCluster empty(){ - return new BestCluster(List.of(), 0.0, 0.0, false, false); + return new BestCluster(List.of(), 0, 0.0, 0.0, false, false); } - int count(){ - return userIds.size(); + static BestCluster verifiedSnapshot(MeetingVerification verification) { + return new BestCluster( + List.of(), + verification.getVerifiedParticipantCount(), + verification.getFinalLatitude() == null ? 0.0 : verification.getFinalLatitude(), + verification.getFinalLongitude() == null ? 0.0 : verification.getFinalLongitude(), + verification.isVerifiedHasMale(), + verification.isVerifiedHasFemale() + ); } FinalLocation locationOrNull(){ - if(userIds.isEmpty()){ + if(count == 0){ return null; } return new FinalLocation(latitude, longitude); diff --git a/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java b/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java index 23b4b47..27b3273 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java @@ -132,15 +132,22 @@ public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long Long chatRoomId = null; if (accepted) { messageRequest.accept(); - chatRoomId = createChatRoom(messageRequest.getFromUserId(), messageRequest.getToUserId(), messageRequest.getSource()); + chatRoomId = createChatRoom( + messageRequest.getFromUserId(), + messageRequest.getToUserId(), + messageRequest.getSource(), + responderUserId + ); } else { messageRequest.reject(rejectReason); - } - - try { - pushService.sendToUser(messageRequest.getFromUserId(), PushMessages.messageRequestResponded(accepted, messageRequest.getId())); - } catch (Exception e) { - log.warn("메시지 요청 응답 푸시 전송 실패 messageRequestId={} accepted={}", messageRequestId, accepted, e); + try { + pushService.sendToUser( + messageRequest.getFromUserId(), + PushMessages.messageRequestResponded(false, messageRequest.getId()) + ); + } catch (Exception e) { + log.warn("메시지 요청 응답 푸시 전송 실패 messageRequestId={} accepted=false", messageRequestId, e); + } } return RespondSignalResponseDto.builder() @@ -150,17 +157,22 @@ public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long .build(); } - private Long createChatRoom(Long requesterUserId, Long targetUserId, MessageSource source) { + private Long createChatRoom( + Long requesterUserId, + Long targetUserId, + MessageSource source, + Long actorUserId + ) { if (source == MessageSource.PROFILE_MATCH) { ProfileRecommendHistory history = profileRecommendHistoryRepository .findTopByRequesterUserIdAndTargetUserIdOrderByRecommendedAtDesc(requesterUserId, targetUserId) .orElseThrow(() -> new IllegalStateException("프로필 추천 이력이 없어 채팅방을 생성할 수 없습니다.")); - return chatRoomService.createProfileChatRoom(history); + return chatRoomService.createProfileChatRoom(history, actorUserId); } LoveViewRecommendHistory history = loveViewRecommendHistoryRepository .findTopByRequesterUserIdAndTargetUserIdOrderByRecommendedAtDesc(requesterUserId, targetUserId) .orElseThrow(() -> new IllegalStateException("연애관 추천 이력이 없어 채팅방을 생성할 수 없습니다.")); - return chatRoomService.createLoveViewChatRoom(history); + return chatRoomService.createLoveViewChatRoom(history, actorUserId); } } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/MatchFailureEvent.java b/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/MatchFailureEvent.java index 90a8285..010560b 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/MatchFailureEvent.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/MatchFailureEvent.java @@ -1,6 +1,5 @@ package mannabom_server.manabom.application.notification.dto; -import java.time.Instant; public record MatchFailureEvent( Long matchId, diff --git a/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/MatchSuccessEvent.java b/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/MatchSuccessEvent.java index 5757107..25007aa 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/MatchSuccessEvent.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/MatchSuccessEvent.java @@ -5,6 +5,7 @@ public record MatchSuccessEvent( Long matchId, Long chatRoomId, - boolean isByTimeout //타임 아웃에 의한 성사인지 + boolean isByTimeout, + Long triggerUserId ) { } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/SseData.java b/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/SseData.java deleted file mode 100644 index 27fc751..0000000 --- a/manabom/src/main/java/mannabom_server/manabom/application/notification/dto/SseData.java +++ /dev/null @@ -1,4 +0,0 @@ -package mannabom_server.manabom.application.notification.dto; - -public record SseData(String title, String body, Object payload){ -} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/notification/handler/NotificationEventListener.java b/manabom/src/main/java/mannabom_server/manabom/application/notification/handler/NotificationEventListener.java deleted file mode 100644 index c1abcad..0000000 --- a/manabom/src/main/java/mannabom_server/manabom/application/notification/handler/NotificationEventListener.java +++ /dev/null @@ -1,38 +0,0 @@ -package mannabom_server.manabom.application.notification.handler; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import mannabom_server.manabom.application.notification.dto.MatchFailureEvent; -import mannabom_server.manabom.application.notification.dto.MatchFoundEvent; -import mannabom_server.manabom.application.notification.dto.MatchSuccessEvent; -import mannabom_server.manabom.application.notification.service.NotificationService; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Component -@Slf4j -@RequiredArgsConstructor -public class NotificationEventListener { - private final NotificationService notificationService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleMatchSuccess(MatchSuccessEvent event){ - notificationService.sendMatchSuccess(event.matchId(), event.chatRoomId(), event.isByTimeout()); - } - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleMatchFailure(MatchFailureEvent event){ - notificationService.sendMatchFailure(event.matchId(), event.triggerUserId(), event.isByTimeout()); - } - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleMatchFound(MatchFoundEvent event){ - notificationService.sendMatchFound(event.matchId(),event.decisionDeadline()); - } - -} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/notification/service/NotificationService.java b/manabom/src/main/java/mannabom_server/manabom/application/notification/service/NotificationService.java index 1d233ba..4d48a5f 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/notification/service/NotificationService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/notification/service/NotificationService.java @@ -1,32 +1,20 @@ package mannabom_server.manabom.application.notification.service; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import mannabom_server.manabom.application.meeting.service.MeetingMatchingService; -import mannabom_server.manabom.application.meeting.service.MeetingMemberService; -import mannabom_server.manabom.application.notification.dto.SseData; import mannabom_server.manabom.application.pushService.service.pushSender.PushService; -import mannabom_server.manabom.domain.meeting.entity.Meeting; -import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; -import mannabom_server.manabom.domain.meeting.entity.MeetingMember; -import mannabom_server.manabom.domain.meeting.enums.MeetingDecision; -import mannabom_server.manabom.domain.meeting.enums.SseEventName; -import mannabom_server.manabom.domain.meeting.repository.MeetingMatchRepository; import mannabom_server.manabom.domain.notification.entity.Notification; +import mannabom_server.manabom.domain.notification.enums.NotificationType; import mannabom_server.manabom.domain.notification.repository.NotificationRepository; import mannabom_server.manabom.domain.pushMessage.PushMessage; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.HashMap; -import java.util.List; import java.util.Map; @Slf4j @@ -35,171 +23,90 @@ @Transactional(readOnly = true) public class NotificationService { - private final SseService sseService; private final PushService pushService; - private final MeetingMemberService meetingMemberService; - private final NotificationRepository notificationRepository; - private final MeetingMatchRepository meetingMatchRepository; - private final ObjectMapper objectMapper; @Transactional - public void sendMatchFound(Long matchId, Instant decisionDeadLine){ - MeetingMatch match = findMatch(matchId); - String timeStr = formatTime(decisionDeadLine); - - String title = "두근두근 새 매칭 도착! 💘"; - String message = timeStr + "까지 수락 여부를 결정해주세요."; - - Map data = new HashMap<>(); - data.put("matchId", matchId); - data.put("deadline", decisionDeadLine.toString()); - - - - - broadcastToMeetingMembers(match.getMeeting1().getId(),SseEventName.MATCH_FOUND,title, message,data); - broadcastToMeetingMembers(match.getMeeting2().getId(),SseEventName.MATCH_FOUND,title, message,data); - } - - @Transactional - public void sendMatchSuccess(Long matchId, Long chatRoomId,boolean isByTimeout){ - MeetingMatch match = findMatch(matchId); - - Map data = new HashMap<>(); - data.put("matchId",matchId); - data.put("chatRoomId",chatRoomId); - - sendMatchSuccessToTeam(match.getMeeting1(),match.getMeeting1Decision(),isByTimeout,data); - sendMatchSuccessToTeam(match.getMeeting2(),match.getMeeting2Decision(),isByTimeout,data); - - } - private void sendMatchSuccessToTeam(Meeting meeting, MeetingDecision decision, boolean isByTimeout, Map data){ - String title, message; - if(decision==MeetingDecision.AUTO_ACCEPTED){ - if(isByTimeout){ - title = "매칭 자동 성사 ⏰"; - message = "시간 초과 및 거절권 부족으로 자동 수락되었습니다. 채팅으로 대화를 나눠보세요"; - }else { - title = "매칭 발견 및 성사 🎉"; - message = "거절권 부족으로 자동 수락되었습니다. 채팅으로 대화를 나눠보세요"; - } - } - else{ - title = "매칭 성사! 🎉"; - message = "상대방의 수락으로 매칭이 성사되었습니다! 채팅으로 대화를 나눠보세요"; - } - broadcastToMeetingMembers(meeting.getId(),SseEventName.MATCHING_COMPLETED,title, message,data); - } - - - @Transactional - public void sendMatchFailure(Long matchId, Long triggerUserId, boolean isByTimeout){ - MeetingMatch match = findMatch(matchId); - - Map data = new HashMap<>(); - data.put("matchId",matchId); - - String title = "매칭 실패 😢"; - String message; - if(isByTimeout){ - boolean isMeeting1Timeout = match.getMeeting1Decision()==MeetingDecision.AUTO_REJECTED; - Long timeoutTeam = isMeeting1Timeout ? match.getMeeting1().getId() : match.getMeeting2().getId(); - Long opponent = isMeeting1Timeout ? match.getMeeting2().getId() : match.getMeeting1().getId(); - - message = "시간 안에 응답하지 않아. 자동으로 거절되었습니다. 더 좋은 인연을 찾아볼게요!"; - broadcastToMeetingMembers(timeoutTeam,SseEventName.MATCHING_FAILED,title,message,data); - message= "상대방의 무응답으로 매칭이 취소되었습니다.더 좋은 인연을 찾아볼게요!"; - broadcastToMeetingMembers(opponent,SseEventName.MATCHING_FAILED,title,message,data); - } - else { - boolean didMeeting1Reject = isMemberOfMeeting(match.getMeeting1().getId(), triggerUserId); Long rejectTeam = didMeeting1Reject ? match.getMeeting1().getId() : match.getMeeting2().getId(); - Long opponent = didMeeting1Reject ? match.getMeeting2().getId() : match.getMeeting1().getId(); - - message = "우리 팀 리더가 매칭을 거절했습니다.더 좋은 인연을 찾아볼게요!"; - broadcastToMeetingMembersExcept(rejectTeam,triggerUserId,SseEventName.MATCHING_FAILED,title,message,data); - message= "상대방의 거절로 매칭이 취소되었습니다. 더 좋은 인연을 찾아볼게요!" ; - broadcastToMeetingMembers(opponent,SseEventName.MATCHING_FAILED,title,message,data); - } - } - - @Transactional - public void sendNotification(Long userId, SseEventName event, String title, String message, Object data) { - String jsonData = null; - try{ - if(data!=null) - jsonData= objectMapper.writeValueAsString(data); - } - catch (JsonProcessingException e) { - jsonData = "{}"; - } + public void sendNotification( + Long userId, + NotificationType type, + String title, + String message, + Object data + ) { Notification notification = Notification.builder() .userId(userId) .title(title) .message(message) - .type(event) - .data(jsonData) + .type(type) + .data(toJson(data)) .isRead(false) .build(); notificationRepository.save(notification); - if (!sseService.send(userId, event, new SseData(title, message, data))) { - - log.info("유저({})가 오프라인이므로 Push 알림을 전송합니다.", userId); - PushMessage pushMessage = new PushMessage(title, message, convertObjectToMap(event, data)); - pushService.sendToUser(userId, pushMessage); - } + sendPushAfterCommit( + userId, + new PushMessage(title, message, convertObjectToMap(type, data)) + ); } - private Map convertObjectToMap(SseEventName event, Object data) { - Map fcmMap = new HashMap<>(); - fcmMap.put("type", event.getDescription()); - if (data != null) { - try{ - Map dataMap = objectMapper.convertValue(data, new TypeReference>() {}); - fcmMap.putAll(dataMap); - } catch (Exception e){ - log.warn("단순 맵 변환 실패, JSON String으로 대체:{}", e.getMessage()); - try{ - fcmMap.put("data", objectMapper.writeValueAsString(data)); - } catch (JsonProcessingException ignored) { - } - } + private void sendPushAfterCommit(Long userId, PushMessage pushMessage) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + sendPushSafely(userId, pushMessage); + } + } + ); + return; } - return fcmMap; - } - private MeetingMatch findMatch(Long matchId){ - return meetingMatchRepository.findById(matchId) - .orElseThrow(()->new IllegalArgumentException("알림: 존재하지 않는 매칭 아이디입니다.")); + sendPushSafely(userId, pushMessage); } - private String formatTime(Instant instant){ - return DateTimeFormatter.ofPattern("M월 d일(E) HH:mm") - .withZone(ZoneId.of("Asia/Seoul")) - .format(instant); + private void sendPushSafely(Long userId, PushMessage pushMessage) { + try { + pushService.sendToUser(userId, pushMessage); + } catch (RuntimeException e) { + log.error("Push 알림 전송 실패: userId={}", userId, e); + } } - private void broadcastToMeetingMembers(Long meetingId,SseEventName event, String title,String message, Object data){ - List members = meetingMemberService.getActiveMembers(meetingId); - - for(MeetingMember member:members){ - sendNotification(member.getUser().getUserId(),event,title,message,data); + private String toJson(Object data) { + if (data == null) { + return null; + } + try { + return objectMapper.writeValueAsString(data); + } catch (JsonProcessingException e) { + log.warn("알림 데이터 JSON 변환 실패", e); + return "{}"; } } - private void broadcastToMeetingMembersExcept(Long meetingId,Long excludeUserId,SseEventName event, String title,String message, Object data){ - List members = meetingMemberService.getActiveMembers(meetingId); - for(MeetingMember member:members){ - if(!member.getUser().getUserId().equals(excludeUserId)) - sendNotification(member.getUser().getUserId(),event,title,message,data); + private Map convertObjectToMap(NotificationType type, Object data) { + Map payload = new HashMap<>(); + payload.put("type", type.name()); + if (data == null) { + return payload; } - } - private boolean isMemberOfMeeting(Long meetingId, Long userId){ - return meetingMemberService.isMember(meetingId,userId); + try { + Map values = objectMapper.convertValue(data, Map.class); + for (Map.Entry entry : values.entrySet()) { + Object value = entry.getValue(); + payload.put( + String.valueOf(entry.getKey()), + value instanceof String ? (String) value : objectMapper.writeValueAsString(value) + ); + } + } catch (Exception e) { + log.warn("Push 알림 데이터 변환 실패", e); + payload.put("data", toJson(data)); + } + return payload; } - - -} \ No newline at end of file +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/notification/service/SseService.java b/manabom/src/main/java/mannabom_server/manabom/application/notification/service/SseService.java deleted file mode 100644 index 974296f..0000000 --- a/manabom/src/main/java/mannabom_server/manabom/application/notification/service/SseService.java +++ /dev/null @@ -1,116 +0,0 @@ -package mannabom_server.manabom.application.notification.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import mannabom_server.manabom.domain.meeting.enums.SseEventName; -import mannabom_server.manabom.domain.notification.entity.SseEventCache; -import mannabom_server.manabom.domain.notification.repository.EmitterRepository; -import mannabom_server.manabom.domain.notification.repository.SseEventCacheRepository; -import org.springframework.stereotype.Service; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.io.IOException; -import java.util.List; - -@Slf4j -@Service -@RequiredArgsConstructor -public class SseService { - - private final EmitterRepository emitterRepository; - private final SseEventCacheRepository cacheRepository; - private final ObjectMapper objectMapper; - - public SseEmitter createEmitterConnection(Long userId, String lastEventId) { - SseEmitter emitter = emitterRepository.save(userId); - - emitter.onCompletion(() -> { - log.info("SSE 연결 종료 : {}", userId); - emitterRepository.remove(userId); - }); - - emitter.onTimeout(() -> { - log.info("SSE 연결 타임 아웃: {}", userId); - emitterRepository.remove(userId); - }); - - emitter.onError((e) -> { - log.info("SSE 연결 에러:{}", userId); - emitterRepository.remove(userId); - }); - - sendToUserBySse(userId, userId + "_" + System.currentTimeMillis(), SseEventName.CONNECTED.getDescription(), SseEventName.CONNECTED); - - //안보내진 알림 보내기 - if (lastEventId != null && !lastEventId.isEmpty()) { - sendLostMessage(userId, lastEventId); - } - - return emitter; - - } - /** - * 알림 전송 (외부 호출용) - * return: true(전송 성공), false(전송 실패 -> Push 알림 필요) - */ - public boolean send(Long userId, SseEventName eventName, Object data) { - String eventId = userId + "_" + System.currentTimeMillis(); - saveEventToRedis(userId, eventId, eventName, data); - - return sendToUserBySse(userId, eventId, eventName.getDescription(), data); - } - - /** - * Redis 저장 로직 - */ - private void saveEventToRedis(Long userId, String eventId, SseEventName eventName, Object data) { - try { - String dataJson = objectMapper.writeValueAsString(data); - SseEventCache eventCache = SseEventCache.builder() - .eventName(eventName.getDescription()) - .userId(userId) - .id(eventId) - .data(dataJson) - .build(); - - cacheRepository.save(eventCache); - } catch (JsonProcessingException e) { - log.error("SSE 데이터 Json 변환 실패 "); - } - } - - /** - * 유실 데이터 재전송 로직 - */ - private void sendLostMessage(Long userId, String lastEventId) { - List list = cacheRepository.findAllByUserId(userId); - list.stream() - .filter(event -> event.getId().compareTo(lastEventId) > 0) - .forEach(sseEventCache -> sendToUserBySse(userId, sseEventCache.getId(), sseEventCache.getEventName(), sseEventCache.getData())); - } - - /** - * 실제 Emitter 전송 로직 - */ - private boolean sendToUserBySse(Long userId, String eventId, String eventName, Object data) { - SseEmitter emitter = emitterRepository.getById(userId); - if (emitter != null) { - try { - emitter.send(SseEmitter.event() - .id(eventId) - .name(eventName) - .data(data) - ); - return true; - } catch (IOException e) { - emitterRepository.remove(userId); - log.error("SSE 전송 에러 :{} userId :{}", e, userId); - return false; - } - } - return false; - } - -} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/pushService/service/pushSender/FcmPushSender.java b/manabom/src/main/java/mannabom_server/manabom/application/pushService/service/pushSender/FcmPushSender.java index c7c4756..150d539 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/pushService/service/pushSender/FcmPushSender.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/pushService/service/pushSender/FcmPushSender.java @@ -25,12 +25,14 @@ public void sendToToken(String token, PushMessage msg) { log.info("firebase를 통해 전송 시작"); FirebaseMessaging messaging = FirebaseMessaging.getInstance(firebaseApp); + Notification.Builder notification = Notification.builder() + .setTitle(msg.title()); + if (msg.body() != null && !msg.body().isBlank()) { + notification.setBody(msg.body()); + } Message.Builder b = Message.builder() .setToken(token) - .setNotification(Notification.builder() - .setTitle(msg.title()) - .setBody(msg.body()) - .build()); + .setNotification(notification.build()); if (msg.data() != null) b.putAllData(msg.data()); @@ -50,12 +52,14 @@ public PushBatchResult sendToTokens(List tokens, PushMessage msg) { FirebaseMessaging messaging = FirebaseMessaging.getInstance(firebaseApp); + Notification.Builder notification = Notification.builder() + .setTitle(msg.title()); + if (msg.body() != null && !msg.body().isBlank()) { + notification.setBody(msg.body()); + } MulticastMessage.Builder b = MulticastMessage.builder() .addAllTokens(tokens) - .setNotification(Notification.builder() - .setTitle(msg.title()) - .setBody(msg.body()) - .build()); + .setNotification(notification.build()); if (msg.data() != null) b.putAllData(msg.data()); diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/chat/entity/ChatMessage.java b/manabom/src/main/java/mannabom_server/manabom/domain/chat/entity/ChatMessage.java index 1a689c5..7ef654d 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/chat/entity/ChatMessage.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/chat/entity/ChatMessage.java @@ -9,6 +9,11 @@ import mannabom_server.manabom.domain.chat.enums.ChatMessageTypeConverter; import mannabom_server.manabom.domain.common.BaseTimeEntity; import mannabom_server.manabom.domain.user.entity.User; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.HashMap; +import java.util.Map; /** * 채팅 메시지 엔터티 @@ -37,5 +42,50 @@ public class ChatMessage extends BaseTimeEntity { private String content; + @Column(name = "system_event_type", length = 64) + private String systemEventType; + + @Column(name = "system_title") + private String systemTitle; + + @Column(name = "actor_user_id") + private Long actorUserId; + + @Column(name = "actor_nickname") + private String actorNickname; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "system_data", columnDefinition = "jsonb") + private Map systemData; + + public static ChatMessage system(ChatRoom room, String content) { + return ChatMessage.builder() + .room(room) + .user(null) + .type(ChatMessageType.SYSTEM) + .content(content) + .build(); + } + public static ChatMessage system( + ChatRoom room, + String content, + String systemEventType, + String systemTitle, + Long actorUserId, + String actorNickname, + Map systemData + ) { + return ChatMessage.builder() + .room(room) + .user(null) + .type(ChatMessageType.SYSTEM) + .content(content) + .systemEventType(systemEventType) + .systemTitle(systemTitle) + .actorUserId(actorUserId) + .actorNickname(actorNickname) + .systemData(systemData == null ? Map.of() : new HashMap<>(systemData)) + .build(); + } } 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/ChatMessageRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMessageRepository.java index 9bcfd17..a0be7df 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMessageRepository.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMessageRepository.java @@ -13,10 +13,10 @@ public interface ChatMessageRepository extends JpaRepository { Optional findTopByRoomIdOrderByIdDesc(Long roomId); - @Query("SELECT m FROM ChatMessage m JOIN FETCH m.user u WHERE m.room.id = :roomId AND m.id > :lastReadId ORDER BY m.id ASC") + @Query("SELECT m FROM ChatMessage m LEFT JOIN FETCH m.user u WHERE m.room.id = :roomId AND m.id > :lastReadId ORDER BY m.id ASC") List findChatMessagesAfter(@Param("roomId") Long roomId, @Param("lastReadId") Long lastReadId, Pageable pageable); - @Query("SELECT m FROM ChatMessage m JOIN FETCH m.user u JOIN Profile p on p.user.userId = u.userId WHERE m.room.id = :roomId AND m.id < :firstMessageId ORDER BY m.id desc ") + @Query("SELECT m FROM ChatMessage m LEFT JOIN FETCH m.user u WHERE m.room.id = :roomId AND m.id < :firstMessageId ORDER BY m.id desc ") List findChatMessagesBefore(@Param("roomId") Long roomId, @Param("firstMessageId") Long firstMessageId, Pageable pageable); @@ -27,7 +27,7 @@ public interface ChatMessageRepository extends JpaRepository { boolean existsByRoomIdAndIdGreaterThan(Long roomId, Long messageId); - int countChatMessagesByRoom_Id(Long roomId); + int countChatMessagesByRoom_IdAndUserIsNotNull(Long roomId); - int countChatMessagesByRoom_IdAndCreatedAtAfter(Long roomId, Instant after); + int countChatMessagesByRoom_IdAndCreatedAtAfterAndUserIsNotNull(Long roomId, Instant after); } 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..867c9b4 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,10 @@ public interface ChatRoomRepository extends JpaRepository { Optional findByMeeting(Meeting meeting); + Optional findByMeeting_Id(Long meetingId); + + 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/entity/MeetingMatch.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingMatch.java index e3b83c3..c028c7d 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingMatch.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingMatch.java @@ -115,7 +115,7 @@ public void processExpiration(){ this.meeting1Decision = determineAutoDecision(meeting1); } - if(meeting1Decision.equals(MeetingDecision.WAITING)){ + if(meeting2Decision.equals(MeetingDecision.WAITING)){ this.meeting2Decision = determineAutoDecision(meeting2); } updateStatus(); diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java index 54ed186..877a9c3 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java @@ -6,6 +6,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.user.entity.User; import java.time.Duration; import java.time.Instant; @@ -31,18 +32,45 @@ public class MeetingVerification { private Instant verifiedAt; + private Instant failureNotifiedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "started_by_user_id") + private User startedBy; + + @Column(name = "participant_count", nullable = false) + private int participantCount; + + @Column(name = "verified_participant_count", nullable = false) + private int verifiedParticipantCount; + + private Double finalLatitude; + + private Double finalLongitude; + + @Column(name = "verified_has_male", nullable = false) + private boolean verifiedHasMale; + + @Column(name = "verified_has_female", nullable = false) + private boolean verifiedHasFemale; + @Builder public MeetingVerification(ChatRoom room) { this.room = room; } - public void startIfNeeded(Instant now, Duration validDuration){ + public void startIfNeeded(Instant now, Duration validDuration, User initiator){ if(this.startedAt == null){ this.startedAt = now; this.expiresAt = now.plus(validDuration); + this.startedBy = initiator; } } + public void updateParticipantCount(int participantCount) { + this.participantCount = Math.max(this.participantCount, participantCount); + } + public boolean isExpired(Instant now){ return this.expiresAt != null && !now.isBefore(this.expiresAt); } @@ -55,9 +83,45 @@ public Duration remainingTime(Instant now){ return remaining.isNegative() ? Duration.ZERO : remaining; } - public void verify(){ + public void verify( + int verifiedParticipantCount, + double finalLatitude, + double finalLongitude, + boolean hasMale, + boolean hasFemale + ){ this.isVerified = true; this.verifiedAt = Instant.now(); + this.verifiedParticipantCount = verifiedParticipantCount; + this.finalLatitude = finalLatitude; + this.finalLongitude = finalLongitude; + this.verifiedHasMale = hasMale; + this.verifiedHasFemale = hasFemale; + } + + public void recordVerifiedLatecomer(boolean firstSubmission) { + if (firstSubmission) { + this.participantCount++; + } + this.verifiedParticipantCount++; + } + + public boolean hasFinalLocation() { + return finalLatitude != null && finalLongitude != null; + } + + public boolean needsFailureNotification(Instant now) { + return !isVerified + && failureNotifiedAt == null + && expiresAt != null + && !now.isBefore(expiresAt); + } + + public void markFailureNotified(Instant now) { + if (!needsFailureNotification(now)) { + throw new IllegalStateException("만남 인증 실패 알림 대상이 아닙니다."); + } + this.failureNotifiedAt = now; } } 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/enums/SseEventName.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/SseEventName.java deleted file mode 100644 index 202e2b5..0000000 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/SseEventName.java +++ /dev/null @@ -1,22 +0,0 @@ -package mannabom_server.manabom.domain.meeting.enums; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum SseEventName { - CONNECTED("CONNECTED"), - MATCHING_STATUS("MATCHING_STATUS"), - MATCH_FOUND("MATCH_FOUND"), - MATCHING_FAILED("MATCHING_FAILED"), - DECISION_RESULT("DECISION_RESULT"), - MATCHING_COMPLETED("MATCHING_COMPLETED"), - NEW_CHAT_MESSAGE("NEW_CHAT_MESSAGE"), - PHOTO_REQUEST_RECEIVED("PHOTO_REQUEST_RECEIVED"), - PHOTO_REQUEST_ACCEPTED("PHOTO_REQUEST_ACCEPTED"), - PHOTO_REQUEST_REJECTED("PHOTO_REQUEST_REJECTED"); - - - private final String description; -} 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/domain/meeting/repository/MeetingVerificationRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingVerificationRepository.java index b4c95e8..6d7d52c 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingVerificationRepository.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingVerificationRepository.java @@ -2,11 +2,31 @@ import mannabom_server.manabom.domain.meeting.entity.MeetingVerification; 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 org.springframework.stereotype.Repository; +import jakarta.persistence.LockModeType; +import java.time.Instant; +import java.util.List; import java.util.Optional; @Repository public interface MeetingVerificationRepository extends JpaRepository { Optional findByRoomId(Long chatRoomId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select v from MeetingVerification v join fetch v.room where v.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Query(""" + select v.id + from MeetingVerification v + where v.isVerified = false + and v.failureNotifiedAt is null + and v.expiresAt is not null + and v.expiresAt <= :now + """) + List findFailureNotificationTargetIds(@Param("now") Instant now); } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/Notification.java b/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/Notification.java index b9104bf..7c85fa9 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/Notification.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/Notification.java @@ -4,8 +4,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import mannabom_server.manabom.domain.meeting.enums.SseEventName; -import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.domain.notification.enums.NotificationType; import org.hibernate.annotations.CreationTimestamp; import java.time.Instant; @@ -28,7 +27,7 @@ public class Notification { @Enumerated(EnumType.STRING) @Column(name = "type", length = 50) - private SseEventName type; + private NotificationType type; @Column(columnDefinition = "TEXT") // 혹은 @Lob. 내용이 길어질 수 있으므로 TEXT 권장 private String data; diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/SseEventCache.java b/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/SseEventCache.java deleted file mode 100644 index d3bf719..0000000 --- a/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/SseEventCache.java +++ /dev/null @@ -1,21 +0,0 @@ -package mannabom_server.manabom.domain.notification.entity; - -import jakarta.persistence.Id; -import lombok.Builder; -import lombok.Getter; -import org.springframework.data.redis.core.RedisHash; -import org.springframework.data.redis.core.index.Indexed; - -@Builder -@Getter -@RedisHash(value = "sse_event",timeToLive = 60*60) -public class SseEventCache { - @Id - private String id; - - @Indexed - private Long userId; - private String eventName; - - private String data; -} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/notification/enums/NotificationType.java b/manabom/src/main/java/mannabom_server/manabom/domain/notification/enums/NotificationType.java new file mode 100644 index 0000000..a144f69 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/notification/enums/NotificationType.java @@ -0,0 +1,15 @@ +package mannabom_server.manabom.domain.notification.enums; + +public enum NotificationType { + CONNECTED, + MATCHING_STATUS, + MATCH_FOUND, + MATCHING_FAILED, + DECISION_RESULT, + MATCHING_COMPLETED, + NEW_CHAT_MESSAGE, + PHOTO_REQUEST_RECEIVED, + PHOTO_REQUEST_ACCEPTED, + PHOTO_REQUEST_REJECTED, + SYSTEM_MESSAGE +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/notification/repository/EmitterRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/notification/repository/EmitterRepository.java deleted file mode 100644 index 3939d44..0000000 --- a/manabom/src/main/java/mannabom_server/manabom/domain/notification/repository/EmitterRepository.java +++ /dev/null @@ -1,28 +0,0 @@ -package mannabom_server.manabom.domain.notification.repository; - -import org.springframework.stereotype.Repository; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Repository -public class EmitterRepository { - private final Map emitters = new ConcurrentHashMap<>(); - private static final long TIMEOUT = 30*60*1000L; // 타임아웃 30분 - - - public SseEmitter save(Long userId){ - SseEmitter emitter = new SseEmitter(TIMEOUT); - emitters.put(userId, emitter); - return emitter; - } - - public void remove(Long userId){ - emitters.remove(userId); - } - - public SseEmitter getById(Long userId){ - return emitters.get(userId); - } -} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/notification/repository/SseEventCacheRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/notification/repository/SseEventCacheRepository.java deleted file mode 100644 index 3ca79cf..0000000 --- a/manabom/src/main/java/mannabom_server/manabom/domain/notification/repository/SseEventCacheRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package mannabom_server.manabom.domain.notification.repository; - -import mannabom_server.manabom.domain.notification.entity.SseEventCache; -import org.springframework.data.repository.CrudRepository; - -import java.util.List; - -public interface SseEventCacheRepository extends CrudRepository { - List findAllByUserId(Long userId); -} 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..81a27f1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/global/error/MeetingCancellationExpiredException.java @@ -0,0 +1,8 @@ +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/config/SecurityConfig.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/config/SecurityConfig.java index f32c669..1f775d9 100644 --- a/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/config/SecurityConfig.java +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/config/SecurityConfig.java @@ -48,6 +48,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/signup/**", "/api/questions/**", "/api/signup/terms/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", "/ws-chat/**", "/error" ).permitAll() 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/java/mannabom_server/manabom/presentation/meeting/controller/MeetingMatchingController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingMatchingController.java index 0b3961f..e49e1df 100644 --- a/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingMatchingController.java +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingMatchingController.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import mannabom_server.manabom.application.common.dto.ApiResponse; -import mannabom_server.manabom.application.notification.service.SseService; import mannabom_server.manabom.application.meeting.dto.response.*; import mannabom_server.manabom.application.meeting.service.MeetingMatchingService; import mannabom_server.manabom.application.meeting.service.MeetingService; @@ -14,7 +13,6 @@ @RestController @RequestMapping("/api/meeting/matching") public class MeetingMatchingController { - private final SseService sseService; private final MeetingService meetingService; private final MeetingMatchingService meetingMatchingService; diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/notification/controller/NotificationController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/notification/controller/NotificationController.java index 467fb0c..d40b70b 100644 --- a/manabom/src/main/java/mannabom_server/manabom/presentation/notification/controller/NotificationController.java +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/notification/controller/NotificationController.java @@ -2,42 +2,30 @@ import lombok.RequiredArgsConstructor; import mannabom_server.manabom.application.notification.service.NotificationService; -import mannabom_server.manabom.application.notification.service.SseService; -import mannabom_server.manabom.domain.meeting.enums.SseEventName; -import org.springframework.http.MediaType; -import org.springframework.security.core.annotation.AuthenticationPrincipal; - +import mannabom_server.manabom.domain.notification.enums.NotificationType; import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequiredArgsConstructor @RequestMapping("/api/notification") public class NotificationController { - private final SseService sseService; private final NotificationService notificationService; - @GetMapping(value = "/sse/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter connectStream(@RequestHeader(required = false, defaultValue = "", value = "Last-Event-ID") String lastEventId, @AuthenticationPrincipal Long userId){ - return sseService.createEmitterConnection(userId, lastEventId); - } - /** * 배포 전 삭제 * **/ @PostMapping("/test/send") public String sendTestNotification(@RequestBody TestNotificationRequest request) { - // 아까 만든 통합 알림 메서드 호출 (DB저장 + SSE전송 + 실패시 Push) notificationService.sendNotification( request.targetUserId(), - SseEventName.MATCH_FOUND, // 테스트용 이벤트 타입 + NotificationType.MATCH_FOUND, request.title(), request.body(), null // 데이터는 일단 null (필요하면 객체 넣어도 됨) ); - return "전송 완료! (SSE 연결된 탭을 확인하세요)"; + return "Push 전송 완료"; } // 테스트용 DTO (내부 클래스나 별도 파일로 생성) 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/main/resources/db/migration/V25__add_meeting_verification_failure_notification.sql b/manabom/src/main/resources/db/migration/V25__add_meeting_verification_failure_notification.sql new file mode 100644 index 0000000..e86d4f9 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V25__add_meeting_verification_failure_notification.sql @@ -0,0 +1,6 @@ +ALTER TABLE meeting_verification + ADD COLUMN IF NOT EXISTS failure_notified_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_meeting_verification_failure_notification + ON meeting_verification (expires_at) + WHERE is_verified = FALSE AND failure_notified_at IS NULL; diff --git a/manabom/src/main/resources/db/migration/V26__add_structured_system_message_fields.sql b/manabom/src/main/resources/db/migration/V26__add_structured_system_message_fields.sql new file mode 100644 index 0000000..d352df7 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V26__add_structured_system_message_fields.sql @@ -0,0 +1,22 @@ +ALTER TABLE chat_messages + ADD COLUMN system_event_type VARCHAR(64), + ADD COLUMN system_title VARCHAR(255), + ADD COLUMN actor_user_id BIGINT, + ADD COLUMN actor_nickname VARCHAR(255), + ADD COLUMN system_data JSONB; + +ALTER TABLE chat_messages + ADD CONSTRAINT fk_chat_message_actor + FOREIGN KEY (actor_user_id) REFERENCES users(user_id); + +CREATE INDEX idx_chat_messages_system_event_type + ON chat_messages(system_event_type) + WHERE system_event_type IS NOT NULL; + +ALTER TABLE meeting_verification + ADD COLUMN started_by_user_id BIGINT, + ADD COLUMN participant_count INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE meeting_verification + ADD CONSTRAINT fk_meeting_verification_started_by + FOREIGN KEY (started_by_user_id) REFERENCES users(user_id); diff --git a/manabom/src/main/resources/db/migration/V27__persist_meeting_verification_result.sql b/manabom/src/main/resources/db/migration/V27__persist_meeting_verification_result.sql new file mode 100644 index 0000000..3a38d0b --- /dev/null +++ b/manabom/src/main/resources/db/migration/V27__persist_meeting_verification_result.sql @@ -0,0 +1,6 @@ +ALTER TABLE meeting_verification + ADD COLUMN verified_participant_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN final_latitude DOUBLE PRECISION, + ADD COLUMN final_longitude DOUBLE PRECISION, + ADD COLUMN verified_has_male BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN verified_has_female BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/manabom/src/test/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponseTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponseTest.java new file mode 100644 index 0000000..7754030 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponseTest.java @@ -0,0 +1,34 @@ +package mannabom_server.manabom.application.chat.dto.response; + +import mannabom_server.manabom.domain.chat.entity.ChatMessage; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChatMessageResponseTest { + + @Test + void convertsSystemMessageWithoutSender() { + ChatMessage message = ChatMessage.system( + ChatRoom.builder().id(77L).build(), + "시스템 알림 본문", + "PHOTO_REQUESTED", + "봄이님이 프로필 공개를 요청했어요", + 1L, + "봄이", + Map.of("status", "RECEIVED") + ); + + ChatMessageResponse response = ChatMessageResponse.of(message); + + assertThat(response.getMessageType()).isEqualTo("SYSTEM"); + assertThat(response.getSenderId()).isNull(); + assertThat(response.getContent()).isEqualTo("시스템 알림 본문"); + assertThat(response.getSystemTitle()).isEqualTo("봄이님이 프로필 공개를 요청했어요"); + assertThat(response.getActorNickname()).isEqualTo("봄이"); + assertThat(response.getData()).containsEntry("status", "RECEIVED"); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandlerTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandlerTest.java new file mode 100644 index 0000000..15bf047 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandlerTest.java @@ -0,0 +1,80 @@ +package mannabom_server.manabom.application.chat.handler; + +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.dto.response.ChatMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; +import mannabom_server.manabom.application.chat.service.SystemMessageService; +import mannabom_server.manabom.application.notification.dto.MatchFailureEvent; +import mannabom_server.manabom.application.notification.dto.MatchFoundEvent; +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.Instant; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ChatSystemMessageEventHandlerTest { + + @Mock private SystemMessageService systemMessageService; + @InjectMocks private ChatSystemMessageEventHandler handler; + + @Test + void connectsMatchFoundEventToBothTeamMessages() { + Instant deadline = Instant.now(); + MatchFoundEvent source = new MatchFoundEvent(20L, deadline); + List messages = List.of(recorded(100L)); + when(systemMessageService.recordMatchFound(20L, deadline)).thenReturn(messages); + + handler.handleMatchFound(source); + + verify(systemMessageService).dispatch(messages); + } + + @Test + void connectsTimeoutFailureEventToTeamMessages() { + MatchFailureEvent source = new MatchFailureEvent(20L, null, true); + List messages = List.of(recorded(100L)); + when(systemMessageService.recordMatchFailure(20L, null, true)).thenReturn(messages); + + handler.handleMatchFailure(source); + + verify(systemMessageService).dispatch(messages); + } + + @Test + void connectsRoomEventToSingleTypedMessage() { + ChatSystemMessageEvent source = ChatSystemMessageEvent.of( + 77L, + SystemMessageType.PHOTO_REQUESTED, + 1L, + List.of(2L), + Map.of("status", "RECEIVED") + ); + SystemMessageService.RecordedSystemMessage message = recorded(77L); + when(systemMessageService.recordForRoom(source)).thenReturn(message); + + handler.handleRoomSystemMessage(source); + + verify(systemMessageService).dispatch(message); + } + + private SystemMessageService.RecordedSystemMessage recorded(Long roomId) { + ChatMessageEvent message = ChatMessageEvent.builder() + .roomId(roomId) + .recipientUserIds(List.of()) + .messageType("SYSTEM") + .build(); + return new SystemMessageService.RecordedSystemMessage( + message, + SystemMessageType.MATCH_FOUND, + true + ); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/chat/message/SystemMessageTypeTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/message/SystemMessageTypeTest.java new file mode 100644 index 0000000..43ccff7 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/message/SystemMessageTypeTest.java @@ -0,0 +1,44 @@ +package mannabom_server.manabom.application.chat.message; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SystemMessageTypeTest { + + @Test + void rendersLeaderNicknameInMatchingMessage() { + SystemMessageType.RenderedSystemMessage message = + SystemMessageType.MATCHING_STARTED.render("봄이", Map.of()); + + assertThat(message.title()).isEqualTo("매칭을 시작했어요"); + assertThat(message.body()).isEqualTo("팀장 봄이님이 매칭을 시작하셨어요."); + } + + @Test + void rendersVerificationCompleterAndParticipantCount() { + SystemMessageType.RenderedSystemMessage message = + SystemMessageType.MEETING_VERIFICATION_SUCCEEDED.render( + "민수", + Map.of( + "submittedCount", 4, + "verifiedParticipantCount", 3 + ) + ); + + assertThat(message.title()).isEqualTo("만남인증 성공"); + assertThat(message.body()) + .isEqualTo("민수님의 참여로 만남인증이 완료되었어요.\n참여인원 : 3명"); + } + + @Test + void rendersProfileRequesterNickname() { + SystemMessageType.RenderedSystemMessage message = + SystemMessageType.PHOTO_REQUESTED.render("지수", Map.of()); + + assertThat(message.title()).isEqualTo("지수님이 프로필 공개를 요청했어요"); + assertThat(message.body()).contains("수락할 시 서로의 프로필이 공개됩니다"); + } +} 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/chat/service/ChatServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatServiceTest.java new file mode 100644 index 0000000..3074eeb --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatServiceTest.java @@ -0,0 +1,114 @@ +package mannabom_server.manabom.application.chat.service; + +import mannabom_server.manabom.application.chat.dto.request.ChatSendRequest; +import mannabom_server.manabom.application.chat.dto.response.ChatMessageResponse; +import mannabom_server.manabom.application.chat.dto.response.ChatSyncResponse; +import mannabom_server.manabom.application.common.port.FileStoragePort; +import mannabom_server.manabom.application.notification.service.NotificationService; +import mannabom_server.manabom.domain.chat.entity.ChatMember; +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.ChatMessageType; +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.domain.user.repository.ProfileRepository; +import mannabom_server.manabom.domain.user.repository.UserRepository; +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.data.redis.core.StringRedisTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class ChatServiceTest { + + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private UserRepository userRepository; + @Mock private ChatRoomRepository chatRoomRepository; + @Mock private ChatMemberRepository chatMemberRepository; + @Mock private ProfileRepository profileRepository; + @Mock private SimpMessagingTemplate messagingTemplate; + @Mock private StringRedisTemplate redisTemplate; + @Mock private ChatMemberService chatMemberService; + @Mock private FileStoragePort fileStoragePort; + @Mock private NotificationService notificationService; + + @InjectMocks + private ChatService chatService; + + @Test + void rejectsClientAuthoredSystemMessage() { + ChatSendRequest request = new ChatSendRequest( + 77L, + ChatMessageType.SYSTEM, + "가짜 시스템 메시지", + "client-message-id" + ); + + assertThatThrownBy(() -> chatService.sendMessage(request, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("클라이언트가 전송할 수 없습니다"); + + verifyNoInteractions(chatRoomRepository, chatMessageRepository); + } + + @Test + void syncReturnsStructuredSystemMessage() { + ChatRoom room = ChatRoom.builder().id(77L).build(); + ChatMember member = ChatMember.builder() + .room(room) + .status(ChatMemberStatus.ACTIVATE) + .build(); + ChatMessage systemMessage = ChatMessage.system( + room, + "수락할 시 서로의 프로필이 공개됩니다.", + "PHOTO_REQUESTED", + "봄이님이 프로필 공개를 요청했어요", + 1L, + "봄이", + Map.of( + "actorStatus", "PENDING", + "recipientStatus", "RECEIVED" + ) + ); + + when(chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus( + 77L, + 2L, + ChatMemberStatus.ACTIVATE + )).thenReturn(Optional.of(member)); + when(chatMemberRepository.findAllActiveMembersWithMainImage(77L, ChatMemberStatus.ACTIVATE)) + .thenReturn(List.of()); + when(chatMessageRepository.findChatMessagesAfter(eq(77L), eq(0L), any())) + .thenReturn(List.of(systemMessage)); + + ChatSyncResponse response = chatService.getLatestChatMessageListSync(77L, 2L, 0L); + + assertThat(response.getMessages()).hasSize(1); + ChatMessageResponse message = response.getMessages().get(0); + assertThat(message.getMessageType()).isEqualTo("SYSTEM"); + assertThat(message.getSystemEventType()).isEqualTo("PHOTO_REQUESTED"); + assertThat(message.getSystemTitle()).isEqualTo("봄이님이 프로필 공개를 요청했어요"); + assertThat(message.getContent()).isEqualTo("수락할 시 서로의 프로필이 공개됩니다."); + assertThat(message.getActorUserId()).isEqualTo(1L); + assertThat(message.getActorNickname()).isEqualTo("봄이"); + assertThat(message.getData()) + .containsEntry("actorStatus", "PENDING") + .containsEntry("recipientStatus", "RECEIVED"); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/chat/service/SystemMessageServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/SystemMessageServiceTest.java new file mode 100644 index 0000000..4797806 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/SystemMessageServiceTest.java @@ -0,0 +1,193 @@ +package mannabom_server.manabom.application.chat.service; + +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.dto.response.ChatMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; +import mannabom_server.manabom.application.notification.service.NotificationService; +import mannabom_server.manabom.domain.chat.entity.ChatMessage; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.chat.enums.ChatMessageType; +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.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.MeetingDecision; +import mannabom_server.manabom.domain.meeting.repository.MeetingMatchRepository; +import mannabom_server.manabom.domain.user.entity.Profile; +import mannabom_server.manabom.domain.user.repository.ProfileRepository; +import org.junit.jupiter.api.BeforeEach; +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.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SystemMessageServiceTest { + + @Mock private ChatRoomRepository chatRoomRepository; + @Mock private ChatMemberRepository chatMemberRepository; + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private MeetingMatchRepository meetingMatchRepository; + @Mock private ProfileRepository profileRepository; + @Mock private SimpMessagingTemplate messagingTemplate; + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + @Mock private NotificationService notificationService; + + @InjectMocks + private SystemMessageService systemMessageService; + + @BeforeEach + void persistPassedMessage() { + when(chatMessageRepository.saveAndFlush(any(ChatMessage.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void recordsSingleTypedEventAndPushesToRecipientOutsideRoom() { + ChatRoom room = ChatRoom.builder().id(77L).build(); + when(chatRoomRepository.findById(77L)).thenReturn(Optional.of(room)); + when(profileRepository.findByUser_UserId(1L)) + .thenReturn(Optional.of(Profile.builder().nickName("봄이").build())); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("user:location:2")).thenReturn("99"); + + ChatSystemMessageEvent source = ChatSystemMessageEvent.of( + 77L, + SystemMessageType.PHOTO_REQUESTED, + 1L, + List.of(2L), + Map.of("status", "RECEIVED") + ); + SystemMessageService.RecordedSystemMessage recorded = + systemMessageService.recordForRoom(source); + systemMessageService.dispatch(recorded); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); + verify(chatMessageRepository).saveAndFlush(messageCaptor.capture()); + assertThat(messageCaptor.getValue().getType()).isEqualTo(ChatMessageType.SYSTEM); + assertThat(messageCaptor.getValue().getUser()).isNull(); + assertThat(recorded.message().getActorUserId()).isEqualTo(1L); + assertThat(recorded.message().getRecipientUserIds()).containsExactly(2L); + assertThat(recorded.message().getSystemEventType()).isEqualTo("PHOTO_REQUESTED"); + assertThat(recorded.message().getSystemTitle()).isEqualTo("봄이님이 프로필 공개를 요청했어요"); + assertThat(recorded.message().getActorNickname()).isEqualTo("봄이"); + assertThat(recorded.message().getContent()).contains("서로의 프로필이 공개됩니다"); + verify(messagingTemplate).convertAndSend("/topic/rooms/77", recorded.message()); + verify(notificationService).sendNotification( + org.mockito.ArgumentMatchers.eq(2L), + any(), + any(), + any(), + any() + ); + } + + @Test + void recordsMatchFoundMessageForBothTeamRooms() { + Meeting meeting1 = Meeting.builder().id(10L).build(); + Meeting meeting2 = Meeting.builder().id(11L).build(); + MeetingMatch match = org.mockito.Mockito.mock(MeetingMatch.class); + when(match.getMeeting1()).thenReturn(meeting1); + when(match.getMeeting2()).thenReturn(meeting2); + when(meetingMatchRepository.findByIdWithMeeting(20L)).thenReturn(Optional.of(match)); + when(chatRoomRepository.findByMeeting_Id(10L)) + .thenReturn(Optional.of(ChatRoom.builder().id(100L).meeting(meeting1).build())); + when(chatRoomRepository.findByMeeting_Id(11L)) + .thenReturn(Optional.of(ChatRoom.builder().id(101L).meeting(meeting2).build())); + + List events = + systemMessageService.recordMatchFound(20L, Instant.now()); + + assertThat(events).extracting(event -> event.message().getRoomId()) + .containsExactly(100L, 101L); + assertThat(events).allSatisfy(event -> { + assertThat(event.message().getSystemTitle()).isEqualTo("미팅할 상대 팀을 찾았어요‼️✨"); + assertThat(event.message().getContent()).contains("수락 가능 시간 안에"); + }); + } + + @Test + void recordsDifferentMessagesForRejectingAndOpponentTeams() { + Meeting meeting1 = Meeting.builder().id(10L).build(); + Meeting meeting2 = Meeting.builder().id(11L).build(); + MeetingMatch match = org.mockito.Mockito.mock(MeetingMatch.class); + when(match.getMeeting1()).thenReturn(meeting1); + when(match.getMeeting2()).thenReturn(meeting2); + when(match.getMeeting1Decision()).thenReturn(MeetingDecision.REJECTED); + when(meetingMatchRepository.findByIdWithMeeting(20L)).thenReturn(Optional.of(match)); + when(chatRoomRepository.findByMeeting_Id(10L)) + .thenReturn(Optional.of(ChatRoom.builder().id(100L).meeting(meeting1).build())); + when(chatRoomRepository.findByMeeting_Id(11L)) + .thenReturn(Optional.of(ChatRoom.builder().id(101L).meeting(meeting2).build())); + + List events = + systemMessageService.recordMatchFailure(20L, 1L, false); + + assertThat(events.get(0).message().getContent()).contains("팀장 사용자님이 매칭을 거절했어요"); + assertThat(events.get(1).message().getContent()).contains("상대 팀과 연결되지 않았어요"); + } + + @Test + void recordsTimeoutAndOpponentTimeoutMessagesWhenOnlyOneTeamTimedOut() { + Meeting meeting1 = Meeting.builder().id(10L).build(); + Meeting meeting2 = Meeting.builder().id(11L).build(); + MeetingMatch match = org.mockito.Mockito.mock(MeetingMatch.class); + when(match.getMeeting1()).thenReturn(meeting1); + when(match.getMeeting2()).thenReturn(meeting2); + when(match.getMeeting2Decision()).thenReturn(MeetingDecision.AUTO_REJECTED); + when(meetingMatchRepository.findByIdWithMeeting(20L)).thenReturn(Optional.of(match)); + when(chatRoomRepository.findByMeeting_Id(10L)) + .thenReturn(Optional.of(ChatRoom.builder().id(100L).meeting(meeting1).build())); + when(chatRoomRepository.findByMeeting_Id(11L)) + .thenReturn(Optional.of(ChatRoom.builder().id(101L).meeting(meeting2).build())); + + List events = + systemMessageService.recordMatchFailure(20L, null, true); + + assertThat(events.get(0).message().getRoomId()).isEqualTo(101L); + assertThat(events.get(0).message().getContent()).contains("자동으로 거절됐어요"); + assertThat(events.get(1).message().getRoomId()).isEqualTo(100L); + assertThat(events.get(1).message().getContent()).contains("상대 팀의 응답 시간이 지나"); + } + + @Test + void recordsOwnTimeoutMessageForBothTeamsWhenBothTimedOut() { + Meeting meeting1 = Meeting.builder().id(10L).build(); + Meeting meeting2 = Meeting.builder().id(11L).build(); + MeetingMatch match = org.mockito.Mockito.mock(MeetingMatch.class); + when(match.getMeeting1()).thenReturn(meeting1); + when(match.getMeeting2()).thenReturn(meeting2); + when(match.getMeeting1Decision()).thenReturn(MeetingDecision.AUTO_REJECTED); + when(match.getMeeting2Decision()).thenReturn(MeetingDecision.AUTO_REJECTED); + when(meetingMatchRepository.findByIdWithMeeting(20L)).thenReturn(Optional.of(match)); + when(chatRoomRepository.findByMeeting_Id(10L)) + .thenReturn(Optional.of(ChatRoom.builder().id(100L).meeting(meeting1).build())); + when(chatRoomRepository.findByMeeting_Id(11L)) + .thenReturn(Optional.of(ChatRoom.builder().id(101L).meeting(meeting2).build())); + + List events = + systemMessageService.recordMatchFailure(20L, null, true); + + assertThat(events).extracting(event -> event.message().getSystemEventType()) + .containsExactly("MATCH_TIMED_OUT", "MATCH_TIMED_OUT"); + assertThat(events).allSatisfy(event -> + assertThat(event.message().getContent()).contains("자동으로 거절됐어요")); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/matching/service/PhotoRequestServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/matching/service/PhotoRequestServiceTest.java new file mode 100644 index 0000000..dd54d49 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/matching/service/PhotoRequestServiceTest.java @@ -0,0 +1,149 @@ +package mannabom_server.manabom.application.matching.service; + +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; +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.enums.ChatRoomType; +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.domain.matching.entity.LoveViewPhotoRequest; +import mannabom_server.manabom.domain.matching.entity.LoveViewRecommendHistory; +import mannabom_server.manabom.domain.matching.enums.PhotoRequestStatus; +import mannabom_server.manabom.domain.matching.repository.LoveViewPhotoRequestRepository; +import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +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.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PhotoRequestServiceTest { + + @Mock private ChatRoomRepository chatRoomRepository; + @Mock private LoveViewPhotoRequestRepository photoRequestRepository; + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private UserRepository userRepository; + @Mock private ChatMemberRepository chatMemberRepository; + @Mock private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private PhotoRequestService photoRequestService; + + private final User user1 = user(1L); + private final User user2 = user(2L); + private ChatRoom room; + private LoveViewRecommendHistory history; + + @BeforeEach + void setUpRoom() { + history = org.mockito.Mockito.mock(LoveViewRecommendHistory.class); + when(history.getId()).thenReturn(88L); + room = ChatRoom.builder() + .id(77L) + .type(ChatRoomType.LOVEVIEW_MATCH) + .loveView(history) + .build(); + when(chatRoomRepository.findById(77L)).thenReturn(Optional.of(room)); + } + + @Test + void publishesSystemMessageWhenPhotoIsRequested() { + when(userRepository.findById(1L)).thenReturn(Optional.of(user1)); + allowActiveMember(user1); + when(photoRequestRepository.findTopByHistoryIdOrderByIdDesc(88L)) + .thenReturn(Optional.empty()); + when(chatMessageRepository.countChatMessagesByRoom_IdAndUserIsNotNull(77L)) + .thenReturn(10); + when(chatMemberRepository.findByRoomId(77L)) + .thenReturn(List.of(ChatMember.create(room, user1), ChatMember.create(room, user2))); + + photoRequestService.createPhotoRequest(77L, 1L); + + assertPublishedMessage(SystemMessageType.PHOTO_REQUESTED, 1L, 2L); + } + + @Test + void publishesSystemMessageWhenPhotoRequestIsAccepted() { + allowActiveMember(user2); + LoveViewPhotoRequest request = pendingRequest(user1, user2); + when(photoRequestRepository.findTopByHistoryIdOrderByIdDesc(88L)) + .thenReturn(Optional.of(request)); + when(chatMemberRepository.findByRoomId(77L)) + .thenReturn(List.of(ChatMember.create(room, user1), ChatMember.create(room, user2))); + + photoRequestService.acceptPhotoRequest(77L, 2L); + + assertThat(request.getStatus()).isEqualTo(PhotoRequestStatus.ACCEPTED); + assertPublishedMessage(SystemMessageType.PHOTO_REQUEST_ACCEPTED, 2L, 1L); + } + + @Test + void publishesSystemMessageWhenPhotoRequestIsRejected() { + allowActiveMember(user2); + LoveViewPhotoRequest request = pendingRequest(user1, user2); + when(photoRequestRepository.findTopByHistoryIdOrderByIdDesc(88L)) + .thenReturn(Optional.of(request)); + when(chatMemberRepository.findByRoomId(77L)) + .thenReturn(List.of(ChatMember.create(room, user1), ChatMember.create(room, user2))); + + photoRequestService.rejectPhotoRequest(77L, 2L); + + assertThat(request.getStatus()).isEqualTo(PhotoRequestStatus.REJECTED); + assertPublishedMessage(SystemMessageType.PHOTO_REQUEST_REJECTED, 2L, 1L); + } + + private void allowActiveMember(User user) { + when(chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus( + 77L, + user.getUserId(), + ChatMemberStatus.ACTIVATE + )).thenReturn(Optional.of(ChatMember.create(room, user))); + } + + private LoveViewPhotoRequest pendingRequest(User sender, User receiver) { + return LoveViewPhotoRequest.builder() + .history(history) + .sender(sender) + .receiver(receiver) + .status(PhotoRequestStatus.PENDING) + .build(); + } + + private void assertPublishedMessage( + SystemMessageType expectedType, + Long actorUserId, + Long recipientUserId + ) { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ChatSystemMessageEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().roomId()).isEqualTo(77L); + assertThat(captor.getValue().type()).isEqualTo(expectedType); + assertThat(captor.getValue().actorUserId()).isEqualTo(actorUserId); + assertThat(captor.getValue().recipientUserIds()).containsExactly(recipientUserId); + } + + private static 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/MatchingResultDataDtoTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/MatchingResultDataDtoTest.java new file mode 100644 index 0000000..33bb856 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/MatchingResultDataDtoTest.java @@ -0,0 +1,68 @@ +package mannabom_server.manabom.application.meeting; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import mannabom_server.manabom.application.meeting.dto.response.MatchingResultDataDto; +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.MatchingStatus; +import mannabom_server.manabom.domain.region.entity.Region; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MatchingResultDataDtoTest { + + @Test + void exposesFailedAndRequeuedStatusWithoutOpponentAfterTimeout() throws JsonProcessingException { + Region region = Region.builder() + .sidoName("서울특별시") + .sigunguName("강남구") + .build(); + Meeting meeting1 = Meeting.builder() + .id(10L) + .region(region) + .remainingRejectCount(1) + .build(); + Meeting meeting2 = Meeting.builder() + .id(11L) + .region(region) + .remainingRejectCount(1) + .build(); + MeetingMatch match = MeetingMatch.builder() + .meeting1(meeting1) + .meeting2(meeting2) + .build(); + match.processExpiration(); + + MatchingResultDataDto response = MatchingResultDataDto.failed(match); + + assertThat(response.getMatchingStatus()).isEqualTo(MatchingStatus.FAILED); + assertThat(response.isRequeued()).isTrue(); + assertThat(response.getOpponentTeamInfo()).isNull(); + assertThat(new ObjectMapper().findAndRegisterModules().writeValueAsString(response)) + .doesNotContain("opponentTeamInfo"); + } + + @Test + void doesNotExposeRequeuedWhileDecisionIsPending() { + Region region = Region.builder() + .sidoName("서울특별시") + .sigunguName("강남구") + .build(); + Meeting meeting1 = Meeting.builder().id(10L).region(region).build(); + Meeting meeting2 = Meeting.builder().id(11L).region(region).build(); + MeetingMatch match = MeetingMatch.builder() + .meeting1(meeting1) + .meeting2(meeting2) + .build(); + + MatchingResultDataDto response = MatchingResultDataDto.withOpponent(match, meeting2, List.of()); + + assertThat(response.getMatchingStatus()).isEqualTo(MatchingStatus.PENDING); + assertThat(response.isRequeued()).isFalse(); + assertThat(response.getOpponentTeamInfo()).isNotNull(); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/meeting/MeetingCancellationResponseTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/MeetingCancellationResponseTest.java new file mode 100644 index 0000000..e2973b1 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/MeetingCancellationResponseTest.java @@ -0,0 +1,63 @@ +package mannabom_server.manabom.application.meeting; + +import mannabom_server.manabom.application.meeting.dto.response.MeetingCancellationResponse; +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.user.entity.User; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MeetingCancellationResponseTest { + + @Test + void countsAgreePendingAndRejectVotes() { + Instant now = Instant.parse("2026-08-06T00:00:00Z"); + MeetingCancellationRequest request = MeetingCancellationRequest.create( + MeetingMatch.builder() + .meeting1(Meeting.builder().id(10L).build()) + .meeting2(Meeting.builder().id(11L).build()) + .build(), + user(1L), + now, + now.plus(Duration.ofHours(24)) + ); + MeetingCancellationVote agreed = MeetingCancellationVote.agreedByInitiator( + request, + user(1L), + now + ); + MeetingCancellationVote pending = MeetingCancellationVote.pending(request, user(2L)); + MeetingCancellationVote rejected = MeetingCancellationVote.pending(request, user(3L)); + rejected.decide(CancellationVoteDecision.REJECT, now); + + MeetingCancellationResponse response = MeetingCancellationResponse.of( + request, + List.of(agreed, pending, rejected) + ); + + assertThat(response.getTotalMemberCount()).isEqualTo(3); + assertThat(response.getAgreedMemberCount()).isEqualTo(1); + assertThat(response.getPendingMemberCount()).isEqualTo(1); + assertThat(response.getRejectedMemberCount()).isEqualTo(1); + assertThat(response.getAgreedMemberCount() + + response.getPendingMemberCount() + + response.getRejectedMemberCount()) + .isEqualTo(response.getTotalMemberCount()); + } + + 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..999f8f5 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java @@ -0,0 +1,120 @@ +package mannabom_server.manabom.application.meeting.service; + +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +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.MeetingMatch; +import mannabom_server.manabom.domain.meeting.entity.MeetingMember; +import mannabom_server.manabom.domain.meeting.enums.ChatUserStatus; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationRequestRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingMemberRepository; +import mannabom_server.manabom.domain.user.entity.User; +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 org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MeetingCancellationExpirationServiceTest { + + @Mock + private MeetingCancellationRequestRepository requestRepository; + @Mock private ChatRoomRepository chatRoomRepository; + @Mock private MeetingMemberRepository meetingMemberRepository; + @Mock private ApplicationEventPublisher eventPublisher; + + @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); + ReflectionTestUtils.setField(request, "id", 30L); + MeetingMatch match = request.getMeetingMatch(); + User user1 = user(1L); + User user2 = user(2L); + when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + when(chatRoomRepository.findByMatch(match)) + .thenReturn(Optional.of(ChatRoom.builder().id(100L).build())); + when(meetingMemberRepository.findByMeetingIdAndStatus(10L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of(MeetingMember.addLeader(match.getMeeting1(), user1))); + when(meetingMemberRepository.findByMeetingIdAndStatus(11L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of(MeetingMember.addLeader(match.getMeeting2(), user2))); + + boolean expired = expirationService.expire(30L, now); + + assertThat(expired).isTrue(); + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.EXPIRED); + assertThat(request.getCompletedAt()).isEqualTo(now); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(ChatSystemMessageEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + ChatSystemMessageEvent event = eventCaptor.getValue(); + assertThat(event.roomId()).isEqualTo(100L); + assertThat(event.type()).isEqualTo(SystemMessageType.MEETING_CANCELLATION_EXPIRED); + assertThat(event.recipientUserIds()).containsExactly(1L, 2L); + assertThat(event.data()) + .containsEntry("requestId", 30L) + .containsEntry("status", MeetingCancellationStatus.EXPIRED.name()) + .containsEntry("expiresAt", request.getExpiresAt().toString()); + } + + @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)) + ); + } + + 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/MeetingCancellationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java new file mode 100644 index 0000000..46657fe --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java @@ -0,0 +1,247 @@ +package mannabom_server.manabom.application.meeting.service; + +import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +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.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +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.lang.reflect.Method; +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; + @Mock + private ApplicationEventPublisher eventPublisher; + + @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)); + when(chatRoomRepository.findByMatch(match)) + .thenReturn(Optional.of(ChatRoom.builder().id(100L).build())); + + 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) + ); + 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.findAllByRequest_IdOrderById(null)).thenReturn(List.of(vote)); + when(meetingMemberRepository.findByMeetingIdAndStatus(10L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of()); + when(meetingMemberRepository.findByMeetingIdAndStatus(11L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of()); + when(chatRoomRepository.findByMatch(match)) + .thenReturn(Optional.of(ChatRoom.builder().id(100L).build())); + + meetingCancellationService.vote(30L, 2L, CancellationVoteDecision.AGREE); + + verify(meeting1).cancelByAgreement(); + verify(meeting2).cancelByAgreement(); + } + + @Test + void expiredVoteDelegatesExpirationAndUsesNoRollbackException() throws NoSuchMethodException { + Instant requestedAt = Instant.now().minus(Duration.ofHours(25)); + Meeting meeting1 = Meeting.builder().id(10L).build(); + Meeting meeting2 = Meeting.builder().id(11L).build(); + MeetingMatch match = MeetingMatch.builder() + .meeting1(meeting1) + .meeting2(meeting2) + .build(); + MeetingCancellationRequest request = MeetingCancellationRequest.create( + match, + user(1L), + requestedAt, + requestedAt.plus(Duration.ofHours(24)) + ); + ReflectionTestUtils.setField(request, "id", 30L); + when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + when(expirationService.expire(eq(request), any(Instant.class))) + .thenReturn(true); + + assertThatThrownBy(() -> meetingCancellationService.vote( + 30L, + 2L, + CancellationVoteDecision.AGREE + )) + .isInstanceOf(MeetingCancellationExpiredException.class) + .hasMessage("이미 만료된 미팅 취소 요청입니다."); + + verify(expirationService).expire(eq(request), any(Instant.class)); + verifyNoInteractions(voteRepository); + + Method voteMethod = MeetingCancellationService.class.getMethod( + "vote", + Long.class, + Long.class, + CancellationVoteDecision.class + ); + Transactional transactional = voteMethod.getAnnotation(Transactional.class); + assertThat(transactional.noRollbackFor()) + .contains(MeetingCancellationExpiredException.class); + } + + @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/application/meeting/service/MeetingMatchingServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingServiceTest.java new file mode 100644 index 0000000..2e80000 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingServiceTest.java @@ -0,0 +1,65 @@ +package mannabom_server.manabom.application.meeting.service; + +import mannabom_server.manabom.application.chat.service.ChatRoomService; +import mannabom_server.manabom.application.meeting.dto.response.MatchingResultDataDto; +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.MatchingStatus; +import mannabom_server.manabom.domain.meeting.repository.MeetingMatchRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingRepository; +import mannabom_server.manabom.infrastructure.redis.meetingmatching.RedisMatchAtomicOps; +import mannabom_server.manabom.infrastructure.redis.meetingmatching.RedisMatchHistory; +import mannabom_server.manabom.infrastructure.redis.meetingmatching.RedisMatchMeta; +import mannabom_server.manabom.infrastructure.redis.meetingmatching.RedisMatchQueue; +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.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MeetingMatchingServiceTest { + + @Mock private RedisMatchQueue queue; + @Mock private RedisMatchHistory history; + @Mock private RedisMatchAtomicOps atomic; + @Mock private RedisMatchMeta meta; + @Mock private MeetingMatchRepository meetingMatchRepository; + @Mock private MeetingRepository meetingRepository; + @Mock private MeetingMemberReadService meetingMemberReadService; + @Mock private ChatRoomService chatRoomService; + @Mock private MeetingMemberService meetingMemberService; + @Mock private MeetingService meetingService; + @Mock private MeetingMatchingTimeoutScheduler meetingMatchingTimeoutScheduler; + @Mock private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private MeetingMatchingService meetingMatchingService; + + @Test + void returnsFailedResultWithoutLoadingOpponentMembers() { + Meeting meeting1 = Meeting.builder().id(10L).build(); + Meeting meeting2 = Meeting.builder().id(11L).build(); + MeetingMatch match = org.mockito.Mockito.mock(MeetingMatch.class); + when(match.getId()).thenReturn(20L); + when(match.getMeeting1()).thenReturn(meeting1); + when(match.getMeeting2()).thenReturn(meeting2); + when(match.getMatchingStatus()).thenReturn(MatchingStatus.FAILED); + when(meetingMatchRepository.findByIdWithMeeting(20L)).thenReturn(Optional.of(match)); + when(meetingMemberService.isMember(10L, 1L)).thenReturn(true); + + MatchingResultDataDto response = meetingMatchingService.getMatchingResult(20L, 1L); + + assertThat(response.getMatchingStatus()).isEqualTo(MatchingStatus.FAILED); + assertThat(response.isRequeued()).isTrue(); + assertThat(response.getOpponentTeamInfo()).isNull(); + verifyNoInteractions(meetingMemberReadService); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationExpirationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationExpirationServiceTest.java new file mode 100644 index 0000000..bc6a7fc --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationExpirationServiceTest.java @@ -0,0 +1,58 @@ +package mannabom_server.manabom.application.meeting.service; + +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageType; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.meeting.entity.MeetingVerification; +import mannabom_server.manabom.domain.meeting.repository.MeetingVerificationRepository; +import mannabom_server.manabom.domain.user.entity.User; +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.time.Duration; +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MeetingVerificationExpirationServiceTest { + + @Mock private MeetingVerificationRepository verificationRepository; + @Mock private ApplicationEventPublisher eventPublisher; + @InjectMocks private MeetingVerificationExpirationService expirationService; + + @Test + void publishesFailureOnlyOnceAfterExpiration() { + Instant startedAt = Instant.parse("2026-07-22T00:00:00Z"); + Instant expiredAt = startedAt.plus(Duration.ofHours(9)); + MeetingVerification verification = MeetingVerification.builder() + .room(ChatRoom.builder().id(77L).build()) + .build(); + verification.startIfNeeded( + startedAt, + Duration.ofHours(9), + User.builder().userId(11L).kakaoId("kakao-11").userName("사용자11").build() + ); + verification.updateParticipantCount(3); + when(verificationRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(verification)); + + assertThat(expirationService.notifyFailure(1L, expiredAt)).isTrue(); + assertThat(expirationService.notifyFailure(1L, expiredAt.plusSeconds(1))).isFalse(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ChatSystemMessageEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().roomId()).isEqualTo(77L); + assertThat(captor.getValue().type()).isEqualTo(SystemMessageType.MEETING_VERIFICATION_FAILED); + assertThat(captor.getValue().actorUserId()).isEqualTo(11L); + assertThat(captor.getValue().data()).containsEntry("participantCount", 3); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/notification/service/NotificationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000..ce48016 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/notification/service/NotificationServiceTest.java @@ -0,0 +1,98 @@ +package mannabom_server.manabom.application.notification.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import mannabom_server.manabom.application.pushService.service.pushSender.PushService; +import mannabom_server.manabom.domain.notification.entity.Notification; +import mannabom_server.manabom.domain.notification.enums.NotificationType; +import mannabom_server.manabom.domain.notification.repository.NotificationRepository; +import mannabom_server.manabom.domain.pushMessage.PushMessage; +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.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock private PushService pushService; + @Mock private NotificationRepository notificationRepository; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @InjectMocks private NotificationService notificationService; + + @Test + void storesNotificationAndAlwaysRequestsPush() { + notificationService.sendNotification( + 2L, + NotificationType.SYSTEM_MESSAGE, + "알림 제목", + "알림 내용", + Map.of("roomId", 77L) + ); + + ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(Notification.class); + verify(notificationRepository).save(notificationCaptor.capture()); + assertThat(notificationCaptor.getValue().getType()).isEqualTo(NotificationType.SYSTEM_MESSAGE); + + ArgumentCaptor pushCaptor = ArgumentCaptor.forClass(PushMessage.class); + verify(pushService).sendToUser(org.mockito.ArgumentMatchers.eq(2L), pushCaptor.capture()); + assertThat(pushCaptor.getValue().data()) + .containsEntry("type", "SYSTEM_MESSAGE") + .containsEntry("roomId", "77"); + } + + @Test + void requestsPushOnlyAfterTransactionCommit() { + TransactionSynchronizationManager.initSynchronization(); + try { + notificationService.sendNotification( + 2L, + NotificationType.SYSTEM_MESSAGE, + "알림 제목", + "알림 내용", + Map.of("roomId", 77L) + ); + + verify(notificationRepository).save(any(Notification.class)); + verifyNoInteractions(pushService); + + TransactionSynchronizationManager.getSynchronizations() + .forEach(TransactionSynchronization::afterCommit); + + verify(pushService).sendToUser(eq(2L), any(PushMessage.class)); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void doesNotPropagatePushFailure() { + doThrow(new RuntimeException("FCM unavailable")) + .when(pushService) + .sendToUser(eq(2L), any(PushMessage.class)); + + assertThatCode(() -> notificationService.sendNotification( + 2L, + NotificationType.SYSTEM_MESSAGE, + "알림 제목", + "알림 내용", + Map.of("roomId", 77L) + )).doesNotThrowAnyException(); + + verify(notificationRepository).save(any(Notification.class)); + } +} 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/MeetingMatchExpirationTest.java b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMatchExpirationTest.java new file mode 100644 index 0000000..0eed918 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMatchExpirationTest.java @@ -0,0 +1,36 @@ +package mannabom_server.manabom.domain.meeting; + +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.MatchingStatus; +import mannabom_server.manabom.domain.meeting.enums.MeetingDecision; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MeetingMatchExpirationTest { + + @Test + void processesExpirationForBothWaitingTeams() { + Meeting meeting1 = Meeting.builder() + .id(10L) + .remainingRejectCount(1) + .build(); + Meeting meeting2 = Meeting.builder() + .id(11L) + .remainingRejectCount(1) + .build(); + MeetingMatch match = MeetingMatch.builder() + .meeting1(meeting1) + .meeting2(meeting2) + .build(); + + match.processExpiration(); + + assertThat(match.getMeeting1Decision()).isEqualTo(MeetingDecision.AUTO_REJECTED); + assertThat(match.getMeeting2Decision()).isEqualTo(MeetingDecision.AUTO_REJECTED); + assertThat(match.getMatchingStatus()).isEqualTo(MatchingStatus.FAILED); + assertThat(meeting1.getRemainingRejectCount()).isZero(); + assertThat(meeting2.getRemainingRejectCount()).isZero(); + } +} 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(); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingVerificationTest.java b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingVerificationTest.java new file mode 100644 index 0000000..8cb070d --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingVerificationTest.java @@ -0,0 +1,55 @@ +package mannabom_server.manabom.domain.meeting; + +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.meeting.entity.MeetingVerification; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MeetingVerificationTest { + + @Test + void persistsSuccessfulClusterSnapshotSeparatelyFromAllSubmissions() { + MeetingVerification verification = MeetingVerification.builder() + .room(ChatRoom.builder().id(1L).build()) + .build(); + verification.updateParticipantCount(4); + + verification.verify(3, 37.5665, 126.9780, true, true); + + assertThat(verification.getParticipantCount()).isEqualTo(4); + assertThat(verification.getVerifiedParticipantCount()).isEqualTo(3); + assertThat(verification.getFinalLatitude()).isEqualTo(37.5665); + assertThat(verification.getFinalLongitude()).isEqualTo(126.9780); + assertThat(verification.isVerifiedHasMale()).isTrue(); + assertThat(verification.isVerifiedHasFemale()).isTrue(); + } + + @Test + void countsVerifiedLatecomerAsSubmittedAndVerified() { + MeetingVerification verification = MeetingVerification.builder() + .room(ChatRoom.builder().id(1L).build()) + .build(); + verification.updateParticipantCount(4); + verification.verify(3, 37.5665, 126.9780, true, true); + + verification.recordVerifiedLatecomer(true); + + assertThat(verification.getParticipantCount()).isEqualTo(5); + assertThat(verification.getVerifiedParticipantCount()).isEqualTo(4); + } + + @Test + void doesNotCountExistingSubmitterTwiceWhenJoiningAfterSuccess() { + MeetingVerification verification = MeetingVerification.builder() + .room(ChatRoom.builder().id(1L).build()) + .build(); + verification.updateParticipantCount(4); + verification.verify(3, 37.5665, 126.9780, true, true); + + verification.recordVerifiedLatecomer(false); + + assertThat(verification.getParticipantCount()).isEqualTo(4); + assertThat(verification.getVerifiedParticipantCount()).isEqualTo(4); + } +}