From 4662e6334001051caa1a5b20df1f9cf995b7470e Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 16 Jul 2026 23:05:03 +0900 Subject: [PATCH 01/17] =?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 602f4fe5e8b5821a937235e8e94d163ad0ff54dd Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:03:30 +0900 Subject: [PATCH 02/17] =?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 4c002303e7de856c6baa3cd50d2a69869a6117c6 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:04:24 +0900 Subject: [PATCH 03/17] =?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 9ca052e072a7f7b8f1459e668766dc42224f2235 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:05:51 +0900 Subject: [PATCH 04/17] =?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 481064ee3a1ce419d0b74b8852d8546c72d9e6bc Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:11:09 +0900 Subject: [PATCH 05/17] =?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 0b4b1b82f8140f5d10861bd940facdef169ab138 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:16:40 +0900 Subject: [PATCH 06/17] =?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 db01a8cd61879ce67830272c205a25e07eb97295 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 11:27:49 +0900 Subject: [PATCH 07/17] =?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 78ce659b7003f428d9e246f44adfd8fba268749a Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 14:16:00 +0900 Subject: [PATCH 08/17] =?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 f7a7057518dc3a86c053dc9446e131230abe9b34 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 14:45:52 +0900 Subject: [PATCH 09/17] =?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 729516622f768f62c5889d24b229c05fc276356e Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 15:19:43 +0900 Subject: [PATCH 10/17] =?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 3ac29d94c98316731e151a66332ac34b89b11742 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 15:39:36 +0900 Subject: [PATCH 11/17] =?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 9e975f16c2e31c659abdd07c00b27fdf72f9763f Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 20:07:20 +0900 Subject: [PATCH 12/17] =?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 8413de5255b3ec44bb9b82534b9757664689e467 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 20:07:26 +0900 Subject: [PATCH 13/17] =?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 d2762169752512cb33da638ae0bc49f409a807d5 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Tue, 21 Jul 2026 20:07:34 +0900 Subject: [PATCH 14/17] =?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 55f187b7606fe8a9f8deaca74606eb88901f4f09 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 14:00:11 +0900 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20=EB=A7=8C=EB=A3=8C=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MeetingCancellationService.java | 37 +++++++++++++++---- .../MeetingCancellationExpiredException.java | 7 ++++ .../MeetingCancellationServiceTest.java | 32 ++++++++++++++-- 3 files changed, 65 insertions(+), 11 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 78dc7af..37901c5 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 @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import mannabom_server.manabom.application.meeting.dto.response.MeetingCancellationResponse; +import mannabom_server.manabom.domain.chat.entity.ChatMember; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; @@ -22,6 +23,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.transaction.annotation.Transactional; @@ -110,7 +112,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, @@ -120,18 +124,37 @@ public MeetingCancellationResponse vote( throw new IllegalArgumentException("투표 결과는 AGREE 또는 REJECT여야 합니다."); } - MeetingCancellationRequest request = requestRepository.findByIdForUpdate(requestId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 미팅 취소 요청입니다.")); + MeetingCancellationRequest request = + requestRepository.findByIdForUpdate(requestId) + .orElseThrow(() -> + new IllegalArgumentException( + "존재하지 않는 미팅 취소 요청입니다." + ) + ); + Instant now = Instant.now(); - expireIfNecessary(request, now); + + if (request.isExpiredAt(now)) { + request.expire(now); + throw new MeetingCancellationExpiredException( + "이미 만료된 미팅 취소 요청입니다." + ); + } if (request.getStatus() != MeetingCancellationStatus.PENDING) { - throw new IllegalStateException("이미 종료된 미팅 취소 요청입니다."); + throw new IllegalStateException( + "이미 종료된 미팅 취소 요청입니다." + ); } MeetingCancellationVote vote = voteRepository .findByRequest_IdAndUser_UserId(requestId, userId) - .orElseThrow(() -> new IllegalArgumentException("해당 미팅의 투표 대상자가 아닙니다.")); + .orElseThrow(() -> + new IllegalArgumentException( + "해당 미팅의 투표 대상자가 아닙니다." + ) + ); + vote.decide(decision, now); if (decision == CancellationVoteDecision.REJECT) { @@ -258,6 +281,6 @@ private void deactivateChatRoomAndMembers(ChatRoom room) { chatMemberRepository.findAllByRoomIdAndStatus( room.getId(), ChatMemberStatus.ACTIVATE - ).forEach(chatMember -> chatMember.deactivate()); + ).forEach(ChatMember::deactivate); } } diff --git a/manabom/src/main/java/mannabom_server/manabom/global/error/MeetingCancellationExpiredException.java b/manabom/src/main/java/mannabom_server/manabom/global/error/MeetingCancellationExpiredException.java new file mode 100644 index 0000000..45e948c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/global/error/MeetingCancellationExpiredException.java @@ -0,0 +1,7 @@ +package mannabom_server.manabom.global.error; + +public class MeetingCancellationExpiredException extends IllegalStateException{ + public MeetingCancellationExpiredException(String message) { + super(message); + } +} diff --git a/manabom/src/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..e0e21d6 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,6 @@ package mannabom_server.manabom.application.meeting.service; +import mannabom_server.manabom.application.meeting.dto.response.MeetingCancellationResponse; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; import mannabom_server.manabom.domain.meeting.entity.MeetingMatch; @@ -17,6 +18,7 @@ import mannabom_server.manabom.domain.meeting.repository.MeetingMatchRepository; import mannabom_server.manabom.domain.user.repository.UserRepository; import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.global.error.MeetingCancellationExpiredException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -29,9 +31,11 @@ import static org.mockito.Mockito.when; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Optional; @@ -50,10 +54,6 @@ class MeetingCancellationServiceTest { @Mock private UserRepository userRepository; @Mock - private ChatRoomRepository chatRoomRepository; - @Mock - private ChatMemberRepository chatMemberRepository; - @Mock private MeetingCancellationExpirationService expirationService; @InjectMocks @@ -159,6 +159,30 @@ void unanimousApprovalCancelsBothMatchedTeams() { verify(meeting2).cancelByAgreement(); } + @Test + void expiredRequestIsMarkedExpiredWhenVoting() { + Instant requestedAt = Instant.now().minus(Duration.ofHours(25)); + MeetingCancellationRequest request = MeetingCancellationRequest.create( + mock(MeetingMatch.class), + user(1L), + requestedAt, + requestedAt.plus(Duration.ofHours(24)) + ); + when(requestRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); + + assertThatThrownBy(() -> meetingCancellationService.vote( + 30L, + 2L, + CancellationVoteDecision.AGREE + )) + .isInstanceOf(MeetingCancellationExpiredException.class) + .hasMessage("이미 만료된 미팅 취소 요청입니다."); + + assertThat(request.getStatus()).isEqualTo(MeetingCancellationStatus.EXPIRED); + assertThat(request.getCompletedAt()).isNotNull(); + verifyNoInteractions(voteRepository); + } + @Test void continuesExpiringOtherRequestsWhenOneRequestFails() { when(requestRepository.findExpiredRequestIds( From 6c635d32c2589ab55275d740f03ec303b8edf3e8 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 14:27:05 +0900 Subject: [PATCH 16/17] =?UTF-8?q?test:=20=EB=AF=B8=ED=8C=85=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=8A=B9=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?mock=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MeetingCancellationServiceTest.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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 e0e21d6..4daeb34 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,6 +24,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThat; @@ -54,6 +55,10 @@ class MeetingCancellationServiceTest { @Mock private UserRepository userRepository; @Mock + private ChatRoomRepository chatRoomRepository; + @Mock + private ChatMemberRepository chatMemberRepository; + @Mock private MeetingCancellationExpirationService expirationService; @InjectMocks @@ -138,6 +143,7 @@ void unanimousApprovalCancelsBothMatchedTeams() { now, now.plusSeconds(3600) ); + ReflectionTestUtils.setField(request, "id", 30L); MeetingCancellationVote vote = MeetingCancellationVote.pending(request, voter); when(match.getId()).thenReturn(20L); @@ -147,7 +153,18 @@ void unanimousApprovalCancelsBothMatchedTeams() { 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(voteRepository.countByRequest_IdAndDecision( + 30L, + CancellationVoteDecision.PENDING + )).thenReturn(0L); + when(voteRepository.countByRequest_IdAndDecision( + 30L, + CancellationVoteDecision.REJECT + )).thenReturn(0L); + when(voteRepository.findAllByRequest_IdOrderById(30L)).thenReturn(List.of(vote)); + when(chatRoomRepository.findByMatch(match)).thenReturn(Optional.empty()); + when(chatRoomRepository.findByMeeting(meeting1)).thenReturn(Optional.empty()); + when(chatRoomRepository.findByMeeting(meeting2)).thenReturn(Optional.empty()); when(meetingMemberRepository.findByMeetingIdAndStatus(10L, ChatUserStatus.ACTIVE)) .thenReturn(List.of()); when(meetingMemberRepository.findByMeetingIdAndStatus(11L, ChatUserStatus.ACTIVE)) From 21bd0fa131ab061402d531475ba52b785c0d0978 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 6 Aug 2026 14:32:49 +0900 Subject: [PATCH 17/17] =?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';