From 89e7af614788cffabc72d59527a6dc478dec6599 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 16 Jul 2026 23:05:03 +0900 Subject: [PATCH 01/30] =?UTF-8?q?feat:=20=EB=AF=B8=ED=8C=85=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/meeting/entity/Meeting.java | 10 +++ .../entity/MeetingCancellationRequest.java | 44 ++++++++++ .../entity/MeetingCancellationVote.java | 40 +++++++++ .../enums/CancellationVoteDecision.java | 7 ++ .../enums/MeetingCancellationStatus.java | 8 ++ .../domain/meeting/enums/MeetingStatus.java | 3 +- .../V21__add_meeting_cancellation_tables.sql | 85 +++++++++++++++++++ 7 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationRequest.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationVote.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/CancellationVoteDecision.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingCancellationStatus.java create mode 100644 manabom/src/main/resources/db/migration/V21__add_meeting_cancellation_tables.sql 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..cc0bc47 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 @@ -203,4 +203,14 @@ 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(); + } } 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..bbda9eb --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationRequest.java @@ -0,0 +1,44 @@ +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_id", nullable = false) + private Meeting meeting; + + @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; +} 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..28a281e --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationVote.java @@ -0,0 +1,40 @@ +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; +} 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..89c60f3 --- /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, + ACCEPTED, + 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/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 From ef6d494d9a486009c5fddbe6e87809b9bd211b7b Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:03:30 +0900 Subject: [PATCH 02/30] =?UTF-8?q?feat:=20=EB=AF=B8=ED=8C=85=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=B7=A8=EC=86=8C=20=ED=88=AC=ED=91=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MeetingCancellationVoteRequest.java | 14 ++ .../response/MeetingCancellationResponse.java | 55 +++++ .../MeetingCancellationVoteResponse.java | 24 +++ .../MeetingCancellationScheduler.java | 23 +++ .../service/MeetingCancellationService.java | 193 ++++++++++++++++++ .../entity/MeetingCancellationRequest.java | 44 ++++ .../entity/MeetingCancellationVote.java | 37 ++++ .../enums/MeetingCancellationStatus.java | 2 +- .../MeetingCancellationRequestRepository.java | 35 ++++ .../MeetingCancellationVoteRepository.java | 24 +++ .../meeting/controller/MeetingController.java | 43 ++++ .../MeetingCancellationDomainTest.java | 94 +++++++++ 12 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/request/MeetingCancellationVoteRequest.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationResponse.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationVoteResponse.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingCancellationScheduler.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingCancellationRequestRepository.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingCancellationVoteRepository.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingCancellationDomainTest.java diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/request/MeetingCancellationVoteRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/request/MeetingCancellationVoteRequest.java new file mode 100644 index 0000000..3261a52 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/request/MeetingCancellationVoteRequest.java @@ -0,0 +1,14 @@ +package mannabom_server.manabom.application.meeting.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; + +@Getter +@NoArgsConstructor +public class MeetingCancellationVoteRequest { + + @NotNull(message = "동의 또는 비동의를 선택해야 합니다.") + private CancellationVoteDecision decision; +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationResponse.java new file mode 100644 index 0000000..c8d2e9f --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationResponse.java @@ -0,0 +1,55 @@ +package mannabom_server.manabom.application.meeting.dto.response; + +import lombok.Builder; +import lombok.Getter; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationVote; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; + +import java.time.Instant; +import java.util.List; + +@Getter +@Builder +public class MeetingCancellationResponse { + private Long requestId; + private Long meetingId; + private Long initiatorUserId; + private MeetingCancellationStatus status; + private Instant requestedAt; + private Instant expiresAt; + private Instant completedAt; + private int totalMemberCount; + private int agreedMemberCount; + private int pendingMemberCount; + private List votes; + + public static MeetingCancellationResponse of( + MeetingCancellationRequest request, + List votes + ) { + int agreed = (int) votes.stream() + .filter(vote -> vote.getDecision() == CancellationVoteDecision.AGREE) + .count(); + int pending = (int) votes.stream() + .filter(vote -> vote.getDecision() == CancellationVoteDecision.PENDING) + .count(); + + return MeetingCancellationResponse.builder() + .requestId(request.getId()) + .meetingId(request.getMeeting().getId()) + .initiatorUserId(request.getInitiator().getUserId()) + .status(request.getStatus()) + .requestedAt(request.getRequestedAt()) + .expiresAt(request.getExpiresAt()) + .completedAt(request.getCompletedAt()) + .totalMemberCount(votes.size()) + .agreedMemberCount(agreed) + .pendingMemberCount(pending) + .votes(votes.stream() + .map(MeetingCancellationVoteResponse::from) + .toList()) + .build(); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationVoteResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationVoteResponse.java new file mode 100644 index 0000000..aa5b536 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/dto/response/MeetingCancellationVoteResponse.java @@ -0,0 +1,24 @@ +package mannabom_server.manabom.application.meeting.dto.response; + +import lombok.Builder; +import lombok.Getter; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationVote; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; + +import java.time.Instant; + +@Getter +@Builder +public class MeetingCancellationVoteResponse { + private Long userId; + private CancellationVoteDecision decision; + private Instant decidedAt; + + public static MeetingCancellationVoteResponse from(MeetingCancellationVote vote) { + return MeetingCancellationVoteResponse.builder() + .userId(vote.getUser().getUserId()) + .decision(vote.getDecision()) + .decidedAt(vote.getDecidedAt()) + .build(); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingCancellationScheduler.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingCancellationScheduler.java new file mode 100644 index 0000000..3df3096 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingCancellationScheduler.java @@ -0,0 +1,23 @@ +package mannabom_server.manabom.application.meeting.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.meeting.service.MeetingCancellationService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class MeetingCancellationScheduler { + + private final MeetingCancellationService meetingCancellationService; + + @Scheduled(fixedDelayString = "${meeting.cancellation.expiration-check-delay:60000}") + public void expireCancellationRequests() { + int expiredCount = meetingCancellationService.expirePendingRequests(); + if (expiredCount > 0) { + log.info("만료된 미팅 전체 취소 요청 {}건을 처리했습니다.", expiredCount); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java new file mode 100644 index 0000000..9b1de5c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java @@ -0,0 +1,193 @@ +package mannabom_server.manabom.application.meeting.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.meeting.dto.response.MeetingCancellationResponse; +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.MeetingMember; +import mannabom_server.manabom.domain.meeting.enums.CancellationVoteDecision; +import mannabom_server.manabom.domain.meeting.enums.ChatUserStatus; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; +import mannabom_server.manabom.domain.meeting.enums.MeetingStatus; +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.MeetingRepository; +import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.domain.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MeetingCancellationService { + + private static final Duration CANCELLATION_DEADLINE = Duration.ofHours(24); + + private final MeetingRepository meetingRepository; + private final MeetingMemberRepository meetingMemberRepository; + private final MeetingCancellationRequestRepository requestRepository; + private final MeetingCancellationVoteRepository voteRepository; + private final UserRepository userRepository; + private final ChatRoomRepository chatRoomRepository; + + @Transactional + public MeetingCancellationResponse create(Long meetingId, Long userId) { + Meeting meeting = meetingRepository.findByIdWithLock(meetingId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅방입니다.")); + validateActiveMember(meetingId, userId); + + if (meeting.getMeetingStatus() == MeetingStatus.CANCELLED) { + throw new IllegalStateException("이미 취소된 미팅입니다."); + } + if (requestRepository.existsByMeeting_IdAndStatus( + meetingId, + MeetingCancellationStatus.PENDING + )) { + throw new IllegalStateException("이미 진행 중인 미팅 취소 요청이 있습니다."); + } + + List members = activeMembers(meetingId); + 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( + meeting, + initiator, + now, + now.plus(CANCELLATION_DEADLINE) + ) + ); + + List votes = members.stream() + .map(member -> member.getUser().getUserId().equals(userId) + ? MeetingCancellationVote.agreedByInitiator(request, member.getUser(), now) + : MeetingCancellationVote.pending(request, member.getUser())) + .toList(); + voteRepository.saveAll(votes); + + return MeetingCancellationResponse.of(request, votes); + } + + @Transactional + 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(); + expireIfNecessary(request, now); + + if (request.getStatus() != MeetingCancellationStatus.PENDING) { + throw new IllegalStateException("이미 종료된 미팅 취소 요청입니다."); + } + + MeetingCancellationVote vote = voteRepository + .findByRequest_IdAndUser_UserId(requestId, userId) + .orElseThrow(() -> new IllegalArgumentException("해당 미팅의 투표 대상자가 아닙니다.")); + vote.decide(decision, now); + + if (decision == CancellationVoteDecision.REJECT) { + request.reject(now); + } else if (allMembersAgreed(requestId)) { + approveCancellation(request, now); + } + + return response(request); + } + + @Transactional + public MeetingCancellationResponse getCurrent(Long meetingId, Long userId) { + validateActiveMember(meetingId, userId); + MeetingCancellationRequest request = requestRepository + .findByMeeting_IdAndStatus(meetingId, MeetingCancellationStatus.PENDING) + .orElseThrow(() -> new IllegalArgumentException("진행 중인 미팅 취소 요청이 없습니다.")); + expireIfNecessary(request, Instant.now()); + return response(request); + } + + @Transactional + public int expirePendingRequests() { + Instant now = Instant.now(); + List expiredRequests = requestRepository + .findAllByStatusAndExpiresAtLessThanEqual( + MeetingCancellationStatus.PENDING, + now + ); + expiredRequests.forEach(request -> request.expire(now)); + return expiredRequests.size(); + } + + private void approveCancellation( + MeetingCancellationRequest request, + Instant now + ) { + request.approve(now); + Meeting meeting = request.getMeeting(); + + chatRoomRepository.findByMeeting(meeting) + .ifPresent(chatRoom -> chatRoom.deactivate()); + activeMembers(meeting.getId()).forEach(MeetingMember::deactivate); + meeting.cancelByAgreement(); + } + + private void expireIfNecessary( + MeetingCancellationRequest request, + Instant now + ) { + if (request.isExpiredAt(now)) { + request.expire(now); + } + } + + private boolean allMembersAgreed(Long requestId) { + return voteRepository.countByRequest_IdAndDecision( + requestId, + CancellationVoteDecision.PENDING + ) == 0 && voteRepository.countByRequest_IdAndDecision( + requestId, + CancellationVoteDecision.REJECT + ) == 0; + } + + private MeetingCancellationResponse response(MeetingCancellationRequest request) { + return MeetingCancellationResponse.of( + request, + voteRepository.findAllByRequest_IdOrderById(request.getId()) + ); + } + + private List activeMembers(Long meetingId) { + return meetingMemberRepository.findByMeetingIdAndStatus( + meetingId, + ChatUserStatus.ACTIVE + ); + } + + private void validateActiveMember(Long meetingId, Long userId) { + if (!meetingMemberRepository.existsByMeeting_IdAndUser_UserIdAndStatus( + meetingId, + userId, + ChatUserStatus.ACTIVE + )) { + throw new IllegalArgumentException("해당 미팅방의 참여자가 아닙니다."); + } + } +} 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 index bbda9eb..52b8777 100644 --- 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 @@ -41,4 +41,48 @@ public class MeetingCancellationRequest { @Version private Long version; + + public static MeetingCancellationRequest create( + Meeting meeting, + User initiator, + Instant requestedAt, + Instant expiresAt + ) { + MeetingCancellationRequest request = new MeetingCancellationRequest(); + request.meeting = meeting; + 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 index 28a281e..0da75f1 100644 --- 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 @@ -37,4 +37,41 @@ public class MeetingCancellationVote { private CancellationVoteDecision decision; private Instant decidedAt; + + public static MeetingCancellationVote pending( + MeetingCancellationRequest request, + User user + ) { + MeetingCancellationVote vote = new MeetingCancellationVote(); + vote.request = request; + vote.user = user; + vote.decision = CancellationVoteDecision.PENDING; + return vote; + } + + public static MeetingCancellationVote agreedByInitiator( + MeetingCancellationRequest request, + User user, + Instant decidedAt + ) { + MeetingCancellationVote vote = pending(request, user); + vote.agree(decidedAt); + return vote; + } + + public void decide(CancellationVoteDecision decision, Instant decidedAt) { + if (decision == CancellationVoteDecision.PENDING) { + throw new IllegalArgumentException("투표 결과는 AGREE 또는 REJECT여야 합니다."); + } + if (this.decision != CancellationVoteDecision.PENDING) { + throw new IllegalStateException("이미 투표를 완료했습니다."); + } + this.decision = decision; + this.decidedAt = decidedAt; + } + + private void agree(Instant decidedAt) { + this.decision = CancellationVoteDecision.AGREE; + this.decidedAt = decidedAt; + } } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingCancellationStatus.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/MeetingCancellationStatus.java index 89c60f3..68b2a28 100644 --- 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 @@ -2,7 +2,7 @@ public enum MeetingCancellationStatus { PENDING, - ACCEPTED, + APPROVED, REJECTED, EXPIRED } 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..ab142f3 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/repository/MeetingCancellationRequestRepository.java @@ -0,0 +1,35 @@ +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 existsByMeeting_IdAndStatus(Long meetingId, MeetingCancellationStatus status); + + Optional findByMeeting_IdAndStatus( + Long meetingId, + MeetingCancellationStatus status + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select r from MeetingCancellationRequest r where r.id = :requestId") + Optional findByIdForUpdate( + @Param("requestId") Long requestId + ); + + List findAllByStatusAndExpiresAtLessThanEqual( + MeetingCancellationStatus status, + 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/presentation/meeting/controller/MeetingController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingController.java index 74a9d1e..f387d5d 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("/rooms/{meetingId}/cancellation-requests") + public ResponseEntity> createCancellationRequest( + @PathVariable Long meetingId, + @AuthenticationPrincipal Long userId + ) { + MeetingCancellationResponse response = meetingCancellationService.create(meetingId, 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("/rooms/{meetingId}/cancellation-requests/current") + public ResponseEntity> getCurrentCancellationRequest( + @PathVariable Long meetingId, + @AuthenticationPrincipal Long userId + ) { + MeetingCancellationResponse response = meetingCancellationService.getCurrent(meetingId, 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/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..cc62845 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingCancellationDomainTest.java @@ -0,0 +1,94 @@ +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.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( + Meeting.builder().id(10L).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(); + } +} From f95bc2c9a753eb991f7b076807b2169607dd0a3d Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:04:24 +0900 Subject: [PATCH 03/30] =?UTF-8?q?refactor:=20=EC=95=88=EC=93=B0=EB=8A=94?= =?UTF-8?q?=20import=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mannabom_server/manabom/domain/meeting/entity/Meeting.java | 1 - 1 file changed, 1 deletion(-) 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 cc0bc47..91ece97 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; From 83a2a9edb400ed7e88fe493afd112882ccc31f19 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:05:51 +0900 Subject: [PATCH 04/30] =?UTF-8?q?fix:=20=EC=B7=A8=EC=86=8C=20=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=EC=A4=91=20=EB=AF=B8=ED=8C=85=EB=B0=A9=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=EC=9E=90=20=EB=B3=80=EA=B2=BD=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MeetingCancellationService.java | 12 ++++ .../meeting/service/MeetingService.java | 13 +++- .../MeetingCancellationServiceTest.java | 63 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java 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 index 9b1de5c..5053a0d 100644 --- 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 @@ -37,6 +37,18 @@ public class MeetingCancellationService { private final UserRepository userRepository; private final ChatRoomRepository chatRoomRepository; + @Transactional(readOnly = true) + public void validateNoPendingCancellation(Long meetingId) { + if (requestRepository.existsByMeeting_IdAndStatus( + meetingId, + MeetingCancellationStatus.PENDING + )) { + throw new IllegalStateException( + "미팅 전체 취소 투표가 진행 중이므로 입장하거나 나갈 수 없습니다." + ); + } + } + @Transactional public MeetingCancellationResponse create(Long meetingId, Long userId) { Meeting meeting = meetingRepository.findByIdWithLock(meetingId) 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..434210a 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 @@ -47,6 +47,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,6 +178,7 @@ public MeetingRoomCreateDataDto enterRoomByCode(MeetingRoomJoinByCodeRequest req Meeting meeting = meetingRepository.findByCodeWithLock(request.getRoomCode()) .orElseThrow(() -> new IllegalArgumentException("방 코드로 미팅방 입장: 존재 하지 않는 초대 코드입니다.")); + meetingCancellationService.validateNoPendingCancellation(meeting.getId()); validateJoinCondition(meeting, user,profile); meeting.addMember(profile.computeAge()); meetingRepository.saveAndFlush(meeting); /*왜 addMember가 반영이 안되지*/ @@ -198,6 +200,7 @@ public MeetingRoomCreateDataDto enterRoomById(Long meetingId, Long userId) { .orElseThrow(() -> new IllegalArgumentException("미팅방 입장: 존재 하지 않는 id입니다. :" + meetingId)); + meetingCancellationService.validateNoPendingCancellation(meetingId); validateJoinCondition(meeting, user,profile); meeting.addMember(profile.computeAge()); meetingMemberService.addMember(meeting, user); @@ -213,8 +216,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("현재 입장할 수 없는 미팅방입니다."); + } /*이미 참여중인지 체크*/ @@ -358,13 +364,14 @@ public boolean cancelMatching(Long userId){ @Transactional public void handleMemberLeave(Long meetingId, Long userId){ - Meeting meeting = meetingRepository.findById(meetingId) + Meeting meeting = meetingRepository.findByIdWithLock(meetingId) .orElseThrow(()-> new IllegalArgumentException("존재하지 않는 미팅아이디입니다.")); User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다.")); Profile profile = profileRepository.findByUser(user) .orElseThrow(() -> new IllegalArgumentException("프로필 정보를 찾을 수 없습니다.")); + meetingCancellationService.validateNoPendingCancellation(meetingId); validateMeetingStatus(meeting); boolean isLeader = meetingMemberService.isLeader(meetingId, userId); 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..65ce525 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java @@ -0,0 +1,63 @@ +package mannabom_server.manabom.application.meeting.service; + +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; +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.MeetingRepository; +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 static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MeetingCancellationServiceTest { + + @Mock + private MeetingRepository meetingRepository; + @Mock + private MeetingMemberRepository meetingMemberRepository; + @Mock + private MeetingCancellationRequestRepository requestRepository; + @Mock + private MeetingCancellationVoteRepository voteRepository; + @Mock + private UserRepository userRepository; + @Mock + private ChatRoomRepository chatRoomRepository; + + @InjectMocks + private MeetingCancellationService meetingCancellationService; + + @Test + void blocksMembershipChangesWhileCancellationVoteIsPending() { + when(requestRepository.existsByMeeting_IdAndStatus( + 10L, + MeetingCancellationStatus.PENDING + )).thenReturn(true); + + assertThatThrownBy(() -> + meetingCancellationService.validateNoPendingCancellation(10L) + ).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("취소 투표가 진행 중"); + } + + @Test + void allowsMembershipChangesWithoutPendingCancellationVote() { + when(requestRepository.existsByMeeting_IdAndStatus( + 10L, + MeetingCancellationStatus.PENDING + )).thenReturn(false); + + assertThatCode(() -> + meetingCancellationService.validateNoPendingCancellation(10L) + ).doesNotThrowAnyException(); + } +} From fc3ac9216bd668bf6e4f55ad9eb55114cc42fa85 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:11:09 +0900 Subject: [PATCH 05/30] =?UTF-8?q?fix:=20=EB=A7=A4=EC=B9=AD=EB=B0=A9=20?= =?UTF-8?q?=ED=87=B4=EC=9E=A5=20=EC=8B=9C=20=EB=B9=A0=EB=A5=B8=20=EC=9E=85?= =?UTF-8?q?=EC=9E=A5=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/service/MeetingService.java | 8 +++ .../domain/meeting/entity/Meeting.java | 32 +++++++++++ .../meeting/MeetingMemberLeaveStatusTest.java | 54 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMemberLeaveStatusTest.java 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 434210a..346211c 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 @@ -373,6 +373,7 @@ public void handleMemberLeave(Long meetingId, Long userId){ meetingCancellationService.validateNoPendingCancellation(meetingId); validateMeetingStatus(meeting); + MeetingStatus previousStatus = meeting.getMeetingStatus(); boolean isLeader = meetingMemberService.isLeader(meetingId, userId); meeting.deleteMember(profile.computeAge()); @@ -383,6 +384,13 @@ public void handleMemberLeave(Long meetingId, Long userId){ meeting.delete(); return; } + + if (previousStatus == MeetingStatus.MATCHED) { + meeting.changeToFastMatchingAfterMemberLeave(); + } else if (previousStatus == MeetingStatus.FULL) { + meeting.changeToRecruitingAfterMemberLeave(); + } + if(isLeader){ meetingMemberService.appointNextLeader(meetingId); } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/Meeting.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/Meeting.java index 91ece97..6e42630 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 @@ -212,4 +212,36 @@ public void cancelByAgreement() { 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/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..d524bda --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMemberLeaveStatusTest.java @@ -0,0 +1,54 @@ +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); + } + + private Meeting meeting( + MeetingStatus status, + int currentMembers, + int maxMembers + ) { + return Meeting.builder() + .meetingStatus(status) + .currentMembers(currentMembers) + .maxMembers(maxMembers) + .avgAge(25) + .build(); + } +} From 424f13f4fc6e9013ddd0b6f750ff30741d8852b3 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:16:40 +0900 Subject: [PATCH 06/30] =?UTF-8?q?feat:=20=EB=B9=A0=EB=A5=B8=20=EC=9E=85?= =?UTF-8?q?=EC=9E=A5=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=A5=BC=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EC=B1=84=ED=8C=85=EB=B0=A9=EC=97=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/ChatRoomService.java | 18 ++++++++++++++++++ .../meeting/service/MeetingService.java | 15 +++++++++++++++ .../chat/repository/ChatRoomRepository.java | 3 +++ .../manabom/domain/meeting/entity/Meeting.java | 5 ++++- .../repository/MeetingMatchRepository.java | 13 +++++++++++++ .../meeting/MeetingMemberLeaveStatusTest.java | 10 ++++++++++ 6 files changed, 63 insertions(+), 1 deletion(-) 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..7d619f7 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 @@ -135,6 +135,24 @@ 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_UserId( + room.getId(), + user.getUserId() + )) { + throw new IllegalStateException("이미 해당 매칭 채팅방에 참여한 사용자입니다."); + } + + chatMemberRepository.save(ChatMember.create(room, user)); + return room.getId(); + } + public Long getChatRoomId(Meeting meeting){ ChatRoom room = chatRoomRepository.findByMeeting(meeting) .orElseThrow(()-> new IllegalArgumentException("미팅id와 연결된 채팅방: 존재하지 않은 채팅방입니다.")); 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 346211c..b4b21a2 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingService.java @@ -13,6 +13,8 @@ import mannabom_server.manabom.domain.meeting.enums.MeetingBucket; import mannabom_server.manabom.domain.meeting.enums.MeetingRole; import mannabom_server.manabom.domain.meeting.repository.MeetingRepository; +import mannabom_server.manabom.domain.meeting.repository.MeetingMatchRepository; +import mannabom_server.manabom.domain.meeting.enums.MatchingStatus; import mannabom_server.manabom.domain.region.entity.Region; import mannabom_server.manabom.domain.user.entity.Profile; import mannabom_server.manabom.domain.user.entity.User; @@ -40,6 +42,7 @@ public class MeetingService { private static final int CODE_RETRY_LIMIT = 10; private final MeetingRepository meetingRepository; + private final MeetingMatchRepository meetingMatchRepository; private final UserRepository userRepository; private final ProfileRepository profileRepository; @@ -202,10 +205,22 @@ public MeetingRoomCreateDataDto enterRoomById(Long meetingId, Long userId) { 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); + if (isFastMatchingEntry) { + var match = meetingMatchRepository.findByMeetingIdAndStatus( + meetingId, + MatchingStatus.SUCCEEDED + ) + .orElseThrow(() -> new IllegalStateException( + "빠른 입장 미팅과 연결된 성사된 매칭을 찾을 수 없습니다." + )); + chatRoomService.joinMatchingChatRoom(match, user); + } + return buildResponseForEnter(meeting, chatRoomId); } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatRoomRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatRoomRepository.java index 2f6adcc..d979c98 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatRoomRepository.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatRoomRepository.java @@ -3,6 +3,7 @@ import jakarta.persistence.LockModeType; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -14,6 +15,8 @@ public interface ChatRoomRepository extends JpaRepository { Optional findByMeeting(Meeting meeting); + Optional findByMatch(MeetingMatch match); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select cr from ChatRoom cr where cr.id = :chatRoomId") Optional findByIdForUpdate(@Param("chatRoomId") Long chatRoomId); diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/Meeting.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/Meeting.java index 6e42630..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 @@ -102,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(); } 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/test/java/mannabom_server/manabom/domain/meeting/MeetingMemberLeaveStatusTest.java b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMemberLeaveStatusTest.java index d524bda..13e3ba3 100644 --- a/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMemberLeaveStatusTest.java +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMemberLeaveStatusTest.java @@ -39,6 +39,16 @@ void recruitingMeetingCannotBeChangedToFastMatching() { .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, From 88bfa11a09dc3b886899c5906ad59fa359733141 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:27:49 +0900 Subject: [PATCH 07/30] =?UTF-8?q?feat:=20=EB=A7=A4=EC=B9=AD=20=EC=84=B1?= =?UTF-8?q?=EC=82=AC=20=ED=9B=84=20=EB=8F=99=EC=84=B1=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/ChatRoomService.java | 34 +++++++++++++++++++ .../application/chat/service/ChatService.java | 18 ++++++++-- .../service/MeetingMatchingService.java | 3 ++ .../chat/repository/ChatMemberRepository.java | 7 ++++ 4 files changed, 60 insertions(+), 2 deletions(-) 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 7d619f7..e9edac4 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatRoomService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatRoomService.java @@ -13,6 +13,7 @@ import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; import mannabom_server.manabom.domain.chat.enums.ChatMessageType; +import mannabom_server.manabom.domain.chat.enums.ChatStatus; import mannabom_server.manabom.domain.chat.repository.ChatMemberQueryRepository; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; @@ -153,6 +154,34 @@ public Long joinMatchingChatRoom(MeetingMatch match, User 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와 연결된 채팅방: 존재하지 않은 채팅방입니다.")); @@ -162,6 +191,11 @@ public Long getChatRoomId(Meeting meeting){ public void leaveChatRoom(Long roomId, Long userId){ ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(()-> new IllegalArgumentException("채팅방 나가기: 존재하지 않는 채팅방아이디 입니다.")); + + if (room.getChatStatus() == ChatStatus.DISABLED) { + throw new IllegalStateException("비활성화된 채팅방에서는 나갈 수 없습니다."); + } + ChatMember chatMember = chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus(roomId, userId, ChatMemberStatus.ACTIVATE) .orElseThrow(()-> new IllegalArgumentException("방에 참여중인 유저가 아닙니다.")); diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java index 2924c2d..2160c79 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java @@ -10,6 +10,7 @@ import mannabom_server.manabom.domain.chat.entity.ChatMessage; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.enums.ChatStatus; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; @@ -54,6 +55,9 @@ public class ChatService { public void sendMessage(ChatSendRequest request, Long userId) { ChatRoom chatRoom = chatRoomRepository.findById(request.getRoomId()) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 채팅방입니다.")); + if (chatRoom.getChatStatus() == ChatStatus.DISABLED) { + throw new IllegalStateException("비활성화된 채팅방에는 메시지를 보낼 수 없습니다."); + } ChatMember sender = chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus(request.getRoomId(), userId, ChatMemberStatus.ACTIVATE) .orElseThrow(()-> new IllegalArgumentException("참여중인 채팅방이 아닙니다.")); User user = userRepository.findById(userId) @@ -118,7 +122,12 @@ private void sendNotificationWithSSEOrPush(ChatSendRequest request, List myRooms = chatMemberRepository.findAllByUser_UserIdAndStatus(userId, ChatMemberStatus.ACTIVATE); + List myRooms = chatMemberRepository + .findAllByUser_UserIdAndStatusAndRoom_ChatStatus( + userId, + ChatMemberStatus.ACTIVATE, + ChatStatus.ENABLED + ); boolean hasUnreadMessages = false; boolean hasNewRoom = false; @@ -144,7 +153,12 @@ public ChatInitialSyncResponse getInitialSync(Long userId) { */ @Transactional(readOnly = true) public List getChatRoomListSync(Long userId) { - List myRooms = chatMemberRepository.findAllByUser_UserIdAndStatus(userId, ChatMemberStatus.ACTIVATE); + List myRooms = chatMemberRepository + .findAllByUser_UserIdAndStatusAndRoom_ChatStatus( + userId, + ChatMemberStatus.ACTIVATE, + ChatStatus.ENABLED + ); return myRooms.stream().map(m -> { ChatRoom room = m.getRoom(); diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java index c85a0e8..7bae30f 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java @@ -337,6 +337,7 @@ else if(match.getMatchingStatus().equals(MatchingStatus.FAILED)) { private MatchedChatRoomInfo handleMatchSuccess(MeetingMatch match, boolean isByTimeout){ MatchedChatRoomInfo info = chatRoomService.createMatchingChatRoom(match); + chatRoomService.disableMeetingGroupChatRooms(match); eventPublisher.publishEvent(new MatchSuccessEvent(match.getId(),info.getRoomId(),isByTimeout)); return info; } @@ -365,6 +366,7 @@ private boolean isMetro(MeetingMatchingEvent event){ return METRO_CODES.contains(event.getSidoCode()); } + @Transactional public void handleMatchMemberLeave(Long matchId, Long userId){ MeetingMatch match = meetingMatchRepository.findById(matchId) .orElseThrow(()-> new IllegalArgumentException("존재하지 않는 미팅 매칭 아이디입니다.")); @@ -387,6 +389,7 @@ else if (match.getMeeting2().getId().equals(userMeetingId)){ } meetingService.handleMemberLeave(myMeeting.getId(), userId); + chatRoomService.deactivateMeetingGroupMember(myMeeting, userId); if((myMeeting.getCurrentMembers()+ opponent.getCurrentMembers())*2< myMeeting.getMaxMembers()+ opponent.getMaxMembers()){ // 다 환불 } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMemberRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMemberRepository.java index d0ed7cc..de56255 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; @@ -20,6 +21,12 @@ public interface ChatMemberRepository extends JpaRepository { 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 " + From 6b024497fdea7a243316e0dea21f741c04ac146a Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 14:16:00 +0900 Subject: [PATCH 08/30] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=20=EB=B9=A0?= =?UTF-8?q?=EB=A5=B8=20=EC=9E=85=EC=9E=A5=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=A4=EC=B9=AD=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=EC=97=90=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/service/MeetingService.java | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) 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 b4b21a2..5acadf1 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 @@ -183,10 +183,12 @@ public MeetingRoomCreateDataDto enterRoomByCode(MeetingRoomJoinByCodeRequest req 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); + joinMatchingChatRoomIfFastEntry(meeting, user, isFastMatchingEntry); return buildResponseForEnter(meeting, chatRoomId); @@ -209,19 +211,28 @@ public MeetingRoomCreateDataDto enterRoomById(Long meetingId, Long userId) { meeting.addMember(profile.computeAge()); meetingMemberService.addMember(meeting, user); Long chatRoomId = chatRoomService.joinChatRoom(meeting, user); + joinMatchingChatRoomIfFastEntry(meeting, user, isFastMatchingEntry); - if (isFastMatchingEntry) { - var match = meetingMatchRepository.findByMeetingIdAndStatus( - meetingId, - MatchingStatus.SUCCEEDED - ) - .orElseThrow(() -> new IllegalStateException( - "빠른 입장 미팅과 연결된 성사된 매칭을 찾을 수 없습니다." - )); - chatRoomService.joinMatchingChatRoom(match, user); + return buildResponseForEnter(meeting, 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); } From 3a1afa56895211ab3594e3321d02f2b8587956b4 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 14:45:52 +0900 Subject: [PATCH 09/30] =?UTF-8?q?fix:=20=EB=A7=A4=EC=B9=AD=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=97=90=EC=84=9C=20=EB=82=A8=EB=85=80=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20ID=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/ChatRoomService.java | 8 ++++ .../meeting/service/MeetingService.java | 40 ++++++++++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) 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 e9edac4..7c49269 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 @@ -188,6 +188,14 @@ public Long getChatRoomId(Meeting meeting){ 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("채팅방 나가기: 존재하지 않는 채팅방아이디 입니다.")); 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 5acadf1..3b546a4 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingService.java @@ -188,9 +188,16 @@ public MeetingRoomCreateDataDto enterRoomByCode(MeetingRoomJoinByCodeRequest req meetingRepository.saveAndFlush(meeting); /*왜 addMember가 반영이 안되지*/ meetingMemberService.addMember(meeting, user); Long chatRoomId = chatRoomService.joinChatRoom(meeting, user); - joinMatchingChatRoomIfFastEntry(meeting, user, isFastMatchingEntry); + Long matchingChatRoomId = joinMatchingChatRoomIfFastEntry( + meeting, + user, + isFastMatchingEntry + ); - return buildResponseForEnter(meeting, chatRoomId); + return buildResponseForEnter( + meeting, + matchingChatRoomId != null ? matchingChatRoomId : chatRoomId + ); } @@ -211,9 +218,16 @@ public MeetingRoomCreateDataDto enterRoomById(Long meetingId, Long userId) { meeting.addMember(profile.computeAge()); meetingMemberService.addMember(meeting, user); Long chatRoomId = chatRoomService.joinChatRoom(meeting, user); - joinMatchingChatRoomIfFastEntry(meeting, user, isFastMatchingEntry); + Long matchingChatRoomId = joinMatchingChatRoomIfFastEntry( + meeting, + user, + isFastMatchingEntry + ); - return buildResponseForEnter(meeting, chatRoomId); + return buildResponseForEnter( + meeting, + matchingChatRoomId != null ? matchingChatRoomId : chatRoomId + ); } private Long joinMatchingChatRoomIfFastEntry( @@ -276,11 +290,27 @@ public MyMeetingStatusDataDto checkUserMeetingStatus(Long userId) { } else { MeetingMember meetingMember = mm.get(); Meeting meeting = meetingMember.getMeeting(); - Long chatRoomId = chatRoomService.getChatRoomId(meeting); + Long chatRoomId = resolveCurrentChatRoomId(meeting); return buildResponseDtoForCheck(meeting, chatRoomId, meetingMember.getMeetingRole() == MeetingRole.LEADER); } } + private Long resolveCurrentChatRoomId(Meeting meeting) { + if (meeting.getMeetingStatus() != MeetingStatus.MATCHED + && meeting.getMeetingStatus() != MeetingStatus.FASTMATCHING) { + return chatRoomService.getChatRoomId(meeting); + } + + var match = meetingMatchRepository.findByMeetingIdAndStatus( + meeting.getId(), + MatchingStatus.SUCCEEDED + ) + .orElseThrow(() -> new IllegalStateException( + "매칭된 미팅과 연결된 성사된 매칭을 찾을 수 없습니다." + )); + return chatRoomService.getMatchingChatRoomId(match); + } + /*팀원 프로필 상세 조회*/ public TeamMemberProfilesDto getTeamMemberProfilesDetail(Long meetingId) { From 2dfcd211ffc705c1346ad853d143aba40da10dfa Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 15:19:43 +0900 Subject: [PATCH 10/30] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EB=AF=B8?= =?UTF-8?q?=ED=8C=85=20=EC=B7=A8=EC=86=8C=EB=A5=BC=20=EB=A7=A4=EC=B9=AD=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/MeetingCancellationResponse.java | 4 +- .../service/MeetingCancellationService.java | 104 ++++++++++----- .../entity/MeetingCancellationRequest.java | 8 +- .../MeetingCancellationRequestRepository.java | 9 +- .../meeting/controller/MeetingController.java | 12 +- ...22__move_meeting_cancellation_to_match.sql | 25 ++++ .../MeetingCancellationServiceTest.java | 118 +++++++++++++++++- .../MeetingCancellationDomainTest.java | 6 +- 8 files changed, 235 insertions(+), 51 deletions(-) create mode 100644 manabom/src/main/resources/db/migration/V22__move_meeting_cancellation_to_match.sql 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 index c8d2e9f..2da5efa 100644 --- 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 @@ -14,7 +14,7 @@ @Builder public class MeetingCancellationResponse { private Long requestId; - private Long meetingId; + private Long matchId; private Long initiatorUserId; private MeetingCancellationStatus status; private Instant requestedAt; @@ -38,7 +38,7 @@ public static MeetingCancellationResponse of( return MeetingCancellationResponse.builder() .requestId(request.getId()) - .meetingId(request.getMeeting().getId()) + .matchId(request.getMeetingMatch().getId()) .initiatorUserId(request.getInitiator().getUserId()) .status(request.getStatus()) .requestedAt(request.getRequestedAt()) 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 index 5053a0d..7189d59 100644 --- 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 @@ -2,19 +2,23 @@ import lombok.RequiredArgsConstructor; 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.enums.MeetingStatus; 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.meeting.repository.MeetingRepository; import mannabom_server.manabom.domain.user.entity.User; import mannabom_server.manabom.domain.user.repository.UserRepository; import org.springframework.stereotype.Service; @@ -22,6 +26,7 @@ import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; @Service @@ -30,17 +35,26 @@ public class MeetingCancellationService { private static final Duration CANCELLATION_DEADLINE = Duration.ofHours(24); - private final MeetingRepository meetingRepository; + 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; @Transactional(readOnly = true) public void validateNoPendingCancellation(Long meetingId) { - if (requestRepository.existsByMeeting_IdAndStatus( + var match = meetingMatchRepository.findByMeetingIdAndStatus( meetingId, + MatchingStatus.SUCCEEDED + ); + if (match.isEmpty()) { + return; + } + + if (requestRepository.existsByMeetingMatch_IdAndStatus( + match.get().getId(), MeetingCancellationStatus.PENDING )) { throw new IllegalStateException( @@ -50,24 +64,25 @@ public void validateNoPendingCancellation(Long meetingId) { } @Transactional - public MeetingCancellationResponse create(Long meetingId, Long userId) { - Meeting meeting = meetingRepository.findByIdWithLock(meetingId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅방입니다.")); - validateActiveMember(meetingId, userId); + public MeetingCancellationResponse create(Long matchId, Long userId) { + MeetingMatch match = meetingMatchRepository.findByIdWithLockAndMeeting(matchId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅 매칭입니다.")); - if (meeting.getMeetingStatus() == MeetingStatus.CANCELLED) { - throw new IllegalStateException("이미 취소된 미팅입니다."); + if (match.getMatchingStatus() != MatchingStatus.SUCCEEDED) { + throw new IllegalStateException("성사된 미팅만 전체 취소를 요청할 수 있습니다."); } - if (requestRepository.existsByMeeting_IdAndStatus( - meetingId, + validateActiveParticipant(match, userId); + + if (requestRepository.existsByMeetingMatch_IdAndStatus( + matchId, MeetingCancellationStatus.PENDING )) { throw new IllegalStateException("이미 진행 중인 미팅 취소 요청이 있습니다."); } - List members = activeMembers(meetingId); + List members = activeMembers(match); if (members.size() < 2) { - throw new IllegalStateException("전체 취소 투표를 진행할 팀원이 없습니다."); + throw new IllegalStateException("전체 취소 투표를 진행할 참여자가 부족합니다."); } User initiator = userRepository.findById(userId) @@ -75,7 +90,7 @@ public MeetingCancellationResponse create(Long meetingId, Long userId) { Instant now = Instant.now(); MeetingCancellationRequest request = requestRepository.save( MeetingCancellationRequest.create( - meeting, + match, initiator, now, now.plus(CANCELLATION_DEADLINE) @@ -126,10 +141,12 @@ public MeetingCancellationResponse vote( } @Transactional - public MeetingCancellationResponse getCurrent(Long meetingId, Long userId) { - validateActiveMember(meetingId, userId); + public MeetingCancellationResponse getCurrent(Long matchId, Long userId) { + MeetingMatch match = meetingMatchRepository.findByIdWithMeeting(matchId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅 매칭입니다.")); + validateActiveParticipant(match, userId); MeetingCancellationRequest request = requestRepository - .findByMeeting_IdAndStatus(meetingId, MeetingCancellationStatus.PENDING) + .findByMeetingMatch_IdAndStatus(matchId, MeetingCancellationStatus.PENDING) .orElseThrow(() -> new IllegalArgumentException("진행 중인 미팅 취소 요청이 없습니다.")); expireIfNecessary(request, Instant.now()); return response(request); @@ -152,12 +169,20 @@ private void approveCancellation( Instant now ) { request.approve(now); - Meeting meeting = request.getMeeting(); - - chatRoomRepository.findByMeeting(meeting) - .ifPresent(chatRoom -> chatRoom.deactivate()); - activeMembers(meeting.getId()).forEach(MeetingMember::deactivate); - meeting.cancelByAgreement(); + MeetingMatch match = request.getMeetingMatch(); + Meeting meeting1 = match.getMeeting1(); + Meeting meeting2 = match.getMeeting2(); + + chatRoomRepository.findByMatch(match) + .ifPresent(this::deactivateChatRoomAndMembers); + chatRoomRepository.findByMeeting(meeting1) + .ifPresent(this::deactivateChatRoomAndMembers); + chatRoomRepository.findByMeeting(meeting2) + .ifPresent(this::deactivateChatRoomAndMembers); + + activeMembers(match).forEach(MeetingMember::deactivate); + meeting1.cancelByAgreement(); + meeting2.cancelByAgreement(); } private void expireIfNecessary( @@ -186,6 +211,13 @@ private MeetingCancellationResponse response(MeetingCancellationRequest request) ); } + 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, @@ -193,13 +225,27 @@ private List activeMembers(Long meetingId) { ); } - private void validateActiveMember(Long meetingId, Long userId) { - if (!meetingMemberRepository.existsByMeeting_IdAndUser_UserIdAndStatus( + 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 - )) { - throw new IllegalArgumentException("해당 미팅방의 참여자가 아닙니다."); - } + ); + } + + 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/domain/meeting/entity/MeetingCancellationRequest.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingCancellationRequest.java index 52b8777..ee74c17 100644 --- 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 @@ -20,8 +20,8 @@ public class MeetingCancellationRequest { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "meeting_id", nullable = false) - private Meeting meeting; + @JoinColumn(name = "meeting_match_id") + private MeetingMatch meetingMatch; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "initiator_user_id", nullable = false) @@ -43,13 +43,13 @@ public class MeetingCancellationRequest { private Long version; public static MeetingCancellationRequest create( - Meeting meeting, + MeetingMatch meetingMatch, User initiator, Instant requestedAt, Instant expiresAt ) { MeetingCancellationRequest request = new MeetingCancellationRequest(); - request.meeting = meeting; + request.meetingMatch = meetingMatch; request.initiator = initiator; request.status = MeetingCancellationStatus.PENDING; request.requestedAt = requestedAt; 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 index ab142f3..f775e78 100644 --- 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 @@ -15,10 +15,13 @@ public interface MeetingCancellationRequestRepository extends JpaRepository { - boolean existsByMeeting_IdAndStatus(Long meetingId, MeetingCancellationStatus status); + boolean existsByMeetingMatch_IdAndStatus( + Long meetingMatchId, + MeetingCancellationStatus status + ); - Optional findByMeeting_IdAndStatus( - Long meetingId, + Optional findByMeetingMatch_IdAndStatus( + Long meetingMatchId, MeetingCancellationStatus status ); 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 f387d5d..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 @@ -55,12 +55,12 @@ public ResponseEntity> getMyMeeting(@Authent return ResponseEntity.ok(ApiResponse.success(dto, "내 미팅 상태 조회 성공")); } - @PostMapping("/rooms/{meetingId}/cancellation-requests") + @PostMapping("/matches/{matchId}/cancellation-requests") public ResponseEntity> createCancellationRequest( - @PathVariable Long meetingId, + @PathVariable Long matchId, @AuthenticationPrincipal Long userId ) { - MeetingCancellationResponse response = meetingCancellationService.create(meetingId, userId); + MeetingCancellationResponse response = meetingCancellationService.create(matchId, userId); return ResponseEntity.ok( ApiResponse.success(response, "미팅 전체 취소 요청을 시작했습니다.") ); @@ -82,12 +82,12 @@ public ResponseEntity> voteCancellation ); } - @GetMapping("/rooms/{meetingId}/cancellation-requests/current") + @GetMapping("/matches/{matchId}/cancellation-requests/current") public ResponseEntity> getCurrentCancellationRequest( - @PathVariable Long meetingId, + @PathVariable Long matchId, @AuthenticationPrincipal Long userId ) { - MeetingCancellationResponse response = meetingCancellationService.getCurrent(meetingId, userId); + MeetingCancellationResponse response = meetingCancellationService.getCurrent(matchId, userId); return ResponseEntity.ok( ApiResponse.success(response, "진행 중인 미팅 전체 취소 요청을 조회했습니다.") ); 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/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java index 65ce525..ce5f580 100644 --- 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 @@ -1,12 +1,22 @@ 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.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.MeetingRepository; +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 org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -16,12 +26,19 @@ import static org.assertj.core.api.Assertions.assertThatCode; 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.ArgumentMatchers.any; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; @ExtendWith(MockitoExtension.class) class MeetingCancellationServiceTest { @Mock - private MeetingRepository meetingRepository; + private MeetingMatchRepository meetingMatchRepository; @Mock private MeetingMemberRepository meetingMemberRepository; @Mock @@ -32,14 +49,22 @@ class MeetingCancellationServiceTest { private UserRepository userRepository; @Mock private ChatRoomRepository chatRoomRepository; + @Mock + private ChatMemberRepository chatMemberRepository; @InjectMocks private MeetingCancellationService meetingCancellationService; @Test void blocksMembershipChangesWhileCancellationVoteIsPending() { - when(requestRepository.existsByMeeting_IdAndStatus( + 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); @@ -51,13 +76,94 @@ void blocksMembershipChangesWhileCancellationVoteIsPending() { @Test void allowsMembershipChangesWithoutPendingCancellationVote() { - when(requestRepository.existsByMeeting_IdAndStatus( + when(meetingMatchRepository.findByMeetingIdAndStatus( 10L, - MeetingCancellationStatus.PENDING - )).thenReturn(false); + MatchingStatus.SUCCEEDED + )).thenReturn(Optional.empty()); assertThatCode(() -> meetingCancellationService.validateNoPendingCancellation(10L) ).doesNotThrowAnyException(); } + + @Test + void createsVotesForMembersOfBothMatchedTeams() { + Meeting meeting1 = mock(Meeting.class); + Meeting meeting2 = mock(Meeting.class); + MeetingMatch match = mock(MeetingMatch.class); + User initiator = user(1L); + + when(match.getId()).thenReturn(20L); + when(match.getMeeting1()).thenReturn(meeting1); + when(match.getMeeting2()).thenReturn(meeting2); + when(match.getMatchingStatus()).thenReturn(MatchingStatus.SUCCEEDED); + when(meeting1.getId()).thenReturn(10L); + when(meeting2.getId()).thenReturn(11L); + when(meetingMatchRepository.findByIdWithLockAndMeeting(20L)) + .thenReturn(Optional.of(match)); + when(meetingMemberRepository.existsByMeeting_IdAndUser_UserIdAndStatus( + 10L, 1L, ChatUserStatus.ACTIVE + )).thenReturn(true); + when(meetingMemberRepository.findByMeetingIdAndStatus(10L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of(member(meeting1, initiator), member(meeting1, user(2L)), member(meeting1, user(3L)))); + when(meetingMemberRepository.findByMeetingIdAndStatus(11L, ChatUserStatus.ACTIVE)) + .thenReturn(List.of(member(meeting2, user(4L)), member(meeting2, user(5L)), member(meeting2, user(6L)))); + when(userRepository.findById(1L)).thenReturn(Optional.of(initiator)); + when(requestRepository.save(any(MeetingCancellationRequest.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var response = meetingCancellationService.create(20L, 1L); + + assertThatCode(() -> response.getVotes()).doesNotThrowAnyException(); + org.assertj.core.api.Assertions.assertThat(response.getMatchId()).isEqualTo(20L); + org.assertj.core.api.Assertions.assertThat(response.getTotalMemberCount()).isEqualTo(6); + org.assertj.core.api.Assertions.assertThat(response.getAgreedMemberCount()).isEqualTo(1); + org.assertj.core.api.Assertions.assertThat(response.getPendingMemberCount()).isEqualTo(5); + } + + @Test + void unanimousApprovalCancelsBothMatchedTeams() { + Meeting meeting1 = mock(Meeting.class); + Meeting meeting2 = mock(Meeting.class); + MeetingMatch match = mock(MeetingMatch.class); + User voter = user(2L); + Instant now = Instant.now(); + MeetingCancellationRequest request = MeetingCancellationRequest.create( + match, + user(1L), + now, + now.plusSeconds(3600) + ); + 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()); + + meetingCancellationService.vote(30L, 2L, CancellationVoteDecision.AGREE); + + verify(meeting1).cancelByAgreement(); + verify(meeting2).cancelByAgreement(); + } + + private MeetingMember member(Meeting meeting, User user) { + return MeetingMember.addMember(meeting, user); + } + + private User user(Long id) { + return User.builder() + .userId(id) + .kakaoId("mock_" + id) + .userName("user" + id) + .build(); + } } diff --git a/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingCancellationDomainTest.java b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingCancellationDomainTest.java index cc62845..fb8e4d6 100644 --- a/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingCancellationDomainTest.java +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingCancellationDomainTest.java @@ -3,6 +3,7 @@ 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; @@ -77,7 +78,10 @@ void approvedRequestCannotBeCompletedAgain() { private MeetingCancellationRequest cancellationRequest(User initiator, Instant now) { return MeetingCancellationRequest.create( - Meeting.builder().id(10L).build(), + MeetingMatch.builder() + .meeting1(Meeting.builder().id(10L).build()) + .meeting2(Meeting.builder().id(11L).build()) + .build(), initiator, now, now.plus(Duration.ofHours(24)) From 13c910d4fbda8160c0925039936d94edfa7fbc2c Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 15:39:36 +0900 Subject: [PATCH 11/30] =?UTF-8?q?fix:=20=EB=B9=84=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/repository/ChatMemberRepository.java | 7 +++++++ .../StompAuthChannelInterceptor.java | 20 +++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) 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 de56255..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 @@ -17,6 +17,13 @@ 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); 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)); From 3a90afa288f85bfbeb10ca284363846dd68473f7 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 20:07:20 +0900 Subject: [PATCH 12/30] =?UTF-8?q?fix:=20=EB=AF=B8=ED=8C=85=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=83=81=ED=83=9C=20=EC=A0=9C=EC=95=BD=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V23__remove_withdrawn_cancellation_status.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 manabom/src/main/resources/db/migration/V23__remove_withdrawn_cancellation_status.sql 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' + )); From 28521bc5dea0de594a528360cf5b45b431224038 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 20:07:26 +0900 Subject: [PATCH 13/30] =?UTF-8?q?fix:=20=EB=A7=A4=EC=B9=AD=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20=EC=9E=AC=EC=9E=85=EC=9E=A5=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/ChatRoomService.java | 5 +- .../V24__add_unique_active_chat_member.sql | 3 + .../chat/service/ChatRoomServiceTest.java | 94 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatRoomServiceTest.java 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 7c49269..100491a 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatRoomService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatRoomService.java @@ -143,9 +143,10 @@ public Long joinMatchingChatRoom(MeetingMatch match, User user) { "매칭 정보와 연결된 남녀 채팅방이 존재하지 않습니다." )); - if (chatMemberRepository.existsByRoomIdAndUser_UserId( + if (chatMemberRepository.existsByRoomIdAndUser_UserIdAndStatus( room.getId(), - user.getUserId() + user.getUserId(), + ChatMemberStatus.ACTIVATE )) { throw new IllegalStateException("이미 해당 매칭 채팅방에 참여한 사용자입니다."); } 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..4c4d39d --- /dev/null +++ b/manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX uk_chat_members_active_room_user + ON chat_members (room_id, user_id) + WHERE status = 'ACTIVATE'; diff --git a/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatRoomServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatRoomServiceTest.java new file mode 100644 index 0000000..704699c --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatRoomServiceTest.java @@ -0,0 +1,94 @@ +package mannabom_server.manabom.application.chat.service; + +import mannabom_server.manabom.domain.chat.entity.ChatMember; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.repository.ChatMemberQueryRepository; +import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; +import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; +import mannabom_server.manabom.application.meeting.service.MeetingMemberService; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.domain.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +class ChatRoomServiceTest { + + @Mock private ChatRoomRepository chatRoomRepository; + @Mock private ChatMemberRepository chatMemberRepository; + @Mock private ChatMemberQueryRepository chatMemberQueryRepository; + @Mock private UserRepository userRepository; + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private MeetingMemberService meetingMemberService; + @Mock private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private ChatRoomService chatRoomService; + + @Test + void allowsReentryWhenPreviousMembershipIsDeactivated() { + MeetingMatch match = mock(MeetingMatch.class); + User user = user(1L); + ChatRoom room = ChatRoom.builder().id(77L).match(match).build(); + when(chatRoomRepository.findByMatch(match)).thenReturn(Optional.of(room)); + when(chatMemberRepository.existsByRoomIdAndUser_UserIdAndStatus( + 77L, + 1L, + ChatMemberStatus.ACTIVATE + )).thenReturn(false); + + Long roomId = chatRoomService.joinMatchingChatRoom(match, user); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMember.class); + verify(chatMemberRepository).save(captor.capture()); + assertThat(roomId).isEqualTo(77L); + assertThat(captor.getValue().getRoom()).isSameAs(room); + assertThat(captor.getValue().getUser()).isSameAs(user); + assertThat(captor.getValue().getStatus()).isEqualTo(ChatMemberStatus.ACTIVATE); + } + + @Test + void rejectsEntryWhenActiveMembershipAlreadyExists() { + MeetingMatch match = mock(MeetingMatch.class); + User user = user(1L); + ChatRoom room = ChatRoom.builder().id(77L).match(match).build(); + when(chatRoomRepository.findByMatch(match)).thenReturn(Optional.of(room)); + when(chatMemberRepository.existsByRoomIdAndUser_UserIdAndStatus( + 77L, + 1L, + ChatMemberStatus.ACTIVATE + )).thenReturn(true); + + assertThatThrownBy(() -> chatRoomService.joinMatchingChatRoom(match, user)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("이미 해당 매칭 채팅방"); + + verify(chatMemberRepository, never()).save(any()); + } + + private User user(Long id) { + return User.builder() + .userId(id) + .kakaoId("mock_" + id) + .userName("user" + id) + .build(); + } +} From fa623128844195833a64f521cdfecf591ac00bf2 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 20:07:34 +0900 Subject: [PATCH 14/30] =?UTF-8?q?fix:=20=EB=AF=B8=ED=8C=85=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EB=A7=8C=EB=A3=8C=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EA=B1=B4=EB=B3=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MeetingCancellationExpirationService.java | 30 ++++++++ .../service/MeetingCancellationService.java | 22 ++++-- .../MeetingCancellationRequestRepository.java | 12 ++- ...tingCancellationExpirationServiceTest.java | 76 +++++++++++++++++++ .../MeetingCancellationServiceTest.java | 22 ++++++ 5 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationService.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationService.java new file mode 100644 index 0000000..dd40be7 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationService.java @@ -0,0 +1,30 @@ +package mannabom_server.manabom.application.meeting.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationRequestRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Service +@RequiredArgsConstructor +public class MeetingCancellationExpirationService { + + private final MeetingCancellationRequestRepository requestRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean expire(Long requestId, Instant now) { + MeetingCancellationRequest request = requestRepository.findByIdForUpdate(requestId) + .orElse(null); + + if (request == null || !request.isExpiredAt(now)) { + return false; + } + + request.expire(now); + return true; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java index 7189d59..78dc7af 100644 --- 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 @@ -1,6 +1,7 @@ package mannabom_server.manabom.application.meeting.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import mannabom_server.manabom.application.meeting.dto.response.MeetingCancellationResponse; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; @@ -31,6 +32,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class MeetingCancellationService { private static final Duration CANCELLATION_DEADLINE = Duration.ofHours(24); @@ -42,6 +44,7 @@ public class MeetingCancellationService { private final UserRepository userRepository; private final ChatRoomRepository chatRoomRepository; private final ChatMemberRepository chatMemberRepository; + private final MeetingCancellationExpirationService expirationService; @Transactional(readOnly = true) public void validateNoPendingCancellation(Long meetingId) { @@ -152,16 +155,25 @@ public MeetingCancellationResponse getCurrent(Long matchId, Long userId) { return response(request); } - @Transactional public int expirePendingRequests() { Instant now = Instant.now(); - List expiredRequests = requestRepository - .findAllByStatusAndExpiresAtLessThanEqual( + List expiredRequestIds = requestRepository + .findExpiredRequestIds( MeetingCancellationStatus.PENDING, now ); - expiredRequests.forEach(request -> request.expire(now)); - return expiredRequests.size(); + + 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( 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 index f775e78..80908f1 100644 --- 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 @@ -31,8 +31,14 @@ Optional findByIdForUpdate( @Param("requestId") Long requestId ); - List findAllByStatusAndExpiresAtLessThanEqual( - MeetingCancellationStatus status, - Instant now + @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/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java new file mode 100644 index 0000000..854c3e3 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java @@ -0,0 +1,76 @@ +package mannabom_server.manabom.application.meeting.service; + +import mannabom_server.manabom.domain.meeting.entity.Meeting; +import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; +import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; +import mannabom_server.manabom.domain.meeting.enums.MeetingCancellationStatus; +import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationRequestRepository; +import mannabom_server.manabom.domain.user.entity.User; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MeetingCancellationExpirationServiceTest { + + @Mock + private MeetingCancellationRequestRepository requestRepository; + + @InjectMocks + private MeetingCancellationExpirationService expirationService; + + @Test + void expiresPendingRequestInItsOwnProcessingStep() { + Instant requestedAt = Instant.parse("2026-07-20T00:00:00Z"); + Instant now = requestedAt.plus(Duration.ofHours(25)); + MeetingCancellationRequest request = cancellationRequest(requestedAt); + when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + + boolean expired = expirationService.expire(30L, now); + + assertThat(expired).isTrue(); + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.EXPIRED); + assertThat(request.getCompletedAt()).isEqualTo(now); + } + + @Test + void skipsRequestThatWasCompletedBeforeLockAcquisition() { + Instant requestedAt = Instant.parse("2026-07-20T00:00:00Z"); + Instant now = requestedAt.plus(Duration.ofHours(25)); + MeetingCancellationRequest request = cancellationRequest(requestedAt); + request.reject(now.minusSeconds(1)); + when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + + boolean expired = expirationService.expire(30L, now); + + assertThat(expired).isFalse(); + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.REJECTED); + } + + private MeetingCancellationRequest cancellationRequest(Instant requestedAt) { + MeetingMatch match = MeetingMatch.builder() + .meeting1(Meeting.builder().id(10L).build()) + .meeting2(Meeting.builder().id(11L).build()) + .build(); + User initiator = User.builder() + .userId(1L) + .kakaoId("mock_1") + .userName("user1") + .build(); + return MeetingCancellationRequest.create( + match, + initiator, + requestedAt, + requestedAt.plus(Duration.ofHours(24)) + ); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java index ce5f580..10ed9ce 100644 --- 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 @@ -24,11 +24,13 @@ import org.mockito.junit.jupiter.MockitoExtension; 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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import java.time.Instant; import java.util.List; @@ -51,6 +53,8 @@ class MeetingCancellationServiceTest { private ChatRoomRepository chatRoomRepository; @Mock private ChatMemberRepository chatMemberRepository; + @Mock + private MeetingCancellationExpirationService expirationService; @InjectMocks private MeetingCancellationService meetingCancellationService; @@ -155,6 +159,24 @@ void unanimousApprovalCancelsBothMatchedTeams() { verify(meeting2).cancelByAgreement(); } + @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); } From 75d4ac11e7c23c41f6f2c65ba07b24b48a62e2a5 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 22 Jul 2026 02:19:44 +0900 Subject: [PATCH 15/30] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/event/ChatSystemMessageEvent.java | 7 + .../dto/response/ChatMessageResponse.java | 2 +- .../ChatSystemMessageEventHandler.java | 65 ++++++++ .../chat/message/SystemMessageContent.java | 26 +++ .../chat/service/ChatRoomService.java | 12 +- .../application/chat/service/ChatService.java | 5 + .../chat/service/SystemMessageService.java | 117 ++++++++++++++ .../matching/service/PhotoRequestService.java | 24 ++- .../domain/chat/entity/ChatMessage.java | 9 +- .../repository/ChatMessageRepository.java | 8 +- .../chat/repository/ChatRoomRepository.java | 2 + .../dto/response/ChatMessageResponseTest.java | 24 +++ .../ChatSystemMessageEventHandlerTest.java | 87 ++++++++++ .../chat/service/ChatServiceTest.java | 55 +++++++ .../service/SystemMessageServiceTest.java | 128 +++++++++++++++ .../service/PhotoRequestServiceTest.java | 153 ++++++++++++++++++ 16 files changed, 708 insertions(+), 16 deletions(-) create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/chat/dto/event/ChatSystemMessageEvent.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandler.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/chat/service/SystemMessageService.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponseTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandlerTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatServiceTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/chat/service/SystemMessageServiceTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/matching/service/PhotoRequestServiceTest.java 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..a6bd831 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/event/ChatSystemMessageEvent.java @@ -0,0 +1,7 @@ +package mannabom_server.manabom.application.chat.dto.event; + +public record ChatSystemMessageEvent( + Long roomId, + String content +) { +} 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..1b63b93 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 @@ -21,7 +21,7 @@ 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()) .build(); } 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..d927f51 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandler.java @@ -0,0 +1,65 @@ +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.dto.response.ChatMessageEvent; +import mannabom_server.manabom.application.chat.service.SystemMessageService; +import mannabom_server.manabom.application.meeting.dto.request.MeetingMatchingEvent; +import mannabom_server.manabom.application.notification.dto.MatchFailureEvent; +import mannabom_server.manabom.application.notification.dto.MatchFoundEvent; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +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; + + @Order(Ordered.HIGHEST_PRECEDENCE) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMatchingStarted(MeetingMatchingEvent event) { + try { + ChatMessageEvent message = systemMessageService.recordMatchingStarted(event.getMeetingId()); + systemMessageService.broadcast(message); + } catch (RuntimeException e) { + log.error("매칭 시작 시스템 메시지 처리 실패: meetingId={}", event.getMeetingId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMatchFound(MatchFoundEvent event) { + try { + systemMessageService.broadcast(systemMessageService.recordMatchFound(event.matchId())); + } catch (RuntimeException e) { + log.error("매칭 발견 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMatchFailure(MatchFailureEvent event) { + try { + systemMessageService.broadcast(systemMessageService.recordMatchFailure( + event.matchId(), + Boolean.TRUE.equals(event.isByTimeout()) + )); + } catch (RuntimeException e) { + log.error("매칭 실패 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleRoomSystemMessage(ChatSystemMessageEvent event) { + try { + ChatMessageEvent message = systemMessageService.recordForRoom(event.roomId(), event.content()); + systemMessageService.broadcast(message); + } catch (RuntimeException e) { + log.error("채팅방 시스템 메시지 처리 실패: roomId={}", event.roomId(), e); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java new file mode 100644 index 0000000..aeedc74 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java @@ -0,0 +1,26 @@ +package mannabom_server.manabom.application.chat.message; + +public final class SystemMessageContent { + + public static final String MATCHING_STARTED = + "팀장님이 매칭을 시작했어요. 상대 팀을 찾고 있어요."; + public static final String MATCH_FOUND = + "미팅할 상대 팀을 찾았어요. 팀장님의 결정을 기다리고 있어요."; + public static final String MATCH_REJECTED_BY_LEADER = + "팀장님이 매칭을 거절했어요. 새로운 상대 팀을 찾고 있어요."; + public static final String MATCH_REJECTED_BY_OPPONENT = + "상대 팀이 매칭을 거절했어요. 새로운 상대 팀을 찾고 있어요."; + public static final String MATCH_TIMED_OUT = + "응답 시간이 지나 매칭이 자동으로 거절됐어요."; + public static final String OPPONENT_MATCH_TIMED_OUT = + "상대 팀의 응답 시간이 지나 매칭이 취소됐어요."; + public static final String PHOTO_REQUESTED = + "프로필 사진 공개 요청이 전송되었어요."; + public static final String PHOTO_REQUEST_ACCEPTED = + "프로필 사진 공개 요청이 수락되었어요."; + public static final String PHOTO_REQUEST_REJECTED = + "프로필 사진 공개 요청이 거절되었어요."; + + private SystemMessageContent() { + } +} 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..63f0c33 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 @@ -12,7 +12,6 @@ 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.ChatMemberQueryRepository; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; @@ -104,13 +103,10 @@ public Long createLoveViewChatRoom(LoveViewRecommendHistory loveViewHistory){ return chatRoom.getId(); } private void sendSystemWelcomeMessage(ChatRoom room ){ - ChatMessage welcomeMessage = ChatMessage.builder() - .room(room) - .type(ChatMessageType.SYSTEM) - .content("🎉 매칭이 성사되었습니다! 서로 인사를 나눠보세요.") - .user(null) - .build(); - chatMessageRepository.save(welcomeMessage); + chatMessageRepository.save(ChatMessage.system( + room, + "🎉 매칭이 성사되었습니다! 서로 인사를 나눠보세요." + )); } private void setOneToOneChatMember(ChatRoom chatRoom, Long user1Id, Long user2Id){ 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..a63ec3f 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java @@ -10,6 +10,7 @@ import mannabom_server.manabom.domain.chat.entity.ChatMessage; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.enums.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; @@ -52,6 +53,10 @@ 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("존재하지 않는 채팅방입니다.")); ChatMember sender = chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus(request.getRoomId(), userId, ChatMemberStatus.ACTIVATE) 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..3d716ca --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/SystemMessageService.java @@ -0,0 +1,117 @@ +package mannabom_server.manabom.application.chat.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.chat.dto.response.ChatMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageContent; +import mannabom_server.manabom.domain.chat.entity.ChatMessage; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +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 org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SystemMessageService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + private final MeetingMatchRepository meetingMatchRepository; + private final SimpMessagingTemplate messagingTemplate; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ChatMessageEvent recordForRoom(Long roomId, String content) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 채팅방입니다.")); + return save(room, content); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ChatMessageEvent recordMatchingStarted(Long meetingId) { + return saveForMeeting(meetingId, SystemMessageContent.MATCHING_STARTED); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List recordMatchFound(Long matchId) { + MeetingMatch match = findMatch(matchId); + return List.of( + saveForMeeting(match.getMeeting1().getId(), SystemMessageContent.MATCH_FOUND), + saveForMeeting(match.getMeeting2().getId(), SystemMessageContent.MATCH_FOUND) + ); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List recordMatchFailure(Long matchId, boolean isByTimeout) { + MeetingMatch match = findMatch(matchId); + Meeting failedMeeting = findFailedMeeting(match, isByTimeout); + Meeting opponentMeeting = failedMeeting.getId().equals(match.getMeeting1().getId()) + ? match.getMeeting2() + : match.getMeeting1(); + + String failedTeamMessage = isByTimeout + ? SystemMessageContent.MATCH_TIMED_OUT + : SystemMessageContent.MATCH_REJECTED_BY_LEADER; + String opponentMessage = isByTimeout + ? SystemMessageContent.OPPONENT_MATCH_TIMED_OUT + : SystemMessageContent.MATCH_REJECTED_BY_OPPONENT; + + return List.of( + saveForMeeting(failedMeeting.getId(), failedTeamMessage), + saveForMeeting(opponentMeeting.getId(), opponentMessage) + ); + } + + public void broadcast(ChatMessageEvent event) { + messagingTemplate.convertAndSend("/topic/rooms/" + event.getRoomId(), event); + } + + public void broadcast(List events) { + events.forEach(this::broadcast); + } + + 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("거절한 미팅 팀을 확인할 수 없습니다."); + } + + private ChatMessageEvent saveForMeeting(Long meetingId, String content) { + ChatRoom room = chatRoomRepository.findByMeeting_Id(meetingId) + .orElseThrow(() -> new IllegalArgumentException("미팅 채팅방이 존재하지 않습니다.")); + return save(room, content); + } + + private ChatMessageEvent save(ChatRoom room, String content) { + ChatMessage message = chatMessageRepository.saveAndFlush(ChatMessage.system(room, content)); + return ChatMessageEvent.builder() + .roomId(room.getId()) + .senderUserId(null) + .messageType(message.getType().name()) + .content(message.getContent()) + .messageId(message.getId()) + .clientMessageId(null) + .sendAt(message.getCreatedAt()) + .build(); + } +} 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..ea607f0 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,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.SystemMessageContent; import mannabom_server.manabom.application.notification.service.NotificationService; import mannabom_server.manabom.domain.chat.entity.ChatMember; import mannabom_server.manabom.domain.chat.entity.ChatRoom; @@ -19,6 +21,7 @@ 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.context.ApplicationEventPublisher; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,6 +42,7 @@ public class PhotoRequestService { 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 +62,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,6 +108,10 @@ public void createPhotoRequest(Long roomId, Long userId) { .build(); photoRequestRepository.save(request); + eventPublisher.publishEvent(new ChatSystemMessageEvent( + roomId, + SystemMessageContent.PHOTO_REQUESTED + )); sendNotification(roomId, receiver.getUserId(), "프로필 공개 요청", "상대방이 프로필을 몹시 궁금해하고 있어요!", SseEventName.PHOTO_REQUEST_RECEIVED, LoveViewPhotoStatus.RECEIVED); @@ -114,6 +126,10 @@ public void acceptPhotoRequest(Long roomId, Long userId) { LoveViewPhotoRequest request = findPendingRequest(room.getLoveView().getId(), userId); request.accept(); + eventPublisher.publishEvent(new ChatSystemMessageEvent( + roomId, + SystemMessageContent.PHOTO_REQUEST_ACCEPTED + )); User opponent = getOpponent(roomId, userId); sendNotification(roomId, opponent.getUserId(), @@ -130,6 +146,10 @@ public void rejectPhotoRequest(Long roomId, Long userId) { LoveViewPhotoRequest request = findPendingRequest(room.getLoveView().getId(), userId); request.reject(); + eventPublisher.publishEvent(new ChatSystemMessageEvent( + roomId, + SystemMessageContent.PHOTO_REQUEST_REJECTED + )); User opponent = getOpponent(roomId, userId); sendNotification(roomId, opponent.getUserId(), 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..7967d69 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 @@ -37,5 +37,12 @@ public class ChatMessage extends BaseTimeEntity { private String content; - + public static ChatMessage system(ChatRoom room, String content) { + return ChatMessage.builder() + .room(room) + .user(null) + .type(ChatMessageType.SYSTEM) + .content(content) + .build(); + } } 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..f8675a4 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 @@ -14,6 +14,8 @@ public interface ChatRoomRepository extends JpaRepository { Optional findByMeeting(Meeting meeting); + Optional findByMeeting_Id(Long meetingId); + @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/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..569134f --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponseTest.java @@ -0,0 +1,24 @@ +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 static org.assertj.core.api.Assertions.assertThat; + +class ChatMessageResponseTest { + + @Test + void convertsSystemMessageWithoutSender() { + ChatMessage message = ChatMessage.system( + ChatRoom.builder().id(77L).build(), + "시스템 알림" + ); + + ChatMessageResponse response = ChatMessageResponse.of(message); + + assertThat(response.getMessageType()).isEqualTo("SYSTEM"); + assertThat(response.getSenderId()).isNull(); + assertThat(response.getContent()).isEqualTo("시스템 알림"); + } +} 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..fe89160 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandlerTest.java @@ -0,0 +1,87 @@ +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.service.SystemMessageService; +import mannabom_server.manabom.application.meeting.dto.request.MeetingMatchingEvent; +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 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 connectsMatchingStartedEventToSystemMessage() { + MeetingMatchingEvent source = MeetingMatchingEvent.builder().meetingId(10L).build(); + ChatMessageEvent message = message(100L, "매칭 시작"); + when(systemMessageService.recordMatchingStarted(10L)).thenReturn(message); + + handler.handleMatchingStarted(source); + + verify(systemMessageService).broadcast(message); + } + + @Test + void connectsMatchFoundEventToBothTeamMessages() { + MatchFoundEvent source = new MatchFoundEvent(20L, Instant.now()); + List messages = List.of( + message(100L, "상대 팀 발견"), + message(101L, "상대 팀 발견") + ); + when(systemMessageService.recordMatchFound(20L)).thenReturn(messages); + + handler.handleMatchFound(source); + + verify(systemMessageService).broadcast(messages); + } + + @Test + void connectsTimeoutFailureEventToTeamMessages() { + MatchFailureEvent source = new MatchFailureEvent(20L, null, true); + List messages = List.of( + message(100L, "시간 초과"), + message(101L, "상대 팀 시간 초과") + ); + when(systemMessageService.recordMatchFailure(20L, true)).thenReturn(messages); + + handler.handleMatchFailure(source); + + verify(systemMessageService).broadcast(messages); + } + + @Test + void connectsRoomEventToSystemMessage() { + ChatSystemMessageEvent source = new ChatSystemMessageEvent(77L, "프로필 요청"); + ChatMessageEvent message = message(77L, "프로필 요청"); + when(systemMessageService.recordForRoom(77L, "프로필 요청")).thenReturn(message); + + handler.handleRoomSystemMessage(source); + + verify(systemMessageService).broadcast(message); + } + + private ChatMessageEvent message(Long roomId, String content) { + return ChatMessageEvent.builder() + .roomId(roomId) + .messageType("SYSTEM") + .content(content) + .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..af8d4a9 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatServiceTest.java @@ -0,0 +1,55 @@ +package mannabom_server.manabom.application.chat.service; + +import mannabom_server.manabom.application.chat.dto.request.ChatSendRequest; +import mannabom_server.manabom.application.common.port.FileStoragePort; +import mannabom_server.manabom.application.notification.service.NotificationService; +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 static org.assertj.core.api.Assertions.assertThatThrownBy; +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); + } +} 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..a26d860 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/SystemMessageServiceTest.java @@ -0,0 +1,128 @@ +package mannabom_server.manabom.application.chat.service; + +import mannabom_server.manabom.application.chat.dto.response.ChatMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageContent; +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.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 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.messaging.simp.SimpMessagingTemplate; + +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 SystemMessageServiceTest { + + @Mock private ChatRoomRepository chatRoomRepository; + @Mock private ChatMessageRepository chatMessageRepository; + @Mock private MeetingMatchRepository meetingMatchRepository; + @Mock private SimpMessagingTemplate messagingTemplate; + + @InjectMocks + private SystemMessageService systemMessageService; + + @BeforeEach + void persistPassedMessage() { + when(chatMessageRepository.saveAndFlush(any(ChatMessage.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void recordsAndBroadcastsSystemMessageWithoutSender() { + ChatRoom room = ChatRoom.builder().id(77L).build(); + when(chatRoomRepository.findById(77L)).thenReturn(Optional.of(room)); + + ChatMessageEvent event = systemMessageService.recordForRoom(77L, "시스템 알림"); + systemMessageService.broadcast(event); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); + verify(chatMessageRepository).saveAndFlush(messageCaptor.capture()); + ChatMessage savedMessage = messageCaptor.getValue(); + assertThat(savedMessage.getType()).isEqualTo(ChatMessageType.SYSTEM); + assertThat(savedMessage.getUser()).isNull(); + assertThat(savedMessage.getContent()).isEqualTo("시스템 알림"); + assertThat(event.getSenderUserId()).isNull(); + assertThat(event.getMessageType()).isEqualTo("SYSTEM"); + verify(messagingTemplate).convertAndSend("/topic/rooms/77", event); + } + + @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); + + assertThat(events).extracting(ChatMessageEvent::getRoomId).containsExactly(100L, 101L); + assertThat(events).allSatisfy(event -> + assertThat(event.getContent()).isEqualTo(SystemMessageContent.MATCH_FOUND)); + } + + @Test + void recordsDifferentMessagesForRejectingAndRejectedTeams() { + 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, false); + + assertThat(events.get(0).getContent()).isEqualTo(SystemMessageContent.MATCH_REJECTED_BY_LEADER); + assertThat(events.get(1).getContent()).isEqualTo(SystemMessageContent.MATCH_REJECTED_BY_OPPONENT); + } + + @Test + void recordsTimeoutMessagesForBothTeams() { + 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, true); + + assertThat(events.get(0).getRoomId()).isEqualTo(101L); + assertThat(events.get(0).getContent()).isEqualTo(SystemMessageContent.MATCH_TIMED_OUT); + assertThat(events.get(1).getRoomId()).isEqualTo(100L); + assertThat(events.get(1).getContent()).isEqualTo(SystemMessageContent.OPPONENT_MATCH_TIMED_OUT); + } +} 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..a4423cd --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/matching/service/PhotoRequestServiceTest.java @@ -0,0 +1,153 @@ +package mannabom_server.manabom.application.matching.service; + +import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; +import mannabom_server.manabom.application.chat.message.SystemMessageContent; +import mannabom_server.manabom.application.notification.service.NotificationService; +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 org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +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 SimpMessagingTemplate messagingTemplate; + @Mock private NotificationService notificationService; + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + @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)); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(any(String.class))).thenReturn("77"); + } + + @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(SystemMessageContent.PHOTO_REQUESTED); + } + + @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(SystemMessageContent.PHOTO_REQUEST_ACCEPTED); + } + + @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(SystemMessageContent.PHOTO_REQUEST_REJECTED); + } + + 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(String expectedContent) { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ChatSystemMessageEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().roomId()).isEqualTo(77L); + assertThat(captor.getValue().content()).isEqualTo(expectedContent); + } + + private static User user(Long id) { + return User.builder() + .userId(id) + .kakaoId("mock_" + id) + .userName("user" + id) + .build(); + } +} From b7465e95329010c0a36c63a278991b381825ab5f Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 22 Jul 2026 19:02:09 +0900 Subject: [PATCH 16/30] =?UTF-8?q?refactor:=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EC=9B=B9=EC=86=8C=EC=BC=93?= =?UTF-8?q?=EA=B3=BC=20=ED=91=B8=EC=8B=9C=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/event/ChatSystemMessageEvent.java | 35 ++- .../chat/dto/response/ChatMessageEvent.java | 6 + .../ChatSystemMessageEventHandler.java | 40 +-- .../chat/message/SystemMessageContent.java | 20 ++ .../chat/message/SystemMessageType.java | 108 ++++++++ .../chat/service/ChatRoomService.java | 40 ++- .../application/chat/service/ChatService.java | 50 +++- .../chat/service/SystemMessageService.java | 242 +++++++++++++++--- .../application/like/service/LikeService.java | 32 ++- .../matching/service/PhotoRequestService.java | 55 ++-- .../MeetingVerificationScheduler.java | 31 +++ .../MeetingCancellationExpirationService.java | 34 +++ .../service/MeetingCancellationService.java | 64 +++++ .../service/MeetingMatchingService.java | 24 +- .../meeting/service/MeetingService.java | 17 ++ .../MeetingVerificationExpirationService.java | 41 +++ .../service/MeetingVerificationService.java | 23 +- .../service/MessageRequestService.java | 32 ++- .../notification/dto/MatchSuccessEvent.java | 3 +- .../application/notification/dto/SseData.java | 4 - .../handler/NotificationEventListener.java | 38 --- .../service/NotificationService.java | 203 +++------------ .../notification/service/SseService.java | 116 --------- .../meeting/entity/MeetingVerification.java | 16 ++ .../domain/meeting/enums/SseEventName.java | 22 -- .../MeetingVerificationRepository.java | 20 ++ .../notification/entity/Notification.java | 5 +- .../notification/entity/SseEventCache.java | 21 -- .../notification/enums/NotificationType.java | 15 ++ .../repository/EmitterRepository.java | 28 -- .../repository/SseEventCacheRepository.java | 10 - .../controller/MeetingMatchingController.java | 2 - .../controller/NotificationController.java | 18 +- ...ting_verification_failure_notification.sql | 6 + .../ChatSystemMessageEventHandlerTest.java | 69 +++-- .../service/SystemMessageServiceTest.java | 81 ++++-- .../service/PhotoRequestServiceTest.java | 28 +- ...tingCancellationExpirationServiceTest.java | 9 + .../MeetingCancellationServiceTest.java | 8 + ...tingVerificationExpirationServiceTest.java | 50 ++++ .../service/NotificationServiceTest.java | 50 ++++ 41 files changed, 1074 insertions(+), 642 deletions(-) create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageType.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/meeting/scheduler/MeetingVerificationScheduler.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationExpirationService.java delete mode 100644 manabom/src/main/java/mannabom_server/manabom/application/notification/dto/SseData.java delete mode 100644 manabom/src/main/java/mannabom_server/manabom/application/notification/handler/NotificationEventListener.java delete mode 100644 manabom/src/main/java/mannabom_server/manabom/application/notification/service/SseService.java delete mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/SseEventName.java delete mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/SseEventCache.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/notification/enums/NotificationType.java delete mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/notification/repository/EmitterRepository.java delete mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/notification/repository/SseEventCacheRepository.java create mode 100644 manabom/src/main/resources/db/migration/V25__add_meeting_verification_failure_notification.sql create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationExpirationServiceTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/notification/service/NotificationServiceTest.java 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 index a6bd831..f722a94 100644 --- 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 @@ -1,7 +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, - String content + 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..945e2ce 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,12 @@ public class ChatMessageEvent { private Long roomId; private Long senderUserId; + private Long actorUserId; + private List recipientUserIds; private String messageType; + private String systemEventType; private String content; + private Map data; private Long messageId; private String clientMessageId; 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 index d927f51..fa7faa8 100644 --- 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 @@ -3,13 +3,10 @@ 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.service.SystemMessageService; -import mannabom_server.manabom.application.meeting.dto.request.MeetingMatchingEvent; import mannabom_server.manabom.application.notification.dto.MatchFailureEvent; import mannabom_server.manabom.application.notification.dto.MatchFoundEvent; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; +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; @@ -21,45 +18,50 @@ public class ChatSystemMessageEventHandler { private final SystemMessageService systemMessageService; - @Order(Ordered.HIGHEST_PRECEDENCE) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleMatchingStarted(MeetingMatchingEvent event) { + public void handleMatchFound(MatchFoundEvent event) { try { - ChatMessageEvent message = systemMessageService.recordMatchingStarted(event.getMeetingId()); - systemMessageService.broadcast(message); + systemMessageService.dispatch( + systemMessageService.recordMatchFound(event.matchId(), event.decisionDeadline()) + ); } catch (RuntimeException e) { - log.error("매칭 시작 시스템 메시지 처리 실패: meetingId={}", event.getMeetingId(), e); + log.error("매칭 발견 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); } } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleMatchFound(MatchFoundEvent event) { + public void handleMatchFailure(MatchFailureEvent event) { try { - systemMessageService.broadcast(systemMessageService.recordMatchFound(event.matchId())); + systemMessageService.dispatch(systemMessageService.recordMatchFailure( + event.matchId(), + event.triggerUserId(), + Boolean.TRUE.equals(event.isByTimeout()) + )); } catch (RuntimeException e) { - log.error("매칭 발견 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); + log.error("매칭 실패 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); } } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleMatchFailure(MatchFailureEvent event) { + public void handleMatchSuccess(MatchSuccessEvent event) { try { - systemMessageService.broadcast(systemMessageService.recordMatchFailure( + systemMessageService.dispatch(systemMessageService.recordMatchSuccess( event.matchId(), - Boolean.TRUE.equals(event.isByTimeout()) + event.triggerUserId(), + event.chatRoomId() )); } catch (RuntimeException e) { - log.error("매칭 실패 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); + log.error("매칭 성사 시스템 메시지 처리 실패: matchId={}", event.matchId(), e); } } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleRoomSystemMessage(ChatSystemMessageEvent event) { try { - ChatMessageEvent message = systemMessageService.recordForRoom(event.roomId(), event.content()); - systemMessageService.broadcast(message); + systemMessageService.dispatch(systemMessageService.recordForRoom(event)); } catch (RuntimeException e) { - log.error("채팅방 시스템 메시지 처리 실패: roomId={}", event.roomId(), e); + log.error("채팅방 시스템 메시지 처리 실패: roomId={}, type={}", + event.roomId(), event.type(), e); } } } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java index aeedc74..c4a0c42 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java @@ -4,6 +4,8 @@ public final class SystemMessageContent { public static final String MATCHING_STARTED = "팀장님이 매칭을 시작했어요. 상대 팀을 찾고 있어요."; + public static final String MATCHING_STOPPED = + "팀장님이 매칭을 중단했어요."; public static final String MATCH_FOUND = "미팅할 상대 팀을 찾았어요. 팀장님의 결정을 기다리고 있어요."; public static final String MATCH_REJECTED_BY_LEADER = @@ -14,6 +16,24 @@ public final class SystemMessageContent { "응답 시간이 지나 매칭이 자동으로 거절됐어요."; public static final String OPPONENT_MATCH_TIMED_OUT = "상대 팀의 응답 시간이 지나 매칭이 취소됐어요."; + public static final String MATCH_COMPLETED = + "상대 팀이 수락해 미팅이 성사됐어요. 새로운 채팅방에서 대화를 시작해 보세요."; + public static final String CHAT_ROOM_CREATED = + "🎉 매칭이 성사되었습니다! 서로 인사를 나눠보세요."; + public static final String MEETING_VERIFICATION_STARTED = + "누군가 만남 인증을 시작했어요."; + public static final String MEETING_VERIFICATION_SUCCEEDED = + "만남 인증에 성공했어요."; + public static final String MEETING_VERIFICATION_FAILED = + "만남 인증에 실패했어요."; + public static final String MEETING_CANCELLATION_VOTE_STARTED = + "미팅 취소 투표가 시작됐어요."; + public static final String MEETING_CANCELLATION_APPROVED = + "미팅 취소 투표가 가결되어 미팅이 취소됐어요."; + public static final String MEETING_CANCELLATION_REJECTED = + "미팅 취소 투표가 부결됐어요."; + public static final String MEETING_CANCELLATION_EXPIRED = + "미팅 취소 투표가 응답 시간 초과로 종료됐어요."; public static final String PHOTO_REQUESTED = "프로필 사진 공개 요청이 전송되었어요."; public static final String PHOTO_REQUEST_ACCEPTED = 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..4a5148b --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageType.java @@ -0,0 +1,108 @@ +package mannabom_server.manabom.application.chat.message; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SystemMessageType { + MATCHING_STARTED( + SystemMessageContent.MATCHING_STARTED, + "매칭 시작", + "팀장님이 매칭을 시작했어요." + ), + MATCHING_STOPPED( + SystemMessageContent.MATCHING_STOPPED, + "매칭 중단", + "팀장님이 매칭을 중단했어요." + ), + MATCH_FOUND( + SystemMessageContent.MATCH_FOUND, + "미팅 상대 팀 발견", + "미팅할 상대 팀을 찾았어요. 팀장님의 결정을 기다리고 있어요." + ), + MATCH_REJECTED_BY_LEADER( + SystemMessageContent.MATCH_REJECTED_BY_LEADER, + "매칭 거절", + "팀장님이 매칭을 거절했어요." + ), + MATCH_REJECTED_BY_OPPONENT( + SystemMessageContent.MATCH_REJECTED_BY_OPPONENT, + "매칭 거절", + "상대 팀이 매칭을 거절했어요." + ), + MATCH_TIMED_OUT( + SystemMessageContent.MATCH_TIMED_OUT, + "매칭 응답 시간 종료", + "응답 시간이 지나 매칭이 자동으로 거절됐어요." + ), + OPPONENT_MATCH_TIMED_OUT( + SystemMessageContent.OPPONENT_MATCH_TIMED_OUT, + "매칭 응답 시간 종료", + "상대 팀의 응답 시간이 지나 매칭이 취소됐어요." + ), + MATCH_COMPLETED( + SystemMessageContent.MATCH_COMPLETED, + "미팅 매칭 성사", + "상대 팀이 수락해 미팅 매칭이 성사됐어요." + ), + CHAT_ROOM_CREATED( + SystemMessageContent.CHAT_ROOM_CREATED, + "새 채팅방 생성", + "매칭이 성사됐어요. 지금 대화를 시작해 보세요." + ), + MEETING_VERIFICATION_STARTED( + SystemMessageContent.MEETING_VERIFICATION_STARTED, + "만남 인증 시작", + "채팅방에서 만남 인증이 시작됐어요." + ), + MEETING_VERIFICATION_SUCCEEDED( + SystemMessageContent.MEETING_VERIFICATION_SUCCEEDED, + "만남 인증 성공", + "만남 인증이 성공했어요." + ), + MEETING_VERIFICATION_FAILED( + SystemMessageContent.MEETING_VERIFICATION_FAILED, + "만남 인증 실패", + "제한 시간 안에 조건을 충족하지 못해 만남 인증이 종료됐어요." + ), + MEETING_CANCELLATION_VOTE_STARTED( + SystemMessageContent.MEETING_CANCELLATION_VOTE_STARTED, + "미팅 취소 투표 시작", + "미팅 취소 투표가 시작됐어요." + ), + MEETING_CANCELLATION_APPROVED( + SystemMessageContent.MEETING_CANCELLATION_APPROVED, + "미팅 취소 결정", + "미팅 취소 투표가 가결됐어요." + ), + MEETING_CANCELLATION_REJECTED( + SystemMessageContent.MEETING_CANCELLATION_REJECTED, + "미팅 취소 결정", + "미팅 취소 투표가 부결됐어요." + ), + MEETING_CANCELLATION_EXPIRED( + SystemMessageContent.MEETING_CANCELLATION_EXPIRED, + "미팅 취소 투표 종료", + "미팅 취소 투표가 응답 시간 초과로 종료됐어요." + ), + PHOTO_REQUESTED( + SystemMessageContent.PHOTO_REQUESTED, + "프로필 공개 요청", + "상대방이 프로필 공개를 요청했어요." + ), + PHOTO_REQUEST_ACCEPTED( + SystemMessageContent.PHOTO_REQUEST_ACCEPTED, + "프로필 공개 성사", + "프로필 공개 요청이 수락됐어요." + ), + PHOTO_REQUEST_REJECTED( + SystemMessageContent.PHOTO_REQUEST_REJECTED, + "프로필 공개 거절", + "프로필 공개 요청이 거절됐어요." + ); + + private final String content; + private final String notificationTitle; + private final String notificationBody; +} 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 4c432e6..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.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,18 +103,26 @@ 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 ){ - chatMessageRepository.save(ChatMessage.system( - room, - "🎉 매칭이 성사되었습니다! 서로 인사를 나눠보세요." + 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()) )); } 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 7b62a7e..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 @@ -16,7 +16,7 @@ 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; @@ -82,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()); @@ -101,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( @@ -114,9 +144,15 @@ private void sendNotificationWithSSEOrPush(ChatSendRequest request, List new IllegalArgumentException("존재하지 않는 채팅방입니다.")); - return save(room, content); - } - - @Transactional(propagation = Propagation.REQUIRES_NEW) - public ChatMessageEvent recordMatchingStarted(Long meetingId) { - return saveForMeeting(meetingId, SystemMessageContent.MATCHING_STARTED); + return save(room, event); } @Transactional(propagation = Propagation.REQUIRES_NEW) - public List recordMatchFound(Long matchId) { + 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().getId(), SystemMessageContent.MATCH_FOUND), - saveForMeeting(match.getMeeting2().getId(), SystemMessageContent.MATCH_FOUND) + 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, boolean isByTimeout) { + 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(); - String failedTeamMessage = isByTimeout - ? SystemMessageContent.MATCH_TIMED_OUT - : SystemMessageContent.MATCH_REJECTED_BY_LEADER; - String opponentMessage = isByTimeout - ? SystemMessageContent.OPPONENT_MATCH_TIMED_OUT - : SystemMessageContent.MATCH_REJECTED_BY_OPPONENT; + SystemMessageType failedTeamType = isByTimeout + ? SystemMessageType.MATCH_TIMED_OUT + : SystemMessageType.MATCH_REJECTED_BY_LEADER; + SystemMessageType opponentType = isByTimeout + ? 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(failedMeeting.getId(), failedTeamMessage), - saveForMeeting(opponentMeeting.getId(), opponentMessage) + 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()); + } + + try { + notificationService.sendNotification( + recipientUserId, + NotificationType.SYSTEM_MESSAGE, + recorded.type().getNotificationTitle(), + recorded.type().getNotificationBody(), + 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) { + ChatMessage message = chatMessageRepository.saveAndFlush( + ChatMessage.system(room, event.type().getContent()) ); + 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(event.type().name()) + .content(message.getContent()) + .data(event.data()) + .messageId(message.getId()) + .clientMessageId(null) + .sendAt(message.getCreatedAt()) + .build(); + return new RecordedSystemMessage(response, event.type(), event.pushEnabled()); } - public void broadcast(ChatMessageEvent event) { - messagingTemplate.convertAndSend("/topic/rooms/" + event.getRoomId(), event); + 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(); } - public void broadcast(List events) { - events.forEach(this::broadcast); + private Long roomIdForMeeting(Meeting meeting) { + return chatRoomRepository.findByMeeting_Id(meeting.getId()) + .map(ChatRoom::getId) + .orElseThrow(() -> new IllegalArgumentException("미팅 채팅방이 존재하지 않습니다.")); } private MeetingMatch findMatch(Long matchId) { @@ -96,22 +266,10 @@ private Meeting findFailedMeeting(MeetingMatch match, boolean isByTimeout) { throw new IllegalStateException("거절한 미팅 팀을 확인할 수 없습니다."); } - private ChatMessageEvent saveForMeeting(Long meetingId, String content) { - ChatRoom room = chatRoomRepository.findByMeeting_Id(meetingId) - .orElseThrow(() -> new IllegalArgumentException("미팅 채팅방이 존재하지 않습니다.")); - return save(room, content); - } - - private ChatMessageEvent save(ChatRoom room, String content) { - ChatMessage message = chatMessageRepository.saveAndFlush(ChatMessage.system(room, content)); - return ChatMessageEvent.builder() - .roomId(room.getId()) - .senderUserId(null) - .messageType(message.getType().name()) - .content(message.getContent()) - .messageId(message.getId()) - .clientMessageId(null) - .sendAt(message.getCreatedAt()) - .build(); + 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 ea607f0..ea708a5 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 @@ -3,8 +3,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; -import mannabom_server.manabom.application.chat.message.SystemMessageContent; -import mannabom_server.manabom.application.notification.service.NotificationService; +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,16 +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.context.ApplicationEventPublisher; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Map; +import java.util.List; import java.util.Optional; @Service @@ -39,9 +36,6 @@ 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; //조회- 채팅방 입장 @@ -108,12 +102,13 @@ public void createPhotoRequest(Long roomId, Long userId) { .build(); photoRequestRepository.save(request); - eventPublisher.publishEvent(new ChatSystemMessageEvent( + eventPublisher.publishEvent(ChatSystemMessageEvent.of( roomId, - SystemMessageContent.PHOTO_REQUESTED + SystemMessageType.PHOTO_REQUESTED, + userId, + List.of(receiver.getUserId()), + Map.of("status", LoveViewPhotoStatus.RECEIVED.name()) )); - sendNotification(roomId, receiver.getUserId(), "프로필 공개 요청", - "상대방이 프로필을 몹시 궁금해하고 있어요!", SseEventName.PHOTO_REQUEST_RECEIVED, LoveViewPhotoStatus.RECEIVED); } @@ -126,15 +121,15 @@ public void acceptPhotoRequest(Long roomId, Long userId) { LoveViewPhotoRequest request = findPendingRequest(room.getLoveView().getId(), userId); request.accept(); - eventPublisher.publishEvent(new ChatSystemMessageEvent( + User opponent = getOpponent(roomId, userId); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( roomId, - SystemMessageContent.PHOTO_REQUEST_ACCEPTED + SystemMessageType.PHOTO_REQUEST_ACCEPTED, + userId, + List.of(opponent.getUserId()), + Map.of("status", LoveViewPhotoStatus.ACCEPTED.name()) )); - - User opponent = getOpponent(roomId, userId); - sendNotification(roomId, opponent.getUserId(), - "프로필 열람 성공!", "상대방이 요청을 수락했어요! 지금 바로 확인해 보세요.", - SseEventName.PHOTO_REQUEST_ACCEPTED, LoveViewPhotoStatus.ACCEPTED); } + } //거절 @Transactional @@ -146,15 +141,14 @@ public void rejectPhotoRequest(Long roomId, Long userId) { LoveViewPhotoRequest request = findPendingRequest(room.getLoveView().getId(), userId); request.reject(); - eventPublisher.publishEvent(new ChatSystemMessageEvent( + User opponent = getOpponent(roomId, userId); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( roomId, - SystemMessageContent.PHOTO_REQUEST_REJECTED + SystemMessageType.PHOTO_REQUEST_REJECTED, + userId, + List.of(opponent.getUserId()), + Map.of("status", LoveViewPhotoStatus.REJECTED.name()) )); - - User opponent = getOpponent(roomId, userId); - sendNotification(roomId, opponent.getUserId(), - "다음에 다시 시도해 봐요!", "상대방이 아직은 사진 공개가 조금 부끄러운가 봐요.", - SseEventName.PHOTO_REQUEST_REJECTED, LoveViewPhotoStatus.REJECTED); } private LoveViewPhotoRequest findPendingRequest(Long historyId, Long userId){ LoveViewPhotoRequest request = photoRequestRepository.findTopByHistoryIdOrderByIdDesc(historyId) @@ -193,15 +187,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/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 index dd40be7..4b44553 100644 --- 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 @@ -1,19 +1,33 @@ 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.ChatMember; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; import mannabom_server.manabom.domain.meeting.entity.MeetingCancellationRequest; import mannabom_server.manabom.domain.meeting.repository.MeetingCancellationRequestRepository; 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.HashMap; +import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor public class MeetingCancellationExpirationService { private final MeetingCancellationRequestRepository requestRepository; + private final ChatRoomRepository chatRoomRepository; + private final ChatMemberRepository chatMemberRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean expire(Long requestId, Instant now) { @@ -25,6 +39,26 @@ public boolean expire(Long requestId, Instant now) { } request.expire(now); + ChatRoom room = chatRoomRepository.findByMatch(request.getMeetingMatch()) + .orElseThrow(() -> new IllegalStateException("매칭 채팅방이 존재하지 않습니다.")); + List recipients = chatMemberRepository + .findAllByRoomIdAndStatus(room.getId(), ChatMemberStatus.ACTIVATE) + .stream() + .map(ChatMember::getUser) + .map(user -> user.getUserId()) + .toList(); + Map data = new HashMap<>(); + if (request.getId() != null) { + data.put("requestId", request.getId()); + } + data.put("status", request.getStatus().name()); + eventPublisher.publishEvent(ChatSystemMessageEvent.of( + room.getId(), + SystemMessageType.MEETING_CANCELLATION_EXPIRED, + null, + recipients, + data + )); return true; } } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationService.java index 78dc7af..8d22eeb 100644 --- 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 @@ -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.meeting.dto.response.MeetingCancellationResponse; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; @@ -23,12 +25,15 @@ import mannabom_server.manabom.domain.user.entity.User; import mannabom_server.manabom.domain.user.repository.UserRepository; 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 @@ -45,6 +50,7 @@ public class MeetingCancellationService { private final ChatRoomRepository chatRoomRepository; private final ChatMemberRepository chatMemberRepository; private final MeetingCancellationExpirationService expirationService; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public void validateNoPendingCancellation(Long meetingId) { @@ -106,6 +112,12 @@ public MeetingCancellationResponse create(Long matchId, Long userId) { : MeetingCancellationVote.pending(request, member.getUser())) .toList(); voteRepository.saveAll(votes); + publishCancellationEvent( + request, + SystemMessageType.MEETING_CANCELLATION_VOTE_STARTED, + userId, + memberUserIds(members) + ); return MeetingCancellationResponse.of(request, votes); } @@ -132,12 +144,25 @@ public MeetingCancellationResponse vote( 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)) { approveCancellation(request, now); + publishCancellationEvent( + request, + SystemMessageType.MEETING_CANCELLATION_APPROVED, + userId, + recipientUserIds + ); } return response(request); @@ -202,7 +227,14 @@ private void expireIfNecessary( Instant now ) { if (request.isExpiredAt(now)) { + List recipients = memberUserIds(activeMembers(request.getMeetingMatch())); request.expire(now); + publishCancellationEvent( + request, + SystemMessageType.MEETING_CANCELLATION_EXPIRED, + null, + recipients + ); } } @@ -237,6 +269,38 @@ private List activeMembers(Long meetingId) { ); } + 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); 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 7bae30f..cc3834b 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); @@ -326,7 +325,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,10 +334,19 @@ else if(match.getMatchingStatus().equals(MatchingStatus.FAILED)) { } - private MatchedChatRoomInfo handleMatchSuccess(MeetingMatch match, boolean isByTimeout){ - MatchedChatRoomInfo info = chatRoomService.createMatchingChatRoom(match); + 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)); + eventPublisher.publishEvent(new MatchSuccessEvent( + match.getId(), + info.getRoomId(), + isByTimeout, + triggerUserId + )); return info; } 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 3b546a4..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.*; @@ -29,6 +31,7 @@ import java.security.SecureRandom; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; @Service @@ -401,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() @@ -415,6 +425,13 @@ 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; } 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..fed23f4 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationExpirationService.java @@ -0,0 +1,41 @@ +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, + null, + null, + Map.of("expiredAt", verification.getExpiresAt().toString()) + )); + 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..70e4bbe 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"; @@ -57,7 +62,17 @@ public String verifyMeeting(Long chatRoomId, Long userId, double latitude, doubl MeetingVerification verification = getOrCreateVerification(room); Instant now = Instant.now(); + boolean startedNow = verification.getStartedAt() == null; verification.startIfNeeded(now, VERIFICATION_TTL); + 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("만남인증 가능 시간이 지났습니다."); } @@ -216,7 +231,13 @@ 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()) + )); return "모든 조건 충족! 만남 인증이 완료되었습니다. ✨"; } else { 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/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..7e7bfe5 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,18 @@ 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 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 +21,66 @@ @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); - } + pushService.sendToUser( + 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 String toJson(Object data) { + if (data == null) { + return null; } - return fcmMap; - } - - private MeetingMatch findMatch(Long matchId){ - return meetingMatchRepository.findById(matchId) - .orElseThrow(()->new IllegalArgumentException("알림: 존재하지 않는 매칭 아이디입니다.")); - } - - private String formatTime(Instant instant){ - return DateTimeFormatter.ofPattern("M월 d일(E) HH:mm") - .withZone(ZoneId.of("Asia/Seoul")) - .format(instant); - } - - 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); + 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/domain/meeting/entity/MeetingVerification.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java index 54ed186..2dd0bcb 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 @@ -31,6 +31,8 @@ public class MeetingVerification { private Instant verifiedAt; + private Instant failureNotifiedAt; + @Builder public MeetingVerification(ChatRoom room) { this.room = room; @@ -60,4 +62,18 @@ public void verify(){ this.verifiedAt = Instant.now(); } + 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/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/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/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/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/test/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandlerTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/handler/ChatSystemMessageEventHandlerTest.java index fe89160..15bf047 100644 --- 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 @@ -2,8 +2,8 @@ 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.meeting.dto.request.MeetingMatchingEvent; import mannabom_server.manabom.application.notification.dto.MatchFailureEvent; import mannabom_server.manabom.application.notification.dto.MatchFoundEvent; import org.junit.jupiter.api.Test; @@ -14,6 +14,7 @@ import java.time.Instant; import java.util.List; +import java.util.Map; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -21,67 +22,59 @@ @ExtendWith(MockitoExtension.class) class ChatSystemMessageEventHandlerTest { - @Mock - private SystemMessageService systemMessageService; - - @InjectMocks - private ChatSystemMessageEventHandler handler; - - @Test - void connectsMatchingStartedEventToSystemMessage() { - MeetingMatchingEvent source = MeetingMatchingEvent.builder().meetingId(10L).build(); - ChatMessageEvent message = message(100L, "매칭 시작"); - when(systemMessageService.recordMatchingStarted(10L)).thenReturn(message); - - handler.handleMatchingStarted(source); - - verify(systemMessageService).broadcast(message); - } + @Mock private SystemMessageService systemMessageService; + @InjectMocks private ChatSystemMessageEventHandler handler; @Test void connectsMatchFoundEventToBothTeamMessages() { - MatchFoundEvent source = new MatchFoundEvent(20L, Instant.now()); - List messages = List.of( - message(100L, "상대 팀 발견"), - message(101L, "상대 팀 발견") - ); - when(systemMessageService.recordMatchFound(20L)).thenReturn(messages); + 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).broadcast(messages); + verify(systemMessageService).dispatch(messages); } @Test void connectsTimeoutFailureEventToTeamMessages() { MatchFailureEvent source = new MatchFailureEvent(20L, null, true); - List messages = List.of( - message(100L, "시간 초과"), - message(101L, "상대 팀 시간 초과") - ); - when(systemMessageService.recordMatchFailure(20L, true)).thenReturn(messages); + List messages = List.of(recorded(100L)); + when(systemMessageService.recordMatchFailure(20L, null, true)).thenReturn(messages); handler.handleMatchFailure(source); - verify(systemMessageService).broadcast(messages); + verify(systemMessageService).dispatch(messages); } @Test - void connectsRoomEventToSystemMessage() { - ChatSystemMessageEvent source = new ChatSystemMessageEvent(77L, "프로필 요청"); - ChatMessageEvent message = message(77L, "프로필 요청"); - when(systemMessageService.recordForRoom(77L, "프로필 요청")).thenReturn(message); + 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).broadcast(message); + verify(systemMessageService).dispatch(message); } - private ChatMessageEvent message(Long roomId, String content) { - return ChatMessageEvent.builder() + private SystemMessageService.RecordedSystemMessage recorded(Long roomId) { + ChatMessageEvent message = ChatMessageEvent.builder() .roomId(roomId) + .recipientUserIds(List.of()) .messageType("SYSTEM") - .content(content) .build(); + return new SystemMessageService.RecordedSystemMessage( + message, + SystemMessageType.MATCH_FOUND, + true + ); } } 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 index a26d860..52c3edc 100644 --- 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 @@ -1,10 +1,14 @@ 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.SystemMessageContent; +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; @@ -18,9 +22,13 @@ 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; @@ -32,9 +40,13 @@ class SystemMessageServiceTest { @Mock private ChatRoomRepository chatRoomRepository; + @Mock private ChatMemberRepository chatMemberRepository; @Mock private ChatMessageRepository chatMessageRepository; @Mock private MeetingMatchRepository meetingMatchRepository; @Mock private SimpMessagingTemplate messagingTemplate; + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + @Mock private NotificationService notificationService; @InjectMocks private SystemMessageService systemMessageService; @@ -46,22 +58,38 @@ void persistPassedMessage() { } @Test - void recordsAndBroadcastsSystemMessageWithoutSender() { + void recordsSingleTypedEventAndPushesToRecipientOutsideRoom() { ChatRoom room = ChatRoom.builder().id(77L).build(); when(chatRoomRepository.findById(77L)).thenReturn(Optional.of(room)); - - ChatMessageEvent event = systemMessageService.recordForRoom(77L, "시스템 알림"); - systemMessageService.broadcast(event); + 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()); - ChatMessage savedMessage = messageCaptor.getValue(); - assertThat(savedMessage.getType()).isEqualTo(ChatMessageType.SYSTEM); - assertThat(savedMessage.getUser()).isNull(); - assertThat(savedMessage.getContent()).isEqualTo("시스템 알림"); - assertThat(event.getSenderUserId()).isNull(); - assertThat(event.getMessageType()).isEqualTo("SYSTEM"); - verify(messagingTemplate).convertAndSend("/topic/rooms/77", event); + 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"); + verify(messagingTemplate).convertAndSend("/topic/rooms/77", recorded.message()); + verify(notificationService).sendNotification( + org.mockito.ArgumentMatchers.eq(2L), + any(), + any(), + any(), + any() + ); } @Test @@ -77,15 +105,17 @@ void recordsMatchFoundMessageForBothTeamRooms() { when(chatRoomRepository.findByMeeting_Id(11L)) .thenReturn(Optional.of(ChatRoom.builder().id(101L).meeting(meeting2).build())); - List events = systemMessageService.recordMatchFound(20L); + List events = + systemMessageService.recordMatchFound(20L, Instant.now()); - assertThat(events).extracting(ChatMessageEvent::getRoomId).containsExactly(100L, 101L); + assertThat(events).extracting(event -> event.message().getRoomId()) + .containsExactly(100L, 101L); assertThat(events).allSatisfy(event -> - assertThat(event.getContent()).isEqualTo(SystemMessageContent.MATCH_FOUND)); + assertThat(event.message().getContent()).isEqualTo(SystemMessageContent.MATCH_FOUND)); } @Test - void recordsDifferentMessagesForRejectingAndRejectedTeams() { + void recordsDifferentMessagesForRejectingAndOpponentTeams() { Meeting meeting1 = Meeting.builder().id(10L).build(); Meeting meeting2 = Meeting.builder().id(11L).build(); MeetingMatch match = org.mockito.Mockito.mock(MeetingMatch.class); @@ -98,10 +128,13 @@ void recordsDifferentMessagesForRejectingAndRejectedTeams() { when(chatRoomRepository.findByMeeting_Id(11L)) .thenReturn(Optional.of(ChatRoom.builder().id(101L).meeting(meeting2).build())); - List events = systemMessageService.recordMatchFailure(20L, false); + List events = + systemMessageService.recordMatchFailure(20L, 1L, false); - assertThat(events.get(0).getContent()).isEqualTo(SystemMessageContent.MATCH_REJECTED_BY_LEADER); - assertThat(events.get(1).getContent()).isEqualTo(SystemMessageContent.MATCH_REJECTED_BY_OPPONENT); + assertThat(events.get(0).message().getContent()) + .isEqualTo(SystemMessageContent.MATCH_REJECTED_BY_LEADER); + assertThat(events.get(1).message().getContent()) + .isEqualTo(SystemMessageContent.MATCH_REJECTED_BY_OPPONENT); } @Test @@ -118,11 +151,13 @@ void recordsTimeoutMessagesForBothTeams() { when(chatRoomRepository.findByMeeting_Id(11L)) .thenReturn(Optional.of(ChatRoom.builder().id(101L).meeting(meeting2).build())); - List events = systemMessageService.recordMatchFailure(20L, true); + List events = + systemMessageService.recordMatchFailure(20L, null, true); - assertThat(events.get(0).getRoomId()).isEqualTo(101L); - assertThat(events.get(0).getContent()).isEqualTo(SystemMessageContent.MATCH_TIMED_OUT); - assertThat(events.get(1).getRoomId()).isEqualTo(100L); - assertThat(events.get(1).getContent()).isEqualTo(SystemMessageContent.OPPONENT_MATCH_TIMED_OUT); + assertThat(events.get(0).message().getRoomId()).isEqualTo(101L); + assertThat(events.get(0).message().getContent()).isEqualTo(SystemMessageContent.MATCH_TIMED_OUT); + assertThat(events.get(1).message().getRoomId()).isEqualTo(100L); + assertThat(events.get(1).message().getContent()) + .isEqualTo(SystemMessageContent.OPPONENT_MATCH_TIMED_OUT); } } 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 index a4423cd..b302272 100644 --- 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 @@ -2,7 +2,7 @@ import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; import mannabom_server.manabom.application.chat.message.SystemMessageContent; -import mannabom_server.manabom.application.notification.service.NotificationService; +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; @@ -24,9 +24,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.ValueOperations; -import org.springframework.messaging.simp.SimpMessagingTemplate; import java.util.List; import java.util.Optional; @@ -44,10 +41,6 @@ class PhotoRequestServiceTest { @Mock private ChatMessageRepository chatMessageRepository; @Mock private UserRepository userRepository; @Mock private ChatMemberRepository chatMemberRepository; - @Mock private SimpMessagingTemplate messagingTemplate; - @Mock private NotificationService notificationService; - @Mock private StringRedisTemplate redisTemplate; - @Mock private ValueOperations valueOperations; @Mock private ApplicationEventPublisher eventPublisher; @InjectMocks @@ -68,8 +61,6 @@ void setUpRoom() { .loveView(history) .build(); when(chatRoomRepository.findById(77L)).thenReturn(Optional.of(room)); - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get(any(String.class))).thenReturn("77"); } @Test @@ -85,7 +76,7 @@ void publishesSystemMessageWhenPhotoIsRequested() { photoRequestService.createPhotoRequest(77L, 1L); - assertPublishedMessage(SystemMessageContent.PHOTO_REQUESTED); + assertPublishedMessage(SystemMessageType.PHOTO_REQUESTED, 1L, 2L); } @Test @@ -100,7 +91,7 @@ void publishesSystemMessageWhenPhotoRequestIsAccepted() { photoRequestService.acceptPhotoRequest(77L, 2L); assertThat(request.getStatus()).isEqualTo(PhotoRequestStatus.ACCEPTED); - assertPublishedMessage(SystemMessageContent.PHOTO_REQUEST_ACCEPTED); + assertPublishedMessage(SystemMessageType.PHOTO_REQUEST_ACCEPTED, 2L, 1L); } @Test @@ -115,7 +106,7 @@ void publishesSystemMessageWhenPhotoRequestIsRejected() { photoRequestService.rejectPhotoRequest(77L, 2L); assertThat(request.getStatus()).isEqualTo(PhotoRequestStatus.REJECTED); - assertPublishedMessage(SystemMessageContent.PHOTO_REQUEST_REJECTED); + assertPublishedMessage(SystemMessageType.PHOTO_REQUEST_REJECTED, 2L, 1L); } private void allowActiveMember(User user) { @@ -135,12 +126,19 @@ private LoveViewPhotoRequest pendingRequest(User sender, User receiver) { .build(); } - private void assertPublishedMessage(String expectedContent) { + 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().content()).isEqualTo(expectedContent); + assertThat(captor.getValue().type()).isEqualTo(expectedType); + assertThat(captor.getValue().type().getContent()).isEqualTo(expectedType.getContent()); + assertThat(captor.getValue().actorUserId()).isEqualTo(actorUserId); + assertThat(captor.getValue().recipientUserIds()).containsExactly(recipientUserId); } private static User user(Long id) { 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 index 854c3e3..8e00f82 100644 --- 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 @@ -1,5 +1,8 @@ package mannabom_server.manabom.application.meeting.service; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +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.MeetingMatch; @@ -11,6 +14,7 @@ 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; @@ -24,6 +28,9 @@ class MeetingCancellationExpirationServiceTest { @Mock private MeetingCancellationRequestRepository requestRepository; + @Mock private ChatRoomRepository chatRoomRepository; + @Mock private ChatMemberRepository chatMemberRepository; + @Mock private ApplicationEventPublisher eventPublisher; @InjectMocks private MeetingCancellationExpirationService expirationService; @@ -34,6 +41,8 @@ void expiresPendingRequestInItsOwnProcessingStep() { Instant now = requestedAt.plus(Duration.ofHours(25)); MeetingCancellationRequest request = cancellationRequest(requestedAt); when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + when(chatRoomRepository.findByMatch(request.getMeetingMatch())) + .thenReturn(Optional.of(ChatRoom.builder().id(100L).build())); boolean expired = expirationService.expire(30L, now); 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 index 10ed9ce..d0bd1e0 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -22,6 +23,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThat; @@ -55,6 +57,8 @@ class MeetingCancellationServiceTest { private ChatMemberRepository chatMemberRepository; @Mock private MeetingCancellationExpirationService expirationService; + @Mock + private ApplicationEventPublisher eventPublisher; @InjectMocks private MeetingCancellationService meetingCancellationService; @@ -115,6 +119,8 @@ void createsVotesForMembersOfBothMatchedTeams() { 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); @@ -152,6 +158,8 @@ void unanimousApprovalCancelsBothMatchedTeams() { .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); 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..fb3f8a4 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationExpirationServiceTest.java @@ -0,0 +1,50 @@ +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 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)); + 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); + } +} 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..814f912 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/notification/service/NotificationServiceTest.java @@ -0,0 +1,50 @@ +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 java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +@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"); + } +} From a998d3fb97b9b035c721dc34953248991e1c5914 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 23 Jul 2026 03:03:56 +0900 Subject: [PATCH 17/30] =?UTF-8?q?feat:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=9C=EB=AA=A9=EA=B3=BC=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EC=A0=95=EB=B3=B4=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/dto/response/ChatMessageEvent.java | 2 + .../dto/response/ChatMessageResponse.java | 11 ++ .../dto/response/ChatRoomListResponse.java | 11 +- .../chat/message/SystemMessageContent.java | 46 ----- .../chat/message/SystemMessageType.java | 182 ++++++++++++------ .../chat/service/SystemMessageService.java | 37 +++- .../matching/service/PhotoRequestService.java | 15 +- .../MeetingVerificationExpirationService.java | 7 +- .../service/MeetingVerificationService.java | 15 +- .../service/pushSender/FcmPushSender.java | 20 +- .../domain/chat/entity/ChatMessage.java | 43 +++++ .../meeting/entity/MeetingVerification.java | 15 +- ...__add_structured_system_message_fields.sql | 22 +++ .../dto/response/ChatMessageResponseTest.java | 14 +- .../chat/message/SystemMessageTypeTest.java | 41 ++++ .../chat/service/ChatServiceTest.java | 59 ++++++ .../service/SystemMessageServiceTest.java | 26 ++- .../service/PhotoRequestServiceTest.java | 2 - ...tingVerificationExpirationServiceTest.java | 10 +- 19 files changed, 433 insertions(+), 145 deletions(-) delete mode 100644 manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java create mode 100644 manabom/src/main/resources/db/migration/V26__add_structured_system_message_fields.sql create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/chat/message/SystemMessageTypeTest.java 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 945e2ce..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 @@ -18,6 +18,8 @@ public class ChatMessageEvent { private List recipientUserIds; private String messageType; private String systemEventType; + private String systemTitle; + private String actorNickname; private String content; private Map data; 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 1b63b93..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; @@ -23,6 +29,11 @@ public static ChatMessageResponse of(ChatMessage msg){ .messageType(msg.getType().name()) .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/message/SystemMessageContent.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java deleted file mode 100644 index c4a0c42..0000000 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/message/SystemMessageContent.java +++ /dev/null @@ -1,46 +0,0 @@ -package mannabom_server.manabom.application.chat.message; - -public final class SystemMessageContent { - - public static final String MATCHING_STARTED = - "팀장님이 매칭을 시작했어요. 상대 팀을 찾고 있어요."; - public static final String MATCHING_STOPPED = - "팀장님이 매칭을 중단했어요."; - public static final String MATCH_FOUND = - "미팅할 상대 팀을 찾았어요. 팀장님의 결정을 기다리고 있어요."; - public static final String MATCH_REJECTED_BY_LEADER = - "팀장님이 매칭을 거절했어요. 새로운 상대 팀을 찾고 있어요."; - public static final String MATCH_REJECTED_BY_OPPONENT = - "상대 팀이 매칭을 거절했어요. 새로운 상대 팀을 찾고 있어요."; - public static final String MATCH_TIMED_OUT = - "응답 시간이 지나 매칭이 자동으로 거절됐어요."; - public static final String OPPONENT_MATCH_TIMED_OUT = - "상대 팀의 응답 시간이 지나 매칭이 취소됐어요."; - public static final String MATCH_COMPLETED = - "상대 팀이 수락해 미팅이 성사됐어요. 새로운 채팅방에서 대화를 시작해 보세요."; - public static final String CHAT_ROOM_CREATED = - "🎉 매칭이 성사되었습니다! 서로 인사를 나눠보세요."; - public static final String MEETING_VERIFICATION_STARTED = - "누군가 만남 인증을 시작했어요."; - public static final String MEETING_VERIFICATION_SUCCEEDED = - "만남 인증에 성공했어요."; - public static final String MEETING_VERIFICATION_FAILED = - "만남 인증에 실패했어요."; - public static final String MEETING_CANCELLATION_VOTE_STARTED = - "미팅 취소 투표가 시작됐어요."; - public static final String MEETING_CANCELLATION_APPROVED = - "미팅 취소 투표가 가결되어 미팅이 취소됐어요."; - public static final String MEETING_CANCELLATION_REJECTED = - "미팅 취소 투표가 부결됐어요."; - public static final String MEETING_CANCELLATION_EXPIRED = - "미팅 취소 투표가 응답 시간 초과로 종료됐어요."; - public static final String PHOTO_REQUESTED = - "프로필 사진 공개 요청이 전송되었어요."; - public static final String PHOTO_REQUEST_ACCEPTED = - "프로필 사진 공개 요청이 수락되었어요."; - public static final String PHOTO_REQUEST_REJECTED = - "프로필 사진 공개 요청이 거절되었어요."; - - private SystemMessageContent() { - } -} 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 index 4a5148b..2f477e2 100644 --- 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 @@ -2,107 +2,171 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; + +import java.util.Map; @Getter @RequiredArgsConstructor public enum SystemMessageType { MATCHING_STARTED( - SystemMessageContent.MATCHING_STARTED, - "매칭 시작", - "팀장님이 매칭을 시작했어요." + "매칭을 시작했어요", + "팀장님이 매칭을 시작하셨어요." ), MATCHING_STOPPED( - SystemMessageContent.MATCHING_STOPPED, - "매칭 중단", - "팀장님이 매칭을 중단했어요." + "매칭을 중단했어요", + "팀장님이 매칭을 중단하셨어요." ), MATCH_FOUND( - SystemMessageContent.MATCH_FOUND, - "미팅 상대 팀 발견", - "미팅할 상대 팀을 찾았어요. 팀장님의 결정을 기다리고 있어요." + "미팅할 상대 팀을 찾았어요‼️✨", + "수락 가능 시간 안에 팀원들과 상의해 주세요.\n" + + "팀장은 상단 헤더에서 수락을 확정해 주세요.\n" + + "수락 가능 시간이 지나면 자동 거절돼요." ), MATCH_REJECTED_BY_LEADER( - SystemMessageContent.MATCH_REJECTED_BY_LEADER, - "매칭 거절", - "팀장님이 매칭을 거절했어요." + "아쉽네요", + "팀장님이 매칭을 거절했어요.\n새로운 상대 팀을 찾고 있어요." ), MATCH_REJECTED_BY_OPPONENT( - SystemMessageContent.MATCH_REJECTED_BY_OPPONENT, - "매칭 거절", - "상대 팀이 매칭을 거절했어요." + "아쉽네요", + "이번에는 상대 팀과 연결되지 않았어요.\n다시 매칭 신청을 눌러주세요!" ), MATCH_TIMED_OUT( - SystemMessageContent.MATCH_TIMED_OUT, - "매칭 응답 시간 종료", - "응답 시간이 지나 매칭이 자동으로 거절됐어요." + "응답 시간이 지났어요", + "수락 가능 시간이 지나 매칭이 자동으로 거절됐어요." ), OPPONENT_MATCH_TIMED_OUT( - SystemMessageContent.OPPONENT_MATCH_TIMED_OUT, - "매칭 응답 시간 종료", - "상대 팀의 응답 시간이 지나 매칭이 취소됐어요." + "아쉽네요", + "상대 팀의 응답 시간이 지나 매칭이 취소됐어요.\n다시 매칭 신청을 눌러주세요!" ), MATCH_COMPLETED( - SystemMessageContent.MATCH_COMPLETED, - "미팅 매칭 성사", - "상대 팀이 수락해 미팅 매칭이 성사됐어요." + "미팅이 성사되었어요 🎉🎉", + "이 채팅방은 더 이상 이용하실 수 없습니다.\n" + + "새로운 채팅방으로 이동하여 상대 팀과 이야기를 나눠보세요!" ), CHAT_ROOM_CREATED( - SystemMessageContent.CHAT_ROOM_CREATED, - "새 채팅방 생성", - "매칭이 성사됐어요. 지금 대화를 시작해 보세요." + "매칭이 성사되었어요 🎉", + "서로 인사를 나누고 즐거운 대화를 시작해 보세요." ), MEETING_VERIFICATION_STARTED( - SystemMessageContent.MEETING_VERIFICATION_STARTED, - "만남 인증 시작", - "채팅방에서 만남 인증이 시작됐어요." + "만남인증 시작", + "만남인증이 시작되었어요." ), MEETING_VERIFICATION_SUCCEEDED( - SystemMessageContent.MEETING_VERIFICATION_SUCCEEDED, - "만남 인증 성공", - "만남 인증이 성공했어요." + "만남인증 성공", + "만남인증이 완료되었어요." ), MEETING_VERIFICATION_FAILED( - SystemMessageContent.MEETING_VERIFICATION_FAILED, - "만남 인증 실패", - "제한 시간 안에 조건을 충족하지 못해 만남 인증이 종료됐어요." + "만남인증 실패", + "만남인증이 완료되지 않았어요." ), MEETING_CANCELLATION_VOTE_STARTED( - SystemMessageContent.MEETING_CANCELLATION_VOTE_STARTED, - "미팅 취소 투표 시작", - "미팅 취소 투표가 시작됐어요." + "미팅 취소 투표", + "미팅 취소 투표가 시작되었어요.\n" + + "모든 인원이 취소에 동의하면 채팅방이 사라지고 채팅방에서 사용된 팅은 위약금으로 제외하고 환불해드려요." ), MEETING_CANCELLATION_APPROVED( - SystemMessageContent.MEETING_CANCELLATION_APPROVED, - "미팅 취소 결정", - "미팅 취소 투표가 가결됐어요." + "미팅 취소 투표 결과", + "모두가 동의하여 미팅과 채팅방이 취소되었어요." ), MEETING_CANCELLATION_REJECTED( - SystemMessageContent.MEETING_CANCELLATION_REJECTED, - "미팅 취소 결정", - "미팅 취소 투표가 부결됐어요." + "미팅 취소 투표 결과", + "모두가 동의하지 않아 취소되지 않았어요." ), MEETING_CANCELLATION_EXPIRED( - SystemMessageContent.MEETING_CANCELLATION_EXPIRED, - "미팅 취소 투표 종료", - "미팅 취소 투표가 응답 시간 초과로 종료됐어요." + "미팅 취소 투표 결과", + "투표 시간이 지나 미팅이 취소되지 않았어요." ), PHOTO_REQUESTED( - SystemMessageContent.PHOTO_REQUESTED, "프로필 공개 요청", - "상대방이 프로필 공개를 요청했어요." + "수락할 시 서로의 프로필이 공개됩니다.\n헤더를 내려 수락하기 버튼을 눌러주세요." ), PHOTO_REQUEST_ACCEPTED( - SystemMessageContent.PHOTO_REQUEST_ACCEPTED, - "프로필 공개 성사", - "프로필 공개 요청이 수락됐어요." + "서로의 프로필이 공개되었습니다! ✨", + "서로의 프로필을 눌러 더 다양한 대화를 나눠보세요!" ), PHOTO_REQUEST_REJECTED( - SystemMessageContent.PHOTO_REQUEST_REJECTED, - "프로필 공개 거절", - "프로필 공개 요청이 거절됐어요." + "프로필 공개가 거절되었어요", + "아직 상대가 조심스러운가 봐요.\n더 대화를 나눠보세요!" ); - private final String content; - private final String notificationTitle; - private final String notificationBody; + 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"); + + 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" + + "참여인원 : " + participantCount + "명" + ); + 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/SystemMessageService.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/SystemMessageService.java index 72f34d6..4b5fb7f 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -41,6 +42,7 @@ public class SystemMessageService { 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; @@ -177,13 +179,16 @@ private void sendPushFallback(RecordedSystemMessage recorded, boolean forcePush) 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, - recorded.type().getNotificationTitle(), - recorded.type().getNotificationBody(), + message.getSystemTitle(), + null, data ); } catch (RuntimeException e) { @@ -205,8 +210,18 @@ private RecordedSystemMessage saveForMeeting(Meeting meeting, ChatSystemMessageE } 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, event.type().getContent()) + ChatMessage.system( + room, + rendered.body(), + event.type().name(), + rendered.title(), + event.actorUserId(), + actorNickname, + event.data() + ) ); List recipients = resolveRecipients(room.getId(), event.actorUserId(), event.recipientUserIds()); @@ -216,9 +231,11 @@ private RecordedSystemMessage save(ChatRoom room, ChatSystemMessageEvent event) .actorUserId(event.actorUserId()) .recipientUserIds(recipients) .messageType(message.getType().name()) - .systemEventType(event.type().name()) + .systemEventType(message.getSystemEventType()) + .systemTitle(message.getSystemTitle()) + .actorNickname(message.getActorNickname()) .content(message.getContent()) - .data(event.data()) + .data(message.getSystemData()) .messageId(message.getId()) .clientMessageId(null) .sendAt(message.getCreatedAt()) @@ -226,6 +243,16 @@ private RecordedSystemMessage save(ChatRoom room, ChatSystemMessageEvent event) 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) 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 ea708a5..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 @@ -107,7 +107,10 @@ public void createPhotoRequest(Long roomId, Long userId) { SystemMessageType.PHOTO_REQUESTED, userId, List.of(receiver.getUserId()), - Map.of("status", LoveViewPhotoStatus.RECEIVED.name()) + Map.of( + "actorStatus", LoveViewPhotoStatus.PENDING.name(), + "recipientStatus", LoveViewPhotoStatus.RECEIVED.name() + ) )); } @@ -127,7 +130,10 @@ public void acceptPhotoRequest(Long roomId, Long userId) { SystemMessageType.PHOTO_REQUEST_ACCEPTED, userId, List.of(opponent.getUserId()), - Map.of("status", LoveViewPhotoStatus.ACCEPTED.name()) + Map.of( + "actorStatus", LoveViewPhotoStatus.ACCEPTED.name(), + "recipientStatus", LoveViewPhotoStatus.ACCEPTED.name() + ) )); } @@ -147,7 +153,10 @@ public void rejectPhotoRequest(Long roomId, Long userId) { SystemMessageType.PHOTO_REQUEST_REJECTED, userId, List.of(opponent.getUserId()), - Map.of("status", LoveViewPhotoStatus.REJECTED.name()) + Map.of( + "actorStatus", LoveViewPhotoStatus.REJECTED.name(), + "recipientStatus", LoveViewPhotoStatus.REJECTED.name() + ) )); } private LoveViewPhotoRequest findPendingRequest(Long historyId, Long userId){ 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 index fed23f4..35bca3a 100644 --- 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 @@ -32,9 +32,12 @@ public boolean notifyFailure(Long verificationId, Instant now) { eventPublisher.publishEvent(ChatSystemMessageEvent.of( verification.getRoom().getId(), SystemMessageType.MEETING_VERIFICATION_FAILED, + verification.getStartedBy() == null ? null : verification.getStartedBy().getUserId(), null, - null, - Map.of("expiredAt", verification.getExpiresAt().toString()) + 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 70e4bbe..34895d4 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 @@ -57,13 +57,13 @@ 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(); boolean startedNow = verification.getStartedAt() == null; - verification.startIfNeeded(now, VERIFICATION_TTL); + verification.startIfNeeded(now, VERIFICATION_TTL, actorMember.getUser()); if (startedNow) { eventPublisher.publishEvent(ChatSystemMessageEvent.of( chatRoomId, @@ -209,6 +209,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); @@ -234,9 +236,14 @@ private String processGeneral(Long chatRoomId, Long userId, double latitude, dou eventPublisher.publishEvent(ChatSystemMessageEvent.of( chatRoomId, SystemMessageType.MEETING_VERIFICATION_SUCCEEDED, - userId, + verification.getStartedBy() == null + ? userId + : verification.getStartedBy().getUserId(), null, - Map.of("verifiedAt", verification.getVerifiedAt().toString()) + Map.of( + "verifiedAt", verification.getVerifiedAt().toString(), + "participantCount", verification.getParticipantCount() + ) )); return "모든 조건 충족! 만남 인증이 완료되었습니다. ✨"; 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 7967d69..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,6 +42,22 @@ 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) @@ -45,4 +66,26 @@ public static ChatMessage system(ChatRoom room, String content) { .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/meeting/entity/MeetingVerification.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java index 2dd0bcb..80f555a 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; @@ -33,18 +34,30 @@ public class MeetingVerification { 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; + @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); } 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/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 index 569134f..7754030 100644 --- 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 @@ -4,6 +4,8 @@ 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 { @@ -12,13 +14,21 @@ class ChatMessageResponseTest { 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.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/message/SystemMessageTypeTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/message/SystemMessageTypeTest.java new file mode 100644 index 0000000..b7ff088 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/chat/message/SystemMessageTypeTest.java @@ -0,0 +1,41 @@ +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 rendersVerificationInitiatorAndParticipantCount() { + SystemMessageType.RenderedSystemMessage message = + SystemMessageType.MEETING_VERIFICATION_SUCCEEDED.render( + "민수", + Map.of("participantCount", 4) + ); + + assertThat(message.title()).isEqualTo("만남인증 성공"); + assertThat(message.body()) + .isEqualTo("민수님이 시작한 만남인증이 완료되었어요.\n참여인원 : 4명"); + } + + @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/ChatServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/ChatServiceTest.java index af8d4a9..3074eeb 100644 --- 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 @@ -1,8 +1,14 @@ 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; @@ -17,7 +23,15 @@ 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) @@ -52,4 +66,49 @@ void rejectsClientAuthoredSystemMessage() { 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 index 52c3edc..0e672bd 100644 --- 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 @@ -2,7 +2,6 @@ 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.SystemMessageContent; import mannabom_server.manabom.application.chat.message.SystemMessageType; import mannabom_server.manabom.application.notification.service.NotificationService; import mannabom_server.manabom.domain.chat.entity.ChatMessage; @@ -15,6 +14,8 @@ 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; @@ -43,6 +44,7 @@ class SystemMessageServiceTest { @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; @@ -61,6 +63,8 @@ void persistPassedMessage() { 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"); @@ -82,6 +86,9 @@ void recordsSingleTypedEventAndPushesToRecipientOutsideRoom() { 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), @@ -110,8 +117,10 @@ void recordsMatchFoundMessageForBothTeamRooms() { assertThat(events).extracting(event -> event.message().getRoomId()) .containsExactly(100L, 101L); - assertThat(events).allSatisfy(event -> - assertThat(event.message().getContent()).isEqualTo(SystemMessageContent.MATCH_FOUND)); + assertThat(events).allSatisfy(event -> { + assertThat(event.message().getSystemTitle()).isEqualTo("미팅할 상대 팀을 찾았어요‼️✨"); + assertThat(event.message().getContent()).contains("수락 가능 시간 안에"); + }); } @Test @@ -131,10 +140,8 @@ void recordsDifferentMessagesForRejectingAndOpponentTeams() { List events = systemMessageService.recordMatchFailure(20L, 1L, false); - assertThat(events.get(0).message().getContent()) - .isEqualTo(SystemMessageContent.MATCH_REJECTED_BY_LEADER); - assertThat(events.get(1).message().getContent()) - .isEqualTo(SystemMessageContent.MATCH_REJECTED_BY_OPPONENT); + assertThat(events.get(0).message().getContent()).contains("팀장 사용자님이 매칭을 거절했어요"); + assertThat(events.get(1).message().getContent()).contains("상대 팀과 연결되지 않았어요"); } @Test @@ -155,9 +162,8 @@ void recordsTimeoutMessagesForBothTeams() { systemMessageService.recordMatchFailure(20L, null, true); assertThat(events.get(0).message().getRoomId()).isEqualTo(101L); - assertThat(events.get(0).message().getContent()).isEqualTo(SystemMessageContent.MATCH_TIMED_OUT); + assertThat(events.get(0).message().getContent()).contains("자동으로 거절됐어요"); assertThat(events.get(1).message().getRoomId()).isEqualTo(100L); - assertThat(events.get(1).message().getContent()) - .isEqualTo(SystemMessageContent.OPPONENT_MATCH_TIMED_OUT); + assertThat(events.get(1).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 index b302272..dd54d49 100644 --- 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 @@ -1,7 +1,6 @@ package mannabom_server.manabom.application.matching.service; import mannabom_server.manabom.application.chat.dto.event.ChatSystemMessageEvent; -import mannabom_server.manabom.application.chat.message.SystemMessageContent; import mannabom_server.manabom.application.chat.message.SystemMessageType; import mannabom_server.manabom.domain.chat.entity.ChatMember; import mannabom_server.manabom.domain.chat.entity.ChatRoom; @@ -136,7 +135,6 @@ private void assertPublishedMessage( verify(eventPublisher).publishEvent(captor.capture()); assertThat(captor.getValue().roomId()).isEqualTo(77L); assertThat(captor.getValue().type()).isEqualTo(expectedType); - assertThat(captor.getValue().type().getContent()).isEqualTo(expectedType.getContent()); assertThat(captor.getValue().actorUserId()).isEqualTo(actorUserId); assertThat(captor.getValue().recipientUserIds()).containsExactly(recipientUserId); } 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 index fb3f8a4..bc6a7fc 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -35,7 +36,12 @@ void publishesFailureOnlyOnceAfterExpiration() { MeetingVerification verification = MeetingVerification.builder() .room(ChatRoom.builder().id(77L).build()) .build(); - verification.startIfNeeded(startedAt, Duration.ofHours(9)); + 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(); @@ -46,5 +52,7 @@ void publishesFailureOnlyOnceAfterExpiration() { 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); } } From 64528afd0bf2c087dea4703d349905758df8b497 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 23 Jul 2026 03:41:08 +0900 Subject: [PATCH 18/30] =?UTF-8?q?feat:=20=EB=AF=B8=ED=8C=85=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=9D=B8=EC=9B=90=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/message/SystemMessageType.java | 5 +- .../service/MeetingVerificationService.java | 77 +++++++++++++++---- .../meeting/entity/MeetingVerification.java | 35 ++++++++- ...7__persist_meeting_verification_result.sql | 6 ++ .../chat/message/SystemMessageTypeTest.java | 7 +- .../meeting/MeetingVerificationTest.java | 41 ++++++++++ 6 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 manabom/src/main/resources/db/migration/V27__persist_meeting_verification_result.sql create mode 100644 manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingVerificationTest.java 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 index 2f477e2..77351e7 100644 --- 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 @@ -96,6 +96,9 @@ public enum SystemMessageType { 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( @@ -117,7 +120,7 @@ public RenderedSystemMessage render(String actorNickname, Map da case MEETING_VERIFICATION_SUCCEEDED -> message( defaultTitle, nickname + "님이 시작한 만남인증이 완료되었어요.\n" - + "참여인원 : " + participantCount + "명" + + "참여인원 : " + verifiedParticipantCount + "명" ); case MEETING_VERIFICATION_FAILED -> message( defaultTitle, 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 34895d4..342c560 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 @@ -77,7 +77,7 @@ public String verifyMeeting(Long chatRoomId, Long userId, double latitude, doubl 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); @@ -112,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( @@ -176,7 +180,13 @@ 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("이미 만남인증에 참여 완료되었습니다."); } @@ -196,6 +206,7 @@ private void processLatecomer(Long chatRoomId, Long userId, double latitude, dou //보상 reward(chatRoomId, List.of(userId)); + verification.recordVerifiedLatecomer(); simpMessagingTemplate.convertAndSend("/topic/chat/"+ chatRoomId, "LATECOMER_OK:"+userId); } @@ -219,7 +230,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()){ @@ -242,7 +259,9 @@ private String processGeneral(Long chatRoomId, Long userId, double latitude, dou null, Map.of( "verifiedAt", verification.getVerifiedAt().toString(), - "participantCount", verification.getParticipantCount() + "participantCount", bestCluster.count(), + "submittedCount", verification.getParticipantCount(), + "verifiedParticipantCount", bestCluster.count() ) )); return "모든 조건 충족! 만남 인증이 완료되었습니다. ✨"; @@ -331,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){ @@ -405,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( @@ -422,7 +443,9 @@ static MeetingVerificationStatusData notStarted(int totalMembers, int requiredCo false, null, null, - bestCluster.locationOrNull() + bestCluster.locationOrNull(), + 0, + 0 ); } @@ -449,7 +472,9 @@ static MeetingVerificationStatusData inProgress( false, myLocation, null, - bestCluster.locationOrNull() + bestCluster.locationOrNull(), + verification.getParticipantCount(), + 0 ); } @@ -477,7 +502,9 @@ static MeetingVerificationStatusData verified( true, myLocation, finalLocation, - bestCluster.locationOrNull() + bestCluster.locationOrNull(), + verification.getParticipantCount(), + verification.getVerifiedParticipantCount() ); } @@ -503,7 +530,9 @@ static MeetingVerificationStatusData expired( false, myLocation, null, - bestCluster.locationOrNull() + bestCluster.locationOrNull(), + verification.getParticipantCount(), + 0 ); } } @@ -514,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/domain/meeting/entity/MeetingVerification.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java index 80f555a..1b269f2 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 @@ -41,6 +41,19 @@ public class MeetingVerification { @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; @@ -70,9 +83,29 @@ 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() { + this.participantCount++; + this.verifiedParticipantCount++; + } + + public boolean hasFinalLocation() { + return finalLatitude != null && finalLongitude != null; } public boolean needsFailureNotification(Instant now) { 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/message/SystemMessageTypeTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/message/SystemMessageTypeTest.java index b7ff088..20a754b 100644 --- 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 @@ -22,12 +22,15 @@ void rendersVerificationInitiatorAndParticipantCount() { SystemMessageType.RenderedSystemMessage message = SystemMessageType.MEETING_VERIFICATION_SUCCEEDED.render( "민수", - Map.of("participantCount", 4) + Map.of( + "submittedCount", 4, + "verifiedParticipantCount", 3 + ) ); assertThat(message.title()).isEqualTo("만남인증 성공"); assertThat(message.body()) - .isEqualTo("민수님이 시작한 만남인증이 완료되었어요.\n참여인원 : 4명"); + .isEqualTo("민수님이 시작한 만남인증이 완료되었어요.\n참여인원 : 3명"); } @Test 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..4bf34c8 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingVerificationTest.java @@ -0,0 +1,41 @@ +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(); + + assertThat(verification.getParticipantCount()).isEqualTo(5); + assertThat(verification.getVerifiedParticipantCount()).isEqualTo(4); + } +} From 1cf3a7cae3b0ff885da517fb80694cb756bd5943 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 23 Jul 2026 03:44:15 +0900 Subject: [PATCH 19/30] =?UTF-8?q?fix:=20=EB=A7=8C=EB=82=A8=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=B0=B8=EC=97=AC=20=EC=9D=B8=EC=9B=90=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A7=91=EA=B3=84=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MeetingVerificationService.java | 3 ++- .../meeting/entity/MeetingVerification.java | 6 ++++-- .../domain/meeting/MeetingVerificationTest.java | 16 +++++++++++++++- 3 files changed, 21 insertions(+), 4 deletions(-) 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 342c560..dfbdacb 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 @@ -190,6 +190,7 @@ private void processLatecomer( 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("인증 유효 기간이 지났습니다."); @@ -206,7 +207,7 @@ private void processLatecomer( //보상 reward(chatRoomId, List.of(userId)); - verification.recordVerifiedLatecomer(); + verification.recordVerifiedLatecomer(firstSubmission); simpMessagingTemplate.convertAndSend("/topic/chat/"+ chatRoomId, "LATECOMER_OK:"+userId); } 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 1b269f2..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 @@ -99,8 +99,10 @@ public void verify( this.verifiedHasFemale = hasFemale; } - public void recordVerifiedLatecomer() { - this.participantCount++; + public void recordVerifiedLatecomer(boolean firstSubmission) { + if (firstSubmission) { + this.participantCount++; + } this.verifiedParticipantCount++; } 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 index 4bf34c8..8cb070d 100644 --- a/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingVerificationTest.java +++ b/manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingVerificationTest.java @@ -33,9 +33,23 @@ void countsVerifiedLatecomerAsSubmittedAndVerified() { verification.updateParticipantCount(4); verification.verify(3, 37.5665, 126.9780, true, true); - verification.recordVerifiedLatecomer(); + 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); + } } From d5d7a518b739edce8007dfe76fccd9d240d08596 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 14:32:49 +0900 Subject: [PATCH 20/30] =?UTF-8?q?fix:=20=ED=99=9C=EC=84=B1=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EB=A9=A4=EB=B2=84=20=EC=A4=91=EB=B3=B5=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V24__add_unique_active_chat_member.sql | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 index 4c4d39d..33aed3a 100644 --- 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 @@ -1,3 +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'; From 749a9ce5210e788eef03fc771f56776aaf1fdeca Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 15:45:39 +0900 Subject: [PATCH 21/30] =?UTF-8?q?fix:=20=EC=96=91=20=ED=8C=80=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/SystemMessageService.java | 7 +++- .../domain/meeting/entity/MeetingMatch.java | 2 +- .../service/SystemMessageServiceTest.java | 26 +++++++++++++- .../meeting/MeetingMatchExpirationTest.java | 36 +++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 manabom/src/test/java/mannabom_server/manabom/domain/meeting/MeetingMatchExpirationTest.java 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 index 4b5fb7f..b1947ce 100644 --- 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 @@ -94,8 +94,13 @@ public List recordMatchFailure( 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 - ? SystemMessageType.OPPONENT_MATCH_TIMED_OUT + ? bothTeamsTimedOut + ? SystemMessageType.MATCH_TIMED_OUT + : SystemMessageType.OPPONENT_MATCH_TIMED_OUT : SystemMessageType.MATCH_REJECTED_BY_OPPONENT; Map data = Map.of("matchId", matchId, "timeout", isByTimeout); 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/test/java/mannabom_server/manabom/application/chat/service/SystemMessageServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/chat/service/SystemMessageServiceTest.java index 0e672bd..4797806 100644 --- 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 @@ -145,7 +145,7 @@ void recordsDifferentMessagesForRejectingAndOpponentTeams() { } @Test - void recordsTimeoutMessagesForBothTeams() { + void recordsTimeoutAndOpponentTimeoutMessagesWhenOnlyOneTeamTimedOut() { Meeting meeting1 = Meeting.builder().id(10L).build(); Meeting meeting2 = Meeting.builder().id(11L).build(); MeetingMatch match = org.mockito.Mockito.mock(MeetingMatch.class); @@ -166,4 +166,28 @@ void recordsTimeoutMessagesForBothTeams() { 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/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(); + } +} From e7769d438ae5efa35ae38b55e7af47cc894f7782 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 15:45:58 +0900 Subject: [PATCH 22/30] =?UTF-8?q?fix:=20=EC=8B=A4=ED=8C=A8=ED=95=9C=20?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20=EA=B2=B0=EA=B3=BC=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/MatchingResultDataDto.java | 24 +++++-- .../service/MeetingMatchingService.java | 36 +++++++--- .../meeting/MatchingResultDataDtoTest.java | 68 +++++++++++++++++++ .../service/MeetingMatchingServiceTest.java | 65 ++++++++++++++++++ 4 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/meeting/MatchingResultDataDtoTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingServiceTest.java 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/service/MeetingMatchingService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java index cc3834b..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 @@ -297,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 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/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); + } +} From 99a03779ce9dd05e5dcf861c28ba58a59effd9d5 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 15:46:14 +0900 Subject: [PATCH 23/30] =?UTF-8?q?refactor:=20=EC=95=88=EC=93=B0=EB=8A=94?= =?UTF-8?q?=20import=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manabom/application/notification/dto/MatchFailureEvent.java | 1 - 1 file changed, 1 deletion(-) 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, From e97149cdb2341886b9c67957763ef122d49b0128 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 15:47:40 +0900 Subject: [PATCH 24/30] =?UTF-8?q?feat:=20api=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manabom/build.gradle | 3 +++ .../manabom/infrastructure/security/config/SecurityConfig.java | 3 +++ 2 files changed, 6 insertions(+) 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/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() From 8aeeb3ebf30506bf205cad206acd2f5f99fc449b Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 16:01:04 +0900 Subject: [PATCH 25/30] =?UTF-8?q?fix:=20=EB=AF=B8=ED=8C=85=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EA=B1=B0=EC=A0=88=20=ED=88=AC=ED=91=9C=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/MeetingCancellationResponse.java | 5 ++ .../MeetingCancellationResponseTest.java | 63 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/meeting/MeetingCancellationResponseTest.java 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 index 2da5efa..924b837 100644 --- 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 @@ -23,6 +23,7 @@ public class MeetingCancellationResponse { private int totalMemberCount; private int agreedMemberCount; private int pendingMemberCount; + private int rejectedMemberCount; private List votes; public static MeetingCancellationResponse of( @@ -35,6 +36,9 @@ public static MeetingCancellationResponse of( 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()) @@ -47,6 +51,7 @@ public static MeetingCancellationResponse of( .totalMemberCount(votes.size()) .agreedMemberCount(agreed) .pendingMemberCount(pending) + .rejectedMemberCount(rejected) .votes(votes.stream() .map(MeetingCancellationVoteResponse::from) .toList()) 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(); + } +} From c3917fb9666cfdb0f38e0b44d21004718fb17962 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 16:09:17 +0900 Subject: [PATCH 26/30] =?UTF-8?q?fix:=20=EB=AF=B8=ED=8C=85=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EB=A7=8C=EB=A3=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MeetingCancellationExpirationService.java | 36 +++++++++++----- ...tingCancellationExpirationServiceTest.java | 41 +++++++++++++++++-- 2 files changed, 64 insertions(+), 13 deletions(-) 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 index 4b44553..010e03c 100644 --- 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 @@ -3,19 +3,21 @@ 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.ChatMember; import mannabom_server.manabom.domain.chat.entity.ChatRoom; -import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; -import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; import mannabom_server.manabom.domain.meeting.entity.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; @@ -26,7 +28,7 @@ public class MeetingCancellationExpirationService { private final MeetingCancellationRequestRepository requestRepository; private final ChatRoomRepository chatRoomRepository; - private final ChatMemberRepository chatMemberRepository; + private final MeetingMemberRepository meetingMemberRepository; private final ApplicationEventPublisher eventPublisher; @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -41,17 +43,13 @@ public boolean expire(Long requestId, Instant now) { request.expire(now); ChatRoom room = chatRoomRepository.findByMatch(request.getMeetingMatch()) .orElseThrow(() -> new IllegalStateException("매칭 채팅방이 존재하지 않습니다.")); - List recipients = chatMemberRepository - .findAllByRoomIdAndStatus(room.getId(), ChatMemberStatus.ACTIVATE) - .stream() - .map(ChatMember::getUser) - .map(user -> user.getUserId()) - .toList(); + 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, @@ -61,4 +59,22 @@ public boolean expire(Long requestId, Instant now) { )); 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/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationExpirationServiceTest.java index 8e00f82..999f8f5 100644 --- 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 @@ -1,26 +1,34 @@ 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.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.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) @@ -29,7 +37,7 @@ class MeetingCancellationExpirationServiceTest { @Mock private MeetingCancellationRequestRepository requestRepository; @Mock private ChatRoomRepository chatRoomRepository; - @Mock private ChatMemberRepository chatMemberRepository; + @Mock private MeetingMemberRepository meetingMemberRepository; @Mock private ApplicationEventPublisher eventPublisher; @InjectMocks @@ -40,15 +48,34 @@ 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(request.getMeetingMatch())) + 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 @@ -82,4 +109,12 @@ private MeetingCancellationRequest cancellationRequest(Instant requestedAt) { requestedAt.plus(Duration.ofHours(24)) ); } + + private User user(Long id) { + return User.builder() + .userId(id) + .kakaoId("mock_" + id) + .userName("user" + id) + .build(); + } } From 102cfa4b4983310ceebf134e5530d50886f9aefd Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 16:15:14 +0900 Subject: [PATCH 27/30] =?UTF-8?q?fix:=20=EB=A7=8C=EB=A3=8C=EB=90=9C=20?= =?UTF-8?q?=EB=AF=B8=ED=8C=85=20=EC=B7=A8=EC=86=8C=20=ED=88=AC=ED=91=9C=20?= =?UTF-8?q?=EB=A1=A4=EB=B0=B1=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MeetingCancellationService.java | 38 ++++++++---- .../MeetingCancellationExpiredException.java | 8 +++ .../MeetingCancellationServiceTest.java | 60 +++++++++++++++++++ 3 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 manabom/src/main/java/mannabom_server/manabom/global/error/MeetingCancellationExpiredException.java 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 index 8d22eeb..212be32 100644 --- 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 @@ -24,6 +24,7 @@ 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; @@ -122,7 +123,9 @@ public MeetingCancellationResponse create(Long matchId, Long userId) { return MeetingCancellationResponse.of(request, votes); } - @Transactional + @Transactional( + noRollbackFor = MeetingCancellationExpiredException.class + ) public MeetingCancellationResponse vote( Long requestId, Long userId, @@ -135,7 +138,11 @@ public MeetingCancellationResponse vote( MeetingCancellationRequest request = requestRepository.findByIdForUpdate(requestId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅 취소 요청입니다.")); Instant now = Instant.now(); - expireIfNecessary(request, now); + if (expireIfNecessary(request, now)) { + throw new MeetingCancellationExpiredException( + "이미 만료된 미팅 취소 요청입니다." + ); + } if (request.getStatus() != MeetingCancellationStatus.PENDING) { throw new IllegalStateException("이미 종료된 미팅 취소 요청입니다."); @@ -156,13 +163,15 @@ public MeetingCancellationResponse vote( recipientUserIds ); } else if (allMembersAgreed(requestId)) { - approveCancellation(request, now); + publishCancellationEvent( request, SystemMessageType.MEETING_CANCELLATION_APPROVED, userId, recipientUserIds ); + + approveCancellation(request, now); } return response(request); @@ -222,20 +231,23 @@ private void approveCancellation( meeting2.cancelByAgreement(); } - private void expireIfNecessary( + private boolean expireIfNecessary( MeetingCancellationRequest request, Instant now ) { - if (request.isExpiredAt(now)) { - List recipients = memberUserIds(activeMembers(request.getMeetingMatch())); - request.expire(now); - publishCancellationEvent( - request, - SystemMessageType.MEETING_CANCELLATION_EXPIRED, - null, - recipients - ); + if (!request.isExpiredAt(now)) { + return false; } + + List recipients = memberUserIds(activeMembers(request.getMeetingMatch())); + request.expire(now); + publishCancellationEvent( + request, + SystemMessageType.MEETING_CANCELLATION_EXPIRED, + null, + recipients + ); + return true; } private boolean allMembersAgreed(Long requestId) { 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/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/meeting/service/MeetingCancellationServiceTest.java index d0bd1e0..fd32584 100644 --- 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 @@ -1,5 +1,7 @@ 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.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; import mannabom_server.manabom.domain.chat.entity.ChatRoom; @@ -18,12 +20,16 @@ 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.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 org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThat; @@ -31,9 +37,12 @@ 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; @@ -167,6 +176,57 @@ void unanimousApprovalCancelsBothMatchedTeams() { verify(meeting2).cancelByAgreement(); } + @Test + void expiredRequestIsCommittedAndPublishesExpirationEventWhenVoting() 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(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())); + + assertThatThrownBy(() -> meetingCancellationService.vote( + 30L, + 2L, + CancellationVoteDecision.AGREE + )) + .isInstanceOf(MeetingCancellationExpiredException.class) + .hasMessage("이미 만료된 미팅 취소 요청입니다."); + + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.EXPIRED); + verifyNoInteractions(voteRepository); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(ChatSystemMessageEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().type()) + .isEqualTo(SystemMessageType.MEETING_CANCELLATION_EXPIRED); + + 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( From 39ac68eba9025b9f0d1257f2f84ea50430a30a8d Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 16:22:42 +0900 Subject: [PATCH 28/30] =?UTF-8?q?refactor:=20=EB=AF=B8=ED=8C=85=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EB=A7=8C=EB=A3=8C=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MeetingCancellationExpirationService.java | 7 +++++- .../service/MeetingCancellationService.java | 23 ++----------------- .../MeetingCancellationServiceTest.java | 20 ++++------------ 3 files changed, 12 insertions(+), 38 deletions(-) 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 index 010e03c..323a74f 100644 --- 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 @@ -36,7 +36,12 @@ public boolean expire(Long requestId, Instant now) { MeetingCancellationRequest request = requestRepository.findByIdForUpdate(requestId) .orElse(null); - if (request == null || !request.isExpiredAt(now)) { + return request != null && expire(request, now); + } + + @Transactional + public boolean expire(MeetingCancellationRequest request, Instant now) { + if (!request.isExpiredAt(now)) { return false; } 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 index 212be32..068d70b 100644 --- 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 @@ -138,7 +138,7 @@ public MeetingCancellationResponse vote( MeetingCancellationRequest request = requestRepository.findByIdForUpdate(requestId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅 취소 요청입니다.")); Instant now = Instant.now(); - if (expireIfNecessary(request, now)) { + if (expirationService.expire(request, now)) { throw new MeetingCancellationExpiredException( "이미 만료된 미팅 취소 요청입니다." ); @@ -185,7 +185,7 @@ public MeetingCancellationResponse getCurrent(Long matchId, Long userId) { MeetingCancellationRequest request = requestRepository .findByMeetingMatch_IdAndStatus(matchId, MeetingCancellationStatus.PENDING) .orElseThrow(() -> new IllegalArgumentException("진행 중인 미팅 취소 요청이 없습니다.")); - expireIfNecessary(request, Instant.now()); + expirationService.expire(request, Instant.now()); return response(request); } @@ -231,25 +231,6 @@ private void approveCancellation( meeting2.cancelByAgreement(); } - private boolean expireIfNecessary( - MeetingCancellationRequest request, - Instant now - ) { - if (!request.isExpiredAt(now)) { - return false; - } - - List recipients = memberUserIds(activeMembers(request.getMeetingMatch())); - request.expire(now); - publishCancellationEvent( - request, - SystemMessageType.MEETING_CANCELLATION_EXPIRED, - null, - recipients - ); - return true; - } - private boolean allMembersAgreed(Long requestId) { return voteRepository.countByRequest_IdAndDecision( requestId, 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 index fd32584..46657fe 100644 --- 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 @@ -1,7 +1,5 @@ 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.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; import mannabom_server.manabom.domain.chat.entity.ChatRoom; @@ -23,7 +21,6 @@ import mannabom_server.manabom.global.error.MeetingCancellationExpiredException; 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; @@ -177,7 +174,7 @@ void unanimousApprovalCancelsBothMatchedTeams() { } @Test - void expiredRequestIsCommittedAndPublishesExpirationEventWhenVoting() throws NoSuchMethodException { + 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(); @@ -193,12 +190,8 @@ void expiredRequestIsCommittedAndPublishesExpirationEventWhenVoting() throws NoS ); ReflectionTestUtils.setField(request, "id", 30L); when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); - 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())); + when(expirationService.expire(eq(request), any(Instant.class))) + .thenReturn(true); assertThatThrownBy(() -> meetingCancellationService.vote( 30L, @@ -208,13 +201,8 @@ void expiredRequestIsCommittedAndPublishesExpirationEventWhenVoting() throws NoS .isInstanceOf(MeetingCancellationExpiredException.class) .hasMessage("이미 만료된 미팅 취소 요청입니다."); - assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.EXPIRED); + verify(expirationService).expire(eq(request), any(Instant.class)); verifyNoInteractions(voteRepository); - ArgumentCaptor eventCaptor = - ArgumentCaptor.forClass(ChatSystemMessageEvent.class); - verify(eventPublisher).publishEvent(eventCaptor.capture()); - assertThat(eventCaptor.getValue().type()) - .isEqualTo(SystemMessageType.MEETING_CANCELLATION_EXPIRED); Method voteMethod = MeetingCancellationService.class.getMethod( "vote", From 448f24106be4f9cccd46047bdc581ef75f483ec9 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 16:33:46 +0900 Subject: [PATCH 29/30] =?UTF-8?q?fix:=20=EB=A7=8C=EB=82=A8=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=99=84=EB=A3=8C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manabom/application/chat/message/SystemMessageType.java | 2 +- .../meeting/service/MeetingVerificationService.java | 5 ++--- .../application/chat/message/SystemMessageTypeTest.java | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) 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 index 77351e7..95d04d2 100644 --- 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 @@ -119,7 +119,7 @@ public RenderedSystemMessage render(String actorNickname, Map da ); case MEETING_VERIFICATION_SUCCEEDED -> message( defaultTitle, - nickname + "님이 시작한 만남인증이 완료되었어요.\n" + nickname + "님의 참여로 만남인증이 완료되었어요.\n" + "참여인원 : " + verifiedParticipantCount + "명" ); case MEETING_VERIFICATION_FAILED -> message( 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 dfbdacb..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 @@ -254,12 +254,11 @@ private String processGeneral(Long chatRoomId, Long userId, double latitude, dou eventPublisher.publishEvent(ChatSystemMessageEvent.of( chatRoomId, SystemMessageType.MEETING_VERIFICATION_SUCCEEDED, - verification.getStartedBy() == null - ? userId - : verification.getStartedBy().getUserId(), + userId, null, Map.of( "verifiedAt", verification.getVerifiedAt().toString(), + "expiresAt", verification.getExpiresAt().toString(), "participantCount", bestCluster.count(), "submittedCount", verification.getParticipantCount(), "verifiedParticipantCount", bestCluster.count() 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 index 20a754b..43ccff7 100644 --- 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 @@ -18,7 +18,7 @@ void rendersLeaderNicknameInMatchingMessage() { } @Test - void rendersVerificationInitiatorAndParticipantCount() { + void rendersVerificationCompleterAndParticipantCount() { SystemMessageType.RenderedSystemMessage message = SystemMessageType.MEETING_VERIFICATION_SUCCEEDED.render( "민수", @@ -30,7 +30,7 @@ void rendersVerificationInitiatorAndParticipantCount() { assertThat(message.title()).isEqualTo("만남인증 성공"); assertThat(message.body()) - .isEqualTo("민수님이 시작한 만남인증이 완료되었어요.\n참여인원 : 3명"); + .isEqualTo("민수님의 참여로 만남인증이 완료되었어요.\n참여인원 : 3명"); } @Test From 8c9a16c065cb22e994aa481a8048da5edda5e0fd Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 16:34:07 +0900 Subject: [PATCH 30/30] =?UTF-8?q?fix:=20Push=20=EB=B0=9C=EC=86=A1=EC=9D=84?= =?UTF-8?q?=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationService.java | 28 ++++++++++- .../service/NotificationServiceTest.java | 48 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) 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 7e7bfe5..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 @@ -11,6 +11,8 @@ 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.util.HashMap; import java.util.Map; @@ -43,12 +45,36 @@ public void sendNotification( .build(); notificationRepository.save(notification); - pushService.sendToUser( + sendPushAfterCommit( userId, new PushMessage(title, message, convertObjectToMap(type, data)) ); } + private void sendPushAfterCommit(Long userId, PushMessage pushMessage) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + sendPushSafely(userId, pushMessage); + } + } + ); + return; + } + + sendPushSafely(userId, pushMessage); + } + + private void sendPushSafely(Long userId, PushMessage pushMessage) { + try { + pushService.sendToUser(userId, pushMessage); + } catch (RuntimeException e) { + log.error("Push 알림 전송 실패: userId={}", userId, e); + } + } + private String toJson(Object data) { if (data == null) { return null; 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 index 814f912..ce48016 100644 --- 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 @@ -13,11 +13,18 @@ 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 { @@ -47,4 +54,45 @@ void storesNotificationAndAlwaysRequestsPush() { .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)); + } }