From bc04774fdb755872bf6cacbf47aafdb161113312 Mon Sep 17 00:00:00 2001 From: onuyyy Date: Thu, 19 Feb 2026 11:32:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix=20:=20=EC=95=BD=EC=86=8D=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20url=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD,=20=ED=99=95=EC=A0=95=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/dto/MeetingDetailResponse.java | 33 +++++++++++--- .../dokdok/meeting/dto/MeetingResponse.java | 27 +++++++++--- .../meeting/repository/MeetingRepository.java | 17 +++++++ .../meeting/service/MeetingService.java | 44 ++++++++++++++++--- 4 files changed, 105 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java b/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java index 5dd51db..bcdd7a2 100644 --- a/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java +++ b/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java @@ -60,14 +60,19 @@ public static MeetingDetailResponse from( List meetingMembers, Long requestUserId, Boolean confirmedTopic, - LocalDateTime confirmedTopicDate + LocalDateTime confirmedTopicDate, + Map profileImageUrlMap ) { List safeMembers = meetingMembers == null ? Collections.emptyList() : meetingMembers; List activeMembers = safeMembers.stream() .filter(member -> member.getCanceledAt() == null) .toList(); - ParticipantsInfo participantsInfo = ParticipantsInfo.from(activeMembers, meeting.getMaxParticipants()); + ParticipantsInfo participantsInfo = ParticipantsInfo.from( + activeMembers, + meeting.getMaxParticipants(), + profileImageUrlMap + ); ActionState actionState = calculateActionState( meeting, @@ -270,9 +275,13 @@ public record ParticipantsInfo( @Schema(description = "참가자 목록") List members ) { - public static ParticipantsInfo from(List activeMembers, Integer maxCount) { + public static ParticipantsInfo from( + List activeMembers, + Integer maxCount, + Map profileImageUrlMap + ) { List members = activeMembers.stream() - .map(MemberInfo::from) + .map(member -> MemberInfo.from(member, profileImageUrlMap)) .toList(); return new ParticipantsInfo(members.size(), maxCount, members); @@ -293,17 +302,29 @@ public record MemberInfo( @Schema(description = "참가자 역할", example = "LEADER") MeetingMemberRole role ) { - public static MemberInfo from(MeetingMember meetingMember) { + public static MemberInfo from(MeetingMember meetingMember, Map profileImageUrlMap) { User user = meetingMember.getUser(); + String profileImageUrl = resolveProfileImageUrl(user, profileImageUrlMap); return new MemberInfo( user.getId(), user.getNickname(), - user.getProfileImageUrl(), + profileImageUrl, meetingMember.getMeetingRole() ); } } + private static String resolveProfileImageUrl(User user, Map profileImageUrlMap) { + if (user == null) { + return null; + } + if (profileImageUrlMap == null) { + return user.getProfileImageUrl(); + } + String presignedUrl = profileImageUrlMap.get(user.getId()); + return presignedUrl != null ? presignedUrl : user.getProfileImageUrl(); + } + @Schema(description = "화면 버튼 상태") public record ActionState( @Schema(description = """ diff --git a/src/main/java/com/dokdok/meeting/dto/MeetingResponse.java b/src/main/java/com/dokdok/meeting/dto/MeetingResponse.java index c8cb466..4416f1e 100644 --- a/src/main/java/com/dokdok/meeting/dto/MeetingResponse.java +++ b/src/main/java/com/dokdok/meeting/dto/MeetingResponse.java @@ -13,6 +13,7 @@ import java.time.LocalTime; import java.util.Collections; import java.util.List; +import java.util.Map; @Schema(description = "약속 응답") public record MeetingResponse( @@ -52,7 +53,7 @@ public static MeetingResponse from(Meeting meeting, List meetingM BookInfo.from(meeting.getBook()), ScheduleInfo.from(meeting.getMeetingStartDate(), meeting.getMeetingEndDate()), MeetingLocationDto.from(meeting.getLocation()), - ParticipantsInfo.from(safeMembers, meeting.getMaxParticipants()) + ParticipantsInfo.from(safeMembers, meeting.getMaxParticipants(), null) ); } @@ -126,10 +127,14 @@ public record ParticipantsInfo( @Schema(description = "참가자 목록") List members ) { - public static ParticipantsInfo from(List meetingMembers, Integer maxCount) { + public static ParticipantsInfo from( + List meetingMembers, + Integer maxCount, + Map profileImageUrlMap + ) { List members = meetingMembers.stream() .filter(member -> member.getCanceledAt() == null) - .map(MemberInfo::from) + .map(member -> MemberInfo.from(member, profileImageUrlMap)) .toList(); return new ParticipantsInfo(members.size(), maxCount, members); @@ -147,9 +152,21 @@ public record MemberInfo( @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") String profileImageUrl ) { - public static MemberInfo from(MeetingMember meetingMember) { + public static MemberInfo from(MeetingMember meetingMember, Map profileImageUrlMap) { User user = meetingMember.getUser(); - return new MemberInfo(user.getId(), user.getNickname(), user.getProfileImageUrl()); + String profileImageUrl = resolveProfileImageUrl(user, profileImageUrlMap); + return new MemberInfo(user.getId(), user.getNickname(), profileImageUrl); } } + + private static String resolveProfileImageUrl(User user, Map profileImageUrlMap) { + if (user == null) { + return null; + } + if (profileImageUrlMap == null) { + return user.getProfileImageUrl(); + } + String presignedUrl = profileImageUrlMap.get(user.getId()); + return presignedUrl != null ? presignedUrl : user.getProfileImageUrl(); + } } diff --git a/src/main/java/com/dokdok/meeting/repository/MeetingRepository.java b/src/main/java/com/dokdok/meeting/repository/MeetingRepository.java index 84a8a16..6989091 100644 --- a/src/main/java/com/dokdok/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/dokdok/meeting/repository/MeetingRepository.java @@ -21,6 +21,23 @@ public interface MeetingRepository extends JpaRepository { boolean existsByGatheringIdAndMeetingStatus(Long gatheringId, MeetingStatus meetingStatus); + @Query(""" + SELECT CASE WHEN COUNT(m) > 0 THEN true ELSE false END + FROM Meeting m + WHERE m.gathering.id = :gatheringId + AND m.meetingStatus = :meetingStatus + AND m.id <> :meetingId + AND m.meetingStartDate < :endDate + AND m.meetingEndDate > :startDate + """) + boolean existsOverlappingMeeting( + @Param("gatheringId") Long gatheringId, + @Param("meetingStatus") MeetingStatus meetingStatus, + @Param("meetingId") Long meetingId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate + ); + int countByGatheringIdAndMeetingStatus(Long gatheringId, MeetingStatus meetingStatus); @EntityGraph(attributePaths = {"book"}) diff --git a/src/main/java/com/dokdok/meeting/service/MeetingService.java b/src/main/java/com/dokdok/meeting/service/MeetingService.java index 3b9e89f..8b0d69f 100644 --- a/src/main/java/com/dokdok/meeting/service/MeetingService.java +++ b/src/main/java/com/dokdok/meeting/service/MeetingService.java @@ -31,6 +31,7 @@ import com.dokdok.topic.repository.TopicAnswerRepository; import com.dokdok.topic.repository.TopicRepository; import com.dokdok.topic.service.TopicService; +import com.dokdok.storage.service.StorageService; import com.dokdok.user.entity.User; import com.dokdok.user.service.UserValidator; import lombok.RequiredArgsConstructor; @@ -52,6 +53,7 @@ public class MeetingService { private final TopicRepository topicRepository; private final TopicAnswerRepository topicAnswerRepository; private final TopicService topicService; + private final StorageService storageService; private final GatheringRepository gatheringRepository; private final GatheringMemberRepository gatheringMemberRepository; private final GatheringValidator gatheringValidator; @@ -76,6 +78,7 @@ public MeetingDetailResponse findMeeting(Long meetingId) { gatheringValidator.validateMembership(meeting.getGathering().getId(), userId); List meetingMembers = meetingMemberRepository.findAllByMeetingId(meetingId); + Map profileImageUrlMap = buildProfileImageUrlMap(meetingMembers); LocalDateTime confirmedTopicDate = topicRepository.findConfirmedTopicDateByMeetingId( meetingId, @@ -88,10 +91,30 @@ public MeetingDetailResponse findMeeting(Long meetingId) { meetingMembers, userId, confirmedTopic, - confirmedTopicDate + confirmedTopicDate, + profileImageUrlMap ); } + /** + * 약속 멤버들의 프로필 이미지 presigned URL Map 생성 + */ + private Map buildProfileImageUrlMap(List members) { + Map profileImageUrlMap = new HashMap<>(); + + members.forEach(member -> { + User user = member.getUser(); + if (user == null || user.getId() == null) { + return; + } + String profileImageUrl = user.getProfileImageUrl(); + String presignedUrl = storageService.getPresignedProfileImage(profileImageUrl); + profileImageUrlMap.put(user.getId(), presignedUrl); + }); + + return profileImageUrlMap; + } + /** * 모임원이 약속 생성 신청을 할 수 있다. 모임에 속한 사용자만 생성 가능 * @param request 약속 생성 신청 폼 @@ -206,11 +229,22 @@ private void validateMaxParticipants(Integer maxParticipants, Long gatheringId) */ private void validateConfirmable(Meeting meeting) { Long gatheringId = meeting.getGathering().getId(); - boolean hasConfirmedMeeting = meetingRepository - .existsByGatheringIdAndMeetingStatus(gatheringId, MeetingStatus.CONFIRMED); - if (hasConfirmedMeeting) { + LocalDateTime startDate = meeting.getMeetingStartDate(); + LocalDateTime endDate = meeting.getMeetingEndDate(); + if (startDate == null || endDate == null) { + throw new MeetingException(MeetingErrorCode.MEETING_DATE_REQUIRED, + "약속 시작/종료 일시는 필수입니다."); + } + boolean hasOverlappingMeeting = meetingRepository.existsOverlappingMeeting( + gatheringId, + MeetingStatus.CONFIRMED, + meeting.getId(), + startDate, + endDate + ); + if (hasOverlappingMeeting) { throw new MeetingException(MeetingErrorCode.INVALID_MEETING_STATUS_CHANGE, - "이미 확정된 약속이 존재합니다."); + "동일 시간에 확정된 약속이 존재합니다."); } } From c6addf2b337e6970c4b1348b050209113332c793 Mon Sep 17 00:00:00 2001 From: onuyyy Date: Thu, 19 Feb 2026 11:33:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix=20:=20=EC=95=BD=EC=86=8D=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=ED=99=95=EC=A0=95?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/service/MeetingServiceTest.java | 69 +++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java b/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java index 1afefd2..d04f792 100644 --- a/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java +++ b/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java @@ -27,6 +27,7 @@ import com.dokdok.topic.repository.TopicAnswerRepository; import com.dokdok.topic.repository.TopicRepository; import com.dokdok.topic.service.TopicService; +import com.dokdok.storage.service.StorageService; import com.dokdok.user.entity.User; import com.dokdok.user.service.UserValidator; import org.junit.jupiter.api.BeforeEach; @@ -72,6 +73,9 @@ class MeetingServiceTest { @Mock private TopicService topicService; + @Mock + private StorageService storageService; + @Mock private GatheringRepository gatheringRepository; @@ -219,6 +223,43 @@ void givenMeetingDates_whenFindMeeting_thenProgressStatus() { } } + @DisplayName("약속 상세 조회 시 멤버 프로필 이미지는 presigned URL로 내려간다.") + @Test + void givenMeetingMembers_whenFindMeeting_thenUsePresignedProfileImage() { + // given + Long userId = 1L; + User memberUser = User.builder() + .id(2L) + .nickname("member") + .profileImageUrl("profiles/2/profile.jpg") + .build(); + MeetingMember member = MeetingMember.builder() + .meeting(meeting) + .user(memberUser) + .build(); + + given(meetingValidator.findMeetingOrThrow(meetingId)) + .willReturn(meeting); + given(meetingMemberRepository.findAllByMeetingId(meetingId)) + .willReturn(List.of(member)); + given(topicRepository.findConfirmedTopicDateByMeetingId(meetingId, TopicStatus.CONFIRMED)) + .willReturn(null); + given(storageService.getPresignedProfileImage(memberUser.getProfileImageUrl())) + .willReturn("https://presigned.example.com/profile.jpg"); + + try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { + securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); + + // when + MeetingDetailResponse response = meetingService.findMeeting(meetingId); + + // then + assertThat(response.participants().members()).hasSize(1); + assertThat(response.participants().members().get(0).profileImageUrl()) + .isEqualTo("https://presigned.example.com/profile.jpg"); + } + } + @DisplayName("약속 상세 응답에 주제 확정 여부와 날짜가 내려간다.") @Test void givenConfirmedTopicDate_whenFindMeeting_thenConfirmedTopicFields() { @@ -546,7 +587,13 @@ void givenMeetingStatus_whenConfirm_thenMeetingStatusChange() { Long gatheringLeaderId = 10L; given(meetingValidator.findMeetingOrThrow(meetingId)).willReturn(meeting); - given(meetingRepository.existsByGatheringIdAndMeetingStatus(gathering.getId(), MeetingStatus.CONFIRMED)) + given(meetingRepository.existsOverlappingMeeting( + gathering.getId(), + MeetingStatus.CONFIRMED, + meetingId, + meeting.getMeetingStartDate(), + meeting.getMeetingEndDate() + )) .willReturn(false); given(meetingMemberRepository.findByMeetingIdAndUserId(meetingId, leader.getId())) .willReturn(Optional.empty()); @@ -569,14 +616,20 @@ void givenMeetingStatus_whenConfirm_thenMeetingStatusChange() { } } - @DisplayName("이미 확정된 약속이 있으면 다른 약속을 확정할 수 없다.") + @DisplayName("시간이 겹치는 확정 약속이 있으면 다른 약속을 확정할 수 없다.") @Test void givenConfirmedMeetingExists_whenConfirm_thenThrowMeetingException() { // given Long meetingId = 1L; Long gatheringLeaderId = 10L; given(meetingValidator.findMeetingOrThrow(meetingId)).willReturn(meeting); - given(meetingRepository.existsByGatheringIdAndMeetingStatus(gathering.getId(), MeetingStatus.CONFIRMED)) + given(meetingRepository.existsOverlappingMeeting( + gathering.getId(), + MeetingStatus.CONFIRMED, + meetingId, + meeting.getMeetingStartDate(), + meeting.getMeetingEndDate() + )) .willReturn(true); try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { @@ -621,10 +674,18 @@ void givenMissingLeader_whenConfirm_thenThrowMeetingException() { .id(meetingId) .meetingName("Meeting 1") .meetingStatus(MeetingStatus.PENDING) + .meetingStartDate(LocalDateTime.now().plusDays(2)) + .meetingEndDate(LocalDateTime.now().plusDays(2).plusHours(1)) .gathering(gathering) .build(); given(meetingValidator.findMeetingOrThrow(meetingId)).willReturn(missingLeaderMeeting); - given(meetingRepository.existsByGatheringIdAndMeetingStatus(gathering.getId(), MeetingStatus.CONFIRMED)) + given(meetingRepository.existsOverlappingMeeting( + gathering.getId(), + MeetingStatus.CONFIRMED, + meetingId, + missingLeaderMeeting.getMeetingStartDate(), + missingLeaderMeeting.getMeetingEndDate() + )) .willReturn(false); try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) {